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
2 changes: 2 additions & 0 deletions .changeset/green-camels-brush.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
---
---
1 change: 1 addition & 0 deletions website/astro.config.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -97,6 +97,7 @@ export default defineConfig({
items: [
{ label: "Extensions", slug: "docs/extend/extensions" },
{ label: "Extension API", slug: "docs/extend/extension-api" },
{ label: "File previews", slug: "docs/extend/file-previews" },
{ label: "VCS adapters", slug: "docs/extend/vcs-adapters" },
{ label: "Custom sidebars", slug: "docs/extend/custom-sidebars" },
],
Expand Down
17 changes: 13 additions & 4 deletions website/src/content/docs/docs/extend/extension-api.md
Original file line number Diff line number Diff line change
@@ -1,13 +1,13 @@
---
title: Extension API
description: Register themes, languages, transforms, commands, dialogs, and events through the extension API object.
description: Register themes, file previews, transforms, commands, dialogs, and events through the extension API object.
---

The extension factory receives one API object. Registration calls are only valid while the factory is running; Hunk seals the object afterwards so a deferred callback cannot mutate the registry mid-session. This page indexes the whole object; the two largest registration calls are documented in depth on their own pages and summarized in place below.
The extension factory receives one API object. Registration calls are only valid while the factory is running; Hunk seals the object afterwards so a deferred callback cannot mutate the registry mid-session. This page indexes the whole object; larger registration calls are documented in depth on their own pages and summarized in place below.

## `hunk.apiVersion`

The API generation this Hunk speaks (currently `1`). Branch on it if you want one file to support several Hunk versions.
The API generation this Hunk speaks (currently `2`). Branch on it if you want one file to support several Hunk versions. Version 2 adds the experimental file-view contract and its command controls.

## `hunk.registerTheme(theme)`

Expand Down Expand Up @@ -48,6 +48,14 @@ Contribute a sidebar view — your own React component, rendered inside Hunk's O

Full contract: [Custom sidebars](/docs/extend/custom-sidebars/).

## `hunk.registerFileView(view)`

Contribute an opt-in alternate presentation for matching files. A view receives the public file and hunk model, typed change ranges, terminal width, cancellation, and lazy exact-source reads. It returns deterministic symbolic rows, optional fixed-height React/OpenTUI row painters, source bindings for inline notes, and positional hunk extents.

Raw diff remains the default and fallback. Hunk continues to own review-stream geometry, scrolling, windowing, hunk navigation, selection, and note rendering.

Full contract and examples: [File previews](/docs/extend/file-previews/).

## `hunk.transformChangeset(fn)`

Rewrite the loaded changeset before it reaches the review UI. Transforms run in registration order, each seeing the previous one's output, on first load and on every reload.
Expand Down Expand Up @@ -88,6 +96,7 @@ Registered commands are also listed in the menu bar's **Extensions** menu under
The handler fires when the key is pressed outside modal UI (dialogs, menus, and focused text inputs own their keys; pager mode does not dispatch extension commands). It receives the standard context plus:

- `ctx.sidebars.open(viewId)` / `close(viewId)` / `toggle(viewId)` / `isOpen(viewId)` — a bare id names your own view, `"files"` the built-in file navigation, `"<extensionId>:<viewId>"` any registered view. Opening also reveals a hidden sidebar area.
- `ctx.fileViews.select(viewId)` / `toggle(viewId)` / `isActive(viewId)` — controls a matching [file preview](/docs/extend/file-previews/) for the current file; `select(null)` restores raw diff.
- `ctx.selection` — where the review was pointing when the command fired.
- `ctx.navigation` — moves the review stream.
- `ctx.dialogs` — asks the user, below.
Expand Down Expand Up @@ -229,7 +238,7 @@ const patterns = (hunk.config.patterns as string[] | undefined) ?? ["*.lock"];

## `ctx.notify(message, type?)`

Every handler and transform receives a context with `cwd` and `notify`; event and bus handlers add `sidebars` and `events.emit`, command handlers add `sidebars`, `selection`, `navigation`, and `dialogs`. `notify` shows one transient line at the bottom of the app; `type` is `"info"` (default), `"warning"`, or `"error"`. Messages raised before the UI mounts are buffered, so a `startup` handler can notify safely.
Every handler and transform receives a context with `cwd` and `notify`; event and bus handlers add `sidebars` and `events.emit`, command handlers add `sidebars`, `fileViews`, `selection`, `navigation`, and `dialogs`. `notify` shows one transient line at the bottom of the app; `type` is `"info"` (default), `"warning"`, or `"error"`. Messages raised before the UI mounts are buffered, so a `startup` handler can notify safely.

## `hunk.log(message)`

Expand Down
3 changes: 2 additions & 1 deletion website/src/content/docs/docs/extend/extensions.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ export default function (hunk: HunkExtensionAPI) {

**The API is experimental**: `hunkdiff/extension` may change in breaking ways between minor releases while it stabilizes. Breaking changes are called out in release notes, and `hunk.apiVersion` identifies the surface an extension was written against.

What an extension can register is covered by the companion pages: the [extension API](/docs/extend/extension-api/), [VCS adapters](/docs/extend/vcs-adapters/), and [custom sidebars](/docs/extend/custom-sidebars/).
What an extension can register is covered by the companion pages: the [extension API](/docs/extend/extension-api/), [file previews](/docs/extend/file-previews/), [VCS adapters](/docs/extend/vcs-adapters/), and [custom sidebars](/docs/extend/custom-sidebars/).

## Where Hunk looks

Expand Down Expand Up @@ -60,6 +60,7 @@ The **id** is the file stem, or the folder name for `<name>/index.ts` and single
- config: `[extension.<id>]`
- commands: `<id>.<commandId>`
- sidebar views: `<id>:<viewId>`
- file previews: `<id>:<viewId>`

Ids start with a letter or digit, then letters, digits, `-`, or `_`. `hunk`, `git`, `jj`, and `sl` are reserved. An invalid id — or a second source offering an already-loaded id — is skipped with a startup notice.

Expand Down
205 changes: 205 additions & 0 deletions website/src/content/docs/docs/extend/file-previews.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,205 @@
---
title: File previews
description: Add opt-in file presentations that keep Hunk's review navigation, scrolling, and inline notes.
---

`hunk.registerFileView(view)` lets an extension offer a different way to read a changed file. A Markdown extension can render headings and lists, a package extension can summarize dependency changes, and a CSS extension can put color swatches beside changed values.

A preview is still part of Hunk's normal review stream. Hunk keeps control of file ordering, measurement, scrolling, windowing, hunk navigation, selection, and inline notes. The extension describes deterministic rows; it does not replace the review pane.

The file-view API is experimental and requires extension API version 2.

## What users see

Raw diff is always the default. Installing an extension does not silently replace any files.

When a registered view matches the selected file, Hunk adds it under **View → File presentation**. The user can choose a presentation for each file independently, so one review may contain custom previews and ordinary Pierre diffs together.

After choosing a preview, **View → Apply “…” to all matching files** selects it for every matching file in the changeset, including files hidden by the current filter. Files that do not match keep their existing presentation.

An extension command can also select or toggle its view for the current file:

```ts
hunk.registerCommand({ id: "toggle-preview", title: "Toggle preview", key: "f8" }, (ctx) => {
ctx.fileViews.toggle("preview");
});
```

`ctx.fileViews.select("preview")` selects a view, `select(null)` returns to raw diff, and `isActive("preview")` reports the current selection. A bare id names the calling extension's view; `"other-extension:preview"` addresses another registered view. These controls intentionally target only the current file; applying a view across the changeset remains a host-owned View-menu action.

## Register a view

A view has an id, a title, a cheap file matcher, and a layout function:

```ts
import type { HunkExtensionAPI } from "hunkdiff/extension";

export default function (hunk: HunkExtensionAPI) {
hunk.registerFileView({
id: "preview",
title: "Line preview",
matches: (file) => file.path.endsWith(".md"),
async layout(input) {
const document = await input.readDocument("new");
if (document === null || document === "") return null;

const lines = (document.endsWith("\n") ? document.slice(0, -1) : document).split("\n");
const hunkRows: Array<{ startRow: number; endRow: number }> = [];

for (const hunk of input.file.hunks ?? []) {
const [start, end] = hunk.newRange ?? [0, 0];
// A missing or invalid new-side range cannot be positioned in this preview.
if (start < 1 || end < start) return null;
hunkRows.push({
startRow: Math.min(lines.length - 1, start - 1),
endRow: Math.min(lines.length - 1, end - 1),
});
}

return {
rows: lines.map((text, index) => ({
id: `line:${index + 1}`,
spans: [{ text: text || " " }],
})),
hunkRows,
};
},
});
}
```

Return `null` whenever the view cannot safely present a file. Hunk will keep or restore the raw diff. This smallest example deliberately omits source bindings; add them only to rows owned by exactly one hunk extent, as described below.

### Matching

`matches(file)` decides whether the view appears in the View menu for that file. Keep it fast and side-effect free. Typical matchers use `file.path`, `file.changeType`, `file.isBinary`, or `file.isTooLarge`.

If `matches` throws, Hunk excludes the view for that file and keeps raw diff.

### Layout input

`layout(input)` receives one immutable snapshot:

| Field | Meaning |
| -------------------- | -------------------------------------------------------------------------------------------------- |
| `file` | The public file model, including path, change type, stats, patch text, and ordered hunk summaries. |
| `width` | Available terminal columns. Return the same layout for the same input and width. |
| `signal` | Aborts when a reload, resize, selection change, view change, or extension reload supersedes it. |
| `changes` | Typed added and removed source ranges with their hunk indexes. |
| `readDocument(side)` | Lazily reads the exact `"old"` or `"new"` source document. |

`readDocument` resolves to:

- a string for an available document;
- `""` for a valid empty document;
- `null` when the side does not exist, cannot be read, or exceeds host limits.

Cancellation aborts the pending request instead of producing a document value; use `input.signal` for any additional asynchronous work the layout starts. Patch text is available as `input.file.patch`, but a patch is not a complete source document. Use `readDocument` when parsing needs exact file contents. Reads are lazy and deduplicated within the request.

### Layout output

A layout contains `rows` and `hunkRows`.

Each row needs a stable `id` and a symbolic `spans` array. A span contains text plus an optional semantic tone and terminal attributes:

```ts
{
id: "dependency:react",
spans: [
{ text: "react", attributes: ["bold"] },
{ text: " 19.1.0 → 19.2.0", tone: "added" },
],
}
```

Tones are `muted`, `accent`, `accent-muted`, `syntax`, `added`, and `removed`. Attributes are `bold`, `italic`, `underline`, and `strikethrough`. Span text cannot contain newlines; create a separate row for each line. Hunk maps tones to the active theme while painting, so changing themes does not require a new layout.

`hunkRows` has one entry for every item in `input.file.hunks`, in the same order. Each entry is an inclusive, zero-based extent into `rows`. Hunk uses these extents for `[`/`]` navigation and selected-hunk highlighting even when several preview rows represent one source hunk.

## Keep inline notes attached to source

A row may declare the exact old/new source lines it presents:

```ts
{
id: "rendered-paragraph:4",
spans: [{ text: "A rendered paragraph" }],
sourceRanges: [{ side: "new", range: [12, 15] }],
}
```

Source ranges are inclusive and one-based. Hunk verifies that they exist in the exact source document, do not overlap ranges owned by other rows on the same side, and belong to one hunk extent.

When agent notes are visible, Hunk uses these bindings to insert its own note cards before the matching preview row. The extension never receives note contents and never measures note UI.

Note placement is all-or-raw for each file. If any visible note has no unique bound row, Hunk temporarily shows the complete raw diff rather than hiding the note or guessing where it belongs. The selected preview returns when notes are hidden or all bindings become resolvable. Draft note editing also remains on raw diff.

Omit `sourceRanges` if the preview does not support inline-note placement. The preview still works whenever no visible note requires a binding.

## Paint a fixed-height JSX row

A row can replace its symbolic paint with a constrained React/OpenTUI component:

```tsx
{
id: "package-summary",
spans: [{ text: "Package changes", attributes: ["bold"] }],
component: {
height: 2,
render: ({ width, height, selected, rowIndex, theme }) => (
<box
style={{
width,
height,
backgroundColor: selected ? theme.selectedHunk : theme.panel,
}}
>
<text content={`Package changes · row ${rowIndex}`} style={{ fg: theme.text }} />
</box>
),
},
}
```

The declared height is final. Hunk measures and windows the review before mounting components, then clips each component to its assigned rectangle. A component cannot grow the row, replace the scrollbox, or perform post-mount measurement that changes review geometry.

Painter props contain only fixed geometry, selected-hunk state, row position, and a live semantic `theme`. Theme changes repaint the component without rerunning `layout`.

Always provide useful `spans`. If the component throws, Hunk paints those spans inside the same fixed height. Components are non-focusable paint surfaces; registered commands are the supported keyboard path. Mouse delivery is cooperative, while wheel scrolling, dragging, and unhandled input remain host-owned.

React hook state is temporary paint state. It is lost when windowing unmounts the row, a width change creates a new layout, the user switches presentations, or the extension/session reloads. Keep durable extension state outside the row component.

## Validation and fallback

Hunk validates every returned layout before using it. Current request limits are:

| Limit | Maximum |
| ----------------- | --------------------: |
| Rows | 10,000 |
| Spans | 40,000 |
| Source bindings | 40,000 |
| Symbolic text | 1,000,000 characters |
| One component row | 256 terminal rows |
| Complete layout | 100,000 terminal rows |
| Layout request | 1.5 seconds |

Layout work runs with bounded concurrency and cached results. Hunk discards layouts prepared for an old width, file snapshot, registration, or cancelled request.

A `null`, invalid, oversized, cancelled, timed-out, or throwing layout produces raw diff. A component failure affects only that row. Extensions are trusted code rather than a sandbox, but they should still treat fallback as a normal part of the contract.

## Examples

- [Rendered Markdown](https://github.com/modem-dev/hunk/tree/main/examples/extensions/rendered-markdown) is an installable symbolic-row preview with exact-source bindings and inline notes.
- [JSX file view](https://github.com/modem-dev/hunk/tree/main/examples/extensions/jsx-file-view) demonstrates fixed-height React/OpenTUI rows.
- [JSX file-view gallery](https://github.com/modem-dev/hunk/tree/main/examples/extensions/jsx-file-view-gallery) includes TypeScript, CSS color, package dependency, and mixed raw/custom review examples.

The examples are not bundled or loaded by default. Run one directly while developing:

```bash
bun run src/main.tsx -- diff \
--extension ./examples/extensions/rendered-markdown \
./examples/extensions/jsx-file-view-gallery/mixed-review/fixtures/before/README.md \
./examples/extensions/jsx-file-view-gallery/mixed-review/fixtures/after/README.md
```

For installation, discovery, folder extensions, and trust, start with [Extensions](/docs/extend/extensions/). For the rest of the API object, see [Extension API](/docs/extend/extension-api/).
3 changes: 2 additions & 1 deletion website/src/content/docs/docs/index.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,7 @@ Hunk is a terminal diff viewer for reviewing complete changesets and keeping age
<LinkCard
title="Extend"
href="/docs/extend/extensions/"
description="Write TypeScript extensions that add themes, VCS backends, changeset transforms, commands, and sidebars."
description="Write TypeScript extensions that add file previews, themes, VCS backends, transforms, commands, and sidebars."
/>
<LinkCard
title="Reference"
Expand All @@ -60,5 +60,6 @@ Hunk is a terminal diff viewer for reviewing complete changesets and keeping age
- [Review a live session with a coding agent](/docs/agents/review-with-an-agent/)
- [Choose a theme and layout](/docs/configure/themes/)
- [Remap a keyboard shortcut](/docs/configure/keybindings/)
- [Build a custom file preview](/docs/extend/file-previews/)
- [Look up a command](/docs/reference/cli/)
- [Troubleshoot terminal behavior](/docs/help/troubleshooting/)
1 change: 1 addition & 0 deletions website/tests/docs-smoke.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,7 @@ test("core documentation has no serious automated accessibility violations", asy
test("key human and machine-readable routes load", async ({ page, request }) => {
for (const route of [
"/docs/agents/review-with-an-agent/",
"/docs/extend/file-previews/",
"/docs/reference/cli/",
"/docs/help/deployment/",
]) {
Expand Down
Loading