From dab916747c9bf9c5021b50d331f52784887e21c5 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 29 Jul 2026 00:57:15 +0000 Subject: [PATCH 1/5] docs(website): add extensions guide to the docs site The site had no page for the experimental extension surface the README links repo docs for; add a compact guide covering load locations, the trust model, capabilities, and the [extensions] config keys, wire it into the Configure sidebar, and cross-link it from the configuration page. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01KHY9ReVesQXwKdRU47MwJ9 --- .changeset/cool-turtles-tickle.md | 2 + website/astro.config.mjs | 1 + .../docs/docs/configure/configuration.md | 2 +- .../content/docs/docs/configure/extensions.md | 54 +++++++++++++++++++ 4 files changed, 58 insertions(+), 1 deletion(-) create mode 100644 .changeset/cool-turtles-tickle.md create mode 100644 website/src/content/docs/docs/configure/extensions.md diff --git a/.changeset/cool-turtles-tickle.md b/.changeset/cool-turtles-tickle.md new file mode 100644 index 000000000..a845151cc --- /dev/null +++ b/.changeset/cool-turtles-tickle.md @@ -0,0 +1,2 @@ +--- +--- diff --git a/website/astro.config.mjs b/website/astro.config.mjs index f67ac169c..a3526444e 100644 --- a/website/astro.config.mjs +++ b/website/astro.config.mjs @@ -89,6 +89,7 @@ export default defineConfig({ { label: "Configuration", slug: "docs/configure/configuration" }, { label: "Themes", slug: "docs/configure/themes" }, { label: "Layout and display", slug: "docs/configure/layout-and-display" }, + { label: "Extensions", slug: "docs/configure/extensions" }, ], }, { diff --git a/website/src/content/docs/docs/configure/configuration.md b/website/src/content/docs/docs/configure/configuration.md index 6ace060b2..e594b5e0f 100644 --- a/website/src/content/docs/docs/configure/configuration.md +++ b/website/src/content/docs/docs/configure/configuration.md @@ -59,4 +59,4 @@ Command sections are named after the input Hunk parses, which is not always the When you change view preferences and quit, Hunk can offer to persist them. It writes to an existing repository config when one exists; otherwise it keeps personal view choices in the user config. Set `prompt_save_view_preferences = false` to disable that prompt. -The [config reference](/docs/reference/config/) lists every key, default, and alias. +The [config reference](/docs/reference/config/) lists every key, default, and alias. The root-only `[extensions]` table has its own guide: [Extensions](/docs/configure/extensions/). diff --git a/website/src/content/docs/docs/configure/extensions.md b/website/src/content/docs/docs/configure/extensions.md new file mode 100644 index 000000000..8e57d1aa9 --- /dev/null +++ b/website/src/content/docs/docs/configure/extensions.md @@ -0,0 +1,54 @@ +--- +title: Extensions +description: Load plain TypeScript extensions that contribute themes, VCS backends, changeset transforms, sidebars, and commands. +--- + +The extension API is experimental and may change in breaking ways between minor releases while it stabilizes; breaking changes are called out in release notes. + +## Where extensions load from + +Hunk loads plain TypeScript extensions from: + +- `~/.config/hunk/extensions/` — your personal extensions +- a repository's `.hunk/extensions/` — only after you explicitly trust that repository +- `--extension ` — an entry file or directory, repeatable, for development + +`--no-extensions` turns user extensions off for one run. Hunk's bundled backends (Git, Jujutsu, and Sapling) are themselves extensions and stay loaded either way. + +Repository-declared extension code never runs without a trust prompt, and a failing extension is isolated rather than taking the review down. + +## What an extension can do + +An extension exports one default function receiving the API object. It can contribute themes and file-extension → language mappings, add a VCS backend, rewrite the changeset before review, replace the file-navigation sidebar with its own React component, register commands, react to lifecycle events, and show transient messages: + +```ts +// ~/.config/hunk/extensions/collapse-lockfiles.ts +import type { HunkExtensionAPI } from "hunkdiff/extension"; + +export default function (hunk: HunkExtensionAPI) { + hunk.transformChangeset((changeset, ctx) => { + const files = changeset.files.filter((file) => !file.path.endsWith(".lock")); + ctx.notify(`Collapsed ${changeset.files.length - files.length} lockfiles`); + return { ...changeset, files }; + }); +} +``` + +## Configure extensions + +`[extensions]` controls loading, and each extension reads its own settings from an `[extension.]` table that Hunk passes through untouched: + +```toml +[extensions] +enabled = true +paths = ["~/dev/my-extension"] + +[extension.my-extension] +verbose = true +``` + +Repository tables merge over user tables key by key, and Hunk names every extension whose settings a repository overrides. See the [config reference](/docs/reference/config/) for the exact keys. + +## Full authoring guide + +The complete API documentation — VCS adapter contracts, sidebar scrollbox rules, command registration, events, and a worked example — lives in [`docs/extensions.md`](https://github.com/modem-dev/hunk/blob/main/docs/extensions.md) in the repository, with the module-level architecture in [`docs/extension-architecture.md`](https://github.com/modem-dev/hunk/blob/main/docs/extension-architecture.md). From e0c931efe8861f24d55b795dc2a3d5794e52575d Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 29 Jul 2026 01:06:22 +0000 Subject: [PATCH 2/5] docs(website): make the extensions guide self-contained The single extensions page summarized the surface and sent readers to the repository for everything real. The site is the documentation, so port the full authoring guide into an Extend section: extensions (discovery, trust, config, worked example), the extension API, VCS adapters, and custom sidebars. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01KHY9ReVesQXwKdRU47MwJ9 --- website/astro.config.mjs | 10 +- .../docs/docs/configure/configuration.md | 2 +- .../content/docs/docs/configure/extensions.md | 54 ---- .../docs/docs/extend/custom-sidebars.md | 198 ++++++++++++++ .../content/docs/docs/extend/extension-api.md | 245 ++++++++++++++++++ .../content/docs/docs/extend/extensions.md | 188 ++++++++++++++ .../content/docs/docs/extend/vcs-adapters.md | 175 +++++++++++++ website/src/content/docs/docs/index.mdx | 5 + 8 files changed, 821 insertions(+), 56 deletions(-) delete mode 100644 website/src/content/docs/docs/configure/extensions.md create mode 100644 website/src/content/docs/docs/extend/custom-sidebars.md create mode 100644 website/src/content/docs/docs/extend/extension-api.md create mode 100644 website/src/content/docs/docs/extend/extensions.md create mode 100644 website/src/content/docs/docs/extend/vcs-adapters.md diff --git a/website/astro.config.mjs b/website/astro.config.mjs index a3526444e..0bccd1f3c 100644 --- a/website/astro.config.mjs +++ b/website/astro.config.mjs @@ -89,7 +89,15 @@ export default defineConfig({ { label: "Configuration", slug: "docs/configure/configuration" }, { label: "Themes", slug: "docs/configure/themes" }, { label: "Layout and display", slug: "docs/configure/layout-and-display" }, - { label: "Extensions", slug: "docs/configure/extensions" }, + ], + }, + { + label: "Extend", + items: [ + { label: "Extensions", slug: "docs/extend/extensions" }, + { label: "Extension API", slug: "docs/extend/extension-api" }, + { label: "VCS adapters", slug: "docs/extend/vcs-adapters" }, + { label: "Custom sidebars", slug: "docs/extend/custom-sidebars" }, ], }, { diff --git a/website/src/content/docs/docs/configure/configuration.md b/website/src/content/docs/docs/configure/configuration.md index e594b5e0f..9fdd1bf78 100644 --- a/website/src/content/docs/docs/configure/configuration.md +++ b/website/src/content/docs/docs/configure/configuration.md @@ -59,4 +59,4 @@ Command sections are named after the input Hunk parses, which is not always the When you change view preferences and quit, Hunk can offer to persist them. It writes to an existing repository config when one exists; otherwise it keeps personal view choices in the user config. Set `prompt_save_view_preferences = false` to disable that prompt. -The [config reference](/docs/reference/config/) lists every key, default, and alias. The root-only `[extensions]` table has its own guide: [Extensions](/docs/configure/extensions/). +The [config reference](/docs/reference/config/) lists every key, default, and alias. The root-only `[extensions]` table has its own guide: [Extensions](/docs/extend/extensions/). diff --git a/website/src/content/docs/docs/configure/extensions.md b/website/src/content/docs/docs/configure/extensions.md deleted file mode 100644 index 8e57d1aa9..000000000 --- a/website/src/content/docs/docs/configure/extensions.md +++ /dev/null @@ -1,54 +0,0 @@ ---- -title: Extensions -description: Load plain TypeScript extensions that contribute themes, VCS backends, changeset transforms, sidebars, and commands. ---- - -The extension API is experimental and may change in breaking ways between minor releases while it stabilizes; breaking changes are called out in release notes. - -## Where extensions load from - -Hunk loads plain TypeScript extensions from: - -- `~/.config/hunk/extensions/` — your personal extensions -- a repository's `.hunk/extensions/` — only after you explicitly trust that repository -- `--extension ` — an entry file or directory, repeatable, for development - -`--no-extensions` turns user extensions off for one run. Hunk's bundled backends (Git, Jujutsu, and Sapling) are themselves extensions and stay loaded either way. - -Repository-declared extension code never runs without a trust prompt, and a failing extension is isolated rather than taking the review down. - -## What an extension can do - -An extension exports one default function receiving the API object. It can contribute themes and file-extension → language mappings, add a VCS backend, rewrite the changeset before review, replace the file-navigation sidebar with its own React component, register commands, react to lifecycle events, and show transient messages: - -```ts -// ~/.config/hunk/extensions/collapse-lockfiles.ts -import type { HunkExtensionAPI } from "hunkdiff/extension"; - -export default function (hunk: HunkExtensionAPI) { - hunk.transformChangeset((changeset, ctx) => { - const files = changeset.files.filter((file) => !file.path.endsWith(".lock")); - ctx.notify(`Collapsed ${changeset.files.length - files.length} lockfiles`); - return { ...changeset, files }; - }); -} -``` - -## Configure extensions - -`[extensions]` controls loading, and each extension reads its own settings from an `[extension.]` table that Hunk passes through untouched: - -```toml -[extensions] -enabled = true -paths = ["~/dev/my-extension"] - -[extension.my-extension] -verbose = true -``` - -Repository tables merge over user tables key by key, and Hunk names every extension whose settings a repository overrides. See the [config reference](/docs/reference/config/) for the exact keys. - -## Full authoring guide - -The complete API documentation — VCS adapter contracts, sidebar scrollbox rules, command registration, events, and a worked example — lives in [`docs/extensions.md`](https://github.com/modem-dev/hunk/blob/main/docs/extensions.md) in the repository, with the module-level architecture in [`docs/extension-architecture.md`](https://github.com/modem-dev/hunk/blob/main/docs/extension-architecture.md). diff --git a/website/src/content/docs/docs/extend/custom-sidebars.md b/website/src/content/docs/docs/extend/custom-sidebars.md new file mode 100644 index 000000000..09d17eca0 --- /dev/null +++ b/website/src/content/docs/docs/extend/custom-sidebars.md @@ -0,0 +1,198 @@ +--- +title: Custom sidebars +description: Render your own React sidebar view inside Hunk, with selection, scrolling, and event-driven state. +--- + +`hunk.registerSidebarView(view)` contributes a sidebar view — your own React component, rendered inside Hunk's OpenTUI tree. Registration is additive: your view exists beside the built-in file navigation, on either side of the review stream, and any number of views can be open at once. Pair it with [`registerCommand`](/docs/extend/extension-api/#hunkregistercommandcommand-handler) so a key opens it: + +```tsx +// ~/.config/hunk/extensions/flat-sidebar.tsx +import { useMemo } from "react"; +import type { ExtensionSidebarViewProps, HunkExtensionAPI } from "hunkdiff/extension"; + +function FlatSidebar({ files, selectedFileId, theme, actions }: ExtensionSidebarViewProps) { + const ordered = useMemo(() => [...files].sort((a, b) => a.path.localeCompare(b.path)), [files]); + + return ( + + {ordered.map((file) => ( + actions.selectFile(file.id)} + /> + ))} + + ); +} + +export default function (hunk: HunkExtensionAPI) { + hunk.registerSidebarView({ + id: "flat", + title: "Flat files", + placement: "right", + component: FlatSidebar, + }); + hunk.registerCommand( + { id: "toggle-flat", title: "Toggle flat sidebar", key: "ctrl+f" }, + (ctx) => { + ctx.sidebars.toggle("flat"); + }, + ); +} +``` + +Beyond `id` and `component`, a view may declare a `title` (for diagnostics and future menu listings), a `placement` of `"left"` (default) or `"right"`, `defaultOpen: true` to start open, or `replacesDefault: true` to start open _in place of_ the built-in file navigation — which stays available, just closed, so a command can reopen it. + +Import `react` normally — Hunk serves its own React instance to extension files at import time, so hooks, context, and JSX all run on the reconciler drawing the rest of the app. **Never bundle or vendor a copy of React into an extension**: a second React means a second hooks dispatcher, and the component will fail to render. OpenTUI elements (`box`, `text`, `scrollbox`, ...) are plain intrinsic elements and need no import. + +## Props + +The component receives fresh props as the app changes: + +| Prop | What it is | +| ------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `files` | the visible reviewed files, review-stream order, filtered, frozen views (each carries `changeType`, `statsTruncated`, and `hunks` summaries beside the usual file fields) | +| `selectedFileId` | the selected file, or `null` | +| `selectedHunkIndex` | the selected hunk within that file, or `null` | +| `width` | terminal columns the sidebar pane occupies | +| `theme` | hex color tokens from the active theme, updated on theme switch | +| `keybindings` | the current command bindings, resolved from defaults and the user's `[keybindings]` table | +| `actions` | navigation the sidebar may trigger | + +`actions.selectFile(fileId)` and `actions.selectHunk(fileId, hunkIndex)` route through the same review controller as the built-in sidebar and the keyboard shortcuts, so the review stream scrolls, selection updates, and the `selection_changed` event fires exactly as if the user had clicked a built-in row. `actions.notify(message, type?)` shows a toast attributed to your extension. An action given a file id that is not currently visible is refused with a warning rather than corrupting the selection. + +The three hunk surfaces line up by design: each file's `hunks` lists public `ExtensionDiffHunk` summaries (`index`, the `@@` header, inclusive old/new line spans) in render order, `selectedHunkIndex` reports the same index, and `actions.selectHunk(fileId, hunkIndex)` accepts it. That is everything a hunk checklist, a per-hunk progress view, or an agent-annotation navigator needs — match an annotation's `oldRange`/`newRange` against the summaries' spans to find its hunk — without touching the opaque `metadata`. + +## Keys inside a component + +A component that owns a key event should ask the injected `keybindings` manager about a **command id**, rather than hard-coding the command's default chord. This keeps local component behavior synchronized with the user's remaps and unbindings: + +```ts +import type { ExtensionKeyEvent, ExtensionSidebarViewProps } from "hunkdiff/extension"; + +export function handleSidebarKey(props: ExtensionSidebarViewProps, key: ExtensionKeyEvent) { + const nextFile = props.files[1]; + if (nextFile && props.keybindings.matches(key, "hunk.review.nextFile")) { + // The user may have remapped this from `.` to another chord. + props.actions.selectFile(nextFile.id); + } +} +``` + +`keybindings.getKeys(commandId)` returns the current chord list for a label or hint; unknown and unbound commands return an empty list. `matches(key, commandId)` returns `false` for those commands too. The manager includes both Hunk commands and extension commands under their documented ids, and its key event argument is structural — OpenTUI's `KeyEvent` works directly. + +`matchesKey`, `parseKeyChord`, and `matchesKeyChord` remain exported for extension-local keys that intentionally are not commands. Prefer a named command whenever a shortcut should be user-remappable. + +## The pane is Hunk's, the content is yours + +Hunk keeps owning pane arrangement — widths, resize dividers, responsive show/hide, and dropping panes that no longer fit a narrow terminal — and your component fills the pane it is given. A component that throws while rendering costs you the pane, not the user the session: the failure is reported as a toast naming your extension, the pane closes, and the built-in file navigation reopens if nothing else is showing. + +Props carry the pane's `width` but not its height: the pane is a flex cell, so give your root element `height="100%"` and let layout size it. Everything else about scrolling — pane viewport height, scroll position, keeping a row visible — goes through the `` itself, via a plain React ref. Hunk serves its own `@opentui/core` to extension files, so the renderable a ref hands you is the very instance the host renders with. + +## Scrolling: the scrollbox ref contract + +The one behavior a list sidebar always ends up needing is following the selection. Give your rows stable `id` props, hold a ref to the scrollbox, and scroll the selected row into view from an effect: + +```tsx +import { useEffect, useRef } from "react"; +import type { ScrollBoxRenderable } from "@opentui/core"; +import type { ExtensionSidebarViewProps } from "hunkdiff/extension"; + +function HunkList({ + files, + selectedFileId, + selectedHunkIndex, + theme, + actions, +}: ExtensionSidebarViewProps) { + const scrollRef = useRef(null); + + // Follow policy is deliberately yours: the host never scrolls a pane it + // cannot see into, so decide here when (and whether) to follow. + useEffect(() => { + if (selectedFileId !== null) { + scrollRef.current?.scrollChildIntoView(`row-${selectedFileId}-${selectedHunkIndex ?? 0}`); + } + }, [selectedFileId, selectedHunkIndex]); + + return ( + + {files.flatMap((file) => + (file.hunks ?? []).map((hunk) => { + const selected = file.id === selectedFileId && hunk.index === selectedHunkIndex; + return ( + actions.selectHunk(file.id, hunk.index)} + > + + + ); + }), + )} + + ); +} +``` + +The ref surface this recipe stands on is the exact one the built-in sidebar runs on: + +- **`scrollChildIntoView(id)`** scrolls the descendant with that `id` prop into view. +- **`scrollTop`** and **`viewport.height`** read the current scroll offset and the pane's viewport rows — the pane-height number the props do not carry. A read before the first layout pass reports `0`, so viewport-dependent code belongs behind the events below rather than a bare mount effect. +- **`verticalScrollBar.on("change", handler)`**, **`viewport.on("layout-changed", handler)`**, and **`viewport.on("resized", handler)`** report scrolling and pane resizes; unsubscribe with the matching `.off` in your effect's cleanup. + +That is enough to window a long list yourself: the built-in sidebar renders only the rows near the viewport, plus spacer boxes sized from those same reads (its render-window helper is host code, but nothing it computes needs anything beyond this surface — `useTerminalDimensions` from `@opentui/react` serves as its pre-first-layout viewport estimate). + +One honest caveat: this contract rides on OpenTUI's renderable API, served at whatever version Hunk pins — a wider surface than `hunkdiff/extension` itself. The built-in sidebar exercising the exact same calls is the compatibility guarantee: a change that breaks your scroll code breaks Hunk's own sidebar first. Still, keep scroll handling small and behind your own helpers. + +The built-in sidebar is itself a bundled extension (`src/extensions/default/ui/sidebar/` in the Hunk repository): it registers through this exact call, its component consumes exactly the props documented above, and its windowing and selection follow run on exactly the ref contract above — so it doubles as the reference implementation for everything a third-party sidebar can build, from grouping and stat badges down to scroll behavior. + +## Sidebar state from events + +Lifecycle handlers run outside React, but a sidebar component only rerenders when React sees a change. The recipe that connects them is a module-local store read through `useSyncExternalStore`: the event handler updates the store, and any mounted component subscribed to it rerenders — while the store keeps accumulating even when the pane is closed. + +```tsx +import { useSyncExternalStore } from "react"; +import type { HunkExtensionAPI } from "hunkdiff/extension"; + +let viewedPaths: ReadonlySet = new Set(); +const listeners = new Set<() => void>(); + +function markViewed(path: string) { + if (viewedPaths.has(path)) return; + viewedPaths = new Set(viewedPaths).add(path); // new reference, so React sees the change + for (const listener of listeners) listener(); +} + +function useViewedPaths() { + return useSyncExternalStore( + (listener) => { + listeners.add(listener); + return () => listeners.delete(listener); + }, + () => viewedPaths, + ); +} + +function ViewedCount() { + const viewed = useViewedPaths(); + return ; +} + +export default function (hunk: HunkExtensionAPI) { + hunk.on("file_viewed", ({ file }) => markViewed(file.path)); + hunk.registerSidebarView({ id: "progress", component: ViewedCount }); +} +``` + +Snapshots must be immutable — replace the set instead of mutating it, so `useSyncExternalStore` can compare references. Storing state in a hook inside the component instead would lose it every time the pane closes and unmounts. diff --git a/website/src/content/docs/docs/extend/extension-api.md b/website/src/content/docs/docs/extend/extension-api.md new file mode 100644 index 000000000..1d90f21e8 --- /dev/null +++ b/website/src/content/docs/docs/extend/extension-api.md @@ -0,0 +1,245 @@ +--- +title: Extension API +description: Register themes, languages, 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. + +Two registration calls are large enough to have their own pages: [`registerVcsAdapter`](/docs/extend/vcs-adapters/) and [`registerSidebarView`](/docs/extend/custom-sidebars/). Everything else is here. + +## `hunk.apiVersion` + +The API generation this Hunk speaks (currently `1`). Branch on it if you want one file to support several Hunk versions. + +## `hunk.registerTheme(theme)` + +Contribute one selectable theme. The object is the same shape as a `[themes.]` config table: + +```ts +hunk.registerTheme({ + id: "midnight-review", + label: "Midnight Review", + base: "catppuccin-mocha", + accent: "#7fd1ff", + syntaxScopes: { "keyword.operator": "#7fd1ff" }, +}); +``` + +Theme ids must be lowercase words separated by `-` or `_` and cannot reuse a built-in id. Config-defined themes always win over extension themes for the same id; the loser is reported as a startup notice. Extension themes appear in the selector after config themes, in load order. + +## `hunk.registerFileLanguage(extension, language)` + +Map a file extension to a syntax-highlighting language. The extension may be written with or without a leading dot and is lowercased. + +```ts +hunk.registerFileLanguage(".zig", "zig"); +hunk.registerFileLanguage("bzl", "python"); +``` + +Later registrations win over earlier ones. Hunk's own `.mts` and `.cts` mappings cannot be overridden; attempts are skipped with a notice. + +## `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. + +```ts +hunk.transformChangeset((changeset) => ({ + ...changeset, + files: changeset.files.filter((file) => !file.path.endsWith(".lock")), +})); +``` + +The function may be async. Filtering and reordering `files` is fully supported — the sidebar and the review stream both follow whatever you return. + +Each file carries an opaque `metadata` field: it is the parsed diff the renderer draws from, so pass it through untouched (spreading a file preserves it). What you return is validated before it is reviewed. A transform that throws, or returns something the review UI could not draw — not a changeset with a `files` array, a file missing `metadata.hunks` or `stats`, two files sharing an `id` — is skipped: the previous changeset carries forward and you get a warning naming your extension and the problem. + +You never need to reach into `metadata` to know what a file's hunks are: the read-only views Hunk hands outward (event payloads, sidebar props, a command's selection) carry a `hunks` list of public summaries — `index`, the `@@` header, and the inclusive old/new line spans, in render order. Like `changeType`, it is derived from the metadata at that boundary, so a transform neither receives nor produces it, and a stale value spread through a transform is replaced with what the metadata actually parses to. + +## `hunk.registerCommand(command, handler)` + +Register a named command, optionally bound to a key. Commands are not a sidebar one-off: they are the same mechanism Hunk's own shortcuts dispatch through — one table, one loop, built-ins first. + +```ts +import type { HunkExtensionAPI } from "hunkdiff/extension"; + +export default function (hunk: HunkExtensionAPI) { + hunk.registerCommand({ id: "hello", title: "Say hello", key: "ctrl+g" }, (ctx) => { + ctx.notify("hello from a command"); + }); +} +``` + +Key chords are `ctrl`, `alt`/`option`, `cmd`/`meta`, and `shift` joined with `+` around a base key — a character (`"y"`, `"["`), an uppercase letter for its shifted form (`"G"`), or a named key (`"f2"`, `"pageup"`, `"left"`). `shift` applies to letters and named keys only: for a shifted symbol or digit, bind the character the shift produces (`"!"`, not `"shift+1"`), since terminals report the character rather than the combination. An unparsable chord fails the registration; a chord already owned by a built-in shortcut — or by an earlier-loaded extension — leaves that chord unbound, with a warning toast naming both sides. Omit `key` to register a command with no binding. + +`key` also takes a list, binding the command to every chord in it: + +```ts +hunk.registerCommand({ id: "hello", title: "Say hello", key: ["ctrl+g", "f9"] }, (ctx) => { + ctx.notify("hello from a command"); +}); +``` + +Chords are refused one at a time: if `ctrl+g` were already taken, the command would still answer to `f9`. + +Whatever an extension declares is a _default_. Users remap commands by id in the `[keybindings]` table of their own config, extension commands included — yours is named `"."`, while Hunk's own are `"hunk.app.quit"` and friends. See [`docs/keybindings.md`](https://github.com/modem-dev/hunk/blob/main/docs/keybindings.md) for the rules; the practical consequence is that a chord you declare may not be the chord your command ends up on. + +Every registered command is also listed in the menu bar's **Extensions** menu, under its `title`, showing whichever key it currently answers to. The menu appears only when something registered a command, entries are grouped by extension in load order, and running one from the menu is the same dispatch the key would have done — so a command with no `key`, or one whose chord was refused, is still reachable with the mouse. + +The handler fires when the key is pressed outside modal UI — dialogs, menus, and focused text inputs own their keys first, and pager mode does not dispatch extension commands. It receives the standard context plus `ctx.sidebars`, the controls for opening sidebar views: + +- `ctx.sidebars.open(viewId)` / `close(viewId)` / `toggle(viewId)` — a bare id names your own extension's view, `"files"` names the built-in file navigation, and `":"` addresses any registered view. Opening a view also reveals the sidebar area when the user has hidden it with `s`, so the open is never silent. +- `ctx.sidebars.isOpen(viewId)` reports current state. + +`ctx.selection` is where the review was pointing when the command fired — the same selection a sidebar component sees in its props, so a command never has to track `selection_changed` itself to know what the user is looking at: + +```ts +hunk.registerCommand( + { id: "show-selection", title: "Show the selected file", key: "ctrl+y" }, + (ctx) => { + const { file, hunkIndex } = ctx.selection; + if (!file) { + ctx.notify("No file selected"); + return; + } + + ctx.notify(hunkIndex === null ? file.path : `${file.path} — hunk ${hunkIndex + 1}`); + }, +); +``` + +`selection.file` is a frozen read-only view, identical to the entries in a sidebar's `files` prop. Hunk keeps the selection inside the visible files, so it is `null` only when nothing is visible at all — a filter that matches no files. `selection.hunkIndex` is that file's selected hunk, and `null` whenever `file` is — or when the file has no hunks to select. The values are captured when the command fires: a handler that awaits still sees the selection it was run from, not wherever the user navigated to meanwhile. + +`ctx.navigation` moves the review stream: `selectFile(fileId)` and `selectHunk(fileId, hunkIndex)`, the same guarded navigation a sidebar's `actions` carry, routed through the same review controller — the stream scrolls, selection updates, and `selection_changed` fires exactly as if the user had clicked a sidebar row. Unlike `selection` it is live, not a snapshot: a call acts on the review as it is at that moment, so a handler that awaits a dialog and then navigates still works. A file id the stream cannot currently show is refused with a warning rather than corrupting the selection, and a hunk index is clamped into the file's real range. + +A handler may be async; a failure (sync or rejected) becomes a warning naming your extension. + +### Asking the user + +`ctx.dialogs` puts a question on screen and waits for the answer. Three shapes, all promise-returning: + +- `confirm({ title, body?, confirmLabel?, cancelLabel? })` → `true` or `false` +- `select({ title, options })` → the chosen string, or `null` +- `input({ title, placeholder?, initial? })` → the typed string, or `null` + +```ts +hunk.registerCommand( + { id: "reformat", title: "Reformat the selected file", key: "ctrl+r" }, + async (ctx) => { + const file = ctx.selection.file; + if (!file) { + return; + } + + const proceed = await ctx.dialogs.confirm({ + title: `Reformat ${file.path}?`, + body: "The file is rewritten in place.", + confirmLabel: "reformat", + }); + + ctx.notify(proceed ? `Reformatting ${file.path}` : "Left it alone"); + }, +); +``` + +`select` is the natural fit for acting on part of the selection — here, asking which hunk of the selected file to jump to, then navigating there: + +```ts +hunk.registerCommand({ id: "pick-hunk", title: "Pick a hunk", key: "ctrl+k" }, async (ctx) => { + const file = ctx.selection.file; + const hunks = file?.hunks ?? []; + if (!file || hunks.length === 0) { + ctx.notify("Nothing to pick from", "warning"); + return; + } + + const labels = hunks.map((hunk) => hunk.header || `hunk ${hunk.index + 1}`); + const picked = await ctx.dialogs.select({ title: "Which hunk?", options: labels }); + + // `navigation` is live, so the jump is valid even after awaiting the dialog. + if (picked !== null) { + ctx.navigation.selectHunk(file.id, labels.indexOf(picked)); + } +}); +``` + +Hunk draws the dialog, not you: your text fills the title, body, and choices, and the frame carries an `ext ` attribution line — the same marker `notify` toasts use — so a prompt can never present itself as Hunk asking. + +One dialog is on screen at a time. Concurrent requests queue in call order, across extensions too, so a second question waits its turn instead of replacing the first. While a dialog is up it owns the keyboard: Escape cancels (`false`, or `null`), Enter accepts — the confirm action, the highlighted option, or the typed text — and review shortcuts stay suppressed underneath. Confirm dialogs also answer to `y`/`n`, select dialogs to `↑`/`↓`, and every dialog's actions and rows are clickable. + +Two things resolve a dialog without the user: the session moving on, and bad arguments. A session reload — the refresh key, a watch-triggered reload, an agent command — cancels open and queued dialogs, since the review they asked about is being replaced; a dialog pending at shutdown resolves its cancel value the same way, and a request made after that point cancels immediately. A blank `title`, or a `select` with no options, is a bug in the extension rather than an answer from the user, so the promise **rejects**; like any other handler failure, that surfaces as a warning naming your extension. + +## `hunk.on(event, handler)` + +Subscribe to a lifecycle or UI event. Handlers may be async; Hunk never blocks the UI waiting for one. Alongside `cwd` and `notify`, every handler receives `ctx.sidebars`, the same open/close/toggle controls command handlers receive. That means a `changeset_loaded` handler can reveal its extension's sidebar when it finds something worth showing — no keypress required. + +| Event | Payload | When | +| ---------------------- | ----------------------- | -------------------------------------------------------- | +| `startup` | `{ cwd }` | once, after the app mounts with its first changeset | +| `changeset_loaded` | `{ changeset }` | first load and every reload | +| `selection_changed` | `{ fileId, hunkIndex }` | when the review selection settles (debounced ~150ms) | +| `file_viewed` | `{ file, hunkIndex }` | when selection settles on a file or a reload replaces it | +| `filter_changed` | `{ filter }` | whenever the file-filter query changes | +| `theme_changed` | `{ themeId }` | when the user commits a new theme | +| `layout_changed` | `{ mode, layout }` | mode or responsive split/stack layout changes | +| `watch_reload_pending` | `{}` | watcher observed a change before its reload check | +| `note_created` | `{ note }` | a user saves an inline review note | +| `note_edited` | `{ note }` | an in-progress inline note's body changes | +| `session_reload` | `{ changeset, reason }` | on every session reload | +| `shutdown` | `{}` | on exit, best-effort within a short timeout | + +`selection_changed` is trailing-debounced on purpose: holding `[`/`]` retargets the selection many times a second, and handlers only care where the user landed. `fileId` and `hunkIndex` are `null` when nothing is selected. + +`session_reload`'s `reason` is `"watch"` (the watcher saw the source change), `"daemon"` (an agent command through the session broker), or `"manual"` (the refresh key, or the reload after granting extension trust). + +`note_created` and `note_edited` cover notes authored in Hunk's own UI, in this session. Review notes are session-local state, so there is no backlog to replay on startup — but comments added through agent session commands do not emit these events, and a `session_reload` may remap or drop notes without one either. A list accumulated from these events is therefore "notes the user saved here this session", not a complete review record; present it as such. + +`shutdown` handlers get a short window (250ms) to finish before Hunk exits anyway, so treat it as best-effort flushing rather than guaranteed cleanup. + +## `hunk.events` + +`hunk.events` is a small bus shared by every loaded extension. Use it to coordinate extensions without coupling them through a command or global state. Names are open-ended, so namespace them with your extension id. Listeners get the same `ctx.sidebars` controls as lifecycle handlers; delivery is fire-and-forget and one listener's failure is reported without stopping the others. Events an extension emits while factories are loading are queued until every extension has had a chance to subscribe. + +```ts +import type { HunkExtensionAPI } from "hunkdiff/extension"; + +export default function (hunk: HunkExtensionAPI) { + hunk.events.on<{ fileCount: number }>("summary:ready", (payload, ctx) => { + if (payload.fileCount > 100) ctx.sidebars.open("summary"); + }); + + hunk.on("changeset_loaded", ({ changeset }, ctx) => { + hunk.events.emit("summary:ready", { fileCount: changeset.files.length }); + ctx.sidebars.open("summary"); + }); +} +``` + +Bus payloads are shallow-frozen copies when they are objects. Keep nested data immutable if multiple extensions will read it. + +## `hunk.config` + +Your extension's own `[extension.]` config table, as a plain object. Hunk does not interpret the keys — unknown keys pass straight through — and repo config overrides user config key by key. + +**Treat these values as untrusted.** Tables merge by extension id with no notion of where the extension was installed from, so a repository under review can set or override configuration for an extension you installed globally. That is deliberate — repo-level tuning of a shared extension is a normal team workflow, and Hunk shows a startup notice listing the extension ids a repo configures — but it means `hunk.config` must never be trusted for exec-adjacent decisions such as binary paths, shell commands, or module loading. Validate those against something the user controls. + +```toml +# ~/.config/hunk/config.toml +[extension.collapse-generated] +patterns = ["*.lock", "dist/**"] +``` + +```ts +const patterns = (hunk.config.patterns as string[] | undefined) ?? ["*.lock"]; +``` + +## `ctx.notify(message, type?)` + +Every handler and transform receives a context object with `cwd` and `notify`. Event and bus handlers additionally receive `sidebars` and `events.emit`; command handlers receive `sidebars`, `selection`, `navigation`, and `dialogs`. `notify` shows a single unobtrusive line at the bottom of the app that clears itself after a few seconds; queued messages appear in turn. `type` is `"info"` (default), `"warning"`, or `"error"`, which selects the color. Notifications raised before the UI has mounted are buffered and flushed once it does, so a `startup` handler can notify safely. + +## `hunk.log(message)` + +Record a diagnostic line. Logs are collected per extension rather than written to the terminal, because the TUI owns the screen. + +## Not contributable yet + +Menu entries, standalone keybindings (a chord contributed without a command — commands registered through `registerCommand` **are** already user-remappable via `[keybindings]`), custom note renderers, session commands, and CLI subcommands are not contributable yet. Commands and their default key bindings landed with `registerCommand` — the named-command registry the rest build on; see [`docs/extension-system-exploration.md`](https://github.com/modem-dev/hunk/blob/main/docs/extension-system-exploration.md) for the design and phasing. diff --git a/website/src/content/docs/docs/extend/extensions.md b/website/src/content/docs/docs/extend/extensions.md new file mode 100644 index 000000000..273d2fe73 --- /dev/null +++ b/website/src/content/docs/docs/extend/extensions.md @@ -0,0 +1,188 @@ +--- +title: Extensions +description: Load plain TypeScript extensions, understand discovery and trust, and configure them. +--- + +A Hunk extension entry is one TypeScript (or JavaScript) file that default-exports a function. Hunk imports it at startup and hands it an API object. An entry may stand alone or be declared by a folder's optional `package.json` manifest; no build step is required. + +```ts +// ~/.config/hunk/extensions/hello.ts +import type { HunkExtensionAPI } from "hunkdiff/extension"; + +export default function (hunk: HunkExtensionAPI) { + hunk.on("startup", (_event, ctx) => { + ctx.notify("Hello from my extension"); + }); +} +``` + +**The extension API is experimental.** Everything documented here works today, but the `hunkdiff/extension` surface may change in breaking ways between minor releases while it stabilizes against real third-party extensions. 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/) (themes, languages, changeset transforms, commands, events, dialogs), [VCS adapters](/docs/extend/vcs-adapters/), and [custom sidebars](/docs/extend/custom-sidebars/). + +## Where Hunk looks for extensions + +Discovery runs group by group, alphabetically by resolved path within each group — a folder extension's entries sort together, at the folder's own path. The first occurrence of a resolved path wins, so a path you pass explicitly keeps its origin even if the same file is also discovered somewhere else. + +| Group | Source | Trust | +| ----- | ---------------------------------------------------- | --------------------- | +| 1 | `--extension ` (repeatable) | runs immediately | +| 2 | `[extensions] paths` in your user config | runs immediately | +| 3 | `~/.config/hunk/extensions/` | runs immediately | +| 4 | `.hunk/extensions/` in the repo under review | **prompts for trust** | +| 4 | `[extensions] paths` in the repo `.hunk/config.toml` | **prompts for trust** | + +The two repo-local sources share a group number because they are one group: both are repo-controlled, so they share a trust decision and their paths are sorted together rather than one source being loaded ahead of the other. + +A directory source matches `*.ts`, `*.tsx`, `*.js`, `*.jsx`, `*.mjs` directly inside it, plus one level of folder extensions, so a folder extension can keep helper modules beside its entry file. + +### Folder extensions + +A folder is an extension if it declares its entry files in a `package.json`, or failing that if it has an `index.{ts,tsx,js,jsx,mjs}` (in that preference order, so a folder shipping both a source and a built entry resolves the same everywhere). The manifest field is `hunk`: + +```text +~/.config/hunk/extensions/my-ext/ + package.json # {"hunk": {"extensions": ["./src/index.ts"]}} + node_modules/ # bun install / npm install, right here + src/ + index.ts # the declared entry + helper.ts +``` + +The manifest wins over the `index.*` fallback, and its paths resolve against the folder. It may list more than one entry, in which case each entry loads as its own extension in the order the manifest gives. Each one is identified by its file stem; when stems collide, later entries receive a numeric suffix while avoiding ids already claimed by other entries in the manifest. + +Because the manifest is a real `package.json`, a folder extension may depend on npm packages: declare them, install them into the folder's own `node_modules`, and imports resolve from the entry file the way they do in any other package. + +Pointing `--extension` or `[extensions] paths` straight at a directory works either way: a directory that is itself a folder extension loads as that one extension, so its helper modules stay helpers. A directory that is not is treated as a directory _of_ extensions and scanned with the patterns above. + +### Extension ids + +An extension's **id** is its file stem, or its folder name for `/index.ts`. A manifest that declares a single entry also keeps the folder's name, whatever the entry file is called. The id is what `[extension.]` config tables key off, so moving a single-file extension into a folder of the same name — or later giving that folder a manifest — keeps its config working. + +The id is also the namespace your extension owns: its commands are `.` and its sidebar views `:`. So the id has to be spelled like a name — starting with a letter or digit, then letters, digits, `-`, or `_`. A dot or a colon would make those composed ids ambiguous, and `hunk`, `git`, `jj`, and `sl` are reserved for what Hunk ships. An extension whose id breaks a rule is skipped with a startup notice naming the file; rename it and it loads. If two discovery sources offer the same id, the first in source order loads and the other is skipped the same way, since one id cannot own two config tables. + +### Explicit intent + +`--no-extensions` disables user extensions for one run — nothing on disk is read, let alone executed. Use it when triaging a bug. + +`--extension` is explicit user intent: the file loads immediately, with no trust prompt, even when the path points inside the repository under review. Never pass a path you have not read — including one copy-pasted from a repository's own README. + +## Bundled extensions + +Every VCS backend Hunk ships — **Git, Jujutsu, and Sapling** — is an extension, and so is the **built-in file-navigation sidebar**. They are compiled into the binary and register through the same `hunk.registerVcsAdapter` and `hunk.registerSidebarView` these pages document. There is no core-registered backend left, no private sidebar, and no private path into the review pipeline. + +Git in particular is the reason: it is the backend that exercises every integration point there is — exact file sources, skipped-too-large placeholders, untracked files, watch plans, rich failures — so running it through the published API is what keeps that API honest. Anything Git can do, your adapter can do, because Git does it the same way you would. + +Bundled extensions differ from yours in three ways, all of them consequences of being Hunk's own code: + +- They are **statically imported**, so they load synchronously, before config resolution picks the session's VCS. +- They are **implicitly trusted**: no discovery, no trust prompt, and no `[extension.]` config table. +- They stay loaded under `--no-extensions` and `[extensions] enabled = false`. Those switches exist to triage extensions _you_ installed; losing VCS support from a debugging flag would break every workflow there is. + +Failure isolation still applies to them. The ids `git`, `jj`, and `sl` are reserved as a result, and so is `hunk`, the id the bundled sidebar and every built-in command are named under. + +## Trust + +Extensions run with your user permissions, exactly like a shell dotfile. That is fine for extensions you installed yourself, and not fine for extensions that came with a repository you are about to review — pointing a diff tool at unfamiliar code is a normal thing to do, and it must never execute that code. + +So repo-local sources are gated. The first time Hunk finds extensions in a repository's `.hunk/extensions` (or repo-config `paths`), it skips them and asks: + +```text +Run this repository's extensions? + + This repository contains extensions in .hunk/extensions. + Extensions run with your user permissions. + + enter/t trust · esc not now · n never +``` + +- **Trust** records the decision and reloads the session so the repo's extensions take effect immediately. +- **Not now** (also `Esc`) dismisses without recording anything; you will be asked again next time. +- **Never** records a denial so Hunk stops offering. + +Decisions are stored per repository root in `~/.config/hunk/state.json`. The prompt is a normal dialog over the review stream, not a gate in front of it: you can dismiss it and keep reviewing. + +Trust is keyed by the repo root's **path**, not by the repository's identity — the same model VS Code workspace trust uses. If you delete a trusted checkout and a different repository later occupies that path, it inherits the decision. Clear the entry from `state.json` if that matters for a path you reuse. + +## Failure isolation + +A broken extension should not break review. An extension that fails to import, has no default export, or throws from its factory is skipped, its partial registrations are rolled back, and it becomes a startup notice in the footer. A handler or transform that throws later is reported as a warning naming the extension, and everything else keeps running. Event handlers receive frozen copies of the changeset, so accidental mutation throws inside the handler instead of corrupting the review. + +This is crash containment, not a sandbox. Per-file `metadata` inside event payloads is shared with the renderer for performance and is not frozen, and an extension runs with your full user permissions — it can do anything your shell can. The containment protects you from bugs, not from code you should not have loaded in the first place. + +## CLI flags and config + +```bash +hunk diff --extension ./path/to/entry.ts # load one entry file (repeatable) +hunk diff --extension ./my-ext # a folder extension: loads ./my-ext/index.ts +hunk diff --no-extensions # disable user extensions for this run +``` + +```toml +# ~/.config/hunk/config.toml or .hunk/config.toml +[extensions] +enabled = true # false disables loading for this layer +paths = ["~/dev/hunk-ext/index.ts"] # extra entry files or directories + +[extension.my-extension] # opaque payload handed to that extension +some_key = "some value" +``` + +`[extensions] enabled` layers like every other option: a repo `.hunk/config.toml` overrides your user config. `--no-extensions` is a hard off switch that no config layer can re-enable. Both govern **user** extensions only — Hunk's bundled Git, Jujutsu, and Sapling backends load either way. `[extensions] paths` from a repo config is trust-gated the same way `.hunk/extensions` is, because it is repo-controlled either way. + +`[extension.]` tables are handed to the matching extension uninterpreted, repo config merging over user config key by key; see [`hunk.config`](/docs/extend/extension-api/#hunkconfig) for the trust caveats that come with that. + +## A complete example + +Collapse lockfiles and generated output out of every review, and say how many files were hidden. + +```ts +// ~/.config/hunk/extensions/collapse-generated.ts +import type { HunkExtensionAPI } from "hunkdiff/extension"; + +/** Match one path against a `*`-only glob, anchored at both ends. */ +function matchesPattern(path: string, pattern: string) { + const source = pattern + .split("*") + .map((part) => part.replaceAll(/[.*+?^${}()|[\]\\]/g, "\\$&")) + .join(".*"); + return new RegExp(`^${source}$`).test(path); +} + +export default function (hunk: HunkExtensionAPI) { + const patterns = (hunk.config.patterns as string[] | undefined) ?? [ + "*.lock", + "*-lock.json", + "dist/*", + ]; + + hunk.transformChangeset((changeset, ctx) => { + const kept = changeset.files.filter( + (file) => !patterns.some((pattern) => matchesPattern(file.path, pattern)), + ); + + const hidden = changeset.files.length - kept.length; + if (hidden > 0) { + ctx.notify(`Collapsed ${hidden} generated ${hidden === 1 ? "file" : "files"}`); + } + + return { ...changeset, files: kept }; + }); +} +``` + +Configure it without touching the code: + +```toml +# .hunk/config.toml +[extension.collapse-generated] +patterns = ["*.lock", "bun.lockb", "generated/*"] +``` + +Try it against the working tree without installing it: + +```bash +hunk diff --extension ./collapse-generated.ts +``` + +Continue with the [extension API](/docs/extend/extension-api/) for everything the API object offers. diff --git a/website/src/content/docs/docs/extend/vcs-adapters.md b/website/src/content/docs/docs/extend/vcs-adapters.md new file mode 100644 index 000000000..aff70f9fc --- /dev/null +++ b/website/src/content/docs/docs/extend/vcs-adapters.md @@ -0,0 +1,175 @@ +--- +title: VCS adapters +description: Contribute a version-control backend with detection, watch support, exact file sources, and rich failures. +--- + +`hunk.registerVcsAdapter(adapter)` contributes an additional VCS backend. This is the same call Hunk's own bundled Git, Jujutsu, and Sapling backends make. + +```ts +hunk.registerVcsAdapter({ + id: "hg", + name: "Mercurial", + detect: (cwd) => (existsSync(join(cwd, ".hg")) ? { id: "hg", repoRoot: cwd } : null), + operations: { + "working-tree-diff": { + async load(input, ctx) { + return { + repoRoot: ctx.cwd, + sourceLabel: ctx.cwd, + title: "Mercurial working copy", + patchText: await runHgDiff(ctx.cwd), + untrackedPaths: await listHgUnknownFiles(ctx.cwd), + }; + }, + }, + }, +}); +``` + +The ids Hunk ships with — `git`, `jj`, and `sl` — are reserved. An adapter that reuses one is skipped with a notice. + +`operations` is optional and may implement any of `working-tree-diff`, `revision-show`, and `stash-show`; an operation you leave out — or leaving the map off entirely — produces a clear "not supported" error for that command 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 | +| `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 binaries and files too large to render. Honor `input.options.excludeUntracked` when you do, so `--exclude-untracked` still means what it says. The other two are covered below. + +## Detection order + +Detection prefers the **nearest** checkout: a Git repository nested inside a jj workspace is reviewed as Git, whatever the priorities say. The same rule covers your adapter — a Mercurial checkout inside a Git repository is reviewed as Mercurial. `detectionPriority` only decides which backend wins when several recognize the _same_ directory — the colocated case, where one working copy carries two sets of markers. + +| Adapter | Priority | +| ------------------------ | -------------------------------------------- | +| bundled `jj` | 200 | +| bundled `sl` | 100 | +| bundled `git` | 0 (`HUNK_CORE_VCS_DETECTION_PRIORITY`) | +| your adapter, by default | -100 (`HUNK_DEFAULT_VCS_DETECTION_PRIORITY`) | + +Higher is consulted first; equal priorities fall back to registration order. jj and Sapling sit above Git because a colocated jj repository — or a Sapling repository created with `sl init --git` — also carries Git metadata, and the Git view is the wrong one. + +The default puts your adapter below Git, so installing an extension never silently changes how an existing repository is reviewed. Set `detectionPriority` explicitly to outrank a shipped backend; it is your machine. + +```ts +import { HUNK_CORE_VCS_DETECTION_PRIORITY } from "hunkdiff/extension"; + +hunk.registerVcsAdapter({ + id: "hg", + name: "Mercurial", + detectionPriority: HUNK_CORE_VCS_DETECTION_PRIORITY + 10, + detect, +}); +``` + +Detection runs the same way for every adapter, whichever tier registered it: the nearest checkout wins, `detectionPriority` breaks ties between adapters that recognize the same root, and equal priorities fall back to registration order. Config resolves the session's VCS before your extension has been imported, so detection runs again once extensions are loaded — with the full adapter list — and that second answer is the one the session uses. + +What detection never overrides is an explicit choice: a `vcs = ""` in Hunk config naming a backend this session loaded is honored as-is, however near a checkout some other adapter finds. + +## Watch support + +`--watch` works through extension adapters. Each operation may add: + +- `watchSignature(input, ctx)` — a cheap fingerprint of the reviewed state. Hunk polls it and reloads when it changes. +- `watchPlan(input, ctx)` — the filesystem targets that cover that state, so Hunk reacts to events instead of polling on a timer. + +```ts +watchPlan: (input, ctx) => ({ + coverage: "hybrid", + targets: [ + { + kind: "directory-tree", + directory: ctx.cwd, + ignoredRoots: [join(ctx.cwd, ".hg")], + sources: ["worktree"], + }, + ], +}), +``` + +`coverage: "hybrid"` promises the targets cover the reviewed state. Leaving `watchPlan` out is equivalent to `poll-only` and still works — it just costs a subprocess per tick. + +## Exact file sources + +A patch carries the changed lines and a little context, and nothing else. If your VCS can produce a file's _whole_ contents on each side, say so with `readFileSource` and Hunk will expand context past the hunk, highlight against the real file, and word-diff accurately. + +```ts +async load(input, ctx) { + // Pin the revisions while the operation loads, then close over them: by the + // time Hunk asks for a file, nothing can have moved underneath it. + const [oldRev, newRev] = await resolveHgRevisions(input, ctx.cwd); + + return { + repoRoot: ctx.cwd, + sourceLabel: ctx.cwd, + title: "Mercurial working copy", + patchText: await runHgDiff(ctx.cwd), + readFileSource: async ({ path, previousPath, changeType, side }) => { + if (side === "old") { + return changeType === "new" ? null : hgCat(oldRev, previousPath ?? path); + } + return changeType === "deleted" ? null : hgCat(newRev, path); + }, + }; +} +``` + +Return `null` for a side that has no content — the old side of an added file, a path the revision never contained — rather than throwing. Hunk calls the reader **at most once per file and side** and caches what it resolves, so you do not need your own cache, and it never calls it for a file the diff reports as binary. Leaving `readFileSource` off is fine: Hunk falls back to the content the patch itself carries, which renders the same diff with less context available. + +## Files outside the patch + +`extraFiles` lists files to review that your `patchText` does not contain, in the order they should appear. Each entry is one of two kinds, and Hunk builds the diff model for both — you describe files, you never assemble them. + +A **patch** entry is a file with its own one-file diff. Reach for it when your VCS produces better text for a file than Hunk reading the working copy would — its own binary detection, its own path quoting: + +```ts +extraFiles: [ + { + kind: "patch", + path: "notes.md", + patchText: await hgDiffOneFile("notes.md"), + isUntracked: true, + }, +]; +``` + +A **skipped** entry is a file Hunk should list but not render. Reviewing a multi-hundred-megabyte generated file costs more than it is worth, so report the file and why instead of producing a diff nothing will read: + +```ts +extraFiles: [ + { + kind: "skipped", + path: "dist/bundle.js", + reason: "too-large", + changeType: "change", + stats: { additions: 100_001, deletions: 0 }, + statsTruncated: true, + }, +]; +``` + +`readFileSource` covers the patch entries too; a skipped entry has no content to read, so it never gets a source reader. + +`untrackedPaths` remains the shorthand for the common case: list the paths your VCS calls unknown and Hunk synthesizes the added-file diffs from the working copy, skipping binaries and files too large to render. Use `extraFiles` instead only when your VCS renders those files better than a plain read would. + +## Moved lines + +`input.options.colorMoved` is true when the user asked for move detection. Hunk reads move classes back out of the patch itself, so emit ANSI-colored diff text painting moved additions cyan and moved deletions magenta — what `git diff --color-moved` produces — and those lines render as moved. This is ordinary post-processing over whatever patch text an adapter returns, not a Git special case. A backend with no notion of moved lines can ignore the option. + +## Failures the user can fix + +Throw a `HunkExtensionUserError` when the problem is how Hunk was invoked rather than a bug — no repository here, an unresolvable revision, a missing binary. Hunk prints the message without a stack trace and lists the suggestions beneath it. Anything else is reported as an unexpected error. + +```ts +import { HunkExtensionUserError } from "hunkdiff/extension"; + +throw new HunkExtensionUserError("`hunk stash show` is not supported by Mercurial.", { + suggestions: ["Use `hunk show ` to review a commit instead."], +}); +``` + +Hunk detects this structurally — an object whose `name` is `"HunkExtensionUserError"` with an optional `suggestions` array of strings — so a plain-JavaScript extension, or one bundling its own copy of the class, is treated the same way. `HUNK_EXTENSION_USER_ERROR_NAME` is exported if you would rather not hard-code the string. Hunk's own bundled Git, Jujutsu, and Sapling backends raise their failures exactly this way. diff --git a/website/src/content/docs/docs/index.mdx b/website/src/content/docs/docs/index.mdx index 089ea2af4..074bedd2e 100644 --- a/website/src/content/docs/docs/index.mdx +++ b/website/src/content/docs/docs/index.mdx @@ -36,6 +36,11 @@ Hunk is a terminal diff viewer for reviewing complete changesets and keeping age href="/docs/configure/configuration/" description="Set layered preferences, choose themes, and control layout and display behavior." /> + Date: Wed, 29 Jul 2026 01:41:26 +0000 Subject: [PATCH 3/5] docs(website): index VCS adapters and sidebars on the extension API page MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The API page is the index of the whole API object, so the two calls documented on their own pages get in-place summary headings — keeping them visible in the page's table of contents — that link to the full contracts. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01KHY9ReVesQXwKdRU47MwJ9 --- .../content/docs/docs/extend/extension-api.md | 16 +++++++++++++--- 1 file changed, 13 insertions(+), 3 deletions(-) diff --git a/website/src/content/docs/docs/extend/extension-api.md b/website/src/content/docs/docs/extend/extension-api.md index 1d90f21e8..0ce432717 100644 --- a/website/src/content/docs/docs/extend/extension-api.md +++ b/website/src/content/docs/docs/extend/extension-api.md @@ -3,9 +3,7 @@ title: Extension API description: Register themes, languages, 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. - -Two registration calls are large enough to have their own pages: [`registerVcsAdapter`](/docs/extend/vcs-adapters/) and [`registerSidebarView`](/docs/extend/custom-sidebars/). Everything else is here. +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. ## `hunk.apiVersion` @@ -38,6 +36,18 @@ hunk.registerFileLanguage("bzl", "python"); Later registrations win over earlier ones. Hunk's own `.mts` and `.cts` mappings cannot be overridden; attempts are skipped with a notice. +## `hunk.registerVcsAdapter(adapter)` + +Contribute an additional version-control backend — the same call Hunk's own bundled Git, Jujutsu, and Sapling backends make. An adapter declares `detect`, its `operations` (`working-tree-diff`, `revision-show`, `stash-show`), and optionally detection priority, watch support, exact file sources, extra files, and rich user-fixable failures. + +Full contract: [VCS adapters](/docs/extend/vcs-adapters/). + +## `hunk.registerSidebarView(view)` + +Contribute a sidebar view — your own React component, rendered inside Hunk's OpenTUI tree beside (or in place of) the built-in file navigation. Views receive live review props, guarded navigation actions, the user's resolved keybindings, and a scrollbox ref contract for selection-following and windowing. + +Full contract: [Custom sidebars](/docs/extend/custom-sidebars/). + ## `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. From f164e11caaa6d7a35ddcd4c6f9f41da826e5e337 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 29 Jul 2026 01:46:10 +0000 Subject: [PATCH 4/5] docs(website): make the extensions page scannable MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Trust was explained in four places and most rules arrived as long prose. State each rule once — discovery as a table plus bullets, trust in its own section only — and cut the editorializing so the page reads as reference, not narrative. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01KHY9ReVesQXwKdRU47MwJ9 --- .../content/docs/docs/extend/extensions.md | 88 ++++++++----------- 1 file changed, 35 insertions(+), 53 deletions(-) diff --git a/website/src/content/docs/docs/extend/extensions.md b/website/src/content/docs/docs/extend/extensions.md index 273d2fe73..658b2c18b 100644 --- a/website/src/content/docs/docs/extend/extensions.md +++ b/website/src/content/docs/docs/extend/extensions.md @@ -3,7 +3,7 @@ title: Extensions description: Load plain TypeScript extensions, understand discovery and trust, and configure them. --- -A Hunk extension entry is one TypeScript (or JavaScript) file that default-exports a function. Hunk imports it at startup and hands it an API object. An entry may stand alone or be declared by a folder's optional `package.json` manifest; no build step is required. +A Hunk extension is one TypeScript (or JavaScript) file that default-exports a function. Hunk imports it at startup and hands it an API object. No build step is required. ```ts // ~/.config/hunk/extensions/hello.ts @@ -16,29 +16,29 @@ export default function (hunk: HunkExtensionAPI) { } ``` -**The extension API is experimental.** Everything documented here works today, but the `hunkdiff/extension` surface may change in breaking ways between minor releases while it stabilizes against real third-party extensions. Breaking changes are called out in release notes, and `hunk.apiVersion` identifies the surface an extension was written against. +**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/) (themes, languages, changeset transforms, commands, events, dialogs), [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/), [VCS adapters](/docs/extend/vcs-adapters/), and [custom sidebars](/docs/extend/custom-sidebars/). -## Where Hunk looks for extensions +## Where Hunk looks -Discovery runs group by group, alphabetically by resolved path within each group — a folder extension's entries sort together, at the folder's own path. The first occurrence of a resolved path wins, so a path you pass explicitly keeps its origin even if the same file is also discovered somewhere else. - -| Group | Source | Trust | +| Group | Source | Runs | | ----- | ---------------------------------------------------- | --------------------- | -| 1 | `--extension ` (repeatable) | runs immediately | -| 2 | `[extensions] paths` in your user config | runs immediately | -| 3 | `~/.config/hunk/extensions/` | runs immediately | -| 4 | `.hunk/extensions/` in the repo under review | **prompts for trust** | -| 4 | `[extensions] paths` in the repo `.hunk/config.toml` | **prompts for trust** | - -The two repo-local sources share a group number because they are one group: both are repo-controlled, so they share a trust decision and their paths are sorted together rather than one source being loaded ahead of the other. - -A directory source matches `*.ts`, `*.tsx`, `*.js`, `*.jsx`, `*.mjs` directly inside it, plus one level of folder extensions, so a folder extension can keep helper modules beside its entry file. +| 1 | `--extension ` (repeatable) | immediately | +| 2 | `[extensions] paths` in your user config | immediately | +| 3 | `~/.config/hunk/extensions/` | immediately | +| 4 | `.hunk/extensions/` in the repo under review | after [trust](#trust) | +| 4 | `[extensions] paths` in the repo `.hunk/config.toml` | after [trust](#trust) | + +- Groups load in order; within a group, entries sort alphabetically by resolved path. The first occurrence of a path wins. +- The two repo-local sources are one group: one trust decision, one sort order. +- A directory source matches `*.ts`, `*.tsx`, `*.js`, `*.jsx`, `*.mjs` directly inside it, plus one level of folder extensions. +- `--no-extensions` disables user extensions for one run; nothing on disk is read. +- `--extension` is explicit intent: it loads immediately, without a trust prompt, even from inside the reviewed repo — so never pass a path you have not read. ### Folder extensions -A folder is an extension if it declares its entry files in a `package.json`, or failing that if it has an `index.{ts,tsx,js,jsx,mjs}` (in that preference order, so a folder shipping both a source and a built entry resolves the same everywhere). The manifest field is `hunk`: +A folder is an extension if its `package.json` declares entries under the `hunk` field, or failing that if it has an `index.{ts,tsx,js,jsx,mjs}` (in that preference order): ```text ~/.config/hunk/extensions/my-ext/ @@ -49,43 +49,31 @@ A folder is an extension if it declares its entry files in a `package.json`, or helper.ts ``` -The manifest wins over the `index.*` fallback, and its paths resolve against the folder. It may list more than one entry, in which case each entry loads as its own extension in the order the manifest gives. Each one is identified by its file stem; when stems collide, later entries receive a numeric suffix while avoiding ids already claimed by other entries in the manifest. - -Because the manifest is a real `package.json`, a folder extension may depend on npm packages: declare them, install them into the folder's own `node_modules`, and imports resolve from the entry file the way they do in any other package. - -Pointing `--extension` or `[extensions] paths` straight at a directory works either way: a directory that is itself a folder extension loads as that one extension, so its helper modules stay helpers. A directory that is not is treated as a directory _of_ extensions and scanned with the patterns above. +- Manifest paths resolve against the folder and may list several entries; each loads as its own extension, in manifest order. +- The manifest is a real `package.json`, so a folder extension can depend on npm packages installed into its own `node_modules`. +- Pointing `--extension` or `[extensions] paths` at a directory works either way: a folder extension loads as one extension; any other directory is scanned as a directory _of_ extensions. ### Extension ids -An extension's **id** is its file stem, or its folder name for `/index.ts`. A manifest that declares a single entry also keeps the folder's name, whatever the entry file is called. The id is what `[extension.]` config tables key off, so moving a single-file extension into a folder of the same name — or later giving that folder a manifest — keeps its config working. - -The id is also the namespace your extension owns: its commands are `.` and its sidebar views `:`. So the id has to be spelled like a name — starting with a letter or digit, then letters, digits, `-`, or `_`. A dot or a colon would make those composed ids ambiguous, and `hunk`, `git`, `jj`, and `sl` are reserved for what Hunk ships. An extension whose id breaks a rule is skipped with a startup notice naming the file; rename it and it loads. If two discovery sources offer the same id, the first in source order loads and the other is skipped the same way, since one id cannot own two config tables. - -### Explicit intent +The **id** is the file stem, or the folder name for `/index.ts` and single-entry manifests. It is the key for everything the extension owns: -`--no-extensions` disables user extensions for one run — nothing on disk is read, let alone executed. Use it when triaging a bug. +- config: `[extension.]` +- commands: `.` +- sidebar views: `:` -`--extension` is explicit user intent: the file loads immediately, with no trust prompt, even when the path points inside the repository under review. Never pass a path you have not read — including one copy-pasted from a repository's own README. +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. ## Bundled extensions -Every VCS backend Hunk ships — **Git, Jujutsu, and Sapling** — is an extension, and so is the **built-in file-navigation sidebar**. They are compiled into the binary and register through the same `hunk.registerVcsAdapter` and `hunk.registerSidebarView` these pages document. There is no core-registered backend left, no private sidebar, and no private path into the review pipeline. +Hunk's own Git, Jujutsu, and Sapling backends and the built-in file-navigation sidebar are themselves extensions, registered through the same public API — which is what keeps that API honest. They differ from yours in three ways: -Git in particular is the reason: it is the backend that exercises every integration point there is — exact file sources, skipped-too-large placeholders, untracked files, watch plans, rich failures — so running it through the published API is what keeps that API honest. Anything Git can do, your adapter can do, because Git does it the same way you would. - -Bundled extensions differ from yours in three ways, all of them consequences of being Hunk's own code: - -- They are **statically imported**, so they load synchronously, before config resolution picks the session's VCS. -- They are **implicitly trusted**: no discovery, no trust prompt, and no `[extension.]` config table. -- They stay loaded under `--no-extensions` and `[extensions] enabled = false`. Those switches exist to triage extensions _you_ installed; losing VCS support from a debugging flag would break every workflow there is. - -Failure isolation still applies to them. The ids `git`, `jj`, and `sl` are reserved as a result, and so is `hunk`, the id the bundled sidebar and every built-in command are named under. +- statically imported, so they load before config resolution picks the session's VCS +- implicitly trusted, with no `[extension.]` config table +- still loaded under `--no-extensions` and `[extensions] enabled = false` — those switches triage extensions _you_ installed ## Trust -Extensions run with your user permissions, exactly like a shell dotfile. That is fine for extensions you installed yourself, and not fine for extensions that came with a repository you are about to review — pointing a diff tool at unfamiliar code is a normal thing to do, and it must never execute that code. - -So repo-local sources are gated. The first time Hunk finds extensions in a repository's `.hunk/extensions` (or repo-config `paths`), it skips them and asks: +Extensions run with your full user permissions, and reviewing a repository must never execute code that came with it. So the repo-local sources stay inert until you approve them, once per repository: ```text Run this repository's extensions? @@ -96,19 +84,15 @@ Run this repository's extensions? enter/t trust · esc not now · n never ``` -- **Trust** records the decision and reloads the session so the repo's extensions take effect immediately. -- **Not now** (also `Esc`) dismisses without recording anything; you will be asked again next time. -- **Never** records a denial so Hunk stops offering. - -Decisions are stored per repository root in `~/.config/hunk/state.json`. The prompt is a normal dialog over the review stream, not a gate in front of it: you can dismiss it and keep reviewing. +**Trust** records the decision and reloads the session; **not now** asks again next time; **never** stops the offers. The prompt is a dialog over the review stream, not a gate in front of it — dismiss it and keep reviewing. -Trust is keyed by the repo root's **path**, not by the repository's identity — the same model VS Code workspace trust uses. If you delete a trusted checkout and a different repository later occupies that path, it inherits the decision. Clear the entry from `state.json` if that matters for a path you reuse. +Decisions are stored per repository root in `~/.config/hunk/state.json`, keyed by path (the VS Code workspace-trust model). A different repository later occupying a trusted path inherits the decision; clear the entry if that matters for a path you reuse. ## Failure isolation -A broken extension should not break review. An extension that fails to import, has no default export, or throws from its factory is skipped, its partial registrations are rolled back, and it becomes a startup notice in the footer. A handler or transform that throws later is reported as a warning naming the extension, and everything else keeps running. Event handlers receive frozen copies of the changeset, so accidental mutation throws inside the handler instead of corrupting the review. +A broken extension is contained, not fatal: a failed import, missing default export, or throwing factory is skipped and rolled back with a startup notice; a handler or transform that throws later becomes a warning naming the extension. Event handlers receive frozen changeset copies, so accidental mutation throws instead of corrupting the review. -This is crash containment, not a sandbox. Per-file `metadata` inside event payloads is shared with the renderer for performance and is not frozen, and an extension runs with your full user permissions — it can do anything your shell can. The containment protects you from bugs, not from code you should not have loaded in the first place. +This is crash containment, not a sandbox — an extension can do anything your shell can. ## CLI flags and config @@ -128,9 +112,7 @@ paths = ["~/dev/hunk-ext/index.ts"] # extra entry files or directories some_key = "some value" ``` -`[extensions] enabled` layers like every other option: a repo `.hunk/config.toml` overrides your user config. `--no-extensions` is a hard off switch that no config layer can re-enable. Both govern **user** extensions only — Hunk's bundled Git, Jujutsu, and Sapling backends load either way. `[extensions] paths` from a repo config is trust-gated the same way `.hunk/extensions` is, because it is repo-controlled either way. - -`[extension.]` tables are handed to the matching extension uninterpreted, repo config merging over user config key by key; see [`hunk.config`](/docs/extend/extension-api/#hunkconfig) for the trust caveats that come with that. +`[extensions] enabled` layers like every other option (repo config overrides user config); `--no-extensions` is a hard off switch no config layer can re-enable. `[extension.]` tables pass through to the extension uninterpreted — see [`hunk.config`](/docs/extend/extension-api/#hunkconfig) for the merge rules and their caveats. ## A complete example From c433b0778134c90a00c88c0e83bbebbe7103f1a3 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 29 Jul 2026 01:56:11 +0000 Subject: [PATCH 5/5] docs(website): tighten the extension API page Cut failure-mode narration a developer discovers on their own (invalid chords, startup-notice details, dialog edge-case exposition) and compress the long prose runs around commands, selection, and events into direct statements. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01KHY9ReVesQXwKdRU47MwJ9 --- .../content/docs/docs/extend/extension-api.md | 77 ++++++++----------- 1 file changed, 31 insertions(+), 46 deletions(-) diff --git a/website/src/content/docs/docs/extend/extension-api.md b/website/src/content/docs/docs/extend/extension-api.md index 0ce432717..439a4e83d 100644 --- a/website/src/content/docs/docs/extend/extension-api.md +++ b/website/src/content/docs/docs/extend/extension-api.md @@ -23,7 +23,7 @@ hunk.registerTheme({ }); ``` -Theme ids must be lowercase words separated by `-` or `_` and cannot reuse a built-in id. Config-defined themes always win over extension themes for the same id; the loser is reported as a startup notice. Extension themes appear in the selector after config themes, in load order. +Theme ids are lowercase words separated by `-` or `_` and cannot reuse a built-in id. Config-defined themes win over extension themes for the same id. Extension themes appear in the selector after config themes, in load order. ## `hunk.registerFileLanguage(extension, language)` @@ -34,7 +34,7 @@ hunk.registerFileLanguage(".zig", "zig"); hunk.registerFileLanguage("bzl", "python"); ``` -Later registrations win over earlier ones. Hunk's own `.mts` and `.cts` mappings cannot be overridden; attempts are skipped with a notice. +Later registrations win. Hunk's own `.mts` and `.cts` mappings cannot be overridden. ## `hunk.registerVcsAdapter(adapter)` @@ -59,15 +59,15 @@ hunk.transformChangeset((changeset) => ({ })); ``` -The function may be async. Filtering and reordering `files` is fully supported — the sidebar and the review stream both follow whatever you return. +The function may be async. Filtering and reordering `files` is fully supported — the sidebar and the review stream follow whatever you return. -Each file carries an opaque `metadata` field: it is the parsed diff the renderer draws from, so pass it through untouched (spreading a file preserves it). What you return is validated before it is reviewed. A transform that throws, or returns something the review UI could not draw — not a changeset with a `files` array, a file missing `metadata.hunks` or `stats`, two files sharing an `id` — is skipped: the previous changeset carries forward and you get a warning naming your extension and the problem. +Each file carries an opaque `metadata` field — the parsed diff the renderer draws from — so pass it through untouched; spreading a file preserves it. Returns are validated: a transform that throws or returns something the review UI cannot draw is skipped, and the previous changeset carries forward. -You never need to reach into `metadata` to know what a file's hunks are: the read-only views Hunk hands outward (event payloads, sidebar props, a command's selection) carry a `hunks` list of public summaries — `index`, the `@@` header, and the inclusive old/new line spans, in render order. Like `changeType`, it is derived from the metadata at that boundary, so a transform neither receives nor produces it, and a stale value spread through a transform is replaced with what the metadata actually parses to. +You never need `metadata` to know a file's hunks: the read-only views Hunk hands outward (event payloads, sidebar props, a command's selection) carry a `hunks` list of public summaries — `index`, the `@@` header, and the inclusive old/new line spans, in render order. Like `changeType`, it is derived at that boundary; a transform neither receives nor produces it. ## `hunk.registerCommand(command, handler)` -Register a named command, optionally bound to a key. Commands are not a sidebar one-off: they are the same mechanism Hunk's own shortcuts dispatch through — one table, one loop, built-ins first. +Register a named command, optionally bound to a key. Commands are the same mechanism Hunk's own shortcuts dispatch through — one table, one loop, built-ins first. ```ts import type { HunkExtensionAPI } from "hunkdiff/extension"; @@ -79,28 +79,18 @@ export default function (hunk: HunkExtensionAPI) { } ``` -Key chords are `ctrl`, `alt`/`option`, `cmd`/`meta`, and `shift` joined with `+` around a base key — a character (`"y"`, `"["`), an uppercase letter for its shifted form (`"G"`), or a named key (`"f2"`, `"pageup"`, `"left"`). `shift` applies to letters and named keys only: for a shifted symbol or digit, bind the character the shift produces (`"!"`, not `"shift+1"`), since terminals report the character rather than the combination. An unparsable chord fails the registration; a chord already owned by a built-in shortcut — or by an earlier-loaded extension — leaves that chord unbound, with a warning toast naming both sides. Omit `key` to register a command with no binding. +Key chords join `ctrl`, `alt`/`option`, `cmd`/`meta`, and `shift` with `+` around a base key — a character (`"y"`), an uppercase letter for its shifted form (`"G"`), or a named key (`"f2"`, `"pageup"`). For a shifted symbol or digit, bind the character shift produces (`"!"`, not `"shift+1"`). `key` also takes a list of chords; omit it to register a command with no binding. A chord already owned by a built-in or an earlier-loaded extension stays with its owner. -`key` also takes a list, binding the command to every chord in it: +Declared keys are defaults: users remap commands by id in their `[keybindings]` table — yours is `"."`. See [`docs/keybindings.md`](https://github.com/modem-dev/hunk/blob/main/docs/keybindings.md). -```ts -hunk.registerCommand({ id: "hello", title: "Say hello", key: ["ctrl+g", "f9"] }, (ctx) => { - ctx.notify("hello from a command"); -}); -``` - -Chords are refused one at a time: if `ctrl+g` were already taken, the command would still answer to `f9`. - -Whatever an extension declares is a _default_. Users remap commands by id in the `[keybindings]` table of their own config, extension commands included — yours is named `"."`, while Hunk's own are `"hunk.app.quit"` and friends. See [`docs/keybindings.md`](https://github.com/modem-dev/hunk/blob/main/docs/keybindings.md) for the rules; the practical consequence is that a chord you declare may not be the chord your command ends up on. - -Every registered command is also listed in the menu bar's **Extensions** menu, under its `title`, showing whichever key it currently answers to. The menu appears only when something registered a command, entries are grouped by extension in load order, and running one from the menu is the same dispatch the key would have done — so a command with no `key`, or one whose chord was refused, is still reachable with the mouse. +Registered commands are also listed in the menu bar's **Extensions** menu under their `title`, showing whichever key they currently answer to — a command with no binding is still reachable with the mouse. -The handler fires when the key is pressed outside modal UI — dialogs, menus, and focused text inputs own their keys first, and pager mode does not dispatch extension commands. It receives the standard context plus `ctx.sidebars`, the controls for opening sidebar views: +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)` — a bare id names your own extension's view, `"files"` names the built-in file navigation, and `":"` addresses any registered view. Opening a view also reveals the sidebar area when the user has hidden it with `s`, so the open is never silent. -- `ctx.sidebars.isOpen(viewId)` reports current state. - -`ctx.selection` is where the review was pointing when the command fired — the same selection a sidebar component sees in its props, so a command never has to track `selection_changed` itself to know what the user is looking at: +- `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.selection` — where the review was pointing when the command fired. +- `ctx.navigation` — moves the review stream. +- `ctx.dialogs` — asks the user, below. ```ts hunk.registerCommand( @@ -117,15 +107,15 @@ hunk.registerCommand( ); ``` -`selection.file` is a frozen read-only view, identical to the entries in a sidebar's `files` prop. Hunk keeps the selection inside the visible files, so it is `null` only when nothing is visible at all — a filter that matches no files. `selection.hunkIndex` is that file's selected hunk, and `null` whenever `file` is — or when the file has no hunks to select. The values are captured when the command fires: a handler that awaits still sees the selection it was run from, not wherever the user navigated to meanwhile. +`selection.file` is a frozen view, identical to a sidebar's `files` entries; it is `null` only when no files are visible. `selection.hunkIndex` is `null` whenever `file` is, or when the file has no hunks. The values are captured when the command fires, so an async handler keeps the selection it started from. -`ctx.navigation` moves the review stream: `selectFile(fileId)` and `selectHunk(fileId, hunkIndex)`, the same guarded navigation a sidebar's `actions` carry, routed through the same review controller — the stream scrolls, selection updates, and `selection_changed` fires exactly as if the user had clicked a sidebar row. Unlike `selection` it is live, not a snapshot: a call acts on the review as it is at that moment, so a handler that awaits a dialog and then navigates still works. A file id the stream cannot currently show is refused with a warning rather than corrupting the selection, and a hunk index is clamped into the file's real range. +`ctx.navigation.selectFile(fileId)` and `selectHunk(fileId, hunkIndex)` route through the same guarded review controller as a sidebar's `actions` — the stream scrolls, selection updates, `selection_changed` fires. Unlike `selection` it is live: a handler that awaits a dialog and then navigates still works. -A handler may be async; a failure (sync or rejected) becomes a warning naming your extension. +A handler may be async; a failure becomes a warning naming your extension. ### Asking the user -`ctx.dialogs` puts a question on screen and waits for the answer. Three shapes, all promise-returning: +`ctx.dialogs` puts a question on screen and waits for the answer. Three methods, all return promises: - `confirm({ title, body?, confirmLabel?, cancelLabel? })` → `true` or `false` - `select({ title, options })` → the chosen string, or `null` @@ -151,7 +141,7 @@ hunk.registerCommand( ); ``` -`select` is the natural fit for acting on part of the selection — here, asking which hunk of the selected file to jump to, then navigating there: +`select` fits acting on part of the selection — asking which hunk to jump to, then navigating there: ```ts hunk.registerCommand({ id: "pick-hunk", title: "Pick a hunk", key: "ctrl+k" }, async (ctx) => { @@ -172,15 +162,13 @@ hunk.registerCommand({ id: "pick-hunk", title: "Pick a hunk", key: "ctrl+k" }, a }); ``` -Hunk draws the dialog, not you: your text fills the title, body, and choices, and the frame carries an `ext ` attribution line — the same marker `notify` toasts use — so a prompt can never present itself as Hunk asking. - -One dialog is on screen at a time. Concurrent requests queue in call order, across extensions too, so a second question waits its turn instead of replacing the first. While a dialog is up it owns the keyboard: Escape cancels (`false`, or `null`), Enter accepts — the confirm action, the highlighted option, or the typed text — and review shortcuts stay suppressed underneath. Confirm dialogs also answer to `y`/`n`, select dialogs to `↑`/`↓`, and every dialog's actions and rows are clickable. +Hunk draws the dialog; your text fills the title, body, and choices, and the frame carries an `ext ` attribution line — the same marker `notify` toasts use — so a prompt cannot present itself as Hunk asking. -Two things resolve a dialog without the user: the session moving on, and bad arguments. A session reload — the refresh key, a watch-triggered reload, an agent command — cancels open and queued dialogs, since the review they asked about is being replaced; a dialog pending at shutdown resolves its cancel value the same way, and a request made after that point cancels immediately. A blank `title`, or a `select` with no options, is a bug in the extension rather than an answer from the user, so the promise **rejects**; like any other handler failure, that surfaces as a warning naming your extension. +One dialog shows at a time; concurrent requests queue in call order, across extensions. Escape cancels (`false` or `null`), Enter accepts; confirm dialogs also answer to `y`/`n`, select dialogs to `↑`/`↓`, and everything is clickable. A session reload cancels open and queued dialogs, and a dialog pending at shutdown resolves its cancel value. ## `hunk.on(event, handler)` -Subscribe to a lifecycle or UI event. Handlers may be async; Hunk never blocks the UI waiting for one. Alongside `cwd` and `notify`, every handler receives `ctx.sidebars`, the same open/close/toggle controls command handlers receive. That means a `changeset_loaded` handler can reveal its extension's sidebar when it finds something worth showing — no keypress required. +Subscribe to a lifecycle or UI event. Handlers may be async; Hunk never blocks the UI waiting for one. Every handler receives `ctx.sidebars` alongside `cwd` and `notify`, so a `changeset_loaded` handler can reveal its extension's sidebar without a keypress. | Event | Payload | When | | ---------------------- | ----------------------- | -------------------------------------------------------- | @@ -197,17 +185,14 @@ Subscribe to a lifecycle or UI event. Handlers may be async; Hunk never blocks t | `session_reload` | `{ changeset, reason }` | on every session reload | | `shutdown` | `{}` | on exit, best-effort within a short timeout | -`selection_changed` is trailing-debounced on purpose: holding `[`/`]` retargets the selection many times a second, and handlers only care where the user landed. `fileId` and `hunkIndex` are `null` when nothing is selected. - -`session_reload`'s `reason` is `"watch"` (the watcher saw the source change), `"daemon"` (an agent command through the session broker), or `"manual"` (the refresh key, or the reload after granting extension trust). - -`note_created` and `note_edited` cover notes authored in Hunk's own UI, in this session. Review notes are session-local state, so there is no backlog to replay on startup — but comments added through agent session commands do not emit these events, and a `session_reload` may remap or drop notes without one either. A list accumulated from these events is therefore "notes the user saved here this session", not a complete review record; present it as such. - -`shutdown` handlers get a short window (250ms) to finish before Hunk exits anyway, so treat it as best-effort flushing rather than guaranteed cleanup. +- `selection_changed` is trailing-debounced: holding `[`/`]` retargets many times a second, and handlers only care where the user landed. `fileId` and `hunkIndex` are `null` when nothing is selected. +- `session_reload`'s `reason` is `"watch"`, `"daemon"` (an agent command through the session broker), or `"manual"`. +- `note_created` and `note_edited` cover notes authored in Hunk's own UI this session. Agent session comments do not emit them, and a reload may remap or drop notes — an accumulated list is not a complete review record. +- `shutdown` handlers get 250ms before Hunk exits anyway; treat it as best-effort flushing. ## `hunk.events` -`hunk.events` is a small bus shared by every loaded extension. Use it to coordinate extensions without coupling them through a command or global state. Names are open-ended, so namespace them with your extension id. Listeners get the same `ctx.sidebars` controls as lifecycle handlers; delivery is fire-and-forget and one listener's failure is reported without stopping the others. Events an extension emits while factories are loading are queued until every extension has had a chance to subscribe. +A small bus shared by every loaded extension, for coordinating without coupling through global state. Namespace event names with your extension id. Delivery is fire-and-forget; events emitted while factories are still loading are queued until every extension has subscribed. ```ts import type { HunkExtensionAPI } from "hunkdiff/extension"; @@ -228,9 +213,9 @@ Bus payloads are shallow-frozen copies when they are objects. Keep nested data i ## `hunk.config` -Your extension's own `[extension.]` config table, as a plain object. Hunk does not interpret the keys — unknown keys pass straight through — and repo config overrides user config key by key. +Your extension's own `[extension.]` config table, as a plain object. Hunk does not interpret the keys, and repo config overrides user config key by key. -**Treat these values as untrusted.** Tables merge by extension id with no notion of where the extension was installed from, so a repository under review can set or override configuration for an extension you installed globally. That is deliberate — repo-level tuning of a shared extension is a normal team workflow, and Hunk shows a startup notice listing the extension ids a repo configures — but it means `hunk.config` must never be trusted for exec-adjacent decisions such as binary paths, shell commands, or module loading. Validate those against something the user controls. +**Treat these values as untrusted.** A repository under review can set or override the table for an extension you installed globally (deliberate — team-level tuning of a shared extension), so never use `hunk.config` for exec-adjacent decisions such as binary paths, shell commands, or module loading. Validate those against something the user controls. ```toml # ~/.config/hunk/config.toml @@ -244,7 +229,7 @@ const patterns = (hunk.config.patterns as string[] | undefined) ?? ["*.lock"]; ## `ctx.notify(message, type?)` -Every handler and transform receives a context object with `cwd` and `notify`. Event and bus handlers additionally receive `sidebars` and `events.emit`; command handlers receive `sidebars`, `selection`, `navigation`, and `dialogs`. `notify` shows a single unobtrusive line at the bottom of the app that clears itself after a few seconds; queued messages appear in turn. `type` is `"info"` (default), `"warning"`, or `"error"`, which selects the color. Notifications raised before the UI has mounted are buffered and flushed once it does, 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`, `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)` @@ -252,4 +237,4 @@ Record a diagnostic line. Logs are collected per extension rather than written t ## Not contributable yet -Menu entries, standalone keybindings (a chord contributed without a command — commands registered through `registerCommand` **are** already user-remappable via `[keybindings]`), custom note renderers, session commands, and CLI subcommands are not contributable yet. Commands and their default key bindings landed with `registerCommand` — the named-command registry the rest build on; see [`docs/extension-system-exploration.md`](https://github.com/modem-dev/hunk/blob/main/docs/extension-system-exploration.md) for the design and phasing. +Menu entries, standalone keybindings (chords without a command — `registerCommand` commands are already user-remappable), custom note renderers, session commands, and CLI subcommands. See [`docs/extension-system-exploration.md`](https://github.com/modem-dev/hunk/blob/main/docs/extension-system-exploration.md) for the design and phasing.