feat(review): migrate all-files code review to Pierre CodeView (#885)

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.
This commit is contained in:
Michael Ramos
2026-06-10 21:07:58 -07:00
committed by GitHub
parent 4b76db9396
commit afac44b940
38 changed files with 2871 additions and 753 deletions
+1
View File
@@ -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? }`) |
+1 -1
View File
@@ -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": {
+2
View File
@@ -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,
+1
View File
@@ -32,6 +32,7 @@ export {
detectVcs,
getGitContext,
getVcsContext,
getVcsDiffFingerprint,
getVcsFileContentsForDiff,
prepareLocalReviewDiff,
resolveInitialDiffType,
+74
View File
@@ -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<string | null> => {
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)) {
+1
View File
@@ -98,6 +98,7 @@ export const {
prepareLocalReviewDiff,
runVcsDiff,
getVcsFileContentsForDiff,
getVcsDiffFingerprint,
canStageFiles,
stageFile,
unstageFile,
+1 -1
View File
@@ -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",
+11 -11
View File
@@ -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=="],
+1 -1
View File
@@ -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",
+127 -23
View File
@@ -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<CodeAnnotation[]>([]);
const [selectedAnnotationId, setSelectedAnnotationId] = useState<string | null>(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<string | null>(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 = () => {
</div>
)}
{/* Diff staleness notice — files changed since this snapshot
was computed (agent editing mid-review). Non-blocking; the
user refreshes when ready. */}
{diffFreshness.isStale && !isLoadingDiff && (
<div className="flex items-center gap-2 text-xs text-amber-700 dark:text-amber-300 px-2 py-1 bg-amber-500/10 rounded border border-amber-500/25">
<span className="hidden md:inline">Diff out of date</span>
<span className="md:hidden">Stale</span>
<button
onClick={handleRefreshStaleDiff}
className="font-medium underline underline-offset-2 hover:text-amber-900 dark:hover:text-amber-100 transition-colors"
title="Re-run the diff with the current settings"
>
Refresh
</button>
<button
onClick={diffFreshness.dismiss}
className="text-amber-700/60 dark:text-amber-300/60 hover:text-amber-900 dark:hover:text-amber-100 transition-colors leading-none"
title="Dismiss"
aria-label="Dismiss stale diff notice"
>
×
</button>
</div>
)}
{/* Agent mode: Close/SendFeedback flip + Approve */}
{!platformMode ? (
<AgentReviewActions
@@ -2182,6 +2281,11 @@ const ReviewApp: React.FC = () => {
</button>
)}
{/* 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. */}
<DiffOptionsPopover />
<div className="w-px h-5 bg-border/50 mx-1 hidden md:block" />
{/* Sidebar tab toggles */}
File diff suppressed because it is too large Load Diff
@@ -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<string>;
onToggleViewed?: (filePath: string) => void;
stagedFiles?: Set<string>;
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<AllFilesDiffViewProps> = ({
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<string | null>(null);
const pendingToolbarRange = useRef<SelectedLineRange | null>(null);
const [collapsedFiles, setCollapsedFiles] = useState<Set<string>>(new Set());
const collapseHistory = useRef<string[]>([]);
const [fileCommentAnchor, setFileCommentAnchor] = useState<{ el: HTMLElement; filePath: string } | null>(null);
useEffect(() => {
setActiveFilePath(null);
setCollapsedFiles(new Set());
collapseHistory.current = [];
setFileCommentAnchor(null);
}, [files]);
const scrollRef = useRef<HTMLDivElement>(null);
const headerRefs = useRef<Map<string, HTMLDivElement>>(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<ToolbarHostHandle>(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<string, ReturnType<typeof getSingularPatch>>();
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<string | null>(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<HTMLElement>('[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 (
<div className="h-full overflow-auto" ref={scrollRef}>
{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 (
<div key={file.path}>
<div
className="sticky top-0 z-10 bg-card border-t border-border/30 first:border-t-0"
ref={(el) => setHeaderRef(file.path, el)}
>
<FileHeader
filePath={file.path}
patch={file.patch}
isViewed={viewedFiles.has(file.path)}
onToggleViewed={onToggleViewed ? () => 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={
<button
onClick={(e) => { e.stopPropagation(); toggleCollapse(file.path); }}
className="flex items-center justify-center w-6 h-6 rounded hover:bg-foreground/10 transition-colors flex-shrink-0"
>
<svg
className={`w-3 h-3 transition-transform ${isCollapsed ? '' : 'rotate-90'}`}
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
strokeWidth={2}
>
<path strokeLinecap="round" strokeLinejoin="round" d="M9 5l7 7-7 7" />
</svg>
</button>
}
onCollapseToggle={() => toggleCollapse(file.path)}
onFileComment={onAddFileComment ? (anchorEl) => setFileCommentAnchor({ el: anchorEl, filePath: file.path }) : undefined}
/>
</div>
{!isCollapsed && (
<LazyFileDiff
file={file}
baseDiff={baseDiffs.get(file.path)!}
scrollRoot={scrollRef.current}
reviewBase={reviewBase}
diffStyle={diffStyle}
forceMount={selectedAnnotationId != null && fileAnnotations.some(a => 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<DiffAnnotationMetadata>) => {
if (!annotation.metadata) return null;
return (
<InlineAnnotation
metadata={annotation.metadata}
language={detectLanguage(file.path)}
onSelect={onSelectAnnotation}
onEdit={handleEdit}
onDelete={onDeleteAnnotation}
/>
);
}}
/>
)}
</div>
);
})}
<ToolbarHost
ref={toolbarHostRef}
patch={activePatch}
filePath={activeFilePath ?? ''}
isFocused={true}
onLineSelection={onLineSelection}
onAddAnnotation={handleAddAnnotation}
onEditAnnotation={onEditAnnotation}
aiAvailable={aiAvailable}
onAskAI={onAskAI}
isAILoading={isAILoading}
onViewAIResponse={onViewAIResponse}
aiHistoryMessages={aiHistoryForSelection}
/>
{fileCommentAnchor && onAddFileComment && (
<CommentPopover
key={`file:${prUrl ?? ''}:${prDiffScope ?? ''}:${fileCommentAnchor.filePath}`}
anchorEl={fileCommentAnchor.el}
contextText={fileCommentAnchor.filePath.split('/').pop() || fileCommentAnchor.filePath}
isGlobal={false}
draftKey={`file:${prUrl ?? ''}:${prDiffScope ?? ''}:${fileCommentAnchor.filePath}`}
onSubmit={(text) => {
onAddFileComment(fileCommentAnchor.filePath, text);
setFileCommentAnchor(null);
}}
onClose={() => setFileCommentAnchor(null)}
/>
)}
</div>
);
};
@@ -103,10 +103,10 @@ export const DiffOptionsPopover: React.FC = () => {
<Popover.Root>
<Popover.Trigger asChild>
<button
className="text-xs text-muted-foreground hover:text-foreground rounded hover:bg-muted transition-colors flex items-center px-1.5 py-1"
className="p-1.5 rounded-md text-muted-foreground hover:text-foreground hover:bg-muted transition-all flex items-center data-[state=open]:bg-primary/15 data-[state=open]:text-primary"
title="Diff display options"
>
<svg className="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.066 2.573c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.573 1.066c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.066-2.573c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z" />
<path strokeLinecap="round" strokeLinejoin="round" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
</svg>
@@ -13,6 +13,7 @@ import { OverlayScrollArea } from '@plannotator/ui/components/OverlayScrollArea'
import { useOverlayViewport } from '@plannotator/ui/hooks/useOverlayViewport';
import { FileHeader } from './FileHeader';
import { getLineNumberFromNode, getSideFromNode, getDiffSelection } from '../utils/diffSelection';
import { isContentConsistentWithPatch } from '../utils/patchConsistency';
import { InlineAnnotation } from './InlineAnnotation';
import { InlineAIMarker } from './InlineAIMarker';
import type { AIChatEntry } from '../hooks/useAIChat';
@@ -307,6 +308,16 @@ export const DiffViewer: React.FC<DiffViewerProps> = ({
// against the complete file (isPartial: false), enabling expansion.
const augmentedDiff = useMemo(() => {
if (!fileContents || fileContents.forPath !== filePath || (fileContents.old == null && fileContents.new == null)) return fileDiff;
// Stale-content guard (same as AllFilesCodeView): the file may have
// changed on disk since the diff was captured — augmenting with contents
// that don't reconcile with the patch breaks Pierre's line math. Fall back
// to the raw patch for this file.
if (!isContentConsistentWithPatch(patch, fileContents.old, fileContents.new)) {
console.warn(
`DiffViewer: skipping full-content expansion for ${filePath} — file changed since the diff was captured`,
);
return fileDiff;
}
try {
const result = processFile(patch, {
oldFile: fileContents.old != null ? { name: oldPath || filePath, contents: fileContents.old } : undefined,
@@ -1,5 +1,4 @@
import React, { useEffect, useRef, useState } from 'react';
import { DiffOptionsPopover } from './DiffOptionsPopover';
import { SemanticFileBadge } from './SemanticFileBadge';
interface FileHeaderProps {
@@ -13,6 +12,13 @@ interface FileHeaderProps {
canStage?: boolean;
stageError?: string | null;
onFileComment?: (anchorEl: HTMLElement) => void;
/**
* Eager registration of the comment button element on mount/unmount (ref
* callback semantics: element on attach, null on detach). Lets keyboard
* shortcuts anchor the comment popover without the button ever being
* clicked. onFileComment alone only surfaces the element on click.
*/
fileCommentButtonRef?: (el: HTMLButtonElement | null) => void;
collapseToggle?: React.ReactNode;
onCollapseToggle?: () => void;
}
@@ -46,6 +52,7 @@ export const FileHeader: React.FC<FileHeaderProps> = ({
canStage = false,
stageError,
onFileComment,
fileCommentButtonRef,
collapseToggle,
onCollapseToggle,
}) => {
@@ -163,7 +170,10 @@ export const FileHeader: React.FC<FileHeaderProps> = ({
)}
{onFileComment && (
<button
ref={fileCommentRef}
ref={(el) => {
fileCommentRef.current = el;
fileCommentButtonRef?.(el);
}}
onClick={() => fileCommentRef.current && onFileComment(fileCommentRef.current)}
className={`text-xs rounded transition-colors flex items-center text-muted-foreground hover:text-foreground hover:bg-muted ${commentLabel ? 'gap-1 px-2 py-1' : 'px-1.5 py-1'}`}
title="Add file-scoped comment"
@@ -204,7 +214,6 @@ export const FileHeader: React.FC<FileHeaderProps> = ({
)}
</button>
<SemanticFileBadge filePath={filePath} />
<DiffOptionsPopover />
</div>
</div>
);
+8 -10
View File
@@ -233,7 +233,8 @@ export const FileTree: React.FC<FileTreeProps> = ({
return (
<aside className="border-r border-border/50 bg-card/30 flex flex-col flex-shrink-0 overflow-hidden" style={{ width: width ?? 256 }}>
{/* Header */}
{/* Header — panel label left, controls right. The viewed counter sits
immediately AFTER the hide-viewed eye toggle it relates to. */}
<div className="px-3 flex items-center border-b border-border/50" style={{ height: 'var(--panel-header-h)' }}>
<div className="w-full flex items-center justify-between">
<span className="text-xs font-semibold text-muted-foreground uppercase tracking-wider">
@@ -241,16 +242,10 @@ export const FileTree: React.FC<FileTreeProps> = ({
</span>
<div className="flex items-center gap-1.5">
{stagedFiles && stagedFiles.size > 0 && (
<>
<span className="text-xs text-primary font-medium">
{stagedFiles.size} added
</span>
<span className="text-muted-foreground/40">·</span>
</>
<span className="text-xs text-primary font-medium">
{stagedFiles.size} added
</span>
)}
<span className="text-xs text-muted-foreground">
{viewedFiles.size}/{files.length}
</span>
{onOpenSearch && (
<button
onClick={onOpenSearch}
@@ -298,6 +293,9 @@ export const FileTree: React.FC<FileTreeProps> = ({
)}
</button>
)}
<span className="text-xs text-muted-foreground">
{viewedFiles.size}/{files.length}
</span>
</div>
</div>
</div>
@@ -1,118 +0,0 @@
import React, { useState, useEffect, useRef, useMemo } from 'react';
import { FileDiff, type DiffLineAnnotation } from '@pierre/diffs/react';
import type { FileDiffMetadata } from '@pierre/diffs';
import { processFile } from '@pierre/diffs';
import type { DiffAnnotationMetadata, SelectedLineRange } from '@plannotator/ui/types';
import type { DiffFile } from '../types';
interface LazyFileDiffProps {
file: DiffFile;
baseDiff: FileDiffMetadata;
forceMount?: boolean;
scrollRoot: HTMLElement | null;
reviewBase?: string;
diffStyle: 'split' | 'unified';
options: Record<string, unknown>;
annotations: DiffLineAnnotation<DiffAnnotationMetadata>[];
selectedLines: SelectedLineRange | undefined;
renderAnnotation: (annotation: DiffLineAnnotation<DiffAnnotationMetadata>) => React.ReactNode;
}
function estimateHeight(fileDiff: FileDiffMetadata, diffStyle: 'split' | 'unified'): number {
const lineCount = diffStyle === 'split' ? fileDiff.splitLineCount : fileDiff.unifiedLineCount;
return (lineCount * 20) + (fileDiff.hunks.length * 32) + 8;
}
export const LazyFileDiff: React.FC<LazyFileDiffProps> = ({
file,
baseDiff,
forceMount = false,
scrollRoot,
reviewBase,
diffStyle,
options,
annotations,
selectedLines,
renderAnnotation,
}) => {
const [mounted, setMounted] = useState(forceMount);
const sentinelRef = useRef<HTMLDivElement>(null);
useEffect(() => {
if (forceMount && !mounted) setMounted(true);
}, [forceMount, mounted]);
useEffect(() => {
if (mounted) return;
const el = sentinelRef.current;
if (!el || !scrollRoot) return;
const observer = new IntersectionObserver(
([entry]) => {
if (entry.isIntersecting) {
setMounted(true);
observer.disconnect();
}
},
{ root: scrollRoot, rootMargin: '100% 0px 100% 0px', threshold: 0 },
);
observer.observe(el);
return () => observer.disconnect();
}, [mounted, scrollRoot]);
// Per-file content fetching (same pattern as DiffViewer.tsx)
const [fileContents, setFileContents] = useState<{ old: string | null; new: string | null } | null>(null);
useEffect(() => {
if (!mounted) return;
setFileContents(null);
const controller = new AbortController();
const params = new URLSearchParams({ path: file.path });
if (file.oldPath) params.set('oldPath', file.oldPath);
if (reviewBase) params.set('base', reviewBase);
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 (data && (data.oldContent != null || data.newContent != null)) {
setFileContents({ old: data.oldContent, new: data.newContent });
}
})
.catch(() => {});
return () => controller.abort();
}, [mounted, file.path, file.oldPath, file.patch, reviewBase]);
const fileDiff = useMemo(() => {
if (!fileContents) return baseDiff;
try {
const result = processFile(file.patch, {
oldFile: fileContents.old != null ? { name: file.oldPath || file.path, contents: fileContents.old } : undefined,
newFile: fileContents.new != null ? { name: file.path, contents: fileContents.new } : undefined,
});
return result || baseDiff;
} catch {
return baseDiff;
}
}, [file.patch, file.path, file.oldPath, fileContents, baseDiff]);
if (!mounted) {
return (
<div
ref={sentinelRef}
style={{ height: estimateHeight(baseDiff, diffStyle) }}
className="pb-2"
/>
);
}
return (
<div className="pb-2">
<FileDiff
key={file.path}
fileDiff={fileDiff}
options={options}
lineAnnotations={annotations}
selectedLines={selectedLines}
renderAnnotation={renderAnnotation}
/>
</div>
);
};
@@ -39,7 +39,22 @@ export const SemanticFileBadge: React.FC<{ filePath: string }> = ({ filePath })
};
const count = changes.length + binaryChanges.length;
if (!state || !available || loading || count === 0) return null;
if (!state || !available) return null;
// Sem is available but this file has no named changes (or is still
// resolving): show a disabled "sem 0" so every header carries the badge in
// the same spot — consistent look, aligned buttons, no popover.
if (loading || count === 0) {
return (
<span
className="semantic-file-badge semantic-file-badge-disabled"
title="No semantic changes in this file"
aria-disabled="true"
>
<span className="semantic-file-badge-label">sem</span>
<span className="semantic-file-badge-count">0</span>
</span>
);
}
const openChange = (change: SemanticDiffChange) => {
state.openDiffFile(change.filePath);
@@ -73,15 +73,25 @@ export interface ReviewState {
activeFileSearchMatches: ReviewSearchMatch[];
activeSearchMatchId: string | null;
activeSearchMatch: ReviewSearchMatch | null;
// All-files (CodeView) search surface: the full match set + the unfiltered
// active match (activeSearchMatch above is filtered to the single-file panel).
searchMatches: ReviewSearchMatch[];
allFilesActiveSearchMatch: ReviewSearchMatch | null;
// AI
aiAvailable: boolean;
aiMessages: AIChatEntry[];
onAskAI: (question: string) => void;
/** File-aware Ask AI for the all-files surface. onAskAI above resolves the
* file from the single-file panel's focus index, which is wrong when the
* selection lives in the all-files CodeView. */
onAskAIForFile: (filePath: string, question: string) => void;
isAILoading: boolean;
onViewAIResponse: (questionId?: string) => void;
onClickAIMarker: (questionId: string) => void;
aiHistoryForSelection: AIChatEntry[];
/** File-aware variant of aiHistoryForSelection (same single-file caveat). */
getAIHistoryForFile: (filePath: string) => AIChatEntry[];
// Agent jobs
agentJobs: AgentJobInfo[];
@@ -1,23 +1,14 @@
import React from 'react';
import type { IDockviewPanelProps } from 'dockview-react';
import { AllFilesDiffView } from '../../components/AllFilesDiffView';
import { AllFilesCodeView } from '../../components/AllFilesCodeView';
import { useReviewState } from '../ReviewStateContext';
export const ReviewAllFilesDiffPanel: React.FC<IDockviewPanelProps> = () => {
const state = useReviewState();
return (
<AllFilesDiffView
<AllFilesCodeView
files={state.files}
annotations={state.allAnnotations}
selectedAnnotationId={state.selectedAnnotationId}
pendingSelection={state.pendingSelection}
onLineSelection={state.onLineSelection}
onAddAnnotation={state.onAddAnnotationForFile}
onAddFileComment={state.onAddFileCommentForFile}
onEditAnnotation={state.onEditAnnotation}
onSelectAnnotation={state.onSelectAnnotation}
onDeleteAnnotation={state.onDeleteAnnotation}
diffStyle={state.diffStyle}
diffOverflow={state.diffOverflow}
diffIndicators={state.diffIndicators}
@@ -27,6 +18,16 @@ export const ReviewAllFilesDiffPanel: React.FC<IDockviewPanelProps> = () => {
expandUnchanged={state.expandUnchanged}
fontFamily={state.fontFamily}
fontSize={state.fontSize}
annotations={state.allAnnotations}
selectedAnnotationId={state.selectedAnnotationId}
pendingSelection={state.pendingSelection}
reviewBase={state.reviewBase}
onLineSelection={state.onLineSelection}
onAddAnnotationForFile={state.onAddAnnotationForFile}
onEditAnnotation={state.onEditAnnotation}
onSelectAnnotation={state.onSelectAnnotation}
onDeleteAnnotation={state.onDeleteAnnotation}
onAddFileCommentForFile={state.onAddFileCommentForFile}
viewedFiles={state.viewedFiles}
onToggleViewed={state.onToggleViewed}
stagedFiles={state.stagedFiles}
@@ -34,17 +35,23 @@ export const ReviewAllFilesDiffPanel: React.FC<IDockviewPanelProps> = () => {
canStageFiles={state.canStageFiles}
stagingFile={state.stagingFile}
stageError={state.stageError}
reviewBase={state.reviewBase}
prUrl={state.prMetadata?.url}
prDiffScope={state.prDiffScope}
// Debounced like ReviewDiffPanel: searchMatches derive from the
// debounced query, so painting marks from the raw query mid-debounce
// mismatches mark ids and re-walks every rendered item per keystroke.
searchQuery={state.isSearchPending ? '' : state.debouncedSearchQuery}
searchMatches={state.searchMatches}
activeSearchMatchId={state.activeSearchMatchId}
activeSearchMatch={state.allFilesActiveSearchMatch}
onCodeNavRequest={state.onCodeNavRequest}
onVisibleFileChange={state.onAllFilesVisibleFileChange}
isActive={state.isAllFilesActive}
aiAvailable={state.aiAvailable}
onAskAI={state.onAskAI}
onAskAIForFile={state.onAskAIForFile}
isAILoading={state.isAILoading}
onViewAIResponse={state.onViewAIResponse}
aiHistoryForSelection={state.aiHistoryForSelection}
onCodeNavRequest={state.onCodeNavRequest}
getAIHistoryForFile={state.getAIHistoryForFile}
/>
);
};
@@ -0,0 +1,89 @@
import { useCallback, useEffect, useState } from 'react';
const POLL_INTERVAL_MS = 5000;
export interface DiffFreshness {
/** True when the underlying files changed since the current diff snapshot
* was computed AND the user hasn't dismissed this particular staleness. */
isStale: boolean;
/** Hide the notice for the CURRENT staleness. A further change (different
* server fingerprint) re-shows it; a diff refresh resets everything. */
dismiss: () => void;
}
/**
* Polls `GET /api/diff/fresh` while the review is open. The server compares a
* cheap VCS fingerprint captured when the diff snapshot was computed against
* the repo's state NOW — files changing mid-review (the normal agent-editing-
* while-you-review workflow) flips `fresh` to false.
*
* Polling is timer-based on purpose: in this product the files change while
* the user is actively IN the review (an agent works underneath them), so a
* focus/visibility trigger would miss the case that matters. Ticks are
* skipped while the document is hidden, and the whole hook no-ops in demo
* mode (`enabled: false`).
*/
export function useDiffFreshness({
enabled,
resetKey,
}: {
enabled: boolean;
/** Identity of the current diff snapshot (e.g. the rawPatch string). A new
* snapshot (refresh / switch) clears staleness + dismissal and resumes. */
resetKey: string;
}): DiffFreshness {
const [staleFingerprint, setStaleFingerprint] = useState<string | null>(null);
const [dismissedFingerprint, setDismissedFingerprint] = useState<string | null>(null);
// New snapshot → clean slate.
useEffect(() => {
setStaleFingerprint(null);
setDismissedFingerprint(null);
}, [resetKey]);
useEffect(() => {
if (!enabled) return;
let cancelled = false;
let timer: ReturnType<typeof setTimeout> | null = null;
const schedule = () => {
if (!cancelled) timer = setTimeout(tick, POLL_INTERVAL_MS);
};
const tick = async () => {
// Nobody is looking — don't burn VCS commands on a hidden window.
if (document.hidden) {
schedule();
return;
}
try {
const res = await fetch('/api/diff/fresh');
if (!cancelled && res.ok) {
const data = (await res.json()) as { fresh: boolean; fingerprint?: string };
// Keep polling even while stale: a reverted edit flips back to
// fresh, and a FURTHER change updates the fingerprint so a
// dismissed notice can reappear.
setStaleFingerprint(data.fresh ? null : data.fingerprint ?? 'stale');
}
} catch {
// Transient/network/server-gone: ignore — staleness is best-effort.
}
schedule();
};
schedule();
return () => {
cancelled = true;
if (timer) clearTimeout(timer);
};
}, [enabled, resetKey]);
const dismiss = useCallback(() => {
setDismissedFingerprint(staleFingerprint);
}, [staleFingerprint]);
return {
isStale: staleFingerprint != null && staleFingerprint !== dismissedFingerprint,
dismiss,
};
}
+20 -1
View File
@@ -449,12 +449,16 @@ diffs-container {
padding: 0 0.5rem;
}
/* "sem · N" pill in a diff file header */
/* "sem · N" pill in a diff file header. Fixed min-width (and a fixed-width
count, below) so 1- vs 2-digit counts — and files with no badge at all,
via the ghost — never skew the header button column across files. */
.semantic-file-badge {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 0.3125rem;
height: 1.5rem;
min-width: 4.125rem;
padding: 0 0.4375rem;
border: 0;
border-radius: var(--radius-sm);
@@ -467,6 +471,19 @@ diffs-container {
transition: color 120ms ease, background 120ms ease;
}
/* Disabled "sem 0" badge for files with no semantic changes — same footprint
and structure as the live badge so every header looks consistent, but
grayed out and inert (no popover, no hover affordance). */
.semantic-file-badge-disabled {
cursor: default;
opacity: 0.45;
}
.semantic-file-badge-disabled .semantic-file-badge-count {
background: var(--muted);
color: var(--muted-foreground);
}
.semantic-file-badge:hover,
.semantic-file-badge[data-state="open"] {
color: var(--foreground);
@@ -484,11 +501,13 @@ diffs-container {
}
.semantic-file-badge-count {
min-width: 1.5em;
padding: 0.0625rem 0.3125rem;
border-radius: calc(var(--radius-sm) - 2px);
background: oklch(from var(--primary) l c h / 0.14);
color: var(--primary);
font-variant-numeric: tabular-nums;
text-align: center;
}
/* Hover popover — same terminal rows as the panel, shadcn surface */
+1 -1
View File
@@ -10,7 +10,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",
@@ -0,0 +1,58 @@
import { describe, expect, test } from 'bun:test';
import { isContentConsistentWithPatch } from './patchConsistency';
const lines = (n: number, prefix = 'line') =>
Array.from({ length: n }, (_, i) => `${prefix}${i + 1}`).join('\n') + '\n';
// A patch replacing 2 lines with 5 (net +3) somewhere in a 10-line file.
const NET_PLUS_3_PATCH = [
'diff --git a/foo.ts b/foo.ts',
'--- a/foo.ts',
'+++ b/foo.ts',
'@@ -4,4 +4,7 @@',
' line4',
'-line5',
'-line6',
'+new5',
'+new6',
'+new7',
'+new8',
'+new9',
' line7',
'',
].join('\n');
describe('isContentConsistentWithPatch', () => {
test('accepts contents whose totals match the patch net', () => {
// old: 10 lines, new: 13 lines, patch net: +3
expect(isContentConsistentWithPatch(NET_PLUS_3_PATCH, lines(10), lines(13))).toBe(true);
});
test('rejects stale contents (file changed since the diff was captured)', () => {
// new side drifted by +3 again (e.g. mid-review edit): 16 ≠ 10 + 3
expect(isContentConsistentWithPatch(NET_PLUS_3_PATCH, lines(10), lines(16))).toBe(false);
// old side drifted
expect(isContentConsistentWithPatch(NET_PLUS_3_PATCH, lines(8), lines(13))).toBe(false);
});
test('passes one-sided contents through (added / deleted files)', () => {
expect(isContentConsistentWithPatch(NET_PLUS_3_PATCH, null, lines(999))).toBe(true);
expect(isContentConsistentWithPatch(NET_PLUS_3_PATCH, lines(999), null)).toBe(true);
});
test('passes hunkless patches through', () => {
expect(isContentConsistentWithPatch('Binary files a/x and b/x differ\n', lines(1), lines(2))).toBe(true);
});
test('handles omitted hunk counts (single-line hunks)', () => {
// @@ -3 +3 @@ → oldCount=1, newCount=1, net 0
const patch = '--- a/f\n+++ b/f\n@@ -3 +3 @@\n-old\n+new\n';
expect(isContentConsistentWithPatch(patch, lines(5), lines(5))).toBe(true);
expect(isContentConsistentWithPatch(patch, lines(5), lines(7))).toBe(false);
});
test('is insensitive to trailing-newline differences', () => {
const tenNoTrailing = lines(10).slice(0, -1);
expect(isContentConsistentWithPatch(NET_PLUS_3_PATCH, tenNoTrailing, lines(13))).toBe(true);
});
});
@@ -0,0 +1,52 @@
/**
* Guard against augmenting a diff with STALE file contents.
*
* The review session captures its patch once (at launch / diff switch), but
* `/api/file-content` reads the file as it is NOW. The two can drift — e.g.
* an agent edits or commits a file while the user is mid-review. Feeding
* `processFile` a patch plus contents that no longer correspond produces a
* FileDiffMetadata whose hunk/context line math is internally inconsistent;
* Pierre's virtualization then fails layout estimation for the file
* ("trailing context mismatch" console errors, disappearing content while
* scrolling). Rendering the raw patch (no expand-context for that file) is
* the correct degradation.
*
* The check: the patch's hunk headers fully determine the expected line-count
* delta between the old and new file. If the fetched contents' totals don't
* reconcile with that delta, the contents are stale relative to the patch.
* (Same-length edits slip through, but those cannot break the count math —
* worst case is a few wrong context lines until the next diff refresh.)
*/
const HUNK_HEADER_RE = /^@@ -\d+(?:,(\d+))? \+\d+(?:,(\d+))? @@/gm;
function countLines(content: string): number {
if (content.length === 0) return 0;
const segments = content.split('\n').length;
// A trailing newline does not start an extra line (git semantics).
return content.endsWith('\n') ? segments - 1 : segments;
}
export function isContentConsistentWithPatch(
patch: string,
oldContent: string | null,
newContent: string | null,
): boolean {
// Only the both-sides case is verifiable from totals alone. One-sided
// fetches (added / deleted files) pass through — processFile receives a
// single file there and cannot produce a two-sided context mismatch.
if (oldContent == null || newContent == null) return true;
let net = 0;
let sawHunk = false;
for (const match of patch.matchAll(HUNK_HEADER_RE)) {
sawHunk = true;
const oldCount = match[1] != null ? Number(match[1]) : 1;
const newCount = match[2] != null ? Number(match[2]) : 1;
net += newCount - oldCount;
}
// No hunks → nothing to reconcile against (binary / metadata-only patch).
if (!sawHunk) return true;
return countLines(newContent) - countLines(oldContent) === net;
}
@@ -19,7 +19,9 @@ export function getSearchRoots(root: ParentNode): ParentNode[] {
while (current) {
if (current instanceof HTMLElement && current.shadowRoot) {
roots.push(current.shadowRoot);
// The recursive call already includes the shadow root itself as its
// first entry — pushing it here too would double every root (and
// double all clear/apply work over its subtree).
roots.push(...getSearchRoots(current.shadowRoot));
}
current = walker.nextNode() as Element | null;
@@ -28,6 +30,12 @@ export function getSearchRoots(root: ParentNode): ParentNode[] {
return roots;
}
// Item nodes that currently contain search marks. Lets the idle path (empty
// query / no matches for the item — i.e. every scroll frame while search is
// not in use) skip the full shadow-root TreeWalker without risking stale
// marks: only nodes this module marked can need clearing.
const markedItemNodes = new WeakSet<HTMLElement>();
export function clearSearchHighlights(root: ParentNode) {
const marks = root.querySelectorAll('mark[data-review-search-match]');
marks.forEach((mark) => {
@@ -155,6 +163,61 @@ export function applySearchHighlights(
});
}
/**
* CodeView variant of {@link applySearchHighlights}.
*
* CodeView renders every file's diff into a single light-DOM scroll container,
* but each visible item is its own `<diffs-container>` with its own shadow root,
* and CodeView recycles those item elements as you scroll (element pool). A
* one-shot `<mark>` mutation would therefore disappear when an element is reused,
* or stick to a reused row that now shows a different file/line.
*
* The fix is to (re)apply marks per ITEM, hooked into CodeView's per-item render
* cycle (`onPostRender`). `itemNode` is the item's container element handed to
* `onPostRender`; `matchesForItem` are the search matches that belong to the
* file this item renders (already filtered by the caller via the
* filePath -> itemId map). We first clear any stale marks inside this item's
* shadow root(s) (defends against recycling: the element may previously have
* shown a different file), then apply fresh marks for this item's matches.
*
* Marks are inline `<mark>` wrappers with zero height delta, so they never alter
* the measured row height (which would desync CodeView's itemMetrics).
*/
export function applyItemSearchHighlights(
itemNode: HTMLElement,
query: string,
matchesForItem: ReviewSearchMatch[],
activeSearchMatchId: string | null,
): void {
const trimmed = query.trim();
const idle = !trimmed || matchesForItem.length === 0;
// Idle fast path: this runs on EVERY item render (every scroll frame) — when
// search is not in use and this node was never marked, there is nothing to
// clear, so skip the shadow-root walk entirely.
if (idle && !markedItemNodes.has(itemNode)) return;
const roots = getSearchRoots(itemNode);
// Clear first so a node with stale marks (recycled element, query changed)
// is reset even when this item now has no matches.
for (const root of roots) clearSearchHighlights(root);
markedItemNodes.delete(itemNode);
if (idle) return;
for (const root of roots) {
applySearchHighlights(root, query, matchesForItem, activeSearchMatchId);
}
markedItemNodes.add(itemNode);
}
/** Clear all search marks inside a single CodeView item's element (used when an
* item leaves the rendered window so a future reuse starts clean). */
export function clearItemSearchHighlights(itemNode: HTMLElement): void {
if (!markedItemNodes.has(itemNode)) return;
const roots = getSearchRoots(itemNode);
for (const root of roots) clearSearchHighlights(root);
markedItemNodes.delete(itemNode);
}
export function swapActiveSearchHighlight(
container: HTMLElement,
newActiveId: string | null,
@@ -166,7 +229,10 @@ export function swapActiveSearchHighlight(
decorateSearchMatch(prev, false);
}
if (newActiveId) {
const next = root.querySelector(`mark[data-review-search-match="${newActiveId}"]`) as HTMLElement | null;
// Match ids embed file paths — CSS.escape so a path character that is
// special inside an attribute selector can't throw from querySelector
// and silently abort the active-match swap.
const next = root.querySelector(`mark[data-review-search-match="${CSS.escape(newActiveId)}"]`) as HTMLElement | null;
if (next) {
decorateSearchMatch(next, true);
}
@@ -211,7 +277,7 @@ export function scrollToSearchMatch(
const lineEl = root.querySelector(getLineSelector(match)) as HTMLElement | null;
if (!lineEl) return false;
const mark = root.querySelector(`mark[data-review-search-match="${match.id}"]`) as HTMLElement | null;
const mark = root.querySelector(`mark[data-review-search-match="${CSS.escape(match.id)}"]`) as HTMLElement | null;
scrollSearchTargetIntoContainer(scrollContainer, mark ?? lineEl);
mark?.scrollIntoView({ behavior: 'smooth', block: 'nearest', inline: 'nearest' });
return true;
+1 -1
View File
@@ -27,7 +27,7 @@
"*.ts"
],
"dependencies": {
"@pierre/diffs": "1.1.20",
"@pierre/diffs": "1.2.8",
"@plannotator/ai": "workspace:*",
"@plannotator/shared": "workspace:*"
},
+2
View File
@@ -1,6 +1,7 @@
import {
canStageFiles,
getVcsContext,
getVcsDiffFingerprint,
getVcsFileContentsForDiff,
runVcsDiff,
stageFile,
@@ -35,6 +36,7 @@ const workspaceRuntime = {
getVcsContext,
runVcsDiff,
getVcsFileContentsForDiff,
getVcsDiffFingerprint,
canStageFiles,
stageFile,
unstageFile,
+70 -1
View File
@@ -11,7 +11,7 @@
import { isRemoteSession, getServerHostname, getServerPort } from "./remote";
import type { Origin } from "@plannotator/shared/agents";
import { type DiffType, type GitContext, runVcsDiff, getVcsFileContentsForDiff, canStageFiles, stageFile, unstageFile, resolveVcsCwd, validateFilePath, getVcsContext, detectRemoteDefaultCompareTarget, gitRuntime } from "./vcs";
import { type DiffType, type GitContext, runVcsDiff, getVcsFileContentsForDiff, getVcsDiffFingerprint, canStageFiles, stageFile, unstageFile, resolveVcsCwd, validateFilePath, getVcsContext, detectRemoteDefaultCompareTarget, gitRuntime } from "./vcs";
import { basename } from "node:path";
import { parseWorktreeDiffType, resolveBaseBranch } from "@plannotator/shared/review-core";
import {
@@ -26,6 +26,7 @@ import {
import type { SemanticDiffAvailability, SemanticDiffResponse } from "@plannotator/shared/semantic-diff-types";
import {
getPRDiffScopeOptions,
getPRFullStackFingerprint,
getPRStackInfo,
resolveStackInfo,
resolvePRFullStackBaseRef,
@@ -187,6 +188,52 @@ export async function startReviewServer(
let currentBase = options.initialBase || detectedCompareTarget();
let baseEverSwitched = false;
// --- Diff staleness fingerprint -------------------------------------------
// Captured beside every patch snapshot (startup + every switch endpoint);
// GET /api/diff/fresh recomputes and compares so the client can show a
// "diff out of date — refresh" notice when files change mid-review (e.g. an
// agent editing/committing while the user reviews). Best-effort everywhere:
// null means "cannot fingerprint" and is reported as fresh, never stale.
let currentFingerprint: string | null = null;
const computeDiffFingerprint = async (): Promise<string | null> => {
try {
if (workspace) return await workspace.getFingerprint();
if (isPRMode) {
if (currentPRDiffScope === "layer") {
// Platform-computed diff — immutable locally. Recaptured on
// pr-switch; remote-side PR updates are out of scope here.
return `pr-layer:${prMetadata?.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 && prMetadata
? options.worktreePool.resolve(prMetadata.url)
: undefined) ?? options.agentCwd;
if (!prMetadata) return null;
return await getPRFullStackFingerprint(gitRuntime, prMetadata, fullStackCwd);
}
if (!hasLocalAccess) return null;
return await getVcsDiffFingerprint(currentDiffType as DiffType, currentBase, 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();
const resolveReviewBase = (requestedBase?: string): string => {
return resolveBaseBranch(requestedBase, detectedCompareTarget());
};
@@ -588,6 +635,23 @@ export async function startReviewServer(
});
}
// API: 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).
if (url.pathname === "/api/diff/fresh" && req.method === "GET") {
const baseline = currentFingerprint;
if (baseline == null) return Response.json({ fresh: true });
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) return Response.json({ fresh: true });
const fresh = probe == null || probe === baseline;
// The probe fingerprint lets the client distinguish "still the
// same staleness I dismissed" from "ANOTHER change landed since".
return Response.json({ fresh, ...(fresh ? {} : { fingerprint: probe }) });
}
// API: Get semantic diff content
if (url.pathname === "/api/semantic-diff" && req.method === "GET") {
return Response.json(await getSemanticDiff(url));
@@ -626,6 +690,7 @@ export async function startReviewServer(
currentDiffType = workspace.diffType;
currentError = snapshot.error;
draftKey = contentHash(currentPatch);
captureDiffFingerprint();
return Response.json({
rawPatch: currentPatch,
@@ -657,6 +722,7 @@ export async function startReviewServer(
currentBase = base;
baseEverSwitched = true;
currentError = result.error;
captureDiffFingerprint();
// Recompute gitContext for the effective cwd so the client's
// sidebar (current branch, default branch, diff-mode options)
@@ -711,6 +777,7 @@ export async function startReviewServer(
currentGitRef = originalPRGitRef;
currentError = originalPRError;
currentPRDiffScope = "layer";
captureDiffFingerprint();
return Response.json({
rawPatch: currentPatch,
gitRef: currentGitRef,
@@ -739,6 +806,7 @@ export async function startReviewServer(
currentGitRef = result.label;
currentError = undefined;
currentPRDiffScope = "full-stack";
captureDiffFingerprint();
return Response.json({
rawPatch: currentPatch,
@@ -808,6 +876,7 @@ export async function startReviewServer(
currentPRDiffScope = "layer";
draftKey = contentHash(pr.rawPatch);
prListCache = null;
captureDiffFingerprint();
// Recompute stack info
prStackInfo = getPRStackInfo(pr.metadata);
+1
View File
@@ -52,6 +52,7 @@ export const {
prepareLocalReviewDiff,
runVcsDiff,
getVcsFileContentsForDiff,
getVcsDiffFingerprint,
canStageFiles,
stageFile,
unstageFile,
+130
View File
@@ -0,0 +1,130 @@
import { afterAll, beforeAll, describe, expect, test } from "bun:test";
import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { getGitDiffFingerprint, type ReviewGitRuntime } from "./review-core";
// Real-git runtime against a throwaway repo — fingerprints are only meaningful
// against actual VCS behavior, so no mocks.
const runtime: ReviewGitRuntime = {
async runGit(args, options) {
const proc = Bun.spawn(["git", ...args], {
cwd: options?.cwd,
stdout: "pipe",
stderr: "pipe",
});
const [stdout, stderr] = await Promise.all([
new Response(proc.stdout).text(),
new Response(proc.stderr).text(),
]);
const exitCode = await proc.exited;
return { exitCode, stdout, stderr };
},
async readTextFile(path) {
try {
return await Bun.file(path).text();
} catch {
return null;
}
},
};
let repo: string;
async function git(...args: string[]): Promise<void> {
const result = await runtime.runGit(args, { cwd: repo });
if (result.exitCode !== 0) throw new Error(`git ${args.join(" ")}: ${result.stderr}`);
}
beforeAll(async () => {
repo = mkdtempSync(join(tmpdir(), "pn-fingerprint-"));
await git("init", "-b", "main");
await git("config", "user.email", "test@test");
await git("config", "user.name", "test");
writeFileSync(join(repo, "a.txt"), "one\ntwo\nthree\n");
await git("add", "-A");
await git("commit", "-m", "init");
});
afterAll(() => {
rmSync(repo, { recursive: true, force: true });
});
describe("getGitDiffFingerprint", () => {
test("uncommitted: stable when nothing changes", async () => {
const a = await getGitDiffFingerprint(runtime, "uncommitted", "main", repo);
const b = await getGitDiffFingerprint(runtime, "uncommitted", "main", repo);
expect(a).not.toBeNull();
expect(a).toBe(b!);
});
test("uncommitted: changes when a tracked file is edited — and again on a second edit", async () => {
const before = await getGitDiffFingerprint(runtime, "uncommitted", "main", repo);
writeFileSync(join(repo, "a.txt"), "one\nTWO\nthree\n");
const afterFirstEdit = await getGitDiffFingerprint(runtime, "uncommitted", "main", repo);
expect(afterFirstEdit).not.toBe(before!);
// The critical case `git status` alone cannot see: an ALREADY-modified
// file modified again.
writeFileSync(join(repo, "a.txt"), "one\nTWO!\nthree\n");
const afterSecondEdit = await getGitDiffFingerprint(runtime, "uncommitted", "main", repo);
expect(afterSecondEdit).not.toBe(afterFirstEdit!);
});
test("uncommitted: changes when an untracked file appears and when its CONTENT changes", async () => {
const before = await getGitDiffFingerprint(runtime, "uncommitted", "main", repo);
writeFileSync(join(repo, "new.txt"), "hello\n");
const created = await getGitDiffFingerprint(runtime, "uncommitted", "main", repo);
expect(created).not.toBe(before!);
writeFileSync(join(repo, "new.txt"), "hello world\n");
const edited = await getGitDiffFingerprint(runtime, "uncommitted", "main", repo);
expect(edited).not.toBe(created!);
});
test("uncommitted: changes when a commit lands (HEAD moves)", async () => {
const before = await getGitDiffFingerprint(runtime, "uncommitted", "main", repo);
await git("add", "-A");
await git("commit", "-m", "snapshot");
const after = await getGitDiffFingerprint(runtime, "uncommitted", "main", repo);
expect(after).not.toBe(before!);
});
test("last-commit: stable across working-tree edits, changes on commit", async () => {
const before = await getGitDiffFingerprint(runtime, "last-commit", "main", repo);
writeFileSync(join(repo, "a.txt"), "working tree noise\n");
const duringEdit = await getGitDiffFingerprint(runtime, "last-commit", "main", repo);
expect(duringEdit).toBe(before!);
await git("add", "-A");
await git("commit", "-m", "another");
const afterCommit = await getGitDiffFingerprint(runtime, "last-commit", "main", repo);
expect(afterCommit).not.toBe(before!);
});
test("merge-base: changes when the branch tip moves", async () => {
await git("checkout", "-b", "feature");
writeFileSync(join(repo, "b.txt"), "feature\n");
await git("add", "-A");
await git("commit", "-m", "feature work");
const before = await getGitDiffFingerprint(runtime, "merge-base", "main", repo);
expect(before).not.toBeNull();
writeFileSync(join(repo, "b.txt"), "feature 2\n");
await git("add", "-A");
await git("commit", "-m", "more feature work");
const after = await getGitDiffFingerprint(runtime, "merge-base", "main", repo);
expect(after).not.toBe(before!);
});
test("staged: changes when the index changes, not on unstaged edits", async () => {
const before = await getGitDiffFingerprint(runtime, "staged", "main", repo);
writeFileSync(join(repo, "a.txt"), "unstaged edit only\n");
const unstagedOnly = await getGitDiffFingerprint(runtime, "staged", "main", repo);
expect(unstagedOnly).toBe(before!);
await git("add", "a.txt");
const staged = await getGitDiffFingerprint(runtime, "staged", "main", repo);
expect(staged).not.toBe(before!);
});
test("unknown diff type returns null (treated as always-fresh)", async () => {
const result = await getGitDiffFingerprint(runtime, "p4-default" as never, "main", repo);
expect(result).toBeNull();
});
});
+56
View File
@@ -108,6 +108,62 @@ export async function runJjDiff(
return { patch, label: args.label };
}
// --- Diff staleness fingerprint ---------------------------------------------
// jj snapshots the working copy on every command invocation, so `@`'s commit
// id reflects the CURRENT file state — a working-copy edit changes it. That
// makes commit ids a complete content fingerprint for every jj mode. `null`
// means "cannot fingerprint" (callers treat as always-fresh).
export async function getJjDiffFingerprint(
runtime: ReviewJjRuntime,
diffType: DiffType,
defaultBranch: string,
cwd?: string,
): Promise<string | null> {
const idOf = async (rev: string): Promise<string | null> => {
const result = await runtime.runJj(
["log", "-r", rev, "--no-graph", "-T", "commit_id"],
{ cwd },
);
return result.exitCode === 0 ? result.stdout.trim() || null : null;
};
try {
switch (diffType) {
case "jj-current":
case "jj-all": {
const id = await idOf("@");
return id ? `jj:${diffType}:${id}` : null;
}
case "jj-last": {
const id = await idOf("@-");
return id ? `jj:${diffType}:${id}` : null;
}
case "jj-line": {
const compareTarget = defaultBranch.length > 0 ? defaultBranch : JJ_TRUNK_REVSET;
const current = await idOf("@");
const base = await idOf(jjLineBaseRevset(compareTarget));
return current && base ? `jj:${diffType}:${base}:${current}` : null;
}
case "jj-evolog": {
// Evolog diffs render `--from <historical entry> --to @`: the
// historical end is immutable, but @ is the LIVE working copy — every
// edit moves it and changes the patch. Fingerprint BOTH ends.
const current = await idOf("@");
if (!current) return null;
if (defaultBranch.length > 0) return `jj:${diffType}:${defaultBranch}:${current}`;
const evologs = await getJjEvoLogEntries(runtime, cwd);
return evologs.length >= 2
? `jj:${diffType}:auto:${evologs[1].commitId}:${current}`
: null;
}
default:
return null;
}
} catch {
return null;
}
}
function dropHunklessGitDiffChunks(patch: string): string {
if (!patch.includes("diff --git ")) return patch;
+32
View File
@@ -135,6 +135,38 @@ export async function runPRFullStackDiff(
};
}
/**
* Staleness fingerprint for the full-stack diff above. The diff is three-dot
* (`baseRef...HEAD` = merge-base(baseRef, HEAD)..HEAD), so its content changes
* exactly when HEAD or the MERGE-BASE moves — and NOT when the base branch
* merely advances past an unchanged fork point. Fingerprinting (merge-base,
* HEAD) therefore detects the real stale cases (commits on HEAD, a lower
* stacked PR merging, a rebase/force-push shifting the fork point) without
* crying stale on every ordinary `origin/<default>` fetch. `null` = cannot
* fingerprint (treated as always-fresh).
*/
export async function getPRFullStackFingerprint(
runtime: ReviewGitRuntime,
metadata: PRMetadata,
cwd?: string,
): Promise<string | null> {
const defaultBranch = metadata.defaultBranch;
if (!defaultBranch || !branchNameIsSafe(defaultBranch)) return null;
const baseRef = await resolvePRFullStackBaseRef(runtime, defaultBranch, cwd);
if (!baseRef) return null;
// --no-optional-locks: probes run on a background poll and must never take
// git's index lock alongside concurrent agent git operations.
const head = await runtime.runGit(["--no-optional-locks", "rev-parse", "HEAD"], { cwd });
if (head.exitCode !== 0) return null;
const mergeBase = await runtime.runGit(
["--no-optional-locks", "merge-base", "--end-of-options", baseRef, "HEAD"],
{ cwd },
);
if (mergeBase.exitCode !== 0) return null;
return `pr-full-stack:${mergeBase.stdout.trim()}:${head.stdout.trim()}`;
}
/**
* Fetch and checkout a PR/MR head in a local worktree.
* Returns true if the checkout succeeded, false otherwise.
+120
View File
@@ -772,6 +772,126 @@ export async function runGitDiffWithContext(
return runGitDiff(runtime, diffType, gitContext.defaultBranch, gitContext.cwd, options);
}
// --- Diff staleness fingerprint ---------------------------------------------
//
// A fingerprint is a small string capturing "what the repo looked like when
// this diff was computed". The server stores it beside the cached patch and
// recomputes it on demand; a mismatch means the diff on screen is stale (the
// agent/user changed files mid-review). `null` means "cannot fingerprint this
// mode" — callers must treat that as always-fresh (no staleness banner), never
// as stale.
//
// Per mode:
// - Commit-anchored modes (last-commit, all, branch, merge-base) change only
// when refs move → pure `rev-parse` fingerprints, ~10ms.
// - Working-tree modes (uncommitted, staged, unstaged) must reflect CONTENT,
// not just `git status` paths — a file that is already modified and gets
// modified again produces identical porcelain output. So these hash the
// same diff the patch itself is built from (still fast; it is exactly what
// a refresh would re-run), plus untracked file contents (capped).
/** djb2-xor hash — cheap change-detection fingerprint, not cryptographic. */
export function hashFingerprintPart(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);
}
const MAX_UNTRACKED_FINGERPRINT_FILES = 20;
export async function getGitDiffFingerprint(
runtime: ReviewGitRuntime,
diffType: DiffType,
defaultBranch: string = "main",
externalCwd?: string,
options?: GitDiffOptions,
): Promise<string | null> {
let cwd: string | undefined = externalCwd;
let effectiveDiffType = diffType as string;
if (diffType.startsWith("worktree:")) {
const parsed = parseWorktreeDiffType(diffType);
if (!parsed) return null;
cwd = parsed.path;
effectiveDiffType = parsed.subType;
}
const wFlag = options?.hideWhitespace ? ["-w"] : [];
try {
// --no-optional-locks: fingerprint probes run in the background (polled
// every few seconds) and must NEVER take git's index lock — `status`/`diff`
// opportunistically refresh the index by default, which races concurrent
// `git add`/commit operations (the agent working while the user reviews).
const runReadOnlyGit = (args: string[]) =>
runtime.runGit(["--no-optional-locks", ...args], { cwd });
const head = await runReadOnlyGit(["rev-parse", "HEAD"]);
const headSha = head.exitCode === 0 ? head.stdout.trim() : "no-head";
const parts = ["git", effectiveDiffType, headSha];
const hashDiffOutput = async (args: string[]): Promise<boolean> => {
const result = await runReadOnlyGit(["diff", "--no-ext-diff", ...wFlag, ...args]);
if (result.exitCode !== 0) return false;
parts.push(hashFingerprintPart(result.stdout));
return true;
};
// Untracked files: porcelain `??` lines capture existence; hash their
// contents too so editing a freshly-created (untracked) file is detected.
// Capped — a pathological number of untracked files degrades to
// existence-only detection rather than unbounded reads.
const hashUntracked = async (): Promise<boolean> => {
const status = await runReadOnlyGit(["status", "--porcelain"]);
if (status.exitCode !== 0) return false;
parts.push(hashFingerprintPart(status.stdout));
const untracked = status.stdout
.split("\n")
.filter((line) => line.startsWith("?? "))
.map((line) => line.slice(3).trim())
.slice(0, MAX_UNTRACKED_FINGERPRINT_FILES);
for (const path of untracked) {
const content = await runtime.readTextFile(cwd ? resolvePath(cwd, path) : path);
parts.push(content != null ? hashFingerprintPart(content) : "unreadable");
}
return true;
};
switch (effectiveDiffType) {
case "uncommitted": {
if (headSha !== "no-head" && !(await hashDiffOutput(["HEAD"]))) return null;
if (!(await hashUntracked())) return null;
break;
}
case "staged": {
if (!(await hashDiffOutput(["--staged"]))) return null;
break;
}
case "unstaged": {
if (!(await hashDiffOutput([]))) return null;
if (!(await hashUntracked())) return null;
break;
}
case "branch":
case "merge-base": {
const baseTip = await runReadOnlyGit(["rev-parse", "--end-of-options", defaultBranch]);
parts.push(baseTip.exitCode === 0 ? baseTip.stdout.trim() : "no-base");
break;
}
case "last-commit":
case "all":
// HEAD alone identifies these.
break;
default:
return null;
}
return parts.join(":");
} catch {
return null;
}
}
export async function getFileContentsForDiff(
runtime: ReviewGitRuntime,
diffType: DiffType,
+30
View File
@@ -61,6 +61,14 @@ export interface WorkspaceReviewRuntime {
canStageFiles(diffType: string, cwd?: string): Promise<boolean>;
stageFile(diffType: string, filePath: string, cwd?: string): Promise<void>;
unstageFile(diffType: string, filePath: string, cwd?: string): Promise<void>;
/** Optional staleness fingerprint probe (see vcs-core). Absent or `null`
* results are treated as always-fresh. */
getVcsDiffFingerprint?(
diffType: DiffType,
defaultBranch?: string,
cwd?: string,
options?: GitDiffOptions,
): Promise<string | null>;
}
export interface WorkspaceReviewBuildOptions {
@@ -377,6 +385,28 @@ export class WorkspaceReviewSession implements WorkspaceReviewState {
return snapshot;
}
/** Combined staleness fingerprint across EVERY child repo (selected or not —
* a repo with no changes at snapshot time still alters the workspace diff if
* it gains changes later). `null` when the runtime has no fingerprint probe. */
async getFingerprint(): Promise<string | null> {
const probe = this.runtime.getVcsDiffFingerprint;
if (!probe) return null;
const parts: string[] = ["workspace", this.diffType];
for (const repo of this.repos) {
if (!repo.vcsType || !repo.gitContext) continue;
const repoDiffType =
repo.diffType ?? mapWorkspaceModeToRepoDiffType(this.diffType, repo.vcsType);
if (!repoDiffType) continue;
const fingerprint = await probe(repoDiffType, repo.gitContext.defaultBranch, repo.cwd, {
hideWhitespace: this.hideWhitespace,
});
// "unknown" is stable across probes, so an unfingerprintable child never
// flip-flops the combined result.
parts.push(`${repo.id}=${fingerprint ?? "unknown"}`);
}
return parts.join("|");
}
getPromptContext(): WorkspaceReviewPromptContext {
return {
root: this.root,
+43
View File
@@ -7,6 +7,7 @@ import {
detectRemoteDefaultBranch,
getFileContentsForDiff as getGitFileContentsForDiff,
getGitContext,
getGitDiffFingerprint,
gitAddFile,
gitResetFile,
parseWorktreeDiffType,
@@ -17,6 +18,7 @@ import {
type ReviewJjRuntime,
detectJjWorkspace,
getJjContext,
getJjDiffFingerprint,
getJjFileContentsForDiff,
runJjDiff,
} from "./jj-core";
@@ -54,6 +56,14 @@ export interface VcsProvider {
oldPath?: string,
cwd?: string,
): Promise<{ oldContent: string | null; newContent: string | null }>;
/** Cheap staleness fingerprint for a diff (see review-core/jj-core). Providers
* without an implementation (e.g. p4) are treated as always-fresh. */
getDiffFingerprint?(
diffType: DiffType,
defaultBranch: string,
cwd?: string,
options?: GitDiffOptions,
): Promise<string | null>;
stageFile?(filePath: string, cwd?: string): Promise<void>;
unstageFile?(filePath: string, cwd?: string): Promise<void>;
resolveCwd?(diffType: string, fallbackCwd?: string): string | undefined;
@@ -81,6 +91,14 @@ export interface VcsApi {
oldPath?: string,
cwd?: string,
): Promise<{ oldContent: string | null; newContent: string | null }>;
/** Best-effort staleness fingerprint for the given diff parameters. `null`
* means "cannot fingerprint" and must be treated as always-fresh. */
getVcsDiffFingerprint(
diffType: DiffType,
defaultBranch?: string,
cwd?: string,
options?: GitDiffOptions,
): Promise<string | null>;
canStageFiles(diffType: string, cwd?: string): Promise<boolean>;
stageFile(diffType: string, filePath: string, cwd?: string): Promise<void>;
unstageFile(diffType: string, filePath: string, cwd?: string): Promise<void>;
@@ -178,6 +196,10 @@ export function createGitProvider(runtime: ReviewGitRuntime): VcsProvider {
return getGitFileContentsForDiff(runtime, diffType, defaultBranch, filePath, oldPath, cwd);
},
getDiffFingerprint(diffType, defaultBranch, cwd?, options?) {
return getGitDiffFingerprint(runtime, diffType, defaultBranch, cwd, options);
},
stageFile(filePath: string, cwd?: string): Promise<void> {
return gitAddFile(runtime, filePath, cwd);
},
@@ -224,6 +246,10 @@ export function createJjProvider(runtime: ReviewJjRuntime): VcsProvider {
getFileContents(diffType, defaultBranch, filePath, oldPath?, cwd?) {
return getJjFileContentsForDiff(runtime, diffType, defaultBranch, filePath, oldPath, cwd);
},
getDiffFingerprint(diffType, defaultBranch, cwd?) {
return getJjDiffFingerprint(runtime, diffType, defaultBranch, cwd);
},
};
}
@@ -435,6 +461,23 @@ export function createVcsApi(providers: readonly VcsProvider[]): VcsApi {
return provider.getFileContents(diffType, defaultBranch, filePath, oldPath, cwd);
},
async getVcsDiffFingerprint(
diffType: DiffType,
defaultBranch: string = "main",
cwd?: string,
options?: GitDiffOptions,
): Promise<string | null> {
try {
const provider = await getProviderForOperation(diffType, cwd);
if (!provider.getDiffFingerprint) return null;
return await provider.getDiffFingerprint(diffType, defaultBranch, cwd, options);
} catch {
// Fingerprinting is best-effort: failure means "always fresh", never
// a user-facing error.
return null;
}
},
async canStageFiles(diffType: string, cwd?: string): Promise<boolean> {
const provider = await getProviderForOperation(diffType, cwd);
return provider.stageFile !== undefined && (provider.canStageFiles?.(diffType) ?? false);
+1 -1
View File
@@ -22,7 +22,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",
@@ -13,10 +13,10 @@ export const reviewAllFilesDiffShortcuts = defineShortcutScope({
displayOrder: 10,
},
undoCollapse: {
description: 'Undo last collapse',
description: 'Re-expand nearest collapsed file',
bindings: ['Z'],
section: 'All-Files View',
hint: 'Reopens the most recently auto-collapsed file (auto-collapse fires when toggling viewed).',
hint: 'Reopens the nearest collapsed file at or above the current position (collapse also fires when toggling viewed).',
displayOrder: 20,
},
addFileComment: {
@@ -27,14 +27,14 @@ export const reviewAllFilesDiffShortcuts = defineShortcutScope({
displayOrder: 30,
},
nextFile: {
description: 'Scroll to next expanded file',
description: 'Scroll to next file',
bindings: [']'],
section: 'All-Files View',
hint: 'All-files panel only.',
displayOrder: 40,
},
prevFile: {
description: 'Scroll to previous expanded file',
description: 'Scroll to previous file',
bindings: ['['],
section: 'All-Files View',
hint: 'All-files panel only.',