Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/collapse-files.md
Original file line number Diff line number Diff line change
@@ -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.
3 changes: 3 additions & 0 deletions src/core/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
12 changes: 12 additions & 0 deletions src/ui/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down Expand Up @@ -758,6 +763,8 @@ export function App({
toggleCopyDecorations,
toggleAgentNotes,
toggleFocusArea,
toggleSelectedFileCollapsed,
toggleAllFilesCollapsed: review.toggleAllFilesCollapsed,
openAgentSkill,
toggleHelp,
toggleHunkHeaders,
Expand Down Expand Up @@ -790,6 +797,8 @@ export function App({
renderSidebar,
toggleAgentNotes,
toggleFocusArea,
toggleSelectedFileCollapsed,
review.toggleAllFilesCollapsed,
toggleHelp,
toggleHunkHeaders,
toggleLineNumbers,
Expand Down Expand Up @@ -850,6 +859,8 @@ export function App({
toggleAgentNotes,
toggleFocusArea,
toggleGapForSelectedHunk: review.toggleSelectedHunkGap,
toggleSelectedFileCollapsed,
toggleAllFilesCollapsed: review.toggleAllFilesCollapsed,
toggleHelp,
toggleHunkHeaders,
toggleLineNumbers,
Expand Down Expand Up @@ -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 })
}
Expand Down
46 changes: 45 additions & 1 deletion src/ui/AppHost.interactions.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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")) ?? ""
);
}
Expand Down Expand Up @@ -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(<AppHost bootstrap={createBootstrap()} />, {
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(
<AppHost bootstrap={{ ...createSingleFileBootstrap(), initialShowMenuBar: false }} />,
Expand Down
1 change: 1 addition & 0 deletions src/ui/components/chrome/HelpDialog.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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"],
],
},
Expand Down
31 changes: 28 additions & 3 deletions src/ui/components/panes/DiffFileHeaderRow.tsx
Original file line number Diff line number Diff line change
@@ -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";
Expand All @@ -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 (
<box
Expand All @@ -39,9 +52,21 @@ export function DiffFileHeaderRow({
>
{/* Clicking the file header jumps the main stream selection without collapsing to a single-file view. */}
<box style={{ flexDirection: "row" }}>
<text fg={theme.text}>
{fitText(filename, Math.max(1, headerLabelWidth - (stateLabel?.length ?? 0)))}
</text>
{/* The chevron toggles collapse on its own; stopping propagation keeps the surrounding header click as a plain select. */}
<box
style={{ flexDirection: "row" }}
onMouseUp={
onToggleCollapse
? (event: TuiMouseEvent) => {
event.stopPropagation();
onToggleCollapse();
}
: undefined
}
>
<text fg={theme.muted}>{chevron}</text>
</box>
<text fg={theme.text}>{fitText(filename, labelWidth)}</text>
{stateLabel && <text fg={theme.muted}>{stateLabel}</text>}
</box>
<box
Expand Down
19 changes: 19 additions & 0 deletions src/ui/components/panes/DiffPane.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -218,6 +218,7 @@ export function DiffPane({
onScrollCodeHorizontally = () => {},
onSelectFile,
onToggleGap = NOOP_TOGGLE_GAP,
onToggleCollapse,
onViewportCenteredHunkChange,
}: {
codeHorizontalOffset?: number;
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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<string, () => 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.
Expand Down Expand Up @@ -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)}
/>
</box>
) : null}
Expand Down Expand Up @@ -1849,6 +1867,7 @@ export function DiffPane({
}
onSelect={selectFileCallback(file.id)}
onToggleGap={(gapKey) => onToggleGap(file.id, gapKey)}
onToggleCollapse={toggleCollapseCallback(file.id)}
/>
);
})}
Expand Down
5 changes: 5 additions & 0 deletions src/ui/components/panes/DiffSection.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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. */
Expand Down Expand Up @@ -76,6 +77,7 @@ function DiffSectionComponent({
onStartUserNoteAtHunk,
onSelect,
onToggleGap,
onToggleCollapse,
}: DiffSectionProps) {
return (
<box
Expand Down Expand Up @@ -109,7 +111,9 @@ function DiffSectionComponent({
headerLabelWidth={headerLabelWidth}
headerStatsWidth={headerStatsWidth}
theme={theme}
collapsed={file.isCollapsed ?? false}
onSelect={onSelect}
onToggleCollapse={onToggleCollapse}
/>
) : null}

Expand Down Expand Up @@ -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 &&
Expand Down
59 changes: 57 additions & 2 deletions src/ui/components/ui-components.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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(
<DiffFileHeaderRow
file={createTestDiffFile(
"hdr",
"hdr.ts",
lines("export const value = 1;"),
lines("export const value = 2;"),
)}
headerLabelWidth={20}
headerStatsWidth={8}
theme={theme}
collapsed={false}
onSelect={onSelect}
onToggleCollapse={onToggleCollapse}
/>,
{ 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);
Expand Down Expand Up @@ -2322,13 +2376,13 @@ describe("UI components", () => {
const frame = await captureFrame(
<HelpDialog
canRefresh={true}
terminalHeight={39}
terminalHeight={41}
terminalWidth={76}
theme={theme}
onClose={() => {}}
/>,
76,
39,
41,
);

const expectedRows = [
Expand All @@ -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",
Expand Down
4 changes: 4 additions & 0 deletions src/ui/diff/renderRows.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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.";
}
Expand Down
Loading