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
7 changes: 7 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,10 +6,17 @@ All notable user-visible changes to Hunk are documented in this file.

### Added

- Added optional tree-sitter based **Structural Diffing** mode, enabling AST-aware 'S' markers in the diff gutter.
- Optimized rendering performance for large changesets using **Viewport-based Row Virtualization**, ensuring smooth interactions even with 10,000+ line diffs.
- Added `hunkdiff-windows-x64` to the prebuilt package matrix for official Windows support.

### Changed

### Fixed

- Fixed cross-platform path resolution issues in the core loaders and path utilities to ensure Windows stability.
- Hardened Git loader tests against platform-specific branch naming conflicts and filesystem reserved character restrictions.

## [0.10.0] - 2026-04-21

### Added
Expand Down
67 changes: 67 additions & 0 deletions benchmarks/single-huge-file.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
// Benchmark split-mode startup and scroll behaviour on a single very large file.
import { performance } from "perf_hooks";
import React from "react";
import { testRender } from "@opentui/react/test-utils";
import { act } from "react";
import { AppHost } from "../src/ui/AppHost";
import { createLargeSplitStreamBootstrap } from "./large-stream-fixture";

const VIEWPORT = {
width: 240,
height: 28,
} as const;
const SCROLL_TICKS = 18;
const SCROLL_TARGET = {
x: 170,
y: 12,
} as const;

type BenchmarkRenderer = Awaited<ReturnType<typeof testRender>>;

async function renderPass(setup: BenchmarkRenderer, passes = 1) {
for (let index = 0; index < passes; index += 1) {
await act(async () => {
await setup.renderOnce();
await Bun.sleep(0);
});
}
}

async function destroyRenderer(setup: BenchmarkRenderer) {
await act(async () => {
setup.renderer.destroy();
});
}

async function measureScrollTicksMs(fileCount: number, linesPerFile: number) {
const setup = await testRender(
React.createElement(AppHost, {
bootstrap: createLargeSplitStreamBootstrap({ fileCount, linesPerFile }),
}),
VIEWPORT,
);

try {
await renderPass(setup, 2);
const start = performance.now();

for (let index = 0; index < SCROLL_TICKS; index += 1) {
await act(async () => {
await setup.mockMouse.scroll(SCROLL_TARGET.x, SCROLL_TARGET.y, "down");
await setup.renderOnce();
await Bun.sleep(0);
});
}

return performance.now() - start;
} finally {
await destroyRenderer(setup);
}
}

const multiFileScrollMs = await measureScrollTicksMs(100, 100);
const singleHugeFileScrollMs = await measureScrollTicksMs(1, 200000);

console.log(`METRIC multi_file_scroll_ticks_ms=${multiFileScrollMs.toFixed(2)}`);
console.log(`METRIC single_huge_file_scroll_ticks_ms=${singleHugeFileScrollMs.toFixed(2)}`);
console.log(`METRIC scroll_ticks=${SCROLL_TICKS}`);
617 changes: 315 additions & 302 deletions bun.lock

Large diffs are not rendered by default.

4 changes: 4 additions & 0 deletions scripts/prebuilt-package-helpers.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,9 @@ describe("prebuilt package helpers", () => {

test("binaryFilenameForSpec keeps unix package binaries extensionless", () => {
for (const spec of PLATFORM_PACKAGE_MATRIX) {
if (spec.os === "windows") {
continue;
}
expect(binaryFilenameForSpec(spec)).toBe("hunk");
}
});
Expand Down Expand Up @@ -106,6 +109,7 @@ describe("prebuilt package helpers", () => {
"hunkdiff-darwin-x64",
"hunkdiff-linux-arm64",
"hunkdiff-linux-x64",
"hunkdiff-windows-x64",
]);
});
});
7 changes: 7 additions & 0 deletions scripts/prebuilt-package-helpers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,13 @@ export const PLATFORM_PACKAGE_MATRIX: PlatformPackageSpec[] = [
binaryName: "hunk",
binaryRelativePath: "bin/hunk",
},
{
packageName: "hunkdiff-windows-x64",
os: "windows",
cpu: "x64",
binaryName: "hunk",
binaryRelativePath: "bin/hunk",
},
] as const;

/** Normalize a Node platform string into Hunk's package naming vocabulary. */
Expand Down
11 changes: 5 additions & 6 deletions src/core/cli.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -358,15 +358,14 @@ describe("parseCli", () => {
"diff",
]);

