From 5074131e8dddfb70400990e9338348b12c38f99c Mon Sep 17 00:00:00 2001 From: Ben Vinegar Date: Sat, 22 Aug 2026 16:54:04 -0400 Subject: [PATCH 1/3] feat(extensions): expose authoritative review snapshots --- .changeset/fuzzy-ravens-review.md | 5 + README.md | 3 +- docs/extension-architecture.md | 9 +- docs/extensions.md | 58 ++++++- examples/README.md | 1 + .../review-snapshot-export/README.md | 25 +++ .../review-snapshot-export/index.ts | 70 ++++++++ .../review-snapshot-export/package.json | 10 ++ .../review-snapshot-export-extension.test.ts | 41 +++++ skills/hunk-extensions/SKILL.md | 14 +- src/app/review/producer.test.ts | 29 ++++ src/app/review/producer.ts | 33 +++- src/core/review/selectors.test.ts | 35 ++++ src/core/review/selectors.ts | 15 +- src/extension-api/index.ts | 6 + src/extension-api/types.ts | 90 +++++++++- src/extensions/reviewSnapshot.test.ts | 163 ++++++++++++++++++ src/extensions/reviewSnapshot.ts | 91 ++++++++++ src/extensions/types.ts | 6 + src/ui/App.tsx | 17 ++ src/ui/AppHost.extension-sidebar.test.tsx | 74 +++++++- test/pty/extensions-integration.test.ts | 51 ++++++ test/review-conformance/conformance.test.ts | 20 +++ test/review-conformance/consumers.ts | 7 + .../consumers/extensionReviewSnapshot.ts | 29 ++++ test/review-conformance/snapshotFixtures.ts | 81 +++++++++ test/review-conformance/types.ts | 33 ++++ .../content/docs/docs/extend/extension-api.md | 13 +- 28 files changed, 1008 insertions(+), 21 deletions(-) create mode 100644 .changeset/fuzzy-ravens-review.md create mode 100644 examples/extensions/review-snapshot-export/README.md create mode 100644 examples/extensions/review-snapshot-export/index.ts create mode 100644 examples/extensions/review-snapshot-export/package.json create mode 100644 scripts/review-snapshot-export-extension.test.ts create mode 100644 src/extensions/reviewSnapshot.test.ts create mode 100644 src/extensions/reviewSnapshot.ts create mode 100644 test/review-conformance/consumers/extensionReviewSnapshot.ts create mode 100644 test/review-conformance/snapshotFixtures.ts diff --git a/.changeset/fuzzy-ravens-review.md b/.changeset/fuzzy-ravens-review.md new file mode 100644 index 000000000..debd49b03 --- /dev/null +++ b/.changeset/fuzzy-ravens-review.md @@ -0,0 +1,5 @@ +--- +"hunkdiff": minor +--- + +Let extension commands capture immutable snapshots of stable review files and every saved review note. diff --git a/README.md b/README.md index 474a99883..e83db1698 100644 --- a/README.md +++ b/README.md @@ -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.]` 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. diff --git a/docs/extension-architecture.md b/docs/extension-architecture.md index d94c8f333..9054d0d85 100644 --- a/docs/extension-architecture.md +++ b/docs/extension-architecture.md @@ -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 diff --git a/docs/extensions.md b/docs/extensions.md index d5fc7d58f..d119c8f19 100644 --- a/docs/extensions.md +++ b/docs/extensions.md @@ -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 @@ -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)`, @@ -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. @@ -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. diff --git a/examples/README.md b/examples/README.md index 4c7a6e0d9..3ee85a9d2 100644 --- a/examples/README.md +++ b/examples/README.md @@ -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. diff --git a/examples/extensions/review-snapshot-export/README.md b/examples/extensions/review-snapshot-export/README.md new file mode 100644 index 000000000..f831b6d75 --- /dev/null +++ b/examples/extensions/review-snapshot-export/README.md @@ -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. diff --git a/examples/extensions/review-snapshot-export/index.ts b/examples/extensions/review-snapshot-export/index.ts new file mode 100644 index 000000000..06955261c --- /dev/null +++ b/examples/extensions/review-snapshot-export/index.ts @@ -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}`, + ); + }, + ); +} diff --git a/examples/extensions/review-snapshot-export/package.json b/examples/extensions/review-snapshot-export/package.json new file mode 100644 index 000000000..52d5e01ea --- /dev/null +++ b/examples/extensions/review-snapshot-export/package.json @@ -0,0 +1,10 @@ +{ + "name": "hunk-review-snapshot-export-extension", + "private": true, + "hunk": { + "extensions": [ + "./index.ts" + ], + "apiVersion": 8 + } +} diff --git a/scripts/review-snapshot-export-extension.test.ts b/scripts/review-snapshot-export-extension.test.ts new file mode 100644 index 000000000..a045ce4c3 --- /dev/null +++ b/scripts/review-snapshot-export-extension.test.ts @@ -0,0 +1,41 @@ +import { describe, expect, test } from "bun:test"; +import { resolve } from "node:path"; +import { + resolveSnapshotExportPath, + snapshotPositionMatches, +} from "../examples/extensions/review-snapshot-export"; +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); + }); +}); diff --git a/skills/hunk-extensions/SKILL.md b/skills/hunk-extensions/SKILL.md index 7703e9bf1..bd6e06bb3 100644 --- a/skills/hunk-extensions/SKILL.md +++ b/skills/hunk-extensions/SKILL.md @@ -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.]` 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. @@ -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`. @@ -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 diff --git a/src/app/review/producer.test.ts b/src/app/review/producer.test.ts index 57fb45769..aa05a8886 100644 --- a/src/app/review/producer.test.ts +++ b/src/app/review/producer.test.ts @@ -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", @@ -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" }); diff --git a/src/app/review/producer.ts b/src/app/review/producer.ts index 2073bcc2c..e4552d413 100644 --- a/src/app/review/producer.ts +++ b/src/app/review/producer.ts @@ -119,6 +119,8 @@ export class ReviewProducer { private publication: ReviewPublication; private resourceStore: ReviewResourceStore; private store: ReviewStore | undefined; + /** Generation the attached store was mounted for; unequal means the store is retired. */ + private storeGeneration: string | undefined; private publicationReservation: object | undefined; constructor(input: PublishReviewInput, options: ReviewProducerOptions = {}) { @@ -142,7 +144,7 @@ export class ReviewProducer { getPublicationAddress(): ReviewPublicationAddress { return { generation: this.publication.generation, - stateRevision: this.store?.getSnapshot().stateRevision ?? 0, + stateRevision: this.currentStore()?.getSnapshot().stateRevision ?? 0, }; } @@ -209,7 +211,10 @@ export class ReviewProducer { this.identity = prepared.identity; this.publication = prepared.publication; this.resourceStore = prepared.resourceStore; - if (options.detachStore) this.store = undefined; + if (options.detachStore) { + this.store = undefined; + this.storeGeneration = undefined; + } return this.publication; }; @@ -242,6 +247,7 @@ export class ReviewProducer { */ attachStore(store: ReviewStore) { this.store = store; + this.storeGeneration = this.publication.generation; } /** @@ -252,7 +258,19 @@ export class ReviewProducer { * see exactly what the next intent will be planned against. */ getReviewState() { - return this.store?.getSnapshot(); + return this.currentStore()?.getSnapshot(); + } + + /** + * Pair the current generation with the state attached for that generation. + * + * Reload commits a new publication with its previous store detached, then the matching + * host attaches replacement state. Returning nothing during that interval prevents a + * caller from combining the new generation with state captured from the retired host. + */ + getPositionedReviewState() { + const state = this.currentStore()?.getSnapshot(); + return state ? { generation: this.publication.generation, state } : undefined; } /** @@ -267,7 +285,7 @@ export class ReviewProducer { intent: T, facts: ReviewIntentFacts = {}, ): ReviewIntentOutcomeByType[T["type"]] { - const store = this.store; + const store = this.currentStore(); if (!store) { throw new Error("Review producer has no review state attached."); } @@ -342,7 +360,7 @@ export class ReviewProducer { * annotated navigation would silently find nothing. */ private intentFacts(): ReviewIntentFacts { - const document = this.store?.getSnapshot().document ?? this.publication.document; + const document = this.currentStore()?.getSnapshot().document ?? this.publication.document; const keyByRuntimeId = new Map(document.files.map((file) => [file.runtimeId, file.key])); return { annotations: buildReviewAnnotationIndex( @@ -352,6 +370,11 @@ export class ReviewProducer { }; } + /** Return the attached store only while it belongs to the current publication. */ + private currentStore() { + return this.storeGeneration === this.publication.generation ? this.store : undefined; + } + /** Attach the current generation to one failure so a caller can resynchronize. */ private fail(code: ReviewProducerErrorCode, message: string): ReviewProducerFailure { return { ok: false, code, message, currentGeneration: this.publication.generation }; diff --git a/src/core/review/selectors.test.ts b/src/core/review/selectors.test.ts index b66b6680f..2c9a240cf 100644 --- a/src/core/review/selectors.test.ts +++ b/src/core/review/selectors.test.ts @@ -17,6 +17,7 @@ import { selectReviewFileByKey, selectReviewGapForSelection, selectReviewNavigationFiles, + selectStoredReviewNotes, resolveReviewRevealNoteId, selectRevealTarget, selectVisibleReviewFiles, @@ -158,6 +159,40 @@ describe("reveal selectors", () => { }); describe("note selectors", () => { + test("return every saved note in collection order without drafts or resolution filtering", () => { + const state = { + ...createTestReviewState(["alpha"]), + liveNotes: [ + createTestStoredNote({ id: "live-active", fileKey: "alpha" }), + createTestStoredNote({ id: "live-orphaned", fileKey: "gone", resolution: "orphaned" }), + ], + userNotes: [ + createTestStoredNote({ + id: "user-stale", + fileKey: "alpha", + source: "user", + resolution: "stale", + }), + ], + draftNote: { + id: "draft:1", + fileKey: "alpha", + hunkIndex: 0, + side: "new" as const, + line: 1, + body: "not saved", + }, + }; + + expect( + selectStoredReviewNotes(state).map((entry) => [entry.note.id, entry.resolution]), + ).toEqual([ + ["live-active", "active"], + ["live-orphaned", "orphaned"], + ["user-stale", "stale"], + ]); + }); + test("group notes by the hunk that owns them, not by range containment", () => { const state = { ...createTestReviewState(["alpha", "beta"]), diff --git a/src/core/review/selectors.ts b/src/core/review/selectors.ts index 699879d27..10ea5c12e 100644 --- a/src/core/review/selectors.ts +++ b/src/core/review/selectors.ts @@ -169,9 +169,22 @@ export function selectRevealTarget( return hunk ? reviewCanonicalHunkLine(hunk) : undefined; } +/** + * Return every saved mutable note, live arrival order before reviewer creation order. + * + * Unlike render selectors, this preserves stale and orphaned entries: exporters and other + * authoritative consumers must decide how to report an unplaced note rather than losing it. + * Drafts are separate state and never appear here. + */ +export function selectStoredReviewNotes( + state: Pick, +): ReviewStoredNote[] { + return [...state.liveNotes, ...state.userNotes]; +} + /** Every mutable note currently safe to render, live notes before the reviewer's own. */ function renderableNotes(state: Pick): ReviewNoteV1[] { - return [...state.liveNotes, ...state.userNotes] + return selectStoredReviewNotes(state) .filter(isRenderableStoredReviewNote) .map((entry) => entry.note); } diff --git a/src/extension-api/index.ts b/src/extension-api/index.ts index fe73fd29e..e7038a7fa 100644 --- a/src/extension-api/index.ts +++ b/src/extension-api/index.ts @@ -79,6 +79,12 @@ export type { ExtensionCommandExecutionOptions, ExtensionCommandHandler, ExtensionReviewSelection, + ExtensionReviewControls, + ExtensionReviewSnapshot, + ExtensionReviewSnapshotFile, + ExtensionReviewSnapshotLineAddress, + ExtensionReviewSnapshotNote, + ExtensionReviewSnapshotNoteAnchor, ExtensionConfirmOptions, ExtensionDialogs, ExtensionInputOptions, diff --git a/src/extension-api/types.ts b/src/extension-api/types.ts index 157baf1bc..c5134664a 100644 --- a/src/extension-api/types.ts +++ b/src/extension-api/types.ts @@ -21,7 +21,7 @@ * Extensions can branch on `hunk.apiVersion` so a newer Hunk can keep loading * older extensions without guessing at their expectations. */ -export const HUNK_EXTENSION_API_VERSION = 7; +export const HUNK_EXTENSION_API_VERSION = 8; export type HunkExtensionApiVersion = typeof HUNK_EXTENSION_API_VERSION; export type ExtensionNotifyType = "info" | "warning" | "error"; @@ -1402,6 +1402,92 @@ export interface ExtensionReviewSelection { } | null; } +/** One stable reviewed file in an authoritative extension snapshot. */ +export interface ExtensionReviewSnapshotFile { + /** Stable semantic address within this review, independent of renderer ids and indexes. */ + readonly fileKey: string; + /** Transitional renderer id for navigation inside this exact generation. */ + readonly runtimeId: string; + readonly path: string; + readonly previousPath?: string; + readonly changeKind: "change" | "rename-pure" | "rename-changed" | "new" | "deleted"; + readonly stats: { + readonly additions: number; + readonly deletions: number; + readonly truncated: boolean; + }; + readonly flags: { + readonly untracked: boolean; + readonly binary: boolean; + readonly tooLarge: boolean; + readonly partial: boolean; + }; + /** Digest of the file's renderer-neutral review content. */ + readonly contentIdentity: string; + readonly sourceIdentity?: string; + readonly sourceAttested?: boolean; +} + +/** The one source line a saved review-note anchor prefers. */ +export interface ExtensionReviewSnapshotLineAddress { + readonly side: ExtensionFileSide; + readonly line: number; +} + +/** Complete semantic anchor retained for one saved review note. */ +export interface ExtensionReviewSnapshotNoteAnchor { + readonly oldRange?: readonly [number, number]; + readonly newRange?: readonly [number, number]; + readonly preferred?: ExtensionReviewSnapshotLineAddress; + readonly intersectingHunkIndices: readonly number[]; + readonly ownerHunkIndex?: number; +} + +/** One complete saved note in an authoritative extension review snapshot. */ +export interface ExtensionReviewSnapshotNote { + readonly id: string; + readonly source: "ai" | "agent" | "user"; + readonly originalSource?: string; + readonly fileKey: string; + readonly anchor: ExtensionReviewSnapshotNoteAnchor; + readonly summary: string; + readonly rationale?: string; + readonly markup?: string; + readonly title?: string; + readonly author?: string; + readonly createdAt?: string; + readonly updatedAt?: string; + readonly editable: boolean; + readonly tags?: readonly string[]; + readonly confidence?: "low" | "medium" | "high"; + /** Reconciliation verdict against the snapshot's current document. */ + readonly resolution: "active" | "stale" | "orphaned"; +} + +/** Immutable projection of the authoritative review state at one instant. */ +export interface ExtensionReviewSnapshot { + /** Opaque producer generation; state revisions compare only within this generation. */ + readonly generation: string; + /** ReviewStore revision captured with the rest of this snapshot. */ + readonly stateRevision: number; + /** Every reviewed file in authoritative review/sidebar order, regardless of filtering. */ + readonly files: readonly ExtensionReviewSnapshotFile[]; + /** + * Every note saved in ReviewStore: live-note arrival order, then reviewer-note creation order. + * Drafts and static sidecar annotations that never entered the store are excluded. + */ + readonly notes: readonly ExtensionReviewSnapshotNote[]; +} + +/** Read the authoritative review while one extension command retains authority. */ +export interface ExtensionReviewControls { + /** + * Capture the current immutable review state, or return null after a reload or host teardown. + * Call again before irreversible asynchronous work and compare generation plus stateRevision. + */ + snapshot(): ExtensionReviewSnapshot | null; +} + /** One question put to the user as a modal confirm dialog. */ export interface ExtensionConfirmOptions { title: string; @@ -1617,6 +1703,8 @@ export interface ExtensionCommandContext extends ExtensionContext { readonly sidebars: ExtensionSidebarControls; /** Host-owned selection controls for alternate file presentations. */ fileViews: ExtensionFileViewControls; + /** Capture complete saved review state from the shared ReviewStore. */ + readonly review: ExtensionReviewControls; /** * Where the review was pointing when this command fired. * diff --git a/src/extensions/reviewSnapshot.test.ts b/src/extensions/reviewSnapshot.test.ts new file mode 100644 index 000000000..876045755 --- /dev/null +++ b/src/extensions/reviewSnapshot.test.ts @@ -0,0 +1,163 @@ +import { describe, expect, test } from "bun:test"; +import { + createTestReviewState, + createTestStoredNote, +} from "../../test/helpers/review-store-helpers"; +import { buildExtensionReviewSnapshot } from "./reviewSnapshot"; + +describe("buildExtensionReviewSnapshot", () => { + test("projects every saved note and stable file identity without including drafts", () => { + const live = createTestStoredNote({ + id: "live:1", + fileKey: "alpha", + line: 2, + resolution: "stale", + summary: "Check this edge case.", + }); + live.note.originalSource = "mcp"; + live.note.rationale = "The fallback changes behavior."; + live.note.tags = ["correctness"]; + live.note.confidence = "high"; + live.note.createdAt = "2026-08-19T12:00:00.000Z"; + + const user = createTestStoredNote({ + id: "user:1", + fileKey: "retired-file", + source: "user", + resolution: "orphaned", + summary: "Keep this even when its file disappears.", + }); + user.note.editable = true; + user.note.anchor = { + oldRange: [4, 4], + preferred: { side: "old", line: 4 }, + intersectingHunkIndices: [], + ownerHunkIndex: 0, + }; + user.note.markup = "Keep this"; + user.note.title = "Retired finding"; + user.note.author = "reviewer"; + user.note.updatedAt = "2026-08-19T13:00:00.000Z"; + + const initial = createTestReviewState([ + { + key: "alpha", + path: "src/alpha.ts", + contentIdentity: "sha256:alpha", + sourceIdentity: "git:alpha", + sourceAttested: true, + }, + ]); + const state = { + ...initial, + document: { + files: [ + { + ...initial.document.files[0]!, + previousPath: "src/old-alpha.ts", + changeKind: "rename-changed" as const, + }, + ], + }, + stateRevision: 7, + liveNotes: [live], + userNotes: [user], + draftNote: { + id: "draft:1", + fileKey: "alpha", + hunkIndex: 0, + side: "new" as const, + line: 3, + body: "unfinished", + }, + }; + + const snapshot = buildExtensionReviewSnapshot("producer:4", state); + + expect(snapshot).toEqual({ + generation: "producer:4", + stateRevision: 7, + files: [ + { + fileKey: "alpha", + runtimeId: "alpha", + path: "src/alpha.ts", + previousPath: "src/old-alpha.ts", + changeKind: "rename-changed", + stats: { additions: 2, deletions: 2, truncated: false }, + flags: { untracked: false, binary: false, tooLarge: false, partial: false }, + contentIdentity: "sha256:alpha", + sourceIdentity: "git:alpha", + sourceAttested: true, + }, + ], + notes: [ + { + id: "live:1", + source: "agent", + originalSource: "mcp", + fileKey: "alpha", + anchor: { + newRange: [2, 2], + preferred: { side: "new", line: 2 }, + intersectingHunkIndices: [0], + ownerHunkIndex: 0, + }, + summary: "Check this edge case.", + rationale: "The fallback changes behavior.", + createdAt: "2026-08-19T12:00:00.000Z", + editable: false, + tags: ["correctness"], + confidence: "high", + resolution: "stale", + }, + { + id: "user:1", + source: "user", + fileKey: "retired-file", + anchor: { + oldRange: [4, 4], + preferred: { side: "old", line: 4 }, + intersectingHunkIndices: [], + ownerHunkIndex: 0, + }, + summary: "Keep this even when its file disappears.", + markup: "Keep this", + title: "Retired finding", + author: "reviewer", + updatedAt: "2026-08-19T13:00:00.000Z", + editable: true, + resolution: "orphaned", + }, + ], + }); + expect(snapshot.notes.some((note) => note.id === "draft:1")).toBe(false); + }); + + test("copies and freezes nested public data without freezing or mutating ReviewStore state", () => { + const entry = createTestStoredNote({ id: "live:1", fileKey: "alpha", line: 2 }); + entry.note.tags = ["one"]; + const state = { ...createTestReviewState(["alpha"]), liveNotes: [entry] }; + + const snapshot = buildExtensionReviewSnapshot("producer:1", state); + const note = snapshot.notes[0]!; + + expect(Object.isFrozen(snapshot)).toBe(true); + expect(Object.isFrozen(snapshot.files)).toBe(true); + expect(Object.isFrozen(snapshot.files[0])).toBe(true); + expect(Object.isFrozen(snapshot.files[0]!.stats)).toBe(true); + expect(Object.isFrozen(snapshot.notes)).toBe(true); + expect(Object.isFrozen(note)).toBe(true); + expect(Object.isFrozen(note.anchor)).toBe(true); + expect(Object.isFrozen(note.anchor.preferred)).toBe(true); + expect(Object.isFrozen(note.anchor.newRange)).toBe(true); + expect(Object.isFrozen(note.anchor.intersectingHunkIndices)).toBe(true); + expect(Object.isFrozen(note.tags)).toBe(true); + + expect(() => (note.anchor.intersectingHunkIndices as number[]).push(9)).toThrow(); + expect(() => (note.tags as string[]).push("two")).toThrow(); + expect(entry.note.anchor.intersectingHunkIndices).toEqual([0]); + expect(entry.note.tags).toEqual(["one"]); + expect(Object.isFrozen(entry.note)).toBe(false); + }); +}); diff --git a/src/extensions/reviewSnapshot.ts b/src/extensions/reviewSnapshot.ts new file mode 100644 index 000000000..85fb06712 --- /dev/null +++ b/src/extensions/reviewSnapshot.ts @@ -0,0 +1,91 @@ +/** + * Projects shared review state into the immutable public snapshot extensions consume. + * + * The projection copies authoritative file identities, note fields, and resolved anchors; + * it never re-derives placement or freezes objects owned by ReviewStore. Complete saved-note + * ordering comes from the shared selector, including stale and orphaned entries. + */ +import { selectStoredReviewNotes } from "../core/review/selectors"; +import type { ReviewStoredNote, ReviewState } from "../core/review/state"; +import type { + ExtensionReviewSnapshot, + ExtensionReviewSnapshotFile, + ExtensionReviewSnapshotNote, + ExtensionReviewSnapshotNoteAnchor, +} from "../extension-api/types"; + +/** Copy one optional inclusive line range into a frozen public tuple. */ +function copyRange(range: readonly [number, number] | undefined) { + return range ? Object.freeze([range[0], range[1]] as const) : undefined; +} + +/** Copy one resolved note anchor without interpreting its placement. */ +function projectAnchor(note: ReviewStoredNote): ExtensionReviewSnapshotNoteAnchor { + const { anchor } = note.note; + return Object.freeze({ + ...(anchor.oldRange ? { oldRange: copyRange(anchor.oldRange) } : {}), + ...(anchor.newRange ? { newRange: copyRange(anchor.newRange) } : {}), + ...(anchor.preferred + ? { + preferred: Object.freeze({ + side: anchor.preferred.side, + line: anchor.preferred.line, + }), + } + : {}), + intersectingHunkIndices: Object.freeze([...anchor.intersectingHunkIndices]), + ...(anchor.ownerHunkIndex !== undefined ? { ownerHunkIndex: anchor.ownerHunkIndex } : {}), + }); +} + +/** Copy one stored note and its reconciliation verdict into the public contract. */ +function projectNote(entry: ReviewStoredNote): ExtensionReviewSnapshotNote { + const note = entry.note; + return Object.freeze({ + id: note.id, + source: note.source, + ...(note.originalSource !== undefined ? { originalSource: note.originalSource } : {}), + fileKey: note.fileKey, + anchor: projectAnchor(entry), + summary: note.summary, + ...(note.rationale !== undefined ? { rationale: note.rationale } : {}), + ...(note.markup !== undefined ? { markup: note.markup } : {}), + ...(note.title !== undefined ? { title: note.title } : {}), + ...(note.author !== undefined ? { author: note.author } : {}), + ...(note.createdAt !== undefined ? { createdAt: note.createdAt } : {}), + ...(note.updatedAt !== undefined ? { updatedAt: note.updatedAt } : {}), + editable: note.editable, + ...(note.tags !== undefined ? { tags: Object.freeze([...note.tags]) } : {}), + ...(note.confidence !== undefined ? { confidence: note.confidence } : {}), + resolution: entry.resolution, + }); +} + +/** Copy one semantic file address and exporter-relevant status into the public contract. */ +function projectFile(file: ReviewState["document"]["files"][number]): ExtensionReviewSnapshotFile { + return Object.freeze({ + fileKey: file.key, + runtimeId: file.runtimeId, + path: file.path, + ...(file.previousPath !== undefined ? { previousPath: file.previousPath } : {}), + changeKind: file.changeKind, + stats: Object.freeze({ ...file.stats }), + flags: Object.freeze({ ...file.flags }), + contentIdentity: file.contentIdentity, + ...(file.sourceIdentity !== undefined ? { sourceIdentity: file.sourceIdentity } : {}), + ...(file.sourceAttested !== undefined ? { sourceAttested: file.sourceAttested } : {}), + }); +} + +/** Build one deeply immutable extension snapshot from the current authoritative state. */ +export function buildExtensionReviewSnapshot( + generation: string, + state: ReviewState, +): ExtensionReviewSnapshot { + return Object.freeze({ + generation, + stateRevision: state.stateRevision, + files: Object.freeze(state.document.files.map(projectFile)), + notes: Object.freeze(selectStoredReviewNotes(state).map(projectNote)), + }); +} diff --git a/src/extensions/types.ts b/src/extensions/types.ts index a830ba43c..1d676159d 100644 --- a/src/extensions/types.ts +++ b/src/extensions/types.ts @@ -48,7 +48,13 @@ export type { ExtensionKeyboardMode, ExtensionKeyboardModeKeyResult, ExtensionLineHighlighter, + ExtensionReviewControls, ExtensionReviewNote, + ExtensionReviewSnapshot, + ExtensionReviewSnapshotFile, + ExtensionReviewSnapshotLineAddress, + ExtensionReviewSnapshotNote, + ExtensionReviewSnapshotNoteAnchor, ExtensionNotifyType, ExtensionPane, ExtensionPaneControls, diff --git a/src/ui/App.tsx b/src/ui/App.tsx index 568b33f54..f51e40126 100644 --- a/src/ui/App.tsx +++ b/src/ui/App.tsx @@ -41,6 +41,7 @@ import { emitExtensionEvent, toReadOnlyFileViews, } from "../extensions/events"; +import { buildExtensionReviewSnapshot } from "../extensions/reviewSnapshot"; import { writeExtensionTrust } from "../extensions/trust"; import type { ExtensionCommandContext, @@ -614,6 +615,18 @@ export function App({ lineCursor: getActiveLineCursor(), }); }, [getExtensionFileViews]); + /** Mint authoritative review snapshot controls for one extension command invocation. */ + const createExtensionReviewControls = useCallback(() => { + const lease = createReviewCapabilityLease(); + return { + snapshot() { + if (!lease.isLive()) return null; + const positioned = reviewProducer?.getPositionedReviewState(); + if (!positioned) return null; + return buildExtensionReviewSnapshot(positioned.generation, positioned.state); + }, + }; + }, [createReviewCapabilityLease, reviewProducer]); /** Read the live internal selection id independently from the frozen public selection. */ const getSelectedFileId = useCallback( () => extensionSelectionInputsRef.current.getSelection().fileId, @@ -992,6 +1005,9 @@ export function App({ sidebars: panes, fileViews: createFileViewControls(registered.extensionId), highlights: createLineHighlightControls(registered.extensionId), + // Reads the shared store directly, returning copied immutable state while this + // command still owns the current review generation. + review: createExtensionReviewControls(), // Snapshot semantics: built when the key fires, so the handler sees // where the review was at that moment, even if it awaits and the user // navigates on. @@ -1026,6 +1042,7 @@ export function App({ [ createExtensionDialogs, createExtensionNavigation, + createExtensionReviewControls, createFileViewControls, createKeyboardModeControls, createLineHighlightControls, diff --git a/src/ui/AppHost.extension-sidebar.test.tsx b/src/ui/AppHost.extension-sidebar.test.tsx index f2fdebaa1..1254b4cd5 100644 --- a/src/ui/AppHost.extension-sidebar.test.tsx +++ b/src/ui/AppHost.extension-sidebar.test.tsx @@ -1,5 +1,5 @@ import { execSync } from "node:child_process"; -import { mkdtempSync, readFileSync, writeFileSync } from "node:fs"; +import { existsSync, mkdtempSync, readFileSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { afterEach, describe, expect, test } from "bun:test"; @@ -373,6 +373,78 @@ describe("extension sidebar views", () => { }); }); + test("a command handler snapshots complete saved notes from the shared review store", async () => { + const repo = createTestRepo("hunk-ext-review-snapshot-"); + const extDir = createTempDir("hunk-ext-review-snapshot-ext-"); + const snapshotPath = join(extDir, "snapshot.json"); + const extPath = join(extDir, "ext.ts"); + writeFileSync( + extPath, + `import { writeFileSync } from "node:fs";\n` + + `export default function (hunk) {\n` + + ` hunk.registerCommand({ id: "snapshot", title: "Snapshot review", key: "y" }, (ctx) => {\n` + + ` const snapshot = ctx.review.snapshot();\n` + + ` writeFileSync(${JSON.stringify(snapshotPath)}, JSON.stringify(snapshot));\n` + + ` });\n` + + `}\n`, + ); + + const bootstrap = await launchWithExtension(repo, extPath); + await withAppHost(bootstrap, async (setup) => { + await flushUntil( + setup, + () => setup.captureCharFrame().includes("alpha.txt"), + "the review to render before authoring a note", + ); + + await act(async () => { + await setup.mockInput.typeText("c"); + }); + await flushUntil( + setup, + () => setup.captureCharFrame().includes("Draft note"), + "the note editor to open", + ); + await act(async () => { + await setup.mockInput.typeText("Export this saved note."); + await setup.mockInput.pressKeys(["\u001b[115;5u"]); + }); + await flushUntil( + setup, + () => setup.captureCharFrame().includes("Your note"), + "the user note to save", + ); + + await act(async () => { + await setup.mockInput.typeText("y"); + }); + await flushUntil(setup, () => existsSync(snapshotPath), "the extension snapshot to write"); + + const snapshot = JSON.parse(readFileSync(snapshotPath, "utf8")) as { + generation: string; + stateRevision: number; + files: Array<{ fileKey: string; runtimeId: string; path: string }>; + notes: Array<{ + source: string; + fileKey: string; + summary: string; + anchor: { preferred?: { side: string; line: number } }; + }>; + }; + expect(snapshot.generation).toMatch(/^generation:p.+:\d+$/); + expect(snapshot.stateRevision).toBeGreaterThan(0); + expect(snapshot.files.map((file) => file.path)).toEqual(["alpha.txt", "beta.txt"]); + expect(snapshot.files.every((file) => file.fileKey !== file.runtimeId)).toBe(true); + expect(snapshot.notes).toHaveLength(1); + expect(snapshot.notes[0]).toMatchObject({ + source: "user", + fileKey: snapshot.files[0]!.fileKey, + summary: "Export this saved note.", + anchor: { preferred: { side: "new", line: 1 } }, + }); + }); + }); + test("a command handler reports no current line when the marker is off", async () => { const repo = createTestRepo("hunk-ext-selection-line-off-"); const extDir = createTempDir("hunk-ext-selection-line-off-ext-"); diff --git a/test/pty/extensions-integration.test.ts b/test/pty/extensions-integration.test.ts index 05a4d6240..1844feda2 100644 --- a/test/pty/extensions-integration.test.ts +++ b/test/pty/extensions-integration.test.ts @@ -8,6 +8,9 @@ const harness = createPtyHarness(); const REVIEW_TRIAGE_EXTENSION = resolve( fileURLToPath(new URL("../../examples/extensions/review-triage", import.meta.url)), ); +const REVIEW_SNAPSHOT_EXPORT_EXTENSION = resolve( + fileURLToPath(new URL("../../examples/extensions/review-snapshot-export", import.meta.url)), +); const VIM_NAVIGATION_EXTENSION = resolve( fileURLToPath(new URL("../../examples/extensions/vim-navigation", import.meta.url)), ); @@ -626,6 +629,54 @@ describe("PTY extensions", () => { } }); + test("the real review snapshot example exports a saved user note", async () => { + const configHome = harness.createIsolatedConfigHome(); + const fixture = harness.createTwoFileRepoFixture(); + const outputPath = join(fixture.dir, "review-snapshot.json"); + const session = await harness.launchHunk({ + args: ["diff", "--mode", "stack", "--extension", REVIEW_SNAPSHOT_EXPORT_EXTENSION], + cwd: fixture.dir, + cols: 140, + rows: 30, + env: { XDG_CONFIG_HOME: configHome }, + }); + + try { + await session.waitForText(/alpha\.ts/, { timeout: 20_000 }); + await harness.ensureKeyboardIsLive(session); + await session.press("c"); + await session.waitForText(/Draft note/, { timeout: 5_000 }); + await session.type("Publish this exact note."); + await session.type("\x13"); + await session.waitForText(/Your note/, { timeout: 5_000 }); + + await session.press("f9"); + await session.waitForText(/Export review snapshot/, { timeout: 5_000 }); + await session.type(outputPath); + await session.press("enter"); + await session.waitForText(/Exported 1 saved note/, { timeout: 5_000 }); + + const snapshot = JSON.parse(readFileSync(outputPath, "utf8")) as { + generation: string; + stateRevision: number; + files: Array<{ fileKey: string; path: string }>; + notes: Array<{ source: string; fileKey: string; summary: string }>; + }; + expect(snapshot.generation).toMatch(/^generation:/); + expect(snapshot.stateRevision).toBeGreaterThan(0); + expect(snapshot.files.some((file) => file.path === "alpha.ts")).toBe(true); + expect(snapshot.notes).toEqual([ + expect.objectContaining({ + source: "user", + fileKey: snapshot.files.find((file) => file.path === "alpha.ts")!.fileKey, + summary: "Publish this exact note.", + }), + ]); + } finally { + session.close(); + } + }); + test("the real review-triage extension loads as a folder extension and exposes its menu commands", async () => { const configHome = harness.createIsolatedConfigHome(); const fixture = harness.createRepoExtensionFixture(TRANSFORM_EXTENSION_SOURCE); diff --git a/test/review-conformance/conformance.test.ts b/test/review-conformance/conformance.test.ts index 5114e64d1..5485e0f15 100644 --- a/test/review-conformance/conformance.test.ts +++ b/test/review-conformance/conformance.test.ts @@ -15,6 +15,7 @@ import { REVIEW_GEOMETRY_CONSUMERS, REVIEW_NAVIGATION_CONSUMERS, REVIEW_ORDERING_CONSUMERS, + REVIEW_SNAPSHOT_CONSUMERS, REVIEW_WIRE_CONSUMERS, } from "./consumers"; import { REVIEW_EVENT_FIXTURES } from "./eventFixtures"; @@ -22,6 +23,7 @@ import { REVIEW_GEOMETRY_FIXTURES } from "./geometryFixtures"; import { REVIEW_NAVIGATION_FIXTURES } from "./navigationFixtures"; import { REVIEW_NOTE_BODY_FIXTURES } from "./noteBodies"; import { REVIEW_NOTE_SIZE_FIXTURES } from "./noteSize"; +import { REVIEW_SNAPSHOT_FIXTURES } from "./snapshotFixtures"; import { REVIEW_PRODUCER_ORDER_FIXTURES, REVIEW_PUBLICATION_ORDER_FIXTURES, @@ -46,6 +48,7 @@ const REQUIRED_FINDINGS = [ "C1", "C4", "D1", + "EXT1", ]; describe("review conformance corpus", () => { @@ -63,6 +66,9 @@ describe("review conformance corpus", () => { "core publication ordering", "broker review mirror", ]); + expect(REVIEW_SNAPSHOT_CONSUMERS.map((consumer) => consumer.name)).toEqual([ + "extension review snapshot", + ]); expect(REVIEW_WIRE_CONSUMERS.map((consumer) => consumer.name)).toEqual([ "review wire protocol", ]); @@ -80,6 +86,7 @@ describe("review conformance corpus", () => { ...REVIEW_PRODUCER_ORDER_FIXTURES.flatMap((fixture) => fixture.findings), ...REVIEW_WIRE_FIXTURES.flatMap((fixture) => fixture.findings), ...REVIEW_EVENT_FIXTURES.flatMap((fixture) => fixture.findings), + ...REVIEW_SNAPSHOT_FIXTURES.flatMap((fixture) => fixture.findings), ...(REVIEW_NOTE_SIZE_FIXTURES.length > 0 ? ["D1"] : []), ]); @@ -107,6 +114,16 @@ for (const consumer of REVIEW_GEOMETRY_CONSUMERS) { }); } +for (const consumer of REVIEW_SNAPSHOT_CONSUMERS) { + describe(`review snapshot conformance: ${consumer.name}`, () => { + for (const fixture of REVIEW_SNAPSHOT_FIXTURES) { + test(`${fixture.id} (${fixture.findings.join(", ")})`, () => { + expect(consumer.project(fixture)).toEqual(fixture.expected); + }); + } + }); +} + describe("review conformance: empty note bodies", () => { for (const fixture of REVIEW_NOTE_BODY_FIXTURES) { test(`${fixture.id} is ${fixture.blank ? "blank" : "a note"}`, () => { @@ -204,6 +221,9 @@ describe("review conformance: producer ordering", () => { const verdicts = fixture.steps.map((step, index) => { if (step.kind === "reload") { producer.publish({ files, sourceLabel: "/repo" }); + // A generation owns its own store. The real host mounts replacement state after + // publishing, so the producer consumer must do the same before a later state step. + producer.attachStore(createReviewStore(producer.getPublication().document)); } else { // Any real state change advances the store's revision; the filter is the // cheapest one that does not depend on what the fixture's files contain. diff --git a/test/review-conformance/consumers.ts b/test/review-conformance/consumers.ts index 41a307dd7..72790c2ff 100644 --- a/test/review-conformance/consumers.ts +++ b/test/review-conformance/consumers.ts @@ -10,6 +10,7 @@ import { brokerMirrorOrderingConsumer } from "./consumers/brokerMirror"; import { browserReviewSurfaceEventConsumer } from "./consumers/browserReviewSurface"; import { reviewEventProtocolConsumer } from "./consumers/reviewEventProtocol"; import { coreModelConsumer } from "./consumers/coreModel"; +import { extensionReviewSnapshotConsumer } from "./consumers/extensionReviewSnapshot"; import { coreOrderingConsumer } from "./consumers/coreOrdering"; import { intentPlannerNavigationConsumer } from "./consumers/intentPlanner"; import { reviewProducerConsumer } from "./consumers/reviewProducer"; @@ -21,6 +22,7 @@ import type { ReviewGeometryConsumer, ReviewNavigationConsumer, ReviewOrderingConsumer, + ReviewSnapshotConsumer, ReviewWireConsumer, } from "./types"; @@ -30,6 +32,11 @@ export const REVIEW_GEOMETRY_CONSUMERS: readonly ReviewGeometryConsumer[] = [ reviewProducerConsumer, ]; +/** Consumers that export complete authoritative saved-note state. */ +export const REVIEW_SNAPSHOT_CONSUMERS: readonly ReviewSnapshotConsumer[] = [ + extensionReviewSnapshotConsumer, +]; + /** * Consumers of the shared navigation semantics. * diff --git a/test/review-conformance/consumers/extensionReviewSnapshot.ts b/test/review-conformance/consumers/extensionReviewSnapshot.ts new file mode 100644 index 000000000..fe3981bf5 --- /dev/null +++ b/test/review-conformance/consumers/extensionReviewSnapshot.ts @@ -0,0 +1,29 @@ +import { buildExtensionReviewSnapshot } from "../../../src/extensions/reviewSnapshot"; +import type { ReviewSnapshotConsumer } from "../types"; + +/** Drive fixtures through the real public extension-snapshot projection. */ +export const extensionReviewSnapshotConsumer: ReviewSnapshotConsumer = { + name: "extension review snapshot", + phase: "extension API v8", + project(fixture) { + const snapshot = buildExtensionReviewSnapshot(fixture.generation, fixture.build()); + return { + generation: snapshot.generation, + stateRevision: snapshot.stateRevision, + files: snapshot.files.map(({ fileKey, contentIdentity }) => ({ + fileKey, + contentIdentity, + })), + notes: snapshot.notes.map((note) => ({ + id: note.id, + fileKey: note.fileKey, + resolution: note.resolution, + ...(note.anchor.preferred ? { preferred: { ...note.anchor.preferred } } : {}), + intersectingHunkIndices: [...note.anchor.intersectingHunkIndices], + ...(note.anchor.ownerHunkIndex !== undefined + ? { ownerHunkIndex: note.anchor.ownerHunkIndex } + : {}), + })), + }; + }, +}; diff --git a/test/review-conformance/snapshotFixtures.ts b/test/review-conformance/snapshotFixtures.ts new file mode 100644 index 000000000..1aba42a84 --- /dev/null +++ b/test/review-conformance/snapshotFixtures.ts @@ -0,0 +1,81 @@ +import { createTestReviewState, createTestStoredNote } from "../helpers/review-store-helpers"; +import type { ReviewSnapshotFixture } from "./types"; + +/** Complete saved-note cases that incremental extension events cannot reconstruct. */ +export const REVIEW_SNAPSHOT_FIXTURES: readonly ReviewSnapshotFixture[] = [ + { + id: "complete-saved-note-state", + findings: ["EXT1"], + description: + "live, user, stale, and orphaned notes survive in collection order while a draft stays out", + generation: "generation:conformance:3", + build: () => ({ + ...createTestReviewState([ + { key: "alpha", contentIdentity: "content:alpha:v2" }, + { key: "beta", contentIdentity: "content:beta:v1" }, + ]), + stateRevision: 6, + liveNotes: [ + createTestStoredNote({ id: "live", fileKey: "alpha", line: 2 }), + createTestStoredNote({ + id: "orphaned", + fileKey: "retired", + line: 1, + resolution: "orphaned", + }), + ], + userNotes: [ + createTestStoredNote({ + id: "user-stale", + fileKey: "beta", + hunkIndex: 1, + line: 12, + source: "user", + resolution: "stale", + }), + ], + draftNote: { + id: "draft", + fileKey: "alpha", + hunkIndex: 0, + side: "new" as const, + line: 3, + body: "not saved", + }, + }), + expected: { + generation: "generation:conformance:3", + stateRevision: 6, + files: [ + { fileKey: "alpha", contentIdentity: "content:alpha:v2" }, + { fileKey: "beta", contentIdentity: "content:beta:v1" }, + ], + notes: [ + { + id: "live", + fileKey: "alpha", + resolution: "active", + preferred: { side: "new", line: 2 }, + intersectingHunkIndices: [0], + ownerHunkIndex: 0, + }, + { + id: "orphaned", + fileKey: "retired", + resolution: "orphaned", + preferred: { side: "new", line: 1 }, + intersectingHunkIndices: [0], + ownerHunkIndex: 0, + }, + { + id: "user-stale", + fileKey: "beta", + resolution: "stale", + preferred: { side: "new", line: 12 }, + intersectingHunkIndices: [1], + ownerHunkIndex: 1, + }, + ], + }, + }, +]; diff --git a/test/review-conformance/types.ts b/test/review-conformance/types.ts index 77ed4b26f..3581e0b51 100644 --- a/test/review-conformance/types.ts +++ b/test/review-conformance/types.ts @@ -22,6 +22,7 @@ import type { } from "../../src/core/review/generationOrder"; import type { ReviewIntent } from "../../src/core/review/intents"; import type { ReviewSelectionScope } from "../../src/core/review/navigation"; +import type { ReviewState } from "../../src/core/review/state"; import type { ReviewNoteV1 } from "../../src/core/review/types"; import type { HunkReviewPublicationBodyV1 } from "../../src/session/reviewHttpProtocol"; import type { DiffFile } from "../../src/core/changeset/model"; @@ -102,6 +103,38 @@ export interface ReviewGeometryConsumer { project: (fixture: ReviewGeometryFixture) => ReviewGeometryProjection; } +/** Renderer-neutral facts the authoritative extension snapshot must preserve. */ +export interface ReviewSnapshotProjection { + generation: string; + stateRevision: number; + files: Array<{ fileKey: string; contentIdentity: string }>; + notes: Array<{ + id: string; + fileKey: string; + resolution: "active" | "stale" | "orphaned"; + preferred?: ConformanceLineAddress; + intersectingHunkIndices: number[]; + ownerHunkIndex?: number; + }>; +} + +/** One hand-authored complete-note fixture for snapshot consumers. */ +export interface ReviewSnapshotFixture { + id: string; + findings: string[]; + description: string; + generation: string; + build: () => ReviewState; + expected: ReviewSnapshotProjection; +} + +/** One real projection of authoritative review snapshots. */ +export interface ReviewSnapshotConsumer { + name: string; + phase: string; + project: (fixture: ReviewSnapshotFixture) => ReviewSnapshotProjection; +} + /** * One position in a fixture's review, addressed the way a fixture can state it. * diff --git a/website/src/content/docs/docs/extend/extension-api.md b/website/src/content/docs/docs/extend/extension-api.md index d73b6c628..4c14fba18 100644 --- a/website/src/content/docs/docs/extend/extension-api.md +++ b/website/src/content/docs/docs/extend/extension-api.md @@ -7,7 +7,7 @@ The extension factory receives one API object. Registration calls are only valid ## `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, terminal-command observation, and live navigation/dialogs in lifecycle and bus handlers; version 5 added line highlighters and line-granular navigation (`revealLine`); version 4 added keyboard modes and docked panes, with API-v3 sidebar names remaining as deprecated aliases. +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 lifecycle and bus handlers; version 5 added line highlighters and line-granular navigation (`revealLine`); version 4 added keyboard modes and docked panes, with API-v3 sidebar names remaining as deprecated aliases. ## `hunk.configureSession(options)` @@ -159,6 +159,7 @@ The handler fires when the key is pressed outside modal UI (dialogs, menus, and - `ctx.fileViews.select(viewId)` / `toggle(viewId)` / `isActive(viewId)` — controls a matching [file preview](/docs/extend/file-previews/) for the current file; `select(null)` restores raw diff. - `ctx.fileViews.refresh(viewId, options?)` — marks that view's prepared layouts stale so a stateful view re-derives; every file presenting it re-lays out, keeping its current rows visible until the replacement resolves. Pass `{ fileId }` to scope the invalidation to one reviewed file's presentation of the view. - `ctx.fileViews.enterMode(viewId)` / `exitMode()` / `isModeActive(viewId)` — starts, stops, or checks an [interactive preview](/docs/extend/file-previews/#interactive-previews). Entering selects the view and returns whether its mode started. +- `ctx.review.snapshot()` — captures stable file identities and every saved note from the authoritative shared ReviewStore. - `ctx.selection` — where the review was pointing when the command fired. - `ctx.navigation` — moves the review stream. - `ctx.dialogs` — asks the user, below. @@ -181,6 +182,14 @@ hunk.registerCommand( `selection.file` is a frozen view, identical to a pane's `files` entries; it is `null` when filtering hides the selected file or when no files are visible. `selection.hunkIndex` is `null` whenever `file` is, or when the file has no hunks. `selection.currentLine` is the one-based `{ side, line }` source address carrying the current-line marker, or `null` when that marker is off or the review has not settled on a rendered line. It belongs to this file and hunk, uses Hunk's canonical new-side address for context rows, and can be passed directly to `navigation.revealLine`. The values are captured when the command fires, so an async handler keeps the selection it started from. +### Authoritative review snapshots + +`ctx.review.snapshot()` returns a deeply immutable value, or `null` after the command's review generation expires. It contains the opaque producer `generation`, the shared store's `stateRevision`, every file in authoritative review/sidebar order, and every saved live or reviewer note. File records expose stable `fileKey`, transient navigation `runtimeId`, content identity, status, and paths. Note records preserve their complete old/new anchor and `active`, `stale`, or `orphaned` reconciliation status. + +Drafts are not saved and are excluded. Static sidecar annotations that never entered ReviewStore remain on changeset file views rather than in the snapshot. Saved notes are ordered by live arrival and then reviewer creation, including orphaned notes an exporter may need to move into a summary. + +For irreversible asynchronous work, capture once, prepare the request, then call `snapshot()` again and compare both `generation` and `stateRevision`. Revisions compare only within one generation. The [`review-snapshot-export` example](https://github.com/modem-dev/hunk/tree/main/examples/extensions/review-snapshot-export) demonstrates the complete JSON export and stale-work check. + `ctx.navigation.selectFile(fileId)`, `selectHunk(fileId, hunkIndex)`, and `revealLine(fileId, side, line)` route through the same guarded review controller as a pane'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. `revealLine` is the finest target: a hunk hundreds of lines tall has one anchor, so `selectHunk` can leave the line you meant pages below the viewport. `line` is 1-based on `side` as the patch numbers it, so a context line answers to either side's number. The revealed line lands a little below the viewport top — where every other Hunk reveal lands — and becomes the current line, pairing with a mark from `registerLineHighlighter`. A line no rendered row carries (inside a collapsed gap, absent from a partial patch, or with the current-line marker off) falls back to the hunk containing it; a line no hunk covers, a side outside `"old"`/`"new"`, and a line number that is not a positive whole number are refused with a warning naming the extension. @@ -292,7 +301,7 @@ Subscribe to a lifecycle or UI event. Handlers may be async; Hunk never blocks t - `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. - `command_executed` reports stable command ids after terminal dispatch from a key, menu, or `ctx.commands.execute`. Detached async extension work may still be running; the event observes the accepted action rather than promise settlement. It follows remapped keys; browser/session review intents and widget-owned Escape, Enter, note-editor Ctrl-S, and F10 menu navigation are not terminal commands. - `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. +- `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. Use them for incremental reactions; use `ctx.review.snapshot()` when a command needs the complete current saved-note record. - `shutdown` handlers get 250ms before Hunk exits anyway; treat it as best-effort flushing. UI authority has already been revoked, so shutdown is for releasing extension-owned resources rather than navigation or dialogs. ## `hunk.events` From 4c525187e0b96835158cc85a6c3e13c4d601711c Mon Sep 17 00:00:00 2001 From: Ben Vinegar Date: Sat, 22 Aug 2026 17:45:43 -0400 Subject: [PATCH 2/3] test(extensions): colocate snapshot exporter coverage --- .../extensions/review-snapshot-export/index.test.ts | 7 ++----- 1 file changed, 2 insertions(+), 5 deletions(-) rename scripts/review-snapshot-export-extension.test.ts => examples/extensions/review-snapshot-export/index.test.ts (86%) diff --git a/scripts/review-snapshot-export-extension.test.ts b/examples/extensions/review-snapshot-export/index.test.ts similarity index 86% rename from scripts/review-snapshot-export-extension.test.ts rename to examples/extensions/review-snapshot-export/index.test.ts index a045ce4c3..e0e481d62 100644 --- a/scripts/review-snapshot-export-extension.test.ts +++ b/examples/extensions/review-snapshot-export/index.test.ts @@ -1,10 +1,7 @@ import { describe, expect, test } from "bun:test"; import { resolve } from "node:path"; -import { - resolveSnapshotExportPath, - snapshotPositionMatches, -} from "../examples/extensions/review-snapshot-export"; -import type { ExtensionReviewSnapshot } from "../src/extension-api/types"; +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( From a9c2cfef50ebcb9606ce4aab566c081425034194 Mon Sep 17 00:00:00 2001 From: Ben Vinegar Date: Sat, 22 Aug 2026 21:45:16 -0400 Subject: [PATCH 3/3] feat(examples): add review note navigator --- docs/extensions.md | 5 +- examples/README.md | 1 + .../review-note-navigator/README.md | 19 +++ .../review-note-navigator/index.test.ts | 142 ++++++++++++++++++ .../extensions/review-note-navigator/index.ts | 120 +++++++++++++++ .../review-note-navigator/package.json | 10 ++ package.json | 2 +- skills/hunk-extensions/SKILL.md | 2 + test/pty/extensions-integration.test.ts | 60 ++++++++ .../content/docs/docs/extend/extension-api.md | 2 +- 10 files changed, 360 insertions(+), 3 deletions(-) create mode 100644 examples/extensions/review-note-navigator/README.md create mode 100644 examples/extensions/review-note-navigator/index.test.ts create mode 100644 examples/extensions/review-note-navigator/index.ts create mode 100644 examples/extensions/review-note-navigator/package.json diff --git a/docs/extensions.md b/docs/extensions.md index d119c8f19..fcdcedf7d 100644 --- a/docs/extensions.md +++ b/docs/extensions.md @@ -1444,7 +1444,10 @@ 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. +example writes the complete value as JSON and demonstrates this stale-work check. The +[`review-note-navigator`](../examples/extensions/review-note-navigator/) example composes +the complete note inventory, authoritative anchors, a selector dialog, and guarded navigation +to currently visible files. #### Navigating the review diff --git a/examples/README.md b/examples/README.md index 3ee85a9d2..3c941d0be 100644 --- a/examples/README.md +++ b/examples/README.md @@ -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-note-navigator/`](extensions/review-note-navigator/) inventories every saved ReviewStore note and navigates to visible authoritative anchors. - [`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. diff --git a/examples/extensions/review-note-navigator/README.md b/examples/extensions/review-note-navigator/README.md new file mode 100644 index 000000000..79e302cea --- /dev/null +++ b/examples/extensions/review-note-navigator/README.md @@ -0,0 +1,19 @@ +# Review note navigator extension + +Lists every note currently saved in Hunk's shared ReviewStore, then navigates to a selected visible note's authoritative source anchor. The example shows how `ctx.review.snapshot()` complements dialogs and live navigation: selection and note events alone cannot recover notes that already existed before the extension began listening. + +Run it directly from this checkout: + +```bash +bun run src/main.tsx -- diff --extension ./examples/extensions/review-note-navigator +``` + +Save one or more review notes, then run **Extensions → Navigate saved review note…** (`F8`). Each choice includes its reconciliation status, file, preferred line, side, and summary. + +- Active notes reveal their current source line. +- Stale notes reveal the last authoritative anchor Hunk retained. +- Orphaned notes remain visible in the inventory but produce a warning because they have no current review location. +- A note whose file is hidden by Hunk's current file filter remains in the inventory, but guarded navigation refuses the hidden target with a warning; clear the filter and retry. +- Drafts and static sidecar annotations that never entered ReviewStore are intentionally absent. + +The command captures the complete note inventory before opening its selector. After the user chooses, it reads the authoritative snapshot again and resolves the selected note by stable note id, so an edit made by another review surface cannot make it navigate using an obsolete anchor. A reload cancels the dialog and retires the command's review controls. diff --git a/examples/extensions/review-note-navigator/index.test.ts b/examples/extensions/review-note-navigator/index.test.ts new file mode 100644 index 000000000..25ae15f98 --- /dev/null +++ b/examples/extensions/review-note-navigator/index.test.ts @@ -0,0 +1,142 @@ +import { describe, expect, test } from "bun:test"; +import type { ExtensionReviewSnapshot } from "hunkdiff/extension"; +import { + buildReviewNoteChoices, + navigateToSavedReviewNote, + selectedReviewNoteChoice, +} from "./index"; + +/** Build a frozen public snapshot with active, stale, and orphaned note shapes. */ +function createTestSnapshot(): ExtensionReviewSnapshot { + return Object.freeze({ + generation: "generation:test:1", + stateRevision: 3, + files: Object.freeze([ + Object.freeze({ + fileKey: "alpha-key", + runtimeId: "runtime-alpha", + path: "src/alpha.ts", + changeKind: "change" as const, + stats: Object.freeze({ additions: 2, deletions: 1, truncated: false }), + flags: Object.freeze({ + untracked: false, + binary: false, + tooLarge: false, + partial: false, + }), + contentIdentity: "sha256:alpha", + }), + ]), + notes: Object.freeze([ + Object.freeze({ + id: "note:active", + source: "user" as const, + fileKey: "alpha-key", + anchor: Object.freeze({ + preferred: Object.freeze({ side: "new" as const, line: 12 }), + intersectingHunkIndices: Object.freeze([0]), + ownerHunkIndex: 0, + }), + summary: "Check the\nreturn value.", + editable: true, + resolution: "active" as const, + }), + Object.freeze({ + id: "note:stale", + source: "agent" as const, + fileKey: "alpha-key", + anchor: Object.freeze({ + preferred: Object.freeze({ side: "old" as const, line: 8 }), + intersectingHunkIndices: Object.freeze([0]), + ownerHunkIndex: 0, + }), + summary: "Recheck after the refactor.", + editable: false, + resolution: "stale" as const, + }), + Object.freeze({ + id: "note:orphaned", + source: "user" as const, + fileKey: "retired-key", + anchor: Object.freeze({ intersectingHunkIndices: Object.freeze([]) }), + summary: "Keep the deleted fallback in mind.", + editable: true, + resolution: "orphaned" as const, + }), + ]), + }); +} + +describe("review note navigator example", () => { + test("labels every saved note in authoritative order with status and location", () => { + expect(buildReviewNoteChoices(createTestSnapshot())).toEqual([ + { + label: "1. [active] src/alpha.ts:12 (new) — Check the return value.", + noteId: "note:active", + }, + { + label: "2. [stale] src/alpha.ts:8 (old) — Recheck after the refactor.", + noteId: "note:stale", + }, + { + label: "3. [orphaned] retired file retired-key — Keep the deleted fallback in mind.", + noteId: "note:orphaned", + }, + ]); + }); + + test("keeps duplicate summaries selectable through unique ordinal labels", () => { + const snapshot = createTestSnapshot(); + const choices = buildReviewNoteChoices({ + ...snapshot, + notes: Object.freeze([ + snapshot.notes[0]!, + Object.freeze({ ...snapshot.notes[0]!, id: "note:second" }), + ]), + }); + + expect(new Set(choices.map((choice) => choice.label)).size).toBe(2); + expect(choices.map((choice) => choice.noteId)).toEqual(["note:active", "note:second"]); + }); + + test("resolves the host-sanitized label through its stable ordinal", () => { + const snapshot = createTestSnapshot(); + const choices = buildReviewNoteChoices({ + ...snapshot, + files: Object.freeze([Object.freeze({ ...snapshot.files[0]!, path: "evil\u001b[31m.ts" })]), + }); + + expect(choices[0]!.label).toContain("\u001b[31m"); + expect( + selectedReviewNoteChoice(choices, "1. [active] evil.ts:12 (new) — Check the return value.") + ?.noteId, + ).toBe("note:active"); + expect(selectedReviewNoteChoice(choices, "not an option")).toBeUndefined(); + }); + + test("lands on the owner hunk when an expanded-gap line can no longer reveal exactly", () => { + const snapshot = createTestSnapshot(); + const calls: string[] = []; + + const gapNote = { + ...snapshot.notes[0]!, + anchor: { + ...snapshot.notes[0]!.anchor, + intersectingHunkIndices: [], + ownerHunkIndex: 0, + }, + }; + + navigateToSavedReviewNote( + { + selectFile: (fileId) => calls.push(`file:${fileId}`), + selectHunk: (fileId, hunkIndex) => calls.push(`hunk:${fileId}:${hunkIndex}`), + revealLine: (fileId, side, line) => calls.push(`line:${fileId}:${side}:${line}`), + }, + snapshot.files[0]!, + gapNote, + ); + + expect(calls).toEqual(["hunk:runtime-alpha:0", "line:runtime-alpha:new:12"]); + }); +}); diff --git a/examples/extensions/review-note-navigator/index.ts b/examples/extensions/review-note-navigator/index.ts new file mode 100644 index 000000000..a983b6e9b --- /dev/null +++ b/examples/extensions/review-note-navigator/index.ts @@ -0,0 +1,120 @@ +import type { + ExtensionCommandContext, + ExtensionReviewNavigation, + ExtensionReviewSnapshot, + ExtensionReviewSnapshotFile, + ExtensionReviewSnapshotNote, + HunkExtensionAPI, +} from "hunkdiff/extension"; + +export interface ReviewNoteChoice { + label: string; + noteId: string; +} + +/** Resolve a sanitized dialog answer through the unique ordinal prefix we control. */ +export function selectedReviewNoteChoice(choices: readonly ReviewNoteChoice[], selected: string) { + const ordinal = /^(\d+)\.\s/u.exec(selected)?.[1]; + if (ordinal === undefined) return undefined; + return choices[Number(ordinal) - 1]; +} + +/** Collapse note text into one compact selector label. */ +function oneLineSummary(summary: string) { + return summary.replace(/\s+/gu, " ").trim() || "Untitled note"; +} + +/** Describe one note's preferred source location without deriving a replacement anchor. */ +function noteLocation(note: ExtensionReviewSnapshotNote) { + const preferred = note.anchor.preferred; + return preferred ? `:${preferred.line} (${preferred.side})` : ""; +} + +/** Build unique selector choices in the authoritative saved-note order. */ +export function buildReviewNoteChoices(snapshot: ExtensionReviewSnapshot): ReviewNoteChoice[] { + const pathByFileKey = new Map(snapshot.files.map((file) => [file.fileKey, file.path])); + return snapshot.notes.map((note, index) => { + const path = pathByFileKey.get(note.fileKey) ?? `retired file ${note.fileKey}`; + return { + label: `${index + 1}. [${note.resolution}] ${path}${noteLocation(note)} — ${oneLineSummary(note.summary)}`, + noteId: note.id, + }; + }); +} + +/** Preserve an authoritative hunk fallback while requesting the note's exact line. */ +export function navigateToSavedReviewNote( + navigation: ExtensionReviewNavigation, + file: ExtensionReviewSnapshotFile, + note: ExtensionReviewSnapshotNote, +) { + const ownerHunkIndex = note.anchor.ownerHunkIndex; + if (ownerHunkIndex !== undefined) { + navigation.selectHunk(file.runtimeId, ownerHunkIndex); + } + + const preferred = note.anchor.preferred; + if (preferred) { + // The line may belong to an expanded gap that has since collapsed. Selecting its + // authoritative owner first leaves a useful landing even when exact reveal is refused. + navigation.revealLine(file.runtimeId, preferred.side, preferred.line); + } else if (ownerHunkIndex === undefined) { + navigation.selectFile(file.runtimeId); + } +} + +/** Navigate to the current authoritative location for one saved note. */ +function revealSavedNote(ctx: ExtensionCommandContext, noteId: string) { + const current = ctx.review.snapshot(); + if (!current) { + ctx.notify("The review changed; open the note navigator again", "warning"); + return; + } + + const note = current.notes.find((candidate) => candidate.id === noteId); + if (!note) { + ctx.notify("That saved note no longer exists", "warning"); + return; + } + if (note.resolution === "orphaned") { + ctx.notify("That note is orphaned and has no current review location", "warning"); + return; + } + + const file = current.files.find((candidate) => candidate.fileKey === note.fileKey); + if (!file) { + ctx.notify("That note's file is no longer in the review", "warning"); + return; + } + + navigateToSavedReviewNote(ctx.navigation, file, note); +} + +/** Register a complete saved-note picker backed by authoritative review snapshots. */ +export default function registerReviewNoteNavigator(hunk: HunkExtensionAPI) { + hunk.registerCommand( + { id: "navigate", title: "Navigate saved review note…", key: "f8" }, + async (ctx) => { + const snapshot = ctx.review.snapshot(); + if (!snapshot) { + ctx.notify("The current review is unavailable to this command", "warning"); + return; + } + + const choices = buildReviewNoteChoices(snapshot); + if (choices.length === 0) { + ctx.notify("This review has no saved notes"); + return; + } + + const selected = await ctx.dialogs.select({ + title: "Navigate saved review note", + options: choices.map((choice) => choice.label), + }); + if (selected === null) return; + + const choice = selectedReviewNoteChoice(choices, selected); + if (choice) revealSavedNote(ctx, choice.noteId); + }, + ); +} diff --git a/examples/extensions/review-note-navigator/package.json b/examples/extensions/review-note-navigator/package.json new file mode 100644 index 000000000..c7e86abb0 --- /dev/null +++ b/examples/extensions/review-note-navigator/package.json @@ -0,0 +1,10 @@ +{ + "name": "hunk-review-note-navigator-extension", + "private": true, + "hunk": { + "extensions": [ + "./index.ts" + ], + "apiVersion": 8 + } +} diff --git a/package.json b/package.json index 5b729e50d..235c051d5 100644 --- a/package.json +++ b/package.json @@ -82,7 +82,7 @@ "changeset:status": "bunx @changesets/cli@2.31.0 status", "release:version": "bunx @changesets/cli@2.31.0 version", "prepare": "simple-git-hooks", - "test": "\"${npm_execpath:-bun}\" test ./src ./packages ./scripts ./test/cli ./test/session", + "test": "\"${npm_execpath:-bun}\" test ./src ./packages ./scripts ./examples ./test/cli ./test/session", "test:theme-contrast": "bun test src/ui/themes.test.ts --test-name-pattern contrast", "test:integration": "\"${npm_execpath:-bun}\" test ./test/pty", "test:tty-smoke": "HUNK_RUN_TTY_SMOKE=1 \"${npm_execpath:-bun}\" test ./test/smoke", diff --git a/skills/hunk-extensions/SKILL.md b/skills/hunk-extensions/SKILL.md index bd6e06bb3..2fa75dfeb 100644 --- a/skills/hunk-extensions/SKILL.md +++ b/skills/hunk-extensions/SKILL.md @@ -183,6 +183,8 @@ Most extension bugs are one of these: `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. + `review-note-navigator` shows how to join stable note ids and file keys back to guarded + navigation after awaiting a selector; file filters can still refuse hidden targets. - **Retained review controls expire on reload.** An old handler cannot control replacement content: pane/navigation calls become inert, dialogs cancel, and workspace reads or not-yet-started writes return `null`/`unavailable`. A diff --git a/test/pty/extensions-integration.test.ts b/test/pty/extensions-integration.test.ts index 1844feda2..ac4467832 100644 --- a/test/pty/extensions-integration.test.ts +++ b/test/pty/extensions-integration.test.ts @@ -8,6 +8,9 @@ const harness = createPtyHarness(); const REVIEW_TRIAGE_EXTENSION = resolve( fileURLToPath(new URL("../../examples/extensions/review-triage", import.meta.url)), ); +const REVIEW_NOTE_NAVIGATOR_EXTENSION = resolve( + fileURLToPath(new URL("../../examples/extensions/review-note-navigator", import.meta.url)), +); const REVIEW_SNAPSHOT_EXPORT_EXTENSION = resolve( fileURLToPath(new URL("../../examples/extensions/review-snapshot-export", import.meta.url)), ); @@ -629,6 +632,63 @@ describe("PTY extensions", () => { } }); + test("the real review note navigator inventories and reveals a saved user note", async () => { + const configHome = harness.createIsolatedConfigHome(); + const fixture = harness.createBottomClampedRepoFixture(); + const session = await harness.launchHunk({ + args: ["diff", "--mode", "stack", "--extension", REVIEW_NOTE_NAVIGATOR_EXTENSION], + cwd: fixture.dir, + cols: 140, + rows: 22, + env: { XDG_CONFIG_HOME: configHome }, + }); + + try { + await session.waitForText(/first\.ts/, { timeout: 20_000 }); + await harness.ensureKeyboardIsLive(session); + await session.press("f8"); + await session.waitForText(/This review has no saved notes/, { timeout: 5_000 }); + + await session.press("c"); + await session.waitForText(/Draft note/, { timeout: 5_000 }); + await session.type("Navigate to this exact note."); + await session.type("\x13"); + await session.waitForText(/Your note/, { timeout: 5_000 }); + + await session.press("]"); + await harness.waitForSnapshot( + session, + (text) => text.includes("second.ts") && !text.includes("Navigate to this exact note."), + 5_000, + ); + + await session.press("f8"); + const picker = await harness.waitForSnapshot( + session, + (text) => + text.includes("Navigate saved review note") && + text.includes("[active]") && + text.includes("Navigate to this exact note."), + 5_000, + ); + expect(picker).toContain("first.ts"); + expect(picker).toMatch(/\((?:old|new)\)/); + + await session.press("enter"); + const revealed = await harness.waitForSnapshot( + session, + (text) => + !text.includes("Navigate saved review note") && + text.includes("first.ts") && + text.includes("Your note"), + 5_000, + ); + expect(revealed).toContain("Navigate to this exact note."); + } finally { + session.close(); + } + }); + test("the real review snapshot example exports a saved user note", async () => { const configHome = harness.createIsolatedConfigHome(); const fixture = harness.createTwoFileRepoFixture(); diff --git a/website/src/content/docs/docs/extend/extension-api.md b/website/src/content/docs/docs/extend/extension-api.md index 4c14fba18..882eecf25 100644 --- a/website/src/content/docs/docs/extend/extension-api.md +++ b/website/src/content/docs/docs/extend/extension-api.md @@ -188,7 +188,7 @@ hunk.registerCommand( Drafts are not saved and are excluded. Static sidecar annotations that never entered ReviewStore remain on changeset file views rather than in the snapshot. Saved notes are ordered by live arrival and then reviewer creation, including orphaned notes an exporter may need to move into a summary. -For irreversible asynchronous work, capture once, prepare the request, then call `snapshot()` again and compare both `generation` and `stateRevision`. Revisions compare only within one generation. The [`review-snapshot-export` example](https://github.com/modem-dev/hunk/tree/main/examples/extensions/review-snapshot-export) demonstrates the complete JSON export and stale-work check. +For irreversible asynchronous work, capture once, prepare the request, then call `snapshot()` again and compare both `generation` and `stateRevision`. Revisions compare only within one generation. The [`review-snapshot-export` example](https://github.com/modem-dev/hunk/tree/main/examples/extensions/review-snapshot-export) demonstrates the complete JSON export and stale-work check. The [`review-note-navigator` example](https://github.com/modem-dev/hunk/tree/main/examples/extensions/review-note-navigator) combines the complete inventory and authoritative anchors with a selector dialog and guarded navigation to currently visible files. `ctx.navigation.selectFile(fileId)`, `selectHunk(fileId, hunkIndex)`, and `revealLine(fileId, side, line)` route through the same guarded review controller as a pane'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.