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

Filter by extension

Filter by extension

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

Show history-style commit metadata in the review-info panel when opening a commit from interactive `hunk log`, with a copyable right-aligned revision and public pane clipboard/theme support.
8 changes: 4 additions & 4 deletions docs/extension-architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@ object and registry collection (`packages/hunk/src/extensions/runExtension.ts`):
that list: the UI pane planner loads its bundled files and delegated review-info registrations
through `runExtensionFactory`.

Git, built-in file navigation, and delegated change-request identity use the public
Git, built-in file navigation, and change-request or history-commit identity use the public
`registerVcsAdapter` and `registerPane` paths. The external [Hunk Lens](https://github.com/modem-dev/hunk-lens)
extension exercises current-line pane paint through that same public contract.

Expand Down Expand Up @@ -106,9 +106,9 @@ the planner resolves it to an integer target before applying bounds and lets a
session-local divider drag override that automatic size.

`packages/hunk/src/ui/components/panes/ExtensionPane.tsx` mounts panes with guarded actions,
immutable delegated review metadata, and failure containment. The fixed three-row
`hunk:review-info` top pane uses one border row above two metadata rows and is available only for
delegated change requests, so ordinary reviews spend no geometry on it. `DiffPane` exposes optional current-line paint — the row
immutable review metadata, and failure containment. The fixed three-row `hunk:review-info` top
pane uses one border row above two metadata rows and is available for delegated change requests or
commits selected from interactive history, so ordinary reviews spend no geometry on it. `DiffPane` exposes optional current-line paint — the row
painter plus the public `{ side, line }` address — without publishing Pierre
rows, plans, cursor keys, or caches. Deprecated sidebar APIs
normalize into this same registry and layout path.
Expand Down
30 changes: 18 additions & 12 deletions docs/extensions.md
Original file line number Diff line number Diff line change
Expand Up @@ -302,8 +302,9 @@ and retires the replaced instance at that explicit ownership boundary.

### `hunk.apiVersion`

The API generation this Hunk speaks (currently `19`). Branch on it if you want
one file to support several Hunk versions. Version 19 adds provider-owned history
The API generation this Hunk speaks (currently `20`). Branch on it if you want
one file to support several Hunk versions. Version 20 adds optional commit timestamps to review
metadata, pane clipboard actions, and the `theme.copyAction` paint token; version 19 adds provider-owned history
enumeration and review planning; version 18 lets lifecycle and custom-event handlers request
a host-owned review reload; version 17 adds structured review metadata to delegated patch
commands and projects it into pane availability and component props; version 16 adds pane-wide
Expand Down Expand Up @@ -370,15 +371,18 @@ A delegated built-in `patch` command may include a provider-neutral `review` des
strings with an optional credential-free HTTPS URL. Hunk rejects unknown fields, control
characters, invalid types, unsafe URLs, fields over their byte limits, and descriptors over 4 KiB,
then copies and freezes the accepted value. `provider` and change-request `id` allow 256 bytes;
`repository`, `author`, `base`, `head`, and `revision` allow 512; `title` and `url` allow 2 KiB.
Change requests may also carry `state` (`open`, `closed`, or `merged`) and boolean `draft`. Exit results and delegation to any built-in other than
`repository`, `author`, `base`, `head`, and `revision` allow 512; `authoredAt` allows 128;
`title` and `url` allow 2 KiB. Change requests may also carry `state` (`open`, `closed`, or
`merged`) and boolean `draft`; commits may carry a parseable `authoredAt` date-time. Exit results and delegation to any built-in other than
`patch` cannot carry review metadata. An ordinary `hunk patch` has no descriptor.

The descriptor describes the review source rather than its diff contents: it stays on the app
bootstrap and does not enter changeset transforms or `ReviewDocumentV1`. Refreshing the same
file-backed patch preserves it, including watch and manual refresh; an explicit reload to a
different patch path or input kind clears it. Live-session list, context, and review JSON snapshots
project the same optional descriptor from registration metadata; it remains outside the semantic
different patch path or input kind clears it. Opening a commit from interactive `hunk log` attaches
a commit descriptor from the selected provider history row and preserves it while refreshing that
exact provider review request. Live-session list, context, and review JSON snapshots project the
same optional descriptor from registration metadata; it remains outside the semantic
review document and grants no remote reload or provider capability.

Delegation cannot target another extension command or change extension bootstrap
Expand Down Expand Up @@ -872,9 +876,9 @@ registration owns that slot and later claims are skipped with a warning.
`replaces` may also name another pane by its fully qualified
`"<extensionId>:<paneId>"` key, and Hunk follows those replacement chains.
Both `available(context)` and the mounted component receive `review`: immutable
metadata supplied by a delegated patch command, or `null` for ordinary reviews.
The bundled `hunk:review-info` top pane uses this to show change-request identity
without taking any rows when no change-request descriptor exists. Pane extensions that read
metadata supplied by a delegated patch command or an interactive history selection, or `null` for
ordinary reviews. The bundled `hunk:review-info` top pane uses this to show change-request and
commit identity without taking any rows when no supported descriptor exists. Pane extensions that read
`review` should declare `"hunk": { "apiVersion": 17 }` in their manifest so older Hunk versions
refuse them cleanly instead of mounting with an incomplete prop contract.

Expand Down Expand Up @@ -912,7 +916,7 @@ The component receives fresh props as the app changes:

| Prop | What it is |
| ------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `review` | immutable delegated review metadata (`change-request`, `commit`, or `comparison`), or `null` for ordinary reviews |
| `review` | immutable review-source metadata (`change-request`, `commit`, or `comparison`), or `null` for ordinary reviews |
| `files` | the visible reviewed files, review-stream order, filtered, frozen views (each carries `changeType`, `statsTruncated`, and `hunks` summaries beside the usual file fields) |
| `selectedFileId` | the selected file, or `null` |
| `selectedHunkIndex` | the selected hunk within that file, or `null` |
Expand All @@ -922,7 +926,7 @@ The component receives fresh props as the app changes:
| `currentLine` | selected-row painter plus `{ side, line }` when the registration opts in, otherwise `null` |
| `theme` | hex color tokens from the active theme, updated on theme switch |
| `keybindings` | the current command bindings, resolved from defaults and the user's `[keybindings]` table |
| `actions` | navigation and notifications the pane may trigger |
| `actions` | navigation, clipboard, and notifications the pane may trigger |

API-v3 sidebar names remain as deprecated aliases: use `registerPane`,
`ExtensionPane*`, `ctx.panes`, and `replaces: "hunk:files"` in new code.
Expand All @@ -931,7 +935,9 @@ API-v3 sidebar names remain as deprecated aliases: use `registerPane`,
`actions.revealLine(fileId, side, line)` route through the same review
controller as the built-in files pane and the keyboard shortcuts, so the review
stream scrolls, selection updates, and the `selection_changed` event fires
exactly as if the user had clicked a built-in row. `actions.notify(message,
exactly as if the user had clicked a built-in row. `actions.copyText(text)` uses the terminal's
OSC 52 clipboard integration and returns `false` when unavailable. Extensions that call it or read
`theme.copyAction` should declare `"hunk": { "apiVersion": 20 }` in their manifest. `actions.notify(message,
type?)` shows a toast attributed to your extension. An action given a file id
that is not currently visible is refused with a warning rather than corrupting
the selection. A pane's `actions` carry the same navigation methods a command
Expand Down
84 changes: 83 additions & 1 deletion packages/hunk/src/app/delegatedReview.test.ts
Original file line number Diff line number Diff line change
@@ -1,16 +1,35 @@
import { describe, expect, test } from "bun:test";
import type { ExtensionReviewDescriptor } from "../extension-api/types";
import { reviewDescriptorAfterReload } from "./delegatedReview";
import { reviewDescriptorAfterReload, reviewDescriptorResourceCwd } from "./delegatedReview";

const review: ExtensionReviewDescriptor = {
kind: "change-request",
provider: "GitHub",
title: "PR title",
id: "#123",
};
const commitReview: ExtensionReviewDescriptor = {
kind: "commit",
provider: "Git",
title: "Commit title",
revision: "abc1234",
};
const patch = (file?: string) => ({ kind: "patch" as const, file, options: {} });

describe("delegated review reload identity", () => {
test("uses the repository root for VCS reviews launched from a subdirectory", () => {
expect(
reviewDescriptorResourceCwd(
{ kind: "show", ref: "revision-a", options: { vcs: "git" } },
"/repo/packages/example",
"/repo",
),
).toBe("/repo");
expect(
reviewDescriptorResourceCwd(patch("review.diff"), "/repo/packages/example", "/repo"),
).toBe("/repo/packages/example");
});

test("preserves metadata while refreshing the same patch path", () => {
expect(
reviewDescriptorAfterReload(
Expand Down Expand Up @@ -45,6 +64,69 @@ describe("delegated review reload identity", () => {
expect(reviewDescriptorAfterReload(patch("-"), "/", review, patch("-"), "/")).toBeUndefined();
});

test("preserves history commit metadata only for the same provider review request", () => {
const show = (ref: string, vcs = "git") => ({
kind: "show" as const,
ref,
options: { vcs },
});
const range = (from: string, to: string, vcs = "git") => ({
kind: "vcs" as const,
rangeEndpoints: { from, to },
staged: false,
options: { vcs },
});

expect(
reviewDescriptorAfterReload(
show("revision-a"),
"/repo",
commitReview,
show("revision-a"),
"/repo",
),
).toBe(commitReview);
expect(
reviewDescriptorAfterReload(
range("parent-a", "revision-a"),
"/repo",
commitReview,
range("parent-a", "revision-a"),
"/repo",
),
).toBe(commitReview);
expect(
reviewDescriptorAfterReload(
show("revision-a"),
"/repo",
commitReview,
show("revision-b"),
"/repo",
),
).toBeUndefined();
expect(
reviewDescriptorAfterReload(
show("revision-a"),
"/repo",
commitReview,
show("revision-a", "jj"),
"/repo",
),
).toBeUndefined();
});

test("does not preserve change-request metadata onto a VCS review", () => {
expect(
reviewDescriptorAfterReload(
{ kind: "show", ref: "revision-a", options: { vcs: "git" } },
"/repo",
review,
{ kind: "show", ref: "revision-a", options: { vcs: "git" } },
"/repo",
),
).toBeUndefined();
});

test("does not invent metadata for ordinary patches", () => {
expect(
reviewDescriptorAfterReload(
Expand Down
44 changes: 39 additions & 5 deletions packages/hunk/src/app/delegatedReview.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,18 +2,46 @@ import { resolve } from "node:path";
import type { ExtensionReviewDescriptor } from "../extension-api/types";
import { resolveCanonicalPath } from "../core/run/paths";
import type { CliInput } from "../core/run/commandInputs";
import { isVcsReviewInput } from "../core/vcs";

/** Resolve the file identity already used by session reload bounds. */
function patchFileIdentity(input: CliInput, cwd: string): string | undefined {
if (input.kind !== "patch" || !input.file || input.file === "-") return undefined;
return resolveCanonicalPath(resolve(cwd, input.file));
}

/** Use the authoritative repository root as stable VCS identity across subdirectory reloads. */
export function reviewDescriptorResourceCwd(
input: CliInput,
startupCwd: string,
repoRoot: string | undefined,
): string {
return isVcsReviewInput(input) ? (repoRoot ?? startupCwd) : startupCwd;
}

/** Resolve one exact provider review identity used by commits opened from interactive history. */
function historyCommitInputIdentity(input: CliInput, cwd: string): string | undefined {
const root = resolveCanonicalPath(cwd);
if (input.kind === "show" && input.ref && !input.pathspecs?.length) {
return JSON.stringify([root, input.options.vcs ?? null, "show", input.ref]);
}
if (input.kind === "vcs" && input.rangeEndpoints && !input.pathspecs?.length) {
return JSON.stringify([
root,
input.options.vcs ?? null,
"range",
input.rangeEndpoints.from,
input.rangeEndpoints.to,
]);
}
return undefined;
}

/**
* Preserve delegated review metadata only while reloading the same patch resource.
* Preserve review metadata only while reloading the same underlying review resource.
*
* The canonical patch path is the reload boundary's existing input identity: changes to the file
* refresh the same remote review, while a different or non-file input starts an unrelated review.
* File-backed delegated patches use their canonical path. History-selected commits use the exact
* provider review request so refresh cannot transfer their identity to another revision or backend.
*/
export function reviewDescriptorAfterReload(
previousInput: CliInput,
Expand All @@ -23,8 +51,14 @@ export function reviewDescriptorAfterReload(
nextCwd: string,
): ExtensionReviewDescriptor | undefined {
if (!previousReview) return undefined;
const previousIdentity = patchFileIdentity(previousInput, previousCwd);
return previousIdentity && previousIdentity === patchFileIdentity(nextInput, nextCwd)
const previousPatchIdentity = patchFileIdentity(previousInput, previousCwd);
if (previousPatchIdentity && previousPatchIdentity === patchFileIdentity(nextInput, nextCwd)) {
return previousReview;
}
if (previousReview.kind !== "commit") return undefined;
const previousCommitIdentity = historyCommitInputIdentity(previousInput, previousCwd);
return previousCommitIdentity &&
previousCommitIdentity === historyCommitInputIdentity(nextInput, nextCwd)
? previousReview
: undefined;
}
2 changes: 1 addition & 1 deletion packages/hunk/src/core/bootstrap.ts
Original file line number Diff line number Diff line change
Expand Up @@ -55,7 +55,7 @@ export interface AppBootstrap<ExtensionState = unknown> {
initialCopyDecorations?: boolean;
initialCursorLine?: CursorLine;
startupNotices?: readonly StartupNotice[];
/** Validated metadata attached only by an extension-delegated patch review. */
/** Validated metadata describing a delegated or history-selected review source. */
review?: ExtensionReviewDescriptor;
viewPreferencesConfigPath?: string;
/** The user's `[keybindings]` table, resolved against command defaults in App. */
Expand Down
14 changes: 13 additions & 1 deletion packages/hunk/src/core/reviewDescriptor.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,8 +24,13 @@ describe("delegated review descriptor validation", () => {
provider: "GitHub",
title: "Commit",
revision: "abc1234",
authoredAt: "2026-01-01T00:00:00Z",
}),
).toMatchObject({ kind: "commit", revision: "abc1234" });
).toMatchObject({
kind: "commit",
revision: "abc1234",
authoredAt: "2026-01-01T00:00:00Z",
});
expect(
validateExtensionReviewDescriptor({
kind: "comparison",
Expand All @@ -44,6 +49,13 @@ describe("delegated review descriptor validation", () => {
{ ...review, url: "http://github.com/modem-dev/hunk/pull/123" },
{ ...review, title: "é".repeat(1025) },
{ ...review, repository: "x".repeat(513) },
{
kind: "commit",
provider: "GitHub",
title: "Commit",
revision: "abc1234",
authoredAt: "yesterday",
},
{
...review,
provider: "p".repeat(256),
Expand Down
8 changes: 7 additions & 1 deletion packages/hunk/src/core/reviewDescriptor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ const REVIEW_DESCRIPTOR_FIELD_LIMITS = Object.freeze({
base: 512,
head: 512,
revision: 512,
authoredAt: 128,
});

/** Measure a public descriptor string in transport bytes rather than UTF-16 code units. */
Expand Down Expand Up @@ -92,7 +93,7 @@ export function validateExtensionReviewDescriptor(value: unknown): ExtensionRevi
kind === "change-request"
? ["id", "repository", "author", "base", "head", "state", "draft"]
: kind === "commit"
? ["revision", "author"]
? ["revision", "author", "authoredAt"]
: ["base", "head"];
const allowed = new Set([...common, ...kindFields]);
const ownKeys = Reflect.ownKeys(value);
Expand Down Expand Up @@ -130,13 +131,18 @@ export function validateExtensionReviewDescriptor(value: unknown): ExtensionRevi
...(draft === undefined ? {} : { draft }),
};
} else if (kind === "commit") {
const authoredAt = validateDescriptorString(candidate, "authoredAt", false);
if (authoredAt !== undefined && Number.isNaN(Date.parse(authoredAt))) {
throw new Error("delegate review authoredAt must be a valid timestamp");
}
descriptor = {
kind,
provider,
title,
...(url === undefined ? {} : { url }),
revision: validateDescriptorString(candidate, "revision", true)!,
...copyOptionalDescriptorFields(candidate, ["author"]),
...(authoredAt === undefined ? {} : { authoredAt }),
};
} else {
descriptor = {
Expand Down
Loading
Loading