Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/fuzzy-ravens-review.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"hunkdiff": minor
---

Let extension commands capture immutable snapshots of stable review files and every saved review note.
3 changes: 2 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -265,7 +265,8 @@ topic.

See [docs/extensions.md](docs/extensions.md) for the full API, the trust model,
publishing guidance, and the `[extensions]` / `[extension.<id>]` config reference.
Installable examples include [review triage](examples/extensions/review-triage/), an optional
Installable examples include [review triage](examples/extensions/review-triage/),
[authoritative review snapshot export](examples/extensions/review-snapshot-export/), an optional
[rendered Markdown file view](examples/extensions/rendered-markdown/), and a
[Vim navigation mode](examples/extensions/vim-navigation/) built from public semantic commands.

Expand Down
9 changes: 8 additions & 1 deletion docs/extension-architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -182,7 +182,14 @@ chord at a time and detected by probing matchers with a synthesized event
`src/ui/lib/extensionSelection.ts`, derived from the same frozen file views the
panes render plus a copied source address for the active current-line cursor.
App reads it through a ref so the dispatch table stays stable while line
navigation moves.
navigation moves. `ctx.review.snapshot()` takes the complementary whole-review
path: `src/extensions/reviewSnapshot.ts` copies the active shared ReviewStore's
document identities and complete saved-note collections, preserving core-owned
anchors and reconciliation verdicts. App pairs that state with the producer's
current generation under the same review capability lease, so retained controls
return `null` after reload instead of reading replacement content. The extension
projection is registered in `test/review-conformance/` as a real semantic
consumer rather than rebuilding note placement in the command host.

`src/ui/lib/extensionNavigation.ts` mints the guarded navigation behind both
`ctx.navigation` and a pane's `actions`, so a jump from either surface is
Expand Down
58 changes: 53 additions & 5 deletions docs/extensions.md
Original file line number Diff line number Diff line change
Expand Up @@ -280,9 +280,10 @@ new instances and run that shutdown/startup pair around the replacement.

### `hunk.apiVersion`

