Skip to content

Commit 1bb164d

Browse files
feat(ui): add fold toggle for unchanged lines between hunks (#303)
Co-authored-by: Ben Vinegar <ben@benv.ca>
1 parent 290f596 commit 1bb164d

40 files changed

Lines changed: 3984 additions & 213 deletions

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ All notable user-visible changes to Hunk are documented in this file.
77
### Added
88

99
- Added mouse-drag text selection in diff views that copies selected rows to the system clipboard via OSC 52. A `View > Copy decorations` toggle (or `copy_decorations` config) controls whether the clipboard includes diff rails, gutters, and file headers or only the changed code.
10+
- Added inline expansion for collapsed unchanged file content. Click an unchanged-context row (`▾ N unchanged lines` when expandable, otherwise the static `··· N unchanged lines ···` form) or press `e` while a hunk is selected to reveal surrounding and trailing file lines without leaving the review. The affordance is shown only for input modes that have reachable source content (`hunk diff`, `show`, `stash show`, file-pair `diff` and `difftool`, untracked files); raw `hunk patch` input still renders as before. Failed and in-flight loads surface a one-line status ("Loading…", "Could not load N unchanged lines") on the gap row. Expanded context rows use the same syntax highlighting as the surrounding diff.
1011

1112
### Changed
1213

src/core/diffFile.ts

Lines changed: 21 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import { getFiletypeFromFileName, type FileDiffMetadata } from "@pierre/diffs";
22
import { findAgentFileContext } from "./agent";
33
import { patchLooksBinary } from "./binary";
44
import { normalizeDiffMetadataPaths, normalizeDiffPath } from "./diffPaths";
5+
import type { FileSourceFetcher } from "./fileSource";
56
import type { AgentContext, DiffFile } from "./types";
67

78
/** Count visible additions and deletions from parsed diff metadata. */
@@ -21,10 +22,19 @@ export function countDiffStats(metadata: FileDiffMetadata) {
2122
return { additions, deletions };
2223
}
2324

25+
export interface DiffFileSourceContext {
26+
path: string;
27+
previousPath?: string;
28+
type: FileDiffMetadata["type"];
29+
isUntracked: boolean;
30+
isBinary: boolean;
31+
}
32+
2433
export interface BuildDiffFileOptions {
2534
isUntracked?: boolean;
2635
previousPath?: string;
2736
isBinary?: boolean;
37+
sourceFetcherBuilder?: (file: DiffFileSourceContext) => FileSourceFetcher | undefined;
2838
isTooLarge?: boolean;
2939
stats?: DiffFile["stats"];
3040
statsTruncated?: boolean;
@@ -41,6 +51,7 @@ export function buildDiffFile(
4151
isUntracked,
4252
previousPath,
4353
isBinary,
54+
sourceFetcherBuilder,
4455
isTooLarge,
4556
stats,
4657
statsTruncated,
@@ -49,6 +60,14 @@ export function buildDiffFile(
4960
const normalizedMetadata = normalizeDiffMetadataPaths(metadata);
5061
const path = normalizedMetadata.name;
5162
const resolvedPreviousPath = normalizeDiffPath(previousPath) ?? normalizedMetadata.prevName;
63+
const resolvedIsBinary = isBinary ?? patchLooksBinary(patch);
64+
const sourceFetcher = sourceFetcherBuilder?.({
65+
path,
66+
previousPath: resolvedPreviousPath,
67+
type: normalizedMetadata.type,
68+
isUntracked: Boolean(isUntracked),
69+
isBinary: resolvedIsBinary,
70+
});
5271

5372
return {
5473
id: `${sourcePrefix}:${index}:${path}`,
@@ -60,9 +79,10 @@ export function buildDiffFile(
6079
metadata: normalizedMetadata,
6180
agent: findAgentFileContext(agentContext, path, resolvedPreviousPath),
6281
isUntracked,
63-
isBinary: isBinary ?? patchLooksBinary(patch),
82+
isBinary: resolvedIsBinary,
6483
isTooLarge,
6584
statsTruncated,
85+
sourceFetcher,
6686
};
6787
}
6888

src/core/fileSource.test.ts

Lines changed: 235 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,235 @@
1+
import { afterEach, describe, expect, test } from "bun:test";
2+
import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
3+
import { tmpdir } from "node:os";
4+
import { join } from "node:path";
5+
import { createFileSourceFetcher } from "./fileSource";
6+
7+
const tempDirs: string[] = [];
8+
9+
function createTempDir(prefix: string) {
10+
const dir = mkdtempSync(join(tmpdir(), prefix));
11+
tempDirs.push(dir);
12+
return dir;
13+
}
14+
15+
function git(cwd: string, ...cmd: string[]) {
16+
const proc = Bun.spawnSync(["git", ...cmd], {
17+
cwd,
18+
stdout: "pipe",
19+
stderr: "pipe",
20+
stdin: "ignore",
21+
});
22+
23+
if (proc.exitCode !== 0) {
24+
const stderr = Buffer.from(proc.stderr).toString("utf8");
25+
throw new Error(stderr.trim() || `git ${cmd.join(" ")} failed`);
26+
}
27+
28+
return Buffer.from(proc.stdout).toString("utf8");
29+
}
30+
31+
function createTempRepo(prefix: string) {
32+
const dir = createTempDir(prefix);
33+
git(dir, "init");
34+
git(dir, "config", "user.name", "Test User");
35+
git(dir, "config", "user.email", "test@example.com");
36+
git(dir, "config", "commit.gpgSign", "false");
37+
return dir;
38+
}
39+
40+
/** Capture console.error calls while exercising diagnostic paths. */
41+
async function captureConsoleErrors(fn: () => Promise<void>) {
42+
const originalConsoleError = console.error;
43+
const loggedErrors: unknown[][] = [];
44+
console.error = (...args: unknown[]) => {
45+
loggedErrors.push(args);
46+
};
47+
48+
try {
49+
await fn();
50+
} finally {
51+
console.error = originalConsoleError;
52+
}
53+
54+
return loggedErrors;
55+
}
56+
57+
afterEach(() => {
58+
while (tempDirs.length > 0) {
59+
const dir = tempDirs.pop();
60+
if (dir) {
61+
rmSync(dir, { recursive: true, force: true });
62+
}
63+
}
64+
});
65+
66+
describe("createFileSourceFetcher", () => {
67+
test("reads fs paths for old and new sides", async () => {
68+
const dir = createTempDir("hunk-source-fs-");
69+
const left = join(dir, "before.txt");
70+
const right = join(dir, "after.txt");
71+
writeFileSync(left, "old contents\n");
72+
writeFileSync(right, "new contents\n");
73+
74+
const fetcher = createFileSourceFetcher({
75+
old: { kind: "fs", absolutePath: left },
76+
new: { kind: "fs", absolutePath: right },
77+
});
78+
79+
expect(await fetcher.getFullText("old")).toBe("old contents\n");
80+
expect(await fetcher.getFullText("new")).toBe("new contents\n");
81+
});
82+
83+
test("returns null for `none` specs", async () => {
84+
const fetcher = createFileSourceFetcher({
85+
old: { kind: "none" },
86+
new: { kind: "none" },
87+
});
88+
89+
expect(await fetcher.getFullText("old")).toBeNull();
90+
expect(await fetcher.getFullText("new")).toBeNull();
91+
});
92+
93+
test("returns null when an fs path cannot be read", async () => {
94+
const dir = createTempDir("hunk-source-fs-missing-");
95+
const fetcher = createFileSourceFetcher({
96+
old: { kind: "fs", absolutePath: join(dir, "missing.txt") },
97+
new: { kind: "none" },
98+
});
99+
100+
expect(await fetcher.getFullText("old")).toBeNull();
101+
});
102+
103+
test("reads git blob contents for both sides via `git show`", async () => {
104+
const repoRoot = createTempRepo("hunk-source-git-");
105+
const filePath = "note.txt";
106+
107+
writeFileSync(join(repoRoot, filePath), "first revision\n");
108+
git(repoRoot, "add", ".");
109+
git(repoRoot, "commit", "-m", "first");
110+
writeFileSync(join(repoRoot, filePath), "second revision\n");
111+
git(repoRoot, "add", ".");
112+
git(repoRoot, "commit", "-m", "second");
113+
114+
const fetcher = createFileSourceFetcher({
115+
old: { kind: "git-blob", repoRoot, ref: "HEAD~1", path: filePath },
116+
new: { kind: "git-blob", repoRoot, ref: "HEAD", path: filePath },
117+
});
118+
119+
expect(await fetcher.getFullText("old")).toBe("first revision\n");
120+
expect(await fetcher.getFullText("new")).toBe("second revision\n");
121+
});
122+
123+
test("reads git index contents through an explicit index spec", async () => {
124+
const repoRoot = createTempRepo("hunk-source-git-index-");
125+
const filePath = "note.txt";
126+
127+
writeFileSync(join(repoRoot, filePath), "committed\n");
128+
git(repoRoot, "add", ".");
129+
git(repoRoot, "commit", "-m", "first");
130+
writeFileSync(join(repoRoot, filePath), "staged\n");
131+
git(repoRoot, "add", filePath);
132+
writeFileSync(join(repoRoot, filePath), "working tree\n");
133+
134+
const fetcher = createFileSourceFetcher({
135+
old: { kind: "git-index", repoRoot, path: filePath },
136+
new: { kind: "fs", absolutePath: join(repoRoot, filePath) },
137+
});
138+
139+
expect(await fetcher.getFullText("old")).toBe("staged\n");
140+
expect(await fetcher.getFullText("new")).toBe("working tree\n");
141+
});
142+
143+
test("passes custom git executable through async git source reads", async () => {
144+
const originalSpawn = Bun.spawn;
145+
const mutableBun = Bun as unknown as { spawn: typeof Bun.spawn };
146+
const spawnCalls: string[][] = [];
147+
148+
mutableBun.spawn = ((cmds: string[]) => {
149+
spawnCalls.push(cmds);
150+
return originalSpawn(
151+
[
152+
process.execPath,
153+
"--eval",
154+
`process.stdout.write(${JSON.stringify(`read:${cmds[2]}\n`)})`,
155+
],
156+
{
157+
stdin: "ignore",
158+
stdout: "pipe",
159+
stderr: "pipe",
160+
},
161+
);
162+
}) as typeof Bun.spawn;
163+
164+
try {
165+
const fetcher = createFileSourceFetcher(
166+
{
167+
old: { kind: "git-blob", repoRoot: process.cwd(), ref: "HEAD", path: "note.txt" },
168+
new: { kind: "git-index", repoRoot: process.cwd(), path: "note.txt" },
169+
},
170+
{ gitExecutable: "custom-git" },
171+
);
172+
173+
expect(await fetcher.getFullText("old")).toBe("read:HEAD:note.txt\n");
174+
expect(await fetcher.getFullText("new")).toBe("read::note.txt\n");
175+
} finally {
176+
mutableBun.spawn = originalSpawn;
177+
}
178+
179+
expect(spawnCalls).toEqual([
180+
["custom-git", "show", "HEAD:note.txt"],
181+
["custom-git", "show", ":note.txt"],
182+
]);
183+
});
184+
185+
test("returns null when a git blob cannot be resolved", async () => {
186+
const repoRoot = createTempRepo("hunk-source-git-missing-");
187+
writeFileSync(join(repoRoot, "tracked.txt"), "x\n");
188+
git(repoRoot, "add", ".");
189+
git(repoRoot, "commit", "-m", "first");
190+
191+
const fetcher = createFileSourceFetcher({
192+
old: { kind: "git-blob", repoRoot, ref: "HEAD", path: "missing-from-history.txt" },
193+
new: { kind: "none" },
194+
});
195+
196+
const loggedErrors = await captureConsoleErrors(async () => {
197+
expect(await fetcher.getFullText("old")).toBeNull();
198+
});
199+
expect(loggedErrors).toHaveLength(0);
200+
});
201+
202+
test("logs unexpected git source failures with object context", async () => {
203+
const repoRoot = createTempDir("hunk-source-git-not-repo-");
204+
const fetcher = createFileSourceFetcher({
205+
old: { kind: "git-blob", repoRoot, ref: "HEAD", path: "note.txt" },
206+
new: { kind: "none" },
207+
});
208+
209+
const loggedErrors = await captureConsoleErrors(async () => {
210+
expect(await fetcher.getFullText("old")).toBeNull();
211+
});
212+
213+
expect(loggedErrors).toHaveLength(1);
214+
expect(String(loggedErrors[0]?.[0])).toContain("HEAD:note.txt");
215+
expect(String(loggedErrors[0]?.[0])).toContain(repoRoot);
216+
});
217+
218+
test("caches resolved text per side", async () => {
219+
const dir = createTempDir("hunk-source-cache-");
220+
const target = join(dir, "value.txt");
221+
writeFileSync(target, "first\n");
222+
223+
const fetcher = createFileSourceFetcher({
224+
old: { kind: "none" },
225+
new: { kind: "fs", absolutePath: target },
226+
});
227+
228+
const initial = await fetcher.getFullText("new");
229+
writeFileSync(target, "rewritten\n");
230+
const cached = await fetcher.getFullText("new");
231+
232+
expect(initial).toBe("first\n");
233+
expect(cached).toBe("first\n");
234+
});
235+
});

0 commit comments

Comments
 (0)