Skip to content

Commit 88c584b

Browse files
committed
feat(diff): synthesize expandable context rows
1 parent 9eab311 commit 88c584b

11 files changed

Lines changed: 988 additions & 40 deletions

src/ui/diff/codeColumns.test.ts

Lines changed: 50 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import { describe, expect, test } from "bun:test";
22
import type { DiffFile } from "../../core/types";
3-
import { maxFileCodeLineWidth } from "./codeColumns";
3+
import { findMaxLineNumberInRows, maxFileCodeLineWidth } from "./codeColumns";
4+
import type { DiffRow } from "./pierre";
45

56
/** Generate a large diff metadata fixture without checking a huge file into the repo. */
67
function createLargeLineFixture(lineCount: number, widestLine: string): DiffFile {
@@ -29,3 +30,51 @@ describe("code column measurement", () => {
2930
expect(maxFileCodeLineWidth(file)).toBe("the widest generated line".length);
3031
});
3132
});
33+
34+
describe("findMaxLineNumberInRows", () => {
35+
test("accounts for collapsed gap ranges that can later expand", () => {
36+
const rows: DiffRow[] = [
37+
{
38+
type: "split-line",
39+
key: "file:line:0",
40+
fileId: "file",
41+
hunkIndex: 0,
42+
left: { kind: "deletion", sign: "-", lineNumber: 5, spans: [{ text: "old" }] },
43+
right: { kind: "addition", sign: "+", lineNumber: 5, spans: [{ text: "new" }] },
44+
},
45+
{
46+
type: "collapsed",
47+
key: "file:collapsed:trailing",
48+
fileId: "file",
49+
hunkIndex: 0,
50+
oldRange: [6, 1000],
51+
newRange: [6, 1000],
52+
position: "trailing",
53+
text: "995 unchanged lines",
54+
},
55+
];
56+
57+
expect(findMaxLineNumberInRows(rows)).toBe(1000);
58+
});
59+
60+
test("accounts for synthesized stack expansion rows", () => {
61+
const rows: DiffRow[] = [
62+
{
63+
type: "stack-line",
64+
key: "file:expanded:trailing:0:0",
65+
fileId: "file",
66+
hunkIndex: 0,
67+
isExpansionRow: true,
68+
cell: {
69+
kind: "context",
70+
sign: " ",
71+
oldLineNumber: 998,
72+
newLineNumber: 1002,
73+
spans: [{ text: "context" }],
74+
},
75+
},
76+
];
77+
78+
expect(findMaxLineNumberInRows(rows, 9)).toBe(1002);
79+
});
80+
});

src/ui/diff/codeColumns.ts

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import type { DiffFile, LayoutMode } from "../../core/types";
2+
import type { DiffRow } from "./pierre";
23

34
export const DIFF_CODE_TAB_WIDTH = 2;
45
export const DIFF_RAIL_PREFIX_WIDTH = 1;
@@ -47,6 +48,29 @@ export function findMaxLineNumber(file: DiffFile) {
4748
return Math.max(highest, 1);
4849
}
4950

