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/extension-event-surface.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"hunkdiff": minor
---

Add extension UI lifecycle events, sidebar controls for event handlers, and an inter-extension event bus.
2 changes: 1 addition & 1 deletion docs/extension-architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -47,7 +47,7 @@ load issue and costs only that extension. The rules themselves are stated in
## One registry, one apply path

Registrations (themes, file languages, VCS adapters, changeset transforms,
sidebar views, commands, lifecycle events) collect into one
sidebar views, commands, lifecycle/UI events, and inter-extension bus listeners) collect into one
`ExtensionRegistry` (`src/extensions/types.ts`) and are resolved/applied
through `src/extensions/apply.ts` on both startup and reload. A factory that
throws is rolled back to its pre-run registration counts
Expand Down
61 changes: 50 additions & 11 deletions docs/extensions.md
Original file line number Diff line number Diff line change
Expand Up @@ -766,16 +766,26 @@ your extension and the problem.

### `hunk.on(event, handler)`

Subscribe to a lifecycle event. Handlers may be async; Hunk never blocks the UI
waiting for one.

| Event | Payload | When |
| ------------------- | ----------------------- | ---------------------------------------------------- |
| `startup` | `{ cwd }` | once, after the app mounts with its first changeset |
| `changeset_loaded` | `{ changeset }` | first load and every reload |
| `selection_changed` | `{ fileId, hunkIndex }` | when the review selection settles (debounced ~150ms) |
| `session_reload` | `{ changeset, reason }` | on every session reload |
| `shutdown` | `{}` | on exit, best-effort within a short timeout |
Subscribe to a lifecycle or UI event. Handlers may be async; Hunk never blocks
the UI waiting for one. Alongside `cwd` and `notify`, every handler receives
`ctx.sidebars`, the same open/close/toggle controls command handlers receive.
That means a `changeset_loaded` handler can reveal its extension's sidebar when
it finds something worth showing — no keypress required.

| Event | Payload | When |
| ---------------------- | ----------------------- | -------------------------------------------------------- |
| `startup` | `{ cwd }` | once, after the app mounts with its first changeset |
| `changeset_loaded` | `{ changeset }` | first load and every reload |
| `selection_changed` | `{ fileId, hunkIndex }` | when the review selection settles (debounced ~150ms) |
| `file_viewed` | `{ file, hunkIndex }` | when selection settles on a file or a reload replaces it |
| `filter_changed` | `{ filter }` | whenever the file-filter query changes |
| `theme_changed` | `{ themeId }` | when the user commits a new theme |
| `layout_changed` | `{ mode, layout }` | mode or responsive split/stack layout changes |
| `watch_reload_pending` | `{}` | watcher observed a change before its reload check |
| `note_created` | `{ note }` | a user saves an inline review note |
| `note_edited` | `{ note }` | an in-progress inline note's body changes |
| `session_reload` | `{ changeset, reason }` | on every session reload |
| `shutdown` | `{}` | on exit, best-effort within a short timeout |

`selection_changed` is trailing-debounced on purpose: holding `[`/`]` retargets
the selection many times a second, and handlers only care where the user landed.
Expand All @@ -788,6 +798,34 @@ refresh key, or the reload after granting extension trust).
`shutdown` handlers get a short window (250ms) to finish before Hunk exits
anyway, so treat it as best-effort flushing rather than guaranteed cleanup.

### `hunk.events`

`hunk.events` is a small bus shared by every loaded extension. Use it to
coordinate extensions without coupling them through a command or global state.
Names are open-ended, so namespace them with your extension id. Listeners get
the same `ctx.sidebars` controls as lifecycle handlers; delivery is fire-and-forget
and one listener's failure is reported without stopping the others. Events an
extension emits while factories are loading are queued until every extension
has had a chance to subscribe.

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

