From afac44b9403c1bf0d84fbac03454eb1fc46a6421 Mon Sep 17 00:00:00 2001 From: Michael Ramos Date: Wed, 10 Jun 2026 21:07:58 -0700 Subject: [PATCH] feat(review): migrate all-files code review to Pierre CodeView (#885) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replaces the all-files renderer with Pierre's virtualized CodeView (one scroller, identical look, scales to huge diffs); deletes the legacy AllFilesDiffView/LazyFileDiff and their flag; single-file panels stay on FileDiff. @pierre/diffs pinned exact 1.2.8 (1.2.9's tree is broken on npm — see #880). Also ships: diff staleness detection ('Diff out of date · Refresh' toolbar notice backed by shared per-VCS fingerprints + GET /api/diff/fresh on both Bun and Pi servers), a stale-content guard so files edited mid-review degrade to raw-patch view instead of breaking virtualization, the Ask AI wrong-file fix (pre-existing), and toolbar/header UI polish (global settings cog, aligned sem badges, no all-files split dragger). Hardened through five review waves; tested with 1384 passing tests incl. real-git fingerprint and patch-consistency suites. Known minor gaps documented in the PR. --- AGENTS.md | 1 + apps/pi-extension/package.json | 2 +- apps/pi-extension/plannotator-browser.ts | 2 + apps/pi-extension/server.ts | 1 + apps/pi-extension/server/serverReview.ts | 74 + apps/pi-extension/server/vcs.ts | 1 + apps/review/package.json | 2 +- bun.lock | 22 +- package.json | 2 +- packages/review-editor/App.tsx | 150 +- .../components/AllFilesCodeView.tsx | 1791 +++++++++++++++++ .../components/AllFilesDiffView.tsx | 555 ----- .../components/DiffOptionsPopover.tsx | 4 +- .../review-editor/components/DiffViewer.tsx | 11 + .../review-editor/components/FileHeader.tsx | 15 +- .../review-editor/components/FileTree.tsx | 18 +- .../review-editor/components/LazyFileDiff.tsx | 118 -- .../components/SemanticFileBadge.tsx | 17 +- .../review-editor/dock/ReviewStateContext.tsx | 10 + .../dock/panels/ReviewAllFilesDiffPanel.tsx | 37 +- .../review-editor/hooks/useDiffFreshness.ts | 89 + packages/review-editor/index.css | 21 +- packages/review-editor/package.json | 2 +- .../utils/patchConsistency.test.ts | 58 + .../review-editor/utils/patchConsistency.ts | 52 + .../utils/reviewSearchHighlight.ts | 72 +- packages/server/package.json | 2 +- packages/server/review-workspace.ts | 2 + packages/server/review.ts | 71 +- packages/server/vcs.ts | 1 + packages/shared/diff-fingerprint.test.ts | 130 ++ packages/shared/jj-core.ts | 56 + packages/shared/pr-stack.ts | 32 + packages/shared/review-core.ts | 120 ++ packages/shared/review-workspace.ts | 30 + packages/shared/vcs-core.ts | 43 + packages/ui/package.json | 2 +- .../code-review/allFilesDiff.shortcuts.ts | 8 +- 38 files changed, 2871 insertions(+), 753 deletions(-) create mode 100644 packages/review-editor/components/AllFilesCodeView.tsx delete mode 100644 packages/review-editor/components/AllFilesDiffView.tsx delete mode 100644 packages/review-editor/components/LazyFileDiff.tsx create mode 100644 packages/review-editor/hooks/useDiffFreshness.ts create mode 100644 packages/review-editor/utils/patchConsistency.test.ts create mode 100644 packages/review-editor/utils/patchConsistency.ts create mode 100644 packages/shared/diff-fingerprint.test.ts diff --git a/AGENTS.md b/AGENTS.md index 98e887a9..b8e8f847 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -287,6 +287,7 @@ During normal plan review, an Archive sidebar tab provides the same browsing via | --------------------- | ------ | ------------------------------------------ | | `/api/diff` | GET | Returns `{ rawPatch, gitRef, origin, mode?, diffType, base, hideWhitespace, gitContext, agentCwd?, semanticDiff? }`. Workspace mode returns `mode: "workspace"` with folder-prefixed paths and no `gitContext`. | | `/api/diff/switch` | POST | Switch diff type, base branch, or whitespace mode (body: `{ diffType, base?, hideWhitespace? }`). Response includes `semanticDiff?`. | +| `/api/diff/fresh` | GET | Cheap staleness probe: recomputes the VCS fingerprint captured with the current diff snapshot and returns `{ fresh, fingerprint? }`. Unfingerprintable modes (e.g. P4) always report fresh. Polled by the UI's "Diff out of date · Refresh" notice. | | `/api/semantic-diff` | GET | Runs semantic diff for the active patch and returns parsed sem output or an unavailable/error response (`?fileExt=` / `?fileExts=` optional). | | `/api/file-content` | GET | Returns `{ oldContent, newContent }` for expandable diff context (`?path=&oldPath=&base=`) | | `/api/git-add` | POST | Stage/unstage a file (body: `{ filePath, undo? }`) | diff --git a/apps/pi-extension/package.json b/apps/pi-extension/package.json index 231f71dd..61c1835f 100644 --- a/apps/pi-extension/package.json +++ b/apps/pi-extension/package.json @@ -40,7 +40,7 @@ }, "dependencies": { "@joplin/turndown-plugin-gfm": "^1.0.64", - "@pierre/diffs": "1.1.20", + "@pierre/diffs": "1.2.8", "turndown": "^7.2.4" }, "peerDependencies": { diff --git a/apps/pi-extension/plannotator-browser.ts b/apps/pi-extension/plannotator-browser.ts index 6309afb5..4c322da9 100644 --- a/apps/pi-extension/plannotator-browser.ts +++ b/apps/pi-extension/plannotator-browser.ts @@ -10,6 +10,7 @@ import { reviewRuntime, detectManagedVcs, getVcsContext, + getVcsDiffFingerprint, getVcsFileContentsForDiff, canStageFiles, runVcsDiff, @@ -108,6 +109,7 @@ async function buildLocalWorkspaceReview( getVcsContext, runVcsDiff, getVcsFileContentsForDiff, + getVcsDiffFingerprint, canStageFiles, stageFile, unstageFile, diff --git a/apps/pi-extension/server.ts b/apps/pi-extension/server.ts index b1ad5952..84eae8bf 100644 --- a/apps/pi-extension/server.ts +++ b/apps/pi-extension/server.ts @@ -32,6 +32,7 @@ export { detectVcs, getGitContext, getVcsContext, + getVcsDiffFingerprint, getVcsFileContentsForDiff, prepareLocalReviewDiff, resolveInitialDiffType, diff --git a/apps/pi-extension/server/serverReview.ts b/apps/pi-extension/server/serverReview.ts index c4f1e1b4..ef73e835 100644 --- a/apps/pi-extension/server/serverReview.ts +++ b/apps/pi-extension/server/serverReview.ts @@ -33,6 +33,7 @@ import { import { checkoutPRHead, getPRDiffScopeOptions, + getPRFullStackFingerprint, getPRStackInfo, resolveStackInfo, resolvePRFullStackBaseRef, @@ -109,6 +110,7 @@ import { canStageFiles, detectRemoteDefaultCompareTarget, getVcsContext, + getVcsDiffFingerprint, getVcsFileContentsForDiff, resolveVcsCwd, reviewRuntime, @@ -288,6 +290,52 @@ export async function startReviewServer(options: { let currentBase = options.initialBase || detectedCompareTarget(); let baseEverSwitched = false; + // --- Diff staleness fingerprint (mirrors packages/server/review.ts) ------- + // Captured beside every patch snapshot; GET /api/diff/fresh recomputes and + // compares so the client can show a "diff out of date — refresh" notice when + // files change mid-review. Best-effort: null = "cannot fingerprint" and is + // reported fresh, never stale. + let currentFingerprint: string | null = null; + const computeDiffFingerprint = async (): Promise => { + try { + if (workspace) return await workspace.getFingerprint(); + if (isPRMode) { + if (currentPRDiffScope === "layer") { + // Platform-computed diff — immutable locally; recaptured on pr-switch. + return `pr-layer:${prMeta?.url ?? ""}`; + } + // Full-stack: three-dot diff against the local checkout — fingerprint + // (merge-base, HEAD), which changes exactly when the patch can. + const fullStackCwd = + (options.worktreePool && prMeta ? options.worktreePool.resolve(prMeta.url) : undefined) ?? + options.agentCwd; + if (!prMeta) return null; + return await getPRFullStackFingerprint(reviewRuntime, prMeta, fullStackCwd); + } + if (!hasLocalAccess) return null; + return await getVcsDiffFingerprint( + currentDiffType as DiffType, + currentBase, + options.gitContext?.cwd, + { hideWhitespace: currentHideWhitespace }, + ); + } catch { + return null; + } + }; + // Fire-and-forget capture: never delays the snapshot response it describes. + // Generation-guarded: two rapid switches can resolve their captures out of + // order — only the LATEST capture may write the baseline, otherwise a stale + // fingerprint would make /api/diff/fresh report stale forever. + let fingerprintGeneration = 0; + const captureDiffFingerprint = (): void => { + const generation = ++fingerprintGeneration; + void computeDiffFingerprint().then((fingerprint) => { + if (generation === fingerprintGeneration) currentFingerprint = fingerprint; + }); + }; + captureDiffFingerprint(); + // Fire-and-forget: query the remote for its actual default branch. if (options.gitContext && !options.initialBase && !isPRMode) { detectRemoteDefaultCompareTarget(options.gitContext.cwd, sessionVcsType).then((remote) => { @@ -619,6 +667,27 @@ export async function startReviewServer(options: { semanticDiff: await getSemanticDiffAdvert(), serverConfig: getServerConfig(gitUser), }); + } else if (url.pathname === "/api/diff/fresh" && req.method === "GET") { + // Cheap staleness probe — has the underlying VCS state changed since + // the current diff snapshot was computed? Best-effort: anything that + // cannot be fingerprinted reports fresh (no banner). + const baseline = currentFingerprint; + if (baseline == null) { + json(res, { fresh: true }); + return; + } + const probe = await computeDiffFingerprint(); + // A diff switch landing mid-probe replaces the snapshot (and its + // fingerprint); report fresh and let the next poll compare against + // the new baseline. + if (currentFingerprint !== baseline) { + json(res, { fresh: true }); + return; + } + const fresh = probe == null || probe === baseline; + // The probe fingerprint lets the client distinguish "still the same + // staleness I dismissed" from "ANOTHER change landed since". + json(res, { fresh, ...(fresh ? {} : { fingerprint: probe }) }); } else if (url.pathname === "/api/semantic-diff" && req.method === "GET") { json(res, await getSemanticDiff(url)); } else if (url.pathname === "/api/diff/switch" && req.method === "POST") { @@ -646,6 +715,7 @@ export async function startReviewServer(options: { currentDiffType = workspace.diffType; currentError = snapshot.error; draftKey = contentHash(currentPatch); + captureDiffFingerprint(); json(res, { rawPatch: currentPatch, @@ -673,6 +743,7 @@ export async function startReviewServer(options: { currentBase = base; baseEverSwitched = true; currentError = result.error; + captureDiffFingerprint(); // Recompute gitContext for the effective cwd so the client's // sidebar reflects the worktree we're now reviewing. @@ -722,6 +793,7 @@ export async function startReviewServer(options: { currentGitRef = originalPRGitRef; currentError = originalPRError; currentPRDiffScope = "layer"; + captureDiffFingerprint(); json(res, { rawPatch: currentPatch, gitRef: currentGitRef, @@ -750,6 +822,7 @@ export async function startReviewServer(options: { currentGitRef = result.label; currentError = undefined; currentPRDiffScope = "full-stack"; + captureDiffFingerprint(); json(res, { rawPatch: currentPatch, gitRef: currentGitRef, @@ -785,6 +858,7 @@ export async function startReviewServer(options: { currentPRDiffScope = "layer"; draftKey = contentHash(pr.rawPatch); prListCache = null; + captureDiffFingerprint(); prStackInfo = getPRStackInfo(pr.metadata); if (prStackTreeCache.has(body.url)) { diff --git a/apps/pi-extension/server/vcs.ts b/apps/pi-extension/server/vcs.ts index f72e1da5..67920815 100644 --- a/apps/pi-extension/server/vcs.ts +++ b/apps/pi-extension/server/vcs.ts @@ -98,6 +98,7 @@ export const { prepareLocalReviewDiff, runVcsDiff, getVcsFileContentsForDiff, + getVcsDiffFingerprint, canStageFiles, stageFile, unstageFile, diff --git a/apps/review/package.json b/apps/review/package.json index 06669dfc..38e81a0c 100644 --- a/apps/review/package.json +++ b/apps/review/package.json @@ -12,7 +12,7 @@ "@plannotator/review-editor": "workspace:*", "@plannotator/server": "workspace:*", "@plannotator/ui": "workspace:*", - "@pierre/diffs": "1.1.20", + "@pierre/diffs": "1.2.8", "dompurify": "^3.3.3", "react": "^19.2.3", "react-dom": "^19.2.3", diff --git a/bun.lock b/bun.lock index b7d719e5..329193db 100644 --- a/bun.lock +++ b/bun.lock @@ -8,7 +8,7 @@ "@anthropic-ai/claude-agent-sdk": "^0.2.92", "@openai/codex-sdk": "^0.118.0", "@opencode-ai/sdk": "^1.3.0", - "@pierre/diffs": "1.1.20", + "@pierre/diffs": "1.2.8", "diff": "^8.0.4", "dockview-react": "^5.2.0", "dompurify": "^3.3.3", @@ -64,7 +64,7 @@ }, "apps/opencode-plugin": { "name": "@plannotator/opencode", - "version": "0.20.0", + "version": "0.20.1", "dependencies": { "@opencode-ai/plugin": "^1.1.10", }, @@ -86,10 +86,10 @@ }, "apps/pi-extension": { "name": "@plannotator/pi-extension", - "version": "0.20.0", + "version": "0.20.1", "dependencies": { "@joplin/turndown-plugin-gfm": "^1.0.64", - "@pierre/diffs": "1.1.20", + "@pierre/diffs": "1.2.8", "turndown": "^7.2.4", }, "devDependencies": { @@ -125,7 +125,7 @@ "name": "@plannotator/review", "version": "0.0.1", "dependencies": { - "@pierre/diffs": "1.1.20", + "@pierre/diffs": "1.2.8", "@plannotator/review-editor": "workspace:*", "@plannotator/server": "workspace:*", "@plannotator/ui": "workspace:*", @@ -188,7 +188,7 @@ "dependencies": { "@fontsource-variable/geist-mono": "5.2.7", "@fontsource-variable/inter": "^5.2.8", - "@pierre/diffs": "1.1.20", + "@pierre/diffs": "1.2.8", "@plannotator/shared": "workspace:*", "@plannotator/ui": "workspace:*", "@radix-ui/react-checkbox": "^1.3.3", @@ -209,9 +209,9 @@ }, "packages/server": { "name": "@plannotator/server", - "version": "0.20.0", + "version": "0.20.1", "dependencies": { - "@pierre/diffs": "1.1.20", + "@pierre/diffs": "1.2.8", "@plannotator/ai": "workspace:*", "@plannotator/shared": "workspace:*", }, @@ -236,7 +236,7 @@ "dependencies": { "@fontsource-variable/geist-mono": "5.2.7", "@fontsource-variable/inter": "^5.2.8", - "@pierre/diffs": "1.1.20", + "@pierre/diffs": "1.2.8", "@plannotator/ai": "workspace:*", "@plannotator/shared": "workspace:*", "@plannotator/web-highlighter": "^0.8.1", @@ -705,9 +705,9 @@ "@oven/bun-windows-x64-baseline": ["@oven/bun-windows-x64-baseline@1.3.5", "", { "os": "win32", "cpu": "x64" }, "sha512-rtVQB9/1XK8FWJgFtsOthbPifRMYypgJwxu+pK3NHx8WvFKmq7HcPDqNr8xLzGULjQEO7eAo2aOZfONOwYz+5g=="], - "@pierre/diffs": ["@pierre/diffs@1.1.20", "", { "dependencies": { "@pierre/theme": "0.0.28", "@shikijs/transformers": "^3.0.0", "diff": "8.0.3", "hast-util-to-html": "9.0.5", "lru_map": "0.4.1", "shiki": "^3.0.0" }, "peerDependencies": { "react": "^18.3.1 || ^19.0.0", "react-dom": "^18.3.1 || ^19.0.0" } }, "sha512-lLi+3sLCm3QDd5/aLO9pw+WbF6UzhrkWm2oTZ5WZJTGemOyUNRJ4DDhcEKmVusu4C4bXx9Nssh6fF+wQcapb5w=="], + "@pierre/diffs": ["@pierre/diffs@1.2.8", "", { "dependencies": { "@pierre/theme": "1.0.3", "@shikijs/transformers": "^3.0.0", "diff": "8.0.3", "hast-util-to-html": "9.0.5", "lru_map": "0.4.1", "shiki": "^3.0.0" }, "peerDependencies": { "react": "^18.3.1 || ^19.0.0", "react-dom": "^18.3.1 || ^19.0.0" } }, "sha512-HVaWzZ1cW5GDKodivaPCN1hU05DGvoLiG/uvVBNZODD+qY2NTr36KYgATGhb79Vr8OCKNG3qATTWJEwbkMGfzA=="], - "@pierre/theme": ["@pierre/theme@0.0.28", "", {}, "sha512-1j/H/fECBuc9dEvntdWI+l435HZapw+RCJTlqCA6BboQ5TjlnE005j/ROWutXIs8aq5OAc82JI2Kwk4A1WWBgw=="], + "@pierre/theme": ["@pierre/theme@1.0.3", "", {}, "sha512-sWHv11TMoqKxKDgTIk5VbhQjdPhs8DCcBxbjh3mRlS3YOM/OcrWoGX6MM8eBGn9cUu3M46Py0JnxsG2nJaFTuA=="], "@pkgjs/parseargs": ["@pkgjs/parseargs@0.11.0", "", {}, "sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg=="], diff --git a/package.json b/package.json index b19e7958..4dd4b5e1 100644 --- a/package.json +++ b/package.json @@ -39,7 +39,7 @@ "@anthropic-ai/claude-agent-sdk": "^0.2.92", "@openai/codex-sdk": "^0.118.0", "@opencode-ai/sdk": "^1.3.0", - "@pierre/diffs": "1.1.20", + "@pierre/diffs": "1.2.8", "diff": "^8.0.4", "dockview-react": "^5.2.0", "dompurify": "^3.3.3", diff --git a/packages/review-editor/App.tsx b/packages/review-editor/App.tsx index b0f77161..ccd7cf03 100644 --- a/packages/review-editor/App.tsx +++ b/packages/review-editor/App.tsx @@ -6,6 +6,7 @@ import { ConfirmDialog } from '@plannotator/ui/components/ConfirmDialog'; import { Settings } from '@plannotator/ui/components/Settings'; import { FeedbackButton, ApproveButton, ExitButton } from '@plannotator/ui/components/ToolbarButtons'; import { AgentReviewActions } from './components/AgentReviewActions'; +import { DiffOptionsPopover } from './components/DiffOptionsPopover'; import { useUpdateCheck } from '@plannotator/ui/hooks/useUpdateCheck'; import { storage } from '@plannotator/ui/utils/storage'; import { CompletionOverlay } from '@plannotator/ui/components/CompletionOverlay'; @@ -59,6 +60,7 @@ import { StackedPRLabel } from './components/StackedPRLabel'; import { PRSelector } from './components/PRSelector'; import { PRSwitchOverlay } from './components/PRSwitchOverlay'; import { usePRStack } from './hooks/usePRStack'; +import { useDiffFreshness } from './hooks/useDiffFreshness'; import { usePRSession, type PRSessionUpdate } from './hooks/usePRSession'; import { useAnnotationFactory } from './hooks/useAnnotationFactory'; import { DEMO_DIFF } from './demoData'; @@ -120,6 +122,11 @@ const ReviewApp: React.FC = () => { const [annotations, setAnnotations] = useState([]); const [selectedAnnotationId, setSelectedAnnotationId] = useState(null); const [isAllFilesActive, setIsAllFilesActive] = useState(false); + // Mirror ref: handlers captured by Pierre slot portals (which only republish + // on item version bumps) and early-declared callbacks read the CURRENT value + // at call time instead of a stale closure capture. + const isAllFilesActiveRef = useRef(isAllFilesActive); + isAllFilesActiveRef.current = isAllFilesActive; const [isSemanticDiffActive, setIsSemanticDiffActive] = useState(false); const [semanticDiffAvailable, setSemanticDiffAvailable] = useState(false); const [isDiffPanelActive, setIsDiffPanelActive] = useState(false); @@ -339,8 +346,18 @@ const ReviewApp: React.FC = () => { }, [dockApi, files]); const handleRevealSearchMatch = useCallback((match: ReviewSearchMatch) => { + // Respect the surface the user is in. When the all-files panel is active, + // reveal IN PLACE — AllFilesCodeView scrolls to + highlights the active + // match via the activeSearchMatch prop. When a single-file panel is active + // (or there's no dock yet), open the match's file there instead (legacy + // behavior). Unconditionally activating the all-files panel here would + // teleport the user out of their tab just for typing a query (reveal also + // fires on first-match auto-activation, not only on explicit clicks). + if (dockApi && isAllFilesActiveRef.current) { + return; + } openDiffFile(match.filePath); - }, [openDiffFile]); + }, [dockApi, openDiffFile]); const { searchQuery, @@ -535,22 +552,34 @@ const ReviewApp: React.FC = () => { resetAISession(); }, [aiProviders, origin, resetAISession]); - const handleAskAI = useCallback((question: string) => { - if (!pendingSelection || !files[activeFileIndex]) return; + // File-aware Ask AI: the all-files surface resolves the owning file itself + // (its toolbar selection lives in a file the single-file panel may never + // have focused), so it must NOT go through activeFileIndex. + const handleAskAIForFile = useCallback((filePath: string, question: string) => { + if (!pendingSelection) return; + const file = files.find(f => f.path === filePath); + if (!file) return; const lineStart = Math.min(pendingSelection.start, pendingSelection.end); const lineEnd = Math.max(pendingSelection.start, pendingSelection.end); const side = pendingSelection.side === 'additions' ? 'new' : 'old'; - const selectedCode = extractLinesFromPatch(files[activeFileIndex].patch, lineStart, lineEnd, side); + const selectedCode = extractLinesFromPatch(file.patch, lineStart, lineEnd, side); askAI({ prompt: question, - filePath: files[activeFileIndex].path, + filePath, lineStart, lineEnd, side, selectedCode: selectedCode || undefined, }); - }, [activeFileIndex, askAI, files, pendingSelection]); + }, [askAI, files, pendingSelection]); + + // Single-file surface: the focused file IS files[activeFileIndex]. + const handleAskAI = useCallback((question: string) => { + const file = files[activeFileIndex]; + if (!file) return; + handleAskAIForFile(file.path, question); + }, [activeFileIndex, files, handleAskAIForFile]); const handleViewAIResponse = useCallback((questionId?: string) => { reviewSidebar.open('ai'); @@ -571,10 +600,11 @@ const ReviewApp: React.FC = () => { }, [openDiffFile]); - // AI messages overlapping the current selection (for toolbar history) - const aiHistoryForSelection = useMemo(() => { - if (!pendingSelection || !files[activeFileIndex]) return []; - const filePath = files[activeFileIndex].path; + // AI messages overlapping the current selection in a GIVEN file (toolbar + // history). File-aware so the all-files surface can ask for its own active + // file instead of inheriting the single-file panel's focus. + const getAIHistoryForFile = useCallback((filePath: string) => { + if (!pendingSelection) return []; const selStart = Math.min(pendingSelection.start, pendingSelection.end); const selEnd = Math.max(pendingSelection.start, pendingSelection.end); const side = pendingSelection.side === 'additions' ? 'new' : 'old'; @@ -584,7 +614,13 @@ const ReviewApp: React.FC = () => { q.lineStart != null && q.lineEnd != null && q.lineStart <= selEnd && q.lineEnd >= selStart; }); - }, [pendingSelection, files, activeFileIndex, aiMessages]); + }, [pendingSelection, aiMessages]); + + // Single-file surface variant (focused file = files[activeFileIndex]). + const aiHistoryForSelection = useMemo(() => { + const file = files[activeFileIndex]; + return file ? getAIHistoryForFile(file.path) : []; + }, [files, activeFileIndex, getAIHistoryForFile]); // Click AI marker in diff → scroll sidebar to that Q&A const [scrollToQuestionId, setScrollToQuestionId] = useState(null); @@ -1095,18 +1131,21 @@ const ReviewApp: React.FC = () => { )); }, [updateExternalAnnotation, externalAnnotations]); + // selectedAnnotationId is cleared via a functional update (not a captured + // value): this handler is captured by Pierre slot portals (inline annotation + // delete buttons) that only republish on item version bumps — a closure over + // the state value goes stale and would leave a dangling selection id after + // deleting the currently-selected annotation. const handleDeleteAnnotation = useCallback((id: string) => { const ann = allAnnotationsRef.current.find(a => a.id === id); if (ann?.source && externalAnnotations.some(e => e.id === id)) { deleteExternalAnnotation(id); - if (selectedAnnotationId === id) setSelectedAnnotationId(null); + setSelectedAnnotationId(prev => (prev === id ? null : prev)); return; } setAnnotations(prev => prev.filter(a => a.id !== id)); - if (selectedAnnotationId === id) { - setSelectedAnnotationId(null); - } - }, [selectedAnnotationId, deleteExternalAnnotation, externalAnnotations]); + setSelectedAnnotationId(prev => (prev === id ? null : prev)); + }, [deleteExternalAnnotation, externalAnnotations]); // Handle identity change - update author on existing annotations const handleIdentityChange = useCallback((oldIdentity: string, newIdentity: string) => { @@ -1300,6 +1339,10 @@ const ReviewApp: React.FC = () => { setActiveFileIndex(0); openDiffFile(nextFiles[0].path); } + // Line numbers can shift when whitespace handling changes, so a + // selection anchored to the old patch is stale — clear it (the + // non-preserve branch below already does). + setPendingSelection(null); } else { dockApi?.getPanel(REVIEW_DIFF_PANEL_ID)?.api.close(); needsInitialDiffPanel.current = true; @@ -1417,7 +1460,34 @@ const ReviewApp: React.FC = () => { fetchDiffSwitch(diffType, selectedBase, { preserveFile: true }); }, [diffHideWhitespace, origin, reviewMode]); // eslint-disable-line react-hooks/exhaustive-deps - // Select annotation - switches file if needed and scrolls to it + // --- Diff staleness --------------------------------------------------------- + // Files changing mid-review (an agent editing/committing while the user + // reviews) make the snapshot on screen stale. The hook polls the server's + // cheap fingerprint check; the toolbar shows a non-blocking notice and the + // user refreshes when THEY are ready — never automatically (annotations are + // line-anchored; rug-pulling the diff under them is worse than staleness). + const diffFreshness = useDiffFreshness({ + enabled: !!origin, + resetKey: diffData?.rawPatch ?? '', + }); + + const handleRefreshStaleDiff = useCallback(() => { + if (prMetadata) { + // Only the full-stack scope can go stale locally — the layer diff is + // computed platform-side and its fingerprint never flips. + if (prDiffScope === 'full-stack') handlePRDiffScopeSelect('full-stack'); + return; + } + // Same params, fresh snapshot. preserveFile keeps the reviewer on the + // file they were reading. + void fetchDiffSwitch(diffType, selectedBase, { preserveFile: true }); + }, [prMetadata, prDiffScope, handlePRDiffScopeSelect, fetchDiffSwitch, diffType, selectedBase]); + + // Select annotation - switches file if needed and scrolls to it. + // isAllFilesActive is read through the ref (declared with the state): this + // handler is baked into Pierre slot portals, which only republish on item + // version bumps — a stale captured value would yank the user out of the + // all-files tab into the single-file panel when they click an annotation. const handleSelectAnnotation = useCallback((id: string | null) => { if (!id) { setSelectedAnnotationId(null); @@ -1425,7 +1495,7 @@ const ReviewApp: React.FC = () => { } // Find the annotation - const annotation = allAnnotations.find(a => a.id === id); + const annotation = allAnnotationsRef.current.find(a => a.id === id); if (!annotation) { setSelectedAnnotationId(id); return; @@ -1433,7 +1503,7 @@ const ReviewApp: React.FC = () => { // In all-files mode, just set the selection — the panel's scroll-to-annotation // effect handles expanding and scrolling. In single-file mode, switch to the file. - if (!isAllFilesActive) { + if (!isAllFilesActiveRef.current) { const fileIndex = files.findIndex(f => f.path === annotation.filePath); if (fileIndex !== -1) { handleFileSwitch(fileIndex); @@ -1441,7 +1511,7 @@ const ReviewApp: React.FC = () => { } setSelectedAnnotationId(id); - }, [allAnnotations, files, isAllFilesActive, handleFileSwitch]); + }, [files, handleFileSwitch]); // Diff context bundled into local-mode feedback headers so the receiving // agent knows which diff the annotations are anchored to. Uses committedBase @@ -1524,13 +1594,17 @@ const ReviewApp: React.FC = () => { activeFileSearchMatches, activeSearchMatchId, activeSearchMatch: activeSearchMatch?.filePath === files[activeFileIndex]?.path ? activeSearchMatch : null, + searchMatches, + allFilesActiveSearchMatch: activeSearchMatch, aiAvailable, aiMessages, onAskAI: handleAskAI, + onAskAIForFile: handleAskAIForFile, isAILoading: aiIsCreatingSession || aiIsStreaming, onViewAIResponse: handleViewAIResponse, onClickAIMarker: handleClickAIMarker, aiHistoryForSelection, + getAIHistoryForFile, agentJobs: agentJobs.jobs, prMetadata, prContext, @@ -1561,10 +1635,10 @@ const ReviewApp: React.FC = () => { handleSelectAnnotation, handleDeleteAnnotation, viewedFiles, handleToggleViewed, stagedFiles, stagingFile, stageFile, canStageFiles, stageError, isSearchPending, debouncedSearchQuery, - activeFileSearchMatches, activeSearchMatchId, activeSearchMatch, + activeFileSearchMatches, activeSearchMatchId, activeSearchMatch, searchMatches, aiAvailable, aiMessages, aiIsCreatingSession, aiIsStreaming, - handleAskAI, handleViewAIResponse, handleClickAIMarker, - aiHistoryForSelection, agentJobs.jobs, prMetadata, prContext, + handleAskAI, handleAskAIForFile, handleViewAIResponse, handleClickAIMarker, + aiHistoryForSelection, getAIHistoryForFile, agentJobs.jobs, prMetadata, prContext, isPRContextLoading, prContextError, fetchPRContext, platformUser, openDiffFile, handleOpenTour, isAllFilesActive, isSemanticDiffActive, semanticDiffAvailable, handleSemanticDiffUnavailable, handleSemanticDiffLoadError, handleSemanticDiffLoadSuccess, handleAddAnnotationForFile, @@ -2100,6 +2174,31 @@ const ReviewApp: React.FC = () => { )} + {/* Diff staleness notice — files changed since this snapshot + was computed (agent editing mid-review). Non-blocking; the + user refreshes when ready. */} + {diffFreshness.isStale && !isLoadingDiff && ( +
+ Diff out of date + Stale + + +
+ )} + {/* Agent mode: Close/SendFeedback flip + Approve */} {!platformMode ? ( { )} + {/* Global diff display options. These settings apply to every + file's diff, so they live once in the toolbar instead of being + repeated in every FileHeader. */} + +
{/* Sidebar tab toggles */} diff --git a/packages/review-editor/components/AllFilesCodeView.tsx b/packages/review-editor/components/AllFilesCodeView.tsx new file mode 100644 index 00000000..7cf38796 --- /dev/null +++ b/packages/review-editor/components/AllFilesCodeView.tsx @@ -0,0 +1,1791 @@ +import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import { getSingularPatch, processFile } from '@pierre/diffs'; +import type { + CodeViewItem, + CodeViewLineSelection, + CodeViewOptions, + DiffLineAnnotation, + FileDiffMetadata, + LineAnnotation, + PostRenderPhase, + SelectedLineRange, +} from '@pierre/diffs'; +import { CodeView, type CodeViewHandle, useStableCallback } from '@pierre/diffs/react'; +import type { DiffTokenEventBaseProps } from '@pierre/diffs'; +import type { + CodeAnnotation, + CodeAnnotationType, + ConventionalDecoration, + ConventionalLabel, + DiffAnnotationMetadata, + TokenAnnotationMeta, +} from '@plannotator/ui/types'; +import { CommentPopover } from '@plannotator/ui/components/CommentPopover'; +import { usePierreTheme } from '../hooks/usePierreTheme'; +import type { DiffFile } from '../types'; +import { buildFileTree, getVisualFileOrder } from '../utils/buildFileTree'; +import { buildCodeNavRequest } from '../utils/buildCodeNavRequest'; +import { getDiffSelection, getLineNumberFromNode, getSideFromNode } from '../utils/diffSelection'; +import { isContentConsistentWithPatch } from '../utils/patchConsistency'; +import { ToolbarHost, type ToolbarHostHandle } from './ToolbarHost'; +import { FileHeader } from './FileHeader'; +import { InlineAnnotation } from './InlineAnnotation'; +import { detectLanguage } from '../utils/detectLanguage'; +import type { AIChatEntry } from '../hooks/useAIChat'; +import type { ReviewSearchMatch } from '../utils/reviewSearch'; +import { + applyItemSearchHighlights, + clearItemSearchHighlights, + swapActiveSearchHighlight, +} from '../utils/reviewSearchHighlight'; + +/** + * AllFilesCodeView (migration phases P1 + P2 + P3 + P4) + * + * Renders every changed file through ONE Pierre `CodeView` inside a single + * scroll container. This IS the all-files surface — the legacy per-file + * `FileDiff` list (`AllFilesDiffView` + `LazyFileDiff`) and its + * `allFilesCodeView` config flag were deleted once the migration completed. + * + * P1 established the static, uncontrolled `initialItems` skeleton. P2 locked + * down item identity and routed navigation + line selection through CodeView's + * own APIs. P3 moved collapse + the full Plannotator FileHeader INTO CodeView + * via the `renderCustomHeader` render slot. + * + * P4 (this phase) routes annotations through CodeView item state: + * + * - CodeView is typed with `` so each diff item's + * `annotations: DiffLineAnnotation[]` and + * `renderAnnotation(annotation, item)` are fully typed. + * - Annotations are grouped per file (the same projection AllFilesDiffView + * builds: side 'additions'/'deletions', lineNumber = ann.lineEnd, metadata = + * DiffAnnotationMetadata) and seeded onto each item at build time. When the + * `annotations` prop changes we rebuild ONLY the affected items' annotation + * arrays, bump `item.version`, and call `viewer.updateItem(item)` — so a + * single annotation add/edit/delete re-renders just its owning file. + * - `renderAnnotation` renders the existing `InlineAnnotation` from + * `annotation.metadata`, routing onSelect/onEdit/onDelete by the OWNING item + * (no active-file side channel). Edit routes through the ToolbarHost handle. + * - Selecting an annotation in the sidebar expands its owning file + * (item.collapsed=false + version bump + updateItem) and + * `scrollTo({ type: 'item' | 'range' })` to it. + * - The annotation toolbar already flows through CodeView's + * `onGutterUtilityClick` / `onLineSelectionEnd` callbacks (P2): file identity + * comes from `context.item.id`, and ToolbarHost is fed that file's patch so + * original-code extraction reads the correct file. Drafts-by-file/range and + * AI markers are preserved by ToolbarHost/useAnnotationToolbar unchanged. + * + * P5 (this phase) preserves lazy full-content hunk expansion through CodeView + * item updates instead of LazyFileDiff's per-mount IntersectionObserver fetch: + * + * - Initial items use `getSingularPatch` (raw-patch context only) — CodeView + * already virtualizes the visible window, so no full content is fetched up + * front. + * - When an item enters CodeView's rendered window (its `onPostRender` fires + * with phase 'mount'/'update', the direct analogue of LazyFileDiff's + * IntersectionObserver becoming visible), we fetch `/api/file-content` for + * that file (path/oldPath preserved — workspace prefixes intact — plus the + * review base), reparse with `processFile`, and swap `item.fileDiff` to the + * augmented `FileDiffMetadata`. The augmented diff gets a NEW `cacheKey` + * (contents changed!), `item.version++`, and `viewer.updateItem(item)`. This + * enables the gutter's expand-unchanged controls in place, without + * remounting the list. + * - CodeView's `updateItem` re-measures the grown item and resolves the + * captured scroll anchor, so the viewport stays put whether the augmented + * item is above OR below the fold. + * - Fetches are guarded (one per item per diff generation) and cancellable + * (AbortController per item, all aborted on unmount / diff switch), so there + * is no fetch storm and no double-fetch. + * + * P6 (this phase) makes search work over CodeView's recycled DOM: + * + * - The raw-patch search INDEX is unchanged (App still owns useReviewSearch). + * Only DOM application + navigation move here for the all-files surface. + * - Navigation: when an active match changes, expand its owning file (if + * collapsed) and `viewer.scrollTo({ type: 'line', id, lineNumber, side })` so + * the line lands in view — robust against virtualization (no DOM dependency). + * - Highlighting survives element recycling by re-applying `` per ITEM via + * `onPostRender`: on mount/update we (re)apply that item's matches; on unmount + * we clear its marks. CodeView reuses item elements from a pool, so a one-shot + * mutation would stick to a reused row or vanish — re-applying on every render + * keeps marks correct after scrolling far enough to recycle. A separate effect + * re-applies across all currently-rendered items when the query/matches change + * (no render is otherwise triggered), and an O(1) effect swaps just the active + * match's styling when stepping between matches. + * + * P7 finished the edges that made CodeView the sole all-files renderer: + * + * - No center split dragger: the legacy all-files view never had one, and a + * single global drag line across every file is noise on files where a + * split is meaningless (new/deleted files). Split columns use Pierre's + * default even 1fr/1fr layout; the single-file DiffViewer keeps its + * per-file dragger. + * - Token code navigation: Cmd/Ctrl-click a token routes through + * `onCodeNavRequest` (parity with the single-file DiffViewer and the legacy + * all-files view), with the `pn-token-nav` affordance (the hover-only + * `pn-token-hover` class is a single-file DiffViewer extra, here as in the + * legacy all-files view). File identity comes from the CodeView callback + * context's owning item, never an active-file side channel. + * - Safari scroll guardian: NOT carried forward. The old DiffViewer guardian + * targeted the OverlayScrollbars viewport wrapping many separate FileDiff + * shadow nodes and restored scrollTop on a ">200 -> 0" jump heuristic. + * CodeView owns its own scroll model and DELIBERATELY rebases the container's + * DOM scrollTop into a bounded 12M-px paged window, so that heuristic would + * misfire against CodeView's own rebasing. CodeView is the scroll authority + * here; we rely on it rather than a guardian that would fight it. (Still + * needs real WebKit validation.) + * + * The worker pool remains a later phase. + */ +interface AllFilesCodeViewProps { + files: DiffFile[]; + diffStyle: 'split' | 'unified'; + diffOverflow?: 'scroll' | 'wrap'; + diffIndicators?: 'bars' | 'classic' | 'none'; + lineDiffType?: 'word-alt' | 'word' | 'char' | 'none'; + disableLineNumbers?: boolean; + disableBackground?: boolean; + expandUnchanged?: boolean; + fontFamily?: string; + fontSize?: string; + // Annotation state (P4). Mirrors AllFilesDiffView's annotation surface so + // line annotations render through CodeView item state. + annotations: CodeAnnotation[]; + selectedAnnotationId: string | null; + pendingSelection: SelectedLineRange | null; + reviewBase?: string; + // Annotation / toolbar wiring (P2). Mirrors AllFilesDiffView's surface so the + // toolbar opens against the file CodeView reports for a selection. + onLineSelection: (range: SelectedLineRange | null) => void; + onAddAnnotationForFile: ( + filePath: string, + type: CodeAnnotationType, + text?: string, + suggestedCode?: string, + originalCode?: string, + conventionalLabel?: ConventionalLabel, + decorations?: ConventionalDecoration[], + tokenMeta?: TokenAnnotationMeta, + ) => void; + onEditAnnotation: ( + id: string, + text?: string, + suggestedCode?: string, + originalCode?: string, + conventionalLabel?: ConventionalLabel | null, + decorations?: ConventionalDecoration[], + ) => void; + onSelectAnnotation: (id: string | null) => void; + onDeleteAnnotation: (id: string) => void; + // Header actions (P3). Mirror AllFilesDiffView's header surface. + onAddFileCommentForFile?: (filePath: string, text: string) => void; + viewedFiles?: Set; + onToggleViewed?: (filePath: string) => void; + stagedFiles?: Set; + onStage?: (filePath: string) => void; + canStageFiles?: boolean; + stagingFile?: string | null; + stageError?: string | null; + prUrl?: string; + prDiffScope?: string; + // Search (P6). The raw-patch index lives in App (useReviewSearch); these feed + // the per-item application + scrollTo navigation over the recycled DOM. + searchQuery?: string; + searchMatches?: ReviewSearchMatch[]; + activeSearchMatchId?: string | null; + activeSearchMatch?: ReviewSearchMatch | null; + // Token code navigation (P7). Cmd/Ctrl-click a token resolves symbol defs/refs. + onCodeNavRequest?: (request: import('@plannotator/shared/code-nav').CodeNavRequest) => void; + // File-tree active-file highlight follows scroll. + onVisibleFileChange?: (filePath: string | null) => void; + // Only handle [/]/z/v/a/c/x keyboard nav when this surface is the active panel. + isActive?: boolean; + // AI props (optional — surfaced into the toolbar). File-aware variants: this + // surface owns which file the selection lives in (activeFilePath), so the + // index-based onAskAI/aiHistoryForSelection (which resolve the file from the + // single-file panel's focus) must not be used here. + aiAvailable?: boolean; + onAskAIForFile?: (filePath: string, question: string) => void; + isAILoading?: boolean; + onViewAIResponse?: (questionId?: string) => void; + getAIHistoryForFile?: (filePath: string) => AIChatEntry[]; +} + +// Diffshub-style stable path-based id allocation. Plannotator's file list is +// normally one entry per (new) path, so ids are identity (id === path) in the +// common case. Pathological patches (e.g. a delete + re-add of the same path, +// or repeated paths) would otherwise collapse two files onto one CodeView item, +// breaking selection/scroll identity — so a per-base suffix disambiguates them +// while still keeping a filePath <-> itemId map for the bridge. +interface ItemIdentity { + items: CodeViewItem[]; + /** Maps a file path to the CodeView item id that owns it. */ + filePathToItemId: Map; + /** Maps a file path to ALL item ids rendering it (duplicate display paths + * produce twins; updates keyed by path must fan out to every twin). */ + filePathToItemIds: Map; + /** Maps a CodeView item id back to the originating file path. */ + itemIdToFilePath: Map; + /** Maps a CodeView item id to its originating DiffFile. Keyed by the unique + * item id (not path) so duplicate display paths resolve to the correct file. */ + itemIdToFile: Map; +} + +// Cheap content hash (djb2 xor variant) for diff-change detection. Replaces +// patch-LENGTH proxies: a same-length different-content patch set must still +// remount CodeView (fileSetKey) and must not collide in highlight caches +// (cacheKey). Not cryptographic — collision odds for this purpose are fine. +function hashString(value: string): string { + let hash = 5381; + for (let i = 0; i < value.length; i++) { + hash = ((hash * 33) ^ value.charCodeAt(i)) >>> 0; + } + return hash.toString(36); +} + +// Project a file's line annotations into Pierre's DiffLineAnnotation shape. This +// is the EXACT projection AllFilesDiffView builds (side, lineNumber = lineEnd, +// metadata = DiffAnnotationMetadata) so the two surfaces render identically. +// Filters to line-scoped annotations that belong to this file in the active +// PR/diff-scope (file-scoped comments live in the header, not the gutter). +function projectFileAnnotations( + annotations: CodeAnnotation[], + filePath: string, + prUrl: string | undefined, + prDiffScope: string | undefined, +): DiffLineAnnotation[] { + return annotations + .filter( + (a) => + a.filePath === filePath && + (a.scope ?? 'line') === 'line' && + (!a.prUrl || !prUrl || a.prUrl === prUrl) && + (!a.diffScope || !prDiffScope || a.diffScope === prDiffScope), + ) + .map((ann) => ({ + side: ann.side === 'new' ? ('additions' as const) : ('deletions' as const), + lineNumber: ann.lineEnd, + metadata: { + annotationId: ann.id, + type: ann.type, + text: ann.text, + suggestedCode: ann.suggestedCode, + originalCode: ann.originalCode, + author: ann.author, + severity: ann.severity, + reasoning: ann.reasoning, + conventionalLabel: ann.conventionalLabel, + decorations: ann.decorations, + } as DiffAnnotationMetadata, + })); +} + +function buildItemIdentity( + files: DiffFile[], + visualOrder: number[], + annotations: CodeAnnotation[], + prUrl: string | undefined, + prDiffScope: string | undefined, + patchHashes: string[], +): ItemIdentity { + const items: CodeViewItem[] = []; + const filePathToItemId = new Map(); + const filePathToItemIds = new Map(); + const itemIdToFilePath = new Map(); + const itemIdToFile = new Map(); + const usedIds = new Set(); + const nextSuffixByBase = new Map(); + + const allocateId = (path: string): string => { + if (!usedIds.has(path)) { + usedIds.add(path); + return path; + } + let suffix = nextSuffixByBase.get(path) ?? 2; + let id = `${path}?${suffix}`; + while (usedIds.has(id)) { + suffix++; + id = `${path}?${suffix}`; + } + nextSuffixByBase.set(path, suffix + 1); + usedIds.add(id); + return id; + }; + + for (const index of visualOrder) { + const file = files[index]; + if (!file) continue; + // getSingularPatch throws when a patch doesn't parse to exactly one file. + // The legacy per-file surface isolated such failures to one FileDiff; here + // one bad patch must not take down the whole all-files surface — skip the + // file (it remains reachable via the tree / single-file panel). + let fileDiff: FileDiffMetadata; + try { + fileDiff = getSingularPatch(file.patch); + } catch (err) { + console.warn(`AllFilesCodeView: skipping unparseable patch for ${file.path}`, err); + continue; + } + const id = allocateId(file.path); + // cacheKey seeds worker highlighting (a later phase), whose cache is a + // singleton that SURVIVES fileSetKey remounts — so the key must be unique + // per item (duplicate display paths) AND per diff content (the same path + // across a base/whitespace/PR switch carries different contents). The + // content hash is the same one fileSetKey uses. + fileDiff.cacheKey = `${id}#${patchHashes[index] ?? ''}`; + // Seed annotations at build time so the first render (and any remount via + // fileSetKey) already paints existing annotations without an extra update. + const fileAnnotations = projectFileAnnotations(annotations, file.path, prUrl, prDiffScope); + items.push({ id, type: 'diff', fileDiff, version: 0, annotations: fileAnnotations }); + // First occurrence of a path wins the canonical lookup so the file tree + // (keyed by path) navigates to the primary item for that path. + if (!filePathToItemId.has(file.path)) { + filePathToItemId.set(file.path, id); + } + const twins = filePathToItemIds.get(file.path); + if (twins) twins.push(id); + else filePathToItemIds.set(file.path, [id]); + itemIdToFilePath.set(id, file.path); + itemIdToFile.set(id, file); + } + + return { items, filePathToItemId, filePathToItemIds, itemIdToFilePath, itemIdToFile }; +} + +// Resolved pixel height of the custom header. Must equal FileHeader's fixed +// container height (`style={{ height: 'var(--panel-header-h)' }}`) so CodeView's +// virtualization reserves exactly the right space for the header. FileHeader is +// internally responsive (ResizeObserver shrinks labels) but its OUTER box height +// is fixed, so the responsive label changes never alter the row height. +const PANEL_HEADER_HEIGHT = 33; // --panel-header-h +// Hunk separator height forced by usePierreTheme unsafeCSS: +// [data-separator='line-info'] { height: 24px; margin-block: 4px; } +// => 24 + 4*2 = 32. Pierre's own 'line-info' default metric is also 32, so +// passing it is redundant today — kept explicit so the metric stays pinned to +// OUR unsafeCSS rule rather than silently tracking a library default. +const HUNK_SEPARATOR_HEIGHT = 32; + +export const AllFilesCodeView: React.FC = ({ + files, + diffStyle, + diffOverflow, + diffIndicators, + lineDiffType, + disableLineNumbers, + disableBackground, + expandUnchanged, + fontFamily, + fontSize, + annotations, + selectedAnnotationId, + pendingSelection, + reviewBase, + onLineSelection, + onAddAnnotationForFile, + onEditAnnotation, + onSelectAnnotation, + onDeleteAnnotation, + onAddFileCommentForFile, + viewedFiles, + onToggleViewed, + stagedFiles, + onStage, + canStageFiles = false, + stagingFile, + stageError, + prUrl, + prDiffScope, + searchQuery = '', + searchMatches = [], + activeSearchMatchId = null, + activeSearchMatch = null, + onCodeNavRequest, + onVisibleFileChange, + isActive = true, + aiAvailable = false, + onAskAIForFile, + isAILoading = false, + onViewAIResponse, + getAIHistoryForFile, +}) => { + // showFileHeader: true suppresses usePierreTheme's `[data-title]` hide rule. + // With renderCustomHeader the built-in header runs in 'custom' mode (only the + // header-custom slot, no [data-title] element), so that rule is moot either + // way — we keep `true` to be explicit that the built-in title is irrelevant + // here (our FileHeader owns all header chrome). + const pierreTheme = usePierreTheme({ fontFamily, fontSize, showFileHeader: true }); + const viewerRef = useRef | null>(null); + const scrollRef = useRef(null); + const toolbarHostRef = useRef(null); + + // NOTE: no center split dragger on this surface (parity with the legacy + // all-files view, which never had one). One global drag line spanning every + // file is noise on files where a split is meaningless (new/deleted files), + // and the columns default to Pierre's even 1fr/1fr split. The single-file + // DiffViewer keeps its per-file dragger. + + // The file path CodeView currently reports as visible (active-file highlight). + // Reset on diff switch so stepping/highlighting never anchors on an old file. + const visibleFileRef = useRef(null); + + // The file CodeView last reported a selection / line-click in. The toolbar is + // keyed off this file's path + patch, but the value is sourced from the + // CodeView callback context (item.id) — never from geometry inference. + const [activeFilePath, setActiveFilePath] = useState(null); + // Mirror ref so stable callbacks (Ask AI) read the active file at CALL time. + const activeFilePathRef = useRef(activeFilePath); + activeFilePathRef.current = activeFilePath; + const [selectedLines, setSelectedLines] = useState(null); + // A range whose toolbar must open only after the ToolbarHost remounts against + // the newly-activated file (its patch/filePath props changed this render). + const pendingToolbarRange = useRef(null); + + // File-scoped comment popover anchor (P3). Anchored by the FileHeader button + // ref handed through the render slot — NOT by querying the recycled/portaled + // header DOM (CodeView reuses header elements, so a DOM lookup is unreliable). + const [fileCommentAnchor, setFileCommentAnchor] = useState<{ el: HTMLElement; filePath: string } | null>(null); + // Per-file-comment-button ref map so the `c` keyboard shortcut can anchor the + // popover without DOM querying. Eagerly populated/cleared by FileHeader's + // fileCommentButtonRef callback as header slots mount/unmount (clicking also + // refreshes the entry via handleFileComment). + const fileCommentButtonRefs = useRef>(new Map()); + + // Previous snapshots of header-driving props (see the header-refresh effect + // below). Declared up here with the other refs so the diff-switch reset effect + // can resync them. + const prevViewedRef = useRef | undefined>(viewedFiles); + const prevStagedRef = useRef | undefined>(stagedFiles); + const prevStagingRef = useRef(stagingFile); + const prevStageErrorRef = useRef(stageError); + // Previous annotations snapshot for the per-item annotation-sync effect (P4). + const prevAnnotationsRef = useRef(annotations); + + // Order items by the current visual file-tree order — same ordering the + // legacy all-files view uses, so the two surfaces present files identically. + const visualOrder = useMemo(() => { + const tree = buildFileTree(files); + return getVisualFileOrder(tree); + }, [files]); + + // `initialItems` + the identity maps are recomputed whenever the file set + // changes. CodeView is uncontrolled (the Diffshub pattern) and only seeds + // `initialItems` once per instance, so changing `files` in place would NOT + // re-seed it. The ALL_FILES dock panel is reused (single fixed panel id, + // `getPanel().api.setActive()`), and diff-type/base/PR-scope/PR/whitespace + // switches all call `setFiles(...)` WITHOUT recreating the panel — so this + // component instance survives a diff switch. To keep CodeView in sync with + // the new diff we remount it via `fileSetKey` (below), which re-runs the + // `initialItems` seed against the freshly computed identity. This restores + // the legacy AllFilesDiffView behavior (which reads `files` live). + // NOTE: `annotations` is intentionally NOT in the dep list. The identity (and + // the CodeView remount it drives via fileSetKey) must only change when the + // FILE SET changes — otherwise every annotation add/edit/delete would remount + // the whole CodeView and lose scroll/selection state. Existing annotations are + // seeded into items on (re)build via the captured `annotations` closure for + // the first paint; subsequent annotation changes are applied incrementally per + // item by the annotation-sync effect below (updateItem on only the changed + // file). We read the latest annotations through a ref at build time so a + // remount triggered by a file-set change still seeds current annotations. + const annotationsRef = useRef(annotations); + annotationsRef.current = annotations; + // Per-file patch content hashes — shared by fileSetKey (remount detection) + // and the items' cacheKeys (highlight cache identity). Hashed once per + // files-identity change. + const patchHashes = useMemo(() => files.map((f) => hashString(f.patch)), [files]); + const identity = useMemo( + () => buildItemIdentity(files, visualOrder, annotationsRef.current, prUrl, prDiffScope, patchHashes), + // eslint-disable-next-line react-hooks/exhaustive-deps + [files, visualOrder, prUrl, prDiffScope, patchHashes], + ); + const { filePathToItemId, filePathToItemIds, itemIdToFilePath, itemIdToFile } = identity; + + // Stable identity of the current diff. Changes whenever the file set or any + // file's patch CONTENT changes (diff type / base / whitespace / PR switch), + // and is used as the CodeView `key` to force a remount + fresh seed. + const fileSetKey = useMemo( + () => `${files.length}:${files.map((f, i) => `${f.path}#${patchHashes[i]}`).join('|')}`, + [files, patchHashes], + ); + + // Visual-order list of file paths (for [/] stepping). Derived from items so it + // matches CodeView's rendered order exactly. + const orderedItemIds = useMemo( + () => identity.items.map((item) => item.id), + [identity.items], + ); + + // Path -> DiffFile lookup for the on-demand content augmentation (P5). The + // post-render callback resolves item.id -> path -> DiffFile to know which + // file's patch/oldPath to fetch + reparse. + const activePatch = useMemo( + () => (activeFilePath ? files.find((f) => f.path === activeFilePath)?.patch ?? '' : ''), + [files, activeFilePath], + ); + + // --- Search (P6) ------------------------------------------------------------ + // Group search matches by the CodeView item id that owns the file, so each + // item's onPostRender (and the bulk reapply effect) can apply ONLY its own + // matches. Matches are file-keyed (filePath); resolve to itemId via the bridge. + const matchesByItemId = useMemo(() => { + const map = new Map(); + if (searchMatches.length === 0) return map; + for (const match of searchMatches) { + const itemId = filePathToItemId.get(match.filePath); + if (itemId == null) continue; + const group = map.get(itemId); + if (group) group.push(match); + else map.set(itemId, [match]); + } + return map; + }, [searchMatches, filePathToItemId]); + + // Read search state through refs so the stable onPostRender callback always + // sees the latest values without changing the CodeView options identity (which + // would churn the options object and reset CodeView). + const matchesByItemIdRef = useRef(matchesByItemId); + matchesByItemIdRef.current = matchesByItemId; + const searchQueryRef = useRef(searchQuery); + searchQueryRef.current = searchQuery; + const activeSearchMatchIdRef = useRef(activeSearchMatchId); + activeSearchMatchIdRef.current = activeSearchMatchId; + + // The CodeView callback context gives us the owning item directly, so file + // identity comes from `item.id` instead of header-geometry inference. If the + // toolbar is already keyed to this file, open immediately; otherwise activate + // the file first and defer until ToolbarHost remounts against its patch. + const routeSelectionToToolbar = useCallback( + (range: SelectedLineRange, filePath: string) => { + if (activeFilePath === filePath) { + toolbarHostRef.current?.handleLineSelectionEnd(range); + } else { + pendingToolbarRange.current = range; + setActiveFilePath(filePath); + // Paint the highlight on the TARGET item directly. The mirror effect + // below can't be trusted to do this: it no-ops on value-equal ranges + // (so a text-drag selecting the same line numbers as the previous + // file's selection would leave the highlight stranded there), and it + // pairs pendingSelection with activeFilePath, which hasn't committed + // yet. + const itemId = filePathToItemId.get(filePath); + if (itemId != null) setSelectedLines({ id: itemId, range }); + // Publish the new range alongside the new active file so the + // pendingSelection mirror effect never sees the PREVIOUS file's range + // paired with the new activeFilePath (one-frame wrong highlight). + // openToolbar re-publishes the same range when the deferred flush + // runs — harmless duplicate. + onLineSelection(range); + } + }, + [activeFilePath, onLineSelection, filePathToItemId], + ); + + // Once ToolbarHost has remounted against the newly-active file, flush the + // deferred selection so the toolbar opens with the correct file + range. + // Keyed on activeFilePath AND activePatch: two different files can carry + // byte-identical patch text (two empty new files, the same one-line change), + // in which case switching the active file does NOT change activePatch — and + // a patch-only dependency would never flush, silently swallowing the + // selection. + useEffect(() => { + if (pendingToolbarRange.current && activePatch) { + toolbarHostRef.current?.handleLineSelectionEnd(pendingToolbarRange.current); + pendingToolbarRange.current = null; + } + }, [activeFilePath, activePatch]); + + const handleAddAnnotation = useCallback( + ( + type: CodeAnnotationType, + text?: string, + suggestedCode?: string, + originalCode?: string, + conventionalLabel?: ConventionalLabel, + decorations?: ConventionalDecoration[], + tokenMeta?: TokenAnnotationMeta, + ) => { + if (!activeFilePath) return; + onAddAnnotationForFile( + activeFilePath, + type, + text, + suggestedCode, + originalCode, + conventionalLabel, + decorations, + tokenMeta, + ); + }, + [activeFilePath, onAddAnnotationForFile], + ); + + // Ask AI + AI history routed by THIS surface's active file (the file the + // toolbar selection lives in) — never by the single-file panel's focus index. + const handleAskAIForActiveFile = useMemo(() => { + if (!onAskAIForFile) return undefined; + return (question: string) => { + const filePath = activeFilePathRef.current; + if (filePath) onAskAIForFile(filePath, question); + }; + }, [onAskAIForFile]); + + const aiHistoryForActiveFile = useMemo( + () => (getAIHistoryForFile && activeFilePath ? getAIHistoryForFile(activeFilePath) : []), + [getAIHistoryForFile, activeFilePath], + ); + + // Edit routes through the ToolbarHost handle (same as AllFilesDiffView). The + // annotation's id resolves to the full CodeAnnotation so the toolbar opens + // pre-filled. ToolbarHost is keyed to the active file's patch; startEdit + // positions itself by last-known mouse position, so it works regardless of + // which file the clicked annotation belongs to. + // useStableCallback + ref read: this handler is baked into slot-portal + // elements (InlineAnnotation onEdit) that only republish on version bumps, + // so it must resolve the annotation at CALL time, never from a captured + // closure. + const handleEditAnnotation = useStableCallback((id: string) => { + const ann = annotationsRef.current.find((a) => a.id === id); + if (!ann) return; + toolbarHostRef.current?.startEdit(ann); + }); + + // Render a single annotation from item state. `renderAnnotation` receives both + // the LineAnnotation and DiffLineAnnotation union — guard `'side' in + // annotation && item.type === 'diff'` (the Diffshub pattern) so file-item + // annotations (none here) and metadata-less annotations are skipped. Actions + // route by the OWNING item, not an active-file side channel. + const renderAnnotation = useStableCallback( + ( + annotation: + | DiffLineAnnotation + | LineAnnotation, + item: CodeViewItem, + ) => { + if (!('side' in annotation) || item.type !== 'diff') return null; + if (!annotation.metadata) return null; + const filePath = itemIdToFilePath.get(item.id); + return ( + + ); + }, + ); + + // Reset to a fresh state when the file set changes (diff switch). CodeView + // itself is remounted via `fileSetKey`; this clears the React-side toolbar / + // selection / active-file / header state so nothing keys off a file from the + // old diff. + useEffect(() => { + // Keep the reader's place across a diff switch/refresh: if the file they + // were on still exists in the new diff, scroll the remounted CodeView back + // to it (rAF lets the seed render settle first). Matters most for the + // staleness-refresh flow — "Refresh" must not dump the user at the top. + const previousVisible = visibleFileRef.current; + if (previousVisible) { + const restoreId = filePathToItemId.get(previousVisible); + if (restoreId != null) { + requestAnimationFrame(() => { + viewerRef.current?.scrollTo({ type: 'item', id: restoreId, align: 'start' }); + }); + } + } + setActiveFilePath(null); + setSelectedLines(null); + pendingToolbarRange.current = null; + visibleFileRef.current = null; + setFileCommentAnchor(null); + fileCommentButtonRefs.current.clear(); + // Resync the header-refresh snapshots to the current props so the post- + // remount header-refresh effect computes deltas against THIS diff, not the + // previous one (the remounted items already seed from live props). + prevViewedRef.current = viewedFiles; + prevStagedRef.current = stagedFiles; + prevStagingRef.current = stagingFile; + prevStageErrorRef.current = stageError; + // Annotations are seeded into the remounted items at build time, so resync + // the snapshot here to avoid a spurious full annotation refresh post-remount. + prevAnnotationsRef.current = annotations; + // Garbage-collect STALE-generation content fetches. Generation-aware on + // purpose: this passive effect runs AFTER the remounted CodeView's seed + // layout effect has already fired the new diff's first postRender wave — + // augmentItem has started the NEW generation's fetches by the time we get + // here, and a blanket abort+clear would kill our own generation's work + // (re-fetch storm at best; an unaugmented initial window if the rAF + // second wave loses the race). Stale generations are already inert — the + // dedup guard ignores them and isStale() blocks their writes — so this + // sweep is pure cleanup. + for (const [itemId, entry] of augmentRef.current) { + if (entry.generation === fileSetKey) continue; + entry.controller.abort(); + augmentRef.current.delete(itemId); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [fileSetKey]); + + // --- Collapse via CodeView item state (Diffshub pattern + anchor fix) ------ + + const toggleItemCollapsed = useStableCallback((itemId: string) => { + const handle = viewerRef.current; + const viewer = handle?.getInstance(); + const item = handle?.getItem(itemId); + if (handle == null || viewer == null || item == null) return; + + // If the item top is above scrollTop, re-anchor after the update so the + // collapsing file stays in view (it would otherwise shift the content + // below it upward, jumping the scroll). Diffshub anchor fix. + const itemTop = viewer.getTopForItem(itemId); + item.collapsed = item.collapsed !== true; + item.version = (item.version ?? 0) + 1; + if (!handle.updateItem(item)) return; + + if (itemTop != null && itemTop < viewer.getScrollTop()) { + viewer.scrollTo({ type: 'item', id: itemId, align: 'start' }); + } + }); + + // Collapse a file (idempotent) — used by viewed+collapse so marking a file + // viewed also folds it away, matching the legacy view. + const collapseItem = useStableCallback((itemId: string) => { + const handle = viewerRef.current; + const item = handle?.getItem(itemId); + if (handle == null || item == null || item.collapsed === true) return; + item.collapsed = true; + item.version = (item.version ?? 0) + 1; + handle.updateItem(item); + }); + + const isItemCollapsed = useCallback((itemId: string): boolean => { + return viewerRef.current?.getItem(itemId)?.collapsed === true; + }, []); + + // Force CodeView to re-render an item's slots (header included) WITHOUT + // otherwise mutating it. Pierre renders `renderCustomHeader` into a portal + // driven by an internal store that only republishes on item mount / unmount / + // updateItem. Because `renderCustomHeader` is a stable callback (its identity + // never changes), the memoized SlotPortals will NOT re-render when external + // React state captured by the closure (viewedFiles / stagedFiles / + // stagingFile / stageError) changes. Bumping `item.version` + `updateItem` + // republishes the slot so the header reflects the new state — the same path + // collapse already uses. + const refreshItem = useCallback((itemId: string) => { + const handle = viewerRef.current; + const item = handle?.getItem(itemId); + if (handle == null || item == null) return; + item.version = (item.version ?? 0) + 1; + handle.updateItem(item); + }, []); + + // --- Lazy full-content hunk expansion via CodeView item updates (P5) -------- + + // Per-item augmentation bookkeeping. `status` guards against double-fetch / + // fetch storms (an item can re-fire onPostRender on every scroll-driven + // remount of its element); `controller` lets us abort an in-flight fetch when + // the diff switches or the component unmounts. Keyed by CodeView item id. + // `generation` is the fileSetKey at fetch start. It makes stale entries + // self-invalidating across diff switches: the remounted CodeView's first + // postRender wave fires BEFORE the diff-switch reset effect can clear this + // map (layout vs passive effect timing), so an entry from the previous diff + // must not satisfy the dedup guard — and a fetch from the previous diff must + // never write into the new diff's (same-id) item. + const augmentRef = useRef< + Map< + string, + { status: 'pending' | 'done' | 'error'; controller: AbortController; generation: string } + > + >(new Map()); + // reviewBase / itemIdToFile / fileSetKey read through refs so the stable + // onPostRender callback always sees the latest values without changing + // identity (which would otherwise churn the CodeView options object). + const reviewBaseRef = useRef(reviewBase); + reviewBaseRef.current = reviewBase; + const itemIdToFileRef = useRef(itemIdToFile); + itemIdToFileRef.current = itemIdToFile; + const fileSetKeyRef = useRef(fileSetKey); + fileSetKeyRef.current = fileSetKey; + + // Fetch full file contents for one item, reparse with processFile, and swap + // the item's fileDiff in place so hunk expansion (expand-unchanged gutter + // controls) works against the COMPLETE file. Mirrors LazyFileDiff's per-mount + // fetch, but updates the existing CodeView item instead of mounting a fresh + // FileDiff — so CodeView's own virtualization + element pool stay in charge. + const augmentItem = useCallback((itemId: string) => { + // NOTE: deliberately no viewerRef check here. The FIRST onPostRender wave + // (every initially visible item) fires synchronously inside CodeView's seed + // layout effect, which runs BEFORE useImperativeHandle assigns the handle — + // so viewerRef.current is still null at that point. Bailing on a null + // handle would make the initial window depend entirely on CodeView's + // second (rAF `fitPerfectly`) render wave for augmentation — a library + // implementation detail we'd rather not lean on. The handle is only needed + // at fetch RESOLUTION, where it is re-read fresh from the ref. + const augmentState = augmentRef.current; + const generation = fileSetKeyRef.current; + // One fetch per item PER DIFF: a same-generation entry ('pending' or + // resolved) means do nothing — an item re-entering the rendered window + // re-fires onPostRender, and this guard is what prevents the fetch storm. + // An entry from a PREVIOUS diff (stale generation) does not count: abort it + // and fetch fresh for the new diff's content. + const existing = augmentState.get(itemId); + if (existing) { + if (existing.generation === generation) return; + existing.controller.abort(); + } + + // Resolve the file by item id (NOT path) so duplicate display paths each + // augment with their own DiffFile content. + const file = itemIdToFileRef.current.get(itemId); + if (file == null) return; + + const controller = new AbortController(); + augmentState.set(itemId, { status: 'pending', controller, generation }); + + // A resolution stage is stale when its fetch was aborted (unmount / diff + // switch) or the diff generation moved on while the response was in flight + // (abort() is a no-op on an already-settled fetch, and the remounted + // CodeView reuses path-derived item ids — without the generation check the + // OLD diff's content would be written into the NEW diff's item). Stale + // stages must not touch augmentState either: it now belongs to the new + // generation. + const isStale = () => + controller.signal.aborted || fileSetKeyRef.current !== generation; + + // Workspace-prefixed paths are passed through verbatim — /api/file-content + // resolves the prefix back to the owning repo (same contract LazyFileDiff / + // DiffViewer rely on). + const params = new URLSearchParams({ path: file.path }); + if (file.oldPath) params.set('oldPath', file.oldPath); + const base = reviewBaseRef.current; + if (base) params.set('base', base); + + fetch(`/api/file-content?${params}`, { signal: controller.signal }) + .then((res) => (res.ok ? res.json() : null)) + .then((data: { oldContent: string | null; newContent: string | null } | null) => { + if (isStale()) return; + if (!data || (data.oldContent == null && data.newContent == null)) { + // No content available (e.g. demo mode / binary): mark done so we do + // not retry on every subsequent render. The raw-patch context still + // shows; there is just nothing to expand. + augmentState.set(itemId, { status: 'done', controller, generation }); + return; + } + + // Stale-content guard: the file may have changed on disk since this + // diff was captured (an agent editing/committing mid-review is normal + // usage). Augmenting with contents that no longer reconcile with the + // patch produces an internally inconsistent FileDiffMetadata — Pierre's + // virtualization then fails layout estimation for the item ("trailing + // context mismatch", content disappearing while scrolling). Keep the + // raw-patch view for this file instead; a diff refresh re-augments. + if (!isContentConsistentWithPatch(file.patch, data.oldContent, data.newContent)) { + console.warn( + `AllFilesCodeView: skipping full-content expansion for ${file.path} — file changed since the diff was captured`, + ); + augmentState.set(itemId, { status: 'done', controller, generation }); + return; + } + + let augmented: FileDiffMetadata; + try { + const result = processFile(file.patch, { + oldFile: + data.oldContent != null + ? { name: file.oldPath || file.path, contents: data.oldContent } + : undefined, + newFile: + data.newContent != null ? { name: file.path, contents: data.newContent } : undefined, + }); + if (!result) { + augmentState.set(itemId, { status: 'done', controller, generation }); + return; + } + augmented = result; + } catch { + augmentState.set(itemId, { status: 'error', controller, generation }); + return; + } + + // Re-check right before the write: processFile above is synchronous but + // an abort / diff switch could have landed since the first check. + if (isStale()) return; + const liveHandle = viewerRef.current; + const item = liveHandle?.getItem(itemId); + // The item may have been torn down between fetch start and resolution; + // belt-and-suspenders on top of the staleness check above. + if (liveHandle == null || item == null || item.type !== 'diff') { + augmentState.set(itemId, { status: 'done', controller, generation }); + return; + } + + // cacheKey MUST change when fileDiff contents change (types.ts warning): + // otherwise the worker / highlight caches would serve the stale partial + // AST. Derive a fresh key from the augmented (now full-content) diff, + // scoped by generation so the same item id across diff switches never + // collides in a (future) cross-mount worker cache. + augmented.cacheKey = `${generation}::${itemId}#full`; + item.fileDiff = augmented; + item.version = (item.version ?? 0) + 1; + // updateItem re-measures the (now taller) item and resolves the captured + // scroll anchor, so the viewport stays put whether this item is above or + // below the fold — no manual scroll correction needed. + liveHandle.updateItem(item); + augmentState.set(itemId, { status: 'done', controller, generation }); + }) + .catch((err) => { + if (isStale()) { + // Aborted (unmount / diff switch) or superseded: drop the entry only + // if it is still ours — a newer generation may already own this id. + if (augmentState.get(itemId)?.controller === controller) { + augmentState.delete(itemId); + } + return; + } + augmentState.set(itemId, { status: 'error', controller, generation }); + void err; + }); + }, []); + + // (Re)apply search marks for ONE item's node. Called on every render of that + // item (onPostRender mount/update) so marks survive CodeView's element + // recycling — a recycled element is cleared and re-marked for whatever file it + // now shows. `node` is the item's `` element. Reads search + // state through refs so the stable onPostRender callback stays identity-stable. + const applyItemHighlights = useCallback((node: HTMLElement, itemId: string) => { + const matches = matchesByItemIdRef.current.get(itemId) ?? []; + applyItemSearchHighlights(node, searchQueryRef.current, matches, activeSearchMatchIdRef.current); + }, []); + + // CodeView fires onPostRender for an item whenever it enters / updates within + // the rendered window. Phase 'mount' (and 'update' for the first paint of a + // freshly-seeded item) is the direct analogue of LazyFileDiff's + // IntersectionObserver firing — so we trigger augmentation there. We ride + // CodeView's existing virtualization rather than layering our own observer on + // top (which would double-virtualize and fight the element pool). + // + // P6: the same per-item render cycle drives search-mark reconciliation. On + // mount/update we (re)apply this item's marks (defends against recycling); on + // unmount we clear them so a future reuse of the element starts clean. Marks + // are reapplied via rAF so they land after CodeView has (re)written the item's + // line DOM for this render — applying synchronously here could mark a tree + // that's about to be overwritten. + // Element -> owning item id, maintained by onPostRender below. CodeView + // recycles elements across items, so this is re-registered + // on every mount/update and dropped on unmount. + const nodeToItemIdRef = useRef(new WeakMap()); + + const handlePostRender = useStableCallback( + ( + node: HTMLElement, + _instance: unknown, + phase: PostRenderPhase, + context: CodeViewItem, + ) => { + if (context.type !== 'diff') return; + if (phase === 'unmount') { + clearItemSearchHighlights(node); + nodeToItemIdRef.current.delete(node); + return; + } + // Track which item currently owns this element so the + // text-drag selection handler can resolve file identity from the + // selection's shadow-root host. Registered on every mount/update because + // CodeView recycles elements across items. + nodeToItemIdRef.current.set(node, context.id); + augmentItem(context.id); + const itemId = context.id; + requestAnimationFrame(() => applyItemHighlights(node, itemId)); + }, + ); + + // Parity with DiffViewer: dragging a text selection across multiple lines of + // diff CONTENT (not the line-number gutter) opens the annotation toolbar for + // that range. CodeView's enableLineSelection only starts drags from the + // number column, so without this the all-files surface would silently lose + // the select-code-text-to-annotate interaction the single-file panel has. + // The owning file comes from the selection's shadow-root host element (each + // item renders into its own ), mapped via nodeToItemIdRef. + const handleContentTextSelection = useStableCallback(() => { + requestAnimationFrame(() => { + const root = scrollRef.current; + const selection = getDiffSelection(root); + if (!selection || selection.isCollapsed || !selection.toString().trim()) return; + const anchorLine = getLineNumberFromNode(selection.anchorNode); + const focusLine = getLineNumberFromNode(selection.focusNode); + if (anchorLine == null || focusLine == null) return; + // Single-line drags keep native copy behavior (same rule as DiffViewer). + if (anchorLine === focusLine) return; + const rootNode = selection.anchorNode?.getRootNode(); + const host = rootNode instanceof ShadowRoot ? rootNode.host : null; + const itemId = host instanceof HTMLElement ? nodeToItemIdRef.current.get(host) : undefined; + if (itemId == null) return; + const filePath = itemIdToFilePath.get(itemId); + if (filePath == null) return; + routeSelectionToToolbar( + { + start: Math.min(anchorLine, focusLine), + end: Math.max(anchorLine, focusLine), + side: getSideFromNode(selection.anchorNode), + }, + filePath, + ); + selection.removeAllRanges(); + }); + }); + + // (Re)attach on fileSetKey: the CodeView remount recreates the container + // element scrollRef points at, dropping any previously-attached listener. + useEffect(() => { + const root = scrollRef.current; + if (!root) return; + const handler = () => handleContentTextSelection(); + root.addEventListener('mouseup', handler, true); + return () => root.removeEventListener('mouseup', handler, true); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [fileSetKey]); + + // Abort all in-flight content fetches on unmount. + useEffect(() => { + const augmentState = augmentRef.current; + return () => { + for (const { controller } of augmentState.values()) controller.abort(); + augmentState.clear(); + }; + }, []); + + // When the query or the match set changes (but no item re-render is triggered), + // re-apply marks across every currently-rendered item. onPostRender only fires + // when an item mounts/updates/recycles, so a pure query change wouldn't repaint + // existing rows without this. We read live rendered items from the viewer (each + // carries its `` element) and apply each item's own matches. + // rAF defers one frame so any pending CodeView render settles first. + useEffect(() => { + const handle = viewerRef.current; + if (handle == null) return; + const raf = requestAnimationFrame(() => { + const viewer = viewerRef.current?.getInstance(); + if (viewer == null) return; + for (const rendered of viewer.getRenderedItems()) { + if (rendered.type !== 'diff' || rendered.element == null) continue; + applyItemHighlights(rendered.element, rendered.id); + } + }); + return () => cancelAnimationFrame(raf); + }, [searchQuery, matchesByItemId, applyItemHighlights]); + + // O(1) active-match swap when stepping between matches: recolor just the + // previously-active and newly-active marks across the whole container instead + // of rebuilding every item's marks. Mirrors DiffViewer's swap effect. + useEffect(() => { + const container = scrollRef.current; + if (container == null) return; + swapActiveSearchHighlight(container, activeSearchMatchId); + }, [activeSearchMatchId]); + + // Navigate to the active match: expand its owning file (if collapsed) and + // scrollTo the line. scrollTo is DOM-independent (resolves the line top from + // CodeView's layout model), so it works even when the target row is far + // outside the rendered window — the line's marks then paint via onPostRender as + // CodeView renders the row. rAF defers the scroll one frame so an expand's + // layout settles before resolving the line top. + // Gated on isActive (also a dep): scrolling while this panel is HIDDEN + // resolves layout against a display:none container and there is no retry — + // bail instead, and the isActive flip re-runs this effect so a match + // selected while the panel was hidden scrolls once the panel is shown. + useEffect(() => { + if (!isActive) return; + if (activeSearchMatch == null) return; + const itemId = filePathToItemId.get(activeSearchMatch.filePath); + if (itemId == null) return; + const handle = viewerRef.current; + if (handle == null) return; + + const item = handle.getItem(itemId); + if (item != null && item.collapsed === true) { + item.collapsed = false; + item.version = (item.version ?? 0) + 1; + handle.updateItem(item); + } + + // ReviewSearchSide: 'addition' -> additions, 'deletion' -> deletions, + // 'context' -> additions (context rows carry the NEW-side line number in the + // search index, so the additions side resolves the correct row). + const side: 'additions' | 'deletions' = + activeSearchMatch.side === 'deletion' ? 'deletions' : 'additions'; + const lineNumber = activeSearchMatch.lineNumber; + const raf = requestAnimationFrame(() => { + const viewer = viewerRef.current; + if (viewer == null) return; + viewer.scrollTo({ type: 'line', id: itemId, lineNumber, side, align: 'center' }); + }); + return () => cancelAnimationFrame(raf); + }, [activeSearchMatch, filePathToItemId, isActive]); + + // --- Annotations through CodeView item state (P4) --------------------------- + + // Set an item's annotations to the current per-file projection, bump version, + // and updateItem. Mirrors Diffshub's updateViewerDiffItem (getItem, mutate, + // version++, updateItem) but rebuilds the whole annotation array from the + // source-of-truth `annotations` rather than splicing a single entry — the diff + // is computed at the item granularity by the sync effect below, so only files + // whose annotation set actually changed get an updateItem. + const syncItemAnnotations = useCallback( + (filePath: string, itemId: string, allAnnotations: CodeAnnotation[]) => { + const handle = viewerRef.current; + const item = handle?.getItem(itemId); + if (handle == null || item == null || item.type !== 'diff') return; + item.annotations = projectFileAnnotations(allAnnotations, filePath, prUrl, prDiffScope); + item.version = (item.version ?? 0) + 1; + handle.updateItem(item); + }, + [prUrl, prDiffScope], + ); + + // Whenever the `annotations` prop changes, re-project per file and updateItem + // ONLY on the files whose annotation set changed (so a single add/edit/delete + // re-renders just its owning file, never the whole CodeView). Diff is keyed on + // a per-file annotation signature so unrelated files are untouched. New diffs + // remount CodeView via fileSetKey and seed annotations at build time, so the + // diff-switch reset effect resynchronizes prevAnnotationsRef to avoid a + // spurious full refresh right after a remount. + useEffect(() => { + const handle = viewerRef.current; + const prev = prevAnnotationsRef.current; + prevAnnotationsRef.current = annotations; + if (handle == null || prev === annotations) return; + + // Per-file annotation signature: id|line|side|content fingerprint. We only + // need to know whether a file's gutter annotations changed, so a stable + // string built from the fields that affect rendering is sufficient and far + // cheaper than deep-equality of the projected objects. + const signatures = (list: CodeAnnotation[]) => { + const map = new Map(); + for (const a of list) { + if ((a.scope ?? 'line') !== 'line') continue; + if (a.prUrl && prUrl && a.prUrl !== prUrl) continue; + if (a.diffScope && prDiffScope && a.diffScope !== prDiffScope) continue; + const sig = JSON.stringify([ + a.id, a.lineEnd, a.side, a.type, + a.text ?? '', a.suggestedCode ?? '', a.originalCode ?? '', + a.conventionalLabel ?? '', (a.decorations ?? []).join(','), + a.severity ?? '', a.reasoning ?? '', a.author ?? '', + ]); + map.set(a.filePath, `${map.get(a.filePath) ?? ''}${sig}\n`); + } + return map; + }; + + const nextSig = signatures(annotations); + const prevSig = signatures(prev); + const changedPaths = new Set(); + nextSig.forEach((sig, path) => { + if (prevSig.get(path) !== sig) changedPaths.add(path); + }); + prevSig.forEach((_sig, path) => { + if (!nextSig.has(path)) changedPaths.add(path); + }); + + for (const path of changedPaths) { + // Fan out to ALL items rendering this path (duplicate display paths + // produce twins; updating only the canonical first item would leave the + // twin rendering deleted/stale annotations until the next remount). + for (const itemId of filePathToItemIds.get(path) ?? []) { + syncItemAnnotations(path, itemId, annotations); + } + } + }, [annotations, prUrl, prDiffScope, filePathToItemIds, syncItemAnnotations]); + + // --- Header actions --------------------------------------------------------- + + const handleToggleViewedAndCollapse = useStableCallback((filePath: string, itemId: string) => { + const wasViewed = viewedFiles?.has(filePath) ?? false; + onToggleViewed?.(filePath); + // Mark-as-viewed also collapses (legacy behavior); un-viewing leaves it. + // collapseItem bumps the version + updateItem so the header re-renders to + // the viewed state. Un-viewing performs no collapse, so it would otherwise + // skip the version bump and leave the (now stale) Viewed badge on screen — + // force a header refresh so the Viewed button reverts both ways. + if (!wasViewed) { + collapseItem(itemId); + } else { + refreshItem(itemId); + } + }); + + const handleFileComment = useStableCallback((filePath: string, anchorEl: HTMLElement) => { + fileCommentButtonRefs.current.set(filePath, anchorEl); + setFileCommentAnchor({ el: anchorEl, filePath }); + }); + + // Header chrome (Viewed badge, staging spinner / Added checkmark, stage-error + // text) is driven by external React props, but the custom header is rendered + // into Pierre's slot portal which only republishes on updateItem — never when + // a stable render callback's captured props change. So whenever any of those + // header-driving props change, force a re-render of every affected item. + // + // Direct paths (the `a` key and the header Git Add button both call + // onStage(filePath) without bumping any version; the header Viewed button's + // un-view branch likewise) are all covered here, so the header stays in sync + // regardless of which surface triggered the change. We track the previous + // snapshots (declared with the other refs above) and refresh exactly the + // items whose state actually changed. + useEffect(() => { + const handle = viewerRef.current; + if (handle == null) { + // Update snapshots even when no viewer is mounted yet so the first real + // diff doesn't refresh everything spuriously. + prevViewedRef.current = viewedFiles; + prevStagedRef.current = stagedFiles; + prevStagingRef.current = stagingFile; + prevStageErrorRef.current = stageError; + return; + } + + const changedPaths = new Set(); + const collectSetDelta = ( + next: Set | undefined, + prev: Set | undefined, + ) => { + if (next === prev) return; + next?.forEach((p) => { + if (!prev?.has(p)) changedPaths.add(p); + }); + prev?.forEach((p) => { + if (!next?.has(p)) changedPaths.add(p); + }); + }; + + collectSetDelta(viewedFiles, prevViewedRef.current); + collectSetDelta(stagedFiles, prevStagedRef.current); + // stagingFile / stageError are single-file scalars: the file that just + // started/stopped staging (or whose error appeared/cleared) needs a refresh. + if (stagingFile !== prevStagingRef.current) { + if (stagingFile) changedPaths.add(stagingFile); + if (prevStagingRef.current) changedPaths.add(prevStagingRef.current); + } + if (stageError !== prevStageErrorRef.current) { + // stageError is shown on the file currently/last staging, so refresh that + // file in both the appear and clear directions. + if (stagingFile) changedPaths.add(stagingFile); + if (prevStagingRef.current) changedPaths.add(prevStagingRef.current); + } + + prevViewedRef.current = viewedFiles; + prevStagedRef.current = stagedFiles; + prevStagingRef.current = stagingFile; + prevStageErrorRef.current = stageError; + + for (const path of changedPaths) { + // All twins of a duplicate path share viewed/staged state (it's keyed by + // path), so refresh every item rendering it. + for (const itemId of filePathToItemIds.get(path) ?? []) { + refreshItem(itemId); + } + } + }, [viewedFiles, stagedFiles, stagingFile, stageError, filePathToItemIds, refreshItem]); + + // --- Line selection through CodeView (replaces geometry-based inference) --- + + const handleSelectedLinesChange = useStableCallback( + (selection: CodeViewLineSelection | null) => { + setSelectedLines(selection); + onLineSelection(selection ? selection.range : null); + }, + ); + + // Mirror ref so the pendingSelection effect below can compare against the + // live CodeView selection without re-running on every drag delta. + const selectedLinesRef = useRef(selectedLines); + selectedLinesRef.current = selectedLines; + + // Reconcile the App-level `pendingSelection` (the range the toolbar / AI is + // operating on) with CodeView's highlighted lines. CodeView selection is + // CONTROLLED here, and `onSelectedLinesChange` fires on EVERY drag delta — + // each delta already paints `selectedLines` on the owning item (correct id) + // AND publishes the range to App. So when pendingSelection matches the live + // selection, this effect must do NOTHING: re-deriving the highlight from + // `activeFilePath` mid-drag would clear it (activeFilePath only updates at + // pointer-up) or paint it on the previously-active file. It only acts on: + // 1. pendingSelection cleared (annotation submitted / cancelled / AI done) + // → drop the highlight instead of leaving it stuck on the file. + // 2. A toolbar-originated range CodeView doesn't know about (gutter-utility + // click on a not-yet-active file, draft restore) → paint it on the + // active file's item. + useEffect(() => { + if (pendingSelection == null) { + setSelectedLines(null); + return; + } + const current = selectedLinesRef.current; + if ( + current != null && + current.range.start === pendingSelection.start && + current.range.end === pendingSelection.end && + current.range.side === pendingSelection.side + ) { + // Selection originated inside CodeView — already on the right item. + return; + } + if (activeFilePath) { + const itemId = filePathToItemId.get(activeFilePath); + if (itemId != null) { + setSelectedLines({ id: itemId, range: pendingSelection }); + } + } + }, [activeFilePath, pendingSelection, filePathToItemId]); + + const handleLineSelectionEnd = useStableCallback( + (range: SelectedLineRange | null, item: CodeViewItem) => { + if (range == null || item.type !== 'diff') return; + const filePath = itemIdToFilePath.get(item.id); + if (filePath == null) return; + routeSelectionToToolbar(range, filePath); + }, + ); + + const handleGutterUtilityClick = useStableCallback( + (range: SelectedLineRange, item: CodeViewItem) => { + if (item.type !== 'diff') return; + const filePath = itemIdToFilePath.get(item.id); + if (filePath == null) return; + routeSelectionToToolbar(range, filePath); + }, + ); + + // --- Token code navigation (P7) --------------------------------------------- + // Cmd/Ctrl-click a token resolves symbol defs/refs (parity with DiffViewer and + // the legacy all-files view). File identity comes from the owning item, not an + // active-file side channel. Only wired when onCodeNavRequest is provided. + const handleTokenClick = useStableCallback( + (props: DiffTokenEventBaseProps, event: MouseEvent, item: CodeViewItem) => { + if (!onCodeNavRequest || item.type !== 'diff') return; + if (!(event.metaKey || event.ctrlKey)) return; + const filePath = itemIdToFilePath.get(item.id); + if (filePath == null) return; + onCodeNavRequest(buildCodeNavRequest(props, filePath)); + }, + ); + + const handleTokenEnter = useStableCallback( + (props: DiffTokenEventBaseProps, event: PointerEvent) => { + if (onCodeNavRequest && (event.metaKey || event.ctrlKey)) { + props.tokenElement.classList.add('pn-token-nav'); + } + }, + ); + + const handleTokenLeave = useStableCallback((props: DiffTokenEventBaseProps) => { + props.tokenElement.classList.remove('pn-token-nav'); + }); + + // --- Active-file tracking via CodeView rendered items (no header geometry) --- + + const reportVisibleFile = useStableCallback(() => { + const viewer = viewerRef.current?.getInstance(); + if (viewer == null) return; + const rendered = viewer.getRenderedItems(); + if (rendered.length === 0) return; + const scrollTop = viewer.getScrollTop(); + // The active file is the last rendered item whose top is at or above the + // current scroll position (with a small threshold), i.e. the file the user + // is currently reading. Falls back to the first rendered item. + let bestId = rendered[0].id; + for (const renderedItem of rendered) { + const top = viewer.getTopForItem(renderedItem.id); + if (top == null) continue; + if (top <= scrollTop + 50) bestId = renderedItem.id; + } + // At-bottom override (legacy parity): a short final file pinned at the + // container bottom never gets its top above scrollTop+threshold, so the + // loop would leave an earlier file active while the user reads the last + // one. DOM scrollTop/scrollHeight are consistent with each other even + // under CodeView's paged scroll rebasing, so this check is safe. + const container = scrollRef.current; + if ( + container != null && + container.scrollTop + container.clientHeight >= container.scrollHeight - 2 + ) { + bestId = rendered[rendered.length - 1].id; + } + const path = itemIdToFilePath.get(bestId) ?? null; + if (path !== visibleFileRef.current) { + visibleFileRef.current = path; + onVisibleFileChange?.(path); + } + }); + + const handleScroll = useStableCallback(() => { + reportVisibleFile(); + }); + + // CodeView's onScroll only fires on actual scroll, so seed the initial + // active-file highlight once the viewer has rendered its first window. rAF + // gives CodeView a frame to mount + measure before we read rendered items. + // Re-runs on `fileSetKey` because a diff switch remounts CodeView, so the + // new diff's first file must be re-reported as the active file. + useEffect(() => { + const raf = requestAnimationFrame(() => reportVisibleFile()); + return () => cancelAnimationFrame(raf); + }, [reportVisibleFile, fileSetKey]); + + // --- [/]/z/v/a/c/x navigation + header actions driven by CodeView ---------- + + const scrollToItem = useCallback((itemId: string) => { + const viewer = viewerRef.current; + if (viewer == null) return; + viewer.scrollTo({ type: 'item', id: itemId, align: 'start' }); + }, []); + + // --- Selected-annotation navigation (P4) ----------------------------------- + + // Selecting an annotation in the sidebar must expand its owning file (if + // collapsed) and scroll to it. We expand via item state (collapsed=false + + // version bump + updateItem — the Diffshub pattern), then scrollTo the + // annotation's line range so it lands in view. rAF defers the scroll one frame + // so the expand's layout has settled before CodeView resolves the line top. + // `annotations` is read through the ref, NOT the dep list: this must fire only + // when the SELECTION changes. With `annotations` as a dep, any annotation + // change while one is selected (add/edit/delete elsewhere, an external SSE + // annotation arriving) re-runs the effect and yanks the viewport back to the + // selected annotation with zero user action. + useEffect(() => { + if (!selectedAnnotationId) return; + const ann = annotationsRef.current.find((a) => a.id === selectedAnnotationId); + if (!ann) return; + const itemId = filePathToItemId.get(ann.filePath); + if (itemId == null) return; + const handle = viewerRef.current; + if (handle == null) return; + + const item = handle.getItem(itemId); + if (item != null && item.collapsed === true) { + item.collapsed = false; + item.version = (item.version ?? 0) + 1; + handle.updateItem(item); + } + + const start = Math.min(ann.lineStart, ann.lineEnd); + const end = Math.max(ann.lineStart, ann.lineEnd); + const side = ann.side === 'new' ? ('additions' as const) : ('deletions' as const); + const raf = requestAnimationFrame(() => { + const viewer = viewerRef.current; + if (viewer == null) return; + viewer.scrollTo({ type: 'range', id: itemId, range: { start, end, side } }); + }); + return () => cancelAnimationFrame(raf); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [selectedAnnotationId, filePathToItemId]); + + useEffect(() => { + if (!isActive) return; + const handler = (e: KeyboardEvent) => { + // composedPath()[0] pierces shadow DOM: window-level e.target retargets + // to the shadow HOST (e.g. ), which would hide a + // typeable element living inside a shadow root from this guard. + const el = (e.composedPath?.()[0] ?? e.target) as HTMLElement | null; + if ( + el && + (el.tagName === 'INPUT' || + el.tagName === 'TEXTAREA' || + el.tagName === 'SELECT' || + el.isContentEditable) + ) + return; + if (e.metaKey || e.ctrlKey || e.shiftKey || e.altKey) return; + if (orderedItemIds.length === 0) return; + + // The item the user is currently reading (active-file tracking). + const currentId = visibleFileRef.current + ? filePathToItemId.get(visibleFileRef.current) ?? null + : null; + const currentPath = currentId ? itemIdToFilePath.get(currentId) ?? null : null; + + // x — collapse/expand the current file. + if (e.key === 'x' && currentId) { + e.preventDefault(); + toggleItemCollapsed(currentId); + return; + } + + // z — re-expand + scroll to a collapsed file. Legacy used a collapse + // history stack; we approximate with the nearest collapsed item AT or + // BEFORE the current position in visual order (the file you most likely + // just collapsed), falling back to the nearest one after it. + if (e.key === 'z') { + const collapsedIds = orderedItemIds.filter((id) => isItemCollapsed(id)); + if (collapsedIds.length === 0) return; + e.preventDefault(); + const currentIdx = currentId ? orderedItemIds.indexOf(currentId) : -1; + const target = + [...collapsedIds] + .reverse() + .find((id) => orderedItemIds.indexOf(id) <= currentIdx) ?? collapsedIds[0]; + toggleItemCollapsed(target); + scrollToItem(target); + return; + } + + // c — open the file-scoped comment popover for the current file. The + // anchor element comes from the eager fileCommentButtonRef registration; + // isConnected guards against an element whose header was recycled out of + // the rendered window between registration and keypress. + if (e.key === 'c' && currentPath && onAddFileCommentForFile) { + e.preventDefault(); + const btn = fileCommentButtonRefs.current.get(currentPath); + if (btn?.isConnected) setFileCommentAnchor({ el: btn, filePath: currentPath }); + return; + } + + // v — toggle viewed (and collapse on mark-viewed) for the current file. + if (e.key === 'v' && currentPath && currentId) { + e.preventDefault(); + handleToggleViewedAndCollapse(currentPath, currentId); + return; + } + + // a — stage/unstage the current file. + if (e.key === 'a' && currentPath && canStageFiles) { + e.preventDefault(); + onStage?.(currentPath); + return; + } + + if (e.key !== '[' && e.key !== ']') return; + e.preventDefault(); + + const currentIdx = currentId ? orderedItemIds.indexOf(currentId) : -1; + let targetIdx: number; + if (e.key === ']') { + targetIdx = currentIdx < orderedItemIds.length - 1 ? currentIdx + 1 : orderedItemIds.length - 1; + } else { + targetIdx = currentIdx > 0 ? currentIdx - 1 : 0; + } + + scrollToItem(orderedItemIds[targetIdx]); + }; + window.addEventListener('keydown', handler); + return () => window.removeEventListener('keydown', handler); + }, [ + isActive, + orderedItemIds, + filePathToItemId, + itemIdToFilePath, + scrollToItem, + toggleItemCollapsed, + isItemCollapsed, + onAddFileCommentForFile, + handleToggleViewedAndCollapse, + canStageFiles, + onStage, + ]); + + // --- Custom header render slot (the full Plannotator FileHeader) ----------- + + const renderCustomHeader = useStableCallback((item: CodeViewItem) => { + if (item.type !== 'diff') return null; + const filePath = itemIdToFilePath.get(item.id); + if (filePath == null) return null; + // Resolve by item id (NOT files.find by path): duplicate display paths each + // have their own DiffFile, and a path lookup would render the FIRST file's + // stats on every duplicate's header. + const file = itemIdToFile.get(item.id); + if (file == null) return null; + + const collapsed = item.collapsed === true; + + return ( + handleToggleViewedAndCollapse(filePath, item.id) : undefined} + isStaged={stagedFiles?.has(filePath)} + isStaging={stagingFile === filePath} + onStage={onStage ? () => onStage(filePath) : undefined} + canStage={canStageFiles} + stageError={stagingFile === filePath ? stageError : null} + onFileComment={onAddFileCommentForFile ? (anchorEl) => handleFileComment(filePath, anchorEl) : undefined} + // Eager registration so the `c` shortcut can anchor the popover for a + // file whose button was never clicked. Detach (null) deletes the entry + // — React detaches the old ref before attaching the new one in the + // same commit, so a slot republish never leaves the map stale. + fileCommentButtonRef={ + onAddFileCommentForFile + ? (el) => { + if (el) fileCommentButtonRefs.current.set(filePath, el); + else fileCommentButtonRefs.current.delete(filePath); + } + : undefined + } + collapseToggle={ + + } + onCollapseToggle={() => toggleItemCollapsed(item.id)} + /> + ); + }); + + // Pass-through allowlist only (CODE_VIEW_DIFF_OPTION_KEYS). hunkSeparators, + // stickyHeaders, itemMetrics, and the selection callbacks are CodeView-level + // options. The selection/gutter callbacks receive a context whose `.item` is + // the owning CodeViewItem, which is how file identity flows without geometry + // inference. itemMetrics must reflect the custom header height and the + // unsafeCSS-customized hunk separator height (see constants above), otherwise + // CodeView's virtualization estimate drifts. + // usePierreTheme forces `line-height: 1.5` ONLY when a custom font size is + // set. In that case CodeView's pre-measure row-height estimate must match + // (fontPx * 1.5) or virtualization/scroll estimates drift. With no custom + // size, Pierre's default lineHeight estimate is correct — leave it unset. + const customLineHeight = useMemo(() => { + if (!fontSize) return undefined; + const px = parseFloat(fontSize); + return Number.isFinite(px) && px > 0 ? Math.round(px * 1.5) : undefined; + }, [fontSize]); + + const options = useMemo>( + () => ({ + themeType: pierreTheme.type, + unsafeCSS: pierreTheme.css, + ...(pierreTheme.syntaxTheme && { theme: pierreTheme.syntaxTheme }), + diffStyle, + overflow: diffOverflow, + diffIndicators, + lineDiffType, + disableLineNumbers, + disableBackground, + expandUnchanged, + enableLineSelection: true, + enableGutterUtility: true, + hunkSeparators: 'line-info', + stickyHeaders: true, + itemMetrics: { + diffHeaderHeight: PANEL_HEADER_HEIGHT, + hunkSeparatorHeight: HUNK_SEPARATOR_HEIGHT, + ...(customLineHeight != null && { lineHeight: customLineHeight }), + }, + // Dev-only safety net for the hand-maintained itemMetrics above: Pierre + // compares its virtualization estimates against measured DOM heights and + // warns on drift. Doubly gated — the option only takes effect when the + // library itself runs a development build (NODE_ENV), so it is inert in + // production even if the flag leaks through. + ...(import.meta.env.DEV && { __devOnlyValidateItemHeights: true }), + onLineSelectionEnd(range, context) { + handleLineSelectionEnd(range, context.item); + }, + onGutterUtilityClick(range, context) { + handleGutterUtilityClick(range, context.item); + }, + // P7: token code navigation. CodeView appends the owning-item context as + // the final arg to every shared callback (same as the selection/gutter + // callbacks), so file identity comes from context.item — no geometry or + // active-file inference. Only wired when onCodeNavRequest is provided. + ...(onCodeNavRequest && { + onTokenClick(props, event, context) { + handleTokenClick(props, event, context.item); + }, + onTokenEnter(props, event, _context) { + handleTokenEnter(props, event); + }, + onTokenLeave(props, _event, _context) { + handleTokenLeave(props); + }, + }), + // P5: lazily augment an item with full file content when it enters the + // rendered window. P6: (re)apply / clear search marks per item so they + // survive recycling. CodeView appends the item context as the final arg. + onPostRender(node, _instance, phase, context) { + handlePostRender(node, _instance, phase, context.item); + }, + }), + [ + pierreTheme.type, + pierreTheme.css, + pierreTheme.syntaxTheme, + diffStyle, + diffOverflow, + diffIndicators, + lineDiffType, + disableLineNumbers, + disableBackground, + expandUnchanged, + customLineHeight, + handleLineSelectionEnd, + handleGutterUtilityClick, + onCodeNavRequest, + handleTokenClick, + handleTokenEnter, + handleTokenLeave, + handlePostRender, + ], + ); + + return ( +
+ + // Remount on diff switch so uncontrolled `initialItems` re-seeds from + // the freshly computed identity. Without this, switching diff + // type/base/whitespace/PR with the all-files panel open would keep the + // OLD diff on screen (the panel instance is reused, not recreated). + key={fileSetKey} + ref={viewerRef} + containerRef={scrollRef} + className="h-full overflow-auto" + initialItems={identity.items} + options={options} + selectedLines={selectedLines} + onSelectedLinesChange={handleSelectedLinesChange} + onScroll={handleScroll} + renderCustomHeader={renderCustomHeader} + renderAnnotation={renderAnnotation} + /> + + + + {fileCommentAnchor && onAddFileCommentForFile && ( + { + onAddFileCommentForFile(fileCommentAnchor.filePath, text); + setFileCommentAnchor(null); + }} + onClose={() => setFileCommentAnchor(null)} + /> + )} +
+ ); +}; diff --git a/packages/review-editor/components/AllFilesDiffView.tsx b/packages/review-editor/components/AllFilesDiffView.tsx deleted file mode 100644 index 5f6ea917..00000000 --- a/packages/review-editor/components/AllFilesDiffView.tsx +++ /dev/null @@ -1,555 +0,0 @@ -import React, { useMemo, useCallback, useRef, useState, useEffect } from 'react'; -import { type DiffLineAnnotation } from '@pierre/diffs/react'; -import { getSingularPatch } from '@pierre/diffs'; -import { CodeAnnotation, CodeAnnotationType, SelectedLineRange, DiffAnnotationMetadata, TokenAnnotationMeta, ConventionalLabel, ConventionalDecoration } from '@plannotator/ui/types'; -import type { DiffTokenEventBaseProps } from '@pierre/diffs'; -import { buildCodeNavRequest } from '../utils/buildCodeNavRequest'; -import { usePierreTheme } from '../hooks/usePierreTheme'; -import { LazyFileDiff } from './LazyFileDiff'; -import { ToolbarHost, type ToolbarHostHandle } from './ToolbarHost'; -import { InlineAnnotation } from './InlineAnnotation'; -import { FileHeader } from './FileHeader'; -import { CommentPopover } from '@plannotator/ui/components/CommentPopover'; -import { detectLanguage } from '../utils/detectLanguage'; -import type { DiffFile } from '../types'; -import { buildFileTree, getVisualFileOrder } from '../utils/buildFileTree'; -import { getLineNumberFromNode, getSideFromNode, getDiffSelection } from '../utils/diffSelection'; -import type { AIChatEntry } from '../hooks/useAIChat'; - -interface AllFilesDiffViewProps { - files: DiffFile[]; - annotations: CodeAnnotation[]; - selectedAnnotationId: string | null; - pendingSelection: SelectedLineRange | null; - onLineSelection: (range: SelectedLineRange | null) => void; - onAddAnnotation: (filePath: string, type: CodeAnnotationType, text?: string, suggestedCode?: string, originalCode?: string, conventionalLabel?: ConventionalLabel, decorations?: ConventionalDecoration[], tokenMeta?: TokenAnnotationMeta) => void; - onAddFileComment?: (filePath: string, text: string) => void; - onEditAnnotation: (id: string, text?: string, suggestedCode?: string, originalCode?: string, conventionalLabel?: ConventionalLabel | null, decorations?: ConventionalDecoration[]) => void; - onSelectAnnotation: (id: string | null) => void; - onDeleteAnnotation: (id: string) => void; - diffStyle: 'split' | 'unified'; - diffOverflow?: 'scroll' | 'wrap'; - diffIndicators?: 'bars' | 'classic' | 'none'; - lineDiffType?: 'word-alt' | 'word' | 'char' | 'none'; - disableLineNumbers?: boolean; - disableBackground?: boolean; - expandUnchanged?: boolean; - fontFamily?: string; - fontSize?: string; - viewedFiles: Set; - onToggleViewed?: (filePath: string) => void; - stagedFiles?: Set; - onStage?: (filePath: string) => void; - canStageFiles?: boolean; - stagingFile?: string | null; - stageError?: string | null; - reviewBase?: string; - prUrl?: string; - prDiffScope?: string; - onVisibleFileChange?: (filePath: string | null) => void; - isActive?: boolean; - // AI props - aiAvailable?: boolean; - onAskAI?: (question: string) => void; - isAILoading?: boolean; - onViewAIResponse?: (questionId?: string) => void; - aiHistoryForSelection?: AIChatEntry[]; - onCodeNavRequest?: (request: import('@plannotator/shared/code-nav').CodeNavRequest) => void; -} - -export const AllFilesDiffView: React.FC = ({ - files, - annotations, - selectedAnnotationId, - pendingSelection, - onLineSelection, - onAddAnnotation, - onAddFileComment, - onEditAnnotation, - onSelectAnnotation, - onDeleteAnnotation, - diffStyle, - diffOverflow, - diffIndicators, - lineDiffType, - disableLineNumbers, - disableBackground, - expandUnchanged, - fontFamily, - fontSize, - viewedFiles, - onToggleViewed, - stagedFiles, - onStage, - canStageFiles, - stagingFile, - stageError, - reviewBase, - prUrl, - prDiffScope, - onVisibleFileChange, - isActive = true, - aiAvailable = false, - onAskAI, - isAILoading = false, - onViewAIResponse, - aiHistoryForSelection = [], - onCodeNavRequest, -}) => { - const pierreTheme = usePierreTheme({ fontFamily, fontSize }); - const [activeFilePath, setActiveFilePath] = useState(null); - const pendingToolbarRange = useRef(null); - const [collapsedFiles, setCollapsedFiles] = useState>(new Set()); - const collapseHistory = useRef([]); - const [fileCommentAnchor, setFileCommentAnchor] = useState<{ el: HTMLElement; filePath: string } | null>(null); - - useEffect(() => { - setActiveFilePath(null); - setCollapsedFiles(new Set()); - collapseHistory.current = []; - setFileCommentAnchor(null); - }, [files]); - - const scrollRef = useRef(null); - const headerRefs = useRef>(new Map()); - - const activePatch = useMemo( - () => files.find(f => f.path === activeFilePath)?.patch ?? '', - [files, activeFilePath], - ); - - const handleAddAnnotation = useCallback(( - type: CodeAnnotationType, - text?: string, - suggestedCode?: string, - originalCode?: string, - conventionalLabel?: ConventionalLabel, - decorations?: ConventionalDecoration[], - tokenMeta?: TokenAnnotationMeta, - ) => { - if (!activeFilePath) return; - onAddAnnotation(activeFilePath, type, text, suggestedCode, originalCode, conventionalLabel, decorations, tokenMeta); - }, [activeFilePath, onAddAnnotation]); - - const toolbarHostRef = useRef(null); - - useEffect(() => { - if (pendingToolbarRange.current && activePatch) { - toolbarHostRef.current?.handleLineSelectionEnd(pendingToolbarRange.current); - pendingToolbarRange.current = null; - } - }, [activePatch]); - - const handleEdit = useCallback((id: string) => { - const ann = annotations.find(a => a.id === id); - if (!ann) return; - toolbarHostRef.current?.startEdit(ann); - }, [annotations]); - - const visualOrder = useMemo(() => { - const tree = buildFileTree(files); - return getVisualFileOrder(tree); - }, [files]); - - const sortedFiles = useMemo(() => visualOrder.map(i => files[i]), [files, visualOrder]); - - const baseDiffs = useMemo(() => { - const map = new Map>(); - for (const file of files) map.set(file.path, getSingularPatch(file.patch)); - return map; - }, [files]); - - const toggleCollapse = useCallback((filePath: string) => { - setCollapsedFiles(prev => { - const next = new Set(prev); - if (next.has(filePath)) { - next.delete(filePath); - } else { - next.add(filePath); - collapseHistory.current.push(filePath); - if (activeFilePath === filePath) setActiveFilePath(null); - } - return next; - }); - }, [activeFilePath]); - - const collapseFile = useCallback((filePath: string) => { - if (!collapsedFiles.has(filePath)) { - collapseHistory.current.push(filePath); - setCollapsedFiles(prev => { - const next = new Set(prev); - next.add(filePath); - return next; - }); - } - if (activeFilePath === filePath) setActiveFilePath(null); - }, [activeFilePath, collapsedFiles]); - - const toggleViewedAndCollapse = useCallback((filePath: string) => { - const isCurrentlyViewed = viewedFiles.has(filePath); - onToggleViewed?.(filePath); - if (!isCurrentlyViewed) collapseFile(filePath); - }, [collapseFile, onToggleViewed, viewedFiles]); - - const setHeaderRef = useCallback((path: string, el: HTMLDivElement | null) => { - if (el) headerRefs.current.set(path, el); - else headerRefs.current.delete(path); - }, []); - - // Scroll tracking for file tree highlight - const visibleFileRef = useRef(null); - useEffect(() => { - if (!onVisibleFileChange) return; - const container = scrollRef.current; - if (!container) return; - const onScroll = () => { - const containerTop = container.getBoundingClientRect().top; - const expandedFiles = sortedFiles.filter(f => !collapsedFiles.has(f.path)); - let bestPath: string | null = expandedFiles[0]?.path ?? null; - const atBottom = container.scrollTop + container.clientHeight >= container.scrollHeight - 20; - if (atBottom && expandedFiles.length > 0) { - bestPath = expandedFiles[expandedFiles.length - 1].path; - } else { - for (const file of expandedFiles) { - const header = headerRefs.current.get(file.path); - if (!header) continue; - const rect = header.getBoundingClientRect(); - if (rect.top <= containerTop + 50) bestPath = file.path; - else break; - } - } - if (bestPath !== visibleFileRef.current) { - visibleFileRef.current = bestPath; - onVisibleFileChange(bestPath); - } - }; - onScroll(); - container.addEventListener('scroll', onScroll, { passive: true }); - return () => container.removeEventListener('scroll', onScroll); - }, [onVisibleFileChange, sortedFiles, collapsedFiles]); - - // Keyboard shortcuts for all-files mode: [/] scroll, v viewed+collapse, a stage, c collapse - useEffect(() => { - if (!isActive) return; - const handler = (e: KeyboardEvent) => { - const el = e.target as HTMLElement | null; - if (el && (el.tagName === 'INPUT' || el.tagName === 'TEXTAREA' || el.isContentEditable)) return; - if (e.metaKey || e.ctrlKey || e.shiftKey) return; - - const currentPath = activeFilePath || visibleFileRef.current; - - if (e.key === 'z') { - // Find the most recent entry that's actually still collapsed - let last: string | undefined; - while (collapseHistory.current.length > 0) { - const candidate = collapseHistory.current.pop()!; - if (collapsedFiles.has(candidate)) { last = candidate; break; } - } - if (!last) return; - e.preventDefault(); - setCollapsedFiles(prev => { - const next = new Set(prev); - next.delete(last!); - return next; - }); - setActiveFilePath(last); - setTimeout(() => { - const header = headerRefs.current.get(last!); - header?.scrollIntoView({ behavior: 'smooth', block: 'nearest' }); - }, 50); - return; - } - - if (e.key === 'x' && currentPath) { - e.preventDefault(); - toggleCollapse(currentPath); - return; - } - - if (e.key === 'c' && currentPath && onAddFileComment) { - e.preventDefault(); - const header = headerRefs.current.get(currentPath); - if (header) { - const btn = header.querySelector('[title="Add file-scoped comment"]'); - if (btn) { - setFileCommentAnchor({ el: btn, filePath: currentPath }); - } - } - return; - } - - if (e.key === 'v' && currentPath) { - e.preventDefault(); - toggleViewedAndCollapse(currentPath); - return; - } - - if (e.key === 'a' && currentPath && canStageFiles) { - e.preventDefault(); - onStage?.(currentPath); - return; - } - - if (e.key !== '[' && e.key !== ']') return; - - e.preventDefault(); - const expandedFiles = sortedFiles.filter(f => !collapsedFiles.has(f.path)); - if (expandedFiles.length === 0) return; - - const currentIdx = currentPath ? expandedFiles.findIndex(f => f.path === currentPath) : -1; - - let targetIdx: number; - if (e.key === ']') { - targetIdx = currentIdx < expandedFiles.length - 1 ? currentIdx + 1 : expandedFiles.length - 1; - } else { - targetIdx = currentIdx > 0 ? currentIdx - 1 : 0; - } - - const targetFile = expandedFiles[targetIdx]; - const header = headerRefs.current.get(targetFile.path); - if (header) { - header.scrollIntoView({ behavior: 'smooth', block: 'start' }); - setActiveFilePath(targetFile.path); - } - }; - window.addEventListener('keydown', handler); - return () => window.removeEventListener('keydown', handler); - }, [isActive, sortedFiles, collapsedFiles, activeFilePath, toggleCollapse, toggleViewedAndCollapse, canStageFiles, onStage, onAddFileComment]); - - // Click-and-drag line selection in diff content - useEffect(() => { - const root = scrollRef.current; - if (!root) return; - const handler = () => { - requestAnimationFrame(() => { - const selection = getDiffSelection(root); - if (!selection || selection.isCollapsed || !selection.toString().trim()) return; - const anchorLine = getLineNumberFromNode(selection.anchorNode); - const focusLine = getLineNumberFromNode(selection.focusNode); - if (anchorLine == null || focusLine == null) return; - if (anchorLine === focusLine) return; - const side = getSideFromNode(selection.anchorNode); - // Determine which file the selection is in by checking header positions - const anchorRect = selection.getRangeAt(0).getBoundingClientRect(); - let closestFile: string | null = null; - for (const file of sortedFiles) { - const header = headerRefs.current.get(file.path); - if (header && header.getBoundingClientRect().top <= anchorRect.top) { - closestFile = file.path; - } - } - if (closestFile) { - if (activeFilePath !== closestFile) { - pendingToolbarRange.current = { - start: Math.min(anchorLine, focusLine), - end: Math.max(anchorLine, focusLine), - side, - }; - setActiveFilePath(closestFile); - selection.removeAllRanges(); - return; - } - setActiveFilePath(closestFile); - } - toolbarHostRef.current?.handleLineSelectionEnd({ - start: Math.min(anchorLine, focusLine), - end: Math.max(anchorLine, focusLine), - side, - }); - selection.removeAllRanges(); - }); - }; - root.addEventListener('mouseup', handler, true); - return () => root.removeEventListener('mouseup', handler, true); - }, [sortedFiles, activeFilePath]); - - // Scroll to selected annotation — auto-expand collapsed file - useEffect(() => { - if (!selectedAnnotationId) return; - const ann = annotations.find(a => a.id === selectedAnnotationId); - if (!ann) return; - setCollapsedFiles(prev => { - if (!prev.has(ann.filePath)) return prev; - const next = new Set(prev); - next.delete(ann.filePath); - return next; - }); - requestAnimationFrame(() => { - const header = headerRefs.current.get(ann.filePath); - header?.scrollIntoView({ behavior: 'smooth', block: 'start' }); - }); - }, [selectedAnnotationId, annotations]); - - return ( -
- {sortedFiles.map(file => { - const isCollapsed = collapsedFiles.has(file.path); - const fileAnnotations = annotations - .filter(a => - a.filePath === file.path && - (a.scope ?? 'line') === 'line' && - (!a.prUrl || !prUrl || a.prUrl === prUrl) && - (!a.diffScope || !prDiffScope || a.diffScope === prDiffScope) - ) - .map(ann => ({ - side: ann.side === 'new' ? 'additions' as const : 'deletions' as const, - lineNumber: ann.lineEnd, - metadata: { - annotationId: ann.id, - type: ann.type, - text: ann.text, - suggestedCode: ann.suggestedCode, - originalCode: ann.originalCode, - author: ann.author, - severity: ann.severity, - reasoning: ann.reasoning, - conventionalLabel: ann.conventionalLabel, - decorations: ann.decorations, - } as DiffAnnotationMetadata, - })); - - return ( -
-
setHeaderRef(file.path, el)} - > - toggleViewedAndCollapse(file.path) : undefined} - isStaged={stagedFiles?.has(file.path)} - isStaging={stagingFile === file.path} - onStage={onStage ? () => onStage(file.path) : undefined} - canStage={canStageFiles} - stageError={stagingFile === file.path ? stageError : null} - collapseToggle={ - - } - onCollapseToggle={() => toggleCollapse(file.path)} - onFileComment={onAddFileComment ? (anchorEl) => setFileCommentAnchor({ el: anchorEl, filePath: file.path }) : undefined} - /> -
- {!isCollapsed && ( - a.metadata.annotationId === selectedAnnotationId)} - options={{ - themeType: pierreTheme.type, - unsafeCSS: pierreTheme.css, - ...(pierreTheme.syntaxTheme && { theme: pierreTheme.syntaxTheme }), - diffStyle, - overflow: diffOverflow, - diffIndicators, - lineDiffType, - disableLineNumbers, - disableBackground, - expandUnchanged, - hunkSeparators: 'line-info', - enableLineSelection: true, - enableGutterUtility: true, - onGutterUtilityClick: (range: SelectedLineRange) => { - if (activeFilePath === file.path) { - toolbarHostRef.current?.handleLineSelectionEnd(range); - } else { - pendingToolbarRange.current = range; - setActiveFilePath(file.path); - } - }, - onLineSelectionEnd: (range: SelectedLineRange | null) => { - if (range) { - if (activeFilePath === file.path) { - toolbarHostRef.current?.handleLineSelectionEnd(range); - } else { - pendingToolbarRange.current = range; - setActiveFilePath(file.path); - } - } - onLineSelection(range); - }, - ...(onCodeNavRequest && { - onTokenClick: (props: DiffTokenEventBaseProps, event: MouseEvent) => { - if (event.metaKey || event.ctrlKey) { - onCodeNavRequest(buildCodeNavRequest(props, file.path)); - } - }, - onTokenEnter: (props: DiffTokenEventBaseProps, event: PointerEvent) => { - if (event.metaKey || event.ctrlKey) { - props.tokenElement.classList.add('pn-token-nav'); - } - }, - onTokenLeave: (props: DiffTokenEventBaseProps) => { - props.tokenElement.classList.remove('pn-token-nav'); - }, - }), - }} - annotations={fileAnnotations} - selectedLines={activeFilePath === file.path ? (pendingSelection || undefined) : undefined} - renderAnnotation={(annotation: DiffLineAnnotation) => { - if (!annotation.metadata) return null; - return ( - - ); - }} - /> - )} -
- ); - })} - - - - {fileCommentAnchor && onAddFileComment && ( - { - onAddFileComment(fileCommentAnchor.filePath, text); - setFileCommentAnchor(null); - }} - onClose={() => setFileCommentAnchor(null)} - /> - )} -
- ); -}; diff --git a/packages/review-editor/components/DiffOptionsPopover.tsx b/packages/review-editor/components/DiffOptionsPopover.tsx index b871dadf..500c90a3 100644 --- a/packages/review-editor/components/DiffOptionsPopover.tsx +++ b/packages/review-editor/components/DiffOptionsPopover.tsx @@ -103,10 +103,10 @@ export const DiffOptionsPopover: React.FC = () => {
); diff --git a/packages/review-editor/components/FileTree.tsx b/packages/review-editor/components/FileTree.tsx index c503f44d..962aa96d 100644 --- a/packages/review-editor/components/FileTree.tsx +++ b/packages/review-editor/components/FileTree.tsx @@ -233,7 +233,8 @@ export const FileTree: React.FC = ({ return (