1import { RefreshIcon } from "~/components/ui/refresh-icon";
2import { useAtomValue } from "@effect/atom-react";
3import type { FileDiffContentsLoader, FileDiffMetadata } from "@pierre/diffs";
4import { useParams } from "@tanstack/react-router";
5import {
6 isAtomCommandInterrupted,
7 squashAtomCommandFailure,
8} from "@t3tools/client-runtime/state/runtime";
9import { safeErrorLogAttributes } from "@t3tools/client-runtime/errors";
10import type { ScopedThreadRef, RunId } from "@t3tools/contracts";
11import {
12 ArrowRightIcon,
13 CheckIcon,
14 ChevronDownIcon,
15 Columns2Icon,
16 FolderTreeIcon,
17 PilcrowIcon,
18 Rows3Icon,
19 TextWrapIcon,
20} from "lucide-react";
21import { ChevronDown, ChevronRight, ChevronsDownUp, ChevronsUpDown } from "lucide";
22import * as Schema from "effect/Schema";
23import * as DateTime from "effect/DateTime";
24import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react";
25import { useCodeViewFileReveal } from "./diffs/useCodeViewFileReveal";
26import { useFilesystemReadAccess } from "~/state/filesystem";
27import { useOpenInPreferredEditor } from "../editorPreferences";
28import { useFileContextMenuHandler } from "../fileContextMenu";
29import { type DraftId } from "../composerDraftStore";
30import { openDiffFilePrimaryAction } from "../diffFileActions";
31import { useCheckpointDiff } from "~/lib/checkpointDiffState";
32import { cn } from "~/lib/utils";
33import { selectThreadDiffPanelSelection, useDiffPanelStore } from "../diffPanelStore";
34import { useLocalStorage } from "../hooks/useLocalStorage";
35import { useTheme } from "../hooks/useTheme";
36import {
37 buildFileDiffContentVersion,
38 buildFileDiffIdentityKey,
39 getDiffCollapseIconClassName,
40 getDiffLineStat,
41 getRenderablePatch,
42 resolveDiffThemeName,
43 resolveFileDiffPath,
44} from "../lib/diffRendering";
45import { PREFERRED_HIGHLIGHTER } from "../lib/syntaxHighlighting";
46import { areAllDiffFilesCollapsed, toggleAllDiffFiles } from "../lib/diffCollapse";
47import { useTurnDiffSummaries } from "../hooks/useTurnDiffSummaries";
48import { useWorkspaceMutationRefresh } from "../hooks/useWorkspaceMutationRefresh";
49import { useProject, useThreadProjection, useThreadShell } from "../state/entities";
50import { resolveThreadRouteRef } from "../threadRoutes";
51import { useClientSettings, useUpdateClientSettings } from "../hooks/useSettings";
52import { formatShortTimestamp } from "../timestampFormat";
53import { DiffFilePathCopyButton } from "./DiffFilePathCopyButton";
54import { DiffPanelLoadingState, DiffPanelShell, type DiffPanelMode } from "./DiffPanelShell";
55import { DiffStatLabel } from "./chat/DiffStatLabel";
56import { AnnotatableCodeView, type AnnotatableCodeViewHandle } from "./diffs/AnnotatableCodeView";
57import { DiffFileTree } from "./diffs/DiffFileTree";
58import { diffFileTreeEntries } from "./diffs/diffFileTree.logic";
59import { Button } from "./ui/button";
60import { MorphIcon } from "~/components/MorphIcon";
61import { ToggleGroup, Toggle } from "./ui/toggle-group";
62import { Switch } from "./ui/switch";
63import {
64 Combobox,
65 ComboboxEmpty,
66 ComboboxSearchInput,
67 ComboboxItem,
68 ComboboxList,
69 ComboboxPopup,
70 ComboboxTrigger,
71} from "./ui/combobox";
72import {
73 DropdownMenu,
74 DropdownMenuContent,
75 DropdownMenuRadioGroup,
76 DropdownMenuRadioItem,
77 DropdownMenuSub,
78 DropdownMenuSubContent,
79 DropdownMenuSubTrigger,
80 DropdownMenuTrigger,
81} from "./ui/menu";
82import { Tooltip, TooltipPopup, TooltipTrigger } from "./ui/tooltip";
83import { useEnvironmentQuery } from "../state/query";
84import { useAtomCommand } from "../state/use-atom-command";
85import { serverEnvironment } from "../state/server";
86import { reviewEnvironment } from "../state/review";
87import { vcsEnvironment } from "../state/vcs";
88import { buildBaseRefChoices, filterBaseRefChoices } from "../lib/baseRefChoices";
89import { createGitDiffFileContentsLoader } from "../lib/diffFileContents";
90
91import { useReviewFilePatches } from "./diffs/useReviewFilePatches";
92import { DiffFileLoadingBoundary } from "./diffs/DiffFileLoadingBoundary";
93import { DiffFileStatus } from "./diffs/DiffFileStatus";
94
95type DiffThemeType = "light" | "dark";
96const AUTOMATIC_BASE_REF = "__automatic_base_ref__";
97const DIFF_FILE_TREE_STORAGE_KEY = "t3code.diffFileTreeOpen";
98const fileEntryCache = new WeakMap<
99 FileDiffMetadata,
100 { fileDiff: FileDiffMetadata; fileKey: string; fileVersion: number }
101>();
102
103function getCachedFileEntry(fileDiff: FileDiffMetadata) {
104 const cached = fileEntryCache.get(fileDiff);
105 if (cached) return cached;
106 const entry = {
107 fileDiff,
108 fileKey: buildFileDiffIdentityKey(fileDiff),
109 fileVersion: buildFileDiffContentVersion(fileDiff),
110 };
111 fileEntryCache.set(fileDiff, entry);
112 return entry;
113}
114
115interface CollapsedDiffFilesState {
116 readonly scopeKey: string | null;
117 readonly fileKeys: ReadonlySet<string>;
118}
119
120const EMPTY_COLLAPSED_DIFF_FILE_KEYS: ReadonlySet<string> = new Set();
121
122/** Collapse control for one file header; re-renders only when its own file changes. */
123function DiffFileCollapseToggle({
124 filePath,
125 fileKey,
126 collapsed,
127 unavailable,
128 iconClassName,
129 onToggle,
130}: {
131 filePath: string;
132 fileKey: string;
133 collapsed: boolean;
134 unavailable: boolean;
135 iconClassName: string;
136 onToggle: (fileKey: string) => void;
137}) {
138 return (
139 <Tooltip>
140 <TooltipTrigger
141 render={
142 <Button
143 size="icon-micro"
144 variant="ghost"
145 className="-ms-0.5"
146 aria-label={collapsed ? `Expand ${filePath}` : `Collapse ${filePath}`}
147 aria-expanded={!collapsed}
148 disabled={unavailable}
149 onClick={(event) => {
150 event.stopPropagation();
151 onToggle(fileKey);
152 }}
153 />
154 }
155 >
156 <MorphIcon
157 className={cn("size-4", iconClassName)}
158 icon={collapsed ? ChevronRight : ChevronDown}
159 />
160 </TooltipTrigger>
161 <TooltipPopup side="top">{collapsed ? "Expand diff" : "Collapse diff"}</TooltipPopup>
162 </Tooltip>
163 );
164}
165
166/** Copy and status controls for one file header; re-renders only when its own file changes. */
167function DiffFileHeaderSuffix({
168 filePath,
169 hasStat,
170 error,
171 truncated,
172 onRetry,
173}: {
174 filePath: string;
175 hasStat: boolean;
176 error: boolean;
177 truncated: boolean;
178 onRetry: (path: string) => void;
179}) {
180 return (
181 <>
182 <DiffFilePathCopyButton filePath={filePath} />
183 {hasStat ? (
184 <DiffFileStatus error={error} truncated={truncated} retry={() => onRetry(filePath)} />
185 ) : null}
186 </>
187 );
188}
189
190interface DiffPanelProps {
191 mode?: DiffPanelMode;
192 composerDraftTarget: ScopedThreadRef | DraftId;
193 workspaceMutationId: string | null;
194}
195
196export default function DiffPanel({
197 mode = "inline",
198 composerDraftTarget,
199 workspaceMutationId,
200}: DiffPanelProps) {
201 const { resolvedTheme } = useTheme();
202 const settings = useClientSettings();
203 const diffLayout = settings.diffLayout;
204 const updateClientSettings = useUpdateClientSettings();
205 const [wordWrap, setWordWrap] = useState(settings.wordWrap);
206 const [diffIgnoreWhitespace, setDiffIgnoreWhitespace] = useState(settings.diffIgnoreWhitespace);
207 const [fileTreeOpen, setFileTreeOpen] = useLocalStorage(
208 DIFF_FILE_TREE_STORAGE_KEY,
209 false,
210 Schema.Boolean,
211 );
212 const [baseRefQuery, setBaseRefQuery] = useState("");
213 const [collapsedDiffFiles, setCollapsedDiffFiles] = useState<CollapsedDiffFilesState>(() => ({
214 scopeKey: null,
215 fileKeys: EMPTY_COLLAPSED_DIFF_FILE_KEYS,
216 }));
217 const [codeViewRevision, setCodeViewRevision] = useState(0);
218 const [codeView, setCodeView] = useState<AnnotatableCodeViewHandle | null>(null);
219
220 const routeThreadRef = useParams({
221 strict: false,
222 select: (params) => resolveThreadRouteRef(params),
223 });
224 const activeThreadId = routeThreadRef?.threadId ?? null;
225 const activeThread = useThreadShell(routeThreadRef);
226 const activeThreadProjection = useThreadProjection(routeThreadRef)?.projection ?? null;
227 const fileAccess = useFilesystemReadAccess(activeThread?.environmentId ?? null);
228 const { canReadFiles } = fileAccess;
229 const activeProjectId = activeThread?.projectId ?? null;
230 const activeProject = useProject(
231 activeThread && activeProjectId
232 ? {
233 environmentId: activeThread.environmentId,
234 projectId: activeProjectId,
235 }
236 : null,
237 );
238 const activeCwd = activeThread?.worktreePath ?? activeProject?.workspaceRoot;
239 const activeRepositoryRoot = activeThread?.worktreePath
240 ? undefined
241 : activeProject?.repositoryIdentity?.rootPath;
242 const serverConfig = useAtomValue(
243 serverEnvironment.configValueAtom(activeThread?.environmentId ?? null),
244 );
245 const onFileContextMenu = useFileContextMenuHandler(activeThread?.environmentId ?? null);
246 const openInPreferredEditor = useOpenInPreferredEditor(
247 activeThread?.environmentId ?? null,
248 serverConfig?.availableEditors ?? [],
249 );
250 const getDiffFileContents = useAtomCommand(reviewEnvironment.diffFileContents);
251 const gitStatusQuery = useEnvironmentQuery(
252 activeThread !== null && activeThread !== undefined && activeCwd != null
253 ? vcsEnvironment.status({
254 environmentId: activeThread.environmentId,
255 input: { cwd: activeCwd },
256 })
257 : null,
258 );
259 const diffSelection = useDiffPanelStore((state) =>
260 selectThreadDiffPanelSelection(state.byThreadKey, routeThreadRef),
261 );
262 const isGitRepo = gitStatusQuery.data?.isRepo ?? true;
263 const { turnDiffSummaries, inferredCheckpointTurnCountByRunId } =
264 useTurnDiffSummaries(activeThreadProjection);
265 const orderedTurnDiffSummaries = useMemo(
266 () =>
267 [...turnDiffSummaries].toSorted((left, right) => {
268 const leftTurnCount =
269 left.checkpointTurnCount ?? inferredCheckpointTurnCountByRunId[left.runId] ?? 0;
270 const rightTurnCount =
271 right.checkpointTurnCount ?? inferredCheckpointTurnCountByRunId[right.runId] ?? 0;
272 if (leftTurnCount !== rightTurnCount) {
273 return rightTurnCount - leftTurnCount;
274 }
275 return right.completedAt.localeCompare(left.completedAt);
276 }),
277 [inferredCheckpointTurnCountByRunId, turnDiffSummaries],
278 );
279
280 useEffect(() => {
281 if (!routeThreadRef || diffSelection.kind !== "turn") return;
282 useDiffPanelStore.getState().reconcileTurnSelection(
283 routeThreadRef,
284 orderedTurnDiffSummaries.map((summary) => summary.runId),
285 );
286 }, [diffSelection, orderedTurnDiffSummaries, routeThreadRef]);
287
288 const selectedRunId = diffSelection.kind === "turn" ? diffSelection.turnId : null;
289 const selectedGitScope = diffSelection.kind === "unstaged" ? "unstaged" : "branch";
290 const selectedBaseRef = diffSelection.kind === "branch" ? diffSelection.baseRef : null;
291 const selectedFilePath = diffSelection.kind === "turn" ? diffSelection.filePath : null;
292 const selectedFileRevealRequestId =
293 diffSelection.kind === "turn" ? diffSelection.revealRequestId : 0;
294 const selectedTurn =
295 selectedRunId === null
296 ? undefined
297 : (orderedTurnDiffSummaries.find((summary) => summary.runId === selectedRunId) ??
298 orderedTurnDiffSummaries[0]);
299 const selectedCheckpointTurnCount =
300 selectedTurn &&
301 (selectedTurn.checkpointTurnCount ?? inferredCheckpointTurnCountByRunId[selectedTurn.runId]);
302 const latestTurn = orderedTurnDiffSummaries[0];
303 const selectedScopeLabel =
304 selectedRunId === null
305 ? selectedGitScope === "unstaged"
306 ? "Uncommitted"
307 : "Changes"
308 : selectedTurn?.runId === latestTurn?.runId
309 ? "Latest turn"
310 : `Turn ${selectedCheckpointTurnCount ?? "?"}`;
311 const reviewSectionId = selectedTurn ? `turn:${selectedTurn.runId}` : selectedGitScope;
312 const collapseScopeKey = routeThreadRef
313 ? `${routeThreadRef.environmentId}:${routeThreadRef.threadId}:${reviewSectionId}`
314 : null;
315 const codeViewMountKey = `${collapseScopeKey ?? reviewSectionId}:${codeViewRevision}`;
316 const reviewSectionTitle = selectedTurn
317 ? `Turn ${selectedCheckpointTurnCount ?? "?"}`
318 : selectedGitScope === "unstaged"
319 ? "Uncommitted"
320 : "Changes";
321 const selectedCheckpointRange = useMemo(
322 () =>
323 typeof selectedCheckpointTurnCount === "number"
324 ? {
325 fromTurnCount: Math.max(0, selectedCheckpointTurnCount - 1),
326 toTurnCount: selectedCheckpointTurnCount,
327 }
328 : null,
329 [selectedCheckpointTurnCount],
330 );
331 const activeCheckpointDiff = useCheckpointDiff(
332 {
333 environmentId: activeThread?.environmentId ?? null,
334 threadId: activeThreadId,
335 fromTurnCount: selectedCheckpointRange?.fromTurnCount ?? null,
336 toTurnCount: selectedCheckpointRange?.toTurnCount ?? null,
337 ignoreWhitespace: diffIgnoreWhitespace,
338 cacheScope: selectedTurn ? `turn:${selectedTurn.runId}` : null,
339 },
340 { enabled: isGitRepo && selectedTurn !== undefined },
341 );
342 const branchDiffPreview = useEnvironmentQuery(
343 canReadFiles && selectedRunId === null && activeThread && activeCwd
344 ? reviewEnvironment.diffPreview({
345 environmentId: activeThread.environmentId,
346 input: {
347 cwd: activeCwd,
348 ...(selectedBaseRef ? { baseRef: selectedBaseRef } : {}),
349 ignoreWhitespace: diffIgnoreWhitespace,
350 },
351 })
352 : null,
353 );
354 const canRefreshGitDiff =
355 isGitRepo && selectedRunId === null && activeThread != null && activeCwd != null;
356 const activeThreadRefreshKey = routeThreadRef
357 ? `${routeThreadRef.environmentId}:${routeThreadRef.threadId}`
358 : null;
359
360 const selectedGitSource = branchDiffPreview.data?.sources.find(
361 (source) => source.kind === (selectedGitScope === "unstaged" ? "working-tree" : "branch-range"),
362 );
363 const refreshPreviewQuery = branchDiffPreview.refresh;
364 const refreshDiffFromUserAction = refreshPreviewQuery;
365
366 const currentLoadDiffFiles = useMemo<FileDiffContentsLoader | undefined>(() => {
367 const preview = branchDiffPreview.data;
368 if (selectedRunId !== null || !activeThread || !preview || !selectedGitSource) {
369 return undefined;
370 }
371
372 if (!canReadFiles) return undefined;
373 return createGitDiffFileContentsLoader(getDiffFileContents, {
374 environmentId: activeThread.environmentId,
375 cwd: preview.cwd,
376 sourceKind: selectedGitSource.kind,
377 baseRef: selectedGitSource.baseRef,
378 headRef: selectedGitSource.headRef,
379 cacheKey: selectedGitSource.diffHash,
380 });
381 }, [
382 activeThread,
383 branchDiffPreview.data,
384 getDiffFileContents,
385 canReadFiles,
386 selectedGitSource,
387 selectedRunId,
388 ]);
389 const loadDiffFilesRef = useRef(currentLoadDiffFiles);
390 loadDiffFilesRef.current = currentLoadDiffFiles;
391 const loadDiffFiles = useCallback<FileDiffContentsLoader>(async (fileDiff) => {
392 const loader = loadDiffFilesRef.current;
393 if (!loader) throw new Error("Diff file contents are unavailable for this selection.");
394 return loader(fileDiff);
395 }, []);
396 const localBranchRefs = useEnvironmentQuery(
397 selectedRunId === null &&
398 selectedGitScope === "branch" &&
399 activeThread &&
400 branchDiffPreview.data?.cwd
401 ? vcsEnvironment.listRefs({
402 environmentId: activeThread.environmentId,
403 input: {
404 cwd: branchDiffPreview.data.cwd,
405 includeMatchingRemoteRefs: true,
406 refKind: "local",
407 ...(baseRefQuery.trim().length > 0 ? { query: baseRefQuery.trim() } : {}),
408 limit: 100,
409 },
410 })
411 : null,
412 );
413 const remoteBranchRefs = useEnvironmentQuery(
414 selectedRunId === null &&
415 selectedGitScope === "branch" &&
416 activeThread &&
417 branchDiffPreview.data?.cwd
418 ? vcsEnvironment.listRefs({
419 environmentId: activeThread.environmentId,
420 input: {
421 cwd: branchDiffPreview.data.cwd,
422 includeMatchingRemoteRefs: true,
423 refKind: "remote",
424 ...(baseRefQuery.trim().length > 0 ? { query: baseRefQuery.trim() } : {}),
425 limit: 100,
426 },
427 })
428 : null,
429 );
430 const baseRefChoices = buildBaseRefChoices(
431 localBranchRefs.data?.refs.filter((ref) => ref.name !== selectedGitSource?.headRef) ?? [],
432 remoteBranchRefs.data?.refs ?? [],
433 );
434 const matchingBaseRefChoices = filterBaseRefChoices(baseRefChoices, baseRefQuery);
435 const valueForBaseRefChoice = (choice: (typeof baseRefChoices)[number]) =>
436 selectedBaseRef && selectedBaseRef === choice.remote?.name
437 ? selectedBaseRef
438 : (choice.local?.name ?? choice.remote?.name ?? choice.id);
439 const baseRefItems = [AUTOMATIC_BASE_REF, ...baseRefChoices.map(valueForBaseRefChoice)];
440 const filteredBaseRefItems = [
441 ...(baseRefQuery.trim().length === 0 ? [AUTOMATIC_BASE_REF] : []),
442 ...matchingBaseRefChoices.map(valueForBaseRefChoice),
443 ];
444 const gitDiff = selectedGitSource?.diff;
445
446 const selectedPatch = selectedTurn ? activeCheckpointDiff.data?.diff : gitDiff;
447 const isSelectedPatchTruncated = !selectedTurn && selectedGitSource?.truncated === true;
448 const isLoadingSelectedPatch = selectedTurn
449 ? activeCheckpointDiff.isPending
450 : branchDiffPreview.isPending;
451 const selectedPatchError = selectedTurn ? activeCheckpointDiff.error : branchDiffPreview.error;
452 const hasResolvedPatch = typeof selectedPatch === "string";
453 const hasNoNetChanges = hasResolvedPatch && selectedPatch.trim().length === 0;
454 const lazySource =
455 !selectedTurn && selectedGitSource?.truncated && selectedGitSource.files
456 ? selectedGitSource
457 : null;
458 const renderablePatch = useMemo(
459 () =>
460 lazySource
461 ? null
462 : getRenderablePatch(selectedPatch, `diff-panel:${resolvedTheme}`, {
463 compactPartialHunkOffsets: selectedRunId === null,
464 }),
465 [lazySource, resolvedTheme, selectedPatch, selectedRunId],
466 );
467 const fileStats = useMemo(
468 () => new Map(lazySource?.files?.map((file) => [file.path, file])),
469 [lazySource?.files],
470 );
471 const {
472 scope: filePatchScope,
473 isPending: areFilePatchesPending,
474 fileStates,
475 retry,
476 requestFile,
477 readyFilePaths,
478 renderableFiles,
479 settledFileCount,
480 loadNextFiles,
481 } = useReviewFilePatches({
482 environmentId: activeThread?.environmentId,
483 cwd: branchDiffPreview.data?.cwd,
484 source: lazySource,
485 baseRef: lazySource?.baseRef ?? selectedBaseRef,
486 ignoreWhitespace: diffIgnoreWhitespace,
487 theme: resolvedTheme,
488 revision: branchDiffPreview.data
489 ? DateTime.formatIso(branchDiffPreview.data.generatedAt)
490 : undefined,
491 preview: renderablePatch,
492 });
493 const refreshBranchDiffPreview = refreshPreviewQuery;
494
495 useEffect(() => {
496 if (!canRefreshGitDiff) return;
497 const refreshOnFocus = () => refreshBranchDiffPreview();
498 window.addEventListener("focus", refreshOnFocus);
499 return () => window.removeEventListener("focus", refreshOnFocus);
500 }, [canRefreshGitDiff, refreshBranchDiffPreview]);
501
502 useWorkspaceMutationRefresh({
503 enabled: canRefreshGitDiff,
504 mutationId: workspaceMutationId,
505 refresh: refreshBranchDiffPreview,
506 resourceKey: `diff:${activeThreadRefreshKey ?? ""}`,
507 });
508
509 const isRefreshingDiff = branchDiffPreview.isPending || areFilePatchesPending;
510 const renderableFileEntries = useMemo(
511 () => renderableFiles.map(getCachedFileEntry),
512 [renderableFiles],
513 );
514 const defaultCollapsedDiffFileKeys = useMemo(
515 () =>
516 settings.diffFilesCollapsed
517 ? new Set(renderableFileEntries.map((file) => file.fileKey))
518 : EMPTY_COLLAPSED_DIFF_FILE_KEYS,
519 [renderableFileEntries, settings.diffFilesCollapsed],
520 );
521 const collapsedDiffFileKeys =
522 collapsedDiffFiles.scopeKey === collapseScopeKey
523 ? collapsedDiffFiles.fileKeys
524 : defaultCollapsedDiffFileKeys;
525 const renderLoadingBoundary = useCallback(
526 () =>
527 settledFileCount < renderableFiles.length ? (
528 <DiffFileLoadingBoundary
529 load={loadNextFiles}
530 count={renderableFiles.length - settledFileCount}
531 />
532 ) : null,
533 [settledFileCount, renderableFiles.length, loadNextFiles],
534 );
535 const codeViewFiles = useMemo(
536 () =>
537 renderableFileEntries
538 .filter(({ fileDiff }) => !lazySource || readyFilePaths.has(resolveFileDiffPath(fileDiff)))
539 .map(({ fileDiff, fileKey, fileVersion }) => {
540 return {
541 fileDiff,
542 filePath: resolveFileDiffPath(fileDiff),
543 fileKey,
544 fileVersion,
545 // Header-only placeholders use the viewer's collapsed geometry until their patch arrives.
546 collapsed:
547 collapsedDiffFileKeys.has(fileKey) ||
548 fileDiff.cacheKey?.endsWith(":pending") === true,
549 };
550 }),
551 [collapsedDiffFileKeys, renderableFileEntries, lazySource, readyFilePaths],
552 );
553 const diffFileKeys = useMemo(
554 () => renderableFileEntries.map((file) => file.fileKey),
555 [renderableFileEntries],
556 );
557 const allDiffFilesCollapsed = areAllDiffFilesCollapsed(diffFileKeys, collapsedDiffFileKeys);
558 const diffLineStat = useMemo(() => {
559 if (!selectedTurn && selectedGitSource?.files) {
560 return selectedGitSource.files.reduce(
561 (total, file) => ({
562 additions: total.additions + file.additions,
563 deletions: total.deletions + file.deletions,
564 }),
565 { additions: 0, deletions: 0 },
566 );
567 }
568 return getDiffLineStat(renderableFiles);
569 }, [renderableFiles, selectedGitSource, selectedTurn]);
570 const fileTreeEntries = useMemo(() => diffFileTreeEntries(renderableFiles), [renderableFiles]);
571 const selectedDiffFileKey = selectedFilePath
572 ? (codeViewFiles.find((candidate) => candidate.filePath === selectedFilePath)?.fileKey ?? null)
573 : null;
574
575 useEffect(() => {
576 if (!selectedDiffFileKey || !codeView?.getInstance()) return;
577 codeView.scrollTo({ type: "item", id: selectedDiffFileKey, align: "start" });
578 }, [codeView, codeViewMountKey, selectedDiffFileKey, selectedFileRevealRequestId]);
579
580 const treeRevealScope = useMemo(
581 () => ({ collapseScopeKey, diffSelection }),
582 [collapseScopeKey, diffSelection],
583 );
584 const requestTreeReveal = useCodeViewFileReveal(
585 codeView,
586 treeRevealScope,
587 codeViewFiles.map((file) => file.fileKey),
588 );
589 const revealDiffFile = useCallback(
590 (filePath: string) => {
591 const index = renderableFileEntries.findIndex(
592 (candidate) => resolveFileDiffPath(candidate.fileDiff) === filePath,
593 );
594 const file = renderableFileEntries[index];
595 if (!file) return;
596 setCollapsedDiffFiles((current) => {
597 const next = new Set(
598 current.scopeKey === collapseScopeKey ? current.fileKeys : defaultCollapsedDiffFileKeys,
599 );
600 next.delete(file.fileKey);
601 return { scopeKey: collapseScopeKey, fileKeys: next };
602 });
603 if (lazySource && index >= settledFileCount) {
604 requestFile(index);
605 }
606 requestTreeReveal(file.fileKey);
607 },
608 [
609 renderableFileEntries,
610 collapseScopeKey,
611 defaultCollapsedDiffFileKeys,
612 requestTreeReveal,
613 lazySource,
614 settledFileCount,
615 requestFile,
616 ],
617 );
618
619 const externalRevealRef = useRef<{ cache: string; key: string } | null>(null);
620 useEffect(() => {
621 if (!lazySource || !selectedFilePath) return;
622 const key = `${selectedFilePath}:${selectedFileRevealRequestId}`;
623 if (
624 externalRevealRef.current?.cache === filePatchScope &&
625 externalRevealRef.current.key === key
626 )
627 return;
628 externalRevealRef.current = { cache: filePatchScope, key };
629 revealDiffFile(selectedFilePath);
630 }, [lazySource, selectedFilePath, selectedFileRevealRequestId, filePatchScope, revealDiffFile]);
631
632 const openDiffFile = useCallback(
633 (filePath: string) => {
634 openDiffFilePrimaryAction({
635 threadRef: routeThreadRef,
636 filePath,
637 activeCwd,
638 repositoryRoot: activeRepositoryRoot,
639 openInEditor: (targetPath) => {
640 void (async () => {
641 const result = await openInPreferredEditor(targetPath);
642 if (result._tag === "Failure" && !isAtomCommandInterrupted(result)) {
643 console.warn("Failed to open diff file in editor.", {
644 operation: "open-diff-file",
645 ...(routeThreadRef
646 ? {
647 environmentId: routeThreadRef.environmentId,
648 threadId: routeThreadRef.threadId,
649 }
650 : {}),
651 ...safeErrorLogAttributes(squashAtomCommandFailure(result)),
652 });
653 }
654 })();
655 },
656 });
657 },
658 [activeCwd, activeRepositoryRoot, openInPreferredEditor, routeThreadRef],
659 );
660 const collapseDefaultsRef = useRef({ collapseScopeKey, defaultCollapsedDiffFileKeys });
661 useLayoutEffect(() => {
662 collapseDefaultsRef.current = { collapseScopeKey, defaultCollapsedDiffFileKeys };
663 }, [collapseScopeKey, defaultCollapsedDiffFileKeys]);
664 const toggleDiffFileCollapsed = useCallback((fileKey: string) => {
665 const { collapseScopeKey, defaultCollapsedDiffFileKeys } = collapseDefaultsRef.current;
666 setCollapsedDiffFiles((current) => {
667 const next = new Set(
668 current.scopeKey === collapseScopeKey ? current.fileKeys : defaultCollapsedDiffFileKeys,
669 );
670 if (next.has(fileKey)) {
671 next.delete(fileKey);
672 } else {
673 next.add(fileKey);
674 }
675 return { scopeKey: collapseScopeKey, fileKeys: next };
676 });
677 }, []);
678 // Find can ask again before the unfolded file reaches the viewer, so this must never fold.
679 const unfoldDiffFile = useCallback((fileKey: string) => {
680 const { collapseScopeKey, defaultCollapsedDiffFileKeys } = collapseDefaultsRef.current;
681 setCollapsedDiffFiles((current) => {
682 const fileKeys =
683 current.scopeKey === collapseScopeKey ? current.fileKeys : defaultCollapsedDiffFileKeys;
684 if (!fileKeys.has(fileKey)) return current;
685 const next = new Set(fileKeys);
686 next.delete(fileKey);
687 return { scopeKey: collapseScopeKey, fileKeys: next };
688 });
689 }, []);
690
691 const toggleDiffFileCollapse = useCallback(() => {
692 setCodeViewRevision((current) => current + 1);
693 setCollapsedDiffFiles((current) => {
694 const currentKeys =
695 current.scopeKey === collapseScopeKey ? current.fileKeys : defaultCollapsedDiffFileKeys;
696
697 return {
698 scopeKey: collapseScopeKey,
699 fileKeys: toggleAllDiffFiles(diffFileKeys, currentKeys),
700 };
701 });
702 }, [collapseScopeKey, defaultCollapsedDiffFileKeys, diffFileKeys]);
703
704 const selectTurn = (runId: RunId) => {
705 if (!routeThreadRef) return;
706 useDiffPanelStore.getState().selectTurn(routeThreadRef, runId);
707 };
708 const selectGitScope = (scope: "branch" | "unstaged") => {
709 if (!routeThreadRef) return;
710 useDiffPanelStore.getState().selectGitScope(routeThreadRef, scope);
711 };
712 const selectBranchBaseRef = (baseRef: string | null) => {
713 if (!routeThreadRef) return;
714 useDiffPanelStore.getState().selectBranchBaseRef(routeThreadRef, baseRef);
715 };
716 // The scope menu has two radio groups: the top-level one treats the latest
717 // turn as "latest", while the turn sub-menu keys every turn by id so the
718 // latest turn is also marked there.
719 const selectedTurnValue = selectedTurn ? `turn:${selectedTurn.runId}` : "";
720 const selectedScopeValue =
721 selectedRunId === null
722 ? selectedGitScope
723 : selectedTurn?.runId === latestTurn?.runId
724 ? "latest"
725 : selectedTurnValue;
726 const selectScopeValue = (value: string) => {
727 if (value === "unstaged" || value === "branch") {
728 selectGitScope(value);
729 } else if (value === "latest") {
730 if (latestTurn) selectTurn(latestTurn.runId);
731 } else {
732 const turn = orderedTurnDiffSummaries.find((summary) => `turn:${summary.runId}` === value);
733 if (turn) selectTurn(turn.runId);
734 }
735 };
736
737 const headerRow = (
738 <>
739 <div className="flex min-w-0 flex-1 items-center gap-3 [-webkit-app-region:no-drag]">
740 <DropdownMenu>
741 <DropdownMenuTrigger
742 render={<Button size="xs" variant="secondary" />}
743 className="max-w-full"
744 aria-label={`Diff scope: ${selectedScopeLabel}`}
745 >
746 <span className="truncate">{selectedScopeLabel}</span>
747 <ChevronDownIcon className="size-3.5 shrink-0 opacity-70" />
748 </DropdownMenuTrigger>
749 <DropdownMenuContent align="start">
750 <DropdownMenuRadioGroup value={selectedScopeValue} onValueChange={selectScopeValue}>
751 <DropdownMenuRadioItem value="branch" closeOnClick>
752 <span>Changes</span>
753 </DropdownMenuRadioItem>
754 <DropdownMenuRadioItem value="unstaged" closeOnClick>
755 <span>Uncommitted</span>
756 </DropdownMenuRadioItem>
757 <DropdownMenuRadioItem value="latest" closeOnClick>
758 <span>Latest turn</span>
759 </DropdownMenuRadioItem>
760 </DropdownMenuRadioGroup>
761 <DropdownMenuSub>
762 <DropdownMenuSubTrigger>Turn</DropdownMenuSubTrigger>
763 <DropdownMenuSubContent>
764 <DropdownMenuRadioGroup value={selectedTurnValue} onValueChange={selectScopeValue}>
765 {orderedTurnDiffSummaries.map((summary) => {
766 const turnCount =
767 summary.checkpointTurnCount ??
768 inferredCheckpointTurnCountByRunId[summary.runId] ??
769 "?";
770 return (
771 <DropdownMenuRadioItem
772 key={summary.runId}
773 value={`turn:${summary.runId}`}
774 closeOnClick
775 >
776 <span className="flex items-center gap-2">
777 <span>Turn {turnCount}</span>
778 <span className="ml-auto text-xs tabular-nums text-muted-foreground">
779 {formatShortTimestamp(summary.completedAt, settings.timestampFormat)}
780 </span>
781 </span>
782 </DropdownMenuRadioItem>
783 );
784 })}
785 </DropdownMenuRadioGroup>
786 </DropdownMenuSubContent>
787 </DropdownMenuSub>
788 </DropdownMenuContent>
789 </DropdownMenu>
790 {selectedRunId === null && selectedGitScope === "branch" && selectedGitSource?.baseRef && (
791 <div
792 className="flex min-w-0 max-w-full items-center gap-2 overflow-hidden text-xs text-muted-foreground"
793 aria-label={`Comparing ${selectedGitSource.headRef ?? "HEAD"} against ${selectedGitSource.baseRef}`}
794 >
795 <Tooltip>
796 <TooltipTrigger render={<span className="flex min-w-0 items-center gap-2" />}>
797 <span className="min-w-0 max-w-48 truncate">
798 {selectedGitSource.headRef ?? "HEAD"}
799 </span>
800 <ArrowRightIcon className="size-3.5 shrink-0 opacity-70" />
801 </TooltipTrigger>
802 <TooltipPopup side="top">
803 {`${selectedGitSource.headRef ?? "HEAD"} → ${selectedGitSource.baseRef}`}
804 </TooltipPopup>
805 </Tooltip>
806 <Combobox
807 items={baseRefItems}
808 filteredItems={filteredBaseRefItems}
809 value={selectedBaseRef ?? AUTOMATIC_BASE_REF}
810 onOpenChange={(open) => {
811 if (!open) setBaseRefQuery("");
812 }}
813 onValueChange={(value) => {
814 if (!value) return;
815 selectBranchBaseRef(value === AUTOMATIC_BASE_REF ? null : value);
816 }}
817 >
818 <ComboboxTrigger
819 render={<Button variant="ghost-muted" size="xs" />}
820 className="min-w-0 max-w-48"
821 aria-label={`Change comparison target. Currently ${selectedGitSource.baseRef}`}
822 >
823 <span className="min-w-0 truncate">{selectedGitSource.baseRef}</span>
824 <ChevronDownIcon className="size-3.5 shrink-0 opacity-70" />
825 </ComboboxTrigger>
826 <ComboboxPopup
827 align="start"
828 className="w-72 min-w-0 max-w-[calc(100vw-1rem)] overflow-hidden"
829 >
830 <ComboboxSearchInput
831 placeholder="Search refs..."
832 value={baseRefQuery}
833 onChange={(event) => setBaseRefQuery(event.target.value)}
834 />
835 <div className="grid shrink-0 grid-cols-[1rem_minmax(0,1fr)] items-center gap-2 border-b border-border/70 ps-3 pe-6.5 pt-2 pb-1.5 font-medium text-3xs text-muted-foreground uppercase tracking-wide">
836 <span aria-hidden="true" />
837 <div className="grid min-w-0 grid-cols-[minmax(0,1fr)_2rem] items-center">
838 <span>Branch</span>
839 <span className="text-right">Remote</span>
840 </div>
841 </div>
842 <ComboboxEmpty>No matching refs.</ComboboxEmpty>
843 <ComboboxList className="max-h-64 min-w-0 overflow-x-hidden">
844 <ComboboxItem
845 className="w-full min-w-0 grid-cols-[1rem_minmax(0,1fr)]"
846 value={AUTOMATIC_BASE_REF}
847 >
848 <span className="block min-w-0 truncate">Automatic</span>
849 </ComboboxItem>
850 {baseRefChoices.map((choice) => {
851 const item = valueForBaseRefChoice(choice);
852 const hasBoth = choice.local !== null && choice.remote !== null;
853 const useRemote = choice.remote?.name === item;
854 return (
855 <ComboboxItem
856 key={choice.id}
857 className="w-full min-w-0 grid-cols-[1rem_minmax(0,1fr)]"
858 value={item}
859 >
860 <div className="grid w-full min-w-0 grid-cols-[minmax(0,1fr)_2rem] items-center overflow-hidden">
861 <span className="block min-w-0 truncate pe-2">{choice.label}</span>
862 {hasBoth ? (
863 <div
864 className="flex justify-end"
865 onClick={(event) => event.stopPropagation()}
866 onPointerDown={(event) => event.stopPropagation()}
867 >
868 <Switch
869 aria-label={`Use remote version of ${choice.label}`}
870 checked={useRemote}
871 className="[--thumb-size:--spacing(3)]"
872 onCheckedChange={(checked) => {
873 const nextRef = checked
874 ? choice.remote?.name
875 : choice.local?.name;
876 if (nextRef) selectBranchBaseRef(nextRef);
877 }}
878 />
879 </div>
880 ) : choice.remote ? (
881 <Tooltip>
882 <TooltipTrigger
883 render={
884 <span className="flex justify-end text-muted-foreground">
885 <CheckIcon
886 role="img"
887 aria-label="Remote only"
888 className="size-3"
889 />
890 </span>
891 }
892 />
893 <TooltipPopup side="top">Remote only</TooltipPopup>
894 </Tooltip>
895 ) : null}
896 </div>
897 </ComboboxItem>
898 );
899 })}
900 </ComboboxList>
901 </ComboboxPopup>
902 </Combobox>
903 </div>
904 )}
905 </div>
906 <div className="flex shrink-0 items-center gap-1 [-webkit-app-region:no-drag]">
907 {codeViewFiles.length > 0 || (!selectedTurn && selectedGitSource?.files?.length) ? (
908 <DiffStatLabel
909 additions={diffLineStat.additions}
910 deletions={diffLineStat.deletions}
911 className="mr-1 text-2xs"
912 layout="inline"
913 />
914 ) : null}
915 {canRefreshGitDiff && (
916 <Tooltip>
917 <TooltipTrigger
918 render={
919 <Button
920 type="button"
921 size="icon-sm"
922 variant="ghost"
923 aria-label={isRefreshingDiff ? "Refreshing diff" : "Refresh diff"}
924 onClick={refreshDiffFromUserAction}
925 />
926 }
927 >
928 <RefreshIcon size="sm" refreshing={isRefreshingDiff} />
929 </TooltipTrigger>
930 <TooltipPopup side="top">
931 {isRefreshingDiff ? "Refreshing diff…" : "Refresh diff"}
932 </TooltipPopup>
933 </Tooltip>
934 )}
935 {diffFileKeys.length > 0 && (
936 <Tooltip>
937 <TooltipTrigger
938 render={
939 <Button
940 type="button"
941 size="icon-sm"
942 variant="ghost"
943 aria-label={allDiffFilesCollapsed ? "Expand all files" : "Collapse all files"}
944 onClick={toggleDiffFileCollapse}
945 />
946 }
947 >
948 <MorphIcon
949 className="size-3.5"
950 icon={allDiffFilesCollapsed ? ChevronsUpDown : ChevronsDownUp}
951 />
952 </TooltipTrigger>
953 <TooltipPopup side="top">
954 {allDiffFilesCollapsed ? "Expand all files" : "Collapse all files"}
955 </TooltipPopup>
956 </Tooltip>
957 )}
958 <ToggleGroup
959 aria-label="Diff layout"
960 className="shrink-0"
961 variant="segmented"
962 value={[diffLayout]}
963 onValueChange={(value) => {
964 const next = value[0];
965 if (next === "stacked" || next === "split") {
966 updateClientSettings({ diffLayout: next });
967 }
968 }}
969 >
970 <Toggle aria-label="Stacked diff view" value="stacked">
971 <Rows3Icon className="size-3.5" />
972 </Toggle>
973 <Toggle aria-label="Split diff view" value="split">
974 <Columns2Icon className="size-3.5" />
975 </Toggle>
976 </ToggleGroup>
977 <Tooltip>
978 <TooltipTrigger
979 render={
980 <Toggle
981 aria-label={wordWrap ? "Disable diff line wrapping" : "Enable diff line wrapping"}
982 variant="ghost"
983 size="sm"
984 pressed={wordWrap}
985 onPressedChange={(pressed) => {
986 setWordWrap(Boolean(pressed));
987 }}
988 />
989 }
990 >
991 <TextWrapIcon className="size-3.5" />
992 </TooltipTrigger>
993 <TooltipPopup side="top">
994 {wordWrap ? "Disable line wrapping" : "Enable line wrapping"}
995 </TooltipPopup>
996 </Tooltip>
997 <Tooltip>
998 <TooltipTrigger
999 render={
1000 <Toggle
1001 aria-label={
1002 diffIgnoreWhitespace ? "Show whitespace changes" : "Hide whitespace changes"
1003 }
1004 variant="ghost"
1005 size="sm"
1006 pressed={diffIgnoreWhitespace}
1007 onPressedChange={(pressed) => {
1008 setDiffIgnoreWhitespace(Boolean(pressed));
1009 }}
1010 />
1011 }
1012 >
1013 <PilcrowIcon className="size-3.5" />
1014 </TooltipTrigger>
1015 <TooltipPopup side="top">
1016 {diffIgnoreWhitespace ? "Show whitespace changes" : "Hide whitespace changes"}
1017 </TooltipPopup>
1018 </Tooltip>
1019 {diffFileKeys.length > 0 && (
1020 <Tooltip>
1021 <TooltipTrigger
1022 render={
1023 <Toggle
1024 aria-label={fileTreeOpen ? "Hide file tree" : "Show file tree"}
1025 variant="ghost"
1026 size="sm"
1027 pressed={fileTreeOpen}
1028 onPressedChange={(pressed) => setFileTreeOpen(Boolean(pressed))}
1029 />
1030 }
1031 >
1032 <FolderTreeIcon className="size-3.5" />
1033 </TooltipTrigger>
1034 <TooltipPopup side="top">
1035 {fileTreeOpen ? "Hide file tree" : "Show file tree"}
1036 </TooltipPopup>
1037 </Tooltip>
1038 )}
1039 </div>
1040 </>
1041 );
1042
1043 return (
1044 <DiffPanelShell mode={mode} header={headerRow}>
1045 {!activeThread ? (
1046 <div className="flex flex-1 items-center justify-center px-5 text-center text-xs text-muted-foreground/70">
1047 Select a thread to inspect turn diffs.
1048 </div>
1049 ) : !isGitRepo ? (
1050 <div className="flex flex-1 items-center justify-center px-5 text-center text-xs text-muted-foreground/70">
1051 Turn diffs are unavailable because this project is not a git repository.
1052 </div>
1053 ) : selectedRunId !== null && orderedTurnDiffSummaries.length === 0 ? (
1054 <div className="flex flex-1 items-center justify-center px-5 text-center text-xs text-muted-foreground/70">
1055 No completed turns yet.
1056 </div>
1057 ) : selectedRunId === null && !canReadFiles ? (
1058 fileAccess.isPending ? (
1059 <DiffPanelLoadingState label="Checking file access..." />
1060 ) : (
1061 <div className="flex flex-1 items-center justify-center px-5 text-center text-xs text-muted-foreground/70">
1062 {fileAccess.error ?? "This connection cannot read local diffs."}
1063 </div>
1064 )
1065 ) : (
1066 <>
1067 <div className="flex min-h-0 min-w-0 flex-1 flex-col overflow-hidden bg-background">
1068 {isSelectedPatchTruncated && !lazySource && (
1069 <p className="shrink-0 border-b border-border/70 bg-muted/40 px-3 py-1.5 text-2xs text-muted-foreground">
1070 This preview exceeds the size limit. Changes shown are incomplete.
1071 {selectedGitSource?.files ? " Totals include all changes." : ""}
1072 </p>
1073 )}
1074 {selectedPatchError && !renderablePatch && (
1075 <div className="px-3">
1076 <p className="mb-2 text-2xs text-error/80">{selectedPatchError}</p>
1077 </div>
1078 )}
1079 {!renderablePatch && !lazySource ? (
1080 isLoadingSelectedPatch ? (
1081 <DiffPanelLoadingState
1082 label={
1083 selectedTurn
1084 ? "Loading checkpoint diff..."
1085 : selectedGitScope === "unstaged"
1086 ? "Loading uncommitted changes..."
1087 : "Loading changes..."
1088 }
1089 />
1090 ) : (
1091 <div className="flex h-full items-center justify-center px-3 py-2 text-xs text-muted-foreground/70">
1092 <p>
1093 {hasNoNetChanges
1094 ? "No net changes in this selection."
1095 : "No patch available for this selection."}
1096 </p>
1097 </div>
1098 )
1099 ) : lazySource || renderablePatch?.kind === "files" ? (
1100 <div className="flex min-h-0 flex-1 overflow-hidden">
1101 <div
1102 className="min-h-0 min-w-0 flex-1"
1103 onClickCapture={(event) => {
1104 const composedPath = event.nativeEvent.composedPath?.() ?? [];
1105 for (const node of composedPath) {
1106 if (!(node instanceof HTMLElement)) continue;
1107 // Header controls keep their own actions. In particular, the chevron must
1108 // not also trigger the row handler or the two toggles cancel each other.
1109 if (node instanceof HTMLButtonElement || node instanceof HTMLAnchorElement) {
1110 return;
1111 }
1112 }
1113 const title = composedPath.find(
1114 (node): node is HTMLElement =>
1115 node instanceof HTMLElement && node.hasAttribute("data-title"),
1116 );
1117 const filePath = title?.textContent;
1118 // The filename remains the explicit "open in editor" affordance.
1119 if (filePath) {
1120 openDiffFile(filePath);
1121 return;
1122 }
1123 const header = composedPath.find(
1124 (node): node is HTMLElement =>
1125 node instanceof HTMLElement && node.hasAttribute("data-diffs-header"),
1126 );
1127 const headerFilePath = header?.querySelector("[data-title]")?.textContent;
1128 if (!headerFilePath) return;
1129 const file = codeViewFiles.find(
1130 (candidate) => candidate.filePath === headerFilePath,
1131 );
1132 if (file) toggleDiffFileCollapsed(file.fileKey);
1133 }}
1134 onContextMenuCapture={(event) => {
1135 const composedPath = event.nativeEvent.composedPath?.() ?? [];
1136 const title = composedPath.find(
1137 (node): node is HTMLElement =>
1138 node instanceof HTMLElement && node.hasAttribute("data-title"),
1139 );
1140 const filePath = title?.textContent?.trim();
1141 if (!filePath) return;
1142 event.preventDefault();
1143 onFileContextMenu(
1144 {
1145 environmentId: activeThread?.environmentId ?? null,
1146 filePath,
1147 workspaceRoot: activeCwd,
1148 repositoryRoot: activeRepositoryRoot,
1149 },
1150 event,
1151 );
1152 }}
1153 >
1154 <AnnotatableCodeView
1155 key={collapseScopeKey ?? reviewSectionId}
1156 viewerRef={setCodeView}
1157 codeViewKey={`${codeViewMountKey}:${lazySource ? filePatchScope : "preview"}`}
1158 className="h-full min-h-0 overflow-auto"
1159 files={codeViewFiles}
1160 renderCodeViewFooter={renderLoadingBoundary}
1161 sectionId={reviewSectionId}
1162 sectionTitle={reviewSectionTitle}
1163 composerDraftTarget={composerDraftTarget}
1164 renderHeaderFilenameSuffix={(fileDiff) => {
1165 const path = resolveFileDiffPath(fileDiff);
1166 const state = fileStates.get(path);
1167 return (
1168 <DiffFileHeaderSuffix
1169 filePath={path}
1170 hasStat={fileStats.has(path)}
1171 error={state?.error ?? false}
1172 truncated={state?.truncated ?? false}
1173 onRetry={retry}
1174 />
1175 );
1176 }}
1177 {...(lazySource
1178 ? {
1179 unsafeCSSExtra:
1180 "[data-additions-count], [data-deletions-count] { display: none; }",
1181 renderHeaderMetadata: (fileDiff: FileDiffMetadata) => {
1182 const stat = fileStats.get(resolveFileDiffPath(fileDiff));
1183 return stat ? (
1184 <DiffStatLabel
1185 additions={stat.additions}
1186 deletions={stat.deletions}
1187 />
1188 ) : null;
1189 },
1190 }
1191 : {})}
1192 onRevealSearchMatch={unfoldDiffFile}
1193 renderHeaderPrefix={(fileDiff, fileKey) => {
1194 const unavailable = fileDiff.cacheKey?.endsWith(":pending") === true;
1195 return (
1196 <DiffFileCollapseToggle
1197 filePath={resolveFileDiffPath(fileDiff)}
1198 fileKey={fileKey}
1199 collapsed={unavailable || collapsedDiffFileKeys.has(fileKey)}
1200 unavailable={unavailable}
1201 iconClassName={getDiffCollapseIconClassName(fileDiff)}
1202 onToggle={toggleDiffFileCollapsed}
1203 />
1204 );
1205 }}
1206 options={{
1207 diffStyle: diffLayout === "split" ? "split" : "unified",
1208 lineDiffType: "none",
1209 overflow: wordWrap ? "wrap" : "scroll",
1210 theme: resolveDiffThemeName(resolvedTheme),
1211 preferredHighlighter: PREFERRED_HIGHLIGHTER,
1212 themeType: resolvedTheme as DiffThemeType,
1213 stickyHeaders: true,
1214 ...(currentLoadDiffFiles ? { loadDiffFiles } : {}),
1215 }}
1216 />
1217 </div>
1218 {fileTreeOpen ? (
1219 <aside className="flex w-[min(16rem,40%)] min-w-40 shrink-0 border-l border-border/60">
1220 <DiffFileTree
1221 ariaLabel={`${reviewSectionTitle} files`}
1222 entries={fileTreeEntries}
1223 selectedPath={selectedFilePath}
1224 revealRequestId={selectedFileRevealRequestId}
1225 onSelectFile={revealDiffFile}
1226 />
1227 </aside>
1228 ) : null}
1229 </div>
1230 ) : (
1231 <div className="min-h-0 flex-1 overflow-auto p-2">
1232 <div className="space-y-2">
1233 <p className="text-2xs text-muted-foreground/75">
1234 {renderablePatch?.kind === "raw" ? renderablePatch.reason : null}
1235 </p>
1236 <pre
1237 className={cn(
1238 "max-h-[72vh] rounded-md border border-border/70 bg-background/70 p-3 font-mono text-2xs leading-relaxed text-muted-foreground/90",
1239 wordWrap
1240 ? "overflow-auto whitespace-pre-wrap wrap-break-word"
1241 : "overflow-auto",
1242 )}
1243 >
1244 {renderablePatch?.kind === "raw" ? renderablePatch.text : null}
1245 </pre>
1246 </div>
1247 </div>
1248 )}
1249 </div>
1250 </>
1251 )}
1252 </DiffPanelShell>
1253 );
1254}