From e6a1806fc8bd8ac46a15ccaa2090d07ecca6171a Mon Sep 17 00:00:00 2001 From: Francisco Pizarro Date: Tue, 11 Aug 2026 23:52:59 -0400 Subject: [PATCH 1/2] feat(workspace): trees+diffs sidepanel, daemon port Replace the workspace file tree, read-only code viewer, and hand-rolled diff parser with @pierre/trees and @pierre/diffs. Add inline file editing, a top-level Diffs tab, and port the workspace presenter to the daemon so the feature works in web/headless mode too. Monaco + CodeMirror are removed; chat file links open the diff for in-workspace files. --- README.md | 2 +- apps/daemon/package.json | 7 +- apps/daemon/src/dispatch/daemonDispatcher.ts | 140 +++ apps/daemon/src/index.ts | 33 + .../src/workspace/daemonWorkspacePresenter.ts | 978 ++++++++++++++++++ .../presenter/workspacePresenter/index.ts | 144 +++ apps/desktop/src/main/routes/index.ts | 37 + .../main/presenter/workspacePresenter.test.ts | 114 ++ apps/landing/src/components/Spotlight.tsx | 2 +- bun.lock | 92 +- docs/features/trees-diffs-workspace/plan.md | 140 +++ docs/features/trees-diffs-workspace/spec.md | 76 ++ docs/features/trees-diffs-workspace/tasks.md | 87 ++ packages/shared-contracts/src/desktop-only.ts | 3 + .../shared-contracts/src/domainSchemas.ts | 6 +- packages/shared-contracts/src/routes.ts | 12 +- .../src/routes/workspace.routes.ts | 64 ++ .../src/types/presenters/workspace.d.ts | 39 +- packages/ui/api/WorkspaceClient.ts | 33 +- packages/ui/package.json | 5 +- .../markdown/useMarkdownLinkNavigation.ts | 42 +- .../components/sidepanel/ChatSidePanel.tsx | 14 + .../src/components/sidepanel/DiffsPanel.tsx | 233 +++++ .../components/sidepanel/TreesFileTree.tsx | 408 ++++++++ .../components/sidepanel/WorkspacePanel.tsx | 104 +- .../components/sidepanel/WorkspaceViewer.tsx | 186 +++- .../sidepanel/viewer/DiffsCodePane.tsx | 54 + .../sidepanel/viewer/DiffsEditorPane.tsx | 116 +++ .../sidepanel/viewer/DiffsPatchPane.tsx | 53 + .../sidepanel/viewer/WorkspaceCodePane.tsx | 222 ---- .../sidepanel/viewer/WorkspaceDiffView.tsx | 119 --- .../sidepanel/viewer/diffsOptions.ts | 18 + .../ui/src/components/trace/TraceDialog.tsx | 53 +- .../workspace/WorkspaceFileNode.tsx | 162 --- packages/ui/src/stores/ui/sidepanel.ts | 20 + packages/ui/vite.config.ts | 17 +- 36 files changed, 3045 insertions(+), 790 deletions(-) create mode 100644 apps/daemon/src/workspace/daemonWorkspacePresenter.ts create mode 100644 docs/features/trees-diffs-workspace/plan.md create mode 100644 docs/features/trees-diffs-workspace/spec.md create mode 100644 docs/features/trees-diffs-workspace/tasks.md create mode 100644 packages/ui/src/components/sidepanel/DiffsPanel.tsx create mode 100644 packages/ui/src/components/sidepanel/TreesFileTree.tsx create mode 100644 packages/ui/src/components/sidepanel/viewer/DiffsCodePane.tsx create mode 100644 packages/ui/src/components/sidepanel/viewer/DiffsEditorPane.tsx create mode 100644 packages/ui/src/components/sidepanel/viewer/DiffsPatchPane.tsx delete mode 100644 packages/ui/src/components/sidepanel/viewer/WorkspaceCodePane.tsx delete mode 100644 packages/ui/src/components/sidepanel/viewer/WorkspaceDiffView.tsx create mode 100644 packages/ui/src/components/sidepanel/viewer/diffsOptions.ts delete mode 100644 packages/ui/src/components/workspace/WorkspaceFileNode.tsx diff --git a/README.md b/README.md index 8bc8ffee1..896d3a1df 100644 --- a/README.md +++ b/README.md @@ -45,7 +45,7 @@ Beyond chat, Argos supports agentic workflows: rich tool calling via MCP (Model - **Multiple Cloud LLM Providers**: DeepSeek, OpenAI, Moonshot/Kimi, Grok, Gemini, Anthropic, and more — plus any OpenAI-, Gemini-, or Anthropic-compatible API. - **Local Model Deployment**: Integrated Ollama with download, deploy, and run controls — no command line needed. -- **Rich Chat Experience**: Markdown + [CodeMirror](https://codemirror.net/) rendering, multi-window/multi-tab parallelism, Artifacts, message retry and conversation forking, multi-modal (images, Mermaid, text-to-image), inline source highlighting. +- **Rich Chat Experience**: Markdown + `@tanstack/highlight` code rendering, multi-window/multi-tab parallelism, Artifacts, message retry and conversation forking, multi-modal (images, Mermaid, text-to-image), inline source highlighting. - **Search Extensions**: Built-in BoSearch and Brave Search MCP integrations, plus any custom search engine via a search-assistant model. - **MCP Support**: Full Resources/Prompts/Tools coverage, semantic workflows, inMemory services, StreamableHTTP/SSE/Stdio transports, visual debugging, and bundled Bun interpreter. - **Skills**: Install from folders, ZIPs, or URLs; enable per conversation; import/export with Claude Code, Codex, Cursor, Windsurf, GitHub Copilot, Kiro, Antigravity, OpenCode, Goose, Kilo Code, and more. Built-ins cover code review, document collaboration, Office/PDF, frontend design, MCP development, and others. diff --git a/apps/daemon/package.json b/apps/daemon/package.json index 7305f670a..8d71ff987 100644 --- a/apps/daemon/package.json +++ b/apps/daemon/package.json @@ -13,7 +13,6 @@ "typecheck": "tsc --noEmit" }, "dependencies": { - "@earendil-works/pi-coding-agent": "0.83.0", "@agentclientprotocol/sdk": "^1.3.0", "@argos/acp-runtime": "workspace:*", "@argos/agent-runtime": "workspace:*", @@ -26,12 +25,14 @@ "@argos/shared-contracts": "workspace:*", "@argos/skills-runtime": "workspace:*", "@duckdb/node-api": "1.5.5-r.3", + "@earendil-works/pi-coding-agent": "0.83.0", "ai": "catalog:", - "sharp": "^0.35.3", - "typebox": "1.3.9", + "chokidar": "^5.0.0", "fflate": "catalog:", "gray-matter": "^4.0.3", "nanoid": "catalog:", + "sharp": "^0.35.3", + "typebox": "1.3.9", "zod": "catalog:" }, "devDependencies": { diff --git a/apps/daemon/src/dispatch/daemonDispatcher.ts b/apps/daemon/src/dispatch/daemonDispatcher.ts index ef5a61df3..ec4751deb 100644 --- a/apps/daemon/src/dispatch/daemonDispatcher.ts +++ b/apps/daemon/src/dispatch/daemonDispatcher.ts @@ -94,6 +94,22 @@ import { modelsSetBatchStatusRoute, toolsListDefinitionsRoute, workspaceBrowseDirectoryRoute, + workspaceRegisterRoute, + workspaceUnregisterRoute, + workspaceWatchRoute, + workspaceUnwatchRoute, + workspaceReadDirectoryRoute, + workspaceExpandDirectoryRoute, + workspaceReadFilePreviewRoute, + workspaceReadFileTextRoute, + workspaceWriteFileRoute, + workspaceCreateEntryRoute, + workspaceDeletePathRoute, + workspaceRenameOrMovePathRoute, + workspaceResolveMarkdownLinkedFileRoute, + workspaceGetGitStatusRoute, + workspaceGetGitDiffRoute, + workspaceSearchFilesRoute, fileIsDirectoryRoute, filePrepareDirectoryRoute, fileReadFileRoute, @@ -791,6 +807,26 @@ export function createDaemonDispatcher( orchestrationRuntime?: { definitions(): unknown[]; }, + workspacePresenter?: { + registerWorkspace(workspacePath: string): Promise; + registerWorkdir(workdir: string): Promise; + unregisterWorkspace(workspacePath: string): Promise; + unregisterWorkdir(workdir: string): Promise; + watchWorkspace(workspacePath: string): Promise; + unwatchWorkspace(workspacePath: string): Promise; + readDirectory(dirPath: string): Promise; + expandDirectory(dirPath: string): Promise; + readFilePreview(filePath: string): Promise; + readFileText(filePath: string): Promise<{ content: string | null; exists: boolean }>; + writeFile(filePath: string, content: string): Promise; + createEntry(parentDir: string, name: string, isDirectory: boolean): Promise; + deletePath(targetPath: string): Promise; + renameOrMovePath(fromPath: string, toPath: string): Promise; + resolveMarkdownLinkedFile(input: unknown): Promise; + getGitStatus(workspacePath: string): Promise; + getGitDiff(workspacePath: string, filePath?: string): Promise; + searchFiles(workspacePath: string, query: string): Promise; + }, ): RouteDispatcher { const settingsHandler = new SettingsRouteHandler(createSettingsRouteAdapter(configPresenter)); const runtime: { @@ -1484,6 +1520,110 @@ export function createDaemonDispatcher( }); } + if (workspacePresenter && route === workspaceRegisterRoute.name) { + const input = workspaceRegisterRoute.input.parse(rawInput); + if (input.mode === "workdir") await workspacePresenter.registerWorkdir(input.workspacePath); + else await workspacePresenter.registerWorkspace(input.workspacePath); + return workspaceRegisterRoute.output.parse({ registered: true }); + } + + if (workspacePresenter && route === workspaceUnregisterRoute.name) { + const input = workspaceUnregisterRoute.input.parse(rawInput); + if (input.mode === "workdir") await workspacePresenter.unregisterWorkdir(input.workspacePath); + else await workspacePresenter.unregisterWorkspace(input.workspacePath); + return workspaceUnregisterRoute.output.parse({ unregistered: true }); + } + + if (workspacePresenter && route === workspaceWatchRoute.name) { + const input = workspaceWatchRoute.input.parse(rawInput); + await workspacePresenter.watchWorkspace(input.workspacePath); + return workspaceWatchRoute.output.parse({ watching: true }); + } + + if (workspacePresenter && route === workspaceUnwatchRoute.name) { + const input = workspaceUnwatchRoute.input.parse(rawInput); + await workspacePresenter.unwatchWorkspace(input.workspacePath); + return workspaceUnwatchRoute.output.parse({ watching: false }); + } + + if (workspacePresenter && route === workspaceReadDirectoryRoute.name) { + const input = workspaceReadDirectoryRoute.input.parse(rawInput); + return workspaceReadDirectoryRoute.output.parse({ nodes: await workspacePresenter.readDirectory(input.path) }); + } + + if (workspacePresenter && route === workspaceExpandDirectoryRoute.name) { + const input = workspaceExpandDirectoryRoute.input.parse(rawInput); + return workspaceExpandDirectoryRoute.output.parse({ + nodes: await workspacePresenter.expandDirectory(input.path), + }); + } + + if (workspacePresenter && route === workspaceReadFilePreviewRoute.name) { + const input = workspaceReadFilePreviewRoute.input.parse(rawInput); + return workspaceReadFilePreviewRoute.output.parse({ + preview: await workspacePresenter.readFilePreview(input.path), + }); + } + + if (workspacePresenter && route === workspaceReadFileTextRoute.name) { + const input = workspaceReadFileTextRoute.input.parse(rawInput); + return workspaceReadFileTextRoute.output.parse(await workspacePresenter.readFileText(input.path)); + } + + if (workspacePresenter && route === workspaceWriteFileRoute.name) { + const input = workspaceWriteFileRoute.input.parse(rawInput); + await workspacePresenter.writeFile(input.path, input.content); + return workspaceWriteFileRoute.output.parse({ written: true }); + } + + if (workspacePresenter && route === workspaceCreateEntryRoute.name) { + const input = workspaceCreateEntryRoute.input.parse(rawInput); + return workspaceCreateEntryRoute.output.parse({ + path: await workspacePresenter.createEntry(input.parentDir, input.name, input.isDirectory), + }); + } + + if (workspacePresenter && route === workspaceDeletePathRoute.name) { + const input = workspaceDeletePathRoute.input.parse(rawInput); + await workspacePresenter.deletePath(input.path); + return workspaceDeletePathRoute.output.parse({ deleted: true }); + } + + if (workspacePresenter && route === workspaceRenameOrMovePathRoute.name) { + const input = workspaceRenameOrMovePathRoute.input.parse(rawInput); + return workspaceRenameOrMovePathRoute.output.parse({ + path: await workspacePresenter.renameOrMovePath(input.fromPath, input.toPath), + }); + } + + if (workspacePresenter && route === workspaceResolveMarkdownLinkedFileRoute.name) { + const input = workspaceResolveMarkdownLinkedFileRoute.input.parse(rawInput); + return workspaceResolveMarkdownLinkedFileRoute.output.parse({ + resolution: await workspacePresenter.resolveMarkdownLinkedFile(input), + }); + } + + if (workspacePresenter && route === workspaceGetGitStatusRoute.name) { + const input = workspaceGetGitStatusRoute.input.parse(rawInput); + return workspaceGetGitStatusRoute.output.parse({ + state: await workspacePresenter.getGitStatus(input.workspacePath), + }); + } + + if (workspacePresenter && route === workspaceGetGitDiffRoute.name) { + const input = workspaceGetGitDiffRoute.input.parse(rawInput); + return workspaceGetGitDiffRoute.output.parse({ + diff: await workspacePresenter.getGitDiff(input.workspacePath, input.filePath), + }); + } + + if (workspacePresenter && route === workspaceSearchFilesRoute.name) { + const input = workspaceSearchFilesRoute.input.parse(rawInput); + return workspaceSearchFilesRoute.output.parse({ + nodes: await workspacePresenter.searchFiles(input.workspacePath, input.query), + }); + } + if (route === projectListRecentRoute.name) { const input = projectListRecentRoute.input.parse(rawInput); // Derive recent projects from sessions' project_dir (most-recent-first). diff --git a/apps/daemon/src/index.ts b/apps/daemon/src/index.ts index 6d0a2ae77..dc018f4fd 100644 --- a/apps/daemon/src/index.ts +++ b/apps/daemon/src/index.ts @@ -12,6 +12,7 @@ import { DaemonArgosAgentRuntime } from "./host/daemonArgosAgentRuntime"; import { BunEventPublisher } from "./host/bun-event-publisher"; import { initializeDatabase } from "./host/db-init"; import { createDaemonDispatcher } from "./dispatch/daemonDispatcher"; +import { DaemonWorkspacePresenter } from "./workspace/daemonWorkspacePresenter"; import { ProviderImportService } from "@argos/backend-core"; import { PiProviderExecutionPort } from "./host/pi-provider-execution"; import { PiAgentProfileManager } from "./host/piAgentProfileManager"; @@ -150,6 +151,14 @@ function withCors(response: Response): Response { }); } +function inferPreviewContentType(filePath: string): string { + const ext = filePath.slice(filePath.lastIndexOf(".") + 1).toLowerCase(); + if (ext === "html" || ext === "htm") return "text/html; charset=utf-8"; + if (ext === "svg") return "image/svg+xml"; + if (ext === "pdf") return "application/pdf"; + return "application/octet-stream"; +} + function serveStaticWeb(webRoot: string, pathname: string): Response { const safePath = pathname .split("/") @@ -724,6 +733,8 @@ export async function startDaemon(options?: { }, }); + const workspacePresenter = new DaemonWorkspacePresenter(eventPublisher, "http://127.0.0.1:0"); + const dispatcher = options?.dispatcher ?? createDaemonDispatcher( @@ -743,6 +754,7 @@ export async function startDaemon(options?: { db, environmentId, orchestrationRuntime, + workspacePresenter, ); setRouteDispatcher(dispatcher); @@ -813,6 +825,26 @@ export async function startDaemon(options?: { return withCors(await handleRouteDispatch(request)); } + // Workspace file preview (html/pdf/svg) served as raw bytes. The path must + // resolve inside a registered/allow-listed workspace; otherwise 404. + if (url.pathname === "/api/v1/workspace/preview" && request.method === "GET") { + const targetPath = url.searchParams.get("path"); + if (!targetPath || !workspacePresenter.isPathAllowed(targetPath)) { + return new Response("Not found", { status: 404 }); + } + try { + const file = Bun.file(targetPath); + if (!(await file.exists())) return new Response("Not found", { status: 404 }); + return withCors( + new Response(file, { + headers: { "Content-Type": inferPreviewContentType(targetPath), "Cache-Control": "no-store" }, + }), + ); + } catch { + return new Response("Not found", { status: 404 }); + } + } + if (url.pathname === "/api/v1/sessions" && request.method === "GET") { return withCors(await handleListSessions(sessionAuthRepo)); } @@ -909,6 +941,7 @@ export async function startDaemon(options?: { }); const serverPort = (server as any).port ?? port; + workspacePresenter.setBaseUrl(`http://${host}:${serverPort}`); if (!isNonLoopbackHost(host)) { const originHost = host === "::1" ? "[::1]" : host; pluginPresenter.setSettingsBaseUrl(`http://${originHost}:${serverPort}`); diff --git a/apps/daemon/src/workspace/daemonWorkspacePresenter.ts b/apps/daemon/src/workspace/daemonWorkspacePresenter.ts new file mode 100644 index 000000000..ee53c1d51 --- /dev/null +++ b/apps/daemon/src/workspace/daemonWorkspacePresenter.ts @@ -0,0 +1,978 @@ +import fs from "node:fs"; +import fsp from "node:fs/promises"; +import path from "node:path"; +import { execFile } from "node:child_process"; +import { promisify } from "node:util"; +import { fileURLToPath } from "node:url"; +import { watch, type FSWatcher } from "chokidar"; +import type { IEventPublisher } from "@argos/backend-core"; +import { workspaceInvalidatedEvent } from "@argos/shared-contracts/events"; +import type { + ResolveMarkdownLinkedFileInput, + WorkspaceFileMetadata, + WorkspaceFileNode, + WorkspaceFilePreview, + WorkspaceFilePreviewKind, + WorkspaceGitChangeType, + WorkspaceGitDiff, + WorkspaceGitFileChange, + WorkspaceGitState, + WorkspaceInvalidationEvent, + WorkspaceInvalidationKind, + WorkspaceInvalidationSource, + WorkspaceLinkedFileResolution, +} from "@argos/shared/presenter"; + +const execFileAsync = promisify(execFile); + +const execGit = async (workspacePath: string, args: string[]): Promise => { + try { + const result = await execFileAsync("git", args, { + cwd: workspacePath, + windowsHide: true, + maxBuffer: 8 * 1024 * 1024, + }); + return result.stdout.trimEnd(); + } catch (error) { + if ( + typeof error === "object" && + error !== null && + "code" in error && + (error as { code?: string }).code === "ENOENT" + ) { + return null; + } + throw error; + } +}; + +const WATCH_IGNORED_DIRS = [ + "node_modules", + "dist", + "build", + "__pycache__", + ".venv", + "venv", + ".idea", + ".vscode", + ".cache", + "coverage", + ".next", + ".nuxt", + "out", + ".turbo", +] as const; + +const WATCH_DEBOUNCE_MS = 120; +const WATCH_STABILITY_THRESHOLD_MS = 250; +const WATCH_POLL_INTERVAL_MS = 100; +const READ_TEXT_MAX_BYTES = 2 * 1024 * 1024; +const BINARY_SNIFF_BYTES = 8192; +const SEARCH_MAX_RESULTS = 200; +/** Cap untracked files synthesized into the full-workspace diff (perf guard). */ +const UNTRACKED_FULL_DIFF_MAX_FILES = 100; + +const MIME_BY_EXTENSION: Record = { + md: "text/markdown", + markdown: "text/markdown", + mdx: "text/markdown", + html: "text/html", + htm: "text/html", + pdf: "application/pdf", + svg: "image/svg+xml", + png: "image/png", + jpg: "image/jpeg", + jpeg: "image/jpeg", + gif: "image/gif", + webp: "image/webp", + ico: "image/x-icon", + bmp: "image/bmp", + json: "application/json", + ts: "application/typescript", + tsx: "application/typescript", + js: "application/javascript", + jsx: "application/javascript", + css: "text/css", + xml: "application/xml", + yaml: "application/yaml", + yml: "application/yaml", + py: "text/x-python", + rb: "text/x-ruby", + rs: "text/x-rust", + go: "text/x-go", + sh: "application/x-sh", + txt: "text/plain", +}; + +const getInvalidationPriority = (kind: WorkspaceInvalidationKind): number => { + switch (kind) { + case "full": + return 3; + case "fs": + return 2; + case "git": + return 1; + default: + return 0; + } +}; + +type WorkspaceWatchRuntime = { + workspacePath: string; + refCount: number; + contentWatcher: FSWatcher; + gitWatcher: FSWatcher | null; + gitWatchKey: string | null; + debounceTimer: ReturnType | null; + pendingKind: WorkspaceInvalidationKind | null; + pendingSource: WorkspaceInvalidationSource | null; + disposed: boolean; +}; + +/** + * Daemon-side workspace presenter. A Bun port of the desktop `WorkspacePresenter`: + * file-tree reads, file preview (HTTP preview URLs instead of an Electron custom + * protocol), git status/diff, file editing, and chokidar watchers that publish + * `workspace.invalidated` over the daemon event publisher. `revealFileInFolder` + * and `openFile` are desktop-only (Electron `shell`) and throw here. + */ +export class DaemonWorkspacePresenter { + private readonly allowedPaths = new Set(); + private readonly allowedExactPaths = new Set(); + private readonly eventPublisher: IEventPublisher; + private baseUrl: string; + private readonly watchRuntimes = new Map(); + + constructor(eventPublisher: IEventPublisher, baseUrl: string) { + this.eventPublisher = eventPublisher; + this.baseUrl = baseUrl.replace(/\/$/, ""); + } + + /** Update the HTTP origin used to build preview URLs (after the server bound its port). */ + setBaseUrl(baseUrl: string): void { + this.baseUrl = baseUrl.replace(/\/$/, ""); + } + + // ---- registration / security boundary ---- + + async registerWorkspace(workspacePath: string): Promise { + this.allowedPaths.add(path.resolve(workspacePath)); + } + + async registerWorkdir(workdir: string): Promise { + await this.registerWorkspace(workdir); + } + + async unregisterWorkspace(workspacePath: string): Promise { + this.allowedPaths.delete(path.resolve(workspacePath)); + } + + async unregisterWorkdir(workdir: string): Promise { + await this.unregisterWorkspace(workdir); + } + + /** Public for the HTTP preview endpoint. */ + isPathAllowed(targetPath: string): boolean { + const normalizedTarget = this.normalizePathForAccess(targetPath); + if (this.allowedExactPaths.has(normalizedTarget)) { + return true; + } + const targetWithSep = normalizedTarget.endsWith(path.sep) ? normalizedTarget : `${normalizedTarget}${path.sep}`; + for (const workspace of this.allowedPaths) { + const normalizedWorkspace = this.normalizePathForAccess(workspace); + const workspaceWithSep = normalizedWorkspace.endsWith(path.sep) + ? normalizedWorkspace + : `${normalizedWorkspace}${path.sep}`; + if (normalizedTarget === normalizedWorkspace || targetWithSep.startsWith(workspaceWithSep)) { + return true; + } + } + return false; + } + + private authorizeExactFile(filePath: string): string { + const normalized = this.normalizePathForAccess(filePath); + this.allowedExactPaths.add(normalized); + return normalized; + } + + private normalizePathForAccess(targetPath: string): string { + try { + return path.normalize(fs.realpathSync(targetPath)); + } catch { + return path.normalize(path.resolve(targetPath)); + } + } + + private getWorkspaceRootForPath(targetPath: string): string | null { + const normalizedTarget = this.normalizePathForAccess(targetPath); + for (const workspace of this.allowedPaths) { + const normalizedWorkspace = this.normalizePathForAccess(workspace); + const relativePath = path.relative(normalizedWorkspace, normalizedTarget); + if ( + normalizedTarget === normalizedWorkspace || + (relativePath && !relativePath.startsWith("..") && !path.isAbsolute(relativePath)) + ) { + return normalizedWorkspace; + } + } + return null; + } + + private toRelativeWorkspacePath(workspaceRoot: string, targetPath: string): string { + const relativePath = path.relative(workspaceRoot, path.resolve(targetPath)); + return relativePath.split(path.sep).join("/"); + } + + destroy(): void { + const runtimes = Array.from(this.watchRuntimes.values()); + this.watchRuntimes.clear(); + for (const runtime of runtimes) void this.disposeRuntime(runtime); + } + + // ---- watchers ---- + + async watchWorkspace(workspacePath: string): Promise { + const normalized = path.resolve(workspacePath); + if (!this.isPathAllowed(normalized)) return; + + const existing = this.watchRuntimes.get(normalized); + if (existing) { + existing.refCount += 1; + return; + } + + const runtime: WorkspaceWatchRuntime = { + workspacePath: normalized, + refCount: 1, + contentWatcher: this.createContentWatcher(normalized), + gitWatcher: null, + gitWatchKey: null, + debounceTimer: null, + pendingKind: null, + pendingSource: null, + disposed: false, + }; + this.watchRuntimes.set(normalized, runtime); + await this.refreshGitWatcher(runtime); + } + + async unwatchWorkspace(workspacePath: string): Promise { + const normalized = path.resolve(workspacePath); + const runtime = this.watchRuntimes.get(normalized); + if (!runtime) return; + runtime.refCount -= 1; + if (runtime.refCount > 0) return; + this.watchRuntimes.delete(normalized); + await this.disposeRuntime(runtime); + } + + private createContentWatcher(workspacePath: string): FSWatcher { + const watcher = watch(workspacePath, { + ignoreInitial: true, + atomic: true, + followSymlinks: false, + ignored: (watchPath: string) => this.shouldIgnoreContentWatchPath(watchPath), + awaitWriteFinish: { + stabilityThreshold: WATCH_STABILITY_THRESHOLD_MS, + pollInterval: WATCH_POLL_INTERVAL_MS, + }, + }); + + watcher.on("all", (_eventName, targetPath: string) => { + const runtime = this.watchRuntimes.get(workspacePath); + if (!runtime || runtime.disposed) return; + if (path.basename(path.normalize(targetPath)) === ".git") { + void this.refreshGitWatcher(runtime); + this.scheduleInvalidation(runtime, "full", "watcher"); + return; + } + this.scheduleInvalidation(runtime, "fs", "watcher"); + }); + + watcher.on("error", (error: unknown) => { + console.error(`[DaemonWorkspace] Content watcher error for ${workspacePath}:`, error); + }); + + return watcher; + } + + private shouldIgnoreContentWatchPath(watchPath: string): boolean { + const normalizedPath = path.normalize(watchPath); + if (normalizedPath.includes(`${path.sep}.git${path.sep}`)) return true; + const baseName = path.basename(normalizedPath); + if (WATCH_IGNORED_DIRS.includes(baseName as (typeof WATCH_IGNORED_DIRS)[number])) return true; + return WATCH_IGNORED_DIRS.some((segment) => normalizedPath.includes(`${path.sep}${segment}${path.sep}`)); + } + + private scheduleInvalidation( + runtime: WorkspaceWatchRuntime, + kind: WorkspaceInvalidationKind, + source: WorkspaceInvalidationSource, + ): void { + if (runtime.disposed) return; + if (!runtime.pendingKind || getInvalidationPriority(kind) >= getInvalidationPriority(runtime.pendingKind)) { + runtime.pendingKind = kind; + runtime.pendingSource = source; + } + if (runtime.debounceTimer) clearTimeout(runtime.debounceTimer); + runtime.debounceTimer = setTimeout(() => { + runtime.debounceTimer = null; + const current = this.watchRuntimes.get(runtime.workspacePath); + if (!current || current !== runtime || runtime.disposed) return; + const payload: WorkspaceInvalidationEvent = { + workspacePath: runtime.workspacePath, + kind: runtime.pendingKind ?? kind, + source: runtime.pendingSource ?? source, + }; + runtime.pendingKind = null; + runtime.pendingSource = null; + this.eventPublisher.publish(workspaceInvalidatedEvent.name, { ...payload, version: Date.now() }); + }, WATCH_DEBOUNCE_MS); + } + + private async refreshGitWatcher(runtime: WorkspaceWatchRuntime): Promise { + const metadata = await this.resolveGitWatchMetadata(runtime.workspacePath); + if (runtime.disposed || this.watchRuntimes.get(runtime.workspacePath) !== runtime) return; + const nextWatchKey = metadata ? metadata.paths.join("\0") : null; + if (runtime.gitWatchKey === nextWatchKey) return; + const previous = runtime.gitWatcher; + runtime.gitWatcher = null; + runtime.gitWatchKey = nextWatchKey; + if (previous) await previous.close(); + if (!metadata) return; + const gitWatcher = watch(metadata.paths, { + ignoreInitial: true, + atomic: true, + followSymlinks: false, + awaitWriteFinish: { stabilityThreshold: WATCH_STABILITY_THRESHOLD_MS, pollInterval: WATCH_POLL_INTERVAL_MS }, + }); + gitWatcher.on("all", () => { + const current = this.watchRuntimes.get(runtime.workspacePath); + if (!current || current !== runtime || runtime.disposed) return; + this.scheduleInvalidation(runtime, "git", "watcher"); + }); + gitWatcher.on("error", (error: unknown) => { + console.error(`[DaemonWorkspace] Git watcher error for ${runtime.workspacePath}:`, error); + }); + if (runtime.disposed || this.watchRuntimes.get(runtime.workspacePath) !== runtime) { + await gitWatcher.close(); + return; + } + runtime.gitWatcher = gitWatcher; + } + + private async resolveGitWatchMetadata(workspacePath: string): Promise<{ repoRoot: string; paths: string[] } | null> { + const repoRoot = await this.resolveGitWorkspace(workspacePath); + if (!repoRoot) return null; + const [headPath, indexPath, packedRefsPath, refsPath] = await Promise.all([ + this.resolveGitPath(workspacePath, "HEAD"), + this.resolveGitPath(workspacePath, "index"), + this.resolveGitPath(workspacePath, "packed-refs"), + this.resolveGitPath(workspacePath, "refs"), + ]); + const paths = Array.from( + new Set( + [headPath, indexPath, packedRefsPath, refsPath].filter((value): value is string => typeof value === "string"), + ), + ); + if (paths.length === 0) return null; + return { repoRoot, paths }; + } + + private async resolveGitPath(workspacePath: string, key: string): Promise { + try { + const value = await execGit(workspacePath, ["rev-parse", "--git-path", key]); + const resolved = value?.split(/\r?\n/)[0]?.trim(); + if (!resolved) return null; + return path.isAbsolute(resolved) + ? path.normalize(resolved) + : path.normalize(path.resolve(workspacePath, resolved)); + } catch { + return null; + } + } + + private async disposeRuntime(runtime: WorkspaceWatchRuntime): Promise { + runtime.disposed = true; + if (runtime.debounceTimer) { + clearTimeout(runtime.debounceTimer); + runtime.debounceTimer = null; + } + const closures: Array> = [runtime.contentWatcher.close()]; + if (runtime.gitWatcher) { + closures.push(runtime.gitWatcher.close()); + runtime.gitWatcher = null; + } + await Promise.allSettled(closures); + } + + // ---- directory reads ---- + + async readDirectory(dirPath: string): Promise { + if (!this.isPathAllowed(dirPath)) return []; + return readDirectoryShallow(dirPath); + } + + async expandDirectory(dirPath: string): Promise { + if (!this.isPathAllowed(dirPath)) return []; + return readDirectoryShallow(dirPath); + } + + // ---- file preview ---- + + async readFilePreview(filePath: string): Promise { + if (!this.isPathAllowed(filePath)) return null; + let stats: fs.Stats; + try { + stats = fs.statSync(filePath); + if (!stats.isFile()) return null; + } catch { + return null; + } + + const normalizedPath = this.normalizePathForAccess(filePath); + const workspaceRoot = this.getWorkspaceRootForPath(normalizedPath); + const extension = path.extname(normalizedPath).toLowerCase(); + const mimeType = inferMimeType(normalizedPath); + + // Extension decides only the special preview kinds (markdown/html/pdf/svg/image). + // For everything else (source, config, unknown), sniff the content: a NUL byte + // in the leading bytes means binary; otherwise treat as text — default to text, + // detect binary by content rather than an extension allowlist. + const extensionKind = previewKindFromExtension(extension); + let kind: WorkspaceFilePreviewKind; + let content = ""; + let thumbnail: string | undefined; + + if (extensionKind === undefined) { + const isBinary = await sniffFileBinary(filePath); + kind = isBinary ? "binary" : "text"; + if (kind === "text") { + try { + content = await fsp.readFile(filePath, "utf8"); + } catch { + content = ""; + } + } + } else { + kind = extensionKind; + if (kind === "markdown") { + try { + content = await fsp.readFile(filePath, "utf8"); + } catch { + content = ""; + } + } else if (kind === "image") { + try { + content = (await fsp.readFile(filePath)).toString("base64"); + thumbnail = content; + } catch { + content = ""; + } + } + } + + const metadata: WorkspaceFileMetadata = { + fileName: path.basename(normalizedPath), + fileSize: stats.size, + fileCreated: stats.birthtime, + fileModified: stats.mtime, + }; + + return { + path: normalizedPath, + relativePath: workspaceRoot ? this.toRelativeWorkspacePath(workspaceRoot, normalizedPath) : normalizedPath, + name: path.basename(normalizedPath), + mimeType, + kind, + content, + previewUrl: this.resolvePreviewUrl(normalizedPath, kind, workspaceRoot), + thumbnail, + language: inferLanguage(normalizedPath, kind), + metadata, + }; + } + + private resolvePreviewUrl( + normalizedPath: string, + kind: WorkspaceFilePreviewKind, + _workspaceRoot: string | null, + ): string | undefined { + if (kind !== "html" && kind !== "pdf" && kind !== "svg") return undefined; + return `${this.baseUrl}/api/v1/workspace/preview?path=${encodeURIComponent(normalizedPath)}`; + } + + async resolveMarkdownLinkedFile( + input: ResolveMarkdownLinkedFileInput, + ): Promise { + const resolvedPath = this.resolveMarkdownLinkedPath(input); + if (!resolvedPath) return null; + let stat: fs.Stats; + try { + stat = fs.statSync(resolvedPath); + } catch { + return null; + } + if (!stat.isFile()) return null; + // Authorize the resolved file for subsequent preview/open reads, even when it + // lives outside a registered workspace (e.g. a chat link to another project). + const normalizedPath = this.authorizeExactFile(resolvedPath); + const workspaceRoot = this.getWorkspaceRootForPath(normalizedPath); + return { + path: normalizedPath, + name: path.basename(normalizedPath), + relativePath: workspaceRoot ? this.toRelativeWorkspacePath(workspaceRoot, normalizedPath) : normalizedPath, + workspaceRoot, + }; + } + + private resolveMarkdownLinkedPath(input: ResolveMarkdownLinkedFileInput): string | null { + const rawHref = stripMarkdownLinkDecorators(input.href); + if (!rawHref) return null; + if (rawHref.startsWith("file://")) { + try { + return this.normalizePathForAccess(fileURLToPath(rawHref)); + } catch { + return null; + } + } + if (rawHref.startsWith("/") || /^[a-zA-Z]:[\\/]/.test(rawHref)) { + return this.normalizePathForAccess(rawHref); + } + const sourceFilePath = input.sourceFilePath?.trim() || null; + const workspacePath = input.workspacePath?.trim() || null; + const baseDir = sourceFilePath ? path.dirname(sourceFilePath) : workspacePath ? workspacePath : null; + if (!baseDir) return null; + return this.normalizePathForAccess(path.resolve(baseDir, rawHref)); + } + + // ---- file editing ---- + + async readFileText(filePath: string): Promise<{ content: string | null; exists: boolean }> { + if (!this.isPathAllowed(filePath)) return { content: null, exists: false }; + let stats: fs.Stats; + try { + stats = fs.statSync(filePath); + } catch { + return { content: null, exists: false }; + } + if (!stats.isFile()) return { content: null, exists: true }; + if (stats.size > READ_TEXT_MAX_BYTES) return { content: null, exists: true }; + try { + const buffer = await fsp.readFile(filePath); + if (looksBinary(buffer)) return { content: null, exists: true }; + return { content: buffer.toString("utf8"), exists: true }; + } catch (error) { + console.error(`[DaemonWorkspace] Failed to read file text: ${filePath}`, error); + return { content: null, exists: true }; + } + } + + async writeFile(filePath: string, content: string): Promise { + if (!this.isPathAllowed(filePath)) return; + const normalizedPath = path.resolve(filePath); + await fsp.mkdir(path.dirname(normalizedPath), { recursive: true }); + await fsp.writeFile(normalizedPath, content, "utf8"); + } + + async createEntry(parentDir: string, name: string, isDirectory: boolean): Promise { + if (!isSafeEntryName(name)) throw new Error(`[DaemonWorkspace] Invalid entry name: ${name}`); + if (!this.isPathAllowed(parentDir)) throw new Error(`[DaemonWorkspace] Unauthorized parent: ${parentDir}`); + const targetPath = path.join(path.resolve(parentDir), name); + if (!this.isPathAllowed(targetPath)) throw new Error(`[DaemonWorkspace] Unauthorized entry path: ${targetPath}`); + if (isDirectory) await fsp.mkdir(targetPath, { recursive: false }); + else await fsp.writeFile(targetPath, "", "utf8"); + return targetPath; + } + + async deletePath(targetPath: string): Promise { + if (!this.isPathAllowed(targetPath)) throw new Error(`[DaemonWorkspace] Unauthorized path: ${targetPath}`); + await fsp.rm(path.resolve(targetPath), { recursive: true, force: false }); + } + + async renameOrMovePath(fromPath: string, toPath: string): Promise { + if (!this.isPathAllowed(fromPath)) throw new Error(`[DaemonWorkspace] Unauthorized source: ${fromPath}`); + if (!this.isPathAllowed(toPath)) throw new Error(`[DaemonWorkspace] Unauthorized target: ${toPath}`); + const resolvedTo = path.resolve(toPath); + await fsp.mkdir(path.dirname(resolvedTo), { recursive: true }); + await fsp.rename(path.resolve(fromPath), resolvedTo); + return resolvedTo; + } + + // ---- git ---- + + async getGitStatus(workspacePath: string): Promise { + if (!this.isPathAllowed(workspacePath)) return null; + const repoRoot = await this.resolveGitWorkspace(workspacePath); + if (!repoRoot) return null; + try { + const output = await execGit(workspacePath, ["status", "--porcelain=v1", "--branch"]); + if (output == null) return null; + const lines = output.split(/\r?\n/).filter(Boolean); + const branchLine = lines.find((line) => line.startsWith("##")); + const branchSummary = parseBranchSummary(branchLine ?? ""); + const changes: WorkspaceGitFileChange[] = lines + .filter((line) => !line.startsWith("##")) + .map((line) => { + const stagedStatus = line[0] && line[0] !== " " ? line[0] : null; + const unstagedStatus = line[1] && line[1] !== " " ? line[1] : null; + const rawPath = line.slice(3); + const [previousPathPart, currentPathPart] = rawPath.includes(" -> ") + ? rawPath.split(" -> ") + : [null, rawPath]; + const currentRelativePath = normalizeGitPath(currentPathPart ?? rawPath); + const previousPath = previousPathPart ? normalizeGitPath(previousPathPart) : null; + return { + path: path.resolve(repoRoot, currentRelativePath), + relativePath: currentRelativePath, + previousPath, + stagedStatus, + unstagedStatus, + type: resolveGitChangeType(stagedStatus, unstagedStatus), + }; + }); + return { + workspacePath: repoRoot, + branch: branchSummary.branch, + ahead: branchSummary.ahead, + behind: branchSummary.behind, + changes, + }; + } catch (error) { + console.warn(`[DaemonWorkspace] Failed git status for ${workspacePath}`, error); + return null; + } + } + + async getGitDiff(workspacePath: string, filePath?: string): Promise { + if (!this.isPathAllowed(workspacePath)) return null; + if (filePath && !this.isPathAllowed(filePath)) return null; + const repoRoot = await this.resolveGitWorkspace(workspacePath); + if (!repoRoot) return null; + const relativePath = filePath ? this.toRelativeWorkspacePath(repoRoot, filePath) : null; + const fileArgs = relativePath ? ["--", relativePath] : []; + try { + const [staged, unstaged] = await Promise.all([ + execGit(workspacePath, ["diff", "--cached", "--find-renames", ...fileArgs]), + execGit(workspacePath, ["diff", "--find-renames", ...fileArgs]), + ]); + let resolvedUnstaged = unstaged ?? ""; + if (relativePath && !staged && !resolvedUnstaged) { + const untracked = await execGit(workspacePath, [ + "ls-files", + "--others", + "--exclude-standard", + "--", + relativePath, + ]); + if (untracked && untracked.trim()) { + resolvedUnstaged = await this.runGitDiffNoIndex(workspacePath, relativePath); + } + } else if (!relativePath) { + // Full-workspace diff: `git diff` omits untracked files, so synthesize + // "added" diffs for them so new files show up alongside modified/deleted. + resolvedUnstaged = await this.appendUntrackedDiffs(workspacePath, resolvedUnstaged); + } + return { + workspacePath: repoRoot, + filePath: filePath ? path.resolve(filePath) : null, + relativePath, + staged: staged ?? "", + unstaged: resolvedUnstaged, + }; + } catch (error) { + console.warn(`[DaemonWorkspace] Failed git diff for ${workspacePath}`, error); + return null; + } + } + + private async appendUntrackedDiffs(workspacePath: string, unstagedPatch: string): Promise { + try { + const listing = await execGit(workspacePath, ["ls-files", "--others", "--exclude-standard"]); + const untrackedFiles = (listing ?? "") + .split(/\r?\n/) + .map((value) => value.trim()) + .filter(Boolean) + .slice(0, UNTRACKED_FULL_DIFF_MAX_FILES); + if (untrackedFiles.length === 0) return unstagedPatch; + const diffs = await Promise.all(untrackedFiles.map((file) => this.runGitDiffNoIndex(workspacePath, file))); + const combined = diffs.filter(Boolean).join("\n"); + if (!combined) return unstagedPatch; + return unstagedPatch ? `${unstagedPatch}\n${combined}` : combined; + } catch (error) { + console.warn(`[DaemonWorkspace] Failed to enumerate untracked files for ${workspacePath}`, error); + return unstagedPatch; + } + } + + private async runGitDiffNoIndex(workspacePath: string, relativePath: string): Promise { + const normalize = (raw: string): string => { + let output = raw.trimEnd(); + // `git diff --no-index` does not emit a "new file mode" marker (it compares + // two arbitrary paths, not a repo add). Inject one so diff renderers classify + // the untracked file as added (and show the new-file icon / all-add coloring). + if (output.startsWith("diff --git") && !/^[^\n]*\nnew file mode/m.test(output)) { + output = output.replace(/^(diff --git [^\n]*)/, "$1\nnew file mode 100644"); + } + return output; + }; + try { + const result = await execFileAsync("git", ["diff", "--no-index", "--", "/dev/null", relativePath], { + cwd: workspacePath, + windowsHide: true, + maxBuffer: 8 * 1024 * 1024, + }); + return normalize(result.stdout); + } catch (error) { + if ( + typeof error === "object" && + error !== null && + "code" in error && + (error as { code?: number }).code === 1 && + "stdout" in error && + typeof (error as { stdout?: unknown }).stdout === "string" + ) { + return normalize((error as { stdout: string }).stdout); + } + return ""; + } + } + + private async resolveGitWorkspace(workspacePath: string): Promise { + try { + const repoRoot = await execGit(workspacePath, ["rev-parse", "--show-toplevel"]); + return repoRoot?.split(/\r?\n/)[0]?.trim() || null; + } catch { + return null; + } + } + + // ---- search ---- + + async searchFiles(workspacePath: string, query: string): Promise { + if (!this.isPathAllowed(workspacePath) || !query.trim()) return []; + const results: WorkspaceFileNode[] = []; + const needle = query.toLowerCase(); + await this.collectSearchMatches(path.resolve(workspacePath), needle, "", results, SEARCH_MAX_RESULTS); + return results; + } + + private async collectSearchMatches( + dirPath: string, + needle: string, + relativePrefix: string, + results: WorkspaceFileNode[], + limit: number, + ): Promise { + if (results.length >= limit) return; + let names: string[]; + try { + names = (await fsp.readdir(dirPath)) as string[]; + } catch { + return; + } + for (const name of names) { + if (results.length >= limit) return; + if (name.startsWith(".") || WATCH_IGNORED_DIRS.includes(name as (typeof WATCH_IGNORED_DIRS)[number])) { + continue; + } + const childPath = path.join(dirPath, name); + let isDirectory = false; + try { + isDirectory = (await fsp.stat(childPath)).isDirectory(); + } catch { + continue; + } + const relativePath = relativePrefix ? `${relativePrefix}/${name}` : name; + if (name.toLowerCase().includes(needle)) { + results.push({ name, path: childPath, isDirectory }); + } + if (isDirectory) { + await this.collectSearchMatches(childPath, needle, relativePath, results, limit); + } + } + } + + // ---- desktop-only (not supported on the daemon) ---- + + async revealFileInFolder(): Promise { + throw new Error("workspace.revealFileInFolder is desktop-only"); + } + + async openFile(): Promise { + throw new Error("workspace.openFile is desktop-only"); + } +} + +// ---- pure helpers (ported from the desktop presenter) ---- + +const IGNORED_PATTERNS = [ + "node_modules", + ".git", + ".DS_Store", + "dist", + "build", + "__pycache__", + ".venv", + "venv", + ".idea", + ".vscode", + ".cache", + "coverage", + ".next", + ".nuxt", + "out", + ".turbo", +]; + +async function readDirectoryShallow(dirPath: string): Promise { + try { + // Plain readdir + stat per entry. Bun's `readdir({ withFileTypes })` / + // Dirent helpers are less reliable than Node's, so mirror the daemon's + // existing browseDirectory pattern instead. + const names = (await fsp.readdir(dirPath)) as string[]; + const nodes: WorkspaceFileNode[] = []; + for (const name of names) { + if (IGNORED_PATTERNS.includes(name) || name.startsWith(".")) continue; + const childPath = path.join(dirPath, name); + let isDirectory = false; + try { + isDirectory = (await fsp.stat(childPath)).isDirectory(); + } catch { + continue; + } + nodes.push({ name, path: childPath, isDirectory }); + } + return nodes.sort((a, b) => + a.isDirectory !== b.isDirectory ? (a.isDirectory ? -1 : 1) : a.name.localeCompare(b.name), + ); + } catch (error) { + console.error(`[DaemonWorkspace] Failed to read directory ${dirPath}`, error); + return []; + } +} + +function inferMimeType(filePath: string): string { + const extension = path.extname(filePath).slice(1).toLowerCase(); + return MIME_BY_EXTENSION[extension] ?? "application/octet-stream"; +} + +const IMAGE_EXTENSIONS = new Set([".png", ".jpg", ".jpeg", ".gif", ".webp", ".ico", ".bmp", ".avif"]); + +/** + * Resolve the special preview kinds purely from extension (bespoke rendering: + * markdown, html iframe, pdf, svg, image). Returns `undefined` for everything + * else — text-vs-binary is decided by content sniffing (see sniffFileBinary). + */ +function previewKindFromExtension(extension: string): WorkspaceFilePreviewKind | undefined { + if (extension === ".md" || extension === ".markdown" || extension === ".mdx") return "markdown"; + if (extension === ".html" || extension === ".htm") return "html"; + if (extension === ".pdf") return "pdf"; + if (extension === ".svg") return "svg"; + if (IMAGE_EXTENSIONS.has(extension)) return "image"; + return undefined; +} + +/** + * Detect a binary file by scanning its leading bytes for a NUL byte. Reads only + * the first chunk so large binaries stay cheap. + */ +async function sniffFileBinary(filePath: string): Promise { + let handle: fs.promises.FileHandle | undefined; + try { + handle = await fsp.open(filePath, "r"); + const buffer = Buffer.alloc(BINARY_SNIFF_BYTES); + const { bytesRead } = await handle.read(buffer, 0, BINARY_SNIFF_BYTES, 0); + return looksBinary(buffer.subarray(0, bytesRead)); + } catch { + return true; + } finally { + try { + await handle?.close(); + } catch { + // ignore close errors + } + } +} + +function inferLanguage(filePath: string, kind: WorkspaceFilePreviewKind): string | null { + if (kind === "markdown") return "markdown"; + if (kind === "html") return "html"; + if (kind === "svg") return "svg"; + if (kind !== "text") return null; + return path.extname(filePath).slice(1).toLowerCase() || null; +} + +function looksBinary(buffer: Buffer): boolean { + const scanLength = Math.min(buffer.length, BINARY_SNIFF_BYTES); + for (let index = 0; index < scanLength; index += 1) { + if (buffer[index] === 0) return true; + } + return false; +} + +function isSafeEntryName(name: string): boolean { + const trimmed = name.trim(); + if (!trimmed || trimmed === "." || trimmed === "..") return false; + if (trimmed.includes("/") || trimmed.includes("\\") || trimmed.includes(path.sep) || trimmed.includes("\0")) + return false; + return true; +} + +function stripMarkdownLinkDecorators(href: string): string { + const trimmed = href.trim(); + const queryIndex = trimmed.indexOf("?"); + const hashIndex = trimmed.indexOf("#"); + const firstDecoratorIndex = [queryIndex, hashIndex].filter((index) => index >= 0).sort((a, b) => a - b)[0]; + if (firstDecoratorIndex == null) return trimmed; + return trimmed.slice(0, firstDecoratorIndex); +} + +function normalizeGitPath(value: string): string { + const trimmed = value.trim(); + if (trimmed.startsWith('"') && trimmed.endsWith('"')) { + try { + return JSON.parse(trimmed) as string; + } catch { + return trimmed.slice(1, -1); + } + } + return trimmed; +} + +function resolveGitChangeType(stagedStatus: string | null, unstagedStatus: string | null): WorkspaceGitChangeType { + const status = stagedStatus || unstagedStatus || "?"; + switch (status) { + case "A": + return "added"; + case "D": + return "deleted"; + case "R": + return "renamed"; + case "C": + return "copied"; + case "?": + return "untracked"; + case "!": + return "ignored"; + case "U": + return "unmerged"; + default: + return "modified"; + } +} + +function parseBranchSummary(summary: string): { branch: string | null; ahead: number; behind: number } { + const trimmed = summary.replace(/^##\s*/, "").trim(); + if (!trimmed) return { branch: null, ahead: 0, behind: 0 }; + const branchToken = trimmed.split(" ")[0] || ""; + const branchName = branchToken.split("...")[0]; + const aheadMatch = trimmed.match(/ahead (\d+)/); + const behindMatch = trimmed.match(/behind (\d+)/); + return { + branch: branchName === "HEAD" || branchName === "(no" ? null : branchName, + ahead: aheadMatch ? Number(aheadMatch[1]) : 0, + behind: behindMatch ? Number(behindMatch[1]) : 0, + }; +} diff --git a/apps/desktop/src/main/presenter/workspacePresenter/index.ts b/apps/desktop/src/main/presenter/workspacePresenter/index.ts index 426d3e609..0707dabb7 100644 --- a/apps/desktop/src/main/presenter/workspacePresenter/index.ts +++ b/apps/desktop/src/main/presenter/workspacePresenter/index.ts @@ -69,6 +69,11 @@ const WATCH_DEBOUNCE_MS = 120; const WATCH_STABILITY_THRESHOLD_MS = 250; const WATCH_POLL_INTERVAL_MS = 100; +/** Max file size for `readFileText` (editing). Larger files are not loaded into the editor. */ +const READ_TEXT_MAX_BYTES = 2 * 1024 * 1024; +/** Number of leading bytes scanned for a NUL to detect binary files cheaply. */ +const BINARY_SNIFF_BYTES = 8192; + type WorkspaceWatchRuntime = { workspacePath: string; refCount: number; @@ -938,4 +943,143 @@ export class WorkspacePresenter implements IWorkspacePresenter { } return await searchWorkspaceFiles(workspacePath, query); } + + async readFileText(filePath: string): Promise<{ content: string | null; exists: boolean }> { + if (!this.isPathAllowed(filePath)) { + console.warn(`[Workspace] Blocked read-text attempt for unauthorized path: ${filePath}`); + return { content: null, exists: false }; + } + + let stats: fs.Stats; + try { + stats = fs.statSync(filePath); + } catch { + return { content: null, exists: false }; + } + + if (!stats.isFile()) { + return { content: null, exists: true }; + } + + if (stats.size > READ_TEXT_MAX_BYTES) { + return { content: null, exists: true }; + } + + try { + const buffer = await fs.promises.readFile(filePath); + if (this.looksBinary(buffer)) { + return { content: null, exists: true }; + } + return { content: buffer.toString("utf8"), exists: true }; + } catch (error) { + console.error(`[Workspace] Failed to read file text: ${filePath}`, error); + return { content: null, exists: true }; + } + } + + async writeFile(filePath: string, content: string): Promise { + if (!this.isPathAllowed(filePath)) { + console.warn(`[Workspace] Blocked write attempt for unauthorized path: ${filePath}`); + return; + } + + const normalizedPath = path.resolve(filePath); + try { + await fs.promises.mkdir(path.dirname(normalizedPath), { recursive: true }); + await fs.promises.writeFile(normalizedPath, content, "utf8"); + } catch (error) { + console.error(`[Workspace] Failed to write file: ${normalizedPath}`, error); + throw error; + } + } + + async createEntry(parentDir: string, name: string, isDirectory: boolean): Promise { + if (!this.isSafeEntryName(name)) { + throw new Error(`[Workspace] Invalid entry name: ${name}`); + } + + if (!this.isPathAllowed(parentDir)) { + console.warn(`[Workspace] Blocked create attempt for unauthorized parent: ${parentDir}`); + throw new Error(`[Workspace] Unauthorized parent directory: ${parentDir}`); + } + + const resolvedParent = path.resolve(parentDir); + const targetPath = path.join(resolvedParent, name); + if (!this.isPathAllowed(targetPath)) { + throw new Error(`[Workspace] Resolved entry path is not allowed: ${targetPath}`); + } + + try { + if (isDirectory) { + await fs.promises.mkdir(targetPath, { recursive: false }); + } else { + await fs.promises.writeFile(targetPath, "", "utf8"); + } + return targetPath; + } catch (error) { + console.error(`[Workspace] Failed to create entry: ${targetPath}`, error); + throw error; + } + } + + async deletePath(targetPath: string): Promise { + if (!this.isPathAllowed(targetPath)) { + console.warn(`[Workspace] Blocked delete attempt for unauthorized path: ${targetPath}`); + throw new Error(`[Workspace] Unauthorized path: ${targetPath}`); + } + + const normalizedPath = path.resolve(targetPath); + try { + await fs.promises.rm(normalizedPath, { recursive: true, force: false }); + } catch (error) { + console.error(`[Workspace] Failed to delete path: ${normalizedPath}`, error); + throw error; + } + } + + async renameOrMovePath(fromPath: string, toPath: string): Promise { + if (!this.isPathAllowed(fromPath)) { + console.warn(`[Workspace] Blocked rename source attempt for unauthorized path: ${fromPath}`); + throw new Error(`[Workspace] Unauthorized source path: ${fromPath}`); + } + if (!this.isPathAllowed(toPath)) { + console.warn(`[Workspace] Blocked rename target attempt for unauthorized path: ${toPath}`); + throw new Error(`[Workspace] Unauthorized target path: ${toPath}`); + } + + const resolvedFrom = path.resolve(fromPath); + const resolvedTo = path.resolve(toPath); + try { + await fs.promises.mkdir(path.dirname(resolvedTo), { recursive: true }); + await fs.promises.rename(resolvedFrom, resolvedTo); + return resolvedTo; + } catch (error) { + console.error(`[Workspace] Failed to rename/move ${resolvedFrom} -> ${resolvedTo}`, error); + throw error; + } + } + + private looksBinary(buffer: Buffer): boolean { + const scanLength = Math.min(buffer.length, BINARY_SNIFF_BYTES); + for (let index = 0; index < scanLength; index += 1) { + if (buffer[index] === 0) { + return true; + } + } + return false; + } + + private isSafeEntryName(name: string): boolean { + const trimmed = name.trim(); + if (!trimmed || trimmed === "." || trimmed === "..") { + return false; + } + if (trimmed.includes("/") || trimmed.includes("\\") || trimmed.includes(path.sep)) { + return false; + } + if (trimmed.includes("\0")) { + return false; + } + return true; + } } diff --git a/apps/desktop/src/main/routes/index.ts b/apps/desktop/src/main/routes/index.ts index 41a1ce4a9..44e9066d5 100644 --- a/apps/desktop/src/main/routes/index.ts +++ b/apps/desktop/src/main/routes/index.ts @@ -239,13 +239,18 @@ import { workspaceOpenFileRoute, workspaceReadDirectoryRoute, workspaceReadFilePreviewRoute, + workspaceReadFileTextRoute, workspaceRegisterRoute, + workspaceRenameOrMovePathRoute, workspaceResolveMarkdownLinkedFileRoute, workspaceRevealFileInFolderRoute, workspaceSearchFilesRoute, workspaceUnregisterRoute, workspaceUnwatchRoute, workspaceWatchRoute, + workspaceWriteFileRoute, + workspaceCreateEntryRoute, + workspaceDeletePathRoute, type SettingsActivityInput, } from "@argos/shared-contracts/routes"; import { ChatService } from "./chat/chatService"; @@ -1304,6 +1309,38 @@ export async function dispatchArgosRoute( }); } + case workspaceReadFileTextRoute.name: { + const input = workspaceReadFileTextRoute.input.parse(rawInput); + const result = await runtime.workspacePresenter.readFileText(input.path); + return workspaceReadFileTextRoute.output.parse(result); + } + + case workspaceWriteFileRoute.name: { + const input = workspaceWriteFileRoute.input.parse(rawInput); + await runtime.workspacePresenter.writeFile(input.path, input.content); + return workspaceWriteFileRoute.output.parse({ written: true }); + } + + case workspaceCreateEntryRoute.name: { + const input = workspaceCreateEntryRoute.input.parse(rawInput); + return workspaceCreateEntryRoute.output.parse({ + path: await runtime.workspacePresenter.createEntry(input.parentDir, input.name, input.isDirectory), + }); + } + + case workspaceDeletePathRoute.name: { + const input = workspaceDeletePathRoute.input.parse(rawInput); + await runtime.workspacePresenter.deletePath(input.path); + return workspaceDeletePathRoute.output.parse({ deleted: true }); + } + + case workspaceRenameOrMovePathRoute.name: { + const input = workspaceRenameOrMovePathRoute.input.parse(rawInput); + return workspaceRenameOrMovePathRoute.output.parse({ + path: await runtime.workspacePresenter.renameOrMovePath(input.fromPath, input.toPath), + }); + } + case browserGetStatusRoute.name: { const input = browserGetStatusRoute.input.parse(rawInput); return browserGetStatusRoute.output.parse({ diff --git a/apps/desktop/test/main/presenter/workspacePresenter.test.ts b/apps/desktop/test/main/presenter/workspacePresenter.test.ts index c7b9ec41d..b315b5b46 100644 --- a/apps/desktop/test/main/presenter/workspacePresenter.test.ts +++ b/apps/desktop/test/main/presenter/workspacePresenter.test.ts @@ -587,3 +587,117 @@ describe("workspacePreviewProtocol helpers", () => { expect(resolveWorkspacePreviewRequest(previewUrl)).toBeNull(); }); }); + +describe("WorkspacePresenter file editing", () => { + let workspacePath: string; + let outsidePath: string; + let presenter: WorkspacePresenter; + + beforeEach(() => { + workspacePath = fs.mkdtempSync(path.join(os.tmpdir(), "argos-workspace-edit-")); + outsidePath = fs.mkdtempSync(path.join(os.tmpdir(), "argos-outside-edit-")); + presenter = new WorkspacePresenter({ + prepareFileCompletely: vi.fn<(...args: any[]) => any>(), + } as any); + }); + + afterEach(async () => { + presenter?.destroy(); + fs.rmSync(workspacePath, { recursive: true, force: true }); + fs.rmSync(outsidePath, { recursive: true, force: true }); + }); + + it("reads text file content via readFileText", async () => { + await presenter.registerWorkspace(workspacePath); + const file = path.join(workspacePath, "note.txt"); + fs.writeFileSync(file, "hello world"); + + const result = await presenter.readFileText(file); + expect(result).toEqual({ content: "hello world", exists: true }); + }); + + it("returns null content for binary files", async () => { + await presenter.registerWorkspace(workspacePath); + const file = path.join(workspacePath, "blob.bin"); + fs.writeFileSync(file, Buffer.from([0, 1, 2, 0, 3])); + + const result = await presenter.readFileText(file); + expect(result.exists).toBe(true); + expect(result.content).toBeNull(); + }); + + it("returns exists:false for missing files", async () => { + await presenter.registerWorkspace(workspacePath); + const result = await presenter.readFileText(path.join(workspacePath, "missing.txt")); + expect(result).toEqual({ content: null, exists: false }); + }); + + it("writes file content via writeFile", async () => { + await presenter.registerWorkspace(workspacePath); + const file = path.join(workspacePath, "out.txt"); + + await presenter.writeFile(file, "written"); + expect(fs.readFileSync(file, "utf8")).toBe("written"); + }); + + it("does not write outside an allowed workspace", async () => { + const file = path.join(outsidePath, "nope.txt"); + await presenter.writeFile(file, "x"); + expect(fs.existsSync(file)).toBe(false); + }); + + it("creates a file and a directory via createEntry", async () => { + await presenter.registerWorkspace(workspacePath); + const filePath = await presenter.createEntry(workspacePath, "new.txt", false); + const dirPath = await presenter.createEntry(workspacePath, "sub", true); + + expect(fs.existsSync(filePath)).toBe(true); + expect(fs.statSync(dirPath).isDirectory()).toBe(true); + }); + + it("rejects path traversal names in createEntry", async () => { + await presenter.registerWorkspace(workspacePath); + await expect(presenter.createEntry(workspacePath, "../escape", false)).rejects.toThrow(); + }); + + it("deletes files and directories via deletePath", async () => { + await presenter.registerWorkspace(workspacePath); + const file = path.join(workspacePath, "doomed.txt"); + const dir = path.join(workspacePath, "folder"); + fs.writeFileSync(file, "x"); + fs.mkdirSync(path.join(dir, "nested"), { recursive: true }); + + await presenter.deletePath(file); + await presenter.deletePath(dir); + expect(fs.existsSync(file)).toBe(false); + expect(fs.existsSync(dir)).toBe(false); + }); + + it("refuses to delete paths outside the workspace", async () => { + await presenter.registerWorkspace(workspacePath); + await expect(presenter.deletePath(outsidePath)).rejects.toThrow(); + expect(fs.existsSync(outsidePath)).toBe(true); + }); + + it("renames and moves files via renameOrMovePath", async () => { + await presenter.registerWorkspace(workspacePath); + const from = path.join(workspacePath, "a.txt"); + const to = path.join(workspacePath, "b.txt"); + fs.writeFileSync(from, "data"); + + const resolved = await presenter.renameOrMovePath(from, to); + expect(resolved).toBe(path.resolve(to)); + expect(fs.existsSync(from)).toBe(false); + expect(fs.readFileSync(to, "utf8")).toBe("data"); + }); + + it("refuses to move into a path outside the workspace", async () => { + await presenter.registerWorkspace(workspacePath); + const from = path.join(workspacePath, "a.txt"); + const to = path.join(outsidePath, "a.txt"); + fs.writeFileSync(from, "data"); + + await expect(presenter.renameOrMovePath(from, to)).rejects.toThrow(); + expect(fs.existsSync(from)).toBe(true); + }); +}); diff --git a/apps/landing/src/components/Spotlight.tsx b/apps/landing/src/components/Spotlight.tsx index 9973fca51..069c06042 100644 --- a/apps/landing/src/components/Spotlight.tsx +++ b/apps/landing/src/components/Spotlight.tsx @@ -3,7 +3,7 @@ import { Reveal } from "~/components/Reveal"; const DETAILS = [ { icon: Article, label: "Markdown & rich rendering" }, - { icon: Code, label: "CodeMirror code blocks" }, + { icon: Code, label: "Syntax-highlighted code" }, { icon: PaintBrush, label: "Mermaid & Artifacts" }, { icon: Browsers, label: "Multi-window & tabs" }, { icon: ArrowsClockwise, label: "Retry & regenerate" }, diff --git a/bun.lock b/bun.lock index 87c1ad8e8..d75446883 100644 --- a/bun.lock +++ b/bun.lock @@ -48,6 +48,7 @@ "@duckdb/node-api": "1.5.5-r.3", "@earendil-works/pi-coding-agent": "0.83.0", "ai": "catalog:", + "chokidar": "^5.0.0", "fflate": "catalog:", "gray-matter": "^4.0.3", "nanoid": "catalog:", @@ -336,6 +337,8 @@ "@iconify-json/lucide": "^1.2.121", "@iconify-json/vscode-icons": "^1.2.68", "@iconify/react": "^6.0.2", + "@pierre/diffs": "^1.3.5", + "@pierre/trees": "^1.0.0-beta.6", "@tanstack/highlight": "^0.0.9", "@tanstack/react-query": "catalog:", "@tanstack/react-router": "catalog:", @@ -363,7 +366,6 @@ "@dnd-kit/core": "^6.3.1", "@dnd-kit/sortable": "^10.0.0", "@dnd-kit/utilities": "^3.2.2", - "@dvaji/vite-plugin-monaco-editor": "^2.0.0", "@electron-toolkit/tsconfig": "^2.0.0", "@rolldown/plugin-babel": "^0.2.3", "@tailwindcss/typography": "^0.5.20", @@ -387,8 +389,6 @@ "babel-plugin-react-compiler": "^1.0.0", "jsdom": "catalog:", "mermaid": "^11.16.0", - "monaco-editor": "^0.55.1", - "stream-monaco": "^0.0.49", "tailwind-scrollbar-hide": "^4.0.0", "tailwindcss": "catalog:", "tailwindcss-animate": "^1.0.7", @@ -683,8 +683,6 @@ "@duckdb/node-bindings-win32-x64": ["@duckdb/node-bindings-win32-x64@1.5.5-r.3", "", { "os": "win32", "cpu": "x64" }, "sha512-NTODIfgfKARm86kOgSW5CvFElE3J+3X0Y0+3Y40P3biO4wrg4FBGtNSCaVyKuWe+CBkA5PODjgIo3w07hu5qrQ=="], - "@dvaji/vite-plugin-monaco-editor": ["@dvaji/vite-plugin-monaco-editor@2.0.0", "", { "dependencies": { "rolldown": "^1.1.2" }, "peerDependencies": { "monaco-editor": ">=0.33.0" } }, "sha512-41Pib2alAkFWHEF2BgqF1Gllr53DDeLOwwfuECtyba9Prizrhd79tZtoqD+LU1+2S/im3tbDZ185VrJ5IVralQ=="], - "@earendil-works/pi-agent-core": ["@earendil-works/pi-agent-core@0.83.0", "", { "dependencies": { "@earendil-works/pi-ai": "^0.83.0", "diff": "8.0.4", "ignore": "7.0.5", "typebox": "1.3.7", "yaml": "2.9.0" } }, "sha512-RorGp9OH5l3ElpuC5a5ZQ2eWcchZGXflXRzVGkV99y3y6tT+LLNyxoYIdVKvTKWEObwhExeQbTH0fI2tE4iX4g=="], "@earendil-works/pi-ai": ["@earendil-works/pi-ai@0.83.0", "", { "dependencies": { "@anthropic-ai/sdk": "0.91.1", "@aws-sdk/client-bedrock-runtime": "3.1048.0", "@google/genai": "1.52.0", "@mistralai/mistralai": "2.2.6", "@opentelemetry/api": "1.9.0", "@smithy/node-http-handler": "4.7.3", "http-proxy-agent": "7.0.2", "https-proxy-agent": "7.0.6", "openai": "6.26.0", "partial-json": "0.1.7", "typebox": "1.3.7" }, "bin": { "pi-ai": "dist/cli.js" } }, "sha512-m3IZD4g3er0V8TC9+Vpgw/sjTKqcJlkcIBy/JvsgRubuuik3tAVzyugUg4rVrShIkkOT69mEd34NEqKUIsl6JQ=="], @@ -1093,6 +1091,14 @@ "@phosphor-icons/react": ["@phosphor-icons/react@2.1.10", "", { "peerDependencies": { "react": ">= 16.8", "react-dom": ">= 16.8" } }, "sha512-vt8Tvq8GLjheAZZYa+YG/pW7HDbov8El/MANW8pOAz4eGxrwhnbfrQZq0Cp4q8zBEu8NIhHdnr+r8thnfRSNYA=="], + "@pierre/diffs": ["@pierre/diffs@1.3.5", "", { "dependencies": { "@pierre/theme": "2.0.0", "@pierre/theming": "1.0.1", "@shikijs/transformers": "^3.0.0 || ^4.0.0", "diff": "9.0.0", "hast-util-to-html": "9.0.5", "lru_map": "0.4.1", "shiki": "^3.0.0 || ^4.0.0" }, "peerDependencies": { "react": "^18.3.1 || ^19.0.0", "react-dom": "^18.3.1 || ^19.0.0" } }, "sha512-BhaLEiUvR+BdIyOYdogA4JLQjluWPubuwySmmIqEkcE0FwIWRbgJgHNC/r884dlxEr89fO4hTk/sBln0a7NSOw=="], + + "@pierre/theme": ["@pierre/theme@2.0.0", "", {}, "sha512-yNDd9GYLQl1mEUJR8AneJ5e4ohLIHQd/wZLWr4fagt78vS2RwwZNW530vVgHqXFAyFVcFlRmGUD5ramXH46OXw=="], + + "@pierre/theming": ["@pierre/theming@1.0.1", "", { "peerDependencies": { "@pierre/theme": "^1.1.0 || ^2.0.0", "@shikijs/themes": "^3.0.0 || ^4.0.0", "react": "^18.3.1 || ^19.0.0", "react-dom": "^18.3.1 || ^19.0.0", "shiki": "^3.0.0 || ^4.0.0" }, "optionalPeers": ["@pierre/theme", "@shikijs/themes", "react", "react-dom", "shiki"] }, "sha512-WCI5Qd7iprDpISL9fBYOLe8RV53+b7mFNA3bPzl60/2CKCSrsKN8zEcep6Y3BAzvARlmca50zGjDodqPGiTUKA=="], + + "@pierre/trees": ["@pierre/trees@1.0.0-beta.6", "", { "dependencies": { "@pierre/theming": "1.0.0", "preact": "11.0.0-beta.0", "preact-render-to-string": "6.6.5" }, "peerDependencies": { "react": "^18.3.1 || ^19.0.0", "react-dom": "^18.3.1 || ^19.0.0" } }, "sha512-zxeuSFM9TveM7b5XofweJALCtm/tGYV9HZzdbf7Uf+kBxIlUyz24/EHaGRjB0dsmmfDQl2ETz7AWwJ15lhSnpw=="], + "@playwright/test": ["@playwright/test@1.62.1", "", { "dependencies": { "playwright": "1.62.1" }, "bin": { "playwright": "cli.js" } }, "sha512-DTcUc8qii+cpHvtOwggMtBRMjKZHXYWdw8syRYu2vtzuq4Wxphqq4NfCs5Zt44L6mA8rfDfj+PHnxFc/FeK6mQ=="], "@polka/url": ["@polka/url@1.0.0-next.29", "", {}, "sha512-wwQAWhWSuHaag8c4q/KN/vCoeOJYshAIvMQwD4GpSb3OiZklFfvAgmj0VCBBImRpuF/aFgIRzllXlVX93Jevww=="], @@ -1277,7 +1283,7 @@ "@rolldown/pluginutils": ["@rolldown/pluginutils@1.0.1", "", {}, "sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw=="], - "@shikijs/core": ["@shikijs/core@3.23.0", "", { "dependencies": { "@shikijs/types": "3.23.0", "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4", "hast-util-to-html": "^9.0.5" } }, "sha512-NSWQz0riNb67xthdm5br6lAkvpDJRTgB36fxlo37ZzM2yq0PQFFzbd8psqC2XMPgCzo1fW6cVi18+ArJ44wqgA=="], + "@shikijs/core": ["@shikijs/core@4.4.2", "", { "dependencies": { "@shikijs/primitive": "4.4.2", "@shikijs/types": "4.4.2", "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.5", "hast-util-to-html": "^9.0.5" } }, "sha512-StyzbAyxg2/tBGf78gwbBkGyeQ73lf8UiJArFaQhTQIDqQOCKPCQFanvrs4/Yv3Yfyc+ONInJM6K+FMIf+P+kA=="], "@shikijs/engine-javascript": ["@shikijs/engine-javascript@3.23.0", "", { "dependencies": { "@shikijs/types": "3.23.0", "@shikijs/vscode-textmate": "^10.0.2", "oniguruma-to-es": "^4.3.4" } }, "sha512-aHt9eiGFobmWR5uqJUViySI1bHMqrAgamWE1TYSUoftkAeCCAiGawPMwM+VCadylQtF4V3VNOZ5LmfItH5f3yA=="], @@ -1285,11 +1291,13 @@ "@shikijs/langs": ["@shikijs/langs@3.23.0", "", { "dependencies": { "@shikijs/types": "3.23.0" } }, "sha512-2Ep4W3Re5aB1/62RSYQInK9mM3HsLeB91cHqznAJMuylqjzNVAVCMnNWRHFtcNHXsoNRayP9z1qj4Sq3nMqYXg=="], - "@shikijs/monaco": ["@shikijs/monaco@3.23.0", "", { "dependencies": { "@shikijs/core": "3.23.0", "@shikijs/types": "3.23.0", "@shikijs/vscode-textmate": "^10.0.2" } }, "sha512-OCApTdAGTHMFUXSYwGztW6EnlxXsWNrpnGf+uO+AznE+khC6V1/8QjuJESIcvZUIq9iAp4ZCNYosZKSVj1Hctg=="], + "@shikijs/primitive": ["@shikijs/primitive@4.4.2", "", { "dependencies": { "@shikijs/types": "4.4.2", "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.5" } }, "sha512-l6fQQKsOMlz72n38fztmSgZ76MO6KSWuw8o+GJ+FhmqrpC9pIOJNQNXGgbb5yX2AwpzlEHwsaLPnk/8o4Fm+rA=="], "@shikijs/themes": ["@shikijs/themes@3.23.0", "", { "dependencies": { "@shikijs/types": "3.23.0" } }, "sha512-5qySYa1ZgAT18HR/ypENL9cUSGOeI2x+4IvYJu4JgVJdizn6kG4ia5Q1jDEOi7gTbN4RbuYtmHh0W3eccOrjMA=="], - "@shikijs/types": ["@shikijs/types@3.23.0", "", { "dependencies": { "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4" } }, "sha512-3JZ5HXOZfYjsYSk0yPwBrkupyYSLpAE26Qc0HLghhZNGTZg/SKxXIIgoxOpmmeQP0RRSDJTk1/vPfw9tbw+jSQ=="], + "@shikijs/transformers": ["@shikijs/transformers@4.4.2", "", { "dependencies": { "@shikijs/core": "4.4.2", "@shikijs/types": "4.4.2" } }, "sha512-d81PJ9KkR1tVP95FH/9296HTtDo0mh76wv10u9T1YmsZq/UcXgt0OLdBszfUQ1i+umkRMCjDnFbFZU7/tCODTQ=="], + + "@shikijs/types": ["@shikijs/types@4.4.2", "", { "dependencies": { "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.5" } }, "sha512-PFYitV4vpDr/iPCIhnHp+Q4ftic5N5VeNJ3KQ1O8gn3h2ar8qgwMAXF7tq4m1CWaMS60fV4VqF6vfnWH4F7vqQ=="], "@shikijs/vscode-textmate": ["@shikijs/vscode-textmate@10.0.2", "", {}, "sha512-83yeghZ2xxin3Nj8z1NMd/NCuca+gsYXswywDy5bHvwlWL8tpTQmzGeUuHd9FC3E/SBEMvzJRwWEOz5gGes9Qg=="], @@ -1631,8 +1639,6 @@ "ajv-formats": ["ajv-formats@3.0.1", "", { "dependencies": { "ajv": "^8.0.0" } }, "sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ=="], - "alien-signals": ["alien-signals@2.0.8", "", {}, "sha512-844G1VLkk0Pe2SJjY0J8vp8ADI73IM4KliNu2OGlYzWpO28NexEUvjHTcFjFX3VXoiUtwTbHxLNI9ImkcoBqzA=="], - "ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="], "ansi-styles": ["ansi-styles@4.3.0", "", { "dependencies": { "color-convert": "^2.0.1" } }, "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg=="], @@ -2375,6 +2381,8 @@ "lru-cache": ["lru-cache@11.5.2", "", {}, "sha512-4pfM1Ff0x50o0tQwb5ucw/RzNyD0/YJME6IVcStalZuMWxdt3sR3huStTtxz4PUmvZfRguvDejasvQ2kifR11g=="], + "lru_map": ["lru_map@0.4.1", "", {}, "sha512-I+lBvqMMFfqaV8CJCISjI3wbjmwVu/VyOoU7+qtu9d7ioW5klMgsTTiUOUp+DJvfTTzKXoPbyC6YfgkNcyPSOg=="], + "lucide-react": ["lucide-react@1.28.0", "", { "peerDependencies": { "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-fARAFJULsGuDDydjp6+6blekG/sBIM29TerzLjc9bQUKAcEfrSc4ZQKb25KRz4OMKd87cZTb5dgq0w/T6KufVg=="], "macos-version": ["macos-version@5.2.1", "", { "dependencies": { "semver": "^5.6.0" } }, "sha512-OHJU8nTNxHYL1FQhD+nZawWgXKXAqDGr4kluLtaqKO4au3cR41y1mKuVShOU5U4rOYiuPanljq6oFGmV2B9DFA=="], @@ -2523,8 +2531,6 @@ "module-error": ["module-error@1.0.2", "", {}, "sha512-0yuvsqSCv8LbaOKhnsQ/T5JhyFlCYLPXK3U2sgV10zoKQwzs/MyfuQUOZQ1V/6OCOJsK/TRgNVrPuPDqtdMFtA=="], - "monaco-editor": ["monaco-editor@0.55.1", "", { "dependencies": { "dompurify": "3.2.7", "marked": "14.0.0" } }, "sha512-jz4x+TJNFHwHtwuV9vA9rMujcZRb0CEilTEwG2rRSpe/A7Jdkuj8xPKttCgOh+v/lkHy7HsZ64oj+q3xoAFl9A=="], - "mrmime": ["mrmime@2.0.1", "", {}, "sha512-Y3wQdFg2Va6etvQ5I82yUhGdsKrcYox6p7FfL1LbK2J4V01F9TGlepTIhnK24t7koZibmg82KGglhA1XK5IsLQ=="], "ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="], @@ -2657,6 +2663,10 @@ "postject": ["postject@1.0.0-alpha.6", "", { "dependencies": { "commander": "^9.4.0" }, "bin": { "postject": "dist/cli.js" } }, "sha512-b9Eb8h2eVqNE8edvKdwqkrY6O7kAwmI8kcnBv1NScolYJbo59XUF0noFq+lxbC1yN20bmC0WBEbDC5H/7ASb0A=="], + "preact": ["preact@11.0.0-beta.0", "", {}, "sha512-IcODoASASYwJ9kxz7+MJeiJhvLriwSb4y4mHIyxdgaRZp6kPUud7xytrk/6GZw8U3y6EFJaRb5wi9SrEK+8+lg=="], + + "preact-render-to-string": ["preact-render-to-string@6.6.5", "", { "peerDependencies": { "preact": ">=10 || >= 11.0.0-0" } }, "sha512-O6MHzYNIKYaiSX3bOw0gGZfEbOmlIDtDfWwN1JJdc/T3ihzRT6tGGSEWE088dWrEDGa1u7101q+6fzQnO9XCPA=="], + "prettier": ["prettier@3.9.5", "", { "bin": { "prettier": "bin/prettier.cjs" } }, "sha512-/FVl766LpUfB5vXgCYOYa0MeV/441Ia99AeICQIQFTY/Nw0roZwULcXpku5i1/m5kt/baz+s4Zogspd839HSMg=="], "proc-log": ["proc-log@6.1.0", "", {}, "sha512-iG+GYldRf2BQ0UDUAd6JQ/RwzaQy6mXmsk/IzlYyal4A4SNFw54MeH4/tLkF4I5WoWG9SQwuqWzS99jaFQHBuQ=="], @@ -2881,8 +2891,6 @@ "std-env": ["std-env@4.2.0", "", {}, "sha512-oCUKSupKTHX53EyjDtuZQ64pjLJ6yYCtpmEw0goYxtjG9KpbRe8KAsl2tBUGU9DyMcJ0RwJ8GqJAFzMXcXW1Rw=="], - "stream-monaco": ["stream-monaco@0.0.49", "", { "dependencies": { "@shikijs/monaco": "^3.23.0", "alien-signals": "^2.0.8", "shiki": "^3.23.0" }, "peerDependencies": { "monaco-editor": ">=0.52.2 <0.56.0" }, "bin": { "run": "cli.mjs" } }, "sha512-ksZYJXw45NralnpgWqcptqrgDdbGZaTyYmvATvhlK8eY0tyeTxs0iuQzMTF4cpGf5OlIgkNJOfbFh4M97OozSw=="], - "string-width": ["string-width@4.2.3", "", { "dependencies": { "emoji-regex": "^8.0.0", "is-fullwidth-code-point": "^3.0.0", "strip-ansi": "^6.0.1" } }, "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g=="], "string_decoder": ["string_decoder@1.1.1", "", { "dependencies": { "safe-buffer": "~5.1.0" } }, "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg=="], @@ -3191,8 +3199,6 @@ "@babel/traverse/@babel/code-frame": ["@babel/code-frame@7.29.7", "", { "dependencies": { "@babel/helper-validator-identifier": "^7.29.7", "js-tokens": "^4.0.0", "picocolors": "^1.1.1" } }, "sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw=="], - "@dvaji/vite-plugin-monaco-editor/rolldown": ["rolldown@1.1.5", "", { "dependencies": { "@oxc-project/types": "=0.139.0", "@rolldown/pluginutils": "^1.0.0" }, "optionalDependencies": { "@rolldown/binding-android-arm64": "1.1.5", "@rolldown/binding-darwin-arm64": "1.1.5", "@rolldown/binding-darwin-x64": "1.1.5", "@rolldown/binding-freebsd-x64": "1.1.5", "@rolldown/binding-linux-arm-gnueabihf": "1.1.5", "@rolldown/binding-linux-arm64-gnu": "1.1.5", "@rolldown/binding-linux-arm64-musl": "1.1.5", "@rolldown/binding-linux-ppc64-gnu": "1.1.5", "@rolldown/binding-linux-s390x-gnu": "1.1.5", "@rolldown/binding-linux-x64-gnu": "1.1.5", "@rolldown/binding-linux-x64-musl": "1.1.5", "@rolldown/binding-openharmony-arm64": "1.1.5", "@rolldown/binding-wasm32-wasi": "1.1.5", "@rolldown/binding-win32-arm64-msvc": "1.1.5", "@rolldown/binding-win32-x64-msvc": "1.1.5" }, "bin": { "rolldown": "./bin/cli.mjs" } }, "sha512-t9z29cJjXf/vxQ8dyhCSpt6H6aSwHTk8cT5I3iy6SMXuFpk5mB6PL6XfC8PCwrPTx93udwKUm9HRteAlTGBLiA=="], - "@earendil-works/pi-agent-core/diff": ["diff@8.0.4", "", {}, "sha512-DPi0FmjiSU5EvQV0++GFDOJ9ASQUVFh5kD+OzOnYdi7n3Wpm9hWWGfB/O2blfHcMVTL5WkQXSnRiK9makhrcnw=="], "@earendil-works/pi-agent-core/typebox": ["typebox@1.3.7", "", {}, "sha512-meKuifc33Pccx0O6PdIzYMq3Og8zvP4TIi/a+Bw3AEMZMxOD0+RHGQvpglEe6Zdy3wZ8nqn/j95h8LUZLk/6Hg=="], @@ -3247,6 +3253,8 @@ "@mistralai/mistralai/ws": ["ws@8.21.1", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-+0NTnW77fFN/DjQi6k/Sq/Yvk4Sgajw7urW8V+asjXnRgDs9gyGkdb7EzgfhA4goXsRIZKE28fzIXBHEzhuiWw=="], + "@pierre/trees/@pierre/theming": ["@pierre/theming@1.0.0", "", { "peerDependencies": { "@pierre/theme": "^1.1.0", "@shikijs/themes": "^3.0.0 || ^4.0.0", "react": "^18.3.1 || ^19.0.0", "react-dom": "^18.3.1 || ^19.0.0", "shiki": "^3.0.0 || ^4.0.0" }, "optionalPeers": ["@pierre/theme", "@shikijs/themes", "react", "react-dom", "shiki"] }, "sha512-WsdrnhKfjeyXGDikZmN9pkpeZ5S/cl6EE72feiSc0tlynT1tMYqXqouhuv/foK+PY9OEnebOAVRQn3+rAstR8g=="], + "@poppinss/dumper/@sindresorhus/is": ["@sindresorhus/is@7.2.0", "", {}, "sha512-P1Cz1dWaFfR4IR+U13mqqiGsLFf1KbayybWwdd2vfctdV6hDpUkgCY0nKOLLTMSoRd/jJNjtbqzf13K8DCCXQw=="], "@poppinss/dumper/supports-color": ["supports-color@10.2.2", "", {}, "sha512-SS+jx45GF1QjgEXQx4NJZV9ImqmO2NPz5FNsIHrsDjh2YsHnawpan7SNQ1o8NuhrbHZy9AZhIoCUiCeaW/C80g=="], @@ -3257,6 +3265,14 @@ "@rolldown/binding-wasm32-wasi/@napi-rs/wasm-runtime": ["@napi-rs/wasm-runtime@1.2.2", "", { "dependencies": { "@tybys/wasm-util": "^0.10.3" }, "peerDependencies": { "@emnapi/core": "^1.7.1 || ^2.0.0-alpha.3", "@emnapi/runtime": "^1.7.1 || ^2.0.0-alpha.3" } }, "sha512-JfB4kuJQjaoHuCTseIINHtHWeJnvgEcxjwA5t/Y00ZgaOO1Crz3fjT/p8kT28zA/Caz7oiUMn3d6H2yOVCVwuw=="], + "@shikijs/engine-javascript/@shikijs/types": ["@shikijs/types@3.23.0", "", { "dependencies": { "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4" } }, "sha512-3JZ5HXOZfYjsYSk0yPwBrkupyYSLpAE26Qc0HLghhZNGTZg/SKxXIIgoxOpmmeQP0RRSDJTk1/vPfw9tbw+jSQ=="], + + "@shikijs/engine-oniguruma/@shikijs/types": ["@shikijs/types@3.23.0", "", { "dependencies": { "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4" } }, "sha512-3JZ5HXOZfYjsYSk0yPwBrkupyYSLpAE26Qc0HLghhZNGTZg/SKxXIIgoxOpmmeQP0RRSDJTk1/vPfw9tbw+jSQ=="], + + "@shikijs/langs/@shikijs/types": ["@shikijs/types@3.23.0", "", { "dependencies": { "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4" } }, "sha512-3JZ5HXOZfYjsYSk0yPwBrkupyYSLpAE26Qc0HLghhZNGTZg/SKxXIIgoxOpmmeQP0RRSDJTk1/vPfw9tbw+jSQ=="], + + "@shikijs/themes/@shikijs/types": ["@shikijs/types@3.23.0", "", { "dependencies": { "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4" } }, "sha512-3JZ5HXOZfYjsYSk0yPwBrkupyYSLpAE26Qc0HLghhZNGTZg/SKxXIIgoxOpmmeQP0RRSDJTk1/vPfw9tbw+jSQ=="], + "@smithy/eventstream-codec/@smithy/core": ["@smithy/core@3.29.4", "", { "dependencies": { "@smithy/types": "^4.16.1", "tslib": "^2.6.2" } }, "sha512-G1GRglAabzEhqghJMBAd54FkRS7SAFGHEwbhcI9r+O+LIMuFsLyXkLZkCoFSgAglRu8s/URVXJB0hglq3ZipIg=="], "@smithy/util-utf8/@smithy/core": ["@smithy/core@3.29.4", "", { "dependencies": { "@smithy/types": "^4.16.1", "tslib": "^2.6.2" } }, "sha512-G1GRglAabzEhqghJMBAd54FkRS7SAFGHEwbhcI9r+O+LIMuFsLyXkLZkCoFSgAglRu8s/URVXJB0hglq3ZipIg=="], @@ -3391,10 +3407,6 @@ "mlly/pkg-types": ["pkg-types@1.3.1", "", { "dependencies": { "confbox": "^0.1.8", "mlly": "^1.7.4", "pathe": "^2.0.1" } }, "sha512-/Jm5M4RvtBFVkKWRu2BLUTNP8/M2a+UwuAX+ae4770q1qVGtfjG+WTCupoZixokjmHiry8uI+dlY8KXYV5HVVQ=="], - "monaco-editor/dompurify": ["dompurify@3.2.7", "", { "optionalDependencies": { "@types/trusted-types": "^2.0.7" } }, "sha512-WhL/YuveyGXJaerVlMYGWhvQswa7myDG17P7Vu65EWC05o8vfeNbvNf4d/BOvH99+ZW+LlQsc1GDKMa1vNK6dw=="], - - "monaco-editor/marked": ["marked@14.0.0", "", { "bin": { "marked": "bin/marked.js" } }, "sha512-uIj4+faQ+MgHgwUW1l2PsPglZLOLOT1uErt06dAPtx2kjteLAkbsd/0FiYg/MGS+i7ZKLb7w2WClxHkzOOuryQ=="], - "node-abi/semver": ["semver@7.8.5", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA=="], "node-api-version/semver": ["semver@7.8.5", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA=="], @@ -3435,6 +3447,10 @@ "sharp/semver": ["semver@7.8.5", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA=="], + "shiki/@shikijs/core": ["@shikijs/core@3.23.0", "", { "dependencies": { "@shikijs/types": "3.23.0", "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4", "hast-util-to-html": "^9.0.5" } }, "sha512-NSWQz0riNb67xthdm5br6lAkvpDJRTgB36fxlo37ZzM2yq0PQFFzbd8psqC2XMPgCzo1fW6cVi18+ArJ44wqgA=="], + + "shiki/@shikijs/types": ["@shikijs/types@3.23.0", "", { "dependencies": { "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4" } }, "sha512-3JZ5HXOZfYjsYSk0yPwBrkupyYSLpAE26Qc0HLghhZNGTZg/SKxXIIgoxOpmmeQP0RRSDJTk1/vPfw9tbw+jSQ=="], + "simple-update-notifier/semver": ["semver@7.8.5", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA=="], "source-map-support/source-map": ["source-map@0.6.1", "", {}, "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g=="], @@ -3489,38 +3505,6 @@ "@babel/helper-compilation-targets/lru-cache/yallist": ["yallist@3.1.1", "", {}, "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g=="], - "@dvaji/vite-plugin-monaco-editor/rolldown/@oxc-project/types": ["@oxc-project/types@0.139.0", "", {}, "sha512-r9gHphtCs+1M7J0pw6Sn/hh/Wpa/iQrOOkrNAlVLF/gHq+/CJmHIWKKUUhdWjcD6CIa8idarspCsASiXCXvFUw=="], - - "@dvaji/vite-plugin-monaco-editor/rolldown/@rolldown/binding-android-arm64": ["@rolldown/binding-android-arm64@1.1.5", "", { "os": "android", "cpu": "arm64" }, "sha512-lZg8fqIv2v7FF237bwMgzGZEJvGL79/s5knJ/i6FmsGF4XXlzccZ4jb+TrFIxtSSxFtIpdsgrPZeMk1I9AFcyQ=="], - - "@dvaji/vite-plugin-monaco-editor/rolldown/@rolldown/binding-darwin-arm64": ["@rolldown/binding-darwin-arm64@1.1.5", "", { "os": "darwin", "cpu": "arm64" }, "sha512-51Bnx9pNiMRKSUNtBfySkNJ9vMU9Hh3I1ozDd6gyPPYzaXCfnptUcEZxXGYFn+ul2dtcMUiqGR1Yai2K10uoTw=="], - - "@dvaji/vite-plugin-monaco-editor/rolldown/@rolldown/binding-darwin-x64": ["@rolldown/binding-darwin-x64@1.1.5", "", { "os": "darwin", "cpu": "x64" }, "sha512-Tm+gbfC0aHu1tBA/JvKQh32S0K6YgCHkiAF4/W6xX0K0RmNuc94VeK419dJoE65R5aRxmo+noZQSWrAMF6yb6g=="], - - "@dvaji/vite-plugin-monaco-editor/rolldown/@rolldown/binding-freebsd-x64": ["@rolldown/binding-freebsd-x64@1.1.5", "", { "os": "freebsd", "cpu": "x64" }, "sha512-JMzDKCCXq93YccG5gz3hvOs1oXRKAf0XYpfOS88e+wZrC8Iugj6j68867vrYZkvpDDpKn/KoKORThmchMpF6TA=="], - - "@dvaji/vite-plugin-monaco-editor/rolldown/@rolldown/binding-linux-arm-gnueabihf": ["@rolldown/binding-linux-arm-gnueabihf@1.1.5", "", { "os": "linux", "cpu": "arm" }, "sha512-uML21j2K5TfPGutKxub+M+nLjZIrWjXQ5Grx4lCe/nimTj9B4L63zHpjXLl4y0L3mcm2htEQIb06oCG/szerNw=="], - - "@dvaji/vite-plugin-monaco-editor/rolldown/@rolldown/binding-linux-arm64-gnu": ["@rolldown/binding-linux-arm64-gnu@1.1.5", "", { "os": "linux", "cpu": "arm64" }, "sha512-navSiuTMogvnQoZoM/v+l3ZWo50/NTwSHSzheABx/RCnmUPaKwq9qSo4Br2OYRs21+Fz8uFqITZM3H4opOB0/Q=="], - - "@dvaji/vite-plugin-monaco-editor/rolldown/@rolldown/binding-linux-arm64-musl": ["@rolldown/binding-linux-arm64-musl@1.1.5", "", { "os": "linux", "cpu": "arm64" }, "sha512-lAryqH7IteztmCXQXk0etKj4wBQ7Gx5S6LjKhsgp9zb8I5bsuvU/2llH1hDQcjsFeqIsovMVN339/8pUDDBXxA=="], - - "@dvaji/vite-plugin-monaco-editor/rolldown/@rolldown/binding-linux-ppc64-gnu": ["@rolldown/binding-linux-ppc64-gnu@1.1.5", "", { "os": "linux", "cpu": "ppc64" }, "sha512-fsK/sNBnxzBlL4O1JNrZakVQxPspqpED5dLtNsZS9oOKmtSpdNIzxH2kkol5HYTWJN47sE20ztMJPxfZ89qGOg=="], - - "@dvaji/vite-plugin-monaco-editor/rolldown/@rolldown/binding-linux-s390x-gnu": ["@rolldown/binding-linux-s390x-gnu@1.1.5", "", { "os": "linux", "cpu": "s390x" }, "sha512-gLYb4BIadlfTOYT5gO503n8zQjXflgzpD0FcyKh0Mzx3rqCZKnHoJWV9xe1KXUJ5lx2JfcSHr/mhzS0PC/McAA=="], - - "@dvaji/vite-plugin-monaco-editor/rolldown/@rolldown/binding-linux-x64-gnu": ["@rolldown/binding-linux-x64-gnu@1.1.5", "", { "os": "linux", "cpu": "x64" }, "sha512-FjcpEKUyJygHgs1o50VYNvkt5+7Le/VEdYt0AkRpkL33MnyQfwr8l5mXwMmfmTbyMPr5vJLC+8/Gd9gXnwU1QQ=="], - - "@dvaji/vite-plugin-monaco-editor/rolldown/@rolldown/binding-linux-x64-musl": ["@rolldown/binding-linux-x64-musl@1.1.5", "", { "os": "linux", "cpu": "x64" }, "sha512-Me+PfPI2TMeOQk0gYWfLQZtTktrmzbr8cDboqX83XKc7UrgAi55gF+2dUkWdxd19n55Essp2yeca+O9N5rBxHg=="], - - "@dvaji/vite-plugin-monaco-editor/rolldown/@rolldown/binding-openharmony-arm64": ["@rolldown/binding-openharmony-arm64@1.1.5", "", { "os": "none", "cpu": "arm64" }, "sha512-yc5WrLzXks6zCQfn9Oxr8pORKyl/pF+QjHmW/Qx3qu0oyrrNC+y2JLTU1E2rcWYAmzlnqngWXHQjy51VzW70Vw=="], - - "@dvaji/vite-plugin-monaco-editor/rolldown/@rolldown/binding-wasm32-wasi": ["@rolldown/binding-wasm32-wasi@1.1.5", "", { "dependencies": { "@emnapi/core": "1.11.1", "@emnapi/runtime": "1.11.1", "@napi-rs/wasm-runtime": "^1.1.6" }, "cpu": "none" }, "sha512-VbQGPX2b4r48TAMIM2cjgluIM1HYutm4pcTEJsle7iEP7sB1dFqtPLBVbdLAZCxy1txCcPxf4QFf4v8uvltPqA=="], - - "@dvaji/vite-plugin-monaco-editor/rolldown/@rolldown/binding-win32-arm64-msvc": ["@rolldown/binding-win32-arm64-msvc@1.1.5", "", { "os": "win32", "cpu": "arm64" }, "sha512-gHv82k63z4qpV5+Q1y/12KrK0ltWBukVDI8nZcbT7Tt/ZlOIVwppazneq0F93oDxTo3IgAMEDIoQh3E2n6mVsw=="], - - "@dvaji/vite-plugin-monaco-editor/rolldown/@rolldown/binding-win32-x64-msvc": ["@rolldown/binding-win32-x64-msvc@1.1.5", "", { "os": "win32", "cpu": "x64" }, "sha512-tTZuDBPw85tEN5PQi1pnEBzDy0Z49HtScLAbD5t6hyeU92A95pRWaSMw1GZZi/RwgSgUIl0xrSlXIT/9QzvYSA=="], - "@earendil-works/pi-ai/@smithy/node-http-handler/@smithy/core": ["@smithy/core@3.29.4", "", { "dependencies": { "@smithy/types": "^4.16.1", "tslib": "^2.6.2" } }, "sha512-G1GRglAabzEhqghJMBAd54FkRS7SAFGHEwbhcI9r+O+LIMuFsLyXkLZkCoFSgAglRu8s/URVXJB0hglq3ZipIg=="], "@earendil-works/pi-ai/https-proxy-agent/agent-base": ["agent-base@7.1.4", "", {}, "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ=="], @@ -3719,10 +3703,6 @@ "@aws-sdk/client-bedrock-runtime/@aws-sdk/token-providers/@aws-sdk/nested-clients/@aws-sdk/signature-v4-multi-region": ["@aws-sdk/signature-v4-multi-region@3.996.40", "", { "dependencies": { "@aws-sdk/types": "^3.974.1", "@smithy/signature-v4": "^5.6.3", "@smithy/types": "^4.16.1", "tslib": "^2.6.2" } }, "sha512-wrGZ/authosokclY1DXsiWT/1WjfCI22FuZGgdcilF+XLTXs5dCjAtiFYSPsEToZkbm3Lj2YP8PoWg0yoMNu0g=="], - "@dvaji/vite-plugin-monaco-editor/rolldown/@rolldown/binding-wasm32-wasi/@emnapi/core": ["@emnapi/core@1.11.1", "", { "dependencies": { "@emnapi/wasi-threads": "1.2.2", "tslib": "^2.4.0" } }, "sha512-RSvbQmHzdKzNsLYa/wHrbc3KN4sYLKAdPZxqiM2HATqv/SBk2/ENSHpvXGaLOMcsAyz0poEGqkmmKYG3OWiJEQ=="], - - "@dvaji/vite-plugin-monaco-editor/rolldown/@rolldown/binding-wasm32-wasi/@emnapi/runtime": ["@emnapi/runtime@1.11.1", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-vgj7R3y3Wgx24IQaGPA/R6YFXLHVMOZ0uVEyIQPaWs+rd1AzfEMXlAC22FYwO1XkKR6NPsq7mUandH8oIRdZFw=="], - "@electron/asar/minimatch/brace-expansion/balanced-match": ["balanced-match@1.0.2", "", {}, "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw=="], "@electron/universal/minimatch/brace-expansion/balanced-match": ["balanced-match@1.0.2", "", {}, "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw=="], diff --git a/docs/features/trees-diffs-workspace/plan.md b/docs/features/trees-diffs-workspace/plan.md new file mode 100644 index 000000000..a3281e4ce --- /dev/null +++ b/docs/features/trees-diffs-workspace/plan.md @@ -0,0 +1,140 @@ +# Trees + Diffs Workspace — Implementation Plan + +Reference: `spec.md` in this folder. + +## Architecture + +The change is split into four layers, each following the existing typed boundary: + +``` +shared-contracts/routes/workspace.routes.ts (new write + read-text routes) + │ ARGOS_ROUTE_CATALOG registration + ▼ +shared/types/presenters/workspace.d.ts (IWorkspacePresenter + SidePanelTab) + │ implemented by + ▼ +apps/desktop/.../workspacePresenter/index.ts (fs write ops, allow-list, invalidation) + │ dispatched from + ▼ +apps/desktop/.../routes/index.ts (new case per route) + ▲ invoked via bridge + │ +ui/api/WorkspaceClient.ts (typed wrappers) + ▲ +ui/components/sidepanel/* (Trees + Diffs components) +ui/stores/ui/sidepanel.ts (diffs tab state) +``` + +### New route contracts (shared-contracts) + +Add to `packages/shared-contracts/src/routes/workspace.routes.ts` and register each in `ARGOS_ROUTE_CATALOG` (`routes.ts`): + +| Route | Input | Output | +| --- | --- | --- | +| `workspace.readFileText` | `{ path }` | `{ content: string \| null, exists: boolean }` — raw text for the editor (distinct from `readFilePreview`, which normalizes for preview). | +| `workspace.writeFile` | `{ path, content }` | `{ written: boolean }` | +| `workspace.createEntry` | `{ parentDir, name, isDirectory }` | `{ path: string }` | +| `workspace.deletePath` | `{ path }` | `{ deleted: boolean }` | +| `workspace.renameOrMovePath` | `{ fromPath, toPath }` | `{ path: string }` | + +All use `zod.string().min(1)` and reuse existing `defineRouteContract`. + +### Presenter (desktop main) + +Extend `WorkspacePresenter` (`apps/desktop/src/main/presenter/workspacePresenter/index.ts`) and the `IWorkspacePresenter` interface (`packages/shared/src/types/presenters/workspace.d.ts`): + +- `readFileText(filePath): Promise` — read raw UTF-8 text; `isPathAllowed` guard; return `null` for non-text/binary/large files (reuse `resolvePreviewKind`: only proceed when `kind === "text"` and size under a cap, e.g. 2 MB). +- `writeFile(filePath, content): Promise` — guard; `fs.promises.writeFile`; the chokidar content watcher already emits an `fs` invalidation, so no manual emit is required. +- `createEntry(parentDir, name, isDirectory): Promise` — guard parent; resolve target; reject path traversal (`..`); `mkdir`/`writeFile` empty. +- `deletePath(path): Promise` — guard; `fs.promises.rm({ recursive: true, force: false })`. +- `renameOrMovePath(fromPath, toPath): Promise` — guard both; ensure `toPath` resolves inside an allowed workspace; `fs.promises.rename`. + +Each write method must: validate via `isPathAllowed`, normalize with `normalizePathForAccess`, reject attempts to escape the allowed workspace root, and let the watcher drive invalidation (already wired). No new event payloads. + +### Types (`packages/shared`) + +- `SidePanelTab` → `"workspace" | "browser" | "diffs"`. +- `IWorkspacePresenter` gains the five methods above. +- No new `WorkspaceNavSection` (the Git section stays; the Diffs tab is separate). + +### UI client (`packages/ui/api/WorkspaceClient.ts`) + +Add `readFileText`, `writeFile`, `createEntry`, `deletePath`, `renameOrMovePath` wrappers following the existing `bridge.invoke(route.name, input)` pattern. + +### Route dispatcher (`apps/desktop/src/main/routes/index.ts`) + +Add one `case` per new route (mirror the `workspaceReadFilePreviewRoute` block), importing the new route contracts alongside the existing workspace imports. + +## UI Changes + +### Dependencies (`packages/ui/package.json`) + +```jsonc +"dependencies": { + "@pierre/trees": "^1.0.0-beta.6", + "@pierre/diffs": "^1.3.5" +} +``` + +Install with `bun add`. Verify Vite bundles Shiki (used by `@pierre/diffs`); add to `optimizeDeps.include` only if dev cold-start breaks. + +### New components (`packages/ui/src/components/sidepanel/`) + +| File | Responsibility | +| --- | --- | +| `TreesFileTree.tsx` | Adapter around `@pierre/trees/react` `useFileTree` + ``. Feeds prepared paths from `readDirectory`/`expandDirectory`, maps git status (`getGitStatus`) to Trees `gitStatus`, wires `onRename`/`dragAndDrop` to `workspace.renameOrMovePath`, `renderContextMenu` for New File/Folder/Delete. Calls `sidepanelStore.selectFile` on selection. | +| `DiffsCodePane.tsx` | Read-only code view via `@pierre/diffs/react` ``. Theme toggled by `themeStore.isDark` (`pierre-dark`/`pierre-light`). | +| `DiffsEditorPane.tsx` | Editable editor via `@pierre/diffs` `EditProvider` + `Editor` + `` (Shiki highlight while editing). Dirty tracking through `Editor.onChange`/`getText()`; on save calls `workspaceClient.writeFile`. Cmd/Ctrl+S handler. Mounted keyed by file path. | +| `DiffsPatchPane.tsx` | Single-file diff via `@pierre/diffs/react` ``. Replaces `WorkspaceDiffView` for the Git section. | +| `DiffsPanel.tsx` | The new top-level tab body. Loads `getGitStatus` + per-file `getGitDiff`; renders `@pierre/diffs/react` `` (virtualized multi-file diffs) with sticky headers. Reuses `useWorkspaceSync` invalidation. | + +### Edited components + +- `ChatSidePanel.tsx`: add the **Diffs** button (lines ~230-253) and a third branch rendering `` when `activeTab === "diffs"`. +- `WorkspacePanel.tsx`: replace the custom `` tree with ``; replace the code/diff panes with the new `Diffs*Pane` components; keep Files/Git/Artifacts nav sections and the preview pane. +- `WorkspaceViewer.tsx`: add an **Edit** toggle for text files (view = ``, edit = ``); keep Preview/Code toggle for preview-eligible files. + +### Store (`packages/ui/src/stores/ui/sidepanel.ts`) + +- `openDiffs()` action mirroring `openBrowser()` (`activeTab: "diffs"`). +- Expose `openDiffs` through `useSidepanelStore()`. +- Optional `diffsStore` (new `stores/ui/diffs.ts`) holding the Diffs tab's selected-file filter and expanded-file set — keep small; reuse `useWorkspaceSync` for data. + +### Editor dirty-state + +`DiffsEditorPane` holds local `original`/`current` strings; dirty = `current !== original`. On save: `writeFile` then set `original = current`. If the file is invalidated externally (watcher) while dirty, prompt or reload-on-confirm (first iteration: reload silently only when not dirty). + +## Data Flow (editing) + +``` +user drags src/a.ts -> lib/a.ts in + -> onDropComplete({ draggedPaths: ["src/a.ts"], target: "lib/" }) + -> workspaceClient.renameOrMovePath("…/src/a.ts", "…/lib/a.ts") + -> bridge.invoke -> routes dispatcher -> WorkspacePresenter.renameOrMovePath + -> isPathAllowed(both) -> fs.rename + -> chokidar content watcher fires -> scheduleInvalidation("fs") + -> renderer onInvalidated -> useWorkspaceSync re-reads tree + git status + -> model.resetPaths(...) refreshes +``` + +## Compatibility / Migration + +- Read-only routes are unchanged; the daemon/headless path is unaffected (write routes simply aren't dispatched there — they'll return the standard "desktop-only" error, matching `revealFileInFolder`/`openFile`). +- The preview protocol (`workspacePreviewProtocol`) and markdown linked-file resolution are untouched. +- `WorkspaceDiffView.tsx` is retired (replaced by `DiffsPatchPane`); remove it and its imports in the same change to avoid dead code. +- The custom `WorkspaceFileNode.tsx` tree renderer is retired; `WorkspaceFileNode` *type* stays (presenter still returns it). + +## Test Strategy + +- **Unit (main, `apps/desktop/test/main`):** extend `workspacePresenter.test.ts` with the five new methods — allow-list rejections, path-traversal guards, round-trip write/read/delete/rename. Use a temp dir fixture; mock the watcher where needed. +- **Unit (renderer, `packages/ui`):** `TreesFileTree.test.tsx` (rename/dnd calls the client), `DiffsPanel.test.tsx` (renders changed files from a mock `getGitStatus`/`getGitDiff`), `DiffsEditorPane.test.tsx` (dirty + save calls `writeFile`). +- **Manual:** verify AC-1..AC-10 in dev (`bun run dev`). +- **Gates:** `bun run format`, `bun run lint` (incl. architecture-guard), `bun run typecheck`, `bun test`, and `bun run build` for AC-11/AC-12. + +## Build Size Check + +After install, run `bun run build` and record the `@argos/ui` chunk-size delta from Shiki + Trees. If the delta is large, configure dynamic import of the Diffs components (`React.lazy`) so the editor/chat bundle stays lean. + +## Rollout + +Single PR to `master` (default base). No flags; the feature replaces existing read-only surfaces in place. Keep `WorkspaceDiffView`/`WorkspaceFileNode` (renderer) removal in the same PR. diff --git a/docs/features/trees-diffs-workspace/spec.md b/docs/features/trees-diffs-workspace/spec.md new file mode 100644 index 000000000..0aa23be20 --- /dev/null +++ b/docs/features/trees-diffs-workspace/spec.md @@ -0,0 +1,76 @@ +# Trees + Diffs Workspace + +Status: in-progress +Owner: workspace-sidepanel +Created: 2026-08-10 + +## User Need + +The right-hand workspace sidepanel currently ships a hand-rolled recursive file tree (`WorkspaceFileNode`), a read-only Monaco code viewer, and a custom unified-diff parser. (Monaco has since been removed; editing now uses `@pierre/diffs`.) Users want to: + +1. **Edit files** directly from the sidepanel — rename, move (drag & drop), create, delete entries in the tree, and edit file contents inline. Today everything is read-only. +2. See a richer, consistent **diff** surface than the hand-rolled parser provides. +3. Get to the repo's pending changes fast through a dedicated **Diffs** tab, instead of only via the collapsed "Git" section inside the workspace. + +## Goal + +Replace the workspace sidepanel's tree, code viewer, and diff renderer with the Pierre libraries, enable full filesystem editing, and add a top-level "Diffs" tab: + +| Surface | Today | After | +| --- | --- | --- | +| File tree | Custom recursive `WorkspaceFileNode` | `@pierre/trees` `` (path-first, virtualized, built-in search/rename/dnd/git-status) | +| Code viewer (read) | Read-only Monaco | `@pierre/diffs` `` (Shiki highlight, matches diff styling) | +| Code editing | None | `@pierre/diffs` `` via `EditProvider` + `Editor` (Shiki highlighting while editing); Save (Cmd/Ctrl+S) writes via `workspace.writeFile` | +| Diff renderer | Custom unified-diff row parser (`WorkspaceDiffView`) | `@pierre/diffs` `` (parses the unified patch the presenter already returns) | +| Preview pane (md/html/img/pdf/svg) | Iframe preview protocol | **Unchanged** | +| Top-level tab | `workspace` \| `browser` | `workspace` \| `browser` \| `diffs` | + +### Single rendering pipeline + +`@pierre/diffs` powers read-only code viewing (``), inline editing (`` + `EditProvider`), and diffs (``) with one Shiki pipeline and theme. Monaco is removed entirely (`monaco-editor`, `stream-monaco`, `@dvaji/vite-plugin-monaco-editor`, Vite workers, and the dead `WorkspaceCodePane`/`TraceDialog` Monaco setup). `TraceDialog`'s JSON body now uses `` too. + +## Acceptance Criteria + +- **AC-1** Selecting a text file in the workspace renders it with `@pierre/diffs `. +- **AC-2** A view/edit toggle switches a text file to an editable `@pierre/diffs` editor; Cmd/Ctrl+S (and a Save button) writes content via `workspace.writeFile`; a dirty indicator shows unsaved changes. +- **AC-3** Inline rename in the tree renames the file on disk and refreshes the tree. +- **AC-4** Drag-and-drop in the tree moves files/directories on disk and refreshes the tree. +- **AC-5** Context menu + tree affordances support "New File" and "New Folder" creation and "Delete". +- **AC-6** Git-status row signals (added/modified/deleted/untracked/...) render in the tree via Trees' built-in `gitStatus`. +- **AC-7** Selecting a changed file in the Git section renders its diff with `@pierre/diffs ` (staged + unstaged). +- **AC-8** A new top-level **Diffs** tab exists beside Workspace/Browser; it lists all changed files (from `getGitStatus`) and renders them via `@pierre/diffs ` (virtualized multi-file) using the workspace's unified diff. +- **AC-9** All write operations enforce the existing workspace path allow-list (`isPathAllowed`); unauthorized paths are rejected. +- **AC-10** Write operations trigger the existing `workspace.invalidated` invalidation flow so the tree/diffs refresh. +- **AC-11** `bun run typecheck`, `bun run lint`, `bun run format`, and the relevant `bun test` suites pass. +- **AC-12** `@argos/ui` build (`bun run build`) succeeds with the new dependencies bundled. + +## Constraints + +- Follow the typed route/client boundary: new capabilities go through `shared-contracts/routes`, `routes/index.ts` dispatcher, `WorkspacePresenter`, and `WorkspaceClient`. No new `window.api`/legacy paths. +- All filesystem writes must be inside a registered workspace/workdir (security boundary already enforced by `isPathAllowed`). +- Desktop is the primary target (the presenter lives in main). The daemon/headless path only needs to remain non-breaking for the existing read routes; write routes are desktop-only for now. +- Do not regress the markdown/html/pdf/svg/image preview pane or the artifact viewer. +- Editing uses `@pierre/diffs` (`EditProvider` + `Editor` + ``); Monaco is removed entirely. +- `@pierre/trees` is `1.0.0-beta.x`; pin a caret range and treat beta API drift as a tracked risk. + +## Non-Goals + +- Replace the markdown/html/image/pdf preview pane with `@pierre/diffs`. +- Port write/edit routes to the daemon (web/headless mode stays read-only for workspace files in this iteration). +- Multi-file staging/unstaging/commit actions in the Diffs tab (future work). +- Migrating the unrelated "WorkspaceSelector" (machine switcher) feature. + +## Open Questions + +Resolved before implementation: + +- **Q1:** Where does the "Diffs" tab live? **A:** Top-level peer of Workspace/Browser (new `SidePanelTab = "diffs"`). +- **Q2:** What does "replace the whole workspace" include? **A:** Tree + diff renderer + code viewer; preview pane stays. +- **Q3:** What editing? **A:** Rename, move (dnd), create, delete, and inline file-content editing. + +None remain open. + +## Out-of-Scope Risks + +- Trees beta API churn — mitigated by pinning the version and a thin adapter component. +- Shiki bundle size added by `@pierre/diffs` — verify build size delta at AC-12. diff --git a/docs/features/trees-diffs-workspace/tasks.md b/docs/features/trees-diffs-workspace/tasks.md new file mode 100644 index 000000000..1ce9e0359 --- /dev/null +++ b/docs/features/trees-diffs-workspace/tasks.md @@ -0,0 +1,87 @@ +# Trees + Diffs Workspace — Tasks + +Ordered for reviewable commits. Update status as work lands. + +## 1. Contracts & types + +- [x] 1.1 Add `workspace.readFileText`, `workspace.writeFile`, `workspace.createEntry`, `workspace.deletePath`, `workspace.renameOrMovePath` route contracts to `packages/shared-contracts/src/routes/workspace.routes.ts`. +- [x] 1.2 Register the five new routes in `ARGOS_ROUTE_CATALOG` (`packages/shared-contracts/src/routes.ts`). +- [x] 1.3 Extend `SidePanelTab` to `"workspace" | "browser" | "diffs"` in `packages/shared/src/types/presenters/workspace.d.ts`. +- [x] 1.4 Add the five new methods to `IWorkspacePresenter` (same file). + +## 2. Presenter (desktop main) + +- [x] 2.1 Implement `readFileText` on `WorkspacePresenter` (text-only, size cap, allow-list). +- [x] 2.2 Implement `writeFile`, `createEntry`, `deletePath`, `renameOrMovePath` (allow-list + path-traversal guard). +- [x] 2.3 Add the five `case` blocks to `apps/desktop/src/main/routes/index.ts`. + +## 3. UI client & store + +- [x] 3.1 Add the five wrappers to `packages/ui/api/WorkspaceClient.ts`. +- [x] 3.2 Add `openDiffs()` action + `SidePanelTab` handling in `packages/ui/src/stores/ui/sidepanel.ts`. +- [~] 3.3 (Optional) Add `stores/ui/diffs.ts` for Diffs-tab filter/expand state — deferred; DiffsPanel holds local state for now. + +## 4. Dependencies & build + +- [x] 4.1 `bun add @pierre/trees@^1.0.0-beta.6 @pierre/diffs@^1.3.5` in `packages/ui`. +- [x] 4.2 Verified Vite dev/build bundles Shiki; used `disableWorkerPool` to avoid worker-URL plumbing. +- [x] 4.3 `bun run build` passes; chunk-size warning is pre-existing (icons/editor.api), not introduced here. + +## 5. Tree replacement + +- [x] 5.1 Create `TreesFileTree.tsx` adapter (paths from `readDirectory`/`expandDirectory`, git status -> Trees `gitStatus`). +- [x] 5.2 Wire inline rename + drag/drop to `workspaceClient.renameOrMovePath`. +- [x] 5.3 Wire context menu: New File / New Folder / Delete -> `createEntry`/`deletePath`. +- [x] 5.4 Swap `` for `` in `WorkspacePanel.tsx`. +- [x] 5.5 Keep drag-to-chat file-reference behavior (context menu "Insert reference" dispatches `INSERT_REFERENCE_REQUESTED` via `onInsertFileReference`). +- [x] 5.6 Removed dead `WorkspaceFileNode.tsx` renderer (type retained in `@argos/shared/presenter`). + +## 6. Code viewer + editing + +- [x] 6.1 Create `DiffsCodePane.tsx` (read-only `@pierre/diffs` ``). +- [x] 6.2 Create `DiffsEditorPane.tsx` (editable `@pierre/diffs` `EditProvider` + `Editor` + ``, Save/dirty + Cmd/Ctrl+S; Monaco removed). +- [x] 6.3 Add View/Edit toggle in `WorkspaceViewer.tsx`; swap `WorkspaceCodePane` usages for `DiffsCodePane`. + +## 7. Diff renderer + +- [x] 7.1 Create `DiffsPatchPane.tsx` (`@pierre/diffs` `` from staged/unstaged patch text). +- [x] 7.2 Replace `WorkspaceDiffView` usages with ``. +- [x] 7.3 Delete `WorkspaceDiffView.tsx`. + +## 8. Diffs tab + +- [x] 8.1 Create `DiffsPanel.tsx` (changed-file list + `@pierre/diffs` `` per selection; full patch by default). Note: used `` over a per-file list rather than `` multi-file to consume the presenter's unified patch directly (see plan "Diff renderer" decision; `` remains a future enhancement). +- [x] 8.2 Add the **Diffs** button + branch in `ChatSidePanel.tsx`. +- [x] 8.3 Wire `workspaceClient.onInvalidated` into the Diffs tab (status + focused patch refresh). + +## 9. Tests + +- [x] 9.1 Extend `workspacePresenter.test.ts` (read/write/create/delete/rename + allow-list/traversal) — 12 new cases, 25 total passing. +- [~] 9.2 UI component tests (`TreesFileTree`/`DiffsPanel`) — deferred. `@pierre/trees`/`@pierre/diffs` use shadow DOM + Shiki that do not render in jsdom; coverage is provided by the presenter tests + manual verification (AC-1..AC-10). + +## 10. Gates + +- [x] 10.1 `bun run format`. +- [x] 10.2 `bun run lint` (architecture-guard, agent-cleanup-guard, route-catalog-drift-guard, oxlint all green; 342 routes registered). +- [x] 10.3 `bun run typecheck` (desktop `typecheck:node` + `@argos/ui` `typecheck:web`). +- [x] 10.4 `workspacePresenter` test run (25 passed). +- [x] 10.5 `bun run build` (`@argos/ui` builds with the new deps). + +## 11. Daemon port (workspace routes are NOT desktop-only) + +The workspace FS/git/edit routes belong in the daemon (web/headless + desktop), not fenced off as desktop-only. Implemented so the HybridBridge routes them to the daemon where the logic actually lives. + +- [x] 11.1 Reverted the temporary `desktop-only` band-aid (only `workspace.revealFileInFolder`/`openFile` remain desktop-only — Electron `shell`). +- [x] 11.2 `apps/daemon/src/workspace/daemonWorkspacePresenter.ts` — Bun port: allow-list, readDirectory/expandDirectory, readFilePreview (HTTP preview URLs), readFileText/writeFile/createEntry/deletePath/renameOrMovePath, resolveMarkdownLinkedFile, getGitStatus/getGitDiff (git CLI), searchFiles (recursive), reveal/open throw. +- [x] 11.3 chokidar watchers → `eventPublisher.publish(workspace.invalidated)`. +- [x] 11.4 HTTP preview endpoint `GET /api/v1/workspace/preview?path=` in `apps/daemon/src/index.ts` (allow-list enforced; serves html/pdf/svg raw bytes). +- [x] 11.5 Wired all 16 workspace routes into `createDaemonDispatcher` (new optional `workspacePresenter` param); instantiated in `index.ts`, base URL set after `serve()`. +- [x] 11.6 `chokidar` added to `@argos/daemon` deps. +- [x] 11.7 Gates: daemon + desktop + UI typecheck, lint, daemon test suite (50 passed; 1 unrelated MCP env failure). + +## Follow-ups (out of this iteration) + +- Remove the now-dead desktop main `WorkspacePresenter` + its unreachable route `case`s (routes go to the daemon; the desktop presenter is only referenced by its own dispatcher). +- Adopt `@pierre/diffs` `` for the Diffs tab once old/new file-content fetching is wired (richer virtualized multi-file review). +- Preserve Trees expansion across invalidation reloads (track expanded paths; pass to `resetPaths({ initialExpandedPaths })`). +- Daemon preview endpoint auth (today it relies on local-only exposure; add a token for network-exposed daemons). diff --git a/packages/shared-contracts/src/desktop-only.ts b/packages/shared-contracts/src/desktop-only.ts index 034cd8374..993721485 100644 --- a/packages/shared-contracts/src/desktop-only.ts +++ b/packages/shared-contracts/src/desktop-only.ts @@ -22,6 +22,9 @@ export const DESKTOP_ONLY_ROUTE_PREFIXES = [ "project.selectDirectory", "file.saveImage", "file.copyImage", + // `revealFileInFolder`/`openFile` use the Electron `shell` module and stay + // desktop-only. All other workspace routes (tree, git, file edit, preview) are + // implemented in the daemon so they work in web/headless mode too. "workspace.revealFileInFolder", "workspace.openFile", "sync.openFolder", diff --git a/packages/shared-contracts/src/domainSchemas.ts b/packages/shared-contracts/src/domainSchemas.ts index d6749344a..fc2d35294 100644 --- a/packages/shared-contracts/src/domainSchemas.ts +++ b/packages/shared-contracts/src/domainSchemas.ts @@ -522,8 +522,10 @@ export const WorkspaceFileMetadataSchema = zod.object({ fileName: zod.string(), fileSize: zod.number(), fileDescription: zod.string().optional(), - fileCreated: zod.date(), - fileModified: zod.date(), + // `coerce.date()`: Electron IPC preserves Date instances, but the daemon sends + // JSON over WebSocket where dates arrive as ISO strings. Coerce so both paths parse. + fileCreated: zod.coerce.date(), + fileModified: zod.coerce.date(), }); export const WorkspaceFilePreviewSchema = zod.object({ diff --git a/packages/shared-contracts/src/routes.ts b/packages/shared-contracts/src/routes.ts index 3741e5467..a5d23bcf3 100644 --- a/packages/shared-contracts/src/routes.ts +++ b/packages/shared-contracts/src/routes.ts @@ -373,20 +373,25 @@ import { windowToggleMaximizeCurrentRoute, } from "./routes/window.routes"; import { + workspaceBrowseDirectoryRoute, + workspaceCreateEntryRoute, + workspaceDeletePathRoute, workspaceExpandDirectoryRoute, workspaceGetGitDiffRoute, workspaceGetGitStatusRoute, workspaceOpenFileRoute, workspaceReadDirectoryRoute, workspaceReadFilePreviewRoute, + workspaceReadFileTextRoute, workspaceRegisterRoute, + workspaceRenameOrMovePathRoute, workspaceResolveMarkdownLinkedFileRoute, workspaceRevealFileInFolderRoute, workspaceSearchFilesRoute, - workspaceBrowseDirectoryRoute, workspaceUnregisterRoute, workspaceUnwatchRoute, workspaceWatchRoute, + workspaceWriteFileRoute, } from "./routes/workspace.routes"; export * from "./routes/browser.routes"; @@ -474,6 +479,11 @@ export const ARGOS_ROUTE_CATALOG = { [workspaceGetGitDiffRoute.name]: workspaceGetGitDiffRoute, [workspaceSearchFilesRoute.name]: workspaceSearchFilesRoute, [workspaceBrowseDirectoryRoute.name]: workspaceBrowseDirectoryRoute, + [workspaceReadFileTextRoute.name]: workspaceReadFileTextRoute, + [workspaceWriteFileRoute.name]: workspaceWriteFileRoute, + [workspaceCreateEntryRoute.name]: workspaceCreateEntryRoute, + [workspaceDeletePathRoute.name]: workspaceDeletePathRoute, + [workspaceRenameOrMovePathRoute.name]: workspaceRenameOrMovePathRoute, [browserGetStatusRoute.name]: browserGetStatusRoute, [browserLoadUrlRoute.name]: browserLoadUrlRoute, [browserAttachCurrentWindowRoute.name]: browserAttachCurrentWindowRoute, diff --git a/packages/shared-contracts/src/routes/workspace.routes.ts b/packages/shared-contracts/src/routes/workspace.routes.ts index cc56d8aca..22b74bb93 100644 --- a/packages/shared-contracts/src/routes/workspace.routes.ts +++ b/packages/shared-contracts/src/routes/workspace.routes.ts @@ -175,3 +175,67 @@ export const workspaceBrowseDirectoryRoute = defineRouteContract({ ), }), }); + +/** + * Read raw UTF-8 text for editing. Distinct from `readFilePreview`, which + * normalizes content for preview rendering. Returns null content when the file + * is binary, non-text, or above the size cap. + */ +export const workspaceReadFileTextRoute = defineRouteContract({ + name: "workspace.readFileText", + input: zod.object({ + path: zod.string().min(1), + }), + output: zod.object({ + content: zod.string().nullable(), + exists: zod.boolean(), + }), +}); + +/** Write file content to disk (overwrites). */ +export const workspaceWriteFileRoute = defineRouteContract({ + name: "workspace.writeFile", + input: zod.object({ + path: zod.string().min(1), + content: zod.string(), + }), + output: zod.object({ + written: zod.boolean(), + }), +}); + +/** Create a new file or directory. */ +export const workspaceCreateEntryRoute = defineRouteContract({ + name: "workspace.createEntry", + input: zod.object({ + parentDir: zod.string().min(1), + name: zod.string().min(1), + isDirectory: zod.boolean(), + }), + output: zod.object({ + path: zod.string(), + }), +}); + +/** Delete a file or directory (recursive). */ +export const workspaceDeletePathRoute = defineRouteContract({ + name: "workspace.deletePath", + input: zod.object({ + path: zod.string().min(1), + }), + output: zod.object({ + deleted: zod.boolean(), + }), +}); + +/** Rename or move a file/directory. */ +export const workspaceRenameOrMovePathRoute = defineRouteContract({ + name: "workspace.renameOrMovePath", + input: zod.object({ + fromPath: zod.string().min(1), + toPath: zod.string().min(1), + }), + output: zod.object({ + path: zod.string(), + }), +}); diff --git a/packages/shared/src/types/presenters/workspace.d.ts b/packages/shared/src/types/presenters/workspace.d.ts index 014501589..e7b9a8555 100644 --- a/packages/shared/src/types/presenters/workspace.d.ts +++ b/packages/shared/src/types/presenters/workspace.d.ts @@ -3,7 +3,7 @@ * Types for the unified right sidepanel workspace experience. */ -export type SidePanelTab = "workspace" | "browser"; +export type SidePanelTab = "workspace" | "browser" | "diffs"; export type WorkspaceNavSection = "artifacts" | "files" | "git" | "subagents"; @@ -206,4 +206,41 @@ export interface IWorkspacePresenter { * @param query Search query (plain string) */ searchFiles(workspacePath: string, query: string): Promise; + + /** + * Read raw UTF-8 text of a file for editing. Returns null content for binary, + * non-text, or oversized files. + * @param filePath Absolute file path + */ + readFileText(filePath: string): Promise<{ content: string | null; exists: boolean }>; + + /** + * Write file content to disk (overwrites existing content). + * @param filePath Absolute file path + * @param content File content + */ + writeFile(filePath: string, content: string): Promise; + + /** + * Create a new file (empty) or directory. + * @param parentDir Absolute parent directory path + * @param name Entry name + * @param isDirectory Whether to create a directory + * @returns The created absolute path + */ + createEntry(parentDir: string, name: string, isDirectory: boolean): Promise; + + /** + * Delete a file or directory (recursive). + * @param targetPath Absolute path to delete + */ + deletePath(targetPath: string): Promise; + + /** + * Rename or move a file/directory. + * @param fromPath Absolute source path + * @param toPath Absolute destination path + * @returns The resolved destination path + */ + renameOrMovePath(fromPath: string, toPath: string): Promise; } diff --git a/packages/ui/api/WorkspaceClient.ts b/packages/ui/api/WorkspaceClient.ts index 20a493b56..0d202c756 100644 --- a/packages/ui/api/WorkspaceClient.ts +++ b/packages/ui/api/WorkspaceClient.ts @@ -7,14 +7,19 @@ import { workspaceOpenFileRoute, workspaceReadDirectoryRoute, workspaceReadFilePreviewRoute, + workspaceReadFileTextRoute, workspaceRegisterRoute, + workspaceRenameOrMovePathRoute, workspaceResolveMarkdownLinkedFileRoute, workspaceRevealFileInFolderRoute, workspaceSearchFilesRoute, + workspaceBrowseDirectoryRoute, + workspaceCreateEntryRoute, + workspaceDeletePathRoute, workspaceUnregisterRoute, workspaceUnwatchRoute, workspaceWatchRoute, - workspaceBrowseDirectoryRoute, + workspaceWriteFileRoute, } from "@argos/shared-contracts/routes"; import { getArgosBridge } from "./core"; @@ -95,6 +100,27 @@ export function createWorkspaceClient(bridge: ArgosBridge = getArgosBridge()) { return await bridge.invoke(workspaceBrowseDirectoryRoute.name, path ? { path } : {}); } + /** Read raw UTF-8 text for editing (null content for binary/non-text/oversized). */ + async function readFileText(path: string) { + return await bridge.invoke(workspaceReadFileTextRoute.name, { path }); + } + + async function writeFile(path: string, content: string) { + return await bridge.invoke(workspaceWriteFileRoute.name, { path, content }); + } + + async function createEntry(parentDir: string, name: string, isDirectory: boolean) { + return await bridge.invoke(workspaceCreateEntryRoute.name, { parentDir, name, isDirectory }); + } + + async function deletePath(path: string) { + return await bridge.invoke(workspaceDeletePathRoute.name, { path }); + } + + async function renameOrMovePath(fromPath: string, toPath: string) { + return await bridge.invoke(workspaceRenameOrMovePathRoute.name, { fromPath, toPath }); + } + function onInvalidated( listener: (payload: { workspacePath: string; @@ -121,6 +147,11 @@ export function createWorkspaceClient(bridge: ArgosBridge = getArgosBridge()) { getGitDiff, searchFiles, browseDirectory, + readFileText, + writeFile, + createEntry, + deletePath, + renameOrMovePath, onInvalidated, }; } diff --git a/packages/ui/package.json b/packages/ui/package.json index e7ae2a2eb..1219f15d1 100644 --- a/packages/ui/package.json +++ b/packages/ui/package.json @@ -21,6 +21,8 @@ "@iconify-json/lucide": "^1.2.121", "@iconify-json/vscode-icons": "^1.2.68", "@iconify/react": "^6.0.2", + "@pierre/diffs": "^1.3.5", + "@pierre/trees": "^1.0.0-beta.6", "@tanstack/highlight": "^0.0.9", "@tanstack/react-query": "catalog:", "@tanstack/react-router": "catalog:", @@ -48,7 +50,6 @@ "@dnd-kit/core": "^6.3.1", "@dnd-kit/sortable": "^10.0.0", "@dnd-kit/utilities": "^3.2.2", - "@dvaji/vite-plugin-monaco-editor": "^2.0.0", "@electron-toolkit/tsconfig": "^2.0.0", "@rolldown/plugin-babel": "^0.2.3", "@tailwindcss/typography": "^0.5.20", @@ -72,8 +73,6 @@ "babel-plugin-react-compiler": "^1.0.0", "jsdom": "catalog:", "mermaid": "^11.16.0", - "monaco-editor": "^0.55.1", - "stream-monaco": "^0.0.49", "tailwind-scrollbar-hide": "^4.0.0", "tailwindcss": "catalog:", "tailwindcss-animate": "^1.0.7", diff --git a/packages/ui/src/components/markdown/useMarkdownLinkNavigation.ts b/packages/ui/src/components/markdown/useMarkdownLinkNavigation.ts index 37fcf7259..36061fe36 100644 --- a/packages/ui/src/components/markdown/useMarkdownLinkNavigation.ts +++ b/packages/ui/src/components/markdown/useMarkdownLinkNavigation.ts @@ -2,9 +2,17 @@ import { useCallback, useMemo } from "react"; import { createBrowserClient } from "#api/BrowserClient"; import { createWorkspaceClient } from "#api/WorkspaceClient"; import { sessionStore, getActiveSession } from "#/stores/ui/session"; -import { openBrowser, selectFile } from "#/stores/ui/sidepanel"; +import { openBrowser, openDiffs, selectFile, setDiffsSelection } from "#/stores/ui/sidepanel"; import { classifyMarkdownLink, type MarkdownLinkContext } from "./linkTypes"; +const isPathWithinWorkspace = (filePath: string, workspacePath: string | null): boolean => { + if (!workspacePath) return false; + const normalize = (value: string) => value.replace(/[\\/]+$/, "").toLowerCase(); + const target = normalize(filePath); + const root = normalize(workspacePath); + return target === root || target.startsWith(`${root}/`) || target.startsWith(`${root}\\`); +}; + interface UseMarkdownLinkNavigationOptions { linkContext?: MarkdownLinkContext | undefined; } @@ -19,6 +27,19 @@ function buildSafeAttributeSelector(value: string): string { return value.replace(/\\/g, "\\\\").replace(/"/g, '\\"'); } +/** + * Normalize a local-file href from chat markdown so it resolves on disk: + * - strip a leading "/" before a Windows drive letter (`/C:/...` -> `C:/...`) + * - strip a trailing editor-style line/column suffix (`path:18` / `path:18:5`) + * The line number is dropped (the file opens at the top); line-scrolling is future work. + */ +function normalizeLocalFilePathHref(rawHref: string): string { + let href = rawHref.trim(); + href = href.replace(/^\/([a-zA-Z]:[\\/])/, "$1"); + href = href.replace(/:[0-9]+(?::[0-9]+)?$/, ""); + return href; +} + export function useMarkdownLinkNavigation(options: UseMarkdownLinkNavigationOptions = {}) { const browserClient = useMemo(() => createBrowserClient(), []); const workspaceClient = useMemo(() => createWorkspaceClient(), []); @@ -95,9 +116,10 @@ export function useMarkdownLinkNavigation(options: UseMarkdownLinkNavigationOpti const openLocalFile = async (fileHref: string): Promise => { const { sessionId: ctxSessionId, workspacePath, sourceFilePath: ctxSourceFilePath } = getSessionContext(); + const normalizedHref = normalizeLocalFilePathHref(fileHref); const resolution = await workspaceClient.resolveMarkdownLinkedFile({ workspacePath, - href: fileHref, + href: normalizedHref, sourceFilePath: ctxSourceFilePath, }); @@ -107,10 +129,18 @@ export function useMarkdownLinkNavigation(options: UseMarkdownLinkNavigationOpti } if (ctxSessionId) { - selectFile(ctxSessionId, resolution.path, { - open: true, - viewMode: "preview", - }); + // In-workspace files open in the Diffs tab (the diff is usually what + // you want from a chat link); files outside the workspace fall back to + // the workspace file viewer (no diff available for other projects). + if (isPathWithinWorkspace(resolution.path, workspacePath)) { + setDiffsSelection(resolution.path); + openDiffs(); + } else { + selectFile(ctxSessionId, resolution.path, { + open: true, + viewMode: "preview", + }); + } return true; } diff --git a/packages/ui/src/components/sidepanel/ChatSidePanel.tsx b/packages/ui/src/components/sidepanel/ChatSidePanel.tsx index bfab3b429..fbfde1b5d 100644 --- a/packages/ui/src/components/sidepanel/ChatSidePanel.tsx +++ b/packages/ui/src/components/sidepanel/ChatSidePanel.tsx @@ -4,6 +4,7 @@ import { Button } from "#shadcn/components/ui/button"; import { createBrowserClient } from "#api/BrowserClient"; import { BrowserPanel } from "./BrowserPanel"; import { WorkspacePanel } from "./WorkspacePanel"; +import { DiffsPanel } from "./DiffsPanel"; import { WORKSPACE_EVENTS } from "#/events"; import { useSidepanelStore } from "#/stores/ui/sidepanel"; @@ -239,6 +240,17 @@ export function ChatSidePanel({ sessionId, workspacePath }: ChatSidePanelProps) > Workspace + + {changes.map((change) => { + const meta = getStatusMeta(change); + const active = selectedPath === change.path; + return ( + + ); + })} + + +
+ {!selectionReady || loadingPatch ? ( +
Loading...
+ ) : ( + + )} +
+ + )} + + ); +} diff --git a/packages/ui/src/components/sidepanel/TreesFileTree.tsx b/packages/ui/src/components/sidepanel/TreesFileTree.tsx new file mode 100644 index 000000000..724a1744f --- /dev/null +++ b/packages/ui/src/components/sidepanel/TreesFileTree.tsx @@ -0,0 +1,408 @@ +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { FileTree, useFileTree } from "@pierre/trees/react"; +import { prepareFileTreeInput } from "@pierre/trees"; +import { Icon } from "@iconify/react"; +import { createWorkspaceClient } from "#api/WorkspaceClient"; +import { useSidepanelStore } from "#/stores/ui/sidepanel"; +import type { WorkspaceGitChangeType, WorkspaceFileNode } from "@argos/shared/presenter"; + +/** Trees status vocabulary. */ +type TreesGitStatus = "added" | "deleted" | "ignored" | "modified" | "renamed" | "untracked"; +type TreesGitStatusEntry = { path: string; status: TreesGitStatus }; +type TreesContextItem = { kind: "directory" | "file"; name: string; path: string }; +type TreesContextOpenContext = { + anchorRect: { top: number; right: number; bottom: number; left: number }; + close: () => void; +}; + +/** Eager-load safety caps so huge repos do not block the sidepanel. */ +const MAX_TREE_DEPTH = 6; +const MAX_TREE_NODES = 8000; + +const mapGitStatus = (type: WorkspaceGitChangeType): TreesGitStatus => { + switch (type) { + case "added": + return "added"; + case "deleted": + return "deleted"; + case "renamed": + return "renamed"; + case "untracked": + return "untracked"; + case "ignored": + return "ignored"; + case "copied": + case "unmerged": + case "modified": + default: + return "modified"; + } +}; + +const trimTrailingSep = (value: string) => value.replace(/[\\/]+$/, ""); + +/** Absolute OS path -> forward-slash path relative to the workspace root. */ +const toRelativePath = (workspacePath: string, absolutePath: string): string => { + const root = trimTrailingSep(workspacePath); + let rel = absolutePath; + if (rel.toLowerCase().startsWith(root.toLowerCase())) { + rel = rel.slice(root.length); + } + return rel + .replace(/^[\\/]+/, "") + .split(/[\\/]+/) + .filter(Boolean) + .join("/"); +}; + +/** Forward-slash relative path -> absolute OS path (trailing slash stripped). */ +const toAbsolutePath = (workspacePath: string, relativePath: string): string => + `${trimTrailingSep(workspacePath)}${"/"}${relativePath.replace(/[\\/]+$/, "")}`; + +const getBasename = (relativePath: string) => { + const segments = relativePath.split("/").filter(Boolean); + return segments[segments.length - 1] ?? relativePath; +}; + +const getParentRelative = (relativePath: string): string => { + const segments = relativePath.split("/").filter(Boolean); + segments.pop(); + return segments.join("/"); +}; + +interface TreesFileTreeProps { + workspacePath: string; + sessionId: string; + onInsertFileReference?: (filePath: string) => void; +} + +/** + * File-tree surface backed by `@pierre/trees`. Replaces the hand-rolled + * `WorkspaceFileNode` renderer. Trees owns selection/focus/search/rename/dnd; + * this adapter feeds it paths + git status from the workspace presenter and + * persists mutations (rename/move/create/delete) back through the typed client. + * + * Paths are exchanged with Trees as forward-slash relative strings (stable + * cross-platform identity) and converted to absolute paths at the client boundary. + */ +export function TreesFileTree({ workspacePath, sessionId, onInsertFileReference }: TreesFileTreeProps) { + const workspaceClient = useMemo(() => createWorkspaceClient(), []); + const sidepanelStore = useSidepanelStore(); + + const [paths, setPaths] = useState([]); + const [gitStatus, setGitStatus] = useState([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + const reloadTokenRef = useRef(0); + + const collectPaths = useCallback( + async (dirPath: string, depth: number, acc: string[]): Promise => { + if (depth > MAX_TREE_DEPTH || acc.length > MAX_TREE_NODES) return; + let nodes: WorkspaceFileNode[]; + try { + nodes = (await workspaceClient.expandDirectory(dirPath)) as WorkspaceFileNode[]; + } catch { + return; + } + for (const node of nodes) { + if (acc.length > MAX_TREE_NODES) break; + const relativePath = toRelativePath(workspacePath, node.path); + // Trees infers "directory" from a trailing slash (canonical dir path), so + // mark dirs explicitly — otherwise empty folders render as files. + acc.push(node.isDirectory ? `${relativePath}/` : relativePath); + if (node.isDirectory) { + await collectPaths(node.path, depth + 1, acc); + } + } + }, + [workspaceClient, workspacePath], + ); + + const loadGitStatus = useCallback(async (): Promise => { + const state = await workspaceClient.getGitStatus(workspacePath); + if (!state) return []; + return state.changes.map((change) => ({ + path: change.relativePath, + status: mapGitStatus(change.type), + })); + }, [workspaceClient, workspacePath]); + + const reload = useCallback(async () => { + const token = ++reloadTokenRef.current; + setLoading(true); + setError(null); + try { + const collected: string[] = []; + await collectPaths(workspacePath, 0, collected); + const status = await loadGitStatus(); + if (token !== reloadTokenRef.current) return; + setPaths(collected); + setGitStatus(status); + } catch (err) { + console.error("[TreesFileTree] reload failed", err); + if (token === reloadTokenRef.current) setError("Failed to load workspace"); + } finally { + if (token === reloadTokenRef.current) setLoading(false); + } + }, [collectPaths, loadGitStatus, workspacePath]); + + const handleSelectionChange = useCallback( + (selected: readonly string[]) => { + const first = selected[0]; + if (!first) return; + sidepanelStore.selectFile(sessionId, toAbsolutePath(workspacePath, first), { open: false }); + }, + [sessionId, sidepanelStore, workspacePath], + ); + + const persistMove = useCallback( + async (sourcePath: string, destinationPath: string) => { + try { + await workspaceClient.renameOrMovePath( + toAbsolutePath(workspacePath, sourcePath), + toAbsolutePath(workspacePath, destinationPath), + ); + } catch (err) { + console.error("[TreesFileTree] move/rename failed", err); + } + }, + [workspaceClient, workspacePath], + ); + + const handleRename = useCallback( + (event: { sourcePath: string; destinationPath: string }) => { + void persistMove(event.sourcePath, event.destinationPath); + }, + [persistMove], + ); + + const handleDropComplete = useCallback( + (event: { draggedPaths: readonly string[]; target: { directoryPath: string | null } }) => { + const targetDir = event.target.directoryPath + ? toAbsolutePath(workspacePath, event.target.directoryPath) + : workspacePath; + for (const dragged of event.draggedPaths) { + const fromAbs = toAbsolutePath(workspacePath, dragged); + const toAbs = `${trimTrailingSep(targetDir)}/${getBasename(dragged)}`; + if (fromAbs === toAbs) continue; + void persistMove(dragged, toRelativePath(workspacePath, toAbs)); + } + }, + [persistMove, workspaceClient, workspacePath], + ); + + const handleCreate = useCallback( + async (parentRelative: string | null, name: string, isDirectory: boolean) => { + const parentAbs = parentRelative ? toAbsolutePath(workspacePath, parentRelative) : workspacePath; + try { + await workspaceClient.createEntry(parentAbs, name, isDirectory); + } catch (err) { + console.error("[TreesFileTree] create failed", err); + } + }, + [workspaceClient, workspacePath], + ); + + const handleDelete = useCallback( + async (relativePath: string) => { + try { + await workspaceClient.deletePath(toAbsolutePath(workspacePath, relativePath)); + } catch (err) { + console.error("[TreesFileTree] delete failed", err); + } + }, + [workspaceClient, workspacePath], + ); + + const { model } = useFileTree({ + preparedInput: prepareFileTreeInput([]), + gitStatus: [], + search: true, + fileTreeSearchMode: "hide-non-matches", + onSelectionChange: handleSelectionChange, + renaming: { + canRename: () => true, + onError: (message) => console.warn("[TreesFileTree] rename error", message), + onRename: handleRename, + }, + dragAndDrop: { + canDrag: () => true, + canDrop: () => true, + onDropComplete: handleDropComplete, + onDropError: (message) => console.warn("[TreesFileTree] drop error", message), + }, + initialExpansion: "closed", + }); + + // `useFileTree` reads options once; push path/git updates through the model. + // Use preparedInput (not raw `paths`) so Trees resolves directory/file kinds + // up front — raw paths can throw "Path collides with an existing file" when a + // node is a parent of a later path. + useEffect(() => { + try { + model.resetPaths({ preparedInput: prepareFileTreeInput(paths) }); + } catch (error) { + console.error("[TreesFileTree] resetPaths failed", error); + } + }, [model, paths]); + + useEffect(() => { + model.setGitStatus(gitStatus); + }, [model, gitStatus]); + + // Register + watch before the first read so the daemon allow-list is populated + // (avoids an empty-tree race when this mounts before the panel-level sync registers). + useEffect(() => { + let cancelled = false; + let off: (() => void) | undefined; + void (async () => { + try { + await workspaceClient.registerWorkspace(workspacePath); + await workspaceClient.watchWorkspace(workspacePath); + } catch (error) { + console.error("[TreesFileTree] register/watch failed", error); + } + if (cancelled) { + void workspaceClient.unwatchWorkspace(workspacePath); + return; + } + off = workspaceClient.onInvalidated((payload) => { + if (payload.workspacePath !== workspacePath) return; + void reload(); + }); + void reload(); + })(); + return () => { + cancelled = true; + off?.(); + void workspaceClient.unwatchWorkspace(workspacePath); + }; + }, [workspacePath, reload, workspaceClient]); + + const renderContextMenu = useCallback( + (item: TreesContextItem, context: TreesContextOpenContext) => { + const isDir = item.kind === "directory"; + const parentRelative = isDir ? item.path : getParentRelative(item.path); + const style = { left: context.anchorRect.left, top: context.anchorRect.bottom }; + const close = () => context.close(); + return ( +
+ {isDir && ( + <> + { + const name = window.prompt("New file name"); + if (name) void handleCreate(item.path, name, false); + close(); + }} + /> + { + const name = window.prompt("New folder name"); + if (name) void handleCreate(item.path, name, true); + close(); + }} + /> + + + )} + {!isDir && onInsertFileReference && ( + { + onInsertFileReference(toAbsolutePath(workspacePath, item.path)); + close(); + }} + /> + )} + { + void workspaceClient.revealFileInFolder(toAbsolutePath(workspacePath, item.path)); + close(); + }} + /> + + { + if (window.confirm(`Delete ${item.name}?`)) void handleDelete(item.path); + close(); + }} + /> + {!isDir && ( + { + const name = window.prompt("New file name"); + if (name) void handleCreate(parentRelative, name, false); + close(); + }} + /> + )} +
+ ); + }, + [handleCreate, handleDelete, onInsertFileReference, workspaceClient, workspacePath], + ); + + if (loading && paths.length === 0) { + return
Loading files...
; + } + + if (error) { + return
{error}
; + } + + if (paths.length === 0) { + return
Empty workspace
; + } + + return ( +
+ +
+ ); +} + +interface MenuButtonProps { + label: string; + icon: string; + destructive?: boolean; + onClick: () => void; +} + +function MenuButton({ label, icon, destructive, onClick }: MenuButtonProps) { + return ( + + ); +} + +function MenuDivider() { + return
; +} diff --git a/packages/ui/src/components/sidepanel/WorkspacePanel.tsx b/packages/ui/src/components/sidepanel/WorkspacePanel.tsx index 5f69cee92..80d8f6a1d 100644 --- a/packages/ui/src/components/sidepanel/WorkspacePanel.tsx +++ b/packages/ui/src/components/sidepanel/WorkspacePanel.tsx @@ -6,14 +6,14 @@ import { createFileClient } from "#api/FileClient"; import { createProjectClient } from "#api/ProjectClient"; import { createWorkspaceClient } from "#api/WorkspaceClient"; import { extractArtifactsFromContent } from "#/composables/useArtifacts"; -import WorkspaceFileNode from "#/components/workspace/WorkspaceFileNode"; +import { TreesFileTree } from "./TreesFileTree"; import { WorkspaceViewer } from "./WorkspaceViewer"; import { useWorkspaceSync } from "./composables/useWorkspaceSync"; import { useArtifactStore } from "#/stores/artifact"; import { useMessageStore, getMessages } from "#/stores/ui/message"; import { useSidepanelStore, getSessionState, type WorkspaceArtifactContext } from "#/stores/ui/sidepanel"; import { useSessionStore } from "#/stores/ui/session"; -import type { WorkspaceGitFileChange, WorkspaceNavSection } from "@argos/shared/presenter"; +import type { WorkspaceNavSection } from "@argos/shared/presenter"; interface WorkspacePanelProps { sessionId: string; @@ -37,8 +37,6 @@ type ArtifactItem = WorkspaceArtifactContext & { const NAV_COLLAPSED_WIDTH = 38; -const formatGitFlag = (change: WorkspaceGitFileChange) => change.stagedStatus || change.unstagedStatus || "M"; - const getArtifactIcon = (type: string) => { switch (type) { case "application/vnd.ant.code": @@ -74,19 +72,12 @@ export function WorkspacePanel({ const projectClient = useMemo(() => createProjectClient(), []); const fileClient = useMemo(() => createFileClient(), []); - const sessionState = useMemo(() => getSessionState(sessionId), [sessionId]); + // Read reactively from the store (NOT memoized by sessionId) so selection / + // section state changes propagate to useWorkspaceSync immediately. + const sessionState = getSessionState(sessionId); const navCollapsed = sidepanelStore.navCollapsed; - const { - fileTree, - selectedFilePreview, - selectedGitDiff, - gitState, - loadingFiles, - loadingFilePreview, - loadingGitDiff, - toggleNode, - } = useWorkspaceSync({ + const { selectedFilePreview, selectedGitDiff, loadingFilePreview, loadingGitDiff } = useWorkspaceSync({ sessionId: useMemo(() => sessionId, [sessionId]), workspacePath: useMemo(() => workspacePath, [workspacePath]), active: useMemo(() => sidepanelStore.open, [sidepanelStore.open]), @@ -231,20 +222,6 @@ export function WorkspacePanel({ return () => stopNavResize(); }, []); - const handleFileSelect = useCallback( - (filePath: string) => { - sidepanelStore.selectFile(sessionId, filePath, { open: false, viewMode: "preview" }); - }, - [sidepanelStore, sessionId], - ); - - const handleDiffSelect = useCallback( - (filePath: string) => { - sidepanelStore.selectDiff(sessionId, filePath, { open: false }); - }, - [sidepanelStore, sessionId], - ); - const handleArtifactSelect = useCallback( (item: ArtifactItem) => { artifactStore.showArtifact( @@ -362,8 +339,8 @@ export function WorkspacePanel({ className="h-3.5 w-3.5 shrink-0" /> -
-
+
+
- {gitState && ( -
- - {!navCollapsed && sessionState.sections.git && ( -
- {gitState.changes.map((change) => ( - - ))} - {gitState.changes.length === 0 && ( -
No changes
- )} -
- )} -
- )} - {artifactItems.length > 0 && ( -
+
- {shouldShowTabs && ( + {shouldShowTabs && !editMode && (
+ )} + {editMode && ( + + )} + - {openFilePath && ( + {openFilePath && !editMode && ( @@ -157,65 +211,83 @@ export function WorkspaceViewer({
- {paneKind === "empty" && !(activeSource === "file" && loadingFilePreview) && ( -
-
{emptyMessage}
-
- )} + {editMode && openFilePath ? ( + + ) : ( + <> + {paneKind === "empty" && !(activeSource === "file" && loadingFilePreview) && ( +
+
{emptyMessage}
+
+ )} - {activeSource === "file" && loadingFilePreview && ( -
- Loading... -
- )} + {activeSource === "file" && loadingFilePreview && ( +
+ Loading... +
+ )} - {paneKind === "git-diff" && !(activeSource === "file" && loadingFilePreview) && ( -
- {loadingGitDiff ? ( -
Loading...
- ) : gitDiff ? ( - <> - {gitDiff.staged && ( -
-

- Staged -

- -
- )} - {gitDiff.unstaged && ( -
-

- Unstaged -

- -
+ {paneKind === "git-diff" && !(activeSource === "file" && loadingFilePreview) && ( +
+ {loadingGitDiff ? ( +
Loading...
+ ) : gitDiff ? ( + <> + {gitDiff.staged && ( +
+

+ Staged +

+
+ +
+
+ )} + {gitDiff.unstaged && ( +
+

+ Unstaged +

+
+ +
+
+ )} + {!gitDiff.staged && !gitDiff.unstaged && ( +
No changes
+ )} + + ) : ( +
No changes
)} - {!gitDiff.staged && !gitDiff.unstaged &&
No changes
} - - ) : ( -
No changes
+
)} -
- )} - {paneKind === "code" && codeSource && } + {paneKind === "code" && codeSource && } - {paneKind === "preview" && previewKind && ( - - )} + {paneKind === "preview" && previewKind && ( + + )} - {paneKind === "info" && filePreview && } + {paneKind === "info" && filePreview && } - {!["empty", "git-diff", "code", "preview", "info"].includes(paneKind) && ( -
- Workspace -
+ {!["empty", "git-diff", "code", "preview", "info"].includes(paneKind) && ( +
+ Workspace +
+ )} + )}
diff --git a/packages/ui/src/components/sidepanel/viewer/DiffsCodePane.tsx b/packages/ui/src/components/sidepanel/viewer/DiffsCodePane.tsx new file mode 100644 index 000000000..f11dc05d3 --- /dev/null +++ b/packages/ui/src/components/sidepanel/viewer/DiffsCodePane.tsx @@ -0,0 +1,54 @@ +import { useMemo } from "react"; +import { File } from "@pierre/diffs/react"; +import { useDiffsBaseOptions } from "./diffsOptions"; + +export type DiffsCodeSource = { + id: string; + content: string; + name?: string; + language?: string | null; +}; + +interface DiffsCodePaneProps { + source: DiffsCodeSource; + className?: string; +} + +/** + * Read-only code surface backed by `@pierre/diffs` `` (Shiki highlight). + * Replaces the read-only Monaco viewer so code, diffs, and the Diffs tab share + * one rendering pipeline and theme. + * + * `disableWorkerPool` runs Shiki on the main thread, avoiding Vite worker-URL + * plumbing for the sidepanel's typical file sizes. + */ +export function DiffsCodePane({ source, className }: DiffsCodePaneProps) { + const base = useDiffsBaseOptions(); + + const file = useMemo( + () => ({ + name: source.name ?? source.id, + contents: source.content ?? "", + }), + [source.name, source.id, source.content], + ); + + const options = useMemo( + () => ({ + ...base, + disableLineNumbers: false, + overflow: "wrap" as const, + stickyHeader: false, + }), + [base], + ); + + return ( +
+ +
+ ); +} diff --git a/packages/ui/src/components/sidepanel/viewer/DiffsEditorPane.tsx b/packages/ui/src/components/sidepanel/viewer/DiffsEditorPane.tsx new file mode 100644 index 000000000..28c3362cb --- /dev/null +++ b/packages/ui/src/components/sidepanel/viewer/DiffsEditorPane.tsx @@ -0,0 +1,116 @@ +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { EditProvider, File, type CreateEditor } from "@pierre/diffs/react"; +import { Editor } from "@pierre/diffs/edit"; +import { Icon } from "@iconify/react"; +import { toast } from "sonner"; +import { Button } from "#shadcn/components/ui/button"; +import { createWorkspaceClient } from "#api/WorkspaceClient"; +import { useDiffsBaseOptions } from "./diffsOptions"; + +interface DiffsEditorPaneProps { + filePath: string; + initialContent: string; + language?: string | null; + onSaved?: () => void; +} + +/** + * Writable code editor backed by `@pierre/diffs` (`EditProvider` + `Editor` + + * ``). Same Shiki rendering as the read-only viewer and the diff + * surfaces, so view/edit/diff share one pipeline. Saves via `workspace.writeFile`; + * dirty indicator + Cmd/Ctrl+S. + * + * Mount this component keyed by file path so each file gets a fresh editor. + */ +export function DiffsEditorPane({ filePath, initialContent, language, onSaved }: DiffsEditorPaneProps) { + const base = useDiffsBaseOptions(); + const workspaceClient = createWorkspaceClient(); + + const [dirty, setDirty] = useState(false); + const [saving, setSaving] = useState(false); + const originalRef = useRef(initialContent); + const editorRef = useRef | null>(null); + + const fileBasename = useMemo(() => { + const segments = filePath.split(/[\\/]+/).filter(Boolean); + return segments[segments.length - 1] ?? filePath; + }, [filePath]); + + // `language` is a hint; Shiki infers from the filename, so we don't force it. + void language; + + const file = useMemo(() => ({ name: fileBasename, contents: initialContent }), [fileBasename, initialContent]); + + const options = useMemo( + () => ({ ...base, disableLineNumbers: false, overflow: "scroll" as const, stickyHeader: false }), + [base], + ); + + const createEditor = useCallback>((editorOptions) => { + const editor = new Editor({ + ...editorOptions, + onChange: (changedFile, lineAnnotations, event) => { + editorOptions.onChange?.(changedFile, lineAnnotations, event); + setDirty(editor.getText() !== originalRef.current); + }, + }); + editorRef.current = editor; + return editor; + }, []); + + const handleSave = useCallback(async () => { + const editor = editorRef.current; + if (!editor || !dirty || saving) return; + setSaving(true); + try { + const text = editor.getText(); + await workspaceClient.writeFile(filePath, text); + originalRef.current = text; + setDirty(false); + onSaved?.(); + } catch (error) { + console.error("[DiffsEditorPane] save failed", error); + toast.error(`Failed to save ${fileBasename}`); + } finally { + setSaving(false); + } + }, [dirty, saving, editorRef, workspaceClient, filePath, onSaved, fileBasename]); + + useEffect(() => { + const onKeyDown = (event: KeyboardEvent) => { + const isSave = (event.metaKey || event.ctrlKey) && (event.key === "s" || event.key === "S"); + if (isSave) { + event.preventDefault(); + void handleSave(); + } + }; + window.addEventListener("keydown", onKeyDown); + return () => window.removeEventListener("keydown", onKeyDown); + }, [handleSave]); + + return ( +
+
+
+ {fileBasename} + {dirty && ● unsaved} +
+ +
+
+ + + +
+
+ ); +} diff --git a/packages/ui/src/components/sidepanel/viewer/DiffsPatchPane.tsx b/packages/ui/src/components/sidepanel/viewer/DiffsPatchPane.tsx new file mode 100644 index 000000000..32875ea56 --- /dev/null +++ b/packages/ui/src/components/sidepanel/viewer/DiffsPatchPane.tsx @@ -0,0 +1,53 @@ +import { useMemo } from "react"; +import { PatchDiff } from "@pierre/diffs/react"; +import { useDiffsBaseOptions } from "./diffsOptions"; + +interface DiffsPatchPaneProps { + /** + * Unified diff patch text (e.g. `git diff` output). May contain any number of + * file diffs — `` only accepts exactly one, so this splits the + * patch on `diff --git` boundaries and renders one `` per file. + */ + patch: string; + diffStyle?: "split" | "unified"; + className?: string; +} + +const splitIntoFilePatches = (patch: string): string[] => { + const segments = patch + .split(/(?=^diff --git )/m) + .map((segment) => segment.trim()) + .filter(Boolean); + // Skip segments with no hunks (e.g. "Binary files … differ") — PatchDiff + // cannot render them. + return segments.filter((segment) => segment.includes("@@")); +}; + +export function DiffsPatchPane({ patch, diffStyle = "unified", className }: DiffsPatchPaneProps) { + const base = useDiffsBaseOptions(); + const filePatches = useMemo(() => splitIntoFilePatches(patch), [patch]); + + const options = useMemo( + () => ({ ...base, diffStyle, disableLineNumbers: false, stickyHeader: true }), + [base, diffStyle], + ); + + if (filePatches.length === 0) { + return ( +
+ No changes +
+ ); + } + + return ( +
+ {filePatches.map((filePatch, index) => ( + + ))} +
+ ); +} diff --git a/packages/ui/src/components/sidepanel/viewer/WorkspaceCodePane.tsx b/packages/ui/src/components/sidepanel/viewer/WorkspaceCodePane.tsx deleted file mode 100644 index 4b94c3752..000000000 --- a/packages/ui/src/components/sidepanel/viewer/WorkspaceCodePane.tsx +++ /dev/null @@ -1,222 +0,0 @@ -import { useEffect, useMemo, useRef, useState, useCallback } from "react"; -import { useMonaco } from "stream-monaco"; -import { useThemeStore } from "#/stores/theme"; -import { useUiSettingsStore, getFormattedCodeFontFamily } from "#/stores/uiSettingsStore"; - -type WorkspaceCodeSource = { - id: string; - content: string; - language?: string | null; - type?: string; -}; - -interface WorkspaceCodePaneProps { - source: WorkspaceCodeSource; -} - -const LANGUAGE_ALIASES: Record = { - md: "markdown", - mdx: "markdown", - txt: "plaintext", - text: "plaintext", - plain: "plaintext", - htm: "html", - xhtml: "html", - js: "javascript", - jsx: "javascript", - cjs: "javascript", - mjs: "javascript", - ts: "typescript", - tsx: "typescript", - py: "python", - rb: "ruby", - rs: "rust", - yml: "yaml", - sh: "shell", - shell: "shell", - bash: "shell", - zsh: "shell", - ps1: "powershell", - docker: "dockerfile", - svg: "xml", -}; - -const sanitizeLanguage = (language: string | undefined | null): string => { - if (!language) return ""; - const normalized = language.trim().toLowerCase(); - return LANGUAGE_ALIASES[normalized] ?? normalized; -}; - -const resolveLanguage = (source: WorkspaceCodeSource): string => { - const explicit = sanitizeLanguage(source.language); - if (explicit) return explicit; - - const type = source.type?.trim().toLowerCase() ?? ""; - if (!type) return "plaintext"; - - switch (type) { - case "application/vnd.ant.code": - return "plaintext"; - case "text/markdown": - return "markdown"; - case "text/html": - case "application/xhtml+xml": - return "html"; - case "image/svg+xml": - return "xml"; - case "application/vnd.ant.mermaid": - return "plaintext"; - case "application/vnd.ant.react": - return "javascript"; - case "application/json": - case "application/ld+json": - return "json"; - case "application/xml": - return "xml"; - case "application/x-yaml": - case "application/yaml": - return "yaml"; - default: - if (type.endsWith("+json")) return "json"; - if (type.endsWith("+xml")) return "xml"; - if (type.startsWith("text/")) return "plaintext"; - return sanitizeLanguage(type) || "plaintext"; - } -}; - -export function WorkspaceCodePane({ source }: WorkspaceCodePaneProps) { - const uiSettingsStore = useUiSettingsStore(); - const themeStore = useThemeStore(); - const editorRef = useRef(null); - const [editorInitialized, setEditorInitialized] = useState(false); - const createEditorTaskRef = useRef | null>(null); - const resizeObserverRef = useRef(null); - - const resolvedTheme = useMemo(() => (themeStore.isDark ? "vitesse-dark" : "vitesse-light"), [themeStore.isDark]); - const resolvedLanguage = useMemo(() => resolveLanguage(source), [source]); - - const { createEditor, updateCode, cleanupEditor, getEditorView, getEditor } = useMonaco({ - readOnly: true, - domReadOnly: true, - automaticLayout: true, - wordWrap: "on", - wrappingIndent: "same", - scrollBeyondLastLine: false, - minimap: { enabled: false }, - lineNumbers: "on", - renderLineHighlight: "none", - contextmenu: false, - themes: ["vitesse-dark", "vitesse-light"], - theme: resolvedTheme, - fontFamily: getFormattedCodeFontFamily(), - padding: { top: 12, bottom: 12 }, - }); - - const applyFontFamily = useCallback( - (fontFamily: string) => { - getEditorView()?.updateOptions({ fontFamily }); - }, - [getEditorView], - ); - - const applyTheme = useCallback(async () => { - try { - getEditor().setTheme(resolvedTheme); - } catch (error) { - console.warn("[WorkspaceCodePane] Failed to apply Monaco theme:", error); - } - }, [getEditor, resolvedTheme]); - - const layoutEditor = useCallback(() => { - try { - getEditorView()?.layout(); - } catch (error) { - console.warn("[WorkspaceCodePane] Failed to layout Monaco editor:", error); - } - }, [getEditorView]); - - useEffect(() => { - const editorElement = editorRef.current; - if (!editorElement) return; - - const nextContent = source.content ?? ""; - const nextLanguage = resolvedLanguage; - const hasEditor = Boolean(editorElement.querySelector(".monaco-editor")); - - if (!hasEditor || !editorInitialized) { - if (createEditorTaskRef.current) return; - - createEditorTaskRef.current = (async () => { - await createEditor(editorElement, nextContent, nextLanguage); - setEditorInitialized(true); - await applyTheme(); - applyFontFamily(getFormattedCodeFontFamily()); - layoutEditor(); - })(); - - createEditorTaskRef.current.finally(() => { - createEditorTaskRef.current = null; - }); - return; - } - - updateCode(nextContent, nextLanguage); - layoutEditor(); - }, [source.id, source.content, resolvedLanguage, editorInitialized]); - - useEffect(() => { - applyFontFamily(getFormattedCodeFontFamily()); - }, [getFormattedCodeFontFamily()]); - - useEffect(() => { - if (editorInitialized) { - applyTheme(); - } - }, [resolvedTheme, editorInitialized]); - - useEffect(() => { - const element = editorRef.current; - if (!element || typeof ResizeObserver === "undefined") return; - - const observer = new ResizeObserver(() => { - layoutEditor(); - }); - observer.observe(element); - resizeObserverRef.current = observer; - - return () => { - observer.disconnect(); - resizeObserverRef.current = null; - cleanupEditor(); - setEditorInitialized(false); - createEditorTaskRef.current = null; - }; - }, []); - - return ( -
-
- -
- ); -} diff --git a/packages/ui/src/components/sidepanel/viewer/WorkspaceDiffView.tsx b/packages/ui/src/components/sidepanel/viewer/WorkspaceDiffView.tsx deleted file mode 100644 index 868922598..000000000 --- a/packages/ui/src/components/sidepanel/viewer/WorkspaceDiffView.tsx +++ /dev/null @@ -1,119 +0,0 @@ -import { useMemo } from "react"; - -type RowType = "add" | "del" | "context" | "hunk" | "meta"; - -interface DiffRow { - type: RowType; - text: string; - oldNum: number | null; - newNum: number | null; -} - -interface WorkspaceDiffViewProps { - diff: string; -} - -const HUNK_HEADER = /^@@ -(\d+)(?:,\d+)? \+(\d+)(?:,\d+)? @#/; - -const rowClass = (type: RowType): string => { - switch (type) { - case "add": - return "bg-emerald-500/10 text-emerald-700 dark:bg-emerald-500/15 dark:text-emerald-300"; - case "del": - return "bg-rose-500/10 text-rose-700 dark:bg-rose-500/15 dark:text-rose-300"; - case "hunk": - return "bg-sky-500/10 text-sky-700 dark:text-sky-300"; - case "meta": - return "text-muted-foreground/70"; - default: - return "text-foreground"; - } -}; - -const signFor = (type: RowType): string => { - if (type === "add") return "+"; - if (type === "del") return "-"; - return " "; -}; - -export function WorkspaceDiffView({ diff }: WorkspaceDiffViewProps) { - const rows = useMemo(() => { - const text = diff ?? ""; - if (!text) return []; - - const result: DiffRow[] = []; - let oldNum = 0; - let newNum = 0; - - for (const line of text.split("\n")) { - const hunk = HUNK_HEADER.exec(line); - if (hunk) { - oldNum = Number(hunk[1]); - newNum = Number(hunk[2]); - result.push({ type: "hunk", text: line, oldNum: null, newNum: null }); - continue; - } - - if ( - line.startsWith("diff ") || - line.startsWith("index ") || - line.startsWith("--- ") || - line.startsWith("+++ ") || - line.startsWith("old mode ") || - line.startsWith("new mode ") || - line.startsWith("new file") || - line.startsWith("deleted file") || - line.startsWith("rename ") || - line.startsWith("copy ") || - line.startsWith("similarity ") || - line.startsWith("dissimilarity ") || - line.startsWith("Binary files") || - line.startsWith("\\") - ) { - result.push({ type: "meta", text: line, oldNum: null, newNum: null }); - continue; - } - - if (line.startsWith("+")) { - result.push({ type: "add", text: line.slice(1), oldNum: null, newNum: newNum++ }); - continue; - } - - if (line.startsWith("-")) { - result.push({ type: "del", text: line.slice(1), oldNum: oldNum++, newNum: null }); - continue; - } - - const content = line.startsWith(" ") ? line.slice(1) : line; - result.push({ type: "context", text: content, oldNum: oldNum++, newNum: newNum++ }); - } - - const last = result[result.length - 1]; - if (last && last.type === "context" && last.text === "") { - result.pop(); - } - - return result; - }, [diff]); - - return ( -
- - - {rows.map((row, index) => ( - - - - - - - ))} - -
- {row.oldNum ?? ""} - - {row.newNum ?? ""} - {signFor(row.type)}{row.text}
-
- ); -} diff --git a/packages/ui/src/components/sidepanel/viewer/diffsOptions.ts b/packages/ui/src/components/sidepanel/viewer/diffsOptions.ts new file mode 100644 index 000000000..854866806 --- /dev/null +++ b/packages/ui/src/components/sidepanel/viewer/diffsOptions.ts @@ -0,0 +1,18 @@ +import { useMemo } from "react"; +import { useThemeStore } from "#/stores/theme"; + +/** + * Shared `@pierre/diffs` base options (theme + themeType) driven by the app + * theme store. Keeps DiffsCodePane / DiffsPatchPane / inline diff sections in + * sync with light/dark mode and the Pierre theme pair. + */ +export function useDiffsBaseOptions() { + const themeStore = useThemeStore(); + return useMemo( + () => ({ + theme: { dark: "pierre-dark", light: "pierre-light" } as const, + themeType: (themeStore.isDark ? "dark" : "light") as "dark" | "light", + }), + [themeStore.isDark], + ); +} diff --git a/packages/ui/src/components/trace/TraceDialog.tsx b/packages/ui/src/components/trace/TraceDialog.tsx index 80dff45c5..655603b19 100644 --- a/packages/ui/src/components/trace/TraceDialog.tsx +++ b/packages/ui/src/components/trace/TraceDialog.tsx @@ -5,8 +5,7 @@ import { Spinner } from "#shadcn/components/ui/spinner"; import { Icon } from "@iconify/react"; import { createDeviceClient } from "#api/DeviceClient"; import { createSessionClient } from "#api/SessionClient"; -import { useMonaco } from "stream-monaco"; -import { useUiSettingsStore, getFormattedCodeFontFamily } from "#/stores/uiSettingsStore"; +import { DiffsCodePane } from "#/components/sidepanel/viewer/DiffsCodePane"; import type { MessageTraceRecord } from "@argos/shared/types/agent-interface"; import type { ArgosTapeViewManifestRecord } from "@argos/shared/types/tape-view-manifest"; import ManifestPanel from "./ManifestPanel"; @@ -65,35 +64,13 @@ interface TraceDialogProps { } export default function TraceDialog({ messageId, sessionId, onClose }: TraceDialogProps) { - const uiSettingsStore = useUiSettingsStore(); - const jsonEditorRef = useRef(null); const [isOpen, setIsOpen] = useState(false); const [copySuccess, setCopySuccess] = useState(false); const requestIdRef = useRef(0); - const [editorInitialized, setEditorInitialized] = useState(false); const [loadState, dispatch] = useReducer(loadReducer, initialLoadState); const { loading, error, traces: traceList, selectedTraceId, manifests } = loadState; const [selectedManifestId, setSelectedManifestId] = useState(null); - const { cleanupEditor, getEditorView } = useMonaco({ - readOnly: true, - wordWrap: "off", - wrappingIndent: "same", - fontFamily: getFormattedCodeFontFamily(), - minimap: { enabled: false }, - scrollBeyondLastLine: true, - fontSize: 12, - lineNumbers: "on", - folding: true, - automaticLayout: true, - scrollbar: { - horizontal: "visible", - vertical: "visible", - horizontalScrollbarSize: 10, - verticalScrollbarSize: 10, - }, - }); - const selectedTrace = useMemo(() => { if (!traceList.length) return null; if (selectedTraceId) { @@ -168,21 +145,6 @@ export default function TraceDialog({ messageId, sessionId, onClose }: TraceDial } }, [isOpen]); - useEffect(() => { - const applyFontFamily = (fontFamily: string) => { - const editor = getEditorView(); - if (editor) editor.updateOptions({ fontFamily }); - }; - applyFontFamily(getFormattedCodeFontFamily()); - }, [getFormattedCodeFontFamily()]); - - useEffect(() => { - return () => { - cleanupEditor(); - setEditorInitialized(false); - }; - }, []); - const loadTraces = async (msgId: string) => { requestIdRef.current += 1; const currentRequestId = requestIdRef.current; @@ -227,8 +189,6 @@ export default function TraceDialog({ messageId, sessionId, onClose }: TraceDial const resetState = useCallback(() => { dispatch({ type: "reset" }); setCopySuccess(false); - cleanupEditor(); - setEditorInitialized(false); }, []); const close = useCallback(() => { @@ -333,15 +293,8 @@ export default function TraceDialog({ messageId, sessionId, onClose }: TraceDial {copySuccess ? "Copied!" : "Copy JSON"}
-
-
- {formattedJson && !editorInitialized && ( -
-
-                      {formattedJson}
-                    
-
- )} +
+
diff --git a/packages/ui/src/components/workspace/WorkspaceFileNode.tsx b/packages/ui/src/components/workspace/WorkspaceFileNode.tsx deleted file mode 100644 index 951bbcf2f..000000000 --- a/packages/ui/src/components/workspace/WorkspaceFileNode.tsx +++ /dev/null @@ -1,162 +0,0 @@ -import { useMemo } from "react"; -import { Icon } from "@iconify/react"; -import { Tooltip, TooltipContent, TooltipTrigger } from "#shadcn/components/ui/tooltip"; -import { - ContextMenu, - ContextMenuContent, - ContextMenuItem, - ContextMenuSeparator, - ContextMenuTrigger, -} from "#shadcn/components/ui/context-menu"; -import { createWorkspaceClient } from "#api/WorkspaceClient"; -import { setChatInputWorkspaceItemDragData } from "#/lib/chatInputWorkspaceReference"; -import type { WorkspaceFileNode as WorkspaceFileNodeType } from "@argos/shared/presenter"; - -interface WorkspaceFileNodeProps { - node: WorkspaceFileNodeType; - depth: number; - onToggle?: (node: WorkspaceFileNodeType) => void; - onAppendPath?: (filePath: string) => void; - onInsertPath?: (filePath: string) => void; -} - -const EXTENSION_ICON_MAP: Record = { - pdf: "lucide:file-text", - md: "lucide:file-text", - markdown: "lucide:file-text", - txt: "lucide:file-text", - js: "lucide:file-code", - ts: "lucide:file-code", - tsx: "lucide:file-code", - jsx: "lucide:file-code", - vue: "lucide:file-code", - json: "lucide:file-json", - yml: "lucide:file-cog", - yaml: "lucide:file-cog", - png: "lucide:image", - jpg: "lucide:image", - jpeg: "lucide:image", - gif: "lucide:image", - svg: "lucide:image", - mp4: "lucide:file-video", - mov: "lucide:file-video", - mp3: "lucide:music", - wav: "lucide:music", - zip: "lucide:archive", - tar: "lucide:archive", - gz: "lucide:archive", -}; - -export default function WorkspaceFileNode({ - node, - depth, - onToggle, - onAppendPath, - onInsertPath, -}: WorkspaceFileNodeProps) { - const workspaceClient = createWorkspaceClient(); - - const iconName = useMemo(() => { - if (node.isDirectory) { - return node.expanded ? "lucide:folder-open" : "lucide:folder-closed"; - } - const ext = node.name.split(".").pop()?.toLowerCase(); - if (ext && EXTENSION_ICON_MAP[ext]) { - return EXTENSION_ICON_MAP[ext]; - } - return "lucide:file"; - }, [node]); - - const handleClick = () => { - if (node.isDirectory) { - onToggle?.(node); - return; - } - onAppendPath?.(node.path); - }; - - const handleOpenFile = async () => { - if (node.isDirectory) return; - try { - await workspaceClient.openFile(node.path); - } catch (error) { - console.error(`[Workspace] Failed to open file: ${node.path}`, error); - } - }; - - const handleRevealInFolder = async () => { - try { - await workspaceClient.revealFileInFolder(node.path); - } catch (error) { - console.error(`[Workspace] Failed to reveal path: ${node.path}`, error); - } - }; - - const handleDragStart = (event: React.DragEvent) => { - setChatInputWorkspaceItemDragData(event.dataTransfer, { - path: node.path, - isDirectory: node.isDirectory, - }); - }; - - return ( -
- - - } - > - {node.isDirectory ? ( - - ) : ( - - )} - - {node.name} - - - {!node.isDirectory && ( - - - Open File - - )} - - - Reveal in Folder - - - onInsertPath?.(node.path)}> - - Insert Path - - - - - {node.isDirectory && - node.expanded && - node.children && - node.children.map((child) => ( - - ))} -
- ); -} diff --git a/packages/ui/src/stores/ui/sidepanel.ts b/packages/ui/src/stores/ui/sidepanel.ts index df54bc823..970c53528 100644 --- a/packages/ui/src/stores/ui/sidepanel.ts +++ b/packages/ui/src/stores/ui/sidepanel.ts @@ -66,6 +66,11 @@ export const sidepanelStore = new Store({ navCollapsed: loadFromStorage(NAV_COLLAPSED_KEY, false), navWidthStorage: loadFromStorage(NAV_WIDTH_KEY, NAV_DEFAULT_WIDTH), sessionStates: {} as Record, + // Diffs-tab selection (shared so chat links can open a file's diff directly). + // `diffsSelectionReady` gates the patch load so the tab never loads the slow + // full-workspace diff until a real selection exists (auto first-file or user click). + diffsSelectedPath: null as string | null, + diffsSelectionReady: false, }); const getNormalizedWidth = () => { @@ -168,6 +173,20 @@ export const openBrowser = () => { sidepanelStore.setState((prev) => ({ ...prev, open: true, activeTab: "browser" })); }; +export const openDiffs = () => { + sidepanelStore.setState((prev) => ({ ...prev, open: true, activeTab: "diffs" })); +}; + +/** Set the Diffs-tab selection (a file path, or null for "All changes") and mark it ready. */ +export const setDiffsSelection = (selectedPath: string | null) => { + sidepanelStore.setState((prev) => ({ ...prev, diffsSelectedPath: selectedPath, diffsSelectionReady: true })); +}; + +/** Clear the Diffs-tab selection (used when the workspace changes). */ +export const resetDiffsSelection = () => { + sidepanelStore.setState((prev) => ({ ...prev, diffsSelectedPath: null, diffsSelectionReady: false })); +}; + const closePanel = () => { sidepanelStore.setState((prev) => ({ ...prev, open: false })); }; @@ -332,6 +351,7 @@ export function useSidepanelStore() { setWidth, openWorkspace, openBrowser, + openDiffs, closePanel, toggleWorkspace, setViewMode, diff --git a/packages/ui/vite.config.ts b/packages/ui/vite.config.ts index d1dca0982..9ba2fc592 100644 --- a/packages/ui/vite.config.ts +++ b/packages/ui/vite.config.ts @@ -3,7 +3,6 @@ import { resolve } from "path"; import { defineConfig, loadEnv } from "vite"; import react, { reactCompilerPreset } from "@vitejs/plugin-react"; import { tanstackRouter } from "@tanstack/router-plugin/vite"; -import monacoEditorPlugin from "@dvaji/vite-plugin-monaco-editor"; import tailwindcss from "@tailwindcss/vite"; import babel from "@rolldown/plugin-babel"; import { createPathAliasPlugin } from "./vite-plugins/path-alias"; @@ -57,8 +56,7 @@ export default defineConfig(({ mode, command }) => { ], }, optimizeDeps: { - exclude: ["stream-monaco"], - include: ["@antv/infographic", "monaco-editor", "axios"], + include: ["@antv/infographic", "axios"], }, server: { host: "127.0.0.1", @@ -95,19 +93,6 @@ export default defineConfig(({ mode, command }) => { generatedRouteTree: resolve("src/routeTree.gen.ts"), }), tailwindcss(), - monacoEditorPlugin({ - languageWorkers: [], - customWorkers: [ - { label: "editorWorkerService", entry: "monaco-editor/esm/vs/editor/editor.worker.js" }, - { label: "typescript", entry: "monaco-editor/esm/vs/language/typescript/ts.worker.js" }, - { label: "css", entry: "monaco-editor/esm/vs/language/css/css.worker.js" }, - { label: "html", entry: "monaco-editor/esm/vs/language/html/html.worker.js" }, - { label: "json", entry: "monaco-editor/esm/vs/language/json/json.worker.js" }, - ], - customDistPath(_root, buildOutDir, _base) { - return path.resolve(buildOutDir, "monacoeditorwork"); - }, - }), react(), babel({ presets: [reactCompilerPreset()], From d190a995f01e3ca021426881532d8e29437605b0 Mon Sep 17 00:00:00 2001 From: Francisco Pizarro Date: Wed, 12 Aug 2026 00:14:45 -0400 Subject: [PATCH 2/2] fix(workspace): address review (read/write allow-list split, symlink guard) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Greptile P1: split read-authorization from mutation-authorization — exact-file paths resolved from chat links authorize read/preview only, not write/delete/rename (desktop + daemon). Copilot: writeFile now throws on unauthorized so Save surfaces failures instead of reporting false success; search skips symlinked dirs and tracks visited paths to prevent workspace escape/cycles; TreesFileTree ignores directory selections; spec Non-Goals/AC-8 updated to match the daemon-port + PatchDiff implementation. --- .../src/workspace/daemonWorkspacePresenter.ts | 70 +++++++++++++++---- .../presenter/workspacePresenter/index.ts | 37 +++++----- .../main/presenter/workspacePresenter.test.ts | 2 +- docs/features/trees-diffs-workspace/spec.md | 14 ++-- .../components/sidepanel/TreesFileTree.tsx | 3 + 5 files changed, 87 insertions(+), 39 deletions(-) diff --git a/apps/daemon/src/workspace/daemonWorkspacePresenter.ts b/apps/daemon/src/workspace/daemonWorkspacePresenter.ts index ee53c1d51..9da2ea8bd 100644 --- a/apps/daemon/src/workspace/daemonWorkspacePresenter.ts +++ b/apps/daemon/src/workspace/daemonWorkspacePresenter.ts @@ -171,12 +171,23 @@ export class DaemonWorkspacePresenter { await this.unregisterWorkspace(workdir); } - /** Public for the HTTP preview endpoint. */ + /** Public for the HTTP preview endpoint. Authorizes reads/previews only. */ isPathAllowed(targetPath: string): boolean { + return ( + this.isPathWithinRegisteredWorkspace(targetPath) || + this.allowedExactPaths.has(this.normalizePathForAccess(targetPath)) + ); + } + + /** + * Stricter check for mutations (write/delete/rename/create): the target must + * live inside a *registered workspace*. Exact-file authorization (granted to + * external files resolved from chat links, for read/preview only) intentionally + * does NOT grant mutation access — otherwise a resolved external file could be + * overwritten/deleted. + */ + private isPathWithinRegisteredWorkspace(targetPath: string): boolean { const normalizedTarget = this.normalizePathForAccess(targetPath); - if (this.allowedExactPaths.has(normalizedTarget)) { - return true; - } const targetWithSep = normalizedTarget.endsWith(path.sep) ? normalizedTarget : `${normalizedTarget}${path.sep}`; for (const workspace of this.allowedPaths) { const normalizedWorkspace = this.normalizePathForAccess(workspace); @@ -570,7 +581,9 @@ export class DaemonWorkspacePresenter { } async writeFile(filePath: string, content: string): Promise { - if (!this.isPathAllowed(filePath)) return; + if (!this.isPathWithinRegisteredWorkspace(filePath)) { + throw new Error(`[DaemonWorkspace] Unauthorized write: ${filePath}`); + } const normalizedPath = path.resolve(filePath); await fsp.mkdir(path.dirname(normalizedPath), { recursive: true }); await fsp.writeFile(normalizedPath, content, "utf8"); @@ -578,22 +591,32 @@ export class DaemonWorkspacePresenter { async createEntry(parentDir: string, name: string, isDirectory: boolean): Promise { if (!isSafeEntryName(name)) throw new Error(`[DaemonWorkspace] Invalid entry name: ${name}`); - if (!this.isPathAllowed(parentDir)) throw new Error(`[DaemonWorkspace] Unauthorized parent: ${parentDir}`); + if (!this.isPathWithinRegisteredWorkspace(parentDir)) { + throw new Error(`[DaemonWorkspace] Unauthorized parent: ${parentDir}`); + } const targetPath = path.join(path.resolve(parentDir), name); - if (!this.isPathAllowed(targetPath)) throw new Error(`[DaemonWorkspace] Unauthorized entry path: ${targetPath}`); + if (!this.isPathWithinRegisteredWorkspace(targetPath)) { + throw new Error(`[DaemonWorkspace] Unauthorized entry path: ${targetPath}`); + } if (isDirectory) await fsp.mkdir(targetPath, { recursive: false }); else await fsp.writeFile(targetPath, "", "utf8"); return targetPath; } async deletePath(targetPath: string): Promise { - if (!this.isPathAllowed(targetPath)) throw new Error(`[DaemonWorkspace] Unauthorized path: ${targetPath}`); + if (!this.isPathWithinRegisteredWorkspace(targetPath)) { + throw new Error(`[DaemonWorkspace] Unauthorized path: ${targetPath}`); + } await fsp.rm(path.resolve(targetPath), { recursive: true, force: false }); } async renameOrMovePath(fromPath: string, toPath: string): Promise { - if (!this.isPathAllowed(fromPath)) throw new Error(`[DaemonWorkspace] Unauthorized source: ${fromPath}`); - if (!this.isPathAllowed(toPath)) throw new Error(`[DaemonWorkspace] Unauthorized target: ${toPath}`); + if (!this.isPathWithinRegisteredWorkspace(fromPath)) { + throw new Error(`[DaemonWorkspace] Unauthorized source: ${fromPath}`); + } + if (!this.isPathWithinRegisteredWorkspace(toPath)) { + throw new Error(`[DaemonWorkspace] Unauthorized target: ${toPath}`); + } const resolvedTo = path.resolve(toPath); await fsp.mkdir(path.dirname(resolvedTo), { recursive: true }); await fsp.rename(path.resolve(fromPath), resolvedTo); @@ -751,10 +774,11 @@ export class DaemonWorkspacePresenter { // ---- search ---- async searchFiles(workspacePath: string, query: string): Promise { - if (!this.isPathAllowed(workspacePath) || !query.trim()) return []; + if (!this.isPathWithinRegisteredWorkspace(workspacePath) || !query.trim()) return []; const results: WorkspaceFileNode[] = []; const needle = query.toLowerCase(); - await this.collectSearchMatches(path.resolve(workspacePath), needle, "", results, SEARCH_MAX_RESULTS); + const visited = new Set(); + await this.collectSearchMatches(path.resolve(workspacePath), needle, "", results, SEARCH_MAX_RESULTS, visited); return results; } @@ -764,8 +788,20 @@ export class DaemonWorkspacePresenter { relativePrefix: string, results: WorkspaceFileNode[], limit: number, + visited: Set, ): Promise { if (results.length >= limit) return; + // Cycle guard: track visited real paths so a symlink loop can't exhaust CPU/IO. + let resolvedDir: string; + try { + resolvedDir = this.normalizePathForAccess(dirPath); + } catch { + return; + } + const key = `${resolvedDir}\0`; + if (visited.has(key)) return; + visited.add(key); + let names: string[]; try { names = (await fsp.readdir(dirPath)) as string[]; @@ -778,18 +814,22 @@ export class DaemonWorkspacePresenter { continue; } const childPath = path.join(dirPath, name); - let isDirectory = false; + // Use lstat to detect symlinks: don't follow them (avoids escaping the + // workspace via a symlinked directory or traversing external/ancestor dirs). + let lstat: fs.Stats; try { - isDirectory = (await fsp.stat(childPath)).isDirectory(); + lstat = await fsp.lstat(childPath); } catch { continue; } + if (lstat.isSymbolicLink()) continue; + const isDirectory = lstat.isDirectory(); const relativePath = relativePrefix ? `${relativePrefix}/${name}` : name; if (name.toLowerCase().includes(needle)) { results.push({ name, path: childPath, isDirectory }); } if (isDirectory) { - await this.collectSearchMatches(childPath, needle, relativePath, results, limit); + await this.collectSearchMatches(childPath, needle, relativePath, results, limit, visited); } } } diff --git a/apps/desktop/src/main/presenter/workspacePresenter/index.ts b/apps/desktop/src/main/presenter/workspacePresenter/index.ts index 0707dabb7..b257486ad 100644 --- a/apps/desktop/src/main/presenter/workspacePresenter/index.ts +++ b/apps/desktop/src/main/presenter/workspacePresenter/index.ts @@ -408,13 +408,23 @@ export class WorkspacePresenter implements IWorkspacePresenter { * Uses realpathSync when possible and falls back to resolved paths for deleted files. */ private isPathAllowed(targetPath: string): boolean { + return ( + this.isPathWithinRegisteredWorkspace(targetPath) || + this.allowedExactPaths.has(this.normalizePathForAccess(targetPath)) + ); + } + + /** + * Stricter check for mutations (write/delete/rename/create): the target must + * live inside a *registered workspace*. Exact-file authorization (granted to + * external files resolved from markdown links, for read/preview only) does NOT + * grant mutation access — otherwise a resolved external file could be + * overwritten/deleted. + */ + private isPathWithinRegisteredWorkspace(targetPath: string): boolean { const normalizedTarget = this.normalizePathForAccess(targetPath); const targetWithSep = normalizedTarget.endsWith(path.sep) ? normalizedTarget : `${normalizedTarget}${path.sep}`; - if (this.allowedExactPaths.has(normalizedTarget)) { - return true; - } - for (const workspace of this.allowedPaths) { const normalizedWorkspace = this.normalizePathForAccess(workspace); const workspaceWithSep = normalizedWorkspace.endsWith(path.sep) @@ -978,9 +988,8 @@ export class WorkspacePresenter implements IWorkspacePresenter { } async writeFile(filePath: string, content: string): Promise { - if (!this.isPathAllowed(filePath)) { - console.warn(`[Workspace] Blocked write attempt for unauthorized path: ${filePath}`); - return; + if (!this.isPathWithinRegisteredWorkspace(filePath)) { + throw new Error(`[Workspace] Unauthorized write: ${filePath}`); } const normalizedPath = path.resolve(filePath); @@ -998,14 +1007,13 @@ export class WorkspacePresenter implements IWorkspacePresenter { throw new Error(`[Workspace] Invalid entry name: ${name}`); } - if (!this.isPathAllowed(parentDir)) { - console.warn(`[Workspace] Blocked create attempt for unauthorized parent: ${parentDir}`); + if (!this.isPathWithinRegisteredWorkspace(parentDir)) { throw new Error(`[Workspace] Unauthorized parent directory: ${parentDir}`); } const resolvedParent = path.resolve(parentDir); const targetPath = path.join(resolvedParent, name); - if (!this.isPathAllowed(targetPath)) { + if (!this.isPathWithinRegisteredWorkspace(targetPath)) { throw new Error(`[Workspace] Resolved entry path is not allowed: ${targetPath}`); } @@ -1023,8 +1031,7 @@ export class WorkspacePresenter implements IWorkspacePresenter { } async deletePath(targetPath: string): Promise { - if (!this.isPathAllowed(targetPath)) { - console.warn(`[Workspace] Blocked delete attempt for unauthorized path: ${targetPath}`); + if (!this.isPathWithinRegisteredWorkspace(targetPath)) { throw new Error(`[Workspace] Unauthorized path: ${targetPath}`); } @@ -1038,12 +1045,10 @@ export class WorkspacePresenter implements IWorkspacePresenter { } async renameOrMovePath(fromPath: string, toPath: string): Promise { - if (!this.isPathAllowed(fromPath)) { - console.warn(`[Workspace] Blocked rename source attempt for unauthorized path: ${fromPath}`); + if (!this.isPathWithinRegisteredWorkspace(fromPath)) { throw new Error(`[Workspace] Unauthorized source path: ${fromPath}`); } - if (!this.isPathAllowed(toPath)) { - console.warn(`[Workspace] Blocked rename target attempt for unauthorized path: ${toPath}`); + if (!this.isPathWithinRegisteredWorkspace(toPath)) { throw new Error(`[Workspace] Unauthorized target path: ${toPath}`); } diff --git a/apps/desktop/test/main/presenter/workspacePresenter.test.ts b/apps/desktop/test/main/presenter/workspacePresenter.test.ts index b315b5b46..01f94b691 100644 --- a/apps/desktop/test/main/presenter/workspacePresenter.test.ts +++ b/apps/desktop/test/main/presenter/workspacePresenter.test.ts @@ -642,7 +642,7 @@ describe("WorkspacePresenter file editing", () => { it("does not write outside an allowed workspace", async () => { const file = path.join(outsidePath, "nope.txt"); - await presenter.writeFile(file, "x"); + await expect(presenter.writeFile(file, "x")).rejects.toThrow(); expect(fs.existsSync(file)).toBe(false); }); diff --git a/docs/features/trees-diffs-workspace/spec.md b/docs/features/trees-diffs-workspace/spec.md index 0aa23be20..02d0be574 100644 --- a/docs/features/trees-diffs-workspace/spec.md +++ b/docs/features/trees-diffs-workspace/spec.md @@ -37,18 +37,18 @@ Replace the workspace sidepanel's tree, code viewer, and diff renderer with the - **AC-4** Drag-and-drop in the tree moves files/directories on disk and refreshes the tree. - **AC-5** Context menu + tree affordances support "New File" and "New Folder" creation and "Delete". - **AC-6** Git-status row signals (added/modified/deleted/untracked/...) render in the tree via Trees' built-in `gitStatus`. -- **AC-7** Selecting a changed file in the Git section renders its diff with `@pierre/diffs ` (staged + unstaged). -- **AC-8** A new top-level **Diffs** tab exists beside Workspace/Browser; it lists all changed files (from `getGitStatus`) and renders them via `@pierre/diffs ` (virtualized multi-file) using the workspace's unified diff. -- **AC-9** All write operations enforce the existing workspace path allow-list (`isPathAllowed`); unauthorized paths are rejected. +- **AC-7** Selecting a changed file in the Diffs tab renders its diff with `@pierre/diffs ` (staged + unstaged, split per file). +- **AC-8** A new top-level **Diffs** tab exists beside Workspace/Browser; it lists all changed files (from `getGitStatus`) and renders each via `@pierre/diffs ` (one per file / selection), using the workspace's unified diff. (A virtualized `` multi-file view is a future enhancement.) +- **AC-9** All write operations enforce the registered-workspace boundary (`isPathWithinRegisteredWorkspace`); paths outside a registered workspace are rejected (throw). Exact-file authorization granted to chat-link-resolved files is read/preview-only and does NOT permit mutation. - **AC-10** Write operations trigger the existing `workspace.invalidated` invalidation flow so the tree/diffs refresh. - **AC-11** `bun run typecheck`, `bun run lint`, `bun run format`, and the relevant `bun test` suites pass. - **AC-12** `@argos/ui` build (`bun run build`) succeeds with the new dependencies bundled. ## Constraints -- Follow the typed route/client boundary: new capabilities go through `shared-contracts/routes`, `routes/index.ts` dispatcher, `WorkspacePresenter`, and `WorkspaceClient`. No new `window.api`/legacy paths. -- All filesystem writes must be inside a registered workspace/workdir (security boundary already enforced by `isPathAllowed`). -- Desktop is the primary target (the presenter lives in main). The daemon/headless path only needs to remain non-breaking for the existing read routes; write routes are desktop-only for now. +- Follow the typed route/client boundary: new capabilities go through `shared-contracts/routes`, the dispatcher, the presenter, and `WorkspaceClient`. No new `window.api`/legacy paths. +- All filesystem writes must be inside a registered workspace/workdir. Read/preview also accepts exact-file-authorized paths (resolved from chat links), but mutations do not. +- Workspace routes are implemented in the **daemon** (`DaemonWorkspacePresenter`) so they work in desktop and web/headless mode; only `revealFileInFolder`/`openFile` stay desktop-only (Electron `shell`). - Do not regress the markdown/html/pdf/svg/image preview pane or the artifact viewer. - Editing uses `@pierre/diffs` (`EditProvider` + `Editor` + ``); Monaco is removed entirely. - `@pierre/trees` is `1.0.0-beta.x`; pin a caret range and treat beta API drift as a tracked risk. @@ -56,8 +56,8 @@ Replace the workspace sidepanel's tree, code viewer, and diff renderer with the ## Non-Goals - Replace the markdown/html/image/pdf preview pane with `@pierre/diffs`. -- Port write/edit routes to the daemon (web/headless mode stays read-only for workspace files in this iteration). - Multi-file staging/unstaging/commit actions in the Diffs tab (future work). +- Virtualized `` multi-file Diffs view (current impl uses per-file ``). - Migrating the unrelated "WorkspaceSelector" (machine switcher) feature. ## Open Questions diff --git a/packages/ui/src/components/sidepanel/TreesFileTree.tsx b/packages/ui/src/components/sidepanel/TreesFileTree.tsx index 724a1744f..01566acab 100644 --- a/packages/ui/src/components/sidepanel/TreesFileTree.tsx +++ b/packages/ui/src/components/sidepanel/TreesFileTree.tsx @@ -150,6 +150,9 @@ export function TreesFileTree({ workspacePath, sessionId, onInsertFileReference (selected: readonly string[]) => { const first = selected[0]; if (!first) return; + // Trees marks directories with a trailing slash; opening a directory in the + // file viewer would only fail (no file preview), so ignore dir selections. + if (first.endsWith("/")) return; sidepanelStore.selectFile(sessionId, toAbsolutePath(workspacePath, first), { open: false }); }, [sessionId, sidepanelStore, workspacePath],