export default function (hunk: HunkExtensionAPI) {
hunk.events.on<{ fileCount: number }>("summary:ready", (payload, ctx) => {
if (payload.fileCount > 100) ctx.sidebars.open("summary");
});

hunk.on("changeset_loaded", ({ changeset }, ctx) => {
hunk.events.emit("summary:ready", { fileCount: changeset.files.length });
ctx.sidebars.open("summary");
});
}
```

Bus payloads are shallow-frozen copies when they are objects. Keep nested data
immutable if multiple extensions will read it.

### `hunk.config`

Your extension's own `[extension.<id>]` config table, as a plain object. Hunk
Expand Down Expand Up @@ -816,7 +854,8 @@ const patterns = (hunk.config.patterns as string[] | undefined) ?? ["*.lock"];
### `ctx.notify(message, type?)`

Every handler and transform receives a context object with `cwd` and `notify`.
`notify` shows a single unobtrusive line at the bottom of the app that clears
Event and bus handlers additionally receive `sidebars` and `events.emit`; command
handlers receive `sidebars`. `notify` shows a single unobtrusive line at the bottom of the app that clears
itself after a few seconds; queued messages appear in turn. `type` is `"info"`
(default), `"warning"`, or `"error"`, which selects the color. Notifications
raised before the UI has mounted are buffered and flushed once it does, so a
Expand Down
20 changes: 20 additions & 0 deletions src/core/watchController.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -118,6 +118,26 @@ describe("createWatchController", () => {
expect(clock.scheduledDelays).not.toContain(250);
});

test("reports one pending reload for a burst of watcher hints", () => {
const clock = new FakeWatchClock();
const source = fakeSource();
let pending = 0;
createWatchController({
initialSignature: "same",
clock,
createEventSource: source.create,
getSignature: () => "same",
refresh: () => {},
onReloadPending: () => pending++,
});

source.event();
source.event();
source.event();

expect(pending).toBe(1);
});

test("uses signatures to distinguish changed and unchanged hints", async () => {
const clock = new FakeWatchClock();
const source = fakeSource();
Expand Down
6 changes: 6 additions & 0 deletions src/core/watchController.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,8 @@ export interface WatchControllerOptions {
initialSignature: string;
getSignature: () => string | Promise<string>;
refresh: () => void | Promise<void>;
/** A source event arrived and a debounced signature check is now pending. */
onReloadPending?: () => void;
clock?: WatchControllerClock;
createEventSource?: (callbacks: WatchEventSourceCallbacks) => WatchEventSource;
pollOnly?: boolean;
Expand Down Expand Up @@ -259,6 +261,10 @@ export function createWatchController(options: WatchControllerOptions): WatchCon
return;
}
const now = clock.now();
// Report one pending reload per debounce window, not once per noisy file event.
if (state.phase !== "debouncing") {
options.onReloadPending?.();
}
quietDeadline = now + quietDelayMs;
maximumDeadline ??= now + maximumDelayMs;
state.phase = "debouncing";
Expand Down
6 changes: 6 additions & 0 deletions src/extension-api/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,9 @@ export type {
ExtensionChangeset,
ExtensionContext,
ExtensionDiffFile,
ExtensionCustomEventHandler,
ExtensionEventBus,
ExtensionEventContext,
ExtensionEventHandler,
ExtensionEventName,
ExtensionEventPayloads,
Expand All @@ -54,6 +57,9 @@ export type {
ExtensionInputOptions,
ExtensionSelectOptions,
ExtensionNotifyType,
ExtensionLayoutMode,
ExtensionResolvedLayout,
ExtensionReviewNote,
ExtensionSidebarActions,
ExtensionSidebarComponent,
ExtensionSidebarControls,
Expand Down
63 changes: 61 additions & 2 deletions src/extension-api/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -847,6 +847,33 @@ export interface ExtensionCommandContext extends ExtensionContext {

export type ExtensionCommandHandler = (ctx: ExtensionCommandContext) => void | Promise<void>;

/** One listener registered on Hunk's extension-to-extension event bus. */
export type ExtensionCustomEventHandler<Payload = unknown> = (
payload: Payload,
ctx: ExtensionEventContext,
) => void | Promise<void>;

/**
* A small in-process event bus shared by every loaded extension.
*
* Use a namespaced event name (`"my-extension:status-ready"`) so unrelated
* extensions cannot accidentally claim the same channel. Delivery is
* fire-and-forget and isolated like lifecycle events: Hunk never awaits a
* listener, and one failure becomes a warning without stopping another. Events
* emitted while extension factories load are queued until every extension has
* had a chance to subscribe.
*/
export interface ExtensionEventBus {
on<Payload = unknown>(event: string, handler: ExtensionCustomEventHandler<Payload>): void;
emit<Payload = unknown>(event: string, payload: Payload): void;
}

/** Context lifecycle and bus listeners receive, including live sidebar controls. */
export interface ExtensionEventContext extends ExtensionContext {
sidebars: ExtensionSidebarControls;
events: Pick<ExtensionEventBus, "emit">;
}

/* -------------------------------------------------------------------------- */
/* Lifecycle events */
/* -------------------------------------------------------------------------- */
Expand All @@ -861,10 +888,40 @@ export type ExtensionCommandHandler = (ctx: ExtensionCommandContext) => void | P
export type SessionReloadReason = "watch" | "daemon" | "manual";

/** Payload delivered with each lifecycle event, keyed by event name. */
export type ExtensionLayoutMode = "auto" | "split" | "stack";
export type ExtensionResolvedLayout = Exclude<ExtensionLayoutMode, "auto">;

/** A user-authored note as reported by note lifecycle events. */
export interface ExtensionReviewNote {
id: string;
fileId: string;
filePath: string;
hunkIndex: number;
side: "old" | "new";
line: number;
body: string;
/** True while the note is still being composed rather than saved. */
draft: boolean;
}

export interface ExtensionEventPayloads {
startup: { cwd: string };
changeset_loaded: { changeset: ExtensionChangeset };
selection_changed: { fileId: string | null; hunkIndex: number | null };
/** The review stream settled on a different file. */
file_viewed: { file: ExtensionDiffFile; hunkIndex: number | null };
/** The file-filter query changed, including when it was cleared. */
filter_changed: { filter: string };
/** The user committed a different active theme. Selector previews do not emit this event. */
theme_changed: { themeId: string };
/** The configured layout mode or responsive resolved layout changed. */
layout_changed: { mode: ExtensionLayoutMode; layout: ExtensionResolvedLayout };
/** A watch source observed a change and is waiting to check/reload it. */
watch_reload_pending: Record<string, never>;
/** A user saved a new inline review note. */
note_created: { note: ExtensionReviewNote };
/** The body of an in-progress inline review note changed. */
note_edited: { note: ExtensionReviewNote };
session_reload: { changeset: ExtensionChangeset; reason: SessionReloadReason };
shutdown: Record<string, never>;
}
Expand All @@ -873,7 +930,7 @@ export type ExtensionEventName = keyof ExtensionEventPayloads;

export type ExtensionEventHandler<Event extends ExtensionEventName = ExtensionEventName> = (
payload: ExtensionEventPayloads[Event],
ctx: ExtensionContext,
ctx: ExtensionEventContext,
) => void | Promise<void>;

/* -------------------------------------------------------------------------- */
Expand Down Expand Up @@ -915,8 +972,10 @@ export interface HunkExtensionAPI {
registerCommand(command: ExtensionCommand, handler: ExtensionCommandHandler): void;
/** Rewrite every loaded changeset before review. */
transformChangeset(fn: ChangesetTransform): void;
/** Subscribe to one Hunk lifecycle event. */
/** Subscribe to one Hunk lifecycle or UI event. Handlers receive sidebar controls. */
on<Event extends ExtensionEventName>(event: Event, handler: ExtensionEventHandler<Event>): void;
/** Publish or subscribe to a namespaced event shared with other loaded extensions. */
readonly events: ExtensionEventBus;
/**
* This extension's own `[extension.<id>]` config table.
*
Expand Down
61 changes: 61 additions & 0 deletions src/extensions/events.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
import { describe, expect, test } from "bun:test";
import {
bindExtensionEventBus,
emitExtensionCustomEvent,
emitExtensionEvent,
emitExtensionEventBounded,
emitExtensionEventToExtensions,
Expand Down Expand Up @@ -142,11 +144,70 @@ describe("extension event dispatch", () => {
expect(Date.now() - started).toBeLessThan(1_000);
});

test("gives lifecycle handlers live sidebar controls for their owning extension", () => {
const opened: string[] = [];
const { result } = createTestLoadResult([
{
extensionId: "summary",
event: "changeset_loaded",
handler: (_payload, ctx) => ctx.sidebars.open("summary"),
},
]);
result.eventContextProvider = (extensionId) => ({
cwd: "/repo",
notify: () => {},
sidebars: {
open: (viewId) => opened.push(`${extensionId}:${viewId}`),
close: () => {},
toggle: () => {},
isOpen: () => false,
},
events: { emit: () => {} },
});

emitExtensionEvent(result, "changeset_loaded", {
changeset: { id: "c", sourceLabel: "repo", title: "t", files: [] },
});

expect(opened).toEqual(["summary:summary"]);
});

test("is a no-op when the session has no extensions", () => {
expect(() => emitExtensionEvent(undefined, "startup", { cwd: "/repo" })).not.toThrow();
});
});

describe("extension event bus", () => {
test("delivers a namespaced event to every listener and isolates failures", async () => {
const seen: string[] = [];
const { result, notices } = createTestLoadResult();
result.registry.customEventHandlers.push(
{
extensionId: "broken",
event: "summary:ready",
handler: () => {
throw new Error("boom");
},
},
{
extensionId: "sidebar",
event: "summary:ready",
handler: (payload) => {
seen.push((payload as { count: number }).count.toString());
},
},
);
bindExtensionEventBus(result);

result.registry.emitCustomEvent?.("summary:ready", { count: 3 });
emitExtensionCustomEvent(result, "other:event", { count: 4 });
await Promise.resolve();

expect(seen).toEqual(["3"]);
expect(notices).toEqual(["Extension broken failed handling event summary:ready • boom"]);
});
});

/** Build a minimal changeset shaped the way the review pipeline produces one. */
function createTestChangeset() {
return {
Expand Down
Loading
Loading