mirror of
https://github.com/backnotprop/plannotator.git
synced 2026-09-14 14:17:26 +08:00
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:
@@ -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? }`) |
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -32,6 +32,7 @@ export {
|
||||
detectVcs,
|
||||
getGitContext,
|
||||
getVcsContext,
|
||||
getVcsDiffFingerprint,
|
||||
getVcsFileContentsForDiff,
|
||||
prepareLocalReviewDiff,
|
||||
resolveInitialDiffType,
|
||||
|
||||
@@ -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)) {
|
||||
|
||||
@@ -98,6 +98,7 @@ export const {
|
||||
prepareLocalReviewDiff,
|
||||
runVcsDiff,
|
||||
getVcsFileContentsForDiff,
|
||||
getVcsDiffFingerprint,
|
||||
canStageFiles,
|
||||
stageFile,
|
||||
unstageFile,
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
@@ -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
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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 */
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -27,7 +27,7 @@
|
||||
"*.ts"
|
||||
],
|
||||
"dependencies": {
|
||||
"@pierre/diffs": "1.1.20",
|
||||
"@pierre/diffs": "1.2.8",
|
||||
"@plannotator/ai": "workspace:*",
|
||||
"@plannotator/shared": "workspace:*"
|
||||
},
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import {
|
||||
canStageFiles,
|
||||
getVcsContext,
|
||||
getVcsDiffFingerprint,
|
||||
getVcsFileContentsForDiff,
|
||||
runVcsDiff,
|
||||
stageFile,
|
||||
@@ -35,6 +36,7 @@ const workspaceRuntime = {
|
||||
getVcsContext,
|
||||
runVcsDiff,
|
||||
getVcsFileContentsForDiff,
|
||||
getVcsDiffFingerprint,
|
||||
canStageFiles,
|
||||
stageFile,
|
||||
unstageFile,
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -52,6 +52,7 @@ export const {
|
||||
prepareLocalReviewDiff,
|
||||
runVcsDiff,
|
||||
getVcsFileContentsForDiff,
|
||||
getVcsDiffFingerprint,
|
||||
canStageFiles,
|
||||
stageFile,
|
||||
unstageFile,
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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.',
|
||||
|
||||
Reference in New Issue
Block a user