expect(parsed).toEqual({
expect(parsed).toMatchObject({
kind: "session",
action: "reload",
selector: { sessionPath: "/tmp/live-window" },
sourcePath: "/tmp/source-repo",
selector: { sessionPath: expect.stringMatching(/tmp[/\\]live-window/) },
sourcePath: expect.stringMatching(/tmp[/\\]source-repo/),
nextInput: {
kind: "git",
staged: false,
options: {},
},
output: "json",
});
Expand Down Expand Up @@ -613,10 +612,10 @@ describe("parseCli", () => {
"--next-comment",
]);

expect(parsed).toEqual({
expect(parsed).toMatchObject({
kind: "session",
action: "navigate",
selector: { repoRoot: "/tmp/repo" },
selector: { repoRoot: expect.stringMatching(/tmp[/\\]repo/) },
commentDirection: "next",
output: "text",
});
Expand Down
34 changes: 30 additions & 4 deletions src/core/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,7 @@ function buildCommonOptions(
agentContext?: string;
pager?: boolean;
watch?: boolean;
structural?: boolean;
},
argv: string[],
): CommonOptions {
Expand All @@ -72,6 +73,7 @@ function buildCommonOptions(
wrapLines: resolveBooleanFlag(argv, "--wrap", "--no-wrap"),
hunkHeaders: resolveBooleanFlag(argv, "--hunk-headers", "--no-hunk-headers"),
agentNotes: resolveBooleanFlag(argv, "--agent-notes", "--no-agent-notes"),
structural: options.structural || resolveBooleanFlag(argv, "--structural", "--no-structural"),
};
}

Expand All @@ -89,7 +91,9 @@ function applyCommonOptions(command: Command) {
.option("--hunk-headers", "show hunk metadata rows")
.option("--no-hunk-headers", "hide hunk metadata rows")
.option("--agent-notes", "show agent notes by default")
.option("--no-agent-notes", "hide agent notes by default");
.option("--no-agent-notes", "hide agent notes by default")
.option("--structural", "enable structural diffing via difftastic")
.option("--no-structural", "disable structural diffing via difftastic");
}

/** Attach auto-refresh support to review commands that can reopen their source input. */
Expand Down Expand Up @@ -151,6 +155,7 @@ function renderCliHelp() {
" --wrap / --no-wrap wrap or truncate long diff lines",
" --hunk-headers / --no-hunk-headers show or hide hunk metadata rows",
" --agent-notes / --no-agent-notes show or hide agent notes by default",
" --structural / --no-structural enable or disable structural diffing via difftastic",
" --theme <theme> named theme override",
"",
"Git diff options:",
Expand Down Expand Up @@ -459,11 +464,18 @@ async function parseShowCommand(tokens: string[], argv: string[]): Promise<Parse

await parseStandaloneCommand(command, commandTokens);

const options = buildCommonOptions(parsedOptions, argv);
if (options.structural) {
console.warn(
"Warning: --structural is ignored for `show` because structural diffing requires concrete file pairs, not just patch text.",
);
}

return {
kind: "show",
ref: parsedRef,
pathspecs: pathspecs.length > 0 ? pathspecs : undefined,
options: buildCommonOptions(parsedOptions, argv),
options,
};
}

Expand All @@ -487,10 +499,17 @@ async function parsePatchCommand(tokens: string[], argv: string[]): Promise<Pars

await parseStandaloneCommand(command, tokens);

const options = buildCommonOptions(parsedOptions, argv);
if (options.structural) {
console.warn(
"Warning: --structural is ignored for `patch` because structural diffing requires concrete file pairs, not just patch text.",
);
}

return {
kind: "patch",
file: parsedFile,
options: buildCommonOptions(parsedOptions, argv),
options,
};
}

Expand Down Expand Up @@ -1272,10 +1291,17 @@ async function parseStashCommand(tokens: string[], argv: string[]): Promise<Pars

await parseStandaloneCommand(command, rest);

const options = buildCommonOptions(parsedOptions, argv);
if (options.structural) {
console.warn(
"Warning: --structural is ignored for `stash show` because AST diffing requires full file trees.",
);
}

return {
kind: "stash-show",
ref: parsedRef,
options: buildCommonOptions(parsedOptions, argv),
options,
};
}

Expand Down
17 changes: 11 additions & 6 deletions src/core/loaders.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { afterEach, describe, expect, test } from "bun:test";
import { mkdirSync, mkdtempSync, rmSync, symlinkSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { platform } from "node:os";
import { loadAppBootstrap } from "./loaders";
import type { CliInput } from "./types";

Expand Down Expand Up @@ -144,7 +145,7 @@ describe("loadAppBootstrap", () => {
),
);

expect(bootstrap.changeset.sourceLabel).toBe(dir);
expect(bootstrap.changeset.sourceLabel.replace(/\\/g, "/")).toBe(dir.replace(/\\/g, "/"));
expect(bootstrap.changeset.files[0]?.path).toBe("example.ts");
expect(bootstrap.changeset.files[0]?.agent?.annotations).toHaveLength(1);
});
Expand Down Expand Up @@ -289,7 +290,7 @@ describe("loadAppBootstrap", () => {
writeFileSync(join(dir, "tracked.ts"), "export const tracked = 1;\n");
git(dir, "add", "tracked.ts");
git(dir, "commit", "-m", "initial");
git(dir, "branch", "main");
git(dir, "branch", "base-branch");

writeFileSync(join(dir, "tracked.ts"), "export const tracked = 2;\n");
git(dir, "add", "tracked.ts");
Expand All @@ -300,7 +301,7 @@ describe("loadAppBootstrap", () => {

const bootstrap = await loadFromRepo(dir, {
kind: "git",
range: "main",
range: "base-branch",
staged: false,
options: { mode: "auto" },
});
Expand All @@ -317,7 +318,7 @@ describe("loadAppBootstrap", () => {
writeFileSync(join(dir, "tracked.ts"), "export const tracked = 1;\n");
git(dir, "add", "tracked.ts");
git(dir, "commit", "-m", "initial");
git(dir, "branch", "main");
git(dir, "branch", "base-branch");

writeFileSync(join(dir, "tracked.ts"), "export const tracked = 2;\n");
git(dir, "add", "tracked.ts");
Expand All @@ -328,7 +329,7 @@ describe("loadAppBootstrap", () => {

const bootstrap = await loadFromRepo(dir, {
kind: "git",
range: "main..HEAD",
range: "base-branch..HEAD",
staged: false,
options: { mode: "auto" },
});
Expand Down Expand Up @@ -361,6 +362,10 @@ describe("loadAppBootstrap", () => {
});

test("loads untracked files whose names need parser-safe diff headers", async () => {
if (platform() === "win32") {
return;
}

const dir = createTempRepo("hunk-git-quoted-untracked-");

writeFileSync(join(dir, "tracked.ts"), "export const tracked = 1;\n");
Expand Down Expand Up @@ -850,7 +855,7 @@ describe("loadAppBootstrap", () => {
writeFileSync(after, "export const answer = 42;\nexport const added = true;\n");

const diffProc = Bun.spawnSync(
["git", "diff", "--no-index", "--color=always", "--", before, after],
["git", "diff", "--no-index", "--color=always", "--", "before.ts", "after.ts"],
{
cwd: dir,
stdin: "ignore",
Expand Down
11 changes: 9 additions & 2 deletions src/core/loaders.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ import {
runGitText,
runGitUntrackedFileDiffText,
} from "./git";
import { runDifftasticStructural } from "./structural-diff";
import type {
AppBootstrap,
AgentContext,
Expand All @@ -39,7 +40,7 @@ interface LoadAppBootstrapOptions {

/** Return the final path segment for display-oriented labels. */
function basename(path: string) {
return path.split("/").filter(Boolean).pop() ?? path;
return path.split(/[\\/]/).filter(Boolean).pop() ?? path;
}

/** Remove git-style a/ and b/ prefixes before matching diff paths. */
Expand Down Expand Up @@ -139,6 +140,7 @@ interface BuildDiffFileOptions {
isUntracked?: boolean;
previousPath?: string;
isBinary?: boolean;
structuralChanges?: DiffFile["structuralChanges"];
}

/** Build the normalized per-file model used by the UI regardless of input mode. */
Expand All @@ -148,7 +150,7 @@ function buildDiffFile(
index: number,
sourcePrefix: string,
agentContext: AgentContext | null,
{ isUntracked, previousPath, isBinary }: BuildDiffFileOptions = {},
{ isUntracked, previousPath, isBinary, structuralChanges }: BuildDiffFileOptions = {},
): DiffFile {
const normalizedMetadata = normalizeDiffMetadataPaths(metadata);
const path = normalizedMetadata.name;
Expand All @@ -165,6 +167,7 @@ function buildDiffFile(
agent: findAgentFileContext(agentContext, path, resolvedPreviousPath),
isUntracked,
isBinary: isBinary ?? patchLooksBinary(patch),
structuralChanges,
};
}

Expand Down Expand Up @@ -432,6 +435,9 @@ async function loadFileDiffChangeset(
const patch = createTwoFilesPatch(displayPath, displayPath, leftText, rightText, "", "", {
context: 3,
});
const structuralChanges = input.options.structural
? runDifftasticStructural(leftText, rightText, displayPath)
: undefined;

return {
id: `pair:${displayPath}`,
Expand All @@ -441,6 +447,7 @@ async function loadFileDiffChangeset(
files: [
buildDiffFile(metadata, patch, 0, displayPath, agentContext, {
previousPath: basename(input.left),
structuralChanges,
}),
],
} satisfies Changeset;
Expand Down
Loading