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 {
1111 CheckIcon,
1212 ChevronDownIcon,
1313 ChevronRightIcon,
14 ChevronsDownUpIcon,
15 ChevronsUpDownIcon,
1416 Columns2Icon,
1517 PilcrowIcon,
1618 Rows3Icon,
......@@ -32,6 +34,7 @@ import {
3234 resolveDiffThemeName,
3335 resolveFileDiffPath,
3436} from "../lib/diffRendering";
37import { areAllDiffFilesCollapsed, toggleAllDiffFiles } from "../lib/diffCollapse";
3538import { useTurnDiffSummaries } from "../hooks/useTurnDiffSummaries";
3639import { useProject, useThread } from "../state/entities";
3740import { resolveThreadRouteRef } from "../threadRoutes";
......@@ -39,6 +42,7 @@ import { useClientSettings } from "../hooks/useSettings";
3942import { formatShortTimestamp } from "../timestampFormat";
4043import { DiffPanelLoadingState, DiffPanelShell, type DiffPanelMode } from "./DiffPanelShell";
4144import { AnnotatableCodeView, type AnnotatableCodeViewHandle } from "./diffs/AnnotatableCodeView";
45import { Button } from "./ui/button";
4246import { ToggleGroup, Toggle } from "./ui/toggle-group";
4347import { Switch } from "./ui/switch";
4448import {
......@@ -446,6 +450,8 @@ export default function DiffPanel({
446450 }),
447451 [collapsedDiffFileKeys, renderableFiles],
448452 );
453 const diffFileKeys = useMemo(() => codeViewFiles.map((file) => file.fileKey), [codeViewFiles]);
454 const allDiffFilesCollapsed = areAllDiffFilesCollapsed(diffFileKeys, collapsedDiffFileKeys);
449455
450456 useEffect(() => {
451457 if (!selectedFilePath) return;
......@@ -496,6 +502,18 @@ export default function DiffPanel({
496502 [collapseScopeKey],
497503 );
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
499517 const selectTurn = (turnId: TurnId) => {
500518 if (!routeThreadRef) return;
501519 useDiffPanelStore.getState().selectTurn(routeThreadRef, turnId);
......@@ -695,6 +713,30 @@ export default function DiffPanel({
695713 )}
696714 </div>
697715 <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 )}
698740 <ToggleGroup
699741 className="shrink-0"
700742 variant="outline"
......@@ -824,7 +866,7 @@ export default function DiffPanel({
824866 <AnnotatableCodeView
825867 viewerRef={codeViewRef}
826868 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!"
828870 files={codeViewFiles}
829871 sectionId={reviewSectionId}
830872 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}