51+
/** Find the widest line-number gutter needed for an already-expanded row stream. */
52+
export function findMaxLineNumberInRows(rows: Iterable<DiffRow>, fallback = 1) {
53+
let highest = fallback;
54+
55+
for (const row of rows) {
56+
if (row.type === "collapsed") {
57+
highest = Math.max(highest, row.oldRange[1], row.newRange[1]);
58+
continue;
59+
}
60+
61+
if (row.type === "split-line") {
62+
highest = Math.max(highest, row.left.lineNumber ?? 0, row.right.lineNumber ?? 0);
63+
continue;
64+
}
65+
66+
if (row.type === "stack-line") {
67+
highest = Math.max(highest, row.cell.oldLineNumber ?? 0, row.cell.newLineNumber ?? 0);
68+
}
69+
}
70+
71+
return Math.max(highest, 1);
72+
}
73+
5074
/** Split-view panes reserve one rail column on the left and one separator column in the middle. */
5175
export function resolveSplitPaneWidths(width: number) {
5276
const usableWidth = Math.max(0, width - DIFF_RAIL_PREFIX_WIDTH - DIFF_SPLIT_SEPARATOR_WIDTH);
Lines changed: 288 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,288 @@
1+
import { describe, expect, test } from "bun:test";
2+
import { expandCollapsedRows, gapKey, selectGapForKeyboardToggle } from "./expandCollapsedRows";
3+
import type { DiffRow } from "./pierre";
4+
5+
function makeCollapsedRow(
6+
position: "before" | "trailing",
7+
hunkIndex: number,
8+
oldRange: [number, number],
9+
newRange: [number, number],
10+
): Extract<DiffRow, { type: "collapsed" }> {
11+
return {
12+
type: "collapsed",
13+
key: `f:collapsed:${position}:${hunkIndex}`,
14+
fileId: "f",
15+
hunkIndex,
16+
text: `${oldRange[1] - oldRange[0] + 1} unchanged lines`,
17+
position,
18+
oldRange,
19+
newRange,
20+
};
21+
}
22+
23+
function makeHunkHeader(hunkIndex: number): Extract<DiffRow, { type: "hunk-header" }> {
24+
return {
25+
type: "hunk-header",
26+
key: `f:header:${hunkIndex}`,
27+
fileId: "f",
28+
hunkIndex,
29+
text: `@@ hunk ${hunkIndex} @@`,
30+
};
31+
}
32+
33+
const SOURCE = ["alpha", "beta", "gamma", "delta", "epsilon", "zeta"].join("\n") + "\n";
34+
35+
describe("expandCollapsedRows", () => {
36+
test("returns rows unchanged when no gaps are expanded", () => {
37+
const rows: DiffRow[] = [makeCollapsedRow("before", 0, [1, 2], [1, 2]), makeHunkHeader(0)];
38+
39+
const result = expandCollapsedRows(rows, {
40+
layout: "split",
41+
expandedKeys: new Set(),
42+
sourceStatus: { kind: "loaded", text: SOURCE },
43+
side: "new",
44+
});
45+
46+
expect(result).toBe(rows);
47+
});
48+
49+
test("leaves the row unchanged when expansion is requested before status arrives", () => {
50+
const rows: DiffRow[] = [makeCollapsedRow("before", 0, [1, 2], [1, 2]), makeHunkHeader(0)];
51+
52+
const result = expandCollapsedRows(rows, {
53+
layout: "split",
54+
expandedKeys: new Set([gapKey("before", 0)]),
55+
sourceStatus: undefined,
56+
side: "new",
57+
});
58+
59+
expect(result.map((row) => row.type)).toEqual(["collapsed", "hunk-header"]);
60+
const collapsed = result[0];
61+
if (!collapsed || collapsed.type !== "collapsed") {
62+
throw new Error("expected first row to be collapsed");
63+
}
64+
expect(collapsed.text.toLowerCase()).not.toContain("hide");
65+
expect(collapsed.text.toLowerCase()).not.toContain("loading");
66+
});
67+
68+
test("rewrites the label to 'Loading…' while source is being fetched", () => {
69+
const rows: DiffRow[] = [makeCollapsedRow("before", 0, [1, 3], [1, 3]), makeHunkHeader(0)];
70+
71+
const result = expandCollapsedRows(rows, {
72+
layout: "split",
73+
expandedKeys: new Set([gapKey("before", 0)]),
74+
sourceStatus: { kind: "loading" },
75+
side: "new",
76+
});
77+
78+
expect(result.map((row) => row.type)).toEqual(["collapsed", "hunk-header"]);
79+
const collapsed = result[0];
80+
if (!collapsed || collapsed.type !== "collapsed") {
81+
throw new Error("expected first row to be collapsed");
82+
}
83+
expect(collapsed.text.toLowerCase()).toContain("loading");
84+
});
85+
86+
test("rewrites the label when source could not be loaded", () => {
87+
const rows: DiffRow[] = [makeCollapsedRow("before", 0, [1, 3], [1, 3]), makeHunkHeader(0)];
88+
89+
const result = expandCollapsedRows(rows, {
90+
layout: "split",
91+
expandedKeys: new Set([gapKey("before", 0)]),
92+
sourceStatus: { kind: "error" },
93+
side: "new",
94+
});
95+
96+
expect(result.map((row) => row.type)).toEqual(["collapsed", "hunk-header"]);
97+
const collapsed = result[0];
98+
if (!collapsed || collapsed.type !== "collapsed") {
99+
throw new Error("expected first row to be collapsed");
100+
}
101+
expect(collapsed.text.toLowerCase()).toContain("could not load");
102+
});
103+
104+
test("inserts split-line context rows after the expanded collapsed row", () => {
105+
const rows: DiffRow[] = [makeCollapsedRow("before", 0, [1, 3], [1, 3]), makeHunkHeader(0)];
106+
107+
const result = expandCollapsedRows(rows, {
108+
layout: "split",
109+
expandedKeys: new Set([gapKey("before", 0)]),
110+
sourceStatus: { kind: "loaded", text: SOURCE },
111+
side: "new",
112+
});
113+
114+
expect(result.length).toBe(rows.length + 3);
115+
expect(result[0]?.type).toBe("collapsed");
116+
117+
const inserted = result.slice(1, 4);
118+
expect(inserted.every((row) => row.type === "split-line")).toBe(true);
119+
120+
const first = inserted[0];
121+
if (!first || first.type !== "split-line") {
122+
throw new Error("expected split-line context rows");
123+
}
124+
125+
expect(first.left.kind).toBe("context");
126+
expect(first.right.kind).toBe("context");
127+
expect(first.left.lineNumber).toBe(1);
128+
expect(first.right.lineNumber).toBe(1);
129+
expect(first.left.spans[0]?.text).toBe("alpha");
130+
expect(first.right.spans[0]?.text).toBe("alpha");
131+
132+
const third = inserted[2];
133+
if (!third || third.type !== "split-line") {
134+
throw new Error("expected three context rows");
135+
}
136+
expect(third.left.lineNumber).toBe(3);
137+
expect(third.right.spans[0]?.text).toBe("gamma");
138+
});
139+
140+
test("inserts stack-line context rows when layout is stack", () => {
141+
const rows: DiffRow[] = [makeCollapsedRow("before", 0, [2, 3], [2, 3]), makeHunkHeader(0)];
142+
143+
const result = expandCollapsedRows(rows, {
144+
layout: "stack",
145+
expandedKeys: new Set([gapKey("before", 0)]),
146+
sourceStatus: { kind: "loaded", text: SOURCE },
147+
side: "new",
148+
});
149+
150+
const inserted = result.slice(1, 3);
151+
expect(inserted.every((row) => row.type === "stack-line")).toBe(true);
152+
153+
const first = inserted[0];
154+
if (!first || first.type !== "stack-line") {
155+
throw new Error("expected stack-line context rows");
156+
}
157+
expect(first.cell.kind).toBe("context");
158+
expect(first.cell.oldLineNumber).toBe(2);
159+
expect(first.cell.newLineNumber).toBe(2);
160+
expect(first.cell.spans[0]?.text).toBe("beta");
161+
});
162+
163+
test("changes the collapsed-row label to indicate expansion", () => {
164+
const rows: DiffRow[] = [makeCollapsedRow("before", 0, [1, 2], [1, 2]), makeHunkHeader(0)];
165+
166+
const result = expandCollapsedRows(rows, {
167+
layout: "split",
168+
expandedKeys: new Set([gapKey("before", 0)]),
169+
sourceStatus: { kind: "loaded", text: SOURCE },
170+
side: "new",
171+
});
172+
173+
const collapsed = result[0];
174+
if (!collapsed || collapsed.type !== "collapsed") {
175+
throw new Error("expected first row to be the collapsed marker");
176+
}
177+
expect(collapsed.text.toLowerCase()).toContain("hide");
178+
});
179+
180+
test("expands trailing gaps from the requested side", () => {
181+
const rows: DiffRow[] = [makeHunkHeader(0), makeCollapsedRow("trailing", 0, [4, 6], [4, 6])];
182+
183+
const result = expandCollapsedRows(rows, {
184+
layout: "stack",
185+
expandedKeys: new Set([gapKey("trailing", 0)]),
186+
sourceStatus: { kind: "loaded", text: SOURCE },
187+
side: "new",
188+
});
189+
190+
expect(result.length).toBe(rows.length + 3);
191+
const last = result[result.length - 1];
192+
if (!last || last.type !== "stack-line") {
193+
throw new Error("expected synthesized stack-line rows after the trailing collapsed row");
194+
}
195+
expect(last.cell.spans[0]?.text).toBe("zeta");
196+
expect(last.cell.newLineNumber).toBe(6);
197+
});
198+
199+
test("uses the old-side range when side is `old`", () => {
200+
const rows: DiffRow[] = [makeCollapsedRow("before", 0, [2, 3], [10, 11]), makeHunkHeader(0)];
201+
202+
const result = expandCollapsedRows(rows, {
203+
layout: "split",
204+
expandedKeys: new Set([gapKey("before", 0)]),
205+
sourceStatus: { kind: "loaded", text: SOURCE },
206+
side: "old",
207+
});
208+
209+
const inserted = result.slice(1, 3);
210+
const first = inserted[0];
211+
if (!first || first.type !== "split-line") {
212+
throw new Error("expected split-line context rows");
213+
}
214+
expect(first.left.lineNumber).toBe(2);
215+
expect(first.right.lineNumber).toBe(10);
216+
expect(first.left.spans[0]?.text).toBe("beta");
217+
expect(first.right.spans[0]?.text).toBe("beta");
218+
});
219+
220+
test("normalizes CRLF so expanded rows do not carry a stray carriage return", () => {
221+
const sourceWithCrlf = "alpha\r\nbeta\r\ngamma\r\n";
222+
const rows: DiffRow[] = [makeCollapsedRow("before", 0, [1, 2], [1, 2]), makeHunkHeader(0)];
223+
224+
const result = expandCollapsedRows(rows, {
225+
layout: "stack",
226+
expandedKeys: new Set([gapKey("before", 0)]),
227+
sourceStatus: { kind: "loaded", text: sourceWithCrlf },
228+
side: "new",
229+
});
230+
231+
const inserted = result[1];
232+
if (!inserted || inserted.type !== "stack-line") {
233+
throw new Error("expected stack-line context row");
234+
}
235+
expect(inserted.cell.spans[0]?.text).toBe("alpha");
236+
});
237+
238+
test("expands tabs in source lines so terminal cells stay aligned", () => {
239+
const sourceWithTab = "a\tb\nfollow\n";
240+
const rows: DiffRow[] = [makeCollapsedRow("before", 0, [1, 1], [1, 1]), makeHunkHeader(0)];
241+
242+
const result = expandCollapsedRows(rows, {
243+
layout: "stack",
244+
expandedKeys: new Set([gapKey("before", 0)]),
245+
sourceStatus: { kind: "loaded", text: sourceWithTab },
246+
side: "new",
247+
});
248+
249+
const inserted = result[1];
250+
if (!inserted || inserted.type !== "stack-line") {
251+
throw new Error("expected one stack-line row");
252+
}
253+
expect(inserted.cell.spans[0]?.text.includes("\t")).toBe(false);
254+
});
255+
});
256+
257+
describe("selectGapForKeyboardToggle", () => {
258+
test("returns the leading gap of the selected hunk when one exists", () => {
259+
const hunks = [{ collapsedBefore: 3 }, { collapsedBefore: 0 }];
260+
expect(selectGapForKeyboardToggle(hunks, 0, false)).toBe(gapKey("before", 0));
261+
});
262+
263+
test("falls forward to the next hunk's leading gap when the selected hunk has none", () => {
264+
const hunks = [{ collapsedBefore: 0 }, { collapsedBefore: 5 }, { collapsedBefore: 0 }];
265+
expect(selectGapForKeyboardToggle(hunks, 0, false)).toBe(gapKey("before", 1));
266+
});
267+
268+
test("falls back to the trailing gap when no later leading gap exists", () => {
269+
const hunks = [{ collapsedBefore: 0 }, { collapsedBefore: 0 }];
270+
expect(selectGapForKeyboardToggle(hunks, 0, true)).toBe(gapKey("trailing", 1));
271+
});
272+
273+
test("returns null when no leading or trailing gap is reachable", () => {
274+
const hunks = [{ collapsedBefore: 0 }, { collapsedBefore: 0 }];
275+
expect(selectGapForKeyboardToggle(hunks, 0, false)).toBeNull();
276+
});
277+
278+
test("returns null for an empty hunk list", () => {
279+
expect(selectGapForKeyboardToggle([], 0, false)).toBeNull();
280+
});
281+
282+
test("clamps a stale selectedHunkIndex into the valid range", () => {
283+
const hunks = [{ collapsedBefore: 4 }, { collapsedBefore: 0 }];
284+
// Stale index 99 clamps to the last hunk (1); that hunk has no leading gap,
285+
// so the trailing gap is the only reachable target.
286+
expect(selectGapForKeyboardToggle(hunks, 99, true)).toBe(gapKey("trailing", 1));
287+
});
288+
});

0 commit comments

Comments
 (0)