diff --git a/.changeset/collapse-files.md b/.changeset/collapse-files.md
new file mode 100644
index 00000000..33431a8e
--- /dev/null
+++ b/.changeset/collapse-files.md
@@ -0,0 +1,5 @@
+---
+"hunkdiff": minor
+---
+
+Collapse and expand files in the review stream like a GitHub diff. Press `x` to collapse the selected file to its header, `Shift+X` to collapse or expand every file, or click the chevron in any file header. Collapse state is session-only and survives watch-mode reloads.
diff --git a/src/core/types.ts b/src/core/types.ts
index 4c6c5c95..5c8649c8 100644
--- a/src/core/types.ts
+++ b/src/core/types.ts
@@ -58,6 +58,9 @@ export interface DiffFile {
isBinary?: boolean;
isTooLarge?: boolean;
statsTruncated?: boolean;
+ // Set on the placeholder variant when the user has collapsed this file in the
+ // review stream. Drives the collapsed-body message and the header chevron.
+ isCollapsed?: boolean;
// Optional capability for fetching the file's full text on either side.
// Loaders attach this when source content is reachable; absent when not.
sourceFetcher?: FileSourceFetcher;
diff --git a/src/ui/App.tsx b/src/ui/App.tsx
index 8b5b0985..3b781c83 100644
--- a/src/ui/App.tsx
+++ b/src/ui/App.tsx
@@ -372,6 +372,11 @@ export function App({
setShowAgentNotes((current) => !current);
};
+ /** Collapse or expand the currently selected file in the review stream. */
+ const toggleSelectedFileCollapsed = () => {
+ review.toggleFileCollapsed(review.selectedFileId);
+ };
+
/** Toggle line-number gutters without changing the diff content itself. */
const toggleLineNumbers = () => {
setShowLineNumbers((current) => !current);
@@ -758,6 +763,8 @@ export function App({
toggleCopyDecorations,
toggleAgentNotes,
toggleFocusArea,
+ toggleSelectedFileCollapsed,
+ toggleAllFilesCollapsed: review.toggleAllFilesCollapsed,
openAgentSkill,
toggleHelp,
toggleHunkHeaders,
@@ -790,6 +797,8 @@ export function App({
renderSidebar,
toggleAgentNotes,
toggleFocusArea,
+ toggleSelectedFileCollapsed,
+ review.toggleAllFilesCollapsed,
toggleHelp,
toggleHunkHeaders,
toggleLineNumbers,
@@ -850,6 +859,8 @@ export function App({
toggleAgentNotes,
toggleFocusArea,
toggleGapForSelectedHunk: review.toggleSelectedHunkGap,
+ toggleSelectedFileCollapsed,
+ toggleAllFilesCollapsed: review.toggleAllFilesCollapsed,
toggleHelp,
toggleHunkHeaders,
toggleLineNumbers,
@@ -1047,6 +1058,7 @@ export function App({
onCopyFeedback={showTransientNotice}
onSelectFile={jumpToFile}
onToggleGap={review.toggleGap}
+ onToggleCollapse={review.toggleFileCollapsed}
onViewportCenteredHunkChange={(fileId, hunkIndex) =>
review.selectHunk(fileId, hunkIndex, { preserveViewport: true })
}
diff --git a/src/ui/AppHost.interactions.test.tsx b/src/ui/AppHost.interactions.test.tsx
index 8dba4a6e..d2be44e4 100644
--- a/src/ui/AppHost.interactions.test.tsx
+++ b/src/ui/AppHost.interactions.test.tsx
@@ -532,7 +532,8 @@ function firstCrossFileHunkNavigationHeader(frame: string) {
return (
frame
.split("\n")
- .map((line) => line.trim())
+ // Strip the leading collapse chevron so header matching stays filename-first.
+ .map((line) => line.trim().replace(/^[▸▾]\s*/, ""))
.find((line) => line.startsWith("long-file.txt") || line.startsWith("short-file.ts")) ?? ""
);
}
@@ -762,6 +763,49 @@ describe("App interactions", () => {
}
});
+ test("Shift-X collapses and expands every file, not just the selected one", async () => {
+ // Two files so the all-files binding is distinguishable from the single-file x binding:
+ // a single-file toggle would leave beta expanded, whereas Shift-X must collapse it too.
+ const setup = await testRender(, {
+ width: 240,
+ height: 24,
+ });
+
+ try {
+ await flush(setup);
+
+ let frame = setup.captureCharFrame();
+ expect(frame).toContain("add = true");
+ expect(frame).toContain("betaValue");
+
+ await act(async () => {
+ await setup.mockInput.pressKey("x", { shift: true });
+ });
+ await flush(setup);
+
+ // Both files collapse to placeholders; if Shift-X routed to the single-file
+ // toggle instead, beta's "betaValue" line would still be on screen.
+ frame = setup.captureCharFrame();
+ expect(frame).toContain("Collapsed");
+ expect(frame).not.toContain("add = true");
+ expect(frame).not.toContain("betaValue");
+
+ await act(async () => {
+ await setup.mockInput.pressKey("x", { shift: true });
+ });
+ await flush(setup);
+
+ frame = setup.captureCharFrame();
+ expect(frame).not.toContain("Collapsed");
+ expect(frame).toContain("add = true");
+ expect(frame).toContain("betaValue");
+ } finally {
+ await act(async () => {
+ setup.renderer.destroy();
+ });
+ }
+ });
+
test("configured hidden menu bar starts hidden while menus remain keyboard-accessible", async () => {
const setup = await testRender(
,
diff --git a/src/ui/components/chrome/HelpDialog.tsx b/src/ui/components/chrome/HelpDialog.tsx
index 4e3d1835..e524c0bb 100644
--- a/src/ui/components/chrome/HelpDialog.tsx
+++ b/src/ui/components/chrome/HelpDialog.tsx
@@ -48,6 +48,7 @@ export function HelpDialog({
["a", "toggle AI notes"],
["z", "toggle unchanged context"],
["l / w / m / M", "lines / wrap / metadata / menu"],
+ ["x / X", "collapse file / all files"],
["e", "open file in $EDITOR"],
],
},
diff --git a/src/ui/components/panes/DiffFileHeaderRow.tsx b/src/ui/components/panes/DiffFileHeaderRow.tsx
index 81b135bd..0f7cd144 100644
--- a/src/ui/components/panes/DiffFileHeaderRow.tsx
+++ b/src/ui/components/panes/DiffFileHeaderRow.tsx
@@ -1,3 +1,4 @@
+import type { MouseEvent as TuiMouseEvent } from "@opentui/core";
import type { DiffFile } from "../../../core/types";
import { fileLabelParts } from "../../lib/files";
import { fitText } from "../../lib/text";
@@ -8,20 +9,32 @@ interface DiffFileHeaderRowProps {
headerLabelWidth: number;
headerStatsWidth: number;
theme: AppTheme;
+ collapsed?: boolean;
onSelect?: () => void;
+ onToggleCollapse?: () => void;
}
+// Disclosure chevrons mirror GitHub's collapse affordance: ▸ when collapsed,
+// ▾ when expanded. The trailing space keeps the filename aligned either way.
+const COLLAPSE_CHEVRON = "▸ ";
+const EXPAND_CHEVRON = "▾ ";
+
/** Render one file header row in the review stream or sticky overlay. */
export function DiffFileHeaderRow({
file,
headerLabelWidth,
headerStatsWidth,
theme,
+ collapsed = false,
onSelect,
+ onToggleCollapse,
}: DiffFileHeaderRowProps) {
const additionsText = `+${file.stats.additions}${file.statsTruncated ? "+" : ""}`;
const deletionsText = `-${file.stats.deletions}`;
const { filename, stateLabel } = fileLabelParts(file);
+ const chevron = collapsed ? COLLAPSE_CHEVRON : EXPAND_CHEVRON;
+ // The chevron consumes header width; reserve it so the filename doesn't overflow.
+ const labelWidth = Math.max(1, headerLabelWidth - chevron.length - (stateLabel?.length ?? 0));
return (
{/* Clicking the file header jumps the main stream selection without collapsing to a single-file view. */}
-
- {fitText(filename, Math.max(1, headerLabelWidth - (stateLabel?.length ?? 0)))}
-
+ {/* The chevron toggles collapse on its own; stopping propagation keeps the surrounding header click as a plain select. */}
+ {
+ event.stopPropagation();
+ onToggleCollapse();
+ }
+ : undefined
+ }
+ >
+ {chevron}
+
+ {fitText(filename, labelWidth)}
{stateLabel && {stateLabel}}
{},
onSelectFile,
onToggleGap = NOOP_TOGGLE_GAP,
+ onToggleCollapse,
onViewportCenteredHunkChange,
}: {
codeHorizontalOffset?: number;
@@ -267,6 +268,7 @@ export function DiffPane({
onScrollCodeHorizontally?: (delta: number) => void;
onSelectFile: (fileId: string) => void;
onToggleGap?: (fileId: string, gapKey: string) => void;
+ onToggleCollapse?: (fileId: string) => void;
onViewportCenteredHunkChange?: (fileId: string, hunkIndex: number) => void;
}) {
const renderTopChrome = showTopChrome ?? !pagerMode;
@@ -315,6 +317,20 @@ export function DiffPane({
return callback;
}, []);
+ // Same latest-ref + cached-closure pattern as selectFileCallback so collapsing
+ // a file from its header chevron never invalidates memoized sections.
+ const onToggleCollapseRef = useRef(onToggleCollapse);
+ onToggleCollapseRef.current = onToggleCollapse;
+ const toggleCollapseCallbacksRef = useRef(new Map void>());
+ const toggleCollapseCallback = useCallback((fileId: string) => {
+ let callback = toggleCollapseCallbacksRef.current.get(fileId);
+ if (!callback) {
+ callback = () => onToggleCollapseRef.current?.(fileId);
+ toggleCollapseCallbacksRef.current.set(fileId, callback);
+ }
+ return callback;
+ }, []);
+
// Add-note row handlers are cached per file so mounted DiffSections keep a stable prop identity,
// while the ref indirection ensures clicks still use the latest App/review callback after hunk
// navigation changes the selected-file defaults upstream.
@@ -1759,7 +1775,9 @@ export function DiffPane({
headerLabelWidth={headerLabelWidth}
headerStatsWidth={headerStatsWidth}
theme={theme}
+ collapsed={pinnedHeaderFile.isCollapsed ?? false}
onSelect={() => onSelectFile(pinnedHeaderFile.id)}
+ onToggleCollapse={toggleCollapseCallback(pinnedHeaderFile.id)}
/>
) : null}
@@ -1849,6 +1867,7 @@ export function DiffPane({
}
onSelect={selectFileCallback(file.id)}
onToggleGap={(gapKey) => onToggleGap(file.id, gapKey)}
+ onToggleCollapse={toggleCollapseCallback(file.id)}
/>
);
})}
diff --git a/src/ui/components/panes/DiffSection.tsx b/src/ui/components/panes/DiffSection.tsx
index 7dc6af2c..61a72229 100644
--- a/src/ui/components/panes/DiffSection.tsx
+++ b/src/ui/components/panes/DiffSection.tsx
@@ -42,6 +42,7 @@ interface DiffSectionProps {
onStartUserNoteAtHunk?: (hunkIndex: number, target?: UserNoteLineTarget) => void;
onSelect: () => void;
onToggleGap: (gapKey: string) => void;
+ onToggleCollapse?: () => void;
}
/** Render one file section in the main review stream. */
@@ -76,6 +77,7 @@ function DiffSectionComponent({
onStartUserNoteAtHunk,
onSelect,
onToggleGap,
+ onToggleCollapse,
}: DiffSectionProps) {
return (
) : null}
@@ -173,6 +177,7 @@ export const DiffSection = memo(DiffSectionComponent, (previous, next) => {
previous.onMouseScroll === next.onMouseScroll &&
previous.onActiveAddNoteAffordanceChange === next.onActiveAddNoteAffordanceChange &&
previous.onStartUserNoteAtHunk === next.onStartUserNoteAtHunk &&
+ previous.onToggleCollapse === next.onToggleCollapse &&
previous.theme === next.theme &&
previous.visibleAgentNotes === next.visibleAgentNotes &&
previous.visibleBodyBounds === next.visibleBodyBounds &&
diff --git a/src/ui/components/ui-components.test.tsx b/src/ui/components/ui-components.test.tsx
index a7fa905c..de36dcaa 100644
--- a/src/ui/components/ui-components.test.tsx
+++ b/src/ui/components/ui-components.test.tsx
@@ -528,6 +528,60 @@ describe("UI components", () => {
expect(firstLine[statsIndex + "+2 -1".length + 1]).toBe(" ");
});
+ test("DiffFileHeaderRow chevron toggles collapse without triggering header select", async () => {
+ const theme = resolveTheme("github-dark-default", null);
+ const onSelect = mock(() => undefined);
+ const onToggleCollapse = mock(() => undefined);
+ const setup = await testRender(
+ ,
+ { width: 40, height: 2 },
+ );
+
+ try {
+ await act(async () => {
+ await setup.renderOnce();
+ });
+ const rows = setup.captureCharFrame().split("\n");
+ const rowY = rows.findIndex((line) => line.includes("▾"));
+ const chevronX = rows[rowY]?.indexOf("▾") ?? -1;
+ const filenameX = rows[rowY]?.indexOf("hdr") ?? -1;
+ expect(rowY).toBeGreaterThanOrEqual(0);
+ expect(chevronX).toBeGreaterThanOrEqual(0);
+ expect(filenameX).toBeGreaterThanOrEqual(0);
+
+ // Clicking the chevron toggles collapse and stops propagation, so the header's select never fires.
+ await act(async () => {
+ await setup.mockMouse.click(chevronX, rowY);
+ });
+ expect(onToggleCollapse).toHaveBeenCalledTimes(1);
+ expect(onSelect).not.toHaveBeenCalled();
+
+ // Clicking the filename bubbles to the header's select without toggling collapse.
+ await act(async () => {
+ await setup.mockMouse.click(filenameX, rowY);
+ });
+ expect(onSelect).toHaveBeenCalledTimes(1);
+ expect(onToggleCollapse).toHaveBeenCalledTimes(1);
+ } finally {
+ await act(async () => {
+ setup.renderer.destroy();
+ });
+ }
+ });
+
test("DiffRowView renders a clickable add-note affordance for a hovered diff row", async () => {
const theme = resolveTheme("github-dark-default", null);
const startUserNote = mock(() => undefined);
@@ -2322,13 +2376,13 @@ describe("UI components", () => {
const frame = await captureFrame(
{}}
/>,
76,
- 39,
+ 41,
);
const expectedRows = [
@@ -2355,6 +2409,7 @@ describe("UI components", () => {
"a toggle AI notes",
"z toggle unchanged context",
"l / w / m / M lines / wrap / metadata / menu",
+ "x / X collapse file / all files",
"e open file in $EDITOR",
"Review",
"/ focus file filter",
diff --git a/src/ui/diff/renderRows.tsx b/src/ui/diff/renderRows.tsx
index 60bba1cf..17f5b41b 100644
--- a/src/ui/diff/renderRows.tsx
+++ b/src/ui/diff/renderRows.tsx
@@ -1100,6 +1100,10 @@ function renderWrappedStackCellLine(
/** Explain why a file still appears in the review stream even when it has no textual hunks. */
export function diffMessage(file: DiffFile) {
+ if (file.isCollapsed) {
+ return "Collapsed. Press x or click the chevron to expand.";
+ }
+
if (file.metadata.type === "rename-pure") {
return "No textual hunks. This change only renames the file.";
}
diff --git a/src/ui/hooks/useAppKeyboardShortcuts.ts b/src/ui/hooks/useAppKeyboardShortcuts.ts
index 1f09de60..562cccf6 100644
--- a/src/ui/hooks/useAppKeyboardShortcuts.ts
+++ b/src/ui/hooks/useAppKeyboardShortcuts.ts
@@ -42,6 +42,14 @@ function isUppercaseGKey(key: KeyEvent) {
);
}
+/** Detect Shift-X without stealing the lowercase collapse-file toggle. */
+function isUppercaseXKey(key: KeyEvent) {
+ return (
+ (key.sequence === "X" && !key.option && !key.ctrl && !key.meta) ||
+ (key.name === "x" && key.shift && !key.option && !key.ctrl && !key.meta)
+ );
+}
+
/** Detect Shift-M without stealing the lowercase hunk metadata toggle. */
function isUppercaseMKey(key: KeyEvent) {
return (
@@ -82,6 +90,8 @@ export interface UseAppKeyboardShortcutsOptions {
toggleAgentNotes: () => void;
toggleFocusArea: () => void;
toggleGapForSelectedHunk: () => void;
+ toggleSelectedFileCollapsed: () => void;
+ toggleAllFilesCollapsed: () => void;
toggleHelp: () => void;
toggleHunkHeaders: () => void;
toggleLineNumbers: () => void;
@@ -126,6 +136,8 @@ export function useAppKeyboardShortcuts({
toggleAgentNotes,
toggleFocusArea,
toggleGapForSelectedHunk,
+ toggleSelectedFileCollapsed,
+ toggleAllFilesCollapsed,
toggleHelp,
themeSelectorOpen,
toggleHunkHeaders,
@@ -545,6 +557,17 @@ export function useAppKeyboardShortcuts({
return;
}
+ // Shift-X collapses/expands every file; check it before the lowercase x binding.
+ if (isUppercaseXKey(key)) {
+ runAndCloseMenu(toggleAllFilesCollapsed);
+ return;
+ }
+
+ if (key.name === "x" || key.sequence === "x") {
+ runAndCloseMenu(toggleSelectedFileCollapsed);
+ return;
+ }
+
if (key.name === "e" || key.sequence === "e") {
runAndCloseMenu(triggerEditSelectedFile);
return;
diff --git a/src/ui/hooks/useReviewController.test.tsx b/src/ui/hooks/useReviewController.test.tsx
index 8dd65666..eab87e47 100644
--- a/src/ui/hooks/useReviewController.test.tsx
+++ b/src/ui/hooks/useReviewController.test.tsx
@@ -84,6 +84,11 @@ function expectValue(value: T): NonNullable {
return value as NonNullable;
}
+/** Ids of files currently rendered as collapsed placeholders, read from the observable review stream. */
+function collapsedVisibleFileIds(controller: ReviewController): string[] {
+ return controller.visibleFiles.filter((file) => file.isCollapsed).map((file) => file.id);
+}
+
function ReviewControllerHarness({
initialFiles,
onController,
@@ -167,6 +172,132 @@ describe("useReviewController", () => {
}
});
+ test("collapsing a file swaps in a zero-hunk variant and skips its hunk navigation", async () => {
+ const { controllerRef, setup } = await renderReviewController([
+ createDiffFile("alpha", "alpha.ts", "export const alpha = 1;\n", "export const alpha = 2;\n"),
+ createDiffFile("beta", "beta.ts", "export const beta = 1;\n", "export const beta = 2;\n"),
+ ]);
+
+ try {
+ await flush(setup);
+
+ await act(async () => {
+ expectValue(controllerRef.current).toggleFileCollapsed("alpha");
+ });
+ await flush(setup);
+
+ const collapsed = expectValue(controllerRef.current).visibleFiles.find(
+ (file) => file.id === "alpha",
+ );
+ expect(collapsed?.isCollapsed).toBe(true);
+ expect(collapsed?.metadata.hunks).toEqual([]);
+ // The collapsed file contributes no hunk cursors, so [ / ] navigation skips it.
+ expect(
+ expectValue(controllerRef.current).visibleFiles.every((file) =>
+ file.id === "alpha" ? file.metadata.hunks.length === 0 : true,
+ ),
+ ).toBe(true);
+ expect(collapsedVisibleFileIds(expectValue(controllerRef.current))).toEqual(["alpha"]);
+
+ // Toggling again expands it back to its real hunks.
+ await act(async () => {
+ expectValue(controllerRef.current).toggleFileCollapsed("alpha");
+ });
+ await flush(setup);
+ const expanded = expectValue(controllerRef.current).visibleFiles.find(
+ (file) => file.id === "alpha",
+ );
+ expect(expanded?.isCollapsed).toBeFalsy();
+ expect(expanded?.metadata.hunks.length).toBeGreaterThan(0);
+
+ // Collapse-all marks every file, expand-all clears the set.
+ await act(async () => {
+ expectValue(controllerRef.current).toggleAllFilesCollapsed();
+ });
+ await flush(setup);
+ expect(collapsedVisibleFileIds(expectValue(controllerRef.current)).sort()).toEqual([
+ "alpha",
+ "beta",
+ ]);
+
+ await act(async () => {
+ expectValue(controllerRef.current).toggleAllFilesCollapsed();
+ });
+ await flush(setup);
+ expect(collapsedVisibleFileIds(expectValue(controllerRef.current))).toEqual([]);
+ } finally {
+ await act(async () => {
+ setup.renderer.destroy();
+ });
+ }
+ });
+
+ test("re-pins the selected file's header when collapsing a single file or all files", async () => {
+ const { controllerRef, setup } = await renderReviewController([
+ createDiffFile("alpha", "alpha.ts", "export const alpha = 1;\n", "export const alpha = 2;\n"),
+ createDiffFile("beta", "beta.ts", "export const beta = 1;\n", "export const beta = 2;\n"),
+ ]);
+
+ try {
+ await flush(setup);
+
+ // Collapsing one file anchors its header to the top so a tall file above the fold can't scroll it off.
+ const beforeSingle = expectValue(controllerRef.current).selectedFileTopAlignRequestId;
+ await act(async () => {
+ expectValue(controllerRef.current).toggleFileCollapsed("alpha");
+ });
+ await flush(setup);
+ expect(expectValue(controllerRef.current).selectedFileTopAlignRequestId).toBeGreaterThan(
+ beforeSingle,
+ );
+
+ // Bulk collapse re-pins the selected file too, matching the single-file toggle.
+ const beforeBulk = expectValue(controllerRef.current).selectedFileTopAlignRequestId;
+ await act(async () => {
+ expectValue(controllerRef.current).toggleAllFilesCollapsed();
+ });
+ await flush(setup);
+ expect(expectValue(controllerRef.current).selectedFileTopAlignRequestId).toBeGreaterThan(
+ beforeBulk,
+ );
+ } finally {
+ await act(async () => {
+ setup.renderer.destroy();
+ });
+ }
+ });
+
+ test("prunes collapse state for a file whose patch is replaced on reload", async () => {
+ // A reload swaps the file's sourceFetcher; collapse state keyed by the old patch must not leak.
+ const firstFetcher = createTestSourceFetcher(() => null);
+ const { controllerRef, setFilesRef, setup } = await renderReviewController([
+ createAlphaFile(firstFetcher),
+ ]);
+
+ try {
+ await flush(setup);
+
+ await act(async () => {
+ expectValue(controllerRef.current).toggleFileCollapsed("alpha");
+ });
+ await flush(setup);
+ expect(collapsedVisibleFileIds(expectValue(controllerRef.current))).toEqual(["alpha"]);
+
+ // Same id, new fetcher (and thus a new patch) marks the old collapse entry stale, so the
+ // reloaded file renders expanded rather than inheriting the previous patch's collapse.
+ const secondFetcher = createTestSourceFetcher(() => null);
+ await act(async () => {
+ expectValue(setFilesRef.current)([createAlphaFile(secondFetcher)]);
+ });
+ await flush(setup);
+ expect(collapsedVisibleFileIds(expectValue(controllerRef.current))).toEqual([]);
+ } finally {
+ await act(async () => {
+ setup.renderer.destroy();
+ });
+ }
+ });
+
test("clamps the selected hunk index when files update under a soft reload", async () => {
const { controllerRef, setFilesRef, setup } = await renderReviewController([
createTwoHunkFile(),
diff --git a/src/ui/hooks/useReviewController.ts b/src/ui/hooks/useReviewController.ts
index 6e14f203..027851ee 100644
--- a/src/ui/hooks/useReviewController.ts
+++ b/src/ui/hooks/useReviewController.ts
@@ -151,6 +151,8 @@ export interface ReviewController {
sourceStatusByFileId: Record;
toggleGap: (fileId: string, gapKey: string) => void;
toggleSelectedHunkGap: () => void;
+ toggleFileCollapsed: (fileId: string) => void;
+ toggleAllFilesCollapsed: () => void;
visibleFiles: DiffFile[];
addLiveComment: (
input: CommentToolInput,
@@ -202,6 +204,10 @@ export function useReviewController({ files }: { files: DiffFile[] }): ReviewCon
const [sourceStatusByFileId, setSourceStatusByFileId] = useState<
Record
>({});
+ // Session-only `fileId -> true` map of collapsed files. Keyed like the other
+ // per-file session maps so it reconciles through the same `removeKeys` pruning
+ // on reload, but is intentionally not persisted to disk.
+ const [collapsedFileIds, setCollapsedFileIds] = useState>>({});
// Mirror sourceStatusByFileId so toggleGap can dedup synchronously without
// waiting for React's state updater to commit.
const sourceStatusRef = useRef(sourceStatusByFileId);
@@ -236,6 +242,7 @@ export function useReviewController({ files }: { files: DiffFile[] }): ReviewCon
}
setSourceStatusByFileId((prev) => removeKeys(prev, staleFileIds));
setExpandedGapsByFileId((prev) => removeKeys(prev, staleFileIds));
+ setCollapsedFileIds((prev) => removeKeys(prev, staleFileIds));
}
}
@@ -257,8 +264,10 @@ export function useReviewController({ files }: { files: DiffFile[] }): ReviewCon
filterQuery: deferredFilter,
selectedFileId,
selectedHunkIndex,
+ collapsedFileIds,
}),
[
+ collapsedFileIds,
deferredFilter,
files,
liveCommentsByFileId,
@@ -295,6 +304,48 @@ export function useReviewController({ files }: { files: DiffFile[] }): ReviewCon
[selectHunk],
);
+ // Single place that knows a collapse/expand height change must re-pin a file's
+ // header to the top, so a collapse above the fold can't scroll it out of view.
+ const anchorFileHeaderTop = useCallback(
+ (fileId: string) => {
+ if (fileId) {
+ selectFile(fileId, 0, { alignFileHeaderTop: true });
+ }
+ },
+ [selectFile],
+ );
+
+ /** Toggle one file's collapsed state, re-pinning its header so the height change stays in view. */
+ const toggleFileCollapsed = useCallback(
+ (fileId: string) => {
+ if (!fileId) {
+ return;
+ }
+ setCollapsedFileIds((prev) =>
+ prev[fileId] ? removeKeys(prev, new Set([fileId])) : { ...prev, [fileId]: true },
+ );
+ anchorFileHeaderTop(fileId);
+ },
+ [anchorFileHeaderTop],
+ );
+
+ /** Collapse every file when any is expanded, otherwise expand them all. */
+ const toggleAllFilesCollapsed = useCallback(() => {
+ setCollapsedFileIds((prev) => {
+ const anyExpanded = allFiles.some((file) => !prev[file.id]);
+ if (!anyExpanded) {
+ return {};
+ }
+ const next: Record = {};
+ for (const file of allFiles) {
+ next[file.id] = true;
+ }
+ return next;
+ });
+ // Re-pin the selected file too, so a bulk collapse keeps it in view like the single-file toggle.
+ anchorFileHeaderTop(selectedFileId);
+ }, [allFiles, anchorFileHeaderTop, selectedFileId]);
+
/** Reset selection to the first visible file when the current target disappears from the review stream. */
const reselectFirstVisibleFile = useCallback(() => {
startTransition(() => {
@@ -1008,6 +1059,8 @@ export function useReviewController({ files }: { files: DiffFile[] }): ReviewCon
sourceStatusByFileId,
toggleGap,
toggleSelectedHunkGap,
+ toggleFileCollapsed,
+ toggleAllFilesCollapsed,
visibleFiles,
addLiveComment,
addLiveCommentBatch,
diff --git a/src/ui/lib/appMenus.ts b/src/ui/lib/appMenus.ts
index 6dcadf90..886d2713 100644
--- a/src/ui/lib/appMenus.ts
+++ b/src/ui/lib/appMenus.ts
@@ -22,6 +22,8 @@ export interface BuildAppMenusOptions {
toggleCopyDecorations: () => void;
toggleAgentNotes: () => void;
toggleFocusArea: () => void;
+ toggleSelectedFileCollapsed: () => void;
+ toggleAllFilesCollapsed: () => void;
openAgentSkill: () => void;
toggleHelp: () => void;
toggleHunkHeaders: () => void;
@@ -55,6 +57,8 @@ export function buildAppMenus({
toggleCopyDecorations,
toggleAgentNotes,
toggleFocusArea,
+ toggleSelectedFileCollapsed,
+ toggleAllFilesCollapsed,
openAgentSkill,
toggleHelp,
toggleHunkHeaders,
@@ -214,6 +218,19 @@ export function buildAppMenus({
action: () => moveToAnnotatedHunk(1),
},
{ kind: "separator" },
+ {
+ kind: "item",
+ label: "Collapse/expand file",
+ hint: "x",
+ action: toggleSelectedFileCollapsed,
+ },
+ {
+ kind: "item",
+ label: "Collapse/expand all files",
+ hint: "X",
+ action: toggleAllFilesCollapsed,
+ },
+ { kind: "separator" },
{
kind: "item",
label: "Focus filter",
diff --git a/src/ui/lib/fileCollapse.test.ts b/src/ui/lib/fileCollapse.test.ts
new file mode 100644
index 00000000..cf52ea49
--- /dev/null
+++ b/src/ui/lib/fileCollapse.test.ts
@@ -0,0 +1,37 @@
+import { describe, expect, test } from "bun:test";
+import { createTestDiffFile } from "../../../test/helpers/diff-helpers";
+import { applyFileCollapse, collapsedFileVariant } from "./fileCollapse";
+
+describe("collapsedFileVariant", () => {
+ test("empties hunks, flags collapse, and preserves stats/identity", () => {
+ const file = createTestDiffFile({ id: "a", path: "a.ts" });
+ expect(file.metadata.hunks.length).toBeGreaterThan(0);
+
+ const variant = collapsedFileVariant(file);
+
+ expect(variant.metadata.hunks).toEqual([]);
+ expect(variant.isCollapsed).toBe(true);
+ expect(variant.id).toBe(file.id);
+ expect(variant.stats).toEqual(file.stats);
+ expect(variant.metadata.cacheKey).not.toBe(file.metadata.cacheKey);
+ });
+
+ test("returns a stable object so geometry caching can key on identity", () => {
+ const file = createTestDiffFile({ id: "a", path: "a.ts" });
+ expect(collapsedFileVariant(file)).toBe(collapsedFileVariant(file));
+ });
+});
+
+describe("applyFileCollapse", () => {
+ test("swaps only collapsed ids and leaves the array untouched when none collapse", () => {
+ const a = createTestDiffFile({ id: "a", path: "a.ts" });
+ const b = createTestDiffFile({ id: "b", path: "b.ts" });
+ const files = [a, b];
+
+ expect(applyFileCollapse(files, {})).toBe(files);
+
+ const result = applyFileCollapse(files, { a: true });
+ expect(result[0]!.isCollapsed).toBe(true);
+ expect(result[1]).toBe(b);
+ });
+});
diff --git a/src/ui/lib/fileCollapse.ts b/src/ui/lib/fileCollapse.ts
new file mode 100644
index 00000000..876481b7
--- /dev/null
+++ b/src/ui/lib/fileCollapse.ts
@@ -0,0 +1,53 @@
+/**
+ * Manual file collapse for the review stream.
+ *
+ * Collapsing a file swaps it for a zero-hunk placeholder variant so the existing
+ * empty-file render and geometry paths handle it as a single header-plus-message
+ * section. This keeps collapse out of the deep renderer: hunk cursors, windowing,
+ * and sticky headers all read the same `hunks: []` shape they already understand.
+ */
+import type { DiffFile } from "../../core/types";
+
+// Cache the collapsed variant per original file object so its identity stays
+// stable across renders. The geometry layer keys its measurement cache on the
+// DiffFile object, so a fresh variant per render would thrash that cache.
+const collapsedVariants = new WeakMap();
+
+/** Build (or reuse) the zero-hunk placeholder variant for a collapsed file. */
+export function collapsedFileVariant(file: DiffFile): DiffFile {
+ const cached = collapsedVariants.get(file);
+ if (cached) {
+ return cached;
+ }
+
+ const variant: DiffFile = {
+ ...file,
+ isCollapsed: true,
+ metadata: {
+ ...file.metadata,
+ hunks: [],
+ // Distinguish the collapsed geometry from the real file in any string-keyed cache.
+ cacheKey: `${file.metadata.cacheKey}:collapsed`,
+ },
+ };
+ collapsedVariants.set(file, variant);
+ return variant;
+}
+
+/**
+ * Replace collapsed files in a review list with their header-only placeholder variant.
+ *
+ * Collapse state is a `fileId -> true` map, the same shape as the controller's
+ * other per-file session state, so it reconciles through the shared `removeKeys`
+ * stale-id pruning on reload rather than needing its own pruning helper.
+ */
+export function applyFileCollapse(
+ files: DiffFile[],
+ collapsedFileIds: Readonly>,
+): DiffFile[] {
+ const anyCollapsed = files.some((file) => collapsedFileIds[file.id]);
+ if (!anyCollapsed) {
+ return files;
+ }
+ return files.map((file) => (collapsedFileIds[file.id] ? collapsedFileVariant(file) : file));
+}
diff --git a/src/ui/lib/reviewState.ts b/src/ui/lib/reviewState.ts
index 794a74da..89c58d6a 100644
--- a/src/ui/lib/reviewState.ts
+++ b/src/ui/lib/reviewState.ts
@@ -9,6 +9,7 @@
import { findDiffFileByPath, findHunkIndexForLine, hunkLineRange } from "../../core/liveComments";
import type { AgentAnnotation, DiffFile } from "../../core/types";
import type { NavigateToHunkToolInput, SelectedHunkSummary } from "../../hunk-session/types";
+import { applyFileCollapse } from "./fileCollapse";
import {
buildSidebarEntries,
filterReviewFiles,
@@ -22,12 +23,15 @@ import {
type HunkCursor,
} from "./hunks";
+const EMPTY_COLLAPSED_FILE_IDS: Readonly> = {};
+
export interface BuildReviewStateOptions {
files: DiffFile[];
liveCommentsByFileId: Record;
filterQuery: string;
selectedFileId: string;
selectedHunkIndex: number;
+ collapsedFileIds?: Readonly>;
}
export interface ReviewState {
@@ -53,9 +57,15 @@ export function buildReviewState({
filterQuery,
selectedFileId,
selectedHunkIndex,
+ collapsedFileIds,
}: BuildReviewStateOptions): ReviewState {
const allFiles = mergeFileAnnotationsByFileId(files, liveCommentsByFileId);
- const visibleFiles = filterReviewFiles(allFiles, filterQuery);
+ // Collapse swaps each collapsed file for a zero-hunk variant, so the visible
+ // stream, sidebar, and hunk cursors all derive from one already-collapsed list.
+ const visibleFiles = applyFileCollapse(
+ filterReviewFiles(allFiles, filterQuery),
+ collapsedFileIds ?? EMPTY_COLLAPSED_FILE_IDS,
+ );
const selectedFile = resolveSelectedFile(allFiles, visibleFiles, selectedFileId);
return {
diff --git a/src/ui/lib/ui-lib.test.ts b/src/ui/lib/ui-lib.test.ts
index 098ea5cb..4bc89847 100644
--- a/src/ui/lib/ui-lib.test.ts
+++ b/src/ui/lib/ui-lib.test.ts
@@ -150,6 +150,8 @@ describe("ui helpers", () => {
toggleCopyDecorations: () => {},
toggleAgentNotes: () => {},
toggleFocusArea: () => {},
+ toggleSelectedFileCollapsed: () => {},
+ toggleAllFilesCollapsed: () => {},
openAgentSkill: () => {},
toggleHelp: () => {},
toggleHunkHeaders: () => {},
diff --git a/test/pty/collapse.test.ts b/test/pty/collapse.test.ts
new file mode 100644
index 00000000..13f0bcd0
--- /dev/null
+++ b/test/pty/collapse.test.ts
@@ -0,0 +1,51 @@
+import { afterEach, describe, expect, setDefaultTimeout, test } from "bun:test";
+import { createPtyHarness } from "./harness";
+
+const harness = createPtyHarness();
+
+/** Give PTY-backed startup and redraws enough headroom for slower CI machines. */
+setDefaultTimeout(20_000);
+
+afterEach(() => {
+ harness.cleanup();
+});
+
+describe("PTY file collapse", () => {
+ test("x collapses the selected file to a header placeholder and expands it again", async () => {
+ const fixture = harness.createMultiHunkFilePair();
+ const session = await harness.launchHunk({
+ args: ["diff", fixture.before, fixture.after, "--mode", "split"],
+ cols: 104,
+ rows: 12,
+ });
+
+ try {
+ const initial = await session.waitForText(/View\s+Navigate\s+Agent\s+Help/, {
+ timeout: 15_000,
+ });
+ expect(initial).toContain("line1 = 100");
+
+ await session.press("x");
+ const collapsed = await harness.waitForSnapshot(
+ session,
+ (text) => text.includes("Collapsed") && !text.includes("line1 = 100"),
+ 5_000,
+ );
+ expect(collapsed).toContain("Collapsed");
+ expect(collapsed).not.toContain("line1 = 100");
+ // The header chevron flips to the collapsed glyph.
+ expect(collapsed).toContain("▸");
+
+ await session.press("x");
+ const expanded = await harness.waitForSnapshot(
+ session,
+ (text) => text.includes("line1 = 100") && !text.includes("Collapsed"),
+ 5_000,
+ );
+ expect(expanded).toContain("line1 = 100");
+ expect(expanded).not.toContain("Collapsed");
+ } finally {
+ session.close();
+ }
+ });
+});