From c5fa43c96ac09216dcf537515a42857f97bb881c Mon Sep 17 00:00:00 2001 From: Michael Ramos Date: Wed, 17 Jun 2026 07:19:46 -0700 Subject: [PATCH 1/9] feat(annotate): add live file tree workspace status --- apps/pi-extension/package.json | 1 + .../pi-extension/server/file-browser-watch.ts | 179 +++++++++++ apps/pi-extension/server/reference.ts | 26 +- apps/pi-extension/server/serverAnnotate.ts | 3 + apps/pi-extension/server/serverPlan.ts | 3 + apps/pi-extension/vendor.sh | 2 +- bun.lock | 2 + packages/editor/App.tsx | 146 +++++---- packages/editor/directEdits.test.ts | 24 -- packages/editor/directEdits.ts | 33 -- packages/server/annotate.ts | 8 + packages/server/index.ts | 8 + packages/server/package.json | 1 + packages/server/reference-handlers.test.ts | 53 +++- packages/server/reference-handlers.ts | 25 +- packages/server/reference-watch.test.ts | 72 +++++ packages/server/reference-watch.ts | 187 +++++++++++ packages/shared/package.json | 3 +- packages/shared/workspace-status.test.ts | 78 +++++ packages/shared/workspace-status.ts | 294 ++++++++++++++++++ packages/ui/components/AnnotationPanel.tsx | 10 +- .../ui/components/sidebar/FileBrowser.tsx | 132 ++++++-- packages/ui/hooks/useFileBrowser.ts | 110 ++++++- packages/ui/theme.css | 54 ++++ 24 files changed, 1285 insertions(+), 169 deletions(-) create mode 100644 apps/pi-extension/server/file-browser-watch.ts create mode 100644 packages/server/reference-watch.test.ts create mode 100644 packages/server/reference-watch.ts create mode 100644 packages/shared/workspace-status.test.ts create mode 100644 packages/shared/workspace-status.ts diff --git a/apps/pi-extension/package.json b/apps/pi-extension/package.json index 06eea1c8a0..f519361254 100644 --- a/apps/pi-extension/package.json +++ b/apps/pi-extension/package.json @@ -41,6 +41,7 @@ "dependencies": { "@joplin/turndown-plugin-gfm": "^1.0.64", "@pierre/diffs": "1.2.8", + "chokidar": "^5.0.0", "parse5": "^7.3.0", "turndown": "^7.2.4" }, diff --git a/apps/pi-extension/server/file-browser-watch.ts b/apps/pi-extension/server/file-browser-watch.ts new file mode 100644 index 0000000000..b0cc6f6aaf --- /dev/null +++ b/apps/pi-extension/server/file-browser-watch.ts @@ -0,0 +1,179 @@ +import chokidar, { type FSWatcher } from "chokidar"; +import { existsSync, statSync } from "node:fs"; +import type { IncomingMessage, ServerResponse } from "node:http"; +import { isAbsolute, relative } from "node:path"; + +import { FILE_BROWSER_EXCLUDED } from "../generated/reference-common.js"; +import { resolveUserPath } from "../generated/resolve-file.js"; +import { getGitMetadataWatchPaths } from "../generated/workspace-status.js"; +import { json } from "./helpers.js"; + +interface FileBrowserChangeEvent { + type: "ready" | "changed"; + dirPath: string; + reason: "files" | "git" | "initial"; + timestamp: number; +} + +interface WatchEntry { + dirPath: string; + subscribers: Set; + contentWatcher: FSWatcher | null; + gitWatcher: FSWatcher | null; + debounceTimer: ReturnType | null; +} + +const HEARTBEAT_MS = 30_000; +const DEBOUNCE_MS = 180; +const watchers = new Map(); + +function serialize(event: FileBrowserChangeEvent): string { + return `data: ${JSON.stringify(event)}\n\n`; +} + +function isExcludedPath(path: string, root: string): boolean { + const rel = relative(root, path).replace(/\\/g, "/"); + if (!rel || rel.startsWith("..") || isAbsolute(rel)) return false; + return FILE_BROWSER_EXCLUDED.some((entry) => { + const name = entry.replace(/\/$/, ""); + return rel === name || rel.startsWith(`${name}/`) || rel.includes(`/${name}/`); + }); +} + +function isValidDirectory(dirPath: string): boolean { + try { + return existsSync(dirPath) && statSync(dirPath).isDirectory(); + } catch { + return false; + } +} + +function broadcast(entry: WatchEntry, reason: FileBrowserChangeEvent["reason"]): void { + const payload = serialize({ + type: "changed", + dirPath: entry.dirPath, + reason, + timestamp: Date.now(), + }); + for (const res of entry.subscribers) { + try { + res.write(payload); + } catch { + entry.subscribers.delete(res); + } + } +} + +function scheduleBroadcast(entry: WatchEntry, reason: "files" | "git"): void { + if (entry.debounceTimer) clearTimeout(entry.debounceTimer); + entry.debounceTimer = setTimeout(() => { + entry.debounceTimer = null; + broadcast(entry, reason); + }, DEBOUNCE_MS); +} + +function closeWatcher(entry: WatchEntry): void { + if (entry.debounceTimer) clearTimeout(entry.debounceTimer); + void entry.contentWatcher?.close(); + void entry.gitWatcher?.close(); + watchers.delete(entry.dirPath); +} + +function releaseSubscriber(entry: WatchEntry, res: ServerResponse): void { + entry.subscribers.delete(res); + if (entry.subscribers.size === 0) closeWatcher(entry); +} + +function ensureWatcher(dirPath: string): WatchEntry { + const existing = watchers.get(dirPath); + if (existing) return existing; + + const entry: WatchEntry = { + dirPath, + subscribers: new Set(), + contentWatcher: null, + gitWatcher: null, + debounceTimer: null, + }; + + entry.contentWatcher = chokidar.watch(dirPath, { + ignoreInitial: true, + persistent: true, + ignored: (path) => isExcludedPath(path, dirPath), + awaitWriteFinish: { + stabilityThreshold: 120, + pollInterval: 30, + }, + }); + entry.contentWatcher.on("all", () => scheduleBroadcast(entry, "files")); + entry.contentWatcher.on("error", () => scheduleBroadcast(entry, "files")); + + const gitWatchPaths = getGitMetadataWatchPaths(dirPath); + if (gitWatchPaths.length > 0) { + entry.gitWatcher = chokidar.watch(gitWatchPaths, { + ignoreInitial: true, + persistent: true, + awaitWriteFinish: { + stabilityThreshold: 80, + pollInterval: 30, + }, + }); + entry.gitWatcher.on("all", () => scheduleBroadcast(entry, "git")); + entry.gitWatcher.on("error", () => scheduleBroadcast(entry, "git")); + } + + watchers.set(dirPath, entry); + return entry; +} + +export function handleFileBrowserStreamRequest(req: IncomingMessage, res: ServerResponse, url: URL): boolean { + if (url.pathname !== "/api/reference/files/stream" || req.method !== "GET") return false; + + const rawDirPaths = url.searchParams.getAll("dirPath"); + if (rawDirPaths.length === 0) { + json(res, { error: "Missing dirPath parameter" }, 400); + return true; + } + + const dirPaths: string[] = []; + for (const rawDirPath of rawDirPaths) { + const dirPath = resolveUserPath(rawDirPath); + if (!isValidDirectory(dirPath)) { + json(res, { error: "Invalid directory path" }, 400); + return true; + } + if (!dirPaths.includes(dirPath)) dirPaths.push(dirPath); + } + + const entries = dirPaths.map((dirPath) => ensureWatcher(dirPath)); + res.writeHead(200, { + "Content-Type": "text/event-stream", + "Cache-Control": "no-cache", + Connection: "keep-alive", + }); + res.setTimeout(0); + for (const entry of entries) { + res.write(serialize({ + type: "ready", + dirPath: entry.dirPath, + reason: "initial", + timestamp: Date.now(), + })); + entry.subscribers.add(res); + } + + const heartbeat = setInterval(() => { + try { + res.write(": heartbeat\n\n"); + } catch { + for (const entry of entries) releaseSubscriber(entry, res); + clearInterval(heartbeat); + } + }, HEARTBEAT_MS); + + res.on("close", () => { + clearInterval(heartbeat); + for (const entry of entries) releaseSubscriber(entry, res); + }); + return true; +} diff --git a/apps/pi-extension/server/reference.ts b/apps/pi-extension/server/reference.ts index 598e318331..12b8c1a726 100644 --- a/apps/pi-extension/server/reference.ts +++ b/apps/pi-extension/server/reference.ts @@ -22,6 +22,11 @@ import { buildFileTree, FILE_BROWSER_EXCLUDED, } from "../generated/reference-common.js"; +import { + getWorkspaceStatusForDirectory, + getWorkspaceStatusRelativePaths, + type WorkspaceFileChange, +} from "../generated/workspace-status.js"; import { detectObsidianVaults } from "../generated/integrations-common.js"; import { isAbsoluteUserPath, @@ -177,7 +182,9 @@ function jsonDoc(res: Res, data: Record, options?: HandleDocOpt } /** Recursively walk a directory collecting files by extension, skipping ignored dirs. */ -function walkMarkdownFiles(dir: string, root: string, results: string[], extensions: RegExp = /\.(mdx?|txt|html?)$/i): void { +const FILE_BROWSER_EXTENSIONS = /\.(mdx?|txt|html?)$/i; + +function walkMarkdownFiles(dir: string, root: string, results: string[], extensions: RegExp = FILE_BROWSER_EXTENSIONS): void { let entries: Dirent[]; try { entries = readdirSync(dir, { withFileTypes: true }) as Dirent[]; @@ -197,6 +204,10 @@ function walkMarkdownFiles(dir: string, root: string, results: string[], extensi } } +function includeWorkspaceFile(relativePath: string, _change: WorkspaceFileChange): boolean { + return FILE_BROWSER_EXTENSIONS.test(relativePath); +} + /** Serve a linked markdown document. Uses shared resolveMarkdownFile for parity with Bun server. */ export async function handleDocRequest(res: Res, url: URL, options: HandleDocOptions = {}): Promise { const requestedPath = url.searchParams.get("path"); @@ -506,10 +517,15 @@ export function handleFileBrowserRequest(res: Res, url: URL): void { return; } try { - const files: string[] = []; - walkMarkdownFiles(resolvedDir, resolvedDir, files); - files.sort(); - json(res, { tree: buildFileTree(files) }); + const files = new Set(); + const diskFiles: string[] = []; + walkMarkdownFiles(resolvedDir, resolvedDir, diskFiles); + for (const file of diskFiles) files.add(file); + const workspaceStatus = getWorkspaceStatusForDirectory(resolvedDir); + for (const file of getWorkspaceStatusRelativePaths(workspaceStatus, resolvedDir, includeWorkspaceFile)) { + files.add(file); + } + json(res, { tree: buildFileTree([...files].sort()), workspaceStatus }); } catch { json(res, { error: "Failed to list directory files" }, 500); } diff --git a/apps/pi-extension/server/serverAnnotate.ts b/apps/pi-extension/server/serverAnnotate.ts index 720692f2e5..67c8e92d4d 100644 --- a/apps/pi-extension/server/serverAnnotate.ts +++ b/apps/pi-extension/server/serverAnnotate.ts @@ -34,6 +34,7 @@ import { handleObsidianFilesRequest, handleObsidianDocRequest, } from "./reference.js"; +import { handleFileBrowserStreamRequest } from "./file-browser-watch.js"; import { warmFileListCache } from "../generated/resolve-file.js"; import { createExternalAnnotationHandler } from "./external-annotations.js"; import { @@ -431,6 +432,8 @@ export async function startAnnotateServer(options: { handleObsidianDocRequest(res, url); } else if (url.pathname === "/api/reference/files" && req.method === "GET") { handleFileBrowserRequest(res, url); + } else if (handleFileBrowserStreamRequest(req, res, url)) { + return; } else if (url.pathname === "/favicon.svg") { handleFavicon(res); } else if (url.pathname === "/api/exit" && req.method === "POST") { diff --git a/apps/pi-extension/server/serverPlan.ts b/apps/pi-extension/server/serverPlan.ts index e6d170893b..6f33940ff4 100644 --- a/apps/pi-extension/server/serverPlan.ts +++ b/apps/pi-extension/server/serverPlan.ts @@ -50,6 +50,7 @@ import { handleObsidianFilesRequest, handleObsidianVaultsRequest, } from "./reference.js"; +import { handleFileBrowserStreamRequest } from "./file-browser-watch.js"; import { warmFileListCache } from "../generated/resolve-file.js"; export interface PlanReviewDecision { @@ -284,6 +285,8 @@ export async function startPlanReviewServer(options: { handleObsidianDocRequest(res, url); } else if (url.pathname === "/api/reference/files" && req.method === "GET") { handleFileBrowserRequest(res, url); + } else if (handleFileBrowserStreamRequest(req, res, url)) { + return; } else if ( url.pathname === "/api/plan/vscode-diff" && req.method === "POST" diff --git a/apps/pi-extension/vendor.sh b/apps/pi-extension/vendor.sh index 29c4c96275..c62f3d57fe 100755 --- a/apps/pi-extension/vendor.sh +++ b/apps/pi-extension/vendor.sh @@ -7,7 +7,7 @@ cd "$(dirname "$0")" rm -rf generated mkdir -p generated generated/ai/providers -for f in feedback-templates prompts review-core diff-paths cli-pagination jj-core vcs-core review-args storage draft project pr-types pr-provider pr-stack pr-github pr-gitlab checklist integrations-common repo reference-common favicon code-file resolve-file config external-annotation agent-jobs worktree worktree-pool html-to-markdown html-assets html-assets-node url-to-markdown tour annotate-args at-reference review-workspace-node review-workspace pfm-reminder improvement-hooks code-nav data-dir semantic-diff-types semantic-diff source-save source-save-node; do +for f in feedback-templates prompts review-core diff-paths cli-pagination jj-core vcs-core review-args storage draft project pr-types pr-provider pr-stack pr-github pr-gitlab checklist integrations-common repo reference-common favicon code-file resolve-file config external-annotation agent-jobs worktree worktree-pool html-to-markdown html-assets html-assets-node url-to-markdown tour annotate-args at-reference review-workspace-node review-workspace pfm-reminder improvement-hooks code-nav data-dir semantic-diff-types semantic-diff source-save source-save-node workspace-status; do src="../../packages/shared/$f.ts" printf '// @generated — DO NOT EDIT. Source: packages/shared/%s.ts\n' "$f" | cat - "$src" > "generated/$f.ts" done diff --git a/bun.lock b/bun.lock index f8ca25beb1..499e014479 100644 --- a/bun.lock +++ b/bun.lock @@ -90,6 +90,7 @@ "dependencies": { "@joplin/turndown-plugin-gfm": "^1.0.64", "@pierre/diffs": "1.2.8", + "chokidar": "^5.0.0", "parse5": "^7.3.0", "turndown": "^7.2.4", }, @@ -216,6 +217,7 @@ "@pierre/diffs": "1.2.8", "@plannotator/ai": "workspace:*", "@plannotator/shared": "workspace:*", + "chokidar": "^5.0.0", }, "devDependencies": { "glimpseui": "^0.8.0", diff --git a/packages/editor/App.tsx b/packages/editor/App.tsx index 460ad10ee2..13608123aa 100644 --- a/packages/editor/App.tsx +++ b/packages/editor/App.tsx @@ -111,7 +111,6 @@ import { AppHeader } from './components/AppHeader'; import { buildDirectEditsSection, buildSavedFileChangesSection, - buildSourceFileDirectEditsSection, composeFeedbackWithEditSections, normalizeEditedMarkdown, } from './directEdits'; @@ -1543,11 +1542,15 @@ const App: React.FC = () => { setConfirmCancelEdits(false); }, [activeEditableDocument?.key]); - // True when there is anything the Direct Edits diff would carry. - const hasSourceBackedDirectEdits = unsavedEditableDocuments.length > 0; - const hasDirectEdits = hasSourceBackedDirectEdits - ? true - : isEditingMarkdown ? editorDiffersFromBaseline : editedMarkdownRef.current !== null; + const hasUnsavedSourceFileBuffers = unsavedEditableDocuments.length > 0; + + // True when the feedback payload carries unsaved direct edits. Source-backed + // file buffers are ordinary dirty editor state; they only become review + // context once saved to disk and tracked through savedFileChanges. + const hasDirectEdits = + !activeSourceSave && + !hasUnsavedSourceFileBuffers && + (isEditingMarkdown ? editorDiffersFromBaseline : editedMarkdownRef.current !== null); const hasSavedFileChanges = savedFileChanges.length > 0; const hasFeedbackToSend = hasAnyAnnotations || hasDirectEdits || hasSavedFileChanges; const feedbackLoss = feedbackLossDescription(feedbackAnnotationCount, hasDirectEdits); @@ -1563,22 +1566,24 @@ const App: React.FC = () => { : null; // Pinned "Direct edits" card data for the annotation sidebar. Source-backed - // folder edits render one card per dirty file so discard is file-scoped. + // documents show saved-to-disk changes only; dirty buffers stay in the editor + // and file tree until the user explicitly saves. const directEditsPanelInfo = useMemo(() => { - if (unsavedEditableDocuments.length > 0) { - return unsavedEditableDocuments.map((record) => { - const stats = computeEditStats(record.diskBaseline, record.currentText); + if (savedFileChanges.length > 0) { + return savedFileChanges.map((change) => { + const stats = computeEditStats(change.beforeText, change.afterText); return { - id: record.key, - sourceKey: record.key, - label: record.path ?? record.basename, + id: `saved:${change.key}`, + title: 'Saved edits', + label: change.path, added: stats.added, removed: stats.removed, + description: 'Saved to disk; sent with feedback as context.', diffText: createTwoFilesPatch( - `${record.path ?? record.basename} (saved)`, - `${record.path ?? record.basename} (edited)`, - record.diskBaseline, - record.currentText, + `${change.basename} (opened)`, + `${change.basename} (saved)`, + change.beforeText, + change.afterText, undefined, undefined, { context: 3 }, @@ -1587,28 +1592,8 @@ const App: React.FC = () => { }); } + if (activeEditableDocument?.sourceSave?.enabled) return null; if (!editStats) return null; - if (activeEditableDocument?.sourceSave?.enabled) { - const edited = activeEditableDocument.currentText; - const base = activeEditableDocument.diskBaseline; - if (edited === base) return null; - return [{ - id: activeEditableDocument.key, - sourceKey: activeEditableDocument.key, - label: activeEditableDocument.path ?? activeEditableDocument.basename, - added: editStats.added, - removed: editStats.removed, - diffText: createTwoFilesPatch( - `${activeEditableDocument.basename} (saved)`, - `${activeEditableDocument.basename} (edited)`, - base, - edited, - undefined, - undefined, - { context: 3 }, - ), - }]; - } const base = originalMarkdownRef.current; const edited = editedMarkdownRef.current; if (base === null || edited === null) return null; @@ -1618,26 +1603,15 @@ const App: React.FC = () => { removed: editStats.removed, diffText: createTwoFilesPatch('plan.md (original)', 'plan.md (edited)', base, edited, undefined, undefined, { context: 3 }), }]; - }, [activeEditableDocument, editStats, unsavedEditableDocuments]); + }, [activeEditableDocument, editStats, savedFileChanges]); // "Direct Edits" feedback section: unified diff of user edits vs the // as-submitted baseline. getEditedMarkdown owns the read discipline. const buildEditsSection = useCallback((): string => { - const sourceBackedEdits = editableDocuments.getUnsavedDocuments(); - if (sourceBackedEdits.length > 0) { - return buildSourceFileDirectEditsSection( - sourceBackedEdits.map((record) => ({ - path: record.path ?? record.basename, - basename: record.basename, - base: record.diskBaseline, - current: record.currentText, - })), - ); - } - + if (activeSourceSave || hasUnsavedSourceFileBuffers) return ''; const base = originalMarkdownRef.current; return buildDirectEditsSection(base, getEditedMarkdown(), sourceConverted); - }, [editableDocuments, getEditedMarkdown, sourceConverted]); + }, [activeSourceSave, getEditedMarkdown, hasUnsavedSourceFileBuffers, sourceConverted]); const buildSavedChangesSection = useCallback((): string => { return buildSavedFileChangesSection( @@ -2169,6 +2143,13 @@ const App: React.FC = () => { } }, []); + const warnFinishSourceFileEdits = useCallback(() => { + toast('Save or discard file edits first', { + description: 'Unsaved editor text is not sent as feedback until it is saved to disk.', + duration: 5000, + }); + }, []); + // Global keyboard shortcuts (Cmd/Ctrl+Enter to submit) useEffect(() => { const handleKeyDown = (e: KeyboardEvent) => { @@ -2196,6 +2177,11 @@ const App: React.FC = () => { // not the review session. if (isEditingMarkdown) return; + if (hasUnsavedSourceFileBuffers) { + warnFinishSourceFileEdits(); + return; + } + // Folder files are the active review target; normal linked docs are side // references and should not submit the root plan. if (linkedDocHook.isActive && annotateSource !== 'folder') return; @@ -2249,8 +2235,9 @@ const App: React.FC = () => { showExport, showImport, showFeedbackPrompt, showClaudeCodeWarning, showExitWarning, showAgentWarning, showPermissionModeSetup, pendingPasteImage, submitted, isSubmitting, isExiting, goalSetupAction.isSubmitting, isApiMode, isEditingMarkdown, linkedDocHook.isActive, annotations.length, codeAnnotations.length, externalAnnotations.length, annotateMode, - gate, hasFeedbackToSend, goalSetupMode, goalSetupAction.canSubmit, + gate, hasFeedbackToSend, hasUnsavedSourceFileBuffers, goalSetupMode, goalSetupAction.canSubmit, annotateSource, origin, getAgentWarning, + warnFinishSourceFileEdits, ]); const handleAddAnnotation = (ann: Annotation) => { @@ -2750,6 +2737,7 @@ const App: React.FC = () => { sourceSave: nextSourceSave, }); const normalizedEdited = edited.replace(/\r\n?/g, '\n'); + const savedChangedFromOpen = normalizedEdited !== activeDocument.sessionOpenText; editedMarkdownRef.current = null; if (editableDocuments.getActiveKey() === activeDocument.key) { const live = markdownEditorHandleRef.current?.getMarkdown(); @@ -2767,6 +2755,10 @@ const App: React.FC = () => { setEditStats(computeEditStats(normalizedEdited, currentText)); } } + if (savedChangedFromOpen && window.innerWidth >= 768) { + setRightSidebarTab('annotations'); + setIsPanelOpen(true); + } scheduleDraftSave(); toast.success(`Saved ${activeSourceSave.basename}`); return true; @@ -2913,16 +2905,24 @@ const App: React.FC = () => { }; const handleHeaderAnnotateExit = useCallback(() => { + if (hasUnsavedSourceFileBuffers) { + warnFinishSourceFileEdits(); + return; + } if (hasFeedbackToSend) { setExitWarningAction('close'); setShowExitWarning(true); } else { headerHandlersRef.current.handleAnnotateExit(); } - }, [hasFeedbackToSend]); + }, [hasFeedbackToSend, hasUnsavedSourceFileBuffers, warnFinishSourceFileEdits]); const handleHeaderFeedback = useCallback(() => { const h = headerHandlersRef.current; + if (hasUnsavedSourceFileBuffers) { + warnFinishSourceFileEdits(); + return; + } // Direct edits count as feedback — deny is the only Claude Code channel // whose output carries feedback to the agent. if (!hasFeedbackToSend) { @@ -2930,10 +2930,14 @@ const App: React.FC = () => { } else { h.handleDeny(); } - }, [hasFeedbackToSend]); + }, [hasFeedbackToSend, hasUnsavedSourceFileBuffers, warnFinishSourceFileEdits]); const handleHeaderApprove = useCallback(() => { const h = headerHandlersRef.current; + if (hasUnsavedSourceFileBuffers) { + warnFinishSourceFileEdits(); + return; + } if (annotateMode) { if (hasFeedbackToSend) { setExitWarningAction('approve'); @@ -2956,7 +2960,7 @@ const App: React.FC = () => { } } h.handleApprove(); - }, [annotateMode, hasFeedbackToSend, origin]); + }, [annotateMode, hasFeedbackToSend, hasUnsavedSourceFileBuffers, origin, warnFinishSourceFileEdits]); const handleHeaderAnnotateFeedback = useCallback(() => headerHandlersRef.current.handleAnnotateFeedback(), []); const handleHeaderAnnotateApprove = useCallback(() => headerHandlersRef.current.handleAnnotateApprove(), []); @@ -3031,7 +3035,7 @@ const App: React.FC = () => { aiAvailable={canUseAI} isAIChatOpen={isPanelOpen && rightSidebarTab === 'ai'} aiHasMessages={aiMessages.length > 0} - hasAnyAnnotations={hasAnyAnnotations || hasDirectEdits} + hasAnyAnnotations={hasAnyAnnotations || hasDirectEdits || hasSavedFileChanges} linkedDocIsActive={linkedDocHook.isActive} callbackShareUrlReady={callbackConfig ? Boolean(shareUrl || shortShareUrl || (renderAs === 'html' && (shareHtml || rawHtml))) : true} canShareCurrentSession={canShareCurrentSession} @@ -3347,14 +3351,6 @@ const App: React.FC = () => { : 'text-muted-foreground/50 hover:text-muted-foreground' }`} > - {/* Dot slot is always present — only its color changes — so the - button never reflows when edits appear/clear. */} - {/* Invisible widest label reserves the width so Save/Saving/Saved swap without nudging neighbors (font-agnostic, no fixed px). */} @@ -3367,6 +3363,14 @@ const App: React.FC = () => { : 'Saved'} + {/* Dot slot is always present — only its color changes — so the + button never reflows when edits appear/clear. */} + | @@ -3524,10 +3528,14 @@ const App: React.FC = () => { }} onShare={canShareCurrentSession ? () => { setIsPanelOpen(false); setInitialExportTab('share'); setShowExport(true); } : undefined} otherFileAnnotations={otherFileAnnotations} - directEdits={directEditsPanelInfo?.map((item) => ({ - ...item, - onDiscard: () => handleDiscardEdits(item.sourceKey), - })) ?? null} + directEdits={directEditsPanelInfo?.map((item) => { + const sourceKey = 'sourceKey' in item ? item.sourceKey : undefined; + const canDiscard = item.id === 'plan' || typeof sourceKey === 'string'; + return { + ...item, + onDiscard: canDiscard ? () => handleDiscardEdits(sourceKey) : undefined, + }; + }) ?? null} onOtherFileAnnotationsClick={handleFlashAnnotatedFiles} /> {isPanelOpen && rightSidebarTab === 'ai' && wideModeType === null && !goalSetupMode && canUseAI && ( diff --git a/packages/editor/directEdits.test.ts b/packages/editor/directEdits.test.ts index 82f33c3217..02abd91525 100644 --- a/packages/editor/directEdits.test.ts +++ b/packages/editor/directEdits.test.ts @@ -2,7 +2,6 @@ import { describe, expect, test } from 'bun:test'; import { buildDirectEditsSection, buildSavedFileChangesSection, - buildSourceFileDirectEditsSection, composeFeedbackWithEditSections, composeFeedbackWithDirectEdits, normalizeEditedMarkdown, @@ -38,29 +37,6 @@ describe('direct edit feedback helpers', () => { expect(composeFeedbackWithDirectEdits('A real annotation.', edits)).toBe(`${edits}\n\n---\n\nA real annotation.`); }); - test('builds source-file direct edits for unsaved folder files', () => { - const section = buildSourceFileDirectEditsSection([ - { - path: '/repo/docs/a.md', - basename: 'a.md', - base: '# A\n\nold\n', - current: '# A\n\nnew\n', - }, - { - path: '/repo/docs/b.txt', - basename: 'b.txt', - base: 'same\n', - current: 'same\n', - }, - ]); - - expect(section).toContain('Apply these unsaved changes'); - expect(section).toContain('## /repo/docs/a.md'); - expect(section).toContain('-old'); - expect(section).toContain('+new'); - expect(section).not.toContain('/repo/docs/b.txt'); - }); - test('includes saved file changes even when they are the only feedback', () => { const saved = buildSavedFileChangesSection([ { diff --git a/packages/editor/directEdits.ts b/packages/editor/directEdits.ts index 2c0210658c..f2f34735b8 100644 --- a/packages/editor/directEdits.ts +++ b/packages/editor/directEdits.ts @@ -6,13 +6,6 @@ const EMPTY_FEEDBACK_SENTINELS = new Set([ 'User reviewed the messages and has no feedback.', ]); -export interface DirectEditPatchInput { - path: string; - basename: string; - base: string; - current: string; -} - export interface SavedFileChangeInput { path: string; basename: string; @@ -60,32 +53,6 @@ export function composeFeedbackWithDirectEdits(annotationsText: string, editsSec : `${editsSection}\n\n---\n\n${annotationsText}`; } -export function buildSourceFileDirectEditsSection(edits: DirectEditPatchInput[]): string { - const changed = edits.filter((edit) => edit.base !== edit.current); - if (changed.length === 0) return ''; - - const sections = changed.map((edit) => { - const patch = createTwoFilesPatch( - `${edit.basename} (saved)`, - `${edit.basename} (edited)`, - edit.base, - edit.current, - undefined, - undefined, - { context: 3 }, - ); - return [`## ${edit.path}`, '', '```diff', patch.trimEnd(), '```'].join('\n'); - }); - - return [ - '# Direct Edits', - '', - 'The user edited local source files in Plannotator. Apply these unsaved changes:', - '', - ...sections, - ].join('\n\n'); -} - export function buildSavedFileChangesSection(changes: SavedFileChangeInput[]): string { const changed = changes.filter((change) => change.beforeText !== change.afterText); if (changed.length === 0) return ''; diff --git a/packages/server/annotate.ts b/packages/server/annotate.ts index b21924362c..552d1cc123 100644 --- a/packages/server/annotate.ts +++ b/packages/server/annotate.ts @@ -16,6 +16,7 @@ import { getRepoInfo } from "./repo"; import type { Origin } from "@plannotator/shared/agents"; import { handleImage, handleUpload, handleServerReady, handleDraftSave, handleDraftLoad, handleDraftDelete, handleFavicon, handleSaveNotes } from "./shared-handlers"; import { handleDoc, handleDocExists, handleFileBrowserFiles, handleObsidianVaults, handleObsidianFiles, handleObsidianDoc } from "./reference-handlers"; +import { handleFileBrowserFilesStream } from "./reference-watch"; import { warmFileListCache } from "@plannotator/shared/resolve-file"; import { contentHash, deleteDraft } from "./draft"; import { disabledSourceSave, type SourceSaveRequest } from "@plannotator/shared/source-save"; @@ -426,6 +427,13 @@ export async function startAnnotateServer( return handleFileBrowserFiles(req); } + // API: Watch file browser roots and refresh the tree/status snapshot on changes + if (url.pathname === "/api/reference/files/stream" && req.method === "GET") { + return handleFileBrowserFilesStream(req, { + disableIdleTimeout: () => server.timeout(req, 0), + }); + } + // API: Upload image -> save to temp -> return path if (url.pathname === "/api/upload" && req.method === "POST") { return handleUpload(req); diff --git a/packages/server/index.ts b/packages/server/index.ts index 03ffe22000..3a41390970 100644 --- a/packages/server/index.ts +++ b/packages/server/index.ts @@ -47,6 +47,7 @@ import { composeImproveContext } from "@plannotator/shared/pfm-reminder"; import { handleImage, handleUpload, handleAgents, handleServerReady, handleDraftSave, handleDraftLoad, handleDraftDelete, handleFavicon, handleSaveNotes, type OpencodeClient } from "./shared-handlers"; import { contentHash, deleteDraft } from "./draft"; import { handleDoc, handleDocExists, handleObsidianVaults, handleObsidianFiles, handleObsidianDoc, handleFileBrowserFiles } from "./reference-handlers"; +import { handleFileBrowserFilesStream } from "./reference-watch"; import { warmFileListCache } from "@plannotator/shared/resolve-file"; import { createEditorAnnotationHandler } from "./editor-annotations"; import { createExternalAnnotationHandler } from "./external-annotations"; @@ -396,6 +397,13 @@ export async function startPlannotatorServer( return handleFileBrowserFiles(req); } + // API: Watch file browser roots and refresh the tree/status snapshot on changes + if (url.pathname === "/api/reference/files/stream" && req.method === "GET") { + return handleFileBrowserFilesStream(req, { + disableIdleTimeout: () => server.timeout(req, 0), + }); + } + // API: Get available agents (OpenCode only) if (url.pathname === "/api/agents") { return handleAgents(options.opencodeClient); diff --git a/packages/server/package.json b/packages/server/package.json index 539a05ebab..03df64e7af 100644 --- a/packages/server/package.json +++ b/packages/server/package.json @@ -28,6 +28,7 @@ "*.ts" ], "dependencies": { + "chokidar": "^5.0.0", "@pierre/diffs": "1.2.8", "@plannotator/ai": "workspace:*", "@plannotator/shared": "workspace:*" diff --git a/packages/server/reference-handlers.test.ts b/packages/server/reference-handlers.test.ts index c0c37767c0..eeed6d0bc5 100644 --- a/packages/server/reference-handlers.test.ts +++ b/packages/server/reference-handlers.test.ts @@ -1,8 +1,11 @@ import { afterEach, describe, expect, test } from "bun:test"; -import { mkdtempSync, mkdirSync, rmSync, writeFileSync } from "node:fs"; +import { mkdtempSync, mkdirSync, realpathSync, rmSync, unlinkSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join, resolve } from "node:path"; -import { handleDoc, handleDocExists } from "./reference-handlers"; +import { spawnSync } from "node:child_process"; +import { handleDoc, handleDocExists, handleFileBrowserFiles } from "./reference-handlers"; +import type { VaultNode } from "@plannotator/shared/reference-common"; +import type { WorkspaceStatusPayload } from "@plannotator/shared/workspace-status"; const tempDirs: string[] = []; @@ -19,6 +22,22 @@ function writeTempFile(root: string, relativePath: string, content = "x"): strin return full; } +function git(cwd: string, ...args: string[]): void { + const result = spawnSync("git", args, { cwd, encoding: "utf8" }); + if (result.status !== 0) { + throw new Error(`git ${args.join(" ")} failed: ${result.stderr}`); + } +} + +function flattenTree(nodes: VaultNode[]): string[] { + const paths: string[] = []; + for (const node of nodes) { + if (node.type === "file") paths.push(node.path); + else paths.push(...flattenTree(node.children ?? [])); + } + return paths; +} + async function postDocExists(body: unknown, options: { rootPath?: string; rootPaths?: string[] }) { const res = await handleDocExists( new Request("http://localhost/api/doc/exists", { @@ -112,3 +131,33 @@ describe("handleDocExists", () => { expect(res.status).toBe(404); }); }); + +describe("handleFileBrowserFiles", () => { + test("returns git workspace status and keeps deleted tracked files in the tree", async () => { + const root = makeTempDir("plannotator-files-root-"); + git(root, "init", "-b", "main"); + git(root, "config", "user.email", "test@test"); + git(root, "config", "user.name", "Test"); + writeTempFile(root, "docs/plan.md", "one\ntwo\n"); + writeTempFile(root, "docs/gone.md", "remove me\n"); + git(root, "add", "-A"); + git(root, "commit", "-m", "init"); + + writeTempFile(root, "docs/plan.md", "one\nTWO\nthree\n"); + unlinkSync(join(root, "docs/gone.md")); + writeTempFile(root, "docs/new.md", "new\n"); + + const url = new URL("http://localhost/api/reference/files"); + url.searchParams.set("dirPath", join(root, "docs")); + const res = await handleFileBrowserFiles(new Request(url.toString())); + const data = await res.json() as { tree: VaultNode[]; workspaceStatus: WorkspaceStatusPayload }; + const realDocs = realpathSync(join(root, "docs")); + + expect(res.status).toBe(200); + expect(flattenTree(data.tree).sort()).toEqual(["gone.md", "new.md", "plan.md"]); + expect(data.workspaceStatus.totals.files).toBe(3); + expect(data.workspaceStatus.files[join(realDocs, "gone.md")]?.status).toBe("deleted"); + expect(data.workspaceStatus.files[join(realDocs, "new.md")]?.status).toBe("untracked"); + expect(data.workspaceStatus.files[join(realDocs, "plan.md")]?.additions).toBe(2); + }); +}); diff --git a/packages/server/reference-handlers.ts b/packages/server/reference-handlers.ts index 3e5c651307..52b00b2a6a 100644 --- a/packages/server/reference-handlers.ts +++ b/packages/server/reference-handlers.ts @@ -8,6 +8,11 @@ import { existsSync, statSync } from "fs"; import { resolve } from "path"; import { buildFileTree, FILE_BROWSER_EXCLUDED } from "@plannotator/shared/reference-common"; +import { + getWorkspaceStatusForDirectory, + getWorkspaceStatusRelativePaths, + type WorkspaceFileChange, +} from "@plannotator/shared/workspace-status"; import { parseCodePath } from "@plannotator/shared/code-file"; import { detectObsidianVaults } from "./integrations"; import { @@ -499,6 +504,12 @@ export async function handleObsidianDoc(req: Request): Promise { // --- File Browser --- +const FILE_BROWSER_EXTENSIONS = /\.(mdx?|txt|html?)$/i; + +function includeWorkspaceFile(relativePath: string, _change: WorkspaceFileChange): boolean { + return FILE_BROWSER_EXTENSIONS.test(relativePath); +} + /** List markdown files in a directory as a nested tree. */ export async function handleFileBrowserFiles(req: Request): Promise { const url = new URL(req.url); @@ -517,18 +528,22 @@ export async function handleFileBrowserFiles(req: Request): Promise { try { const glob = new Bun.Glob("**/*.{md,mdx,txt,html,htm}"); - const files: string[] = []; + const files = new Set(); for await (const match of glob.scan({ cwd: resolvedDir, onlyFiles: true, })) { if (FILE_BROWSER_EXCLUDED.some((dir) => match.includes(dir))) continue; - files.push(match); + files.add(match); } - files.sort(); + const workspaceStatus = getWorkspaceStatusForDirectory(resolvedDir); + for (const match of getWorkspaceStatusRelativePaths(workspaceStatus, resolvedDir, includeWorkspaceFile)) { + files.add(match); + } + const sortedFiles = [...files].sort(); - const tree = buildFileTree(files); - return Response.json({ tree }); + const tree = buildFileTree(sortedFiles); + return Response.json({ tree, workspaceStatus }); } catch { return Response.json( { error: "Failed to list directory files" }, diff --git a/packages/server/reference-watch.test.ts b/packages/server/reference-watch.test.ts new file mode 100644 index 0000000000..7357a1d23c --- /dev/null +++ b/packages/server/reference-watch.test.ts @@ -0,0 +1,72 @@ +import { afterEach, describe, expect, test } from "bun:test"; +import { mkdtempSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { handleFileBrowserFilesStream } from "./reference-watch"; + +const tempDirs: string[] = []; + +function makeTempDir(prefix: string): string { + const dir = mkdtempSync(join(tmpdir(), prefix)); + tempDirs.push(dir); + return dir; +} + +async function readSSEEvents(response: Response, count: number): Promise> { + const reader = response.body?.getReader(); + if (!reader) throw new Error("Missing response body"); + const decoder = new TextDecoder(); + const events: Array<{ type?: string; dirPath?: string }> = []; + let pending = ""; + + try { + while (events.length < count) { + let timeout: ReturnType | null = null; + const result = await Promise.race([ + reader.read(), + new Promise((_, reject) => { + timeout = setTimeout(() => reject(new Error("Timed out waiting for SSE event")), 1000); + }), + ]); + if (timeout) clearTimeout(timeout); + if (result.done) break; + pending += decoder.decode(result.value, { stream: true }); + const blocks = pending.split("\n\n"); + pending = blocks.pop() ?? ""; + for (const block of blocks) { + const line = block.split("\n").find((item) => item.startsWith("data: ")); + if (!line) continue; + events.push(JSON.parse(line.slice("data: ".length))); + } + } + return events; + } finally { + await reader.cancel(); + } +} + +afterEach(() => { + for (const dir of tempDirs.splice(0)) { + rmSync(dir, { recursive: true, force: true }); + } +}); + +describe("handleFileBrowserFilesStream", () => { + test("opens one SSE stream for multiple roots", async () => { + const first = makeTempDir("plannotator-watch-a-"); + const second = makeTempDir("plannotator-watch-b-"); + const url = new URL("http://localhost/api/reference/files/stream"); + url.searchParams.append("dirPath", first); + url.searchParams.append("dirPath", second); + + const response = handleFileBrowserFilesStream(new Request(url.toString())); + const events = await readSSEEvents(response, 2); + + expect(response.status).toBe(200); + expect(response.headers.get("content-type")).toBe("text/event-stream"); + expect(events.map((event) => [event.type, event.dirPath]).sort()).toEqual([ + ["ready", first], + ["ready", second], + ].sort()); + }); +}); diff --git a/packages/server/reference-watch.ts b/packages/server/reference-watch.ts new file mode 100644 index 0000000000..c2e64a1b60 --- /dev/null +++ b/packages/server/reference-watch.ts @@ -0,0 +1,187 @@ +import chokidar, { type FSWatcher } from "chokidar"; +import { existsSync, statSync } from "fs"; +import { isAbsolute, relative } from "path"; +import { FILE_BROWSER_EXCLUDED } from "@plannotator/shared/reference-common"; +import { resolveUserPath } from "@plannotator/shared/resolve-file"; +import { getGitMetadataWatchPaths } from "@plannotator/shared/workspace-status"; + +interface FileBrowserChangeEvent { + type: "ready" | "changed"; + dirPath: string; + reason: "files" | "git" | "initial"; + timestamp: number; +} + +interface WatchEntry { + dirPath: string; + subscribers: Set; + contentWatcher: FSWatcher | null; + gitWatcher: FSWatcher | null; + debounceTimer: ReturnType | null; +} + +const HEARTBEAT_MS = 30_000; +const DEBOUNCE_MS = 180; +const watchers = new Map(); +const encoder = new TextEncoder(); + +function serialize(event: FileBrowserChangeEvent): Uint8Array { + return encoder.encode(`data: ${JSON.stringify(event)}\n\n`); +} + +function isExcludedPath(path: string, root: string): boolean { + const rel = relative(root, path).replace(/\\/g, "/"); + if (!rel || rel.startsWith("..") || isAbsolute(rel)) return false; + return FILE_BROWSER_EXCLUDED.some((entry) => { + const name = entry.replace(/\/$/, ""); + return rel === name || rel.startsWith(`${name}/`) || rel.includes(`/${name}/`); + }); +} + +function isValidDirectory(dirPath: string): boolean { + try { + return existsSync(dirPath) && statSync(dirPath).isDirectory(); + } catch { + return false; + } +} + +function broadcast(entry: WatchEntry, reason: FileBrowserChangeEvent["reason"]): void { + const payload = serialize({ + type: "changed", + dirPath: entry.dirPath, + reason, + timestamp: Date.now(), + }); + for (const subscriber of entry.subscribers) { + try { + subscriber.enqueue(payload); + } catch { + entry.subscribers.delete(subscriber); + } + } +} + +function scheduleBroadcast(entry: WatchEntry, reason: "files" | "git"): void { + if (entry.debounceTimer) clearTimeout(entry.debounceTimer); + entry.debounceTimer = setTimeout(() => { + entry.debounceTimer = null; + broadcast(entry, reason); + }, DEBOUNCE_MS); +} + +function closeWatcher(entry: WatchEntry): void { + if (entry.debounceTimer) clearTimeout(entry.debounceTimer); + void entry.contentWatcher?.close(); + void entry.gitWatcher?.close(); + watchers.delete(entry.dirPath); +} + +function releaseSubscriber(entry: WatchEntry, controller: ReadableStreamDefaultController): void { + entry.subscribers.delete(controller); + if (entry.subscribers.size === 0) closeWatcher(entry); +} + +function ensureWatcher(dirPath: string): WatchEntry { + const existing = watchers.get(dirPath); + if (existing) return existing; + + const entry: WatchEntry = { + dirPath, + subscribers: new Set(), + contentWatcher: null, + gitWatcher: null, + debounceTimer: null, + }; + + entry.contentWatcher = chokidar.watch(dirPath, { + ignoreInitial: true, + persistent: true, + ignored: (path) => isExcludedPath(path, dirPath), + awaitWriteFinish: { + stabilityThreshold: 120, + pollInterval: 30, + }, + }); + entry.contentWatcher.on("all", () => scheduleBroadcast(entry, "files")); + entry.contentWatcher.on("error", () => scheduleBroadcast(entry, "files")); + + const gitWatchPaths = getGitMetadataWatchPaths(dirPath); + if (gitWatchPaths.length > 0) { + entry.gitWatcher = chokidar.watch(gitWatchPaths, { + ignoreInitial: true, + persistent: true, + awaitWriteFinish: { + stabilityThreshold: 80, + pollInterval: 30, + }, + }); + entry.gitWatcher.on("all", () => scheduleBroadcast(entry, "git")); + entry.gitWatcher.on("error", () => scheduleBroadcast(entry, "git")); + } + + watchers.set(dirPath, entry); + return entry; +} + +export function handleFileBrowserFilesStream( + req: Request, + options?: { disableIdleTimeout?: () => void }, +): Response { + const url = new URL(req.url); + const rawDirPaths = url.searchParams.getAll("dirPath"); + if (rawDirPaths.length === 0) { + return Response.json({ error: "Missing dirPath parameter" }, { status: 400 }); + } + + const dirPaths: string[] = []; + for (const rawDirPath of rawDirPaths) { + const dirPath = resolveUserPath(rawDirPath); + if (!isValidDirectory(dirPath)) { + return Response.json({ error: "Invalid directory path" }, { status: 400 }); + } + if (!dirPaths.includes(dirPath)) dirPaths.push(dirPath); + } + + options?.disableIdleTimeout?.(); + const entries = dirPaths.map((dirPath) => ensureWatcher(dirPath)); + + let controllerRef: ReadableStreamDefaultController | null = null; + let heartbeatTimer: ReturnType | null = null; + const stream = new ReadableStream({ + start(controller) { + controllerRef = controller; + for (const entry of entries) { + entry.subscribers.add(controller); + controller.enqueue(serialize({ + type: "ready", + dirPath: entry.dirPath, + reason: "initial", + timestamp: Date.now(), + })); + } + heartbeatTimer = setInterval(() => { + try { + controller.enqueue(encoder.encode(": heartbeat\n\n")); + } catch { + for (const entry of entries) releaseSubscriber(entry, controller); + if (heartbeatTimer) clearInterval(heartbeatTimer); + } + }, HEARTBEAT_MS); + }, + cancel() { + if (heartbeatTimer) clearInterval(heartbeatTimer); + if (controllerRef) { + for (const entry of entries) releaseSubscriber(entry, controllerRef); + } + }, + }); + + return new Response(stream, { + headers: { + "Content-Type": "text/event-stream", + "Cache-Control": "no-cache", + Connection: "keep-alive", + }, + }); +} diff --git a/packages/shared/package.json b/packages/shared/package.json index 8c655617fb..e23e7c8c86 100644 --- a/packages/shared/package.json +++ b/packages/shared/package.json @@ -50,7 +50,8 @@ "./semantic-diff": "./semantic-diff.ts", "./semantic-diff-types": "./semantic-diff-types.ts", "./source-save": "./source-save.ts", - "./source-save-node": "./source-save-node.ts" + "./source-save-node": "./source-save-node.ts", + "./workspace-status": "./workspace-status.ts" }, "dependencies": { "@joplin/turndown-plugin-gfm": "^1.0.64", diff --git a/packages/shared/workspace-status.test.ts b/packages/shared/workspace-status.test.ts new file mode 100644 index 0000000000..66ca45b95b --- /dev/null +++ b/packages/shared/workspace-status.test.ts @@ -0,0 +1,78 @@ +import { afterEach, describe, expect, test } from "bun:test"; +import { mkdirSync, mkdtempSync, realpathSync, rmSync, unlinkSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { spawnSync } from "node:child_process"; +import { getWorkspaceStatusForDirectory, getWorkspaceStatusRelativePaths } from "./workspace-status"; + +const tempDirs: string[] = []; + +function tempRepo(): string { + const dir = mkdtempSync(join(tmpdir(), "plannotator-workspace-status-")); + tempDirs.push(dir); + git(dir, "init", "-b", "main"); + git(dir, "config", "user.email", "test@test"); + git(dir, "config", "user.name", "Test"); + return dir; +} + +function git(cwd: string, ...args: string[]): void { + const result = spawnSync("git", args, { cwd, encoding: "utf8" }); + if (result.status !== 0) { + throw new Error(`git ${args.join(" ")} failed: ${result.stderr}`); + } +} + +afterEach(() => { + for (const dir of tempDirs.splice(0)) { + rmSync(dir, { recursive: true, force: true }); + } +}); + +describe("workspace status", () => { + test("reports git changes under the requested directory and clears after commit", () => { + const repo = tempRepo(); + const docs = join(repo, "docs"); + mkdirSync(docs); + writeFileSync(join(docs, "plan.md"), "one\ntwo\n"); + writeFileSync(join(docs, "gone.md"), "remove me\n"); + writeFileSync(join(repo, "outside.md"), "outside\n"); + git(repo, "add", "-A"); + git(repo, "commit", "-m", "init"); + + writeFileSync(join(docs, "plan.md"), "one\nTWO\nthree\n"); + unlinkSync(join(docs, "gone.md")); + writeFileSync(join(docs, "new.md"), "alpha\nbeta\n"); + writeFileSync(join(repo, "outside.md"), "outside changed\n"); + + const status = getWorkspaceStatusForDirectory(docs); + const realDocs = realpathSync(docs); + + expect(status.available).toBe(true); + expect(Object.keys(status.files).sort()).toEqual([ + join(realDocs, "gone.md"), + join(realDocs, "new.md"), + join(realDocs, "plan.md"), + ].sort()); + expect(status.files[join(realDocs, "plan.md")]?.status).toBe("modified"); + expect(status.files[join(realDocs, "plan.md")]?.additions).toBe(2); + expect(status.files[join(realDocs, "plan.md")]?.deletions).toBe(1); + expect(status.files[join(realDocs, "gone.md")]?.status).toBe("deleted"); + expect(status.files[join(realDocs, "gone.md")]?.deletions).toBe(1); + expect(status.files[join(realDocs, "new.md")]?.status).toBe("untracked"); + expect(status.files[join(realDocs, "new.md")]?.additions).toBe(2); + expect(getWorkspaceStatusRelativePaths(status, docs).sort()).toEqual([ + "gone.md", + "new.md", + "plan.md", + ]); + + git(repo, "add", "-A"); + git(repo, "commit", "-m", "changes"); + + const afterCommit = getWorkspaceStatusForDirectory(docs); + expect(afterCommit.available).toBe(true); + expect(afterCommit.totals.files).toBe(0); + expect(afterCommit.files).toEqual({}); + }); +}); diff --git a/packages/shared/workspace-status.ts b/packages/shared/workspace-status.ts new file mode 100644 index 0000000000..9fb8cba1b3 --- /dev/null +++ b/packages/shared/workspace-status.ts @@ -0,0 +1,294 @@ +import { spawnSync } from "node:child_process"; +import { existsSync, readFileSync, realpathSync, statSync } from "node:fs"; +import { isAbsolute, relative, resolve } from "node:path"; + +export type WorkspaceFileStatus = + | "modified" + | "added" + | "deleted" + | "renamed" + | "copied" + | "typechange" + | "conflicted" + | "untracked"; + +export interface WorkspaceFileChange { + path: string; + repoRelativePath: string; + oldPath?: string; + status: WorkspaceFileStatus; + additions: number; + deletions: number; + staged: boolean; + unstaged: boolean; +} + +export interface WorkspaceStatusPayload { + available: boolean; + rootPath: string; + repoRoot?: string; + files: Record; + totals: { + files: number; + additions: number; + deletions: number; + }; + error?: string; +} + +export interface GitRepositoryInfo { + repoRoot: string; + gitDir: string; + gitCommonDir: string; +} + +const TEXT_FILE_MAX_BYTES = 2 * 1024 * 1024; + +function runGit(cwd: string, args: string[]): { ok: true; stdout: string } | { ok: false; error: string } { + const result = spawnSync("git", ["-C", cwd, ...args], { + encoding: "utf8", + maxBuffer: 20 * 1024 * 1024, + }); + if (result.error) return { ok: false, error: result.error.message }; + if (result.status !== 0) { + const stderr = typeof result.stderr === "string" ? result.stderr.trim() : ""; + return { ok: false, error: stderr || `git exited with status ${result.status ?? "unknown"}` }; + } + return { ok: true, stdout: result.stdout ?? "" }; +} + +function resolveGitPath(repoRoot: string, value: string): string { + return isAbsolute(value) ? value : resolve(repoRoot, value); +} + +export function getGitRepositoryInfo(cwd: string): GitRepositoryInfo | null { + const topLevel = runGit(cwd, ["rev-parse", "--show-toplevel"]); + if (!topLevel.ok) return null; + const rawRepoRoot = topLevel.stdout.trim(); + if (!rawRepoRoot) return null; + const repoRoot = realpathSync(rawRepoRoot); + + const gitDir = runGit(cwd, ["rev-parse", "--git-dir"]); + const gitCommonDir = runGit(cwd, ["rev-parse", "--git-common-dir"]); + + return { + repoRoot, + gitDir: gitDir.ok && gitDir.stdout.trim() ? resolveGitPath(repoRoot, gitDir.stdout.trim()) : resolve(repoRoot, ".git"), + gitCommonDir: gitCommonDir.ok && gitCommonDir.stdout.trim() + ? resolveGitPath(repoRoot, gitCommonDir.stdout.trim()) + : gitDir.ok && gitDir.stdout.trim() + ? resolveGitPath(repoRoot, gitDir.stdout.trim()) + : resolve(repoRoot, ".git"), + }; +} + +function isWithinPath(candidate: string, root: string): boolean { + const rel = relative(root, candidate); + return rel === "" || (!!rel && !rel.startsWith("..") && !isAbsolute(rel)); +} + +function mapStatus(x: string, y: string): WorkspaceFileStatus { + if (x === "?" || y === "?") return "untracked"; + if (x === "U" || y === "U" || (x === "A" && y === "A") || (x === "D" && y === "D")) return "conflicted"; + if (x === "R" || y === "R") return "renamed"; + if (x === "C" || y === "C") return "copied"; + if (x === "A" || y === "A") return "added"; + if (x === "D" || y === "D") return "deleted"; + if (x === "T" || y === "T") return "typechange"; + return "modified"; +} + +function parsePorcelain(output: string): Array<{ + repoRelativePath: string; + oldRepoRelativePath?: string; + status: WorkspaceFileStatus; + staged: boolean; + unstaged: boolean; +}> { + const fields = output.split("\0").filter(Boolean); + const result: Array<{ + repoRelativePath: string; + oldRepoRelativePath?: string; + status: WorkspaceFileStatus; + staged: boolean; + unstaged: boolean; + }> = []; + + for (let i = 0; i < fields.length; i++) { + const record = fields[i]; + if (record.length < 4) continue; + const x = record[0] ?? " "; + const y = record[1] ?? " "; + const path = record.slice(3); + let oldPath: string | undefined; + if (x === "R" || y === "R" || x === "C" || y === "C") { + oldPath = fields[i + 1]; + i += 1; + } + result.push({ + repoRelativePath: path, + oldRepoRelativePath: oldPath, + status: mapStatus(x, y), + staged: x !== " " && x !== "?", + unstaged: y !== " " && y !== "?", + }); + } + + return result; +} + +function parseNumstat(output: string): Map { + const counts = new Map(); + for (const record of output.split("\0")) { + if (!record) continue; + const parts = record.split("\t"); + if (parts.length < 3) continue; + const additions = parts[0] === "-" ? 0 : Number.parseInt(parts[0] ?? "0", 10); + const deletions = parts[1] === "-" ? 0 : Number.parseInt(parts[1] ?? "0", 10); + const path = parts.slice(2).join("\t"); + if (!path) continue; + counts.set(path, { + additions: Number.isFinite(additions) ? additions : 0, + deletions: Number.isFinite(deletions) ? deletions : 0, + }); + } + return counts; +} + +function countTextFileLines(path: string): number { + try { + const stat = statSync(path); + if (!stat.isFile() || stat.size > TEXT_FILE_MAX_BYTES) return 0; + const text = readFileSync(path, "utf8").replace(/\r\n/g, "\n").replace(/\r/g, "\n"); + if (text.length === 0) return 0; + const trimmed = text.endsWith("\n") ? text.slice(0, -1) : text; + return trimmed.length === 0 ? 1 : trimmed.split("\n").length; + } catch { + return 0; + } +} + +export function getWorkspaceStatusForDirectory(dirPath: string): WorkspaceStatusPayload { + let rootPath: string; + try { + rootPath = realpathSync(resolve(dirPath)); + } catch { + return { + available: false, + rootPath: resolve(dirPath), + files: {}, + totals: { files: 0, additions: 0, deletions: 0 }, + error: "invalid-directory", + }; + } + const repo = getGitRepositoryInfo(rootPath); + if (!repo) { + return { + available: false, + rootPath, + files: {}, + totals: { files: 0, additions: 0, deletions: 0 }, + error: "not-a-git-repo", + }; + } + + const rootPathspec = relative(repo.repoRoot, rootPath).replace(/\\/g, "/") || "."; + const status = runGit(repo.repoRoot, ["status", "--porcelain=v1", "-z", "--untracked-files=all", "--", rootPathspec]); + if ("error" in status) { + return { + available: false, + rootPath, + repoRoot: repo.repoRoot, + files: {}, + totals: { files: 0, additions: 0, deletions: 0 }, + error: status.error, + }; + } + + const numstat = runGit(repo.repoRoot, ["diff", "--numstat", "-z", "HEAD", "--", rootPathspec]); + const lineCounts = numstat.ok ? parseNumstat(numstat.stdout) : new Map(); + + const files: Record = {}; + let totalAdditions = 0; + let totalDeletions = 0; + + for (const entry of parsePorcelain(status.stdout)) { + const absolutePath = resolve(repo.repoRoot, entry.repoRelativePath); + if (!isWithinPath(absolutePath, rootPath)) continue; + + const counts = lineCounts.get(entry.repoRelativePath) ?? { additions: 0, deletions: 0 }; + const oldCounts = entry.oldRepoRelativePath + ? lineCounts.get(entry.oldRepoRelativePath) ?? { additions: 0, deletions: 0 } + : { additions: 0, deletions: 0 }; + const countedAdditions = counts.additions + oldCounts.additions; + const additions = (entry.status === "untracked" || entry.status === "added") && countedAdditions === 0 + ? countTextFileLines(absolutePath) + : countedAdditions; + const deletions = counts.deletions + oldCounts.deletions; + const oldPath = entry.oldRepoRelativePath + ? resolve(repo.repoRoot, entry.oldRepoRelativePath) + : undefined; + + files[absolutePath] = { + path: absolutePath, + repoRelativePath: entry.repoRelativePath, + oldPath, + status: entry.status, + additions, + deletions, + staged: entry.staged, + unstaged: entry.unstaged, + }; + totalAdditions += additions; + totalDeletions += deletions; + } + + return { + available: true, + rootPath, + repoRoot: repo.repoRoot, + files, + totals: { + files: Object.keys(files).length, + additions: totalAdditions, + deletions: totalDeletions, + }, + }; +} + +export function getWorkspaceStatusRelativePaths( + status: WorkspaceStatusPayload, + dirPath: string, + filter?: (relativePath: string, change: WorkspaceFileChange) => boolean, +): string[] { + let rootPath: string; + try { + rootPath = realpathSync(resolve(dirPath)); + } catch { + return []; + } + const paths: string[] = []; + for (const change of Object.values(status.files)) { + const rel = relative(rootPath, change.path).replace(/\\/g, "/"); + if (!rel || rel.startsWith("..") || isAbsolute(rel)) continue; + if (filter && !filter(rel, change)) continue; + paths.push(rel); + } + return paths; +} + +export function getGitMetadataWatchPaths(cwd: string): string[] { + const repo = getGitRepositoryInfo(cwd); + if (!repo) return []; + const candidates = [ + resolve(repo.gitDir, "HEAD"), + resolve(repo.gitDir, "index"), + resolve(repo.gitDir, "MERGE_HEAD"), + resolve(repo.gitDir, "rebase-merge"), + resolve(repo.gitDir, "rebase-apply"), + resolve(repo.gitCommonDir, "HEAD"), + resolve(repo.gitCommonDir, "packed-refs"), + resolve(repo.gitCommonDir, "refs"), + ]; + return [...new Set(candidates)].filter((path) => existsSync(path)); +} diff --git a/packages/ui/components/AnnotationPanel.tsx b/packages/ui/components/AnnotationPanel.tsx index 20cb06f753..69cf2e8f69 100644 --- a/packages/ui/components/AnnotationPanel.tsx +++ b/packages/ui/components/AnnotationPanel.tsx @@ -40,10 +40,12 @@ const TrashCardIcon = () => ( interface DirectEditsPanelItem { id: string; + title?: string; label?: string; added: number; removed: number; diffText: string; + description?: string; onDiscard?: () => void; } @@ -312,12 +314,14 @@ function formatTimestamp(ts: number): string { * diff, and a two-step discard. Not part of the annotation timeline — edits * are document state, not a selection-anchored note. */ const DirectEditsCard: React.FC<{ + title?: string; label?: string; added: number; removed: number; diffText: string; + description?: string; onDiscard?: () => void; -}> = ({ label, added, removed, diffText, onDiscard }) => { +}> = ({ title = 'Edits', label, added, removed, diffText, description, onDiscard }) => { const [expanded, setExpanded] = useState(false); const [confirmDiscard, setConfirmDiscard] = useState(false); @@ -341,7 +345,7 @@ const DirectEditsCard: React.FC<{
- Edits + {title} {label && ( {label} @@ -385,7 +389,7 @@ const DirectEditsCard: React.FC<{

- Your text changes — sent with the feedback as a diff. + {description ?? 'Your text changes — sent with the feedback as a diff.'}

{expanded && (
diff --git a/packages/ui/components/sidebar/FileBrowser.tsx b/packages/ui/components/sidebar/FileBrowser.tsx
index 5d0e05e31e..569672124f 100644
--- a/packages/ui/components/sidebar/FileBrowser.tsx
+++ b/packages/ui/components/sidebar/FileBrowser.tsx
@@ -10,6 +10,7 @@ import type { VaultNode } from "../../types";
 import type { DirState } from "../../hooks/useFileBrowser";
 import { CountBadge } from "./CountBadge";
 import { ObsidianIconRaw } from "../icons/ObsidianIcons";
+import type { WorkspaceFileChange, WorkspaceStatusPayload } from "@plannotator/shared/workspace-status";
 
 interface FileBrowserProps {
   dirs: DirState[];
@@ -31,6 +32,12 @@ export interface FileEditStatus {
   dirty: boolean;
 }
 
+interface AggregateWorkspaceChange {
+  additions: number;
+  deletions: number;
+  files: number;
+}
+
 /** Recursively sum annotation counts for all descendant files of a folder node */
 function getAggregateCount(
   node: VaultNode,
@@ -47,6 +54,34 @@ function getAggregateCount(
   return total;
 }
 
+function getWorkspaceChange(
+  absolutePath: string,
+  workspaceStatus?: WorkspaceStatusPayload
+): WorkspaceFileChange | undefined {
+  return workspaceStatus?.files?.[absolutePath];
+}
+
+function getAggregateWorkspaceChange(
+  node: VaultNode,
+  dirPath: string,
+  workspaceStatus?: WorkspaceStatusPayload
+): AggregateWorkspaceChange {
+  if (node.type === "file") {
+    const change = getWorkspaceChange(`${dirPath}/${node.path}`, workspaceStatus);
+    return change
+      ? { additions: change.additions, deletions: change.deletions, files: 1 }
+      : { additions: 0, deletions: 0, files: 0 };
+  }
+  return (node.children ?? []).reduce((total, child) => {
+    const childTotal = getAggregateWorkspaceChange(child, dirPath, workspaceStatus);
+    return {
+      additions: total.additions + childTotal.additions,
+      deletions: total.deletions + childTotal.deletions,
+      files: total.files + childTotal.files,
+    };
+  }, { additions: 0, deletions: 0, files: 0 });
+}
+
 const TreeNode: React.FC<{
   node: VaultNode;
   depth: number;
@@ -58,7 +93,8 @@ const TreeNode: React.FC<{
   annotationCounts?: Map;
   highlightedFiles?: Set;
   editStatuses?: Map;
-}> = ({ node, depth, dirPath, expandedFolders, onToggleFolder, onSelectFile, activeFile, annotationCounts, highlightedFiles, editStatuses }) => {
+  workspaceStatus?: WorkspaceStatusPayload;
+}> = ({ node, depth, dirPath, expandedFolders, onToggleFolder, onSelectFile, activeFile, annotationCounts, highlightedFiles, editStatuses, workspaceStatus }) => {
   const folderKey = `${dirPath}:${node.path}`;
   const absolutePath = `${dirPath}/${node.path}`;
   const isExpanded = expandedFolders.has(folderKey);
@@ -67,11 +103,12 @@ const TreeNode: React.FC<{
 
   if (node.type === "folder") {
     const aggregateCount = annotationCounts ? getAggregateCount(node, dirPath, annotationCounts) : 0;
+    const aggregateChange = getAggregateWorkspaceChange(node, dirPath, workspaceStatus);
     return (
       <>
         
         {isExpanded && node.children?.map((child) => (
           
         ))}
       
     );
   }
 
-  const displayName = node.name.replace(/\.(mdx?|txt)$/i, "");
+  const displayName = node.name.replace(/\.(mdx?|txt|html?)$/i, "");
   const fileCount = annotationCounts?.get(absolutePath) ?? 0;
   const isHighlighted = highlightedFiles?.has(absolutePath);
   const editStatus = editStatuses?.get(absolutePath);
+  const workspaceChange = getWorkspaceChange(absolutePath, workspaceStatus);
+  const isDeleted = workspaceChange?.status === "deleted";
   const editMarker =
     editStatus?.status === "conflict" || editStatus?.status === "error"
       ? { label: "!", className: "bg-destructive/15 text-destructive", title: editStatus.status === "conflict" ? "Save conflict" : "Save failed" }
@@ -122,30 +170,53 @@ const TreeNode: React.FC<{
           : editStatus?.status === "saved"
             ? { label: "✓", className: "bg-success/15 text-success", title: "Saved" }
             : null;
+  const statusMarker = workspaceChange?.status === "added"
+    ? { label: "A", className: "text-success", title: "Added file" }
+    : workspaceChange?.status === "untracked"
+      ? { label: "U", className: "text-primary", title: "Untracked file" }
+      : workspaceChange?.status === "deleted"
+        ? { label: "D", className: "text-destructive", title: "Deleted file" }
+        : workspaceChange?.status === "renamed"
+          ? { label: "R", className: "text-[#007aff]", title: workspaceChange.oldPath ? `Renamed from ${workspaceChange.oldPath}` : "Renamed file" }
+          : workspaceChange?.status === "conflicted"
+            ? { label: "!", className: "text-destructive", title: "Git conflict" }
+            : null;
   return (
     
   );
 };
@@ -206,6 +277,7 @@ const DirSection: React.FC<{
           annotationCounts={annotationCounts}
           highlightedFiles={highlightedFiles}
           editStatuses={editStatuses}
+          workspaceStatus={dir.workspaceStatus}
         />
       ))}
     
@@ -237,6 +309,17 @@ export const FileBrowser: React.FC = ({
   // Summary header
   const totalCount = annotationCounts ? Array.from(annotationCounts.values()).reduce((s, c) => s + c, 0) : 0;
   const fileCount = annotationCounts?.size ?? 0;
+  const workspaceTotals = dirs.reduce(
+    (total, dir) => {
+      if (!dir.workspaceStatus?.available) return total;
+      return {
+        files: total.files + dir.workspaceStatus.totals.files,
+        additions: total.additions + dir.workspaceStatus.totals.additions,
+        deletions: total.deletions + dir.workspaceStatus.totals.deletions,
+      };
+    },
+    { files: 0, additions: 0, deletions: 0 }
+  );
 
   return (
     
@@ -245,6 +328,13 @@ export const FileBrowser: React.FC = ({ {totalCount} annotation{totalCount === 1 ? '' : 's'} in {fileCount} file{fileCount === 1 ? '' : 's'}
)} + {workspaceTotals.files > 0 && ( +
+ {workspaceTotals.files} changed + {workspaceTotals.additions > 0 && +{workspaceTotals.additions}} + {workspaceTotals.deletions > 0 && 0 ? "" : "ml-auto"}`}>-{workspaceTotals.deletions}} +
+ )} {dirs.map((dir) => { const isCollapsed = collapsedDirs.has(dir.path); return ( diff --git a/packages/ui/hooks/useFileBrowser.ts b/packages/ui/hooks/useFileBrowser.ts index 0547e8b571..60fac44624 100644 --- a/packages/ui/hooks/useFileBrowser.ts +++ b/packages/ui/hooks/useFileBrowser.ts @@ -7,8 +7,9 @@ * from the Obsidian vault endpoint instead of the generic files endpoint. */ -import { useState, useCallback } from "react"; +import { useState, useCallback, useEffect, useMemo, useRef } from "react"; import type { VaultNode } from "../types"; +import type { WorkspaceStatusPayload } from "@plannotator/shared/workspace-status"; export interface DirState { path: string; @@ -16,6 +17,8 @@ export interface DirState { tree: VaultNode[]; isLoading: boolean; error: string | null; + workspaceStatus?: WorkspaceStatusPayload; + lastChangedAt?: number; /** When true, fetches via /api/reference/obsidian/files and opens docs via /api/reference/obsidian/doc */ isVault?: boolean; } @@ -26,7 +29,7 @@ export interface UseFileBrowserReturn { toggleFolder: (key: string) => void; collapsedDirs: Set; toggleCollapse: (dirPath: string) => void; - fetchTree: (dirPath: string) => void; + fetchTree: (dirPath: string, options?: { quiet?: boolean }) => void; fetchAll: (directories: string[]) => void; addVaultDir: (vaultPath: string) => void; clearVaultDirs: () => void; @@ -35,6 +38,43 @@ export interface UseFileBrowserReturn { setActiveFile: (path: string | null) => void; } +function normalizeRoot(path: string): string { + return path.replace(/\/+$/, ""); +} + +function remapWorkspaceStatusForDir( + status: WorkspaceStatusPayload | undefined, + dirPath: string +): WorkspaceStatusPayload | undefined { + if (!status?.rootPath) return status; + const fromRoot = normalizeRoot(status.rootPath); + const toRoot = normalizeRoot(dirPath); + if (!fromRoot || fromRoot === toRoot) return status; + + const files: WorkspaceStatusPayload["files"] = {}; + for (const [path, change] of Object.entries(status.files)) { + const nextPath = path === fromRoot + ? toRoot + : path.startsWith(`${fromRoot}/`) + ? `${toRoot}${path.slice(fromRoot.length)}` + : path; + const nextOldPath = change.oldPath && change.oldPath.startsWith(`${fromRoot}/`) + ? `${toRoot}${change.oldPath.slice(fromRoot.length)}` + : change.oldPath; + files[nextPath] = { + ...change, + path: nextPath, + oldPath: nextOldPath, + }; + } + + return { + ...status, + rootPath: dirPath, + files, + }; +} + export function useFileBrowser(): UseFileBrowserReturn { const [dirs, setDirs] = useState([]); const [expandedFolders, setExpandedFolders] = useState>(new Set()); @@ -50,14 +90,14 @@ export function useFileBrowser(): UseFileBrowserReturn { }); }, []); - const fetchTree = useCallback(async (dirPath: string) => { + const fetchTree = useCallback(async (dirPath: string, options: { quiet?: boolean } = {}) => { const name = dirPath.split("/").pop() || dirPath; setDirs((prev) => { const exists = prev.find((d) => d.path === dirPath); if (exists) { return prev.map((d) => - d.path === dirPath ? { ...d, isLoading: true, error: null } : d + d.path === dirPath ? { ...d, isLoading: options.quiet ? d.isLoading : true, error: null } : d ); } return [...prev, { path: dirPath, name, tree: [], isLoading: true, error: null }]; @@ -78,9 +118,19 @@ export function useFileBrowser(): UseFileBrowserReturn { return; } + const workspaceStatus = remapWorkspaceStatusForDir(data.workspaceStatus, dirPath); setDirs((prev) => prev.map((d) => - d.path === dirPath ? { ...d, tree: data.tree, isLoading: false, error: null } : d + d.path === dirPath + ? { + ...d, + tree: data.tree, + workspaceStatus, + lastChangedAt: Date.now(), + isLoading: false, + error: null, + } + : d ) ); @@ -101,6 +151,11 @@ export function useFileBrowser(): UseFileBrowserReturn { } }, []); + const fetchTreeRef = useRef(fetchTree); + useEffect(() => { + fetchTreeRef.current = fetchTree; + }, [fetchTree]); + const fetchAll = useCallback( (directories: string[]) => { setDirs((prev) => { @@ -183,6 +238,51 @@ export function useFileBrowser(): UseFileBrowserReturn { }); }, []); + const watchDirsKey = useMemo( + () => dirs + .filter((dir) => !dir.isVault && !dir.error) + .map((dir) => dir.path) + .sort() + .join("\n"), + [dirs] + ); + + useEffect(() => { + if (!watchDirsKey || typeof EventSource === "undefined") return; + + const paths = watchDirsKey.split("\n").filter(Boolean); + const timers = new Map>(); + const params = new URLSearchParams(); + for (const path of paths) params.append("dirPath", path); + const source = new EventSource(`/api/reference/files/stream?${params.toString()}`); + const scheduleFetch = (path: string) => { + const existing = timers.get(path); + if (existing) clearTimeout(existing); + timers.set(path, setTimeout(() => { + timers.delete(path); + fetchTreeRef.current(path, { quiet: true }); + }, 120)); + }; + source.onmessage = (event) => { + try { + const data = JSON.parse(event.data) as { type?: string; dirPath?: string }; + if (data.type !== "changed") return; + if (typeof data.dirPath === "string" && paths.includes(data.dirPath)) { + scheduleFetch(data.dirPath); + return; + } + } catch { + return; + } + for (const path of paths) scheduleFetch(path); + }; + + return () => { + for (const timer of timers.values()) clearTimeout(timer); + source.close(); + }; + }, [watchDirsKey]); + return { dirs, expandedFolders, diff --git a/packages/ui/theme.css b/packages/ui/theme.css index e5f6488a9f..b9834ee1a1 100644 --- a/packages/ui/theme.css +++ b/packages/ui/theme.css @@ -358,6 +358,60 @@ html:not(.transitions-ready) * { 100% { background: transparent; } } +/* Shared file tree styling used by review and annotation sidebars. */ +.file-tree-item { + display: flex; + align-items: center; + gap: 0.5rem; + padding: 0.25rem 0.5rem; + border-radius: var(--radius-sm); + font-size: 0.75rem; + cursor: pointer; + color: var(--muted-foreground); +} + +.file-tree-item:hover { + background: var(--muted); + color: var(--foreground); +} + +.file-tree-item:disabled { + cursor: default; +} + +.file-tree-item:disabled:hover { + background: transparent; +} + +.file-tree-item.active { + background: oklch(from var(--primary) l c h / 0.3); + color: var(--primary); +} + +.file-tree-item.has-annotations { + background: oklch(from var(--primary) l c h / 0.08); +} + +.file-tree-item.has-annotations:hover { + background: var(--muted); +} + +.file-tree-item.active.has-annotations { + background: oklch(from var(--primary) l c h / 0.3); +} + +.file-tree-item .additions, +.file-tree-folder .additions, +.file-tree-status-summary .additions { + color: var(--success); +} + +.file-tree-item .deletions, +.file-tree-folder .deletions, +.file-tree-status-summary .deletions { + color: var(--destructive); +} + /* Word-level diff markers inside modified plan-diff blocks. Emitted as / by PlanCleanDiffView's inline renderer; keep the decoration From 0eaa46beb7ea576308dd0c2edfe5d21f3b1d9b04 Mon Sep 17 00:00:00 2001 From: Michael Ramos Date: Wed, 17 Jun 2026 09:02:23 -0700 Subject: [PATCH 2/9] fix(annotate): guard dirty feedback and rename stats --- packages/editor/App.tsx | 216 +++++++++++++++-------- packages/shared/workspace-status.test.ts | 24 +++ packages/shared/workspace-status.ts | 10 +- 3 files changed, 173 insertions(+), 77 deletions(-) diff --git a/packages/editor/App.tsx b/packages/editor/App.tsx index 13608123aa..4560095b4c 100644 --- a/packages/editor/App.tsx +++ b/packages/editor/App.tsx @@ -229,6 +229,8 @@ const feedbackLossDescription = (annotationCount: number, hasDirectEdits: boolea return parts.length > 0 ? parts.join(' and ') : 'feedback'; }; +type SourceFileEditWarningAction = 'send-feedback' | 'approve' | 'close'; + const App: React.FC = () => { const [markdown, setMarkdown] = useState(DEMO_PLAN_CONTENT); const [annotations, setAnnotations] = useState([]); @@ -245,6 +247,9 @@ const App: React.FC = () => { const [showFeedbackPrompt, setShowFeedbackPrompt] = useState(false); const [showClaudeCodeWarning, setShowClaudeCodeWarning] = useState(false); const [showExitWarning, setShowExitWarning] = useState(false); + const [showSourceFileEditWarning, setShowSourceFileEditWarning] = useState(false); + const [sourceFileEditWarningAction, setSourceFileEditWarningAction] = useState('send-feedback'); + const sourceFileEditWarningContinuationRef = useRef<(() => void | Promise) | null>(null); // When the warning dialog confirms, route to the handler matching the button that opened it. const [exitWarningAction, setExitWarningAction] = useState<'close' | 'approve'>('close'); const [showAgentWarning, setShowAgentWarning] = useState(false); @@ -2143,11 +2148,34 @@ const App: React.FC = () => { } }, []); - const warnFinishSourceFileEdits = useCallback(() => { - toast('Save or discard file edits first', { - description: 'Unsaved editor text is not sent as feedback until it is saved to disk.', - duration: 5000, - }); + const confirmUnsavedSourceFileEdits = useCallback(( + action: SourceFileEditWarningAction, + continueAction: () => void | Promise, + ) => { + sourceFileEditWarningContinuationRef.current = continueAction; + setSourceFileEditWarningAction(action); + setShowSourceFileEditWarning(true); + }, []); + + const maybeConfirmUnsavedSourceFileEdits = useCallback(( + action: SourceFileEditWarningAction, + continueAction: () => void | Promise, + ): boolean => { + if (!hasUnsavedSourceFileBuffers) return false; + confirmUnsavedSourceFileEdits(action, continueAction); + return true; + }, [confirmUnsavedSourceFileEdits, hasUnsavedSourceFileBuffers]); + + const closeSourceFileEditWarning = useCallback(() => { + sourceFileEditWarningContinuationRef.current = null; + setShowSourceFileEditWarning(false); + }, []); + + const confirmSourceFileEditWarning = useCallback(() => { + const continuation = sourceFileEditWarningContinuationRef.current; + sourceFileEditWarningContinuationRef.current = null; + setShowSourceFileEditWarning(false); + void continuation?.(); }, []); // Global keyboard shortcuts (Cmd/Ctrl+Enter to submit) @@ -2165,6 +2193,7 @@ const App: React.FC = () => { // Don't intercept if any modal is open if (showExport || showImport || showFeedbackPrompt || showClaudeCodeWarning || + showSourceFileEditWarning || showExitWarning || showAgentWarning || showPermissionModeSetup || pendingPasteImage) return; // Don't intercept if already submitted, submitting, or exiting @@ -2177,11 +2206,6 @@ const App: React.FC = () => { // not the review session. if (isEditingMarkdown) return; - if (hasUnsavedSourceFileBuffers) { - warnFinishSourceFileEdits(); - return; - } - // Folder files are the active review target; normal linked docs are side // references and should not submit the root plan. if (linkedDocHook.isActive && annotateSource !== 'folder') return; @@ -2203,28 +2227,35 @@ const App: React.FC = () => { // Otherwise: send feedback. if (annotateMode) { if (gate && !hasFeedbackToSend) { + if (maybeConfirmUnsavedSourceFileEdits('approve', () => handleAnnotateApprove())) return; handleAnnotateApprove(); return; } + if (maybeConfirmUnsavedSourceFileEdits('send-feedback', () => handleAnnotateFeedback())) return; handleAnnotateFeedback(); return; } // No feedback → Approve, otherwise → Send Feedback if (!hasFeedbackToSend) { - // Check if agent exists for OpenCode users - if (origin === 'opencode') { - const warning = getAgentWarning(); - if (warning) { - setAgentWarningMessage(warning); - setShowAgentWarning(true); - return; + const approve = () => { + // Check if agent exists for OpenCode users + if (origin === 'opencode') { + const warning = getAgentWarning(); + if (warning) { + setAgentWarningMessage(warning); + setShowAgentWarning(true); + return; + } } - } - handleApprove(); + handleApprove(); + }; + if (maybeConfirmUnsavedSourceFileEdits('approve', approve)) return; + approve(); } else { // Direct edits route through deny too: on Claude Code, deny is the only // channel whose output carries feedback to the agent. + if (maybeConfirmUnsavedSourceFileEdits('send-feedback', () => handleDeny())) return; handleDeny(); } }; @@ -2232,12 +2263,12 @@ const App: React.FC = () => { window.addEventListener('keydown', handleKeyDown); return () => window.removeEventListener('keydown', handleKeyDown); }, [ - showExport, showImport, showFeedbackPrompt, showClaudeCodeWarning, showExitWarning, showAgentWarning, + showExport, showImport, showFeedbackPrompt, showClaudeCodeWarning, showSourceFileEditWarning, showExitWarning, showAgentWarning, showPermissionModeSetup, pendingPasteImage, submitted, isSubmitting, isExiting, goalSetupAction.isSubmitting, isApiMode, isEditingMarkdown, linkedDocHook.isActive, annotations.length, codeAnnotations.length, externalAnnotations.length, annotateMode, - gate, hasFeedbackToSend, hasUnsavedSourceFileBuffers, goalSetupMode, goalSetupAction.canSubmit, + gate, hasFeedbackToSend, goalSetupMode, goalSetupAction.canSubmit, annotateSource, origin, getAgentWarning, - warnFinishSourceFileEdits, + maybeConfirmUnsavedSourceFileEdits, ]); const handleAddAnnotation = (ann: Annotation) => { @@ -2809,6 +2840,7 @@ const App: React.FC = () => { if (tag === 'INPUT' || tag === 'TEXTAREA') return; if (showExport || showFeedbackPrompt || showClaudeCodeWarning || + showSourceFileEditWarning || showExitWarning || showAgentWarning || showPermissionModeSetup || pendingPasteImage) return; if (submitted || !isApiMode) return; @@ -2843,7 +2875,7 @@ const App: React.FC = () => { window.addEventListener('keydown', handleSaveShortcut); return () => window.removeEventListener('keydown', handleSaveShortcut); }, [ - showExport, showFeedbackPrompt, showClaudeCodeWarning, showExitWarning, showAgentWarning, + showExport, showFeedbackPrompt, showClaudeCodeWarning, showSourceFileEditWarning, showExitWarning, showAgentWarning, showPermissionModeSetup, pendingPasteImage, submitted, isApiMode, isEditingMarkdown, handleSaveEditedSourceFile, displayedMarkdown, annotationsOutput, ]); @@ -2857,6 +2889,7 @@ const App: React.FC = () => { if (tag === 'INPUT' || tag === 'TEXTAREA') return; if (showExport || showFeedbackPrompt || showClaudeCodeWarning || + showSourceFileEditWarning || showExitWarning || showAgentWarning || showPermissionModeSetup || pendingPasteImage) return; if (submitted) return; @@ -2868,7 +2901,7 @@ const App: React.FC = () => { window.addEventListener('keydown', handlePrintShortcut); return () => window.removeEventListener('keydown', handlePrintShortcut); }, [ - showExport, showFeedbackPrompt, showClaudeCodeWarning, showExitWarning, showAgentWarning, + showExport, showFeedbackPrompt, showClaudeCodeWarning, showSourceFileEditWarning, showExitWarning, showAgentWarning, showPermissionModeSetup, pendingPasteImage, submitted, ]); @@ -2905,65 +2938,74 @@ const App: React.FC = () => { }; const handleHeaderAnnotateExit = useCallback(() => { - if (hasUnsavedSourceFileBuffers) { - warnFinishSourceFileEdits(); - return; - } - if (hasFeedbackToSend) { - setExitWarningAction('close'); - setShowExitWarning(true); - } else { - headerHandlersRef.current.handleAnnotateExit(); - } - }, [hasFeedbackToSend, hasUnsavedSourceFileBuffers, warnFinishSourceFileEdits]); + const close = () => { + if (hasFeedbackToSend) { + setExitWarningAction('close'); + setShowExitWarning(true); + } else { + headerHandlersRef.current.handleAnnotateExit(); + } + }; + if (maybeConfirmUnsavedSourceFileEdits('close', close)) return; + close(); + }, [hasFeedbackToSend, maybeConfirmUnsavedSourceFileEdits]); const handleHeaderFeedback = useCallback(() => { - const h = headerHandlersRef.current; - if (hasUnsavedSourceFileBuffers) { - warnFinishSourceFileEdits(); - return; - } - // Direct edits count as feedback — deny is the only Claude Code channel - // whose output carries feedback to the agent. - if (!hasFeedbackToSend) { - setShowFeedbackPrompt(true); - } else { - h.handleDeny(); - } - }, [hasFeedbackToSend, hasUnsavedSourceFileBuffers, warnFinishSourceFileEdits]); + const sendFeedback = () => { + const h = headerHandlersRef.current; + // Direct edits count as feedback — deny is the only Claude Code channel + // whose output carries feedback to the agent. + if (!hasFeedbackToSend) { + setShowFeedbackPrompt(true); + } else { + h.handleDeny(); + } + }; + if (maybeConfirmUnsavedSourceFileEdits('send-feedback', sendFeedback)) return; + sendFeedback(); + }, [hasFeedbackToSend, maybeConfirmUnsavedSourceFileEdits]); const handleHeaderApprove = useCallback(() => { - const h = headerHandlersRef.current; - if (hasUnsavedSourceFileBuffers) { - warnFinishSourceFileEdits(); - return; - } - if (annotateMode) { - if (hasFeedbackToSend) { - setExitWarningAction('approve'); - setShowExitWarning(true); + const approve = () => { + const h = headerHandlersRef.current; + if (annotateMode) { + if (hasFeedbackToSend) { + setExitWarningAction('approve'); + setShowExitWarning(true); + return; + } + h.handleAnnotateApprove(); return; } - h.handleAnnotateApprove(); - return; - } - if (origin === 'claude-code' && hasFeedbackToSend) { - setShowClaudeCodeWarning(true); - return; - } - if (origin === 'opencode') { - const warning = h.getAgentWarning(); - if (warning) { - setAgentWarningMessage(warning); - setShowAgentWarning(true); + if (origin === 'claude-code' && hasFeedbackToSend) { + setShowClaudeCodeWarning(true); return; } - } - h.handleApprove(); - }, [annotateMode, hasFeedbackToSend, hasUnsavedSourceFileBuffers, origin, warnFinishSourceFileEdits]); - - const handleHeaderAnnotateFeedback = useCallback(() => headerHandlersRef.current.handleAnnotateFeedback(), []); - const handleHeaderAnnotateApprove = useCallback(() => headerHandlersRef.current.handleAnnotateApprove(), []); + if (origin === 'opencode') { + const warning = h.getAgentWarning(); + if (warning) { + setAgentWarningMessage(warning); + setShowAgentWarning(true); + return; + } + } + h.handleApprove(); + }; + if (maybeConfirmUnsavedSourceFileEdits('approve', approve)) return; + approve(); + }, [annotateMode, hasFeedbackToSend, maybeConfirmUnsavedSourceFileEdits, origin]); + + const handleHeaderAnnotateFeedback = useCallback(() => { + const sendFeedback = () => headerHandlersRef.current.handleAnnotateFeedback(); + if (maybeConfirmUnsavedSourceFileEdits('send-feedback', sendFeedback)) return; + sendFeedback(); + }, [maybeConfirmUnsavedSourceFileEdits]); + + const handleHeaderAnnotateApprove = useCallback(() => { + const approve = () => headerHandlersRef.current.handleAnnotateApprove(); + if (maybeConfirmUnsavedSourceFileEdits('approve', approve)) return; + approve(); + }, [maybeConfirmUnsavedSourceFileEdits]); const handleHeaderDownloadAnnotations = useCallback(() => headerHandlersRef.current.handleDownloadAnnotations(), []); const handleHeaderCopyAgentInstructions = useCallback(() => headerHandlersRef.current.handleCopyAgentInstructions(), []); const handleHeaderCopyShareLink = useCallback(() => headerHandlersRef.current.handleCopyShareLink(), []); @@ -3652,6 +3694,30 @@ const App: React.FC = () => { variant="info" /> + {/* Unsaved source-file edit warning dialog */} + You have unsaved file edits. They are not saved to disk and will be lost if you close this session. + : <>You have unsaved file edits. They are not saved to disk, and {agentName} won't get them if you {sourceFileEditWarningAction === 'approve' ? 'approve' : 'send feedback'}. + } + subMessage="Save or discard the file edits first if you want Plannotator to keep them." + confirmText={ + sourceFileEditWarningAction === 'approve' + ? 'Approve Anyway' + : sourceFileEditWarningAction === 'close' + ? 'Close Anyway' + : 'Send Anyway' + } + cancelText="Cancel" + variant="warning" + showCancel + /> + {/* Claude Code feedback warning dialog */} { expect(afterCommit.totals.files).toBe(0); expect(afterCommit.files).toEqual({}); }); + + test("keeps line counts for renamed files with edits", () => { + const repo = tempRepo(); + const docs = join(repo, "docs"); + mkdirSync(docs); + writeFileSync(join(docs, "old.md"), "one\ntwo\nthree\n"); + git(repo, "add", "-A"); + git(repo, "commit", "-m", "init"); + + git(repo, "mv", join("docs", "old.md"), join("docs", "new.md")); + writeFileSync(join(docs, "new.md"), "one\nTWO\nthree\nfour\n"); + + const status = getWorkspaceStatusForDirectory(docs); + const realDocs = realpathSync(docs); + const change = status.files[join(realDocs, "new.md")]; + + expect(status.available).toBe(true); + expect(change?.status).toBe("renamed"); + expect(change?.oldPath).toBe(join(realDocs, "old.md")); + expect(change?.additions).toBe(2); + expect(change?.deletions).toBe(1); + expect(status.totals.additions).toBe(2); + expect(status.totals.deletions).toBe(1); + }); }); diff --git a/packages/shared/workspace-status.ts b/packages/shared/workspace-status.ts index 9fb8cba1b3..53cbe3b63b 100644 --- a/packages/shared/workspace-status.ts +++ b/packages/shared/workspace-status.ts @@ -139,13 +139,19 @@ function parsePorcelain(output: string): Array<{ function parseNumstat(output: string): Map { const counts = new Map(); - for (const record of output.split("\0")) { + const records = output.split("\0"); + for (let i = 0; i < records.length; i++) { + const record = records[i]; if (!record) continue; const parts = record.split("\t"); if (parts.length < 3) continue; const additions = parts[0] === "-" ? 0 : Number.parseInt(parts[0] ?? "0", 10); const deletions = parts[1] === "-" ? 0 : Number.parseInt(parts[1] ?? "0", 10); - const path = parts.slice(2).join("\t"); + let path = parts.slice(2).join("\t"); + if (!path) { + path = records[i + 2] ?? ""; + i += 2; + } if (!path) continue; counts.set(path, { additions: Number.isFinite(additions) ? additions : 0, From afb46ac33c57bbeb53b8f78e73f1d6c7bafdd5e2 Mon Sep 17 00:00:00 2001 From: Michael Ramos Date: Wed, 17 Jun 2026 09:35:35 -0700 Subject: [PATCH 3/9] fix(annotate): tighten live file tree status --- .../pi-extension/server/file-browser-watch.ts | 39 ++++++++------- apps/pi-extension/server/reference.ts | 7 ++- packages/server/reference-handlers.test.ts | 24 +++++++++ packages/server/reference-handlers.ts | 9 ++-- packages/server/reference-watch.test.ts | 16 +++++- packages/server/reference-watch.ts | 39 ++++++++------- packages/shared/reference-common.ts | 8 +++ packages/shared/workspace-status.ts | 34 +++++++++++++ .../ui/components/sidebar/FileBrowser.test.ts | 41 +++++++++++++++ .../ui/components/sidebar/FileBrowser.tsx | 43 ++++++++++++++-- packages/ui/hooks/useFileBrowser.ts | 50 +++++++++++-------- 11 files changed, 243 insertions(+), 67 deletions(-) create mode 100644 packages/ui/components/sidebar/FileBrowser.test.ts diff --git a/apps/pi-extension/server/file-browser-watch.ts b/apps/pi-extension/server/file-browser-watch.ts index b0cc6f6aaf..f82b88a931 100644 --- a/apps/pi-extension/server/file-browser-watch.ts +++ b/apps/pi-extension/server/file-browser-watch.ts @@ -3,7 +3,7 @@ import { existsSync, statSync } from "node:fs"; import type { IncomingMessage, ServerResponse } from "node:http"; import { isAbsolute, relative } from "node:path"; -import { FILE_BROWSER_EXCLUDED } from "../generated/reference-common.js"; +import { isFileBrowserExcludedPath } from "../generated/reference-common.js"; import { resolveUserPath } from "../generated/resolve-file.js"; import { getGitMetadataWatchPaths } from "../generated/workspace-status.js"; import { json } from "./helpers.js"; @@ -17,7 +17,7 @@ interface FileBrowserChangeEvent { interface WatchEntry { dirPath: string; - subscribers: Set; + subscribers: Map; contentWatcher: FSWatcher | null; gitWatcher: FSWatcher | null; debounceTimer: ReturnType | null; @@ -34,10 +34,7 @@ function serialize(event: FileBrowserChangeEvent): string { function isExcludedPath(path: string, root: string): boolean { const rel = relative(root, path).replace(/\\/g, "/"); if (!rel || rel.startsWith("..") || isAbsolute(rel)) return false; - return FILE_BROWSER_EXCLUDED.some((entry) => { - const name = entry.replace(/\/$/, ""); - return rel === name || rel.startsWith(`${name}/`) || rel.includes(`/${name}/`); - }); + return isFileBrowserExcludedPath(rel); } function isValidDirectory(dirPath: string): boolean { @@ -49,13 +46,13 @@ function isValidDirectory(dirPath: string): boolean { } function broadcast(entry: WatchEntry, reason: FileBrowserChangeEvent["reason"]): void { - const payload = serialize({ - type: "changed", - dirPath: entry.dirPath, - reason, - timestamp: Date.now(), - }); - for (const res of entry.subscribers) { + for (const [res, clientDirPath] of entry.subscribers) { + const payload = serialize({ + type: "changed", + dirPath: clientDirPath, + reason, + timestamp: Date.now(), + }); try { res.write(payload); } catch { @@ -90,7 +87,7 @@ function ensureWatcher(dirPath: string): WatchEntry { const entry: WatchEntry = { dirPath, - subscribers: new Set(), + subscribers: new Map(), contentWatcher: null, gitWatcher: null, debounceTimer: null, @@ -136,13 +133,17 @@ export function handleFileBrowserStreamRequest(req: IncomingMessage, res: Server } const dirPaths: string[] = []; + const clientDirPaths: string[] = []; for (const rawDirPath of rawDirPaths) { const dirPath = resolveUserPath(rawDirPath); if (!isValidDirectory(dirPath)) { json(res, { error: "Invalid directory path" }, 400); return true; } - if (!dirPaths.includes(dirPath)) dirPaths.push(dirPath); + if (!dirPaths.includes(dirPath)) { + dirPaths.push(dirPath); + clientDirPaths.push(rawDirPath); + } } const entries = dirPaths.map((dirPath) => ensureWatcher(dirPath)); @@ -152,14 +153,16 @@ export function handleFileBrowserStreamRequest(req: IncomingMessage, res: Server Connection: "keep-alive", }); res.setTimeout(0); - for (const entry of entries) { + for (let i = 0; i < entries.length; i++) { + const entry = entries[i]!; + const clientDirPath = clientDirPaths[i] ?? entry.dirPath; res.write(serialize({ type: "ready", - dirPath: entry.dirPath, + dirPath: clientDirPath, reason: "initial", timestamp: Date.now(), })); - entry.subscribers.add(res); + entry.subscribers.set(res, clientDirPath); } const heartbeat = setInterval(() => { diff --git a/apps/pi-extension/server/reference.ts b/apps/pi-extension/server/reference.ts index 12b8c1a726..eba04d7679 100644 --- a/apps/pi-extension/server/reference.ts +++ b/apps/pi-extension/server/reference.ts @@ -21,8 +21,10 @@ import { type VaultNode, buildFileTree, FILE_BROWSER_EXCLUDED, + isFileBrowserExcludedPath, } from "../generated/reference-common.js"; import { + filterWorkspaceStatusForDirectory, getWorkspaceStatusForDirectory, getWorkspaceStatusRelativePaths, type WorkspaceFileChange, @@ -199,13 +201,14 @@ function walkMarkdownFiles(dir: string, root: string, results: string[], extensi const relative = join(dir, entry.name) .slice(root.length + 1) .replace(/\\/g, "/"); + if (isFileBrowserExcludedPath(relative)) continue; results.push(relative); } } } function includeWorkspaceFile(relativePath: string, _change: WorkspaceFileChange): boolean { - return FILE_BROWSER_EXTENSIONS.test(relativePath); + return FILE_BROWSER_EXTENSIONS.test(relativePath) && !isFileBrowserExcludedPath(relativePath); } /** Serve a linked markdown document. Uses shared resolveMarkdownFile for parity with Bun server. */ @@ -521,7 +524,7 @@ export function handleFileBrowserRequest(res: Res, url: URL): void { const diskFiles: string[] = []; walkMarkdownFiles(resolvedDir, resolvedDir, diskFiles); for (const file of diskFiles) files.add(file); - const workspaceStatus = getWorkspaceStatusForDirectory(resolvedDir); + const workspaceStatus = filterWorkspaceStatusForDirectory(getWorkspaceStatusForDirectory(resolvedDir), resolvedDir, includeWorkspaceFile); for (const file of getWorkspaceStatusRelativePaths(workspaceStatus, resolvedDir, includeWorkspaceFile)) { files.add(file); } diff --git a/packages/server/reference-handlers.test.ts b/packages/server/reference-handlers.test.ts index eeed6d0bc5..5029fce7fa 100644 --- a/packages/server/reference-handlers.test.ts +++ b/packages/server/reference-handlers.test.ts @@ -160,4 +160,28 @@ describe("handleFileBrowserFiles", () => { expect(data.workspaceStatus.files[join(realDocs, "new.md")]?.status).toBe("untracked"); expect(data.workspaceStatus.files[join(realDocs, "plan.md")]?.additions).toBe(2); }); + + test("does not reintroduce git changes from excluded folders", async () => { + const root = makeTempDir("plannotator-files-excluded-"); + git(root, "init", "-b", "main"); + git(root, "config", "user.email", "test@test"); + git(root, "config", "user.name", "Test"); + writeTempFile(root, "docs/visible.md", "visible\n"); + writeTempFile(root, "dist/generated.md", "before\n"); + git(root, "add", "-A"); + git(root, "commit", "-m", "init"); + + writeTempFile(root, "dist/generated.md", "after\n"); + writeTempFile(root, "node_modules/pkg/readme.md", "hidden\n"); + + const url = new URL("http://localhost/api/reference/files"); + url.searchParams.set("dirPath", root); + const res = await handleFileBrowserFiles(new Request(url.toString())); + const data = await res.json() as { tree: VaultNode[]; workspaceStatus: WorkspaceStatusPayload }; + + expect(res.status).toBe(200); + expect(flattenTree(data.tree).sort()).toEqual(["docs/visible.md"]); + expect(data.workspaceStatus.totals.files).toBe(0); + expect(data.workspaceStatus.files).toEqual({}); + }); }); diff --git a/packages/server/reference-handlers.ts b/packages/server/reference-handlers.ts index 52b00b2a6a..712c50b49b 100644 --- a/packages/server/reference-handlers.ts +++ b/packages/server/reference-handlers.ts @@ -7,8 +7,9 @@ import { existsSync, statSync } from "fs"; import { resolve } from "path"; -import { buildFileTree, FILE_BROWSER_EXCLUDED } from "@plannotator/shared/reference-common"; +import { buildFileTree, isFileBrowserExcludedPath } from "@plannotator/shared/reference-common"; import { + filterWorkspaceStatusForDirectory, getWorkspaceStatusForDirectory, getWorkspaceStatusRelativePaths, type WorkspaceFileChange, @@ -507,7 +508,7 @@ export async function handleObsidianDoc(req: Request): Promise { const FILE_BROWSER_EXTENSIONS = /\.(mdx?|txt|html?)$/i; function includeWorkspaceFile(relativePath: string, _change: WorkspaceFileChange): boolean { - return FILE_BROWSER_EXTENSIONS.test(relativePath); + return FILE_BROWSER_EXTENSIONS.test(relativePath) && !isFileBrowserExcludedPath(relativePath); } /** List markdown files in a directory as a nested tree. */ @@ -533,10 +534,10 @@ export async function handleFileBrowserFiles(req: Request): Promise { cwd: resolvedDir, onlyFiles: true, })) { - if (FILE_BROWSER_EXCLUDED.some((dir) => match.includes(dir))) continue; + if (isFileBrowserExcludedPath(match)) continue; files.add(match); } - const workspaceStatus = getWorkspaceStatusForDirectory(resolvedDir); + const workspaceStatus = filterWorkspaceStatusForDirectory(getWorkspaceStatusForDirectory(resolvedDir), resolvedDir, includeWorkspaceFile); for (const match of getWorkspaceStatusRelativePaths(workspaceStatus, resolvedDir, includeWorkspaceFile)) { files.add(match); } diff --git a/packages/server/reference-watch.test.ts b/packages/server/reference-watch.test.ts index 7357a1d23c..5161d25d9f 100644 --- a/packages/server/reference-watch.test.ts +++ b/packages/server/reference-watch.test.ts @@ -1,7 +1,7 @@ import { afterEach, describe, expect, test } from "bun:test"; import { mkdtempSync, rmSync } from "node:fs"; import { tmpdir } from "node:os"; -import { join } from "node:path"; +import { basename, dirname, join } from "node:path"; import { handleFileBrowserFilesStream } from "./reference-watch"; const tempDirs: string[] = []; @@ -69,4 +69,18 @@ describe("handleFileBrowserFilesStream", () => { ["ready", second], ].sort()); }); + + test("echoes the subscribed client path instead of the resolved watcher path", async () => { + const root = makeTempDir("plannotator-watch-c-"); + const nonCanonicalRoot = join(dirname(root), "..", basename(dirname(root)), basename(root)); + const url = new URL("http://localhost/api/reference/files/stream"); + url.searchParams.append("dirPath", nonCanonicalRoot); + + const response = handleFileBrowserFilesStream(new Request(url.toString())); + const events = await readSSEEvents(response, 1); + + expect(response.status).toBe(200); + expect(events[0]?.type).toBe("ready"); + expect(events[0]?.dirPath).toBe(nonCanonicalRoot); + }); }); diff --git a/packages/server/reference-watch.ts b/packages/server/reference-watch.ts index c2e64a1b60..a34f8bf4d6 100644 --- a/packages/server/reference-watch.ts +++ b/packages/server/reference-watch.ts @@ -1,7 +1,7 @@ import chokidar, { type FSWatcher } from "chokidar"; import { existsSync, statSync } from "fs"; import { isAbsolute, relative } from "path"; -import { FILE_BROWSER_EXCLUDED } from "@plannotator/shared/reference-common"; +import { isFileBrowserExcludedPath } from "@plannotator/shared/reference-common"; import { resolveUserPath } from "@plannotator/shared/resolve-file"; import { getGitMetadataWatchPaths } from "@plannotator/shared/workspace-status"; @@ -14,7 +14,7 @@ interface FileBrowserChangeEvent { interface WatchEntry { dirPath: string; - subscribers: Set; + subscribers: Map; contentWatcher: FSWatcher | null; gitWatcher: FSWatcher | null; debounceTimer: ReturnType | null; @@ -32,10 +32,7 @@ function serialize(event: FileBrowserChangeEvent): Uint8Array { function isExcludedPath(path: string, root: string): boolean { const rel = relative(root, path).replace(/\\/g, "/"); if (!rel || rel.startsWith("..") || isAbsolute(rel)) return false; - return FILE_BROWSER_EXCLUDED.some((entry) => { - const name = entry.replace(/\/$/, ""); - return rel === name || rel.startsWith(`${name}/`) || rel.includes(`/${name}/`); - }); + return isFileBrowserExcludedPath(rel); } function isValidDirectory(dirPath: string): boolean { @@ -47,13 +44,13 @@ function isValidDirectory(dirPath: string): boolean { } function broadcast(entry: WatchEntry, reason: FileBrowserChangeEvent["reason"]): void { - const payload = serialize({ - type: "changed", - dirPath: entry.dirPath, - reason, - timestamp: Date.now(), - }); - for (const subscriber of entry.subscribers) { + for (const [subscriber, clientDirPath] of entry.subscribers) { + const payload = serialize({ + type: "changed", + dirPath: clientDirPath, + reason, + timestamp: Date.now(), + }); try { subscriber.enqueue(payload); } catch { @@ -88,7 +85,7 @@ function ensureWatcher(dirPath: string): WatchEntry { const entry: WatchEntry = { dirPath, - subscribers: new Set(), + subscribers: new Map(), contentWatcher: null, gitWatcher: null, debounceTimer: null, @@ -135,12 +132,16 @@ export function handleFileBrowserFilesStream( } const dirPaths: string[] = []; + const clientDirPaths: string[] = []; for (const rawDirPath of rawDirPaths) { const dirPath = resolveUserPath(rawDirPath); if (!isValidDirectory(dirPath)) { return Response.json({ error: "Invalid directory path" }, { status: 400 }); } - if (!dirPaths.includes(dirPath)) dirPaths.push(dirPath); + if (!dirPaths.includes(dirPath)) { + dirPaths.push(dirPath); + clientDirPaths.push(rawDirPath); + } } options?.disableIdleTimeout?.(); @@ -151,11 +152,13 @@ export function handleFileBrowserFilesStream( const stream = new ReadableStream({ start(controller) { controllerRef = controller; - for (const entry of entries) { - entry.subscribers.add(controller); + for (let i = 0; i < entries.length; i++) { + const entry = entries[i]!; + const clientDirPath = clientDirPaths[i] ?? entry.dirPath; + entry.subscribers.set(controller, clientDirPath); controller.enqueue(serialize({ type: "ready", - dirPath: entry.dirPath, + dirPath: clientDirPath, reason: "initial", timestamp: Date.now(), })); diff --git a/packages/shared/reference-common.ts b/packages/shared/reference-common.ts index eb3bc6a83c..f98317ca96 100644 --- a/packages/shared/reference-common.ts +++ b/packages/shared/reference-common.ts @@ -29,6 +29,14 @@ export const FILE_BROWSER_EXCLUDED = [ "storybook-static/", ]; +export function isFileBrowserExcludedPath(relativePath: string): boolean { + const normalized = relativePath.replace(/\\/g, "/").replace(/^\/+/, ""); + return FILE_BROWSER_EXCLUDED.some((entry) => { + const name = entry.replace(/\/+$/, ""); + return normalized === name || normalized.startsWith(`${name}/`) || normalized.includes(`/${name}/`); + }); +} + export interface VaultNode { name: string; path: string; // relative path within vault diff --git a/packages/shared/workspace-status.ts b/packages/shared/workspace-status.ts index 53cbe3b63b..26d7f11712 100644 --- a/packages/shared/workspace-status.ts +++ b/packages/shared/workspace-status.ts @@ -283,6 +283,40 @@ export function getWorkspaceStatusRelativePaths( return paths; } +export function filterWorkspaceStatusForDirectory( + status: WorkspaceStatusPayload, + dirPath: string, + filter?: (relativePath: string, change: WorkspaceFileChange) => boolean, +): WorkspaceStatusPayload { + if (!status.available) return status; + let rootPath = status.rootPath || resolve(dirPath); + try { + rootPath = status.rootPath || realpathSync(resolve(dirPath)); + } catch { + // Fall back to the resolved input when the directory disappeared between calls. + } + const files: Record = {}; + let additions = 0; + let deletions = 0; + for (const change of Object.values(status.files)) { + const rel = relative(rootPath, change.path).replace(/\\/g, "/"); + if (!rel || rel.startsWith("..") || isAbsolute(rel)) continue; + if (filter && !filter(rel, change)) continue; + files[change.path] = change; + additions += change.additions; + deletions += change.deletions; + } + return { + ...status, + files, + totals: { + files: Object.keys(files).length, + additions, + deletions, + }, + }; +} + export function getGitMetadataWatchPaths(cwd: string): string[] { const repo = getGitRepositoryInfo(cwd); if (!repo) return []; diff --git a/packages/ui/components/sidebar/FileBrowser.test.ts b/packages/ui/components/sidebar/FileBrowser.test.ts new file mode 100644 index 0000000000..5eaf7c7018 --- /dev/null +++ b/packages/ui/components/sidebar/FileBrowser.test.ts @@ -0,0 +1,41 @@ +import { describe, expect, test } from "bun:test"; +import type { VaultNode } from "../../types"; +import type { WorkspaceStatusPayload } from "@plannotator/shared/workspace-status"; +import { getAggregateWorkspaceChange, getWorkspaceChange, normalizePathForLookup } from "./FileBrowser"; + +describe("FileBrowser workspace status lookup", () => { + test("matches Windows status keys when the UI path uses mixed separators", () => { + const status: WorkspaceStatusPayload = { + available: true, + rootPath: "C:\\repo\\docs", + repoRoot: "C:\\repo", + files: { + "C:\\repo\\docs\\nested\\a.md": { + path: "C:\\repo\\docs\\nested\\a.md", + repoRelativePath: "docs/nested/a.md", + status: "modified", + additions: 3, + deletions: 1, + staged: false, + unstaged: true, + }, + }, + totals: { files: 1, additions: 3, deletions: 1 }, + }; + + expect(normalizePathForLookup("C:\\repo\\docs/nested/a.md")).toBe("C:/repo/docs/nested/a.md"); + expect(getWorkspaceChange("C:\\repo\\docs/nested/a.md", status)?.additions).toBe(3); + + const node: VaultNode = { + name: "nested", + path: "nested", + type: "folder", + children: [{ name: "a.md", path: "nested/a.md", type: "file" }], + }; + expect(getAggregateWorkspaceChange(node, "C:\\repo\\docs", status)).toEqual({ + additions: 3, + deletions: 1, + files: 1, + }); + }); +}); diff --git a/packages/ui/components/sidebar/FileBrowser.tsx b/packages/ui/components/sidebar/FileBrowser.tsx index 569672124f..871d77e49e 100644 --- a/packages/ui/components/sidebar/FileBrowser.tsx +++ b/packages/ui/components/sidebar/FileBrowser.tsx @@ -38,6 +38,31 @@ interface AggregateWorkspaceChange { files: number; } +export function normalizePathForLookup(path: string): string { + return path.replace(/\\/g, "/").replace(/\/+$/, ""); +} + +function normalizeWorkspaceStatus( + workspaceStatus?: WorkspaceStatusPayload +): WorkspaceStatusPayload | undefined { + if (!workspaceStatus) return workspaceStatus; + const files: WorkspaceStatusPayload["files"] = {}; + for (const [path, change] of Object.entries(workspaceStatus.files ?? {})) { + const normalizedPath = normalizePathForLookup(path); + const normalizedOldPath = change.oldPath ? normalizePathForLookup(change.oldPath) : undefined; + files[normalizedPath] = { + ...change, + path: normalizedPath, + oldPath: normalizedOldPath, + }; + } + return { + ...workspaceStatus, + rootPath: normalizePathForLookup(workspaceStatus.rootPath), + files, + }; +} + /** Recursively sum annotation counts for all descendant files of a folder node */ function getAggregateCount( node: VaultNode, @@ -54,14 +79,22 @@ function getAggregateCount( return total; } -function getWorkspaceChange( +export function getWorkspaceChange( absolutePath: string, workspaceStatus?: WorkspaceStatusPayload ): WorkspaceFileChange | undefined { - return workspaceStatus?.files?.[absolutePath]; + const files = workspaceStatus?.files; + if (!files) return undefined; + const normalizedPath = normalizePathForLookup(absolutePath); + const direct = files[absolutePath] ?? files[normalizedPath]; + if (direct) return direct; + for (const [path, change] of Object.entries(files)) { + if (normalizePathForLookup(path) === normalizedPath) return change; + } + return undefined; } -function getAggregateWorkspaceChange( +export function getAggregateWorkspaceChange( node: VaultNode, dirPath: string, workspaceStatus?: WorkspaceStatusPayload @@ -232,6 +265,8 @@ const DirSection: React.FC<{ highlightedFiles?: Set; editStatuses?: Map; }> = ({ dir, expandedFolders, onToggleFolder, onSelectFile, activeFile, onRetry, annotationCounts, highlightedFiles, editStatuses }) => { + const workspaceStatus = React.useMemo(() => normalizeWorkspaceStatus(dir.workspaceStatus), [dir.workspaceStatus]); + if (dir.isLoading) { return (
@@ -277,7 +312,7 @@ const DirSection: React.FC<{ annotationCounts={annotationCounts} highlightedFiles={highlightedFiles} editStatuses={editStatuses} - workspaceStatus={dir.workspaceStatus} + workspaceStatus={workspaceStatus} /> ))}
diff --git a/packages/ui/hooks/useFileBrowser.ts b/packages/ui/hooks/useFileBrowser.ts index 60fac44624..9674f2c30f 100644 --- a/packages/ui/hooks/useFileBrowser.ts +++ b/packages/ui/hooks/useFileBrowser.ts @@ -39,7 +39,7 @@ export interface UseFileBrowserReturn { } function normalizeRoot(path: string): string { - return path.replace(/\/+$/, ""); + return path.replace(/\\/g, "/").replace(/\/+$/, ""); } function remapWorkspaceStatusForDir( @@ -49,18 +49,20 @@ function remapWorkspaceStatusForDir( if (!status?.rootPath) return status; const fromRoot = normalizeRoot(status.rootPath); const toRoot = normalizeRoot(dirPath); - if (!fromRoot || fromRoot === toRoot) return status; + if (!fromRoot) return status; const files: WorkspaceStatusPayload["files"] = {}; for (const [path, change] of Object.entries(status.files)) { - const nextPath = path === fromRoot + const normalizedPath = normalizeRoot(path); + const nextPath = normalizedPath === fromRoot ? toRoot - : path.startsWith(`${fromRoot}/`) - ? `${toRoot}${path.slice(fromRoot.length)}` - : path; - const nextOldPath = change.oldPath && change.oldPath.startsWith(`${fromRoot}/`) - ? `${toRoot}${change.oldPath.slice(fromRoot.length)}` - : change.oldPath; + : normalizedPath.startsWith(`${fromRoot}/`) + ? `${toRoot}${normalizedPath.slice(fromRoot.length)}` + : normalizedPath; + const normalizedOldPath = change.oldPath ? normalizeRoot(change.oldPath) : undefined; + const nextOldPath = normalizedOldPath && normalizedOldPath.startsWith(`${fromRoot}/`) + ? `${toRoot}${normalizedOldPath.slice(fromRoot.length)}` + : normalizedOldPath; files[nextPath] = { ...change, path: nextPath, @@ -97,7 +99,9 @@ export function useFileBrowser(): UseFileBrowserReturn { const exists = prev.find((d) => d.path === dirPath); if (exists) { return prev.map((d) => - d.path === dirPath ? { ...d, isLoading: options.quiet ? d.isLoading : true, error: null } : d + d.path === dirPath + ? { ...d, isLoading: options.quiet ? d.isLoading : true, error: options.quiet ? d.error : null } + : d ); } return [...prev, { path: dirPath, name, tree: [], isLoading: true, error: null }]; @@ -112,7 +116,9 @@ export function useFileBrowser(): UseFileBrowserReturn { if (!res.ok || data.error) { setDirs((prev) => prev.map((d) => - d.path === dirPath ? { ...d, isLoading: false, error: data.error || "Failed to load" } : d + d.path === dirPath + ? { ...d, isLoading: false, error: options.quiet ? d.error : data.error || "Failed to load" } + : d ) ); return; @@ -134,18 +140,22 @@ export function useFileBrowser(): UseFileBrowserReturn { ) ); - const rootFolders = (data.tree as VaultNode[]) - .filter((n) => n.type === "folder") - .map((n) => `${dirPath}:${n.path}`); - setExpandedFolders((prev) => { - const next = new Set(prev); - rootFolders.forEach((f) => next.add(f)); - return next; - }); + if (!options.quiet) { + const rootFolders = (data.tree as VaultNode[]) + .filter((n) => n.type === "folder") + .map((n) => `${dirPath}:${n.path}`); + setExpandedFolders((prev) => { + const next = new Set(prev); + rootFolders.forEach((f) => next.add(f)); + return next; + }); + } } catch { setDirs((prev) => prev.map((d) => - d.path === dirPath ? { ...d, isLoading: false, error: "Failed to connect to server" } : d + d.path === dirPath + ? { ...d, isLoading: false, error: options.quiet ? d.error : "Failed to connect to server" } + : d ) ); } From 5a82fbce0c665104f01c24825835c4384ca23b65 Mon Sep 17 00:00:00 2001 From: Michael Ramos Date: Wed, 17 Jun 2026 11:17:01 -0700 Subject: [PATCH 4/9] fix(annotate): surface deleted file browser roots --- packages/ui/hooks/useFileBrowser.test.tsx | 120 ++++++++++++++++++++++ packages/ui/hooks/useFileBrowser.ts | 16 ++- 2 files changed, 135 insertions(+), 1 deletion(-) create mode 100644 packages/ui/hooks/useFileBrowser.test.tsx diff --git a/packages/ui/hooks/useFileBrowser.test.tsx b/packages/ui/hooks/useFileBrowser.test.tsx new file mode 100644 index 0000000000..cdb7bddd29 --- /dev/null +++ b/packages/ui/hooks/useFileBrowser.test.tsx @@ -0,0 +1,120 @@ +import { afterEach, describe, expect, test } from "bun:test"; +import React from "react"; +import { createRoot, type Root } from "react-dom/client"; +import { act } from "react"; + +import { useFileBrowser, type UseFileBrowserReturn } from "./useFileBrowser"; +import type { VaultNode } from "../types"; + +const hasDom = typeof document !== "undefined"; +const realFetch = globalThis.fetch; + +function response(body: unknown, status = 200): Response { + return new Response(JSON.stringify(body), { + status, + headers: { "Content-Type": "application/json" }, + }); +} + +function installFetchResponses(responses: Response[]): void { + const nextFetch = async () => responses.shift() ?? response({ error: "unexpected fetch" }, 500); + globalThis.fetch = nextFetch as unknown as typeof fetch; +} + +function Harness({ resultRef }: { resultRef: { current: UseFileBrowserReturn | null } }) { + resultRef.current = useFileBrowser(); + return null; +} + +async function mountHook(): Promise<{ + result: { current: UseFileBrowserReturn | null }; + unmount: () => Promise; +}> { + const host = document.createElement("div"); + document.body.appendChild(host); + const resultRef: { current: UseFileBrowserReturn | null } = { current: null }; + let root: Root; + await act(async () => { + root = createRoot(host); + root.render(); + }); + return { + result: resultRef, + unmount: async () => { + await act(async () => { + root.unmount(); + }); + host.remove(); + }, + }; +} + +async function fetchTree( + browser: UseFileBrowserReturn, + dirPath: string, + options?: { quiet?: boolean }, +): Promise { + await act(async () => { + await (browser.fetchTree(dirPath, options) as unknown as Promise); + }); +} + +afterEach(() => { + globalThis.fetch = realFetch; + if (hasDom) document.body.innerHTML = ""; +}); + +describe("useFileBrowser", () => { + test.skipIf(!hasDom)("quiet invalid-directory refresh clears stale files", async () => { + const dirPath = "/tmp/plannotator-docs"; + const tree: VaultNode[] = [{ type: "file", name: "a.md", path: "a.md" }]; + installFetchResponses([ + response({ + tree, + workspaceStatus: { + available: true, + rootPath: dirPath, + files: {}, + totals: { files: 0, additions: 0, deletions: 0 }, + }, + }), + response({ error: "Invalid directory path" }, 400), + ]); + + const session = await mountHook(); + await fetchTree(session.result.current!, dirPath); + expect(session.result.current!.dirs[0]?.tree).toEqual(tree); + expect(session.result.current!.dirs[0]?.error).toBeNull(); + + await fetchTree(session.result.current!, dirPath, { quiet: true }); + expect(session.result.current!.dirs[0]).toMatchObject({ + path: dirPath, + tree: [], + error: "Invalid directory path", + }); + expect(session.result.current!.dirs[0]?.workspaceStatus).toBeUndefined(); + + await session.unmount(); + }); + + test.skipIf(!hasDom)("quiet server failure preserves the previous tree", async () => { + const dirPath = "/tmp/plannotator-docs"; + const tree: VaultNode[] = [{ type: "file", name: "a.md", path: "a.md" }]; + installFetchResponses([ + response({ tree }), + response({ error: "Failed to list directory files" }, 500), + ]); + + const session = await mountHook(); + await fetchTree(session.result.current!, dirPath); + await fetchTree(session.result.current!, dirPath, { quiet: true }); + + expect(session.result.current!.dirs[0]).toMatchObject({ + path: dirPath, + tree, + error: null, + }); + + await session.unmount(); + }); +}); diff --git a/packages/ui/hooks/useFileBrowser.ts b/packages/ui/hooks/useFileBrowser.ts index 9674f2c30f..481dbfd04d 100644 --- a/packages/ui/hooks/useFileBrowser.ts +++ b/packages/ui/hooks/useFileBrowser.ts @@ -38,6 +38,10 @@ export interface UseFileBrowserReturn { setActiveFile: (path: string | null) => void; } +function isPermanentFileBrowserFetchError(status: number): boolean { + return status >= 400 && status < 500; +} + function normalizeRoot(path: string): string { return path.replace(/\\/g, "/").replace(/\/+$/, ""); } @@ -114,10 +118,20 @@ export function useFileBrowser(): UseFileBrowserReturn { const data = await res.json(); if (!res.ok || data.error) { + const error = data.error || "Failed to load"; + const shouldSurfaceError = !options.quiet || isPermanentFileBrowserFetchError(res.status); setDirs((prev) => prev.map((d) => d.path === dirPath - ? { ...d, isLoading: false, error: options.quiet ? d.error : data.error || "Failed to load" } + ? shouldSurfaceError + ? { + ...d, + tree: options.quiet ? [] : d.tree, + workspaceStatus: options.quiet ? undefined : d.workspaceStatus, + isLoading: false, + error, + } + : { ...d, isLoading: false, error: d.error } : d ) ); From ae0f314c034fd8f8226b677e986572352e6ada72 Mon Sep 17 00:00:00 2001 From: Michael Ramos Date: Wed, 17 Jun 2026 11:48:50 -0700 Subject: [PATCH 5/9] fix(annotate): normalize file tree status paths --- .../ui/components/sidebar/FileBrowser.test.ts | 34 +++++++++++++++++++ .../ui/components/sidebar/FileBrowser.tsx | 4 ++- 2 files changed, 37 insertions(+), 1 deletion(-) diff --git a/packages/ui/components/sidebar/FileBrowser.test.ts b/packages/ui/components/sidebar/FileBrowser.test.ts index 5eaf7c7018..a8f0b45d37 100644 --- a/packages/ui/components/sidebar/FileBrowser.test.ts +++ b/packages/ui/components/sidebar/FileBrowser.test.ts @@ -38,4 +38,38 @@ describe("FileBrowser workspace status lookup", () => { files: 1, }); }); + + test("matches workspace status when configured directory has a trailing slash", () => { + const status: WorkspaceStatusPayload = { + available: true, + rootPath: "/repo/docs", + repoRoot: "/repo", + files: { + "/repo/docs/plan.md": { + path: "/repo/docs/plan.md", + repoRelativePath: "docs/plan.md", + status: "modified", + additions: 4, + deletions: 2, + staged: false, + unstaged: true, + }, + }, + totals: { files: 1, additions: 4, deletions: 2 }, + }; + const node: VaultNode = { + name: "docs", + path: ".", + type: "folder", + children: [{ name: "plan.md", path: "plan.md", type: "file" }], + }; + + expect(normalizePathForLookup("/repo/docs//plan.md")).toBe("/repo/docs/plan.md"); + expect(getWorkspaceChange("/repo/docs//plan.md", status)?.additions).toBe(4); + expect(getAggregateWorkspaceChange(node, "/repo/docs/", status)).toEqual({ + additions: 4, + deletions: 2, + files: 1, + }); + }); }); diff --git a/packages/ui/components/sidebar/FileBrowser.tsx b/packages/ui/components/sidebar/FileBrowser.tsx index 871d77e49e..6b2ab6c332 100644 --- a/packages/ui/components/sidebar/FileBrowser.tsx +++ b/packages/ui/components/sidebar/FileBrowser.tsx @@ -39,7 +39,9 @@ interface AggregateWorkspaceChange { } export function normalizePathForLookup(path: string): string { - return path.replace(/\\/g, "/").replace(/\/+$/, ""); + const normalized = path.replace(/\\/g, "/"); + const prefix = normalized.startsWith("//") ? "//" : ""; + return prefix + normalized.slice(prefix.length).replace(/\/+/g, "/").replace(/\/+$/, ""); } function normalizeWorkspaceStatus( From e7b176fbfd76e66710bb589a27ab20bacd003731 Mon Sep 17 00:00:00 2001 From: Michael Ramos Date: Wed, 17 Jun 2026 11:57:38 -0700 Subject: [PATCH 6/9] fix(annotate): avoid optional git locks for workspace status --- packages/shared/workspace-status.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/shared/workspace-status.ts b/packages/shared/workspace-status.ts index 26d7f11712..49bf744a3c 100644 --- a/packages/shared/workspace-status.ts +++ b/packages/shared/workspace-status.ts @@ -45,7 +45,7 @@ export interface GitRepositoryInfo { const TEXT_FILE_MAX_BYTES = 2 * 1024 * 1024; function runGit(cwd: string, args: string[]): { ok: true; stdout: string } | { ok: false; error: string } { - const result = spawnSync("git", ["-C", cwd, ...args], { + const result = spawnSync("git", ["--no-optional-locks", "-C", cwd, ...args], { encoding: "utf8", maxBuffer: 20 * 1024 * 1024, }); From d2908755bd23ce6782741b0021797849e7fce6f5 Mon Sep 17 00:00:00 2001 From: Michael Ramos Date: Wed, 17 Jun 2026 13:09:31 -0700 Subject: [PATCH 7/9] fix(annotate): tighten workspace status git metadata --- packages/shared/workspace-status.test.ts | 42 ++++++++++++++++++- packages/shared/workspace-status.ts | 52 ++++++++++++++++++++---- 2 files changed, 86 insertions(+), 8 deletions(-) diff --git a/packages/shared/workspace-status.test.ts b/packages/shared/workspace-status.test.ts index 622150c849..eaf6f467ac 100644 --- a/packages/shared/workspace-status.test.ts +++ b/packages/shared/workspace-status.test.ts @@ -3,7 +3,7 @@ import { mkdirSync, mkdtempSync, realpathSync, rmSync, unlinkSync, writeFileSync import { tmpdir } from "node:os"; import { join } from "node:path"; import { spawnSync } from "node:child_process"; -import { getWorkspaceStatusForDirectory, getWorkspaceStatusRelativePaths } from "./workspace-status"; +import { getGitMetadataWatchPaths, getWorkspaceStatusForDirectory, getWorkspaceStatusRelativePaths } from "./workspace-status"; const tempDirs: string[] = []; @@ -99,4 +99,44 @@ describe("workspace status", () => { expect(status.totals.additions).toBe(2); expect(status.totals.deletions).toBe(1); }); + + test("resolves git metadata paths when watching a repository subdirectory", () => { + const repo = tempRepo(); + const subdir = join(repo, "docs", "sub"); + mkdirSync(subdir, { recursive: true }); + writeFileSync(join(subdir, "plan.md"), "# Plan\n"); + git(repo, "add", "-A"); + git(repo, "commit", "-m", "init"); + + const paths = getGitMetadataWatchPaths(subdir); + const realRepo = realpathSync(repo); + + expect(paths).toContain(join(realRepo, ".git", "refs")); + }); + + test("counts staged and unstaged changes when the net diff against HEAD is empty", () => { + const repo = tempRepo(); + const docs = join(repo, "docs"); + mkdirSync(docs); + writeFileSync(join(docs, "plan.md"), "one\ntwo\n"); + git(repo, "add", "-A"); + git(repo, "commit", "-m", "init"); + + writeFileSync(join(docs, "plan.md"), "ONE\ntwo\n"); + git(repo, "add", join("docs", "plan.md")); + writeFileSync(join(docs, "plan.md"), "one\ntwo\n"); + + const status = getWorkspaceStatusForDirectory(docs); + const realDocs = realpathSync(docs); + const change = status.files[join(realDocs, "plan.md")]; + + expect(status.available).toBe(true); + expect(change?.status).toBe("modified"); + expect(change?.staged).toBe(true); + expect(change?.unstaged).toBe(true); + expect(change?.additions).toBe(2); + expect(change?.deletions).toBe(2); + expect(status.totals.additions).toBe(2); + expect(status.totals.deletions).toBe(2); + }); }); diff --git a/packages/shared/workspace-status.ts b/packages/shared/workspace-status.ts index 49bf744a3c..09b5510ea6 100644 --- a/packages/shared/workspace-status.ts +++ b/packages/shared/workspace-status.ts @@ -57,8 +57,29 @@ function runGit(cwd: string, args: string[]): { ok: true; stdout: string } | { o return { ok: true, stdout: result.stdout ?? "" }; } -function resolveGitPath(repoRoot: string, value: string): string { - return isAbsolute(value) ? value : resolve(repoRoot, value); +function resolveGitPath(cwd: string, value: string): string { + return isAbsolute(value) ? value : resolve(cwd, value); +} + +function addLineCounts( + target: Map, + source: Map, +): void { + for (const [path, counts] of source) { + const existing = target.get(path) ?? { additions: 0, deletions: 0 }; + target.set(path, { + additions: existing.additions + counts.additions, + deletions: existing.deletions + counts.deletions, + }); + } +} + +function combinedLineCounts( + ...sources: Array> +): Map { + const combined = new Map(); + for (const source of sources) addLineCounts(combined, source); + return combined; } export function getGitRepositoryInfo(cwd: string): GitRepositoryInfo | null { @@ -66,6 +87,12 @@ export function getGitRepositoryInfo(cwd: string): GitRepositoryInfo | null { if (!topLevel.ok) return null; const rawRepoRoot = topLevel.stdout.trim(); if (!rawRepoRoot) return null; + let gitCwd: string; + try { + gitCwd = realpathSync(resolve(cwd)); + } catch { + return null; + } const repoRoot = realpathSync(rawRepoRoot); const gitDir = runGit(cwd, ["rev-parse", "--git-dir"]); @@ -73,11 +100,11 @@ export function getGitRepositoryInfo(cwd: string): GitRepositoryInfo | null { return { repoRoot, - gitDir: gitDir.ok && gitDir.stdout.trim() ? resolveGitPath(repoRoot, gitDir.stdout.trim()) : resolve(repoRoot, ".git"), + gitDir: gitDir.ok && gitDir.stdout.trim() ? resolveGitPath(gitCwd, gitDir.stdout.trim()) : resolve(repoRoot, ".git"), gitCommonDir: gitCommonDir.ok && gitCommonDir.stdout.trim() - ? resolveGitPath(repoRoot, gitCommonDir.stdout.trim()) + ? resolveGitPath(gitCwd, gitCommonDir.stdout.trim()) : gitDir.ok && gitDir.stdout.trim() - ? resolveGitPath(repoRoot, gitDir.stdout.trim()) + ? resolveGitPath(gitCwd, gitDir.stdout.trim()) : resolve(repoRoot, ".git"), }; } @@ -211,17 +238,28 @@ export function getWorkspaceStatusForDirectory(dirPath: string): WorkspaceStatus }; } + const entries = parsePorcelain(status.stdout); const numstat = runGit(repo.repoRoot, ["diff", "--numstat", "-z", "HEAD", "--", rootPathspec]); - const lineCounts = numstat.ok ? parseNumstat(numstat.stdout) : new Map(); + const headLineCounts = numstat.ok ? parseNumstat(numstat.stdout) : new Map(); + let splitLineCounts: Map | null = null; + if (entries.some((entry) => entry.staged && entry.unstaged)) { + const cached = runGit(repo.repoRoot, ["diff", "--cached", "--numstat", "-z", "--", rootPathspec]); + const unstaged = runGit(repo.repoRoot, ["diff", "--numstat", "-z", "--", rootPathspec]); + splitLineCounts = combinedLineCounts( + cached.ok ? parseNumstat(cached.stdout) : new Map(), + unstaged.ok ? parseNumstat(unstaged.stdout) : new Map(), + ); + } const files: Record = {}; let totalAdditions = 0; let totalDeletions = 0; - for (const entry of parsePorcelain(status.stdout)) { + for (const entry of entries) { const absolutePath = resolve(repo.repoRoot, entry.repoRelativePath); if (!isWithinPath(absolutePath, rootPath)) continue; + const lineCounts = entry.staged && entry.unstaged && splitLineCounts ? splitLineCounts : headLineCounts; const counts = lineCounts.get(entry.repoRelativePath) ?? { additions: 0, deletions: 0 }; const oldCounts = entry.oldRepoRelativePath ? lineCounts.get(entry.oldRepoRelativePath) ?? { additions: 0, deletions: 0 } From 06f3484a3715b82f859c5c96c2b920d910e781df Mon Sep 17 00:00:00 2001 From: Michael Ramos Date: Wed, 17 Jun 2026 13:39:49 -0700 Subject: [PATCH 8/9] fix(annotate): refresh file tree after reconnect --- packages/editor/App.tsx | 12 ++-- packages/review-editor/index.css | 51 +--------------- packages/ui/hooks/useFileBrowser.test.tsx | 73 ++++++++++++++++++++++- packages/ui/hooks/useFileBrowser.ts | 29 +++++++-- 4 files changed, 99 insertions(+), 66 deletions(-) diff --git a/packages/editor/App.tsx b/packages/editor/App.tsx index 4560095b4c..5c1a12a22d 100644 --- a/packages/editor/App.tsx +++ b/packages/editor/App.tsx @@ -3570,14 +3570,10 @@ const App: React.FC = () => { }} onShare={canShareCurrentSession ? () => { setIsPanelOpen(false); setInitialExportTab('share'); setShowExport(true); } : undefined} otherFileAnnotations={otherFileAnnotations} - directEdits={directEditsPanelInfo?.map((item) => { - const sourceKey = 'sourceKey' in item ? item.sourceKey : undefined; - const canDiscard = item.id === 'plan' || typeof sourceKey === 'string'; - return { - ...item, - onDiscard: canDiscard ? () => handleDiscardEdits(sourceKey) : undefined, - }; - }) ?? null} + directEdits={directEditsPanelInfo?.map((item) => ({ + ...item, + onDiscard: item.id === 'plan' ? () => handleDiscardEdits() : undefined, + })) ?? null} onOtherFileAnnotationsClick={handleFlashAnnotatedFiles} /> {isPanelOpen && rightSidebarTab === 'ai' && wideModeType === null && !goalSetupMode && canUseAI && ( diff --git a/packages/review-editor/index.css b/packages/review-editor/index.css index 7ad22e4d5b..392e993bec 100644 --- a/packages/review-editor/index.css +++ b/packages/review-editor/index.css @@ -228,60 +228,11 @@ diffs-container { background: oklch(from var(--primary) l c h / 0.3) !important; } -/* File tree styling */ -.file-tree-item { - display: flex; - align-items: center; - gap: 0.5rem; - padding: 0.25rem 0.5rem; - border-radius: var(--radius-sm); - font-size: 0.75rem; - cursor: pointer; - color: var(--muted-foreground); -} - -.file-tree-item:hover { - background: var(--muted); - color: var(--foreground); -} - -.file-tree-item.active { - background: oklch(from var(--primary) l c h / 0.3); - color: var(--primary); -} - +/* Review-specific file tree states; shared base styles live in theme.css. */ .file-tree-item.scroll-active { background: oklch(from var(--foreground) l c h / 0.06); } -.file-tree-item.has-annotations { - background: oklch(from var(--primary) l c h / 0.08); -} - -.file-tree-item.has-annotations:hover { - background: var(--muted); -} - -.file-tree-item.active.has-annotations { - background: oklch(from var(--primary) l c h / 0.3); -} - -.file-tree-item .additions { - color: var(--success); -} - -.file-tree-item .deletions { - color: var(--destructive); -} - -.file-tree-item.active .additions { - color: var(--success); -} - -.file-tree-item.active .deletions { - color: var(--destructive); -} - .file-tree-item.staged { background: oklch(from var(--success) l c h / 0.1); } diff --git a/packages/ui/hooks/useFileBrowser.test.tsx b/packages/ui/hooks/useFileBrowser.test.tsx index cdb7bddd29..c5b5a5a046 100644 --- a/packages/ui/hooks/useFileBrowser.test.tsx +++ b/packages/ui/hooks/useFileBrowser.test.tsx @@ -8,6 +8,25 @@ import type { VaultNode } from "../types"; const hasDom = typeof document !== "undefined"; const realFetch = globalThis.fetch; +const realEventSource = globalThis.EventSource; + +class MockEventSource { + static instances: MockEventSource[] = []; + onmessage: ((event: MessageEvent) => void) | null = null; + closed = false; + + constructor(public readonly url: string) { + MockEventSource.instances.push(this); + } + + emit(data: unknown): void { + this.onmessage?.({ data: JSON.stringify(data) } as MessageEvent); + } + + close(): void { + this.closed = true; + } +} function response(body: unknown, status = 200): Response { return new Response(JSON.stringify(body), { @@ -16,9 +35,19 @@ function response(body: unknown, status = 200): Response { }); } -function installFetchResponses(responses: Response[]): void { +function installFetchResponses(responses: Response[]): string[] { + const calls: string[] = []; const nextFetch = async () => responses.shift() ?? response({ error: "unexpected fetch" }, 500); - globalThis.fetch = nextFetch as unknown as typeof fetch; + globalThis.fetch = (async (input: RequestInfo | URL) => { + calls.push(String(input)); + return nextFetch(); + }) as unknown as typeof fetch; + return calls; +} + +function installMockEventSource(): void { + MockEventSource.instances = []; + globalThis.EventSource = MockEventSource as unknown as typeof EventSource; } function Harness({ resultRef }: { resultRef: { current: UseFileBrowserReturn | null } }) { @@ -59,8 +88,13 @@ async function fetchTree( }); } +const tick = (ms: number) => act(async () => new Promise((resolve) => setTimeout(resolve, ms))); + afterEach(() => { globalThis.fetch = realFetch; + if (realEventSource) globalThis.EventSource = realEventSource; + else delete (globalThis as Record).EventSource; + MockEventSource.instances = []; if (hasDom) document.body.innerHTML = ""; }); @@ -117,4 +151,39 @@ describe("useFileBrowser", () => { await session.unmount(); }); + + test.skipIf(!hasDom)("refreshes after an SSE ready event from reconnect", async () => { + installMockEventSource(); + const dirPath = "/tmp/plannotator-docs"; + const initialTree: VaultNode[] = [{ type: "file", name: "a.md", path: "a.md" }]; + const reconnectedTree: VaultNode[] = [ + { type: "file", name: "a.md", path: "a.md" }, + { type: "file", name: "b.md", path: "b.md" }, + ]; + const calls = installFetchResponses([ + response({ tree: initialTree }), + response({ tree: reconnectedTree }), + ]); + + const session = await mountHook(); + await fetchTree(session.result.current!, dirPath); + await tick(0); + + const source = MockEventSource.instances[0]; + expect(source?.url).toContain("/api/reference/files/stream?"); + expect(session.result.current!.dirs[0]?.tree).toEqual(initialTree); + expect(calls).toHaveLength(1); + + source!.emit({ type: "ready", dirPath }); + await tick(150); + expect(calls).toHaveLength(1); + expect(session.result.current!.dirs[0]?.tree).toEqual(initialTree); + + source!.emit({ type: "ready", dirPath }); + await tick(150); + expect(calls).toHaveLength(2); + expect(session.result.current!.dirs[0]?.tree).toEqual(reconnectedTree); + + await session.unmount(); + }); }); diff --git a/packages/ui/hooks/useFileBrowser.ts b/packages/ui/hooks/useFileBrowser.ts index 481dbfd04d..ae19ff52ca 100644 --- a/packages/ui/hooks/useFileBrowser.ts +++ b/packages/ui/hooks/useFileBrowser.ts @@ -18,7 +18,6 @@ export interface DirState { isLoading: boolean; error: string | null; workspaceStatus?: WorkspaceStatusPayload; - lastChangedAt?: number; /** When true, fetches via /api/reference/obsidian/files and opens docs via /api/reference/obsidian/doc */ isVault?: boolean; } @@ -146,7 +145,6 @@ export function useFileBrowser(): UseFileBrowserReturn { ...d, tree: data.tree, workspaceStatus, - lastChangedAt: Date.now(), isLoading: false, error: null, } @@ -276,6 +274,7 @@ export function useFileBrowser(): UseFileBrowserReturn { const paths = watchDirsKey.split("\n").filter(Boolean); const timers = new Map>(); + const readyPaths = new Set(); const params = new URLSearchParams(); for (const path of paths) params.append("dirPath", path); const source = new EventSource(`/api/reference/files/stream?${params.toString()}`); @@ -287,18 +286,36 @@ export function useFileBrowser(): UseFileBrowserReturn { fetchTreeRef.current(path, { quiet: true }); }, 120)); }; + const scheduleEventFetch = (dirPath: unknown) => { + if (typeof dirPath === "string" && paths.includes(dirPath)) { + scheduleFetch(dirPath); + return; + } + for (const path of paths) scheduleFetch(path); + }; + const hasSeenReady = (dirPath: unknown): boolean => { + if (typeof dirPath === "string" && paths.includes(dirPath)) { + if (readyPaths.has(dirPath)) return true; + readyPaths.add(dirPath); + return false; + } + + const hadAll = paths.every((path) => readyPaths.has(path)); + for (const path of paths) readyPaths.add(path); + return hadAll; + }; source.onmessage = (event) => { try { const data = JSON.parse(event.data) as { type?: string; dirPath?: string }; - if (data.type !== "changed") return; - if (typeof data.dirPath === "string" && paths.includes(data.dirPath)) { - scheduleFetch(data.dirPath); + if (data.type === "ready") { + if (hasSeenReady(data.dirPath)) scheduleEventFetch(data.dirPath); return; } + if (data.type !== "changed") return; + scheduleEventFetch(data.dirPath); } catch { return; } - for (const path of paths) scheduleFetch(path); }; return () => { From 5a8269c1bf5feb18e09692e9f87f05a253d743a8 Mon Sep 17 00:00:00 2001 From: Michael Ramos Date: Wed, 17 Jun 2026 14:18:06 -0700 Subject: [PATCH 9/9] fix(pi): expose file browser stream route --- apps/pi-extension/server/serverAnnotate.ts | 3 ++- apps/pi-extension/server/serverPlan.ts | 3 ++- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/apps/pi-extension/server/serverAnnotate.ts b/apps/pi-extension/server/serverAnnotate.ts index 67c8e92d4d..e640f2b30e 100644 --- a/apps/pi-extension/server/serverAnnotate.ts +++ b/apps/pi-extension/server/serverAnnotate.ts @@ -432,7 +432,8 @@ export async function startAnnotateServer(options: { handleObsidianDocRequest(res, url); } else if (url.pathname === "/api/reference/files" && req.method === "GET") { handleFileBrowserRequest(res, url); - } else if (handleFileBrowserStreamRequest(req, res, url)) { + } else if (url.pathname === "/api/reference/files/stream" && req.method === "GET") { + handleFileBrowserStreamRequest(req, res, url); return; } else if (url.pathname === "/favicon.svg") { handleFavicon(res); diff --git a/apps/pi-extension/server/serverPlan.ts b/apps/pi-extension/server/serverPlan.ts index 6f33940ff4..048aabab19 100644 --- a/apps/pi-extension/server/serverPlan.ts +++ b/apps/pi-extension/server/serverPlan.ts @@ -285,7 +285,8 @@ export async function startPlanReviewServer(options: { handleObsidianDocRequest(res, url); } else if (url.pathname === "/api/reference/files" && req.method === "GET") { handleFileBrowserRequest(res, url); - } else if (handleFileBrowserStreamRequest(req, res, url)) { + } else if (url.pathname === "/api/reference/files/stream" && req.method === "GET") { + handleFileBrowserStreamRequest(req, res, url); return; } else if ( url.pathname === "/api/plan/vscode-diff" &&