diff --git a/.changeset/green-camels-brush.md b/.changeset/green-camels-brush.md new file mode 100644 index 000000000..a845151cc --- /dev/null +++ b/.changeset/green-camels-brush.md @@ -0,0 +1,2 @@ +--- +--- diff --git a/website/astro.config.mjs b/website/astro.config.mjs index bbd5d8e93..d71eb1f19 100644 --- a/website/astro.config.mjs +++ b/website/astro.config.mjs @@ -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" }, ], diff --git a/website/src/content/docs/docs/extend/extension-api.md b/website/src/content/docs/docs/extend/extension-api.md index 439a4e83d..f12e58a5e 100644 --- a/website/src/content/docs/docs/extend/extension-api.md +++ b/website/src/content/docs/docs/extend/extension-api.md @@ -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)` @@ -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. @@ -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, `":"` 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. @@ -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)` diff --git a/website/src/content/docs/docs/extend/extensions.md b/website/src/content/docs/docs/extend/extensions.md index 658b2c18b..a3b90d5d8 100644 --- a/website/src/content/docs/docs/extend/extensions.md +++ b/website/src/content/docs/docs/extend/extensions.md @@ -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 @@ -60,6 +60,7 @@ The **id** is the file stem, or the folder name for `/index.ts` and single - config: `[extension.]` - commands: `.` - sidebar views: `:` +- file previews: `:` 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. diff --git a/website/src/content/docs/docs/extend/file-previews.md b/website/src/content/docs/docs/extend/file-previews.md new file mode 100644 index 000000000..f1c534aeb --- /dev/null +++ b/website/src/content/docs/docs/extend/file-previews.md @@ -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 }) => ( + + + + ), + }, +} +``` + +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/). diff --git a/website/src/content/docs/docs/index.mdx b/website/src/content/docs/docs/index.mdx index ab0f9ed47..d46167b2e 100644 --- a/website/src/content/docs/docs/index.mdx +++ b/website/src/content/docs/docs/index.mdx @@ -39,7 +39,7 @@ Hunk is a terminal diff viewer for reviewing complete changesets and keeping age { for (const route of [ "/docs/agents/review-with-an-agent/", + "/docs/extend/file-previews/", "/docs/reference/cli/", "/docs/help/deployment/", ]) {