The API generation this Hunk speaks (currently `7`). Branch on it if you want
one file to support several Hunk versions. Version 7 adds the current source
line to command selection snapshots. Version 6 adds session behavior,
The API generation this Hunk speaks (currently `8`). Branch on it if you want
one file to support several Hunk versions. Version 8 adds authoritative review
snapshots to command handlers; version 7 added the current source line to
command selection snapshots. Version 6 added session behavior,
terminal-command observation, and live navigation/dialogs in event handlers;
version 5 added line highlighters and line-granular navigation (`revealLine`);
version 4 added keyboard modes and docked panes, with API-v3 sidebar names
Expand Down Expand Up @@ -1400,6 +1401,51 @@ session keyboard modes. See [Session keyboard modes](#session-keyboard-modes).
`{ fileId }`-scoped. See
[`hunk.registerLineHighlighter`](#hunkregisterlinehighlighterhighlighter).

#### Reading the authoritative review

`ctx.review.snapshot()` returns a deeply immutable projection of the shared
ReviewStore, or `null` after this command's review generation has been retired.
It contains the opaque producer `generation`, the store's `stateRevision`, every
file in authoritative review/sidebar order, and every saved live or reviewer
note. Files carry their stable `fileKey`, transient `runtimeId`, content identity,
paths, stats, and flags; notes carry their complete resolved old/new anchor and
`active`/`stale`/`orphaned` reconciliation status.

This is the command-time source for exporters, publishers, and audit tools. Drafts
are excluded because they are not saved. Static sidecar annotations that never
entered ReviewStore remain available on the changeset's file views, not in this
snapshot. Saved notes appear in live-arrival order followed by reviewer-creation
order, including orphaned notes a publisher may need to move into a summary.

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

export default function (hunk: HunkExtensionAPI) {
hunk.registerCommand({ id: "publish", title: "Publish review" }, async (ctx) => {
const captured = ctx.review.snapshot();
if (!captured) return;

await Promise.resolve(); // Prepare an external request from `captured` here.
const current = ctx.review.snapshot();
if (
!current ||
current.generation !== captured.generation ||
current.stateRevision !== captured.stateRevision
) {
ctx.notify("Review changed; rebuild the request", "warning");
return;
}
});
}
```

Call `snapshot()` again before irreversible asynchronous work and compare both
fields: revisions are comparable only within one generation. Use `fileKey` for
semantic addressing, `contentIdentity` to detect changed reviewed content, and
`runtimeId` only for navigation inside that exact generation. The
[`review-snapshot-export`](../examples/extensions/review-snapshot-export/)
example writes the complete value as JSON and demonstrates this stale-work check.

#### Navigating the review

`ctx.navigation` moves the review stream: `selectFile(fileId)`,
Expand Down Expand Up @@ -1679,8 +1725,8 @@ refresh key, or the reload after granting extension trust).
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.
either. Use them for incremental UI reactions only. A command that needs the
complete current saved-note record uses `ctx.review.snapshot()` instead.

`shutdown` handlers get a short window (250ms) to finish before Hunk replaces
the extension registry or exits anyway, so make cleanup prompt and idempotent.
Expand Down Expand Up @@ -1769,6 +1815,8 @@ Installable extensions and examples include:
paint (`hunk extension install modem-dev/hunk-lens`).
- [`review-triage`](../examples/extensions/review-triage/) for panes, commands,
dialogs, lifecycle events, and the event bus.
- [`review-snapshot-export`](../examples/extensions/review-snapshot-export/) for
authoritative saved-note export and generation/revision stale-work checks.
- [`examples/extensions/rendered-markdown/`](../examples/extensions/rendered-markdown/)
parses Markdown into generic host-owned file-view rows. Its README shows how
to run it from the checkout or copy it into the global extensions directory.
Expand Down
1 change: 1 addition & 0 deletions examples/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ Each folder tells a small review story and includes the exact command to run fro
## Installable extension examples

- [`extensions/review-triage/`](extensions/review-triage/) adds a session-local hunk triage sidebar.
- [`extensions/review-snapshot-export/`](extensions/review-snapshot-export/) exports stable file identities and every saved ReviewStore note with a stale-work guard.
- [`extensions/rendered-markdown/`](extensions/rendered-markdown/) adds an optional parsed Markdown file presentation.
- [`extensions/inline-edit/`](extensions/inline-edit/) edits the file under review in place, composing a file-view mode, layout refresh, and host-mediated workspace writes.
- [`extensions/jsx-file-view/`](extensions/jsx-file-view/) is the smallest hook-using fixed-row JSX proof of concept.
Expand Down
25 changes: 25 additions & 0 deletions examples/extensions/review-snapshot-export/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
# Review snapshot export extension

Exports Hunk's authoritative saved review state as JSON. The example shows why `ctx.review.snapshot()` is more useful than accumulating `note_created` events: one command receives every note currently retained by the shared ReviewStore, including stale and orphaned notes an exporter must handle explicitly.

Run it directly from this checkout:

```bash
bun run src/main.tsx -- diff --extension ./examples/extensions/review-snapshot-export
```

Add one or more review notes, then run **Extensions → Export review snapshot…** (`F9`) and choose a new output path. Relative paths resolve from the review's working directory; the example refuses to overwrite an existing file.

The JSON includes:

- the opaque producer generation and ReviewStore revision
- every reviewed file's stable `fileKey`, runtime navigation id, content identity, status, and path
- all saved live and reviewer notes with their resolved old/new anchors and reconciliation status

Draft notes and static sidecar annotations that never entered ReviewStore are intentionally absent. Saved notes appear in live-arrival order followed by reviewer-creation order.

The command captures a snapshot before opening its path dialog, then reads the controls again before writing. If either the generation or revision changed, it refuses the stale export and asks the user to rerun the command. Publishers can use the same check before an irreversible network request.

## Trust

This example uses `node:fs` directly to write the user-selected export path. Hunk extensions run with the user's full permissions; this is distinct from `ctx.workspace`, which mediates writes to reviewed files and asks for consent. Install and run only extensions you trust.
38 changes: 38 additions & 0 deletions examples/extensions/review-snapshot-export/index.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
import { describe, expect, test } from "bun:test";
import { resolve } from "node:path";
import { resolveSnapshotExportPath, snapshotPositionMatches } from "./index";
import type { ExtensionReviewSnapshot } from "../../../src/extension-api/types";

/** Build the minimal immutable snapshot position these helper tests compare. */
function createTestSnapshot(
generation = "generation:test:1",
stateRevision = 2,
): ExtensionReviewSnapshot {
return Object.freeze({
generation,
stateRevision,
files: Object.freeze([]),
notes: Object.freeze([]),
});
}

describe("review snapshot export example", () => {
test("resolves relative output paths from the command working directory", () => {
expect(resolveSnapshotExportPath(resolve("repo"), "out/review.json")).toBe(
resolve("repo", "out/review.json"),
);
});

test("accepts only the exact generation and state revision captured before async work", () => {
const captured = createTestSnapshot();

expect(snapshotPositionMatches(captured, createTestSnapshot())).toBe(true);
expect(snapshotPositionMatches(captured, createTestSnapshot("generation:test:2", 2))).toBe(
false,
);
expect(snapshotPositionMatches(captured, createTestSnapshot("generation:test:1", 3))).toBe(
false,
);
expect(snapshotPositionMatches(captured, null)).toBe(false);
});
});
70 changes: 70 additions & 0 deletions examples/extensions/review-snapshot-export/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
import { writeFile } from "node:fs/promises";
import { isAbsolute, resolve } from "node:path";
import type { ExtensionReviewSnapshot, HunkExtensionAPI } from "hunkdiff/extension";

/** Resolve one user-entered export path without consulting repo-controlled extension config. */
export function resolveSnapshotExportPath(cwd: string, input: string) {
const trimmed = input.trim();
return isAbsolute(trimmed) ? trimmed : resolve(cwd, trimmed);
}

/** Report whether asynchronous work still belongs to the snapshot it started from. */
export function snapshotPositionMatches(
captured: ExtensionReviewSnapshot,
current: ExtensionReviewSnapshot | null,
) {
return (
current !== null &&
current.generation === captured.generation &&
current.stateRevision === captured.stateRevision
);
}

/** Report whether a filesystem error means the chosen export path already exists. */
function isExistingFileError(error: unknown) {
return (error as { code?: unknown } | null)?.code === "EEXIST";
}

/** Register a JSON exporter for the authoritative saved-note snapshot. */
export default function registerReviewSnapshotExport(hunk: HunkExtensionAPI) {
hunk.registerCommand(
{ id: "export", title: "Export review snapshot…", key: "f9" },
async (ctx) => {
const captured = ctx.review.snapshot();
if (!captured) {
ctx.notify("The current review is unavailable to this command", "warning");
return;
}

const input = await ctx.dialogs.input({
title: "Export review snapshot",
placeholder: "hunk-review-snapshot.json",
});
if (input === null || input.trim() === "") return;

// An agent or another surface can change shared review state while this dialog is open.
// Refuse stale output instead of silently exporting a snapshot the user no longer sees.
if (!snapshotPositionMatches(captured, ctx.review.snapshot())) {
ctx.notify("The review changed while exporting; run the command again", "warning");
return;
}

const outputPath = resolveSnapshotExportPath(ctx.cwd, input);
try {
await writeFile(outputPath, `${JSON.stringify(captured, null, 2)}\n`, {
encoding: "utf8",
flag: "wx",
});
} catch (error) {
if (isExistingFileError(error)) {
ctx.notify(`Refusing to overwrite existing file ${outputPath}`, "warning");
return;
}
throw error;
}
ctx.notify(
`Exported ${captured.notes.length} saved ${captured.notes.length === 1 ? "note" : "notes"} to ${outputPath}`,
);
},
);
}
10 changes: 10 additions & 0 deletions examples/extensions/review-snapshot-export/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
{
"name": "hunk-review-snapshot-export-extension",
"private": true,
"hunk": {
"extensions": [
"./index.ts"
],
"apiVersion": 8
}
}
14 changes: 10 additions & 4 deletions skills/hunk-extensions/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -108,7 +108,8 @@ bad or duplicate id is skipped with a startup notice.
| React to loads, selection, view movement, notes, reloads | `hunk.on(event, handler)` |
| Coordinate with another loaded extension | `hunk.events.emit` / `hunk.events.on` |
| Read user-supplied settings | `hunk.config` (`[extension.<id>]` table) |
| Branch on the API generation (currently `7`) | `hunk.apiVersion` |
| Snapshot stable files and every saved review note | `ctx.review.snapshot()` in a command |
| Branch on the API generation (currently `8`) | `hunk.apiVersion` |

Registration is only valid while the factory runs — Hunk seals the API object
afterwards.
Expand All @@ -129,9 +130,10 @@ transform — gets `ctx.cwd` and `ctx.notify(message, type?)`. A file view's
guarded `selectFile`/`selectHunk`/`revealLine`, the
last landing one exact `(side, line)` near the viewport top), `ctx.commands`
(`isEnabled`/`execute` for public semantic `hunk.*` commands),
`ctx.keyboardModes` (enter/exit/probe this extension's session modes), `ctx.dialogs`
(`confirm`/`select`/`input`, queued and attributed), and `ctx.workspace`
(`readDocument`, `canWriteDocument`, `writeDocument` with consent).
`ctx.keyboardModes` (enter/exit/probe this extension's session modes), `ctx.review`
(deeply immutable snapshots of stable files and complete saved store notes),
`ctx.dialogs` (`confirm`/`select`/`input`, queued and attributed), and
`ctx.workspace` (`readDocument`, `canWriteDocument`, `writeDocument` with consent).
- **Pane components** get frozen `files`, selection, placement, exact dimensions,
optional `currentLine` paint, semantic `theme`, resolved `keybindings`, and
guarded navigation/notification `actions`.
Expand Down Expand Up @@ -177,6 +179,10 @@ Most extension bugs are one of these:
- **Handler state must live outside the component.** Panes unmount when closed;
bridge module-level state into React with `useSyncExternalStore` and immutable
snapshots (`review-triage/index.tsx` is the working version).
- **Use `ctx.review.snapshot()` for complete saved-note state.** `note_created` and
`note_edited` are incremental UI events, not an authoritative collection. Snapshots
include stale and orphaned saved notes, exclude drafts and static sidecar annotations,
and should be re-read before irreversible async work; compare both generation and revision.
- **Retained review controls expire on reload.** An old handler cannot control
replacement content: pane/navigation calls become inert, dialogs cancel, and
workspace reads or not-yet-started writes return `null`/`unavailable`. A
Expand Down
29 changes: 29 additions & 0 deletions src/app/review/producer.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -107,6 +107,10 @@ describe("review producer generations", () => {
test("can detach the previous store until a prepared generation mounts", () => {
const { producer } = createProducer();
producer.attachStore(createReviewStore(producer.getPublication().document));
expect(producer.getPositionedReviewState()).toMatchObject({
generation: producer.getPublication().generation,
state: { stateRevision: 0 },
});
const prepared = producer.preparePublication({
files: [createTestDiffFile({ before: BEFORE, after: AFTER })],
sourceLabel: "/repo",
Expand All @@ -115,11 +119,36 @@ describe("review producer generations", () => {
producer.reservePublication(prepared).commit({ detachStore: true });

expect(producer.getReviewState()).toBeUndefined();
expect(producer.getPositionedReviewState()).toBeUndefined();
expect(() => producer.applyIntent({ type: "filter/set", filter: "stale" })).toThrow(
"no review state",
);
});

test("refuses a store left attached across a generation advance until replacement state mounts", () => {
const { producer } = createProducer();
producer.attachStore(createReviewStore(producer.getPublication().document));
const prepared = producer.preparePublication({
files: [createTestDiffFile({ before: BEFORE, after: AFTER })],
sourceLabel: "/repo",
});

producer.reservePublication(prepared).commit();

expect(producer.getReviewState()).toBeUndefined();
expect(producer.getPositionedReviewState()).toBeUndefined();
expect(() => producer.applyIntent({ type: "filter/set", filter: "retired" })).toThrow(
"no review state",
);

const replacement = createReviewStore(producer.getPublication().document);
producer.attachStore(replacement);
expect(producer.getPositionedReviewState()).toEqual({
generation: producer.getPublication().generation,
state: replacement.getSnapshot(),
});
});

test("refuses stale, foreign, active, and reused publication preparations", () => {
const { producer } = createProducer();
const other = new ReviewProducer({ files: [] }, { producerId: "other" });
Expand Down
Loading
Loading