Skip to content

Commit 5b42730

Browse files
authored
Merge pull request #1 from OpenKnots/okcode/diff-file-collapse-toggle
Add per-file review collapse state to the diff panel
2 parents ac0e086 + ee31995 commit 5b42730

3 files changed

Lines changed: 367 additions & 26 deletions

File tree

apps/web/src/components/DiffPanel.tsx

Lines changed: 215 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,8 @@ import { useQuery } from "@tanstack/react-query";
44
import { useNavigate, useParams, useSearch } from "@tanstack/react-router";
55
import { ThreadId, type TurnId } from "@okcode/contracts";
66
import {
7+
CheckIcon,
8+
ChevronDownIcon,
79
ChevronLeftIcon,
810
ChevronRightIcon,
911
Columns2Icon,
@@ -27,12 +29,21 @@ import { resolvePathLinkTarget } from "../terminal-links";
2729
import { parseDiffRouteSearch, stripDiffSearchParams } from "../diffRouteSearch";
2830
import { useTheme } from "../hooks/useTheme";
2931
import { buildPatchCacheKey } from "../lib/diffRendering";
32+
import {
33+
expandDiffFile,
34+
reconcileDiffFileReviewState,
35+
toggleDiffFileAccepted,
36+
toggleDiffFileCollapsed,
37+
type DiffFileReviewStateByPath,
38+
} from "../lib/diffFileReviewState";
3039
import { resolveDiffThemeName } from "../lib/diffRendering";
3140
import { useTurnDiffSummaries } from "../hooks/useTurnDiffSummaries";
3241
import { useStore } from "../store";
3342
import { useAppSettings } from "../appSettings";
3443
import { formatShortTimestamp } from "../timestampFormat";
3544
import { DiffPanelLoadingState, DiffPanelShell, type DiffPanelMode } from "./DiffPanelShell";
45+
import { DiffStatLabel, hasNonZeroStat } from "./chat/DiffStatLabel";
46+
import { Button } from "./ui/button";
3647
import { ToggleGroup, Toggle } from "./ui/toggle-group";
3748

3849
type DiffRenderMode = "stacked" | "split";
@@ -157,6 +168,113 @@ function buildFileDiffRenderKey(fileDiff: FileDiffMetadata): string {
157168
return fileDiff.cacheKey ?? `${fileDiff.prevName ?? "none"}:${fileDiff.name}`;
158169
}
159170

171+
function summarizeFileDiffStats(fileDiff: FileDiffMetadata): {
172+
additions: number;
173+
deletions: number;
174+
} {
175+
return fileDiff.hunks.reduce(
176+
(summary, hunk) => ({
177+
additions: summary.additions + hunk.additionLines,
178+
deletions: summary.deletions + hunk.deletionLines,
179+
}),
180+
{ additions: 0, deletions: 0 },
181+
);
182+
}
183+
184+
function DiffFileSection(props: {
185+
fileDiff: FileDiffMetadata;
186+
filePath: string;
187+
fileKey: string;
188+
diffRenderMode: DiffRenderMode;
189+
diffWordWrap: boolean;
190+
resolvedTheme: "light" | "dark";
191+
collapsed: boolean;
192+
accepted: boolean;
193+
onOpenInEditor: (filePath: string) => void;
194+
onToggleCollapsed: (filePath: string) => void;
195+
onToggleAccepted: (filePath: string) => void;
196+
}) {
197+
const {
198+
accepted,
199+
collapsed,
200+
diffRenderMode,
201+
diffWordWrap,
202+
fileDiff,
203+
fileKey,
204+
filePath,
205+
onOpenInEditor,
206+
onToggleAccepted,
207+
onToggleCollapsed,
208+
resolvedTheme,
209+
} = props;
210+
const stats = summarizeFileDiffStats(fileDiff);
211+
212+
return (
213+
<section
214+
data-diff-file-path={filePath}
215+
className={cn(
216+
"diff-render-file mb-2 overflow-hidden rounded-md border border-border/70 bg-card/30 first:mt-2 last:mb-0",
217+
accepted && "border-success/40",
218+
)}
219+
>
220+
<div className="flex items-center gap-2 border-b border-border/60 bg-card/70 px-2 py-1.5">
221+
<Button
222+
size="icon-xs"
223+
variant="ghost"
224+
aria-label={collapsed ? `Expand ${filePath}` : `Collapse ${filePath}`}
225+
aria-expanded={!collapsed}
226+
onClick={() => onToggleCollapsed(filePath)}
227+
className="text-muted-foreground/80"
228+
>
229+
<ChevronDownIcon
230+
className={cn("size-3.5 transition-transform", collapsed && "-rotate-90")}
231+
/>
232+
</Button>
233+
<button
234+
type="button"
235+
className="min-w-0 flex-1 truncate text-left font-mono text-[11px] text-foreground/90 underline-offset-2 hover:underline"
236+
onClick={() => onOpenInEditor(filePath)}
237+
title={`Open ${filePath} in editor`}
238+
>
239+
{filePath}
240+
</button>
241+
{hasNonZeroStat(stats) && (
242+
<span className="hidden shrink-0 font-mono text-[10px] tabular-nums text-muted-foreground/80 sm:inline">
243+
<DiffStatLabel additions={stats.additions} deletions={stats.deletions} />
244+
</span>
245+
)}
246+
<Button
247+
size="xs"
248+
variant={accepted ? "secondary" : "outline"}
249+
onClick={() => onToggleAccepted(filePath)}
250+
className={cn(
251+
"gap-1.5",
252+
accepted && "border-success/30 bg-success/12 text-success hover:bg-success/18",
253+
)}
254+
>
255+
<CheckIcon className={cn("size-3.5", accepted ? "opacity-100" : "opacity-35")} />
256+
{accepted ? "Accepted" : "Accept"}
257+
</Button>
258+
</div>
259+
{!collapsed && (
260+
<div key={fileKey}>
261+
<FileDiff
262+
fileDiff={fileDiff}
263+
options={{
264+
diffStyle: diffRenderMode === "split" ? "split" : "unified",
265+
lineDiffType: "none",
266+
overflow: diffWordWrap ? "wrap" : "scroll",
267+
theme: resolveDiffThemeName(resolvedTheme),
268+
themeType: resolvedTheme as DiffThemeType,
269+
unsafeCSS: DIFF_PANEL_UNSAFE_CSS,
270+
}}
271+
/>
272+
</div>
273+
)}
274+
</section>
275+
);
276+
}
277+
160278
interface DiffPanelProps {
161279
mode?: DiffPanelMode;
162280
}
@@ -174,6 +292,9 @@ export default function DiffPanel({ mode = "inline" }: DiffPanelProps) {
174292
const previousDiffOpenRef = useRef(false);
175293
const [canScrollTurnStripLeft, setCanScrollTurnStripLeft] = useState(false);
176294
const [canScrollTurnStripRight, setCanScrollTurnStripRight] = useState(false);
295+
const [reviewStateBySelectionKey, setReviewStateBySelectionKey] = useState<
296+
Record<string, DiffFileReviewStateByPath>
297+
>({});
177298
const routeThreadId = useParams({
178299
strict: false,
179300
select: (params) => (params.threadId ? ThreadId.makeUnsafe(params.threadId) : null),
@@ -301,6 +422,20 @@ export default function DiffPanel({ mode = "inline" }: DiffPanelProps) {
301422
}),
302423
);
303424
}, [renderablePatch]);
425+
const patchReviewSelectionKey = useMemo(() => {
426+
if (!activeThreadId || !selectedPatch) {
427+
return null;
428+
}
429+
const scope = selectedTurn ? `turn:${selectedTurn.turnId}` : "conversation";
430+
return `${activeThreadId}:${scope}:${buildPatchCacheKey(selectedPatch, "diff-review")}`;
431+
}, [activeThreadId, selectedPatch, selectedTurn]);
432+
const renderableFilePaths = useMemo(
433+
() => renderableFiles.map((fileDiff) => resolveFileDiffPath(fileDiff)),
434+
[renderableFiles],
435+
);
436+
const activeReviewState = patchReviewSelectionKey
437+
? (reviewStateBySelectionKey[patchReviewSelectionKey] ?? {})
438+
: {};
304439

305440
useEffect(() => {
306441
if (diffOpen && !previousDiffOpenRef.current) {
@@ -309,6 +444,45 @@ export default function DiffPanel({ mode = "inline" }: DiffPanelProps) {
309444
previousDiffOpenRef.current = diffOpen;
310445
}, [diffOpen, settings.diffWordWrap]);
311446

447+
useEffect(() => {
448+
if (!patchReviewSelectionKey) {
449+
return;
450+
}
451+
setReviewStateBySelectionKey((current) => {
452+
const nextSelectionState = reconcileDiffFileReviewState(
453+
renderableFilePaths,
454+
current[patchReviewSelectionKey],
455+
);
456+
if (current[patchReviewSelectionKey] === nextSelectionState) {
457+
return current;
458+
}
459+
return {
460+
...current,
461+
[patchReviewSelectionKey]: nextSelectionState,
462+
};
463+
});
464+
}, [patchReviewSelectionKey, renderableFilePaths]);
465+
466+
useEffect(() => {
467+
if (!patchReviewSelectionKey || !selectedFilePath) {
468+
return;
469+
}
470+
setReviewStateBySelectionKey((current) => {
471+
const selectionState = current[patchReviewSelectionKey];
472+
if (!selectionState) {
473+
return current;
474+
}
475+
const nextSelectionState = expandDiffFile(selectionState, selectedFilePath);
476+
if (nextSelectionState === selectionState) {
477+
return current;
478+
}
479+
return {
480+
...current,
481+
[patchReviewSelectionKey]: nextSelectionState,
482+
};
483+
});
484+
}, [patchReviewSelectionKey, selectedFilePath]);
485+
312486
useEffect(() => {
313487
if (!selectedFilePath || !patchViewportRef.current) {
314488
return;
@@ -330,6 +504,30 @@ export default function DiffPanel({ mode = "inline" }: DiffPanelProps) {
330504
},
331505
[activeCwd],
332506
);
507+
const updateActiveReviewState = useCallback(
508+
(updater: (current: DiffFileReviewStateByPath) => DiffFileReviewStateByPath) => {
509+
if (!patchReviewSelectionKey) {
510+
return;
511+
}
512+
setReviewStateBySelectionKey((current) => ({
513+
...current,
514+
[patchReviewSelectionKey]: updater(current[patchReviewSelectionKey] ?? {}),
515+
}));
516+
},
517+
[patchReviewSelectionKey],
518+
);
519+
const onToggleFileAccepted = useCallback(
520+
(filePath: string) => {
521+
updateActiveReviewState((current) => toggleDiffFileAccepted(current, filePath));
522+
},
523+
[updateActiveReviewState],
524+
);
525+
const onToggleFileCollapsed = useCallback(
526+
(filePath: string) => {
527+
updateActiveReviewState((current) => toggleDiffFileCollapsed(current, filePath));
528+
},
529+
[updateActiveReviewState],
530+
);
333531

334532
const latestSelectedTurnId = orderedTurnDiffSummaries[0]?.turnId ?? null;
335533

@@ -604,34 +802,25 @@ export default function DiffPanel({ mode = "inline" }: DiffPanelProps) {
604802
const filePath = resolveFileDiffPath(fileDiff);
605803
const fileKey = buildFileDiffRenderKey(fileDiff);
606804
const themedFileKey = `${fileKey}:${resolvedTheme}`;
805+
const fileReviewState = activeReviewState[filePath] ?? {
806+
accepted: false,
807+
collapsed: false,
808+
};
607809
return (
608-
<div
810+
<DiffFileSection
609811
key={themedFileKey}
610-
data-diff-file-path={filePath}
611-
className="diff-render-file mb-2 rounded-md first:mt-2 last:mb-0"
612-
onClickCapture={(event) => {
613-
const nativeEvent = event.nativeEvent as MouseEvent;
614-
const composedPath = nativeEvent.composedPath?.() ?? [];
615-
const clickedHeader = composedPath.some((node) => {
616-
if (!(node instanceof Element)) return false;
617-
return node.hasAttribute("data-title");
618-
});
619-
if (!clickedHeader) return;
620-
openDiffFileInEditor(filePath);
621-
}}
622-
>
623-
<FileDiff
624-
fileDiff={fileDiff}
625-
options={{
626-
diffStyle: diffRenderMode === "split" ? "split" : "unified",
627-
lineDiffType: "none",
628-
overflow: diffWordWrap ? "wrap" : "scroll",
629-
theme: resolveDiffThemeName(resolvedTheme),
630-
themeType: resolvedTheme as DiffThemeType,
631-
unsafeCSS: DIFF_PANEL_UNSAFE_CSS,
632-
}}
633-
/>
634-
</div>
812+
accepted={fileReviewState.accepted}
813+
collapsed={fileReviewState.collapsed}
814+
diffRenderMode={diffRenderMode}
815+
diffWordWrap={diffWordWrap}
816+
fileDiff={fileDiff}
817+
fileKey={themedFileKey}
818+
filePath={filePath}
819+
onOpenInEditor={openDiffFileInEditor}
820+
onToggleAccepted={onToggleFileAccepted}
821+
onToggleCollapsed={onToggleFileCollapsed}
822+
resolvedTheme={resolvedTheme}
823+
/>
635824
);
636825
})}
637826
</Virtualizer>
Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,84 @@
1+
import { describe, expect, it } from "vitest";
2+
import {
3+
expandDiffFile,
4+
reconcileDiffFileReviewState,
5+
toggleDiffFileAccepted,
6+
toggleDiffFileCollapsed,
7+
} from "./diffFileReviewState";
8+
9+
describe("reconcileDiffFileReviewState", () => {
10+
it("preserves existing state for known files and drops removed files", () => {
11+
expect(
12+
reconcileDiffFileReviewState(["src/a.ts"], {
13+
"src/a.ts": { accepted: true, collapsed: true },
14+
"src/b.ts": { accepted: false, collapsed: true },
15+
}),
16+
).toEqual({
17+
"src/a.ts": { accepted: true, collapsed: true },
18+
});
19+
});
20+
21+
it("initializes new files as unaccepted and expanded", () => {
22+
expect(reconcileDiffFileReviewState(["src/a.ts"], undefined)).toEqual({
23+
"src/a.ts": { accepted: false, collapsed: false },
24+
});
25+
});
26+
});
27+
28+
describe("toggleDiffFileAccepted", () => {
29+
it("marks a file accepted and collapses it", () => {
30+
expect(toggleDiffFileAccepted({}, "src/a.ts")).toEqual({
31+
"src/a.ts": { accepted: true, collapsed: true },
32+
});
33+
});
34+
35+
it("clears acceptance and re-expands the file", () => {
36+
expect(
37+
toggleDiffFileAccepted(
38+
{
39+
"src/a.ts": { accepted: true, collapsed: true },
40+
},
41+
"src/a.ts",
42+
),
43+
).toEqual({
44+
"src/a.ts": { accepted: false, collapsed: false },
45+
});
46+
});
47+
});
48+
49+
describe("toggleDiffFileCollapsed", () => {
50+
it("toggles collapsed without changing acceptance", () => {
51+
expect(
52+
toggleDiffFileCollapsed(
53+
{
54+
"src/a.ts": { accepted: true, collapsed: true },
55+
},
56+
"src/a.ts",
57+
),
58+
).toEqual({
59+
"src/a.ts": { accepted: true, collapsed: false },
60+
});
61+
});
62+
});
63+
64+
describe("expandDiffFile", () => {
65+
it("expands a collapsed file without clearing acceptance", () => {
66+
expect(
67+
expandDiffFile(
68+
{
69+
"src/a.ts": { accepted: true, collapsed: true },
70+
},
71+
"src/a.ts",
72+
),
73+
).toEqual({
74+
"src/a.ts": { accepted: true, collapsed: false },
75+
});
76+
});
77+
78+
it("returns the same object when the file is already expanded", () => {
79+
const state = {
80+
"src/a.ts": { accepted: false, collapsed: false },
81+
};
82+
expect(expandDiffFile(state, "src/a.ts")).toBe(state);
83+
});
84+
});

0 commit comments

Comments
 (0)