From 6be45e426d1e19da46c9ed0636845c656d309287 Mon Sep 17 00:00:00 2001 From: Arul Sharma <31745423+arul28@users.noreply.github.com> Date: Sun, 3 May 2026 14:00:35 -0400 Subject: [PATCH 1/8] Add lane-aware work sidebar --- .claude/scheduled_tasks.lock | 1 - apps/ade-cli/src/cli.ts | 3 + .../services/appControl/appControlService.ts | 10 +- .../services/ios/iosSimulatorService.test.ts | 1 + .../main/services/ios/iosSimulatorService.ts | 1 + .../src/main/services/ipc/registerIpc.ts | 2 + .../src/renderer/components/app/AppShell.tsx | 3 - .../components/app/FloatingFilesWorkspace.tsx | 1290 ----------------- .../components/app/RightEdgeFloatingPane.tsx | 834 ----------- .../components/chat/AgentChatPane.tsx | 202 ++- .../components/chat/ChatAppControlPanel.tsx | 19 +- .../chat/ChatIosSimulatorPanel.test.tsx | 1 + .../components/chat/ChatIosSimulatorPanel.tsx | 5 +- .../renderer/components/files/FilesPage.tsx | 23 +- .../components/lanes/useLaneWorkSessions.ts | 3 + .../components/terminals/TerminalsPage.tsx | 127 +- .../components/terminals/WorkSidebar.tsx | 336 +++++ .../components/terminals/WorkStartSurface.tsx | 1 + .../components/terminals/WorkViewArea.tsx | 39 + .../components/terminals/useWorkSessions.ts | 34 + apps/desktop/src/renderer/index.css | 281 ---- .../src/renderer/state/appStore.test.ts | 18 + apps/desktop/src/renderer/state/appStore.ts | 22 + apps/desktop/src/shared/types/appControl.ts | 2 + apps/desktop/src/shared/types/iosSimulator.ts | 2 + docs/ARCHITECTURE.md | 2 +- docs/features/chat/README.md | 2 +- docs/features/chat/composer-and-ui.md | 4 +- docs/features/computer-use/app-control.md | 2 +- docs/features/files-and-editor/README.md | 9 +- .../files-and-editor/editor-surfaces.md | 21 +- docs/features/ios-simulator/README.md | 2 +- .../features/terminals-and-sessions/README.md | 29 +- .../terminals-and-sessions/ui-surfaces.md | 58 +- goal.md | 32 + 35 files changed, 896 insertions(+), 2525 deletions(-) delete mode 100644 .claude/scheduled_tasks.lock delete mode 100644 apps/desktop/src/renderer/components/app/FloatingFilesWorkspace.tsx delete mode 100644 apps/desktop/src/renderer/components/app/RightEdgeFloatingPane.tsx create mode 100644 apps/desktop/src/renderer/components/terminals/WorkSidebar.tsx create mode 100644 goal.md diff --git a/.claude/scheduled_tasks.lock b/.claude/scheduled_tasks.lock deleted file mode 100644 index 8e97eb0ff..000000000 --- a/.claude/scheduled_tasks.lock +++ /dev/null @@ -1 +0,0 @@ -{"sessionId":"c0e0dbe1-c2c9-4b29-9ec8-5d82025adada","pid":25281,"procStart":"Thu Apr 30 23:52:58 2026","acquiredAt":1777593986514} \ No newline at end of file diff --git a/apps/ade-cli/src/cli.ts b/apps/ade-cli/src/cli.ts index 095f2bba6..cab90c5d6 100644 --- a/apps/ade-cli/src/cli.ts +++ b/apps/ade-cli/src/cli.ts @@ -392,6 +392,7 @@ const IOS_SIMULATOR_SUBCOMMAND_HELP: Record = { --project, --xcodeproj

Xcode project path. --scheme Xcode scheme. --project-root ADE project root. + --lane, --lane-id Lane to bind this simulator session to. --chat-session Owner chat session for the single-owner lock. --no-build Skip xcodebuild. --mode snapshot|live Inspector launch mode; default live. @@ -2284,6 +2285,7 @@ function buildIosSimulatorPlan(args: string[]): CliPlan { steps: [actionStep("result", "ios_simulator", "launch", collectGenericObjectArgs(args, { deviceUdid: readValue(args, ["--device", "--udid"]), projectRoot: readValue(args, ["--project-root", "--root"]), + laneId: readValue(args, ["--lane", "--lane-id"]), targetId: readValue(args, ["--target", "--target-id"]), bundleId: readValue(args, ["--bundle-id", "--bundle"]), appBundlePath: readValue(args, ["--app-bundle", "--app"]), @@ -2496,6 +2498,7 @@ function buildAppControlPlan(args: string[]): CliPlan { return { kind: "execute", label: "App Control connect", steps: [actionStep("result", "app_control", "connect", collectGenericObjectArgs(args, { appKind: readValue(args, ["--kind", "--app-kind"]) ?? "electron", projectRoot: readValue(args, ["--project-root", "--root"]), + laneId: readValue(args, ["--lane", "--lane-id"]), cdpPort: readNumberOption(args, ["--cdp-port", "--port"]) ?? Number(numericPositionals()[0]), label: readValue(args, ["--label", "--name"]), chatSessionId: readValue(args, ["--chat-session", "--session"]) ?? process.env.ADE_CHAT_SESSION_ID, diff --git a/apps/desktop/src/main/services/appControl/appControlService.ts b/apps/desktop/src/main/services/appControl/appControlService.ts index 693e0d46d..eb065cbb4 100644 --- a/apps/desktop/src/main/services/appControl/appControlService.ts +++ b/apps/desktop/src/main/services/appControl/appControlService.ts @@ -1311,11 +1311,18 @@ export function createAppControlService(args: CreateAppControlServiceArgs) { const target = pickCdpTarget(await listCdpTargets(cdpPort)); if (!target?.webSocketDebuggerUrl) throw new Error(`No debuggable renderer target was found on CDP port ${cdpPort}.`); cdpAttachmentEpoch += 1; + const laneId = await args.resolveLaneId?.({ + projectRoot, + cwd: projectRoot, + laneId: connectArgs.laneId ?? null, + chatSessionId: connectArgs.chatSessionId ?? null, + }) ?? connectArgs.laneId ?? null; activeSession = { id: randomUUID(), appKind: "electron", label: connectArgs.label?.trim() || target.title || `Electron app on ${cdpPort}`, projectRoot, + laneId, cwd: null, command: null, pid: null, @@ -1357,13 +1364,14 @@ export function createAppControlService(args: CreateAppControlServiceArgs) { chatSessionId: launchArgs.chatSessionId ?? null, }); if (!laneId) { - throw new Error("App Control could not resolve a lane for the chat terminal. Open a chat in a lane or pass laneId."); + throw new Error("App Control could not resolve a lane for the terminal. Select a lane or pass laneId."); } const session: AppControlSession = { id: randomUUID(), appKind: "electron", label: resolved.label, projectRoot, + laneId, cwd: resolved.cwd, command: resolved.commandForDisplay, pid: null, diff --git a/apps/desktop/src/main/services/ios/iosSimulatorService.test.ts b/apps/desktop/src/main/services/ios/iosSimulatorService.test.ts index 2f358634b..4933fa96f 100644 --- a/apps/desktop/src/main/services/ios/iosSimulatorService.test.ts +++ b/apps/desktop/src/main/services/ios/iosSimulatorService.test.ts @@ -199,6 +199,7 @@ describe("iosSimulatorService single-owner lock contract", () => { appBundlePath: null, targetId: null, projectRoot: "/tmp", + laneId: "lane-1", chatSessionId: "chat-A", mode: "snapshot" as const, bridgeUrl: null, diff --git a/apps/desktop/src/main/services/ios/iosSimulatorService.ts b/apps/desktop/src/main/services/ios/iosSimulatorService.ts index 72791efc7..db53bc051 100644 --- a/apps/desktop/src/main/services/ios/iosSimulatorService.ts +++ b/apps/desktop/src/main/services/ios/iosSimulatorService.ts @@ -3275,6 +3275,7 @@ export function createIosSimulatorService(args: CreateIosSimulatorServiceArgs) { appBundlePath: appBundle, targetId: target.target.id, projectRoot, + laneId: launchArgs.laneId ?? null, chatSessionId: launchArgs.chatSessionId ?? null, mode: normalizeLaunchMode(launchArgs.mode), keepSimulatorInBackground: launchArgs.keepSimulatorInBackground ?? true, diff --git a/apps/desktop/src/main/services/ipc/registerIpc.ts b/apps/desktop/src/main/services/ipc/registerIpc.ts index c3591567f..45cda2447 100644 --- a/apps/desktop/src/main/services/ipc/registerIpc.ts +++ b/apps/desktop/src/main/services/ipc/registerIpc.ts @@ -2251,6 +2251,8 @@ export function registerIpc({ if (appKind !== undefined) args.appKind = appKind; const projectRoot = optionalAppControlString(record, "projectRoot", channel, 4096); if (projectRoot !== undefined) args.projectRoot = projectRoot; + const laneId = optionalAppControlString(record, "laneId", channel, 512); + if (laneId !== undefined) args.laneId = laneId; const label = optionalAppControlString(record, "label", channel, 256); if (label !== undefined) args.label = label; const chatSessionId = optionalAppControlString(record, "chatSessionId", channel, 128); diff --git a/apps/desktop/src/renderer/components/app/AppShell.tsx b/apps/desktop/src/renderer/components/app/AppShell.tsx index 8ad2f1dcc..e48704b99 100644 --- a/apps/desktop/src/renderer/components/app/AppShell.tsx +++ b/apps/desktop/src/renderer/components/app/AppShell.tsx @@ -12,7 +12,6 @@ import { Link, useLocation, useNavigate } from "react-router-dom"; import { CommandPalette } from "./CommandPalette"; import { TabNav } from "./TabNav"; import { TopBar } from "./TopBar"; -import { RightEdgeFloatingPane } from "./RightEdgeFloatingPane"; import { getPrToastHeadline, getPrToastMeta, @@ -1103,8 +1102,6 @@ export function AppShell({ children }: { children: React.ReactNode }) { children )} - - {staleCliNotice || prToasts.length > 0 ? (

{staleCliNotice ? ( diff --git a/apps/desktop/src/renderer/components/app/FloatingFilesWorkspace.tsx b/apps/desktop/src/renderer/components/app/FloatingFilesWorkspace.tsx deleted file mode 100644 index 90b3c31b3..000000000 --- a/apps/desktop/src/renderer/components/app/FloatingFilesWorkspace.tsx +++ /dev/null @@ -1,1290 +0,0 @@ -import React from "react"; -import { - CaretDown, - CaretRight, - Copy, - FilePlus, - FileText, - Folder, - FolderOpen, - FolderPlus, - PencilSimple, - Scissors, - ClipboardText, - FloppyDisk, - Trash, - X -} from "@phosphor-icons/react"; -import type { FileContent, FilePreviewKind, FileTreeNode, FilesWorkspace } from "../../../shared/types"; -import { replaceDirtyBuffersForWorkspace } from "../../lib/dirtyWorkspaceBuffers"; -import { cn } from "../ui/cn"; - -type OpenTab = { - path: string; - content: string; - savedContent: string; - languageId: string; - isBinary: boolean; - previewKind?: FilePreviewKind; - mimeType?: string | null; - dataUrl?: string; - size?: number; -}; - -type NodeContextMenuState = { - x: number; - y: number; - nodePath: string; - nodeType: "file" | "directory"; -}; - -type ClipboardMode = "copy" | "cut"; - -type PathClipboard = { - workspaceId: string; - path: string; - type: "file" | "directory"; - mode: ClipboardMode; -}; - -const MIN_CONTEXT_MENU_WIDTH = 176; - -let monacoInit: Promise | null = null; - -async function loadMonaco(): Promise { - if (!monacoInit) { - monacoInit = (async () => { - const [{ default: EditorWorker }, { default: TsWorker }] = await Promise.all([ - import("monaco-editor/esm/vs/editor/editor.worker?worker"), - import("monaco-editor/esm/vs/language/typescript/ts.worker?worker"), - ]); - const globalAny = globalThis as typeof globalThis & { - MonacoEnvironment?: { - getWorker?: (workerId: string, label: string) => Worker; - }; - }; - const existing = globalAny.MonacoEnvironment; - globalAny.MonacoEnvironment = { - ...existing, - getWorker: existing?.getWorker ?? ((_workerId: string, label: string) => { - if (label === "typescript" || label === "javascript") { - return new TsWorker(); - } - return new EditorWorker(); - }) - }; - return await import("monaco-editor"); - })(); - } - - return await monacoInit; -} - -function normalizePath(pathValue: string): string { - return pathValue.replace(/\\/g, "/").replace(/^\/+/, "").replace(/\/+$/, ""); -} - -function joinPath(parentPath: string, name: string): string { - const parent = normalizePath(parentPath); - const child = normalizePath(name); - if (!parent) return child; - if (!child) return parent; - return `${parent}/${child}`; -} - -function parentPathOf(pathValue: string): string { - const normalized = normalizePath(pathValue); - const idx = normalized.lastIndexOf("/"); - if (idx <= 0) return ""; - return normalized.slice(0, idx); -} - -function basename(pathValue: string): string { - const normalized = normalizePath(pathValue); - const idx = normalized.lastIndexOf("/"); - if (idx < 0) return normalized; - return normalized.slice(idx + 1); -} - -type FilePreviewLike = { - isBinary: boolean; - previewKind?: FilePreviewKind; - dataUrl?: string; - mimeType?: string | null; - size?: number; -}; - -function getFilePreviewKind(file: FilePreviewLike | null | undefined): FilePreviewKind { - if (!file) return "text"; - if (file.previewKind) return file.previewKind; - if (file.dataUrl) return "image"; - return file.isBinary ? "binary" : "text"; -} - -function isTextTab(tab: OpenTab | null | undefined): boolean { - return Boolean(tab) && getFilePreviewKind(tab) === "text" && !tab?.isBinary; -} - -function formatFileSize(bytes: number | null | undefined): string | null { - if (typeof bytes !== "number" || !Number.isFinite(bytes) || bytes < 0) return null; - if (bytes < 1024) return `${bytes} B`; - if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(bytes < 10 * 1024 ? 1 : 0)} KB`; - return `${(bytes / (1024 * 1024)).toFixed(bytes < 10 * 1024 * 1024 ? 1 : 0)} MB`; -} - -function openTabFromFileContent(filePath: string, file: FileContent): OpenTab { - return { - path: filePath, - content: file.content, - savedContent: file.content, - languageId: file.languageId, - isBinary: file.isBinary, - previewKind: getFilePreviewKind(file), - mimeType: file.mimeType ?? null, - dataUrl: file.dataUrl, - size: file.size, - }; -} - -function FloatingFilePreview({ tab }: { tab: OpenTab }) { - const previewKind = getFilePreviewKind(tab); - const details = [tab.mimeType, formatFileSize(tab.size)].filter(Boolean).join(" · "); - const [imageFailed, setImageFailed] = React.useState(false); - - React.useEffect(() => { - setImageFailed(false); - }, [tab.dataUrl]); - - if (previewKind === "image" && tab.dataUrl && !imageFailed) { - return ( -
-
- {tab.path} setImageFailed(true)} - /> -
-
- {tab.path} - {details ? · {details} : null} -
-
- ); - } - - return ( -
-
- -
- {previewKind === "image" ? "Image preview unavailable" : "Preview unavailable"} -
-
- {previewKind === "image" ? "This image could not be decoded for inline preview." : "This file type cannot be displayed inline."} -
-
- {tab.path} -
- {details ?
{details}
: null} -
-
- ); -} - -function splitNameAndExtension(fileName: string): { base: string; ext: string } { - const idx = fileName.lastIndexOf("."); - if (idx <= 0) return { base: fileName, ext: "" }; - return { - base: fileName.slice(0, idx), - ext: fileName.slice(idx) - }; -} - -function createCopyName(name: string, sequence: number): string { - if (sequence <= 1) return `${name}-copy`; - return `${name}-copy-${sequence}`; -} - -function createCopyFileName(fileName: string, sequence: number): string { - const { base, ext } = splitNameAndExtension(fileName); - const suffix = sequence <= 1 ? "copy" : `copy-${sequence}`; - return `${base}-${suffix}${ext}`; -} - -function isPathEqualOrDescendant(pathValue: string, rootPath: string): boolean { - const normalizedPath = normalizePath(pathValue); - const normalizedRoot = normalizePath(rootPath); - if (!normalizedRoot) return normalizedPath.length === 0; - return normalizedPath === normalizedRoot || normalizedPath.startsWith(`${normalizedRoot}/`); -} - -function remapPathForRename(pathValue: string, oldPath: string, newPath: string): string { - const normalizedPath = normalizePath(pathValue); - const normalizedOld = normalizePath(oldPath); - const normalizedNew = normalizePath(newPath); - if (!normalizedOld || !normalizedNew) return normalizedPath; - if (normalizedPath === normalizedOld) return normalizedNew; - if (!normalizedPath.startsWith(`${normalizedOld}/`)) return normalizedPath; - return `${normalizedNew}${normalizedPath.slice(normalizedOld.length)}`; -} - -function flattenNodes(nodes: FileTreeNode[]): Map { - const out = new Map(); - const walk = (items: FileTreeNode[]) => { - for (const item of items) { - out.set(item.path, item); - if (item.children?.length) walk(item.children); - } - }; - walk(nodes); - return out; -} - -function filterTree(nodes: FileTreeNode[], query: string): FileTreeNode[] { - const needle = query.trim().toLowerCase(); - if (!needle) return nodes; - - const next: FileTreeNode[] = []; - for (const node of nodes) { - const selfMatch = node.name.toLowerCase().includes(needle) || node.path.toLowerCase().includes(needle); - const childMatches = node.children ? filterTree(node.children, query) : []; - if (selfMatch || childMatches.length > 0) { - next.push({ - ...node, - children: childMatches.length ? childMatches : node.children - }); - } - } - - return next; -} - -export function FloatingFilesWorkspace({ preferredLaneId }: { preferredLaneId: string | null }) { - const [workspaces, setWorkspaces] = React.useState([]); - const [workspaceId, setWorkspaceId] = React.useState(""); - const [allowPrimaryEdit, setAllowPrimaryEdit] = React.useState(false); - - const [tree, setTree] = React.useState([]); - const [expanded, setExpanded] = React.useState>(new Set()); - const [selectedNodePath, setSelectedNodePath] = React.useState(null); - - const [openTabs, setOpenTabs] = React.useState([]); - const [activeTabPath, setActiveTabPath] = React.useState(null); - - const [query, setQuery] = React.useState(""); - const [error, setError] = React.useState(null); - const [menu, setMenu] = React.useState(null); - const [clipboard, setClipboard] = React.useState(null); - - const [editorStatus, setEditorStatus] = React.useState<"loading" | "ready" | "failed">("loading"); - const [editorHost, setEditorHost] = React.useState(null); - - const monacoRef = React.useRef(null); - const editorRef = React.useRef(null); - const modelRef = React.useRef(null); - const modelKeyRef = React.useRef(null); - const applyingRef = React.useRef(false); - const activeTabPathRef = React.useRef(null); - const openTabsRef = React.useRef([]); - const containerRef = React.useRef(null); - - const activeWorkspace = React.useMemo( - () => workspaces.find((workspace) => workspace.id === workspaceId) ?? null, - [workspaces, workspaceId] - ); - - React.useEffect(() => { - if (!activeWorkspace?.rootPath) return; - replaceDirtyBuffersForWorkspace(activeWorkspace.rootPath, openTabs); - }, [activeWorkspace?.rootPath, openTabs]); - - const canEdit = React.useMemo(() => { - if (!activeWorkspace) return false; - if (!activeWorkspace.isReadOnlyByDefault) return true; - return allowPrimaryEdit; - }, [activeWorkspace, allowPrimaryEdit]); - - const activeTab = React.useMemo( - () => openTabs.find((tab) => tab.path === activeTabPath) ?? null, - [openTabs, activeTabPath] - ); - const activeTabIsText = isTextTab(activeTab); - - const nodeMap = React.useMemo(() => flattenNodes(tree), [tree]); - - const filteredTree = React.useMemo(() => filterTree(tree, query), [tree, query]); - - React.useEffect(() => { - activeTabPathRef.current = activeTabPath; - }, [activeTabPath]); - - React.useEffect(() => { - openTabsRef.current = openTabs; - }, [openTabs]); - - const refreshTree = React.useCallback(async () => { - if (!workspaceId) { - setTree([]); - return; - } - - try { - const nodes = await window.ade.files.listTree({ - workspaceId, - depth: 8 - }); - setTree(nodes); - } catch (err) { - setError(err instanceof Error ? err.message : String(err)); - } - }, [workspaceId]); - - const syncCleanTabFromDisk = React.useCallback( - async (pathRaw: string) => { - if (!workspaceId) return; - const path = normalizePath(pathRaw); - if (!path) return; - const hasCleanOpenTab = openTabsRef.current.some((tab) => tab.path === path && tab.content === tab.savedContent); - if (!hasCleanOpenTab) return; - - try { - const file = await window.ade.files.readFile({ workspaceId, path }); - const nextTab = openTabFromFileContent(path, file); - setOpenTabs((prev) => { - let changed = false; - const next = prev.map((tab) => { - if (tab.path !== path) return tab; - if (tab.content !== tab.savedContent) return tab; - - if ( - tab.content === file.content && - tab.savedContent === file.content && - tab.languageId === file.languageId && - tab.isBinary === file.isBinary && - getFilePreviewKind(tab) === getFilePreviewKind(file) && - tab.mimeType === (file.mimeType ?? null) && - tab.dataUrl === file.dataUrl && - tab.size === file.size - ) { - return tab; - } - - changed = true; - return { ...tab, ...nextTab }; - }); - return changed ? next : prev; - }); - } catch { - // A deleted/renamed path can race this read; ignore quietly. - } - }, - [workspaceId] - ); - - React.useEffect(() => { - let cancelled = false; - window.ade.files - .listWorkspaces() - .then((items) => { - if (cancelled) return; - setWorkspaces(items); - setWorkspaceId((current) => { - if (current && items.some((workspace) => workspace.id === current)) return current; - - if (preferredLaneId) { - const laneWorkspace = items.find((workspace) => workspace.laneId === preferredLaneId); - if (laneWorkspace) return laneWorkspace.id; - } - - return items[0]?.id ?? ""; - }); - }) - .catch((err: unknown) => { - if (cancelled) return; - setError(err instanceof Error ? err.message : String(err)); - }); - - return () => { - cancelled = true; - }; - }, [preferredLaneId]); - - React.useEffect(() => { - if (!preferredLaneId) return; - const laneWorkspace = workspaces.find((workspace) => workspace.laneId === preferredLaneId); - if (!laneWorkspace) return; - setWorkspaceId((current) => (current === laneWorkspace.id ? current : laneWorkspace.id)); - }, [preferredLaneId, workspaces]); - - React.useEffect(() => { - if (!workspaceId) return; - setMenu(null); - void refreshTree(); - - let timer: number | null = null; - const pendingTabSyncPaths = new Set(); - - const scheduleFlush = () => { - if (timer != null) window.clearTimeout(timer); - timer = window.setTimeout(() => { - timer = null; - void refreshTree(); - - const paths = Array.from(pendingTabSyncPaths); - pendingTabSyncPaths.clear(); - for (const path of paths) { - void syncCleanTabFromDisk(path); - } - }, 120); - }; - - void window.ade.files.watchChanges({ workspaceId }).catch(() => { - // best effort - }); - - const unsubscribe = window.ade.files.onChange((event) => { - if (event.workspaceId !== workspaceId) return; - - const nextPath = normalizePath(event.path); - const oldPath = normalizePath(event.oldPath ?? ""); - - if (event.type === "renamed" && oldPath && nextPath) { - setOpenTabs((prev) => { - let changed = false; - const next = prev.map((tab) => { - const mappedPath = remapPathForRename(tab.path, oldPath, nextPath); - if (mappedPath === tab.path) return tab; - changed = true; - if (tab.content === tab.savedContent) pendingTabSyncPaths.add(mappedPath); - return { ...tab, path: mappedPath }; - }); - return changed ? next : prev; - }); - setActiveTabPath((current) => (current ? remapPathForRename(current, oldPath, nextPath) : current)); - setSelectedNodePath((current) => (current ? remapPathForRename(current, oldPath, nextPath) : current)); - } else if (event.type === "deleted" && nextPath) { - setOpenTabs((prev) => { - const next = prev.filter((tab) => !isPathEqualOrDescendant(tab.path, nextPath)); - if (next.length !== prev.length) { - const activePath = activeTabPathRef.current; - if (activePath && !next.some((tab) => tab.path === activePath)) { - setActiveTabPath(next[next.length - 1]?.path ?? null); - } - } - return next.length === prev.length ? prev : next; - }); - setSelectedNodePath((current) => (current && isPathEqualOrDescendant(current, nextPath) ? null : current)); - } else if (nextPath) { - pendingTabSyncPaths.add(nextPath); - } - - scheduleFlush(); - }); - - return () => { - unsubscribe(); - if (timer != null) window.clearTimeout(timer); - void window.ade.files.stopWatching({ workspaceId }).catch(() => { - // best effort - }); - }; - }, [workspaceId, refreshTree, syncCleanTabFromDisk]); - - React.useEffect(() => { - const onPointerDown = () => setMenu(null); - window.addEventListener("pointerdown", onPointerDown); - return () => window.removeEventListener("pointerdown", onPointerDown); - }, []); - - React.useEffect(() => { - if (!editorHost) return; - if (editorRef.current) return; - - let disposed = false; - setEditorStatus("loading"); - - void loadMonaco() - .then((monaco) => { - if (disposed) return; - monacoRef.current = monaco; - const editor = monaco.editor.create(editorHost, { - value: "", - language: "plaintext", - automaticLayout: true, - minimap: { enabled: false }, - fontSize: 12, - lineHeight: 18, - theme: "vs-dark", - readOnly: true - }); - editorRef.current = editor; - editor.onDidChangeModelContent(() => { - const tabPath = activeTabPathRef.current; - if (!tabPath || applyingRef.current) return; - const value = editor.getValue(); - setOpenTabs((prev) => - prev.map((tab) => (tab.path === tabPath ? { ...tab, content: value } : tab)) - ); - }); - setEditorStatus("ready"); - }) - .catch((err: unknown) => { - if (disposed) return; - setEditorStatus("failed"); - setError(`Editor failed to load: ${err instanceof Error ? err.message : String(err)}`); - }); - - return () => { - disposed = true; - try { - editorRef.current?.setModel(null); - } catch { - // ignore - } - try { - modelRef.current?.dispose(); - } catch { - // ignore - } - try { - editorRef.current?.dispose(); - } catch { - // ignore - } - modelRef.current = null; - modelKeyRef.current = null; - editorRef.current = null; - }; - }, [editorHost]); - - React.useEffect(() => { - if (!editorRef.current || !monacoRef.current) return; - - if (!activeTab || !activeTabIsText) { - try { - editorRef.current.setModel(null); - } catch { - // ignore - } - try { - modelRef.current?.dispose(); - } catch { - // ignore - } - modelRef.current = null; - modelKeyRef.current = null; - return; - } - - const modelKey = `${activeTab.path}:${activeTab.languageId}`; - if (modelRef.current && modelKeyRef.current === modelKey) { - editorRef.current.updateOptions({ - readOnly: !canEdit || !activeTabIsText - }); - return; - } - - try { - editorRef.current.setModel(null); - } catch { - // ignore - } - try { - modelRef.current?.dispose(); - } catch { - // ignore - } - - modelRef.current = monacoRef.current.editor.createModel( - activeTab.content, - activeTab.languageId || "plaintext" - ); - modelKeyRef.current = modelKey; - editorRef.current.setModel(modelRef.current); - editorRef.current.updateOptions({ - readOnly: !canEdit || !activeTabIsText - }); - }, [activeTab, activeTabIsText, canEdit]); - - React.useEffect(() => { - if (!activeTab || !activeTabIsText || !editorRef.current) return; - const current = editorRef.current.getValue(); - if (current === activeTab.content) return; - applyingRef.current = true; - editorRef.current.setValue(activeTab.content); - applyingRef.current = false; - }, [activeTab, activeTabIsText]); - - const openFile = React.useCallback( - async (path: string) => { - if (!workspaceId) return; - try { - const next = await window.ade.files.readFile({ workspaceId, path }); - const nextTab = openTabFromFileContent(path, next); - setError(null); - setOpenTabs((prev) => { - const existing = prev.find((tab) => tab.path === path); - if (existing) { - return prev.map((tab) => - tab.path === path - ? { ...tab, ...nextTab } - : tab - ); - } - return [...prev, nextTab]; - }); - setActiveTabPath(path); - setSelectedNodePath(path); - } catch (err) { - setError(err instanceof Error ? err.message : String(err)); - } - }, - [workspaceId] - ); - - const closeTab = React.useCallback((path: string) => { - setOpenTabs((prev) => { - const next = prev.filter((tab) => tab.path !== path); - if (activeTabPathRef.current === path) { - setActiveTabPath(next[next.length - 1]?.path ?? null); - } - return next; - }); - }, []); - - const saveActive = React.useCallback(async () => { - if (!workspaceId || !activeTab || !canEdit || !isTextTab(activeTab)) return; - try { - await window.ade.files.writeText({ - workspaceId, - path: activeTab.path, - text: activeTab.content - }); - setOpenTabs((prev) => - prev.map((tab) => (tab.path === activeTab.path ? { ...tab, savedContent: tab.content } : tab)) - ); - await refreshTree(); - } catch (err) { - setError(err instanceof Error ? err.message : String(err)); - } - }, [workspaceId, activeTab, canEdit, refreshTree]); - - const createFile = React.useCallback( - async (baseDir: string) => { - if (!workspaceId || !canEdit) return; - const defaultPath = baseDir ? `${baseDir}/new-file.ts` : "new-file.ts"; - const nextPath = window.prompt("New file path", defaultPath)?.trim(); - if (!nextPath) return; - try { - await window.ade.files.createFile({ - workspaceId, - path: normalizePath(nextPath), - content: "" - }); - await refreshTree(); - await openFile(normalizePath(nextPath)); - } catch (err) { - setError(err instanceof Error ? err.message : String(err)); - } - }, - [workspaceId, canEdit, refreshTree, openFile] - ); - - const createDirectory = React.useCallback( - async (baseDir: string) => { - if (!workspaceId || !canEdit) return; - const defaultPath = baseDir ? `${baseDir}/new-folder` : "new-folder"; - const nextPath = window.prompt("New folder path", defaultPath)?.trim(); - if (!nextPath) return; - try { - await window.ade.files.createDirectory({ - workspaceId, - path: normalizePath(nextPath) - }); - await refreshTree(); - } catch (err) { - setError(err instanceof Error ? err.message : String(err)); - } - }, - [workspaceId, canEdit, refreshTree] - ); - - const renamePath = React.useCallback( - async (path: string) => { - if (!workspaceId || !canEdit) return; - const nextPath = window.prompt("Rename path", path)?.trim(); - if (!nextPath || nextPath === path) return; - try { - await window.ade.files.rename({ - workspaceId, - oldPath: path, - newPath: normalizePath(nextPath) - }); - setOpenTabs((prev) => prev.map((tab) => (tab.path === path ? { ...tab, path: normalizePath(nextPath) } : tab))); - setActiveTabPath((current) => (current === path ? normalizePath(nextPath) : current)); - setSelectedNodePath(normalizePath(nextPath)); - await refreshTree(); - } catch (err) { - setError(err instanceof Error ? err.message : String(err)); - } - }, - [workspaceId, canEdit, refreshTree] - ); - - const deletePath = React.useCallback( - async (path: string) => { - if (!workspaceId || !canEdit) return; - const confirmed = window.confirm(`Delete ${path}?`); - if (!confirmed) return; - try { - await window.ade.files.delete({ workspaceId, path }); - setOpenTabs((prev) => prev.filter((tab) => tab.path !== path)); - setActiveTabPath((current) => (current === path ? null : current)); - setSelectedNodePath((current) => (current === path ? null : current)); - await refreshTree(); - } catch (err) { - setError(err instanceof Error ? err.message : String(err)); - } - }, - [workspaceId, canEdit, refreshTree] - ); - - const makeUniqueDestinationPath = React.useCallback( - (targetDir: string, sourceName: string, isDirectory: boolean) => { - let sequence = 0; - while (sequence < 200) { - const candidateName = - sequence === 0 - ? sourceName - : isDirectory - ? createCopyName(sourceName, sequence) - : createCopyFileName(sourceName, sequence); - const candidatePath = joinPath(targetDir, candidateName); - if (!nodeMap.has(candidatePath)) return candidatePath; - sequence += 1; - } - return joinPath(targetDir, `${sourceName}-${Date.now()}`); - }, - [nodeMap] - ); - - const copyDirectoryRecursive = React.useCallback( - async (sourceWorkspaceId: string, sourceDir: string, destDir: string) => { - await window.ade.files.createDirectory({ workspaceId, path: destDir }); - const children = await window.ade.files.listTree({ - workspaceId: sourceWorkspaceId, - parentPath: sourceDir, - depth: 1 - }); - for (const child of children) { - const childDestPath = joinPath(destDir, child.name); - if (child.type === "directory") { - await copyDirectoryRecursive(sourceWorkspaceId, child.path, childDestPath); - } else { - const content = await window.ade.files.readFile({ - workspaceId: sourceWorkspaceId, - path: child.path - }); - if (content.isBinary) { - throw new Error(`Binary file copy is not supported yet (${child.path}).`); - } - await window.ade.files.createFile({ - workspaceId, - path: childDestPath, - content: content.content - }); - } - } - }, - [workspaceId] - ); - - const pasteInto = React.useCallback( - async (targetDirRaw: string) => { - if (!workspaceId || !canEdit || !clipboard) return; - - const targetDir = normalizePath(targetDirRaw); - const sourceName = basename(clipboard.path); - const destinationPath = makeUniqueDestinationPath(targetDir, sourceName, clipboard.type === "directory"); - - if (clipboard.mode === "cut") { - if (clipboard.workspaceId !== workspaceId) { - setError("Cut/paste currently works only within the same workspace."); - return; - } - - if (destinationPath === clipboard.path) return; - if ( - clipboard.type === "directory" && - destinationPath.startsWith(`${clipboard.path}/`) - ) { - setError("Cannot move a folder into itself."); - return; - } - - try { - await window.ade.files.rename({ - workspaceId, - oldPath: clipboard.path, - newPath: destinationPath - }); - setClipboard(null); - await refreshTree(); - } catch (err) { - setError(err instanceof Error ? err.message : String(err)); - } - return; - } - - try { - if (clipboard.type === "file") { - const source = await window.ade.files.readFile({ - workspaceId: clipboard.workspaceId, - path: clipboard.path - }); - if (source.isBinary) { - throw new Error(`Binary file copy is not supported yet (${clipboard.path}).`); - } - await window.ade.files.createFile({ - workspaceId, - path: destinationPath, - content: source.content - }); - } else { - await copyDirectoryRecursive(clipboard.workspaceId, clipboard.path, destinationPath); - } - await refreshTree(); - } catch (err) { - setError(err instanceof Error ? err.message : String(err)); - } - }, - [workspaceId, canEdit, clipboard, makeUniqueDestinationPath, refreshTree, copyDirectoryRecursive] - ); - - const renderTree = React.useCallback( - (nodes: FileTreeNode[], level = 0): React.ReactNode => { - return nodes.map((node) => { - const isExpanded = expanded.has(node.path); - const isSelected = selectedNodePath === node.path; - - return ( -
- - - {node.type === "directory" && isExpanded && node.children?.length ? ( -
{renderTree(node.children, level + 1)}
- ) : null} -
- ); - }); - }, - [expanded, selectedNodePath, openFile] - ); - - const contextBaseDir = React.useMemo(() => { - if (!menu) return ""; - if (menu.nodeType === "directory") return menu.nodePath; - return parentPathOf(menu.nodePath); - }, [menu]); - - const menuX = React.useMemo(() => { - if (!menu || !containerRef.current) return 8; - return Math.min(menu.x, Math.max(8, containerRef.current.clientWidth - MIN_CONTEXT_MENU_WIDTH - 8)); - }, [menu]); - - const menuY = React.useMemo(() => { - if (!menu || !containerRef.current) return 8; - return Math.min(menu.y, Math.max(8, containerRef.current.clientHeight - 280)); - }, [menu]); - - return ( -
-
- - - {activeWorkspace?.isReadOnlyByDefault ? ( - - ) : null} - -
- - - -
-
- - {error ? ( -
- {error} - -
- ) : null} - -
-
-
- setQuery(event.target.value)} - placeholder="Filter files" - className="h-7 w-full rounded border border-border/60 bg-surface px-2 text-[11px] font-mono text-fg placeholder:text-muted-fg" - data-pane-control="true" - /> -
- -
- {filteredTree.length ? ( - renderTree(filteredTree) - ) : ( -
No files match.
- )} -
-
- -
-
- {openTabs.length === 0 ? ( - No open files - ) : null} - - {openTabs.map((tab) => { - const active = activeTabPath === tab.path; - const dirty = tab.content !== tab.savedContent; - return ( -
- - {dirty ? : null} - -
- ); - })} -
- -
- {!activeTab ? ( -
- Open a file to edit. -
- ) : null} - - {activeTab && !activeTabIsText ? ( - - ) : null} - - {editorStatus === "failed" && activeTab && activeTabIsText ? ( -