Skip to content
Merged
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
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ All notable user-visible changes to Hunk are documented in this file.
### Added

- 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.
- 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.

### Changed

Expand Down
22 changes: 21 additions & 1 deletion src/core/diffFile.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { getFiletypeFromFileName, type FileDiffMetadata } from "@pierre/diffs";
import { findAgentFileContext } from "./agent";
import { patchLooksBinary } from "./binary";
import { normalizeDiffMetadataPaths, normalizeDiffPath } from "./diffPaths";
import type { FileSourceFetcher } from "./fileSource";
import type { AgentContext, DiffFile } from "./types";

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

export interface DiffFileSourceContext {
path: string;
previousPath?: string;
type: FileDiffMetadata["type"];
isUntracked: boolean;
isBinary: boolean;
}

export interface BuildDiffFileOptions {
isUntracked?: boolean;
previousPath?: string;
isBinary?: boolean;
sourceFetcherBuilder?: (file: DiffFileSourceContext) => FileSourceFetcher | undefined;
isTooLarge?: boolean;
stats?: DiffFile["stats"];
statsTruncated?: boolean;
Expand All @@ -41,6 +51,7 @@ export function buildDiffFile(
isUntracked,
previousPath,
isBinary,
sourceFetcherBuilder,
isTooLarge,
stats,
statsTruncated,
Expand All @@ -49,6 +60,14 @@ export function buildDiffFile(
const normalizedMetadata = normalizeDiffMetadataPaths(metadata);
const path = normalizedMetadata.name;
const resolvedPreviousPath = normalizeDiffPath(previousPath) ?? normalizedMetadata.prevName;
const resolvedIsBinary = isBinary ?? patchLooksBinary(patch);
const sourceFetcher = sourceFetcherBuilder?.({
path,
previousPath: resolvedPreviousPath,
type: normalizedMetadata.type,
isUntracked: Boolean(isUntracked),
isBinary: resolvedIsBinary,
});

return {
id: `${sourcePrefix}:${index}:${path}`,
Expand All @@ -60,9 +79,10 @@ export function buildDiffFile(
metadata: normalizedMetadata,
agent: findAgentFileContext(agentContext, path, resolvedPreviousPath),
isUntracked,
isBinary: isBinary ?? patchLooksBinary(patch),
isBinary: resolvedIsBinary,
isTooLarge,
statsTruncated,
sourceFetcher,
};
}

Expand Down
235 changes: 235 additions & 0 deletions src/core/fileSource.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,235 @@
import { afterEach, describe, expect, test } from "bun:test";
import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { createFileSourceFetcher } from "./fileSource";

const tempDirs: string[] = [];

function createTempDir(prefix: string) {
const dir = mkdtempSync(join(tmpdir(), prefix));
tempDirs.push(dir);
return dir;
}

function git(cwd: string, ...cmd: string[]) {
const proc = Bun.spawnSync(["git", ...cmd], {
cwd,
stdout: "pipe",
stderr: "pipe",
stdin: "ignore",
});

if (proc.exitCode !== 0) {
const stderr = Buffer.from(proc.stderr).toString("utf8");
throw new Error(stderr.trim() || `git ${cmd.join(" ")} failed`);
}

return Buffer.from(proc.stdout).toString("utf8");
}

function createTempRepo(prefix: string) {
const dir = createTempDir(prefix);
git(dir, "init");
git(dir, "config", "user.name", "Test User");
git(dir, "config", "user.email", "test@example.com");
git(dir, "config", "commit.gpgSign", "false");
return dir;
}

/** Capture console.error calls while exercising diagnostic paths. */
async function captureConsoleErrors(fn: () => Promise<void>) {
const originalConsoleError = console.error;
const loggedErrors: unknown[][] = [];
console.error = (...args: unknown[]) => {
loggedErrors.push(args);
};

try {
await fn();
} finally {
console.error = originalConsoleError;
}

return loggedErrors;
}

afterEach(() => {
while (tempDirs.length > 0) {
const dir = tempDirs.pop();
if (dir) {
rmSync(dir, { recursive: true, force: true });
}
}
});

describe("createFileSourceFetcher", () => {
test("reads fs paths for old and new sides", async () => {
const dir = createTempDir("hunk-source-fs-");
const left = join(dir, "before.txt");
const right = join(dir, "after.txt");
writeFileSync(left, "old contents\n");
writeFileSync(right, "new contents\n");

const fetcher = createFileSourceFetcher({
old: { kind: "fs", absolutePath: left },
new: { kind: "fs", absolutePath: right },
});

expect(await fetcher.getFullText("old")).toBe("old contents\n");
expect(await fetcher.getFullText("new")).toBe("new contents\n");
});

test("returns null for `none` specs", async () => {
const fetcher = createFileSourceFetcher({
old: { kind: "none" },
new: { kind: "none" },
});

expect(await fetcher.getFullText("old")).toBeNull();
expect(await fetcher.getFullText("new")).toBeNull();
});

test("returns null when an fs path cannot be read", async () => {
const dir = createTempDir("hunk-source-fs-missing-");
const fetcher = createFileSourceFetcher({
old: { kind: "fs", absolutePath: join(dir, "missing.txt") },
new: { kind: "none" },
});

expect(await fetcher.getFullText("old")).toBeNull();
});

test("reads git blob contents for both sides via `git show`", async () => {
const repoRoot = createTempRepo("hunk-source-git-");
const filePath = "note.txt";

writeFileSync(join(repoRoot, filePath), "first revision\n");
git(repoRoot, "add", ".");
git(repoRoot, "commit", "-m", "first");
writeFileSync(join(repoRoot, filePath), "second revision\n");
git(repoRoot, "add", ".");
git(repoRoot, "commit", "-m", "second");

const fetcher = createFileSourceFetcher({
old: { kind: "git-blob", repoRoot, ref: "HEAD~1", path: filePath },
new: { kind: "git-blob", repoRoot, ref: "HEAD", path: filePath },
});

expect(await fetcher.getFullText("old")).toBe("first revision\n");
expect(await fetcher.getFullText("new")).toBe("second revision\n");
});

test("reads git index contents through an explicit index spec", async () => {
const repoRoot = createTempRepo("hunk-source-git-index-");
const filePath = "note.txt";

writeFileSync(join(repoRoot, filePath), "committed\n");
git(repoRoot, "add", ".");
git(repoRoot, "commit", "-m", "first");
writeFileSync(join(repoRoot, filePath), "staged\n");
git(repoRoot, "add", filePath);
writeFileSync(join(repoRoot, filePath), "working tree\n");

const fetcher = createFileSourceFetcher({
old: { kind: "git-index", repoRoot, path: filePath },
new: { kind: "fs", absolutePath: join(repoRoot, filePath) },
});

expect(await fetcher.getFullText("old")).toBe("staged\n");
expect(await fetcher.getFullText("new")).toBe("working tree\n");
});

test("passes custom git executable through async git source reads", async () => {
const originalSpawn = Bun.spawn;
const mutableBun = Bun as unknown as { spawn: typeof Bun.spawn };
const spawnCalls: string[][] = [];

mutableBun.spawn = ((cmds: string[]) => {
spawnCalls.push(cmds);
return originalSpawn(
[
process.execPath,
"--eval",
`process.stdout.write(${JSON.stringify(`read:${cmds[2]}\n`)})`,
],
{
stdin: "ignore",
stdout: "pipe",
stderr: "pipe",
},
);
}) as typeof Bun.spawn;

try {
const fetcher = createFileSourceFetcher(
{
old: { kind: "git-blob", repoRoot: process.cwd(), ref: "HEAD", path: "note.txt" },
new: { kind: "git-index", repoRoot: process.cwd(), path: "note.txt" },
},
{ gitExecutable: "custom-git" },
);

expect(await fetcher.getFullText("old")).toBe("read:HEAD:note.txt\n");
expect(await fetcher.getFullText("new")).toBe("read::note.txt\n");
} finally {
mutableBun.spawn = originalSpawn;
}

expect(spawnCalls).toEqual([
["custom-git", "show", "HEAD:note.txt"],
["custom-git", "show", ":note.txt"],
]);
});

test("returns null when a git blob cannot be resolved", async () => {
const repoRoot = createTempRepo("hunk-source-git-missing-");
writeFileSync(join(repoRoot, "tracked.txt"), "x\n");
git(repoRoot, "add", ".");
git(repoRoot, "commit", "-m", "first");

const fetcher = createFileSourceFetcher({
old: { kind: "git-blob", repoRoot, ref: "HEAD", path: "missing-from-history.txt" },
new: { kind: "none" },
});

const loggedErrors = await captureConsoleErrors(async () => {
expect(await fetcher.getFullText("old")).toBeNull();
});
expect(loggedErrors).toHaveLength(0);
});

test("logs unexpected git source failures with object context", async () => {
const repoRoot = createTempDir("hunk-source-git-not-repo-");
const fetcher = createFileSourceFetcher({
old: { kind: "git-blob", repoRoot, ref: "HEAD", path: "note.txt" },
new: { kind: "none" },
});

const loggedErrors = await captureConsoleErrors(async () => {
expect(await fetcher.getFullText("old")).toBeNull();
});

expect(loggedErrors).toHaveLength(1);
expect(String(loggedErrors[0]?.[0])).toContain("HEAD:note.txt");
expect(String(loggedErrors[0]?.[0])).toContain(repoRoot);
});

test("caches resolved text per side", async () => {
const dir = createTempDir("hunk-source-cache-");
const target = join(dir, "value.txt");
writeFileSync(target, "first\n");

const fetcher = createFileSourceFetcher({
old: { kind: "none" },
new: { kind: "fs", absolutePath: target },
});

const initial = await fetcher.getFullText("new");
writeFileSync(target, "rewritten\n");
const cached = await fetcher.getFullText("new");

expect(initial).toBe("first\n");
expect(cached).toBe("first\n");
});
});
Loading
Loading