Skip to content
Draft
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/fuzzy-editors-dock.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"hunkdiff": minor
---

Let extension commands temporarily hand Hunk's terminal to an application, resolve filesystem-attested review locations, and run Hunk's responsive open-in-editor workflow as a bundled extension.
21 changes: 16 additions & 5 deletions docs/extension-architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -20,11 +20,12 @@ object and registry collection (`src/extensions/runExtension.ts`):
by the app composition root (`app/vcsCatalog.ts`) and loaded synchronously
before config resolution, so backends exist without making core import the
extension host. `default/ui/index.ts` is deliberately not part of that list:
it synchronously loads the bundled files pane through `runExtensionFactory`
only where the app resolves UI panes.
it synchronously loads the bundled files pane and editor command through
`runExtensionFactory` only where the interactive app resolves UI contributions.

Git and the built-in file navigation use the public `registerVcsAdapter` and
`registerPane` paths. The external [Hunk Lens](https://github.com/modem-dev/hunk-lens)
Git, built-in file navigation, and open-in-editor workflow use the public
`registerVcsAdapter`, `registerPane`, and `registerCommand` paths. The external
[Hunk Lens](https://github.com/modem-dev/hunk-lens)
extension exercises current-line pane paint through that same public contract.

Bundled extensions are implicitly trusted and stay loaded under
Expand Down Expand Up @@ -272,10 +273,20 @@ inert before shutdown begins. Session behavior requests are registry data too:
presentation view changes ephemeral without teaching `App` about an extension
id.

`src/ui/hooks/useExtensionAppController.ts` owns `ctx.openInApp`. Command-scoped
leases refuse stale handoffs, one shared lock prevents overlapping applications,
and renderer suspension always resumes in `finally` unless the renderer was
destroyed. The extension owns execution and application-specific metadata;
Hunk's bundled editor command consumes the same public callback and explicitly
refreshes after a successful edit. Dialog admission and workspace writes consult
the same ownership state so host UI cannot deadlock behind a suspended renderer.

`src/ui/lib/extensionWorkspace.ts` owns the policy for `ctx.workspace`. Reads
resolve reviewed file ids through the existing source fetcher, which retains
ownership of caching and size limits. Missing or unreadable sources become
`null`.
`null`. Location resolution maps reviewed file ids and source addresses onto
attested on-disk paths and lines using per-side provenance supplied by loaders
and VCS adapters plus the authoritative parsed hunk.

Writes are limited to reloadable working-tree reviews and reviewed paths inside
the review root. App supplies the current input, unfiltered changeset, and root
Expand Down
94 changes: 82 additions & 12 deletions docs/extensions.md
Original file line number Diff line number Diff line change
Expand Up @@ -280,9 +280,11 @@ new instances and run that shutdown/startup pair around the replacement.

### `hunk.apiVersion`

The API generation this Hunk speaks (currently `15`). Branch on it if you want
one file to support several Hunk versions. Version 15 adds `{ side, line }` to
opted-in pane `currentLine` paint; version 14 added structured `rangeEndpoints`
The API generation this Hunk speaks (currently `16`). Branch on it if you want
one file to support several Hunk versions. Version 16 adds temporary application
handoffs and on-disk location resolution to command handlers; version 15 added
`{ side, line }` to opted-in pane
`currentLine` paint; version 14 added structured `rangeEndpoints`
to two-revision VCS diff requests; version 13 added saved-note parent identities and
committed note-edit events; version 12 adds responsive fractional pane sizing; version 11 added
the `"dim"` line-highlight tone; version 10 added generic top-level CLI commands; version 9
Expand Down Expand Up @@ -467,12 +469,13 @@ instead of a crash.
A `load` result is patch text plus how to label it. Everything else on it is
optional, and each optional field buys one thing:

| Field | What it adds |
| ---------------- | ------------------------------------------------------------------ |
| `untrackedPaths` | files your VCS calls unknown, synthesized into added-file diffs |
| `readFileSource` | exact whole-file contents, for context expansion and highlighting |
| `sourceCacheKey` | stable source-snapshot identity for highlight reuse across reloads |
| `extraFiles` | files reviewed outside the patch, including skipped placeholders |
| Field | What it adds |
| ----------------------- | ------------------------------------------------------------------ |
| `untrackedPaths` | files your VCS calls unknown, synthesized into added-file diffs |
| `readFileSource` | exact whole-file contents, for context expansion and highlighting |
| `resolveFileSourcePath` | exact filesystem provenance for application location handoff |
| `sourceCacheKey` | stable source-snapshot identity for highlight reuse across reloads |
| `extraFiles` | files reviewed outside the patch, including skipped placeholders |

`untrackedPaths` is the shorthand: list the repo-root-relative paths your VCS
reports as unknown and Hunk synthesizes the added-file diffs for you, skipping
Expand Down Expand Up @@ -585,6 +588,10 @@ async load(input, ctx) {
}
return changeType === "deleted" ? null : hgCat(newRev, path);
},
resolveFileSourcePath: ({ path, changeType, side }) => {
if (side !== "new" || changeType === "deleted" || input.range) return null;
return join(ctx.cwd, path);
},
};
}
```
Expand All @@ -604,6 +611,15 @@ stable identity and Hunk will invalidate conservatively. Leaving
`readFileSource` off is fine: Hunk falls back to the content the patch itself carries,
which renders the same diff with less context available.

`resolveFileSourcePath` is separate from source reads because a binary or skipped
file can still have a real path. Return an absolute path only when that exact
reviewed side is backed by the filesystem. Return `null` for absent sides and
for index, revision, stash, patch, merged, or other virtual sources, even when a
same-named working-tree file exists. Hunk uses this provenance for
`ctx.workspace.resolveLocation`; it never invents a checkout path for historical
content. Direct file and difftool comparisons retain their concrete input paths
independently of their display names.

#### Files outside the patch

`extraFiles` lists files to review that your `patchText` does not contain, in
Expand Down Expand Up @@ -1679,6 +1695,47 @@ the same way, and a request made after that point cancels immediately. A blank
answer from the user, so the promise **rejects**; like any other handler
failure, that surfaces as a warning naming your extension.

#### Temporary applications

`ctx.openInApp(callback)` temporarily replaces Hunk with an application your
extension runs. Hunk suspends its renderer before calling you and restores the
review in `finally` after your callback returns or throws:

```ts
async function runProjectTool(metadata: { file: string | undefined; line: number | undefined }) {
// Spawn an interactive process with inherited stdio and encode metadata however the app expects.
return { exitCode: 0, metadata };
}

hunk.registerCommand({ id: "open-tool", title: "Open project tool", key: "f8" }, async (ctx) => {
const file = ctx.selection.file;
const location = file
? ctx.workspace.resolveLocation({
fileId: file.id,
...(ctx.selection.hunkIndex === null ? {} : { hunkIndex: ctx.selection.hunkIndex }),
...(ctx.selection.currentLine === null ? {} : { line: ctx.selection.currentLine }),
})
: null;
const result = await ctx.openInApp(() =>
runProjectTool({
file: location?.path,
line: location?.line,
}),
);
if (result.exitCode !== 0) ctx.notify(`Tool exited with status ${result.exitCode}`, "error");
});
```

The extension owns process execution and decides how to pass file, line, hunk,
or extension state through arguments, environment, files, or an application-specific
protocol. Hunk only owns terminal suspension and restoration. One application
may own the terminal at a time; concurrent calls and controls retained past a
review reload reject without invoking the callback. The callback's value and
error pass through unchanged. Host-presented dialogs cancel immediately and
workspace writes return `unavailable` while the callback owns the terminal, so
do not await Hunk UI from inside it. Non-interactive workspace reads and location
resolution remain available.

#### Workspace documents

`ctx.workspace` reads full documents from the current review and can replace an
Expand All @@ -1687,6 +1744,7 @@ eligible working-tree file.
| Method | Result |
| -------------------------------------- | ------------------------------------------------- |
| `readDocument(fileId, "old" \| "new")` | The reviewed source text, or `null` |
| `resolveLocation({ fileId, ... })` | Absolute on-disk `{ path, line }`, or `null` |
| `canWriteDocument(fileId)` | Whether the review and file allow writes |
| `writeDocument({ fileId, text })` | `{ ok: true }` or `{ ok: false, reason, detail }` |

Expand Down Expand Up @@ -1718,6 +1776,17 @@ returns `null` when the file or side is absent, no source is available, reading
fails, or the document exceeds Hunk's size limit. Reads never prompt. An invalid
side rejects the promise.

`resolveLocation` turns a reviewed file id and optional `hunkIndex` and
`{ side, line }` into an attested absolute path and one-based line on disk. Hunk
uses parsed hunk metadata to map old-side deletions onto a filesystem-backed new
side, so extensions can pass accurate locations to editors, debuggers, browsers,
or other applications without interpreting opaque diff metadata. Direct file
comparisons retain their concrete input paths, including the old path for a
deleted-file comparison. Index, revision, stash, patch, merged, absent, and
other virtual sides return `null` instead of borrowing a same-named checkout
file. Missing hunks and stale controls also return `null`; malformed source
addresses reject.

Writes require all of the following:

- an unstaged working-tree review (`hunk diff` with no revision range)
Expand Down Expand Up @@ -1793,9 +1862,10 @@ ready resolve to their cancel value with a warning rather than opening later.
Controls retained across a review or extension-registry replacement expire:
navigation and pane mutations warn and do nothing, dialogs resolve to their
normal cancel value, and workspace reads or not-yet-started writes return
`null`/`unavailable` instead of acting on replacement content. Once a consented
filesystem write starts, it reports its actual outcome and success reconciles
the review then active.
`null`/`unavailable` instead of acting on replacement content. A stale
`openInApp` callback rejects before taking terminal ownership.
Once a consented filesystem write starts, it reports its actual outcome and
success reconciles the review then active.

| Event | Payload | When |
| ---------------------- | ----------------------- | --------------------------------------------------------- |
Expand Down
24 changes: 15 additions & 9 deletions skills/hunk-extensions/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -110,7 +110,7 @@ bad or duplicate id is skipped with a startup notice.
| Coordinate with another loaded extension | `hunk.events.emit` / `hunk.events.on` |
| Read user-supplied settings | `hunk.config` (`[extension.<id>]` table) |
| Snapshot stable files and every saved review note | `ctx.review.snapshot()` in a command |
| Branch on the API generation (currently `15`) | `hunk.apiVersion` |
| Branch on the API generation (currently `16`) | `hunk.apiVersion` |

Registration is only valid while the factory runs — Hunk seals the API object
afterwards.
Expand Down Expand Up @@ -160,8 +160,10 @@ transform — gets `ctx.cwd` and `ctx.notify(message, type?)`. A file view's
(`isEnabled`/`execute` for public semantic `hunk.*` commands),
`ctx.keyboardModes` (enter/exit/probe this extension's session modes), `ctx.review`
(deeply immutable snapshots of stable files and complete saved store notes),
`ctx.dialogs` (`confirm`/`select`/`input`, queued and attributed), and
`ctx.workspace` (`readDocument`, `canWriteDocument`, `writeDocument` with consent).
`ctx.dialogs` (`confirm`/`select`/`input`, queued and attributed),
`ctx.openInApp` (temporary terminal ownership around extension-run applications),
and `ctx.workspace` (`readDocument`, `resolveLocation`, `canWriteDocument`,
`writeDocument` with consent).
- **Pane components** get frozen `files`, selection, placement, exact dimensions,
optional `currentLine` paint (with `{ side, line }` when opted in), semantic `theme`, resolved `keybindings`, and
guarded navigation/notification `actions`.
Expand Down Expand Up @@ -214,8 +216,9 @@ Most extension bugs are one of these:
`review-note-navigator` shows how to join stable note ids and file keys back to guarded
navigation after awaiting a selector; file filters can still refuse hidden targets.
- **Retained review controls expire on reload.** An old handler cannot control
replacement content: pane/navigation calls become inert, dialogs cancel, and
workspace reads or not-yet-started writes return `null`/`unavailable`. A
replacement content: pane/navigation calls become inert, dialogs cancel,
stale app handoffs reject, and workspace reads or not-yet-started writes
return `null`/`unavailable`. A
consented write already in progress reports its real outcome, holds graceful
exit until it settles, and reconciles the active review on success. `shutdown`
runs after revocation, so use it only
Expand Down Expand Up @@ -252,10 +255,13 @@ Most extension bugs are one of these:
- **Failures are contained, not sandboxed.** A throwing factory is rolled back to
zero registrations and a throwing handler is a warning naming the extension —
containment against bugs, not against code that should not have been loaded.
- **The API touches nothing outside the review.** No clipboard, no filesystem, no
process surface beyond `ctx.workspace` — an extension is ordinary code, so shell
out for the rest. Never write to stdout: the renderer owns it. For the same
reason `hunk.log` is collected as diagnostics and printed nowhere; `ctx.notify`
- **Application execution stays extension-owned.** Extensions are ordinary
trusted code and may spawn processes; use `ctx.openInApp` when one needs the
terminal so Hunk suspends and restores its renderer. `ctx.workspace.resolveLocation`
maps only filesystem-attested reviewed sides to app-ready paths and lines.
Dialogs cancel and writes refuse while an app owns the terminal, so do not
await host UI inside the callback. Never write to stdout while
Hunk owns the terminal; `hunk.log` is collected as diagnostics and `ctx.notify`
is how a user hears from you.
- **`HunkExtensionUserError`** (detected structurally by `name`) buys the full
treatment — message plus `suggestions`, no stack trace — only from a VCS adapter
Expand Down
17 changes: 17 additions & 0 deletions src/core/changeset/diffFile.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -105,6 +105,23 @@ describe("buildDiffFile", () => {
isBinary: false,
});
});

test("retains source paths for binary files independently of source fetching", () => {
let fetched = false;
const file = buildDiffFile(metadata, "Binary files a/x and b/x differ\n", 0, "src", null, {
sourceFetcherBuilder: () => {
fetched = true;
return undefined;
},
sourcePathBuilder: (context) => {
expect(context.isBinary).toBe(true);
return { old: "/repo/old.png", new: "/repo/new.png" };
},
});

expect(fetched).toBe(true);
expect(file.sourcePaths).toEqual({ old: "/repo/old.png", new: "/repo/new.png" });
});
});

describe("change-block line pairing", () => {
Expand Down
11 changes: 8 additions & 3 deletions src/core/changeset/diffFile.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import { findSidecarFileContext } from "./sidecar";
import { patchLooksBinary } from "./binary";
import { fileLanguageForPath } from "./fileLanguageLookup";
import { normalizeDiffMetadataPaths, normalizeDiffPath } from "./diffPaths";
import type { FileSourceFetcher } from "./fileSource";
import type { FileSourceFetcher, FileSourcePaths } from "./fileSource";
import type { DiffFile, DiffLineMoveKinds, SidecarContext } from "./model";

/** Count visible additions and deletions from parsed diff metadata. */
Expand Down Expand Up @@ -36,6 +36,7 @@ export interface BuildDiffFileOptions {
previousPath?: string;
isBinary?: boolean;
sourceFetcherBuilder?: (file: DiffFileSourceContext) => FileSourceFetcher | undefined;
sourcePathBuilder?: (file: DiffFileSourceContext) => FileSourcePaths | undefined;
isTooLarge?: boolean;
stats?: DiffFile["stats"];
statsTruncated?: boolean;
Expand All @@ -55,6 +56,7 @@ export function buildDiffFile(
previousPath,
isBinary,
sourceFetcherBuilder,
sourcePathBuilder,
isTooLarge,
stats,
statsTruncated,
Expand All @@ -69,13 +71,15 @@ export function buildDiffFile(
: (normalizeDiffPath(previousPath) ?? normalizedMetadata.prevName);
const resolvedIsBinary = isBinary ?? patchLooksBinary(patch);
const language = fileLanguageForPath(path);
const sourceFetcher = sourceFetcherBuilder?.({
const sourceContext = {
path,
previousPath: resolvedPreviousPath,
type: normalizedMetadata.type,
isUntracked: Boolean(isUntracked),
isBinary: resolvedIsBinary,
});
} satisfies DiffFileSourceContext;
const sourceFetcher = sourceFetcherBuilder?.(sourceContext);
const sourcePaths = sourcePathBuilder?.(sourceContext);

return {
id: `${sourcePrefix}:${index}:${path}`,
Expand All @@ -93,6 +97,7 @@ export function buildDiffFile(
isTooLarge,
statsTruncated,
sourceFetcher,
sourcePaths,
};
}

Expand Down
15 changes: 14 additions & 1 deletion src/core/changeset/fileSource.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,11 @@ 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, SourceTextTooLargeError } from "./fileSource";
import {
createFileSourceFetcher,
fileSourcePathsForSpecs,
SourceTextTooLargeError,
} from "./fileSource";

const tempDirs: string[] = [];

Expand All @@ -22,6 +26,15 @@ afterEach(() => {
});

describe("createFileSourceFetcher", () => {
test("projects only filesystem-backed specs to source paths", () => {
expect(
fileSourcePathsForSpecs({
old: { kind: "none" },
new: { kind: "fs", absolutePath: join("/repo", "after.txt") },
}),
).toEqual({ old: null, new: join("/repo", "after.txt") });
});

test("reads fs paths for old and new sides", async () => {
const dir = createTempDir("hunk-source-fs-");
const left = join(dir, "before.txt");
Expand Down
Loading
Loading