authorgravatar for hi@4bs3nt.comjan <hi@4bs3nt.com> 2026-07-25 00:15:45+02:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-07-24 18:15:45-04:00
log38cfc25e5422e468303f2010f639cf3de9ad89ba
tree3f935e0c4e333896392ec2a1bbe478c1f51a343c
parent41a430a88e8dde9c428f59d54dd328aa6a66a8fd
signature Signed by PGP key B5690EEEBB952194

feat(web): add collapse-all toggle to diff panel (#4475)


3 files changed, 78 insertions(+), 1 deletions(-)

apps/web/src/components/DiffPanel.tsx+43-1
...@@ -11,6 +11,8 @@ import {...@@ -11,6 +11,8 @@ import {
11 CheckIcon,11 CheckIcon,
12 ChevronDownIcon,12 ChevronDownIcon,
13 ChevronRightIcon,13 ChevronRightIcon,
14 ChevronsDownUpIcon,
15 ChevronsUpDownIcon,
14 Columns2Icon,16 Columns2Icon,
15 PilcrowIcon,17 PilcrowIcon,
16 Rows3Icon,18 Rows3Icon,
...@@ -32,6 +34,7 @@ import {...@@ -32,6 +34,7 @@ import {
32 resolveDiffThemeName,34 resolveDiffThemeName,
33 resolveFileDiffPath,35 resolveFileDiffPath,
34} from "../lib/diffRendering";36} from "../lib/diffRendering";
37import { areAllDiffFilesCollapsed, toggleAllDiffFiles } from "../lib/diffCollapse";
35import { useTurnDiffSummaries } from "../hooks/useTurnDiffSummaries";38import { useTurnDiffSummaries } from "../hooks/useTurnDiffSummaries";
36import { useProject, useThread } from "../state/entities";39import { useProject, useThread } from "../state/entities";
37import { resolveThreadRouteRef } from "../threadRoutes";40import { resolveThreadRouteRef } from "../threadRoutes";
...@@ -39,6 +42,7 @@ import { useClientSettings } from "../hooks/useSettings";...@@ -39,6 +42,7 @@ import { useClientSettings } from "../hooks/useSettings";
39import { formatShortTimestamp } from "../timestampFormat";42import { formatShortTimestamp } from "../timestampFormat";
40import { DiffPanelLoadingState, DiffPanelShell, type DiffPanelMode } from "./DiffPanelShell";43import { DiffPanelLoadingState, DiffPanelShell, type DiffPanelMode } from "./DiffPanelShell";
41import { AnnotatableCodeView, type AnnotatableCodeViewHandle } from "./diffs/AnnotatableCodeView";44import { AnnotatableCodeView, type AnnotatableCodeViewHandle } from "./diffs/AnnotatableCodeView";
45import { Button } from "./ui/button";
42import { ToggleGroup, Toggle } from "./ui/toggle-group";46import { ToggleGroup, Toggle } from "./ui/toggle-group";
43import { Switch } from "./ui/switch";47import { Switch } from "./ui/switch";
44import {48import {
...@@ -446,6 +450,8 @@ export default function DiffPanel({...@@ -446,6 +450,8 @@ export default function DiffPanel({
446 }),450 }),
447 [collapsedDiffFileKeys, renderableFiles],451 [collapsedDiffFileKeys, renderableFiles],
448 );452 );
453 const diffFileKeys = useMemo(() => codeViewFiles.map((file) => file.fileKey), [codeViewFiles]);
454 const allDiffFilesCollapsed = areAllDiffFilesCollapsed(diffFileKeys, collapsedDiffFileKeys);
449455
450 useEffect(() => {456 useEffect(() => {
451 if (!selectedFilePath) return;457 if (!selectedFilePath) return;
...@@ -496,6 +502,18 @@ export default function DiffPanel({...@@ -496,6 +502,18 @@ export default function DiffPanel({
496 [collapseScopeKey],502 [collapseScopeKey],
497 );503 );
498504
505 const toggleDiffFileCollapse = useCallback(() => {
506 setCollapsedDiffFiles((current) => {
507 const currentKeys =
508 current.scopeKey === collapseScopeKey ? current.fileKeys : EMPTY_COLLAPSED_DIFF_FILE_KEYS;
509
510 return {
511 scopeKey: collapseScopeKey,
512 fileKeys: toggleAllDiffFiles(diffFileKeys, currentKeys),
513 };
514 });
515 }, [collapseScopeKey, diffFileKeys]);
516
499 const selectTurn = (turnId: TurnId) => {517 const selectTurn = (turnId: TurnId) => {
500 if (!routeThreadRef) return;518 if (!routeThreadRef) return;
501 useDiffPanelStore.getState().selectTurn(routeThreadRef, turnId);519 useDiffPanelStore.getState().selectTurn(routeThreadRef, turnId);
...@@ -695,6 +713,30 @@ export default function DiffPanel({...@@ -695,6 +713,30 @@ export default function DiffPanel({
695 )}713 )}
696 </div>714 </div>
697 <div className="flex shrink-0 items-center gap-1 [-webkit-app-region:no-drag]">715 <div className="flex shrink-0 items-center gap-1 [-webkit-app-region:no-drag]">
716 {codeViewFiles.length > 0 && (
717 <Tooltip>
718 <TooltipTrigger
719 render={
720 <Button
721 type="button"
722 size="icon-xs"
723 variant="outline"
724 aria-label={allDiffFilesCollapsed ? "Expand all files" : "Collapse all files"}
725 onClick={toggleDiffFileCollapse}
726 />
727 }
728 >
729 {allDiffFilesCollapsed ? (
730 <ChevronsUpDownIcon className="size-3" />
731 ) : (
732 <ChevronsDownUpIcon className="size-3" />
733 )}
734 </TooltipTrigger>
735 <TooltipPopup side="top">
736 {allDiffFilesCollapsed ? "Expand all files" : "Collapse all files"}
737 </TooltipPopup>
738 </Tooltip>
739 )}
698 <ToggleGroup740 <ToggleGroup
699 className="shrink-0"741 className="shrink-0"
700 variant="outline"742 variant="outline"
...@@ -824,7 +866,7 @@ export default function DiffPanel({...@@ -824,7 +866,7 @@ export default function DiffPanel({
824 <AnnotatableCodeView866 <AnnotatableCodeView
825 viewerRef={codeViewRef}867 viewerRef={codeViewRef}
826 key={collapseScopeKey ?? reviewSectionId}868 key={collapseScopeKey ?? reviewSectionId}
827 className="diff-render-surface h-full min-h-0 overflow-auto"869 className="diff-render-surface h-full min-h-0 overflow-auto [&>div>div:last-child]:top-0! [&>div>div:last-child]:bottom-auto!"
828 files={codeViewFiles}870 files={codeViewFiles}
829 sectionId={reviewSectionId}871 sectionId={reviewSectionId}
830 sectionTitle={reviewSectionTitle}872 sectionTitle={reviewSectionTitle}
apps/web/src/lib/diffCollapse.test.ts created+22
...@@ -0,0 +1,22 @@
1import { describe, expect, it } from "vite-plus/test";
2
3import { areAllDiffFilesCollapsed, toggleAllDiffFiles } from "./diffCollapse";
4
5const FILE_KEYS = ["src/app.ts", "src/index.ts"];
6const FIRST_FILE_KEY = FILE_KEYS[0]!;
7
8describe("diff collapse controls", () => {
9 it("reports whether every rendered file is collapsed", () => {
10 expect(areAllDiffFilesCollapsed(FILE_KEYS, new Set(FILE_KEYS))).toBe(true);
11 expect(areAllDiffFilesCollapsed(FILE_KEYS, new Set([FIRST_FILE_KEY]))).toBe(false);
12 expect(areAllDiffFilesCollapsed([], new Set())).toBe(false);
13 });
14
15 it("collapses all files when any rendered file is expanded", () => {
16 expect(toggleAllDiffFiles(FILE_KEYS, new Set([FIRST_FILE_KEY]))).toEqual(new Set(FILE_KEYS));
17 });
18
19 it("expands all files when every rendered file is collapsed", () => {
20 expect(toggleAllDiffFiles(FILE_KEYS, new Set(FILE_KEYS))).toEqual(new Set());
21 });
22});
apps/web/src/lib/diffCollapse.ts created+13
...@@ -0,0 +1,13 @@
1export function areAllDiffFilesCollapsed(
2 fileKeys: ReadonlyArray<string>,
3 collapsedFileKeys: ReadonlySet<string>,
4): boolean {
5 return fileKeys.length > 0 && fileKeys.every((fileKey) => collapsedFileKeys.has(fileKey));
6}
7
8export function toggleAllDiffFiles(
9 fileKeys: ReadonlyArray<string>,
10 collapsedFileKeys: ReadonlySet<string>,
11): ReadonlySet<string> {
12 return areAllDiffFilesCollapsed(fileKeys, collapsedFileKeys) ? new Set() : new Set(fileKeys);
13}