diff --git a/.changeset/extension-event-surface.md b/.changeset/extension-event-surface.md new file mode 100644 index 00000000..f00f7972 --- /dev/null +++ b/.changeset/extension-event-surface.md @@ -0,0 +1,5 @@ +--- +"hunkdiff": minor +--- + +Add extension UI lifecycle events, sidebar controls for event handlers, and an inter-extension event bus. diff --git a/docs/extension-architecture.md b/docs/extension-architecture.md index 69c6e2fa..4e0110fe 100644 --- a/docs/extension-architecture.md +++ b/docs/extension-architecture.md @@ -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 diff --git a/docs/extensions.md b/docs/extensions.md index 819a1de0..5e61e22a 100644 --- a/docs/extensions.md +++ b/docs/extensions.md @@ -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. @@ -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.]` config table, as a plain object. Hunk @@ -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 diff --git a/src/core/watchController.test.ts b/src/core/watchController.test.ts index 6f2066c8..572f94f0 100644 --- a/src/core/watchController.test.ts +++ b/src/core/watchController.test.ts @@ -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(); diff --git a/src/core/watchController.ts b/src/core/watchController.ts index cabc929f..cd69f3e6 100644 --- a/src/core/watchController.ts +++ b/src/core/watchController.ts @@ -29,6 +29,8 @@ export interface WatchControllerOptions { initialSignature: string; getSignature: () => string | Promise; refresh: () => void | Promise; + /** A source event arrived and a debounced signature check is now pending. */ + onReloadPending?: () => void; clock?: WatchControllerClock; createEventSource?: (callbacks: WatchEventSourceCallbacks) => WatchEventSource; pollOnly?: boolean; @@ -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"; diff --git a/src/extension-api/index.ts b/src/extension-api/index.ts index fce0d9c1..d45c1f33 100644 --- a/src/extension-api/index.ts +++ b/src/extension-api/index.ts @@ -41,6 +41,9 @@ export type { ExtensionChangeset, ExtensionContext, ExtensionDiffFile, + ExtensionCustomEventHandler, + ExtensionEventBus, + ExtensionEventContext, ExtensionEventHandler, ExtensionEventName, ExtensionEventPayloads, @@ -54,6 +57,9 @@ export type { ExtensionInputOptions, ExtensionSelectOptions, ExtensionNotifyType, + ExtensionLayoutMode, + ExtensionResolvedLayout, + ExtensionReviewNote, ExtensionSidebarActions, ExtensionSidebarComponent, ExtensionSidebarControls, diff --git a/src/extension-api/types.ts b/src/extension-api/types.ts index 39552256..a4777538 100644 --- a/src/extension-api/types.ts +++ b/src/extension-api/types.ts @@ -847,6 +847,33 @@ export interface ExtensionCommandContext extends ExtensionContext { export type ExtensionCommandHandler = (ctx: ExtensionCommandContext) => void | Promise; +/** One listener registered on Hunk's extension-to-extension event bus. */ +export type ExtensionCustomEventHandler = ( + payload: Payload, + ctx: ExtensionEventContext, +) => void | Promise; + +/** + * 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(event: string, handler: ExtensionCustomEventHandler): void; + emit(event: string, payload: Payload): void; +} + +/** Context lifecycle and bus listeners receive, including live sidebar controls. */ +export interface ExtensionEventContext extends ExtensionContext { + sidebars: ExtensionSidebarControls; + events: Pick; +} + /* -------------------------------------------------------------------------- */ /* Lifecycle events */ /* -------------------------------------------------------------------------- */ @@ -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; + +/** 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; + /** 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; } @@ -873,7 +930,7 @@ export type ExtensionEventName = keyof ExtensionEventPayloads; export type ExtensionEventHandler = ( payload: ExtensionEventPayloads[Event], - ctx: ExtensionContext, + ctx: ExtensionEventContext, ) => void | Promise; /* -------------------------------------------------------------------------- */ @@ -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: Event, handler: ExtensionEventHandler): void; + /** Publish or subscribe to a namespaced event shared with other loaded extensions. */ + readonly events: ExtensionEventBus; /** * This extension's own `[extension.]` config table. * diff --git a/src/extensions/events.test.ts b/src/extensions/events.test.ts index e6b744c6..7e09d884 100644 --- a/src/extensions/events.test.ts +++ b/src/extensions/events.test.ts @@ -1,5 +1,7 @@ import { describe, expect, test } from "bun:test"; import { + bindExtensionEventBus, + emitExtensionCustomEvent, emitExtensionEvent, emitExtensionEventBounded, emitExtensionEventToExtensions, @@ -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 { diff --git a/src/extensions/events.ts b/src/extensions/events.ts index 4891a2bd..d1cd5ca5 100644 --- a/src/extensions/events.ts +++ b/src/extensions/events.ts @@ -5,7 +5,11 @@ import type { ExtensionEventPayloads, ExtensionLoadResult, } from "./types"; -import type { ExtensionVcsFileChangeType } from "../extension-api/types"; +import type { + ExtensionEventContext, + ExtensionSidebarControls, + ExtensionVcsFileChangeType, +} from "../extension-api/types"; /** * How long `shutdown` handlers may run before Hunk exits anyway. @@ -225,16 +229,58 @@ function toHandlerPayload( payload: ExtensionEventPayloads[Event], ): ExtensionEventPayloads[Event] { const changeset = (payload as { changeset?: unknown }).changeset; - if (changeset === null || typeof changeset !== "object") { - return Object.freeze({ ...payload }) as ExtensionEventPayloads[Event]; - } - + const file = (payload as { file?: unknown }).file; + const note = (payload as { note?: unknown }).note; return Object.freeze({ ...payload, - changeset: toReadOnlyChangesetView(changeset as ExtensionChangeset), + ...(changeset !== null && typeof changeset === "object" + ? { changeset: toReadOnlyChangesetView(changeset as ExtensionChangeset) } + : {}), + ...(file !== null && typeof file === "object" + ? { file: toReadOnlyFileViews([file as ExtensionDiffFile])[0] } + : {}), + ...(note !== null && typeof note === "object" ? { note: Object.freeze({ ...note }) } : {}), }) as ExtensionEventPayloads[Event]; } +/** Sidebar controls used only before the mounted app has installed live controls. */ +function unavailableSidebarControls( + result: ExtensionLoadResult, + extensionId: string, +): ExtensionSidebarControls { + const unavailable = (method: string, viewId: string) => { + result.context.notify( + `Extension ${extensionId} cannot ${method} sidebar view "${viewId}" before the app is ready`, + "warning", + ); + }; + + return { + open: (viewId) => unavailable("open", viewId), + close: (viewId) => unavailable("close", viewId), + toggle: (viewId) => unavailable("toggle", viewId), + isOpen: () => false, + }; +} + +/** Build the runtime event context for one owning extension. */ +function createEventContext( + result: ExtensionLoadResult, + extensionId: string, +): ExtensionEventContext { + return ( + result.eventContextProvider?.(extensionId) ?? { + ...result.context, + sidebars: unavailableSidebarControls(result, extensionId), + events: { + emit(event, payload) { + emitExtensionCustomEvent(result, event, payload); + }, + }, + } + ); +} + /** * Invoke every handler for one event, isolating each from the others. * @@ -271,7 +317,7 @@ function runExtensionEventHandlers( }; try { - const returned = handler(payload, result.context); + const returned = handler(payload, createEventContext(result, extensionId)); if (returned && typeof (returned as PromiseLike).then === "function") { settled.push(Promise.resolve(returned).catch(report)); } @@ -283,6 +329,69 @@ function runExtensionEventHandlers( return settled; } +/** + * Emit a named event on the shared extension bus without blocking its caller. + * + * Bus payloads are shallow-frozen copies: listeners share an immutable envelope + * just as lifecycle listeners do, while opaque nested values remain the sender's + * responsibility. Event names are intentionally open-ended so extensions can + * coordinate without Hunk reserving a central registry. + */ +export function emitExtensionCustomEvent( + result: ExtensionLoadResult | undefined, + event: string, + rawPayload: unknown, +) { + if (!result) { + return; + } + + const payload = + rawPayload !== null && typeof rawPayload === "object" + ? Object.freeze({ ...(rawPayload as Record) }) + : rawPayload; + for (const { extensionId, event: registeredEvent, handler } of result.registry + .customEventHandlers) { + if (registeredEvent !== event) { + continue; + } + + const report = (error: unknown) => { + result.context.notify( + `Extension ${extensionId} failed handling event ${event} • ${describeError(error)}`, + "warning", + ); + }; + try { + const returned = handler(payload, createEventContext(result, extensionId)); + if (returned && typeof (returned as PromiseLike).then === "function") { + void Promise.resolve(returned).catch(report); + } + } catch (error) { + report(error); + } + } +} + +/** Bind a loaded registry so hunk.events.emit starts delivering runtime events. */ +export function bindExtensionEventBus(result: ExtensionLoadResult | undefined) { + if (!result) { + return; + } + + const { registry } = result; + registry.emitCustomEvent = (event, payload) => { + emitExtensionCustomEvent(result, event, payload); + }; + registry.eventBusPhase = "ready"; + + // Factories load before a result has the context needed to invoke handlers. + // Replay their events now that every extension has had a chance to subscribe. + for (const { event, payload } of registry.pendingCustomEvents.splice(0)) { + emitExtensionCustomEvent(result, event, payload); + } +} + /** * Emit one lifecycle event without blocking the caller. * diff --git a/src/extensions/host.test.ts b/src/extensions/host.test.ts index 3ff85ea8..36eff943 100644 --- a/src/extensions/host.test.ts +++ b/src/extensions/host.test.ts @@ -101,6 +101,32 @@ export default function (hunk: HunkExtensionAPI) { ).toBe("transformed"); }); + test("replays a factory event after every extension has subscribed", async () => { + const dir = createTempDir("hunk-host-factory-event-"); + const listener = createTestExtension( + dir, + "listener.ts", + `export default function (hunk) { + hunk.events.on("summary:ready", (payload) => hunk.log("received:" + payload.count)); +} +`, + ); + const emitter = createTestExtension( + dir, + "emitter.ts", + `export default function (hunk) { + hunk.events.emit("summary:ready", { count: 3 }); +} +`, + ); + + const result = await loadExtensions({ candidates: [listener, emitter], cwd: dir }); + + expect(result.issues).toEqual([]); + expect(result.registry.logs).toEqual([{ extensionId: "listener", message: "received:3" }]); + expect(result.registry.pendingCustomEvents).toEqual([]); + }); + test("loads a folder extension whose index imports a sibling module", async () => { const dir = createTempDir("hunk-host-folder-"); // The helper is written first so the index's relative import resolves. diff --git a/src/extensions/host.ts b/src/extensions/host.ts index 951668f5..cff2d599 100644 --- a/src/extensions/host.ts +++ b/src/extensions/host.ts @@ -1,6 +1,7 @@ import { pathToFileURL } from "node:url"; import { findVcsRepoRootCandidate, isVcsId } from "../core/vcs"; import { EXTENSION_ID_RULE, HUNK_VENDOR_EXTENSION_ID, isValidExtensionId } from "./extensionIds"; +import { bindExtensionEventBus } from "./events"; import { registerHostRuntimeModules } from "./hostRuntimeModules"; import { createExtensionNotificationHub, type ExtensionNotificationHub } from "./notifications"; import { describeError, readExtensionFactory, runExtensionFactory } from "./runExtension"; @@ -202,7 +203,9 @@ export async function loadExtensions(options: LoadExtensionsOptions): Promise { }); }); +describe("hunk.events", () => { + test("registers a bus listener under its owning extension", () => { + const registry = createEmptyExtensionRegistry(); + const issues: ExtensionLoadIssue[] = []; + const handler = () => {}; + + runExtensionFactory({ + metadata: bundledMetadata("summary"), + registry, + issues, + factory: (hunk) => { + hunk.events.on("summary:ready", handler); + }, + }); + + expect(issues).toEqual([]); + expect(registry.customEventHandlers).toEqual([ + { extensionId: "summary", event: "summary:ready", handler }, + ]); + }); + + test("rolls bus registrations and queued factory events back with a failing factory", () => { + const registry = createEmptyExtensionRegistry(); + const issues: ExtensionLoadIssue[] = []; + + runExtensionFactory({ + metadata: bundledMetadata("broken-events"), + registry, + issues, + factory: (hunk) => { + hunk.events.on("broken:ready", () => {}); + hunk.events.emit("broken:ready", {}); + throw new Error("boom"); + }, + }); + + expect(registry.customEventHandlers).toEqual([]); + expect(registry.pendingCustomEvents).toEqual([]); + }); +}); + describe("registerCommand", () => { test("collects a valid command tagged with the owning extension", () => { const registry = createEmptyExtensionRegistry(); diff --git a/src/extensions/runExtension.ts b/src/extensions/runExtension.ts index 55e85bf0..36255fa4 100644 --- a/src/extensions/runExtension.ts +++ b/src/extensions/runExtension.ts @@ -7,6 +7,8 @@ import { type ExtensionLoadIssue, type ExtensionCommand, type ExtensionCommandHandler, + type ExtensionCustomEventHandler, + type ExtensionEventBus, type ExtensionMetadata, type ExtensionRegistry, type ExtensionSidebarView, @@ -216,6 +218,8 @@ interface RegistrySnapshot { sidebarViews: number; commands: number; eventHandlers: Record; + customEventHandlers: number; + pendingCustomEvents: number; } /** Capture how much each registration list already holds. */ @@ -233,6 +237,8 @@ function snapshotRegistry(registry: ExtensionRegistry): RegistrySnapshot { sidebarViews: registry.sidebarViews.length, commands: registry.commands.length, eventHandlers, + customEventHandlers: registry.customEventHandlers.length, + pendingCustomEvents: registry.pendingCustomEvents.length, }; } @@ -249,6 +255,8 @@ function rollbackRegistry(registry: ExtensionRegistry, snapshot: RegistrySnapsho registry.changesetTransforms.length = snapshot.changesetTransforms; registry.sidebarViews.length = snapshot.sidebarViews; registry.commands.length = snapshot.commands; + registry.customEventHandlers.length = snapshot.customEventHandlers; + registry.pendingCustomEvents.length = snapshot.pendingCustomEvents; for (const [event, handlers] of Object.entries(registry.eventHandlers)) { handlers.length = snapshot.eventHandlers[event] ?? 0; } @@ -276,9 +284,36 @@ export function createExtensionApi( } }; + const events: ExtensionEventBus = { + on(event: string, handler: ExtensionCustomEventHandler) { + assertOpen("events.on"); + assertNonEmptyString(event, "events.on requires a non-empty event name."); + if (typeof handler !== "function") { + throw new Error(`events.on("${event}") requires a handler function.`); + } + + registry.customEventHandlers.push({ + extensionId: metadata.id, + event, + handler: handler as ExtensionCustomEventHandler, + }); + }, + emit(event: string, payload: unknown) { + assertNonEmptyString(event, "events.emit requires a non-empty event name."); + if (registry.emitCustomEvent) { + registry.emitCustomEvent(event, payload); + } else if (registry.eventBusPhase === "loading") { + // Factories load sequentially, before the runtime dispatcher has its + // notification context. Preserve their events until it is available. + registry.pendingCustomEvents.push({ extensionId: metadata.id, event, payload }); + } + }, + }; + const api: HunkExtensionAPI = { apiVersion: HUNK_EXTENSION_API_VERSION, config, + events, registerTheme(theme: ExtensionThemeConfig) { assertOpen("registerTheme"); assertNonEmptyString(theme?.id, "registerTheme requires a theme with a non-empty id."); diff --git a/src/extensions/types.ts b/src/extensions/types.ts index a1009eff..8047b893 100644 --- a/src/extensions/types.ts +++ b/src/extensions/types.ts @@ -5,6 +5,7 @@ import type { ExtensionCommand, ExtensionCommandHandler, ExtensionContext, + ExtensionCustomEventHandler, ExtensionEventHandler, ExtensionEventName, ExtensionNotifyType, @@ -28,13 +29,17 @@ export type { ExtensionCommandHandler, ExtensionConfirmOptions, ExtensionContext, + ExtensionCustomEventHandler, ExtensionDialogs, ExtensionDiffFile, + ExtensionEventBus, + ExtensionEventContext, ExtensionEventHandler, ExtensionEventName, ExtensionEventPayloads, ExtensionFactory, ExtensionInputOptions, + ExtensionReviewNote, ExtensionNotifyType, ExtensionSelectOptions, ExtensionSidebarActions, @@ -116,6 +121,20 @@ export interface RegisteredEventHandler; } +/** One extension listener on a named inter-extension bus channel. */ +export interface RegisteredCustomEventHandler { + extensionId: string; + event: string; + handler: ExtensionCustomEventHandler; +} + +/** One bus event emitted while extension factories are still loading. */ +export interface PendingCustomEvent { + extensionId: string; + event: string; + payload: unknown; +} + export interface ExtensionLogEntry { extensionId: string; message: string; @@ -135,6 +154,13 @@ export interface ExtensionRegistry { sidebarViews: RegisteredSidebarView[]; commands: RegisteredCommand[]; eventHandlers: ExtensionEventHandlerMap; + customEventHandlers: RegisteredCustomEventHandler[]; + /** Events raised by a factory, delivered after every factory has loaded. */ + pendingCustomEvents: PendingCustomEvent[]; + /** The bus accepts queued factory events only while this registry is loading. */ + eventBusPhase: "loading" | "ready" | "closed"; + /** Bound after loading so hunk.events.emit can dispatch at runtime. */ + emitCustomEvent?: (event: string, payload: unknown) => void; logs: ExtensionLogEntry[]; } @@ -156,6 +182,10 @@ export interface ExtensionLoadResult { * so notifications from any extension land in the same sink. */ context: ExtensionContext; + /** UI installs this per-extension context factory once sidebar controls exist. */ + eventContextProvider?: ( + extensionId: string, + ) => import("../extension-api/types").ExtensionEventContext; /** * The sink behind `context.notify`, kept on the result so the UI can attach * its toast surface and so a later load pass (after a trust grant) can reuse @@ -200,9 +230,19 @@ export function createEmptyExtensionRegistry(): ExtensionRegistry { startup: [], changeset_loaded: [], selection_changed: [], + file_viewed: [], + filter_changed: [], + theme_changed: [], + layout_changed: [], + watch_reload_pending: [], + note_created: [], + note_edited: [], session_reload: [], shutdown: [], }, + customEventHandlers: [], + pendingCustomEvents: [], + eventBusPhase: "loading", logs: [], }; } diff --git a/src/ui/App.tsx b/src/ui/App.tsx index 832ead13..cd1e9738 100644 --- a/src/ui/App.tsx +++ b/src/ui/App.tsx @@ -32,10 +32,16 @@ import type { import { canReloadInput } from "../core/watch"; import { sanitizeTerminalLine } from "../lib/terminalText"; import { resolveExtensionCommands } from "../extensions/apply"; -import { emitExtensionEvent, toReadOnlyFileViews } from "../extensions/events"; +import { + emitExtensionCustomEvent, + emitExtensionEvent, + toReadOnlyFileViews, +} from "../extensions/events"; import { writeExtensionTrust } from "../extensions/trust"; import type { ExtensionCommandContext, + ExtensionEventContext, + ExtensionReviewNote, ExtensionSidebarControls, RegisteredCommand, } from "../extensions/types"; @@ -592,6 +598,21 @@ export function App({ setExtensionDialogSelectedIndex((current) => (current + delta + optionCount) % optionCount); }; + // Lifecycle and bus listeners receive the same sidebar controls as commands, + // so an extension can react to loaded content by revealing its own pane. + if (extensions) { + extensions.eventContextProvider = (extensionId): ExtensionEventContext => ({ + cwd: extensions.context.cwd, + notify: (message, type) => extensions.context.notify(message, type), + sidebars: createSidebarControls(extensionId), + events: { + emit(event, payload) { + emitExtensionCustomEvent(extensions, event, payload); + }, + }, + }); + } + /** Invoke one extension command with its context, containing any failure. */ const runExtensionCommand = useCallback( (registered: RegisteredCommand) => { @@ -708,16 +729,31 @@ export function App({ ); }, [keymap, showSessionNotice]); + // The initial selected file is a view too, so extensions can populate a + // file-scoped pane without waiting for the user to navigate first. Track the + // file object, not only its id: a soft reload replaces its contents while + // preserving stable navigation ids. + const lastViewedFileRef = useRef(null); useEffect(() => { const timer = setTimeout(() => { - emitExtensionEvent(extensions, "selection_changed", { - fileId: selectedFileId, - hunkIndex: selectedFileId === null ? null : selectedHunkIndex, - }); + const hunkIndex = selectedFileId === null ? null : selectedHunkIndex; + emitExtensionEvent(extensions, "selection_changed", { fileId: selectedFileId, hunkIndex }); + if (selectedFile && selectedFile !== lastViewedFileRef.current) { + lastViewedFileRef.current = selectedFile; + emitExtensionEvent(extensions, "file_viewed", { file: selectedFile, hunkIndex }); + } }, SELECTION_CHANGED_DEBOUNCE_MS); return () => clearTimeout(timer); - }, [extensions, selectedFileId, selectedHunkIndex]); + }, [extensions, selectedFile, selectedFileId, selectedHunkIndex]); + + const reportedFilterRef = useRef(undefined); + useEffect(() => { + if (reportedFilterRef.current !== undefined && reportedFilterRef.current !== review.filter) { + emitExtensionEvent(extensions, "filter_changed", { filter: review.filter }); + } + reportedFilterRef.current = review.filter; + }, [extensions, review.filter]); const bodyPadding = pagerMode ? 0 : BODY_PADDING; const bodyWidth = Math.max(0, terminal.width - bodyPadding); @@ -726,6 +762,17 @@ export function App({ const sidebarAreaVisible = sidebarVisible && (responsiveLayout.showSidebar || (forceSidebarOpen && canForceShowSidebar)); const resolvedLayout = responsiveLayout.layout; + const reportedLayoutRef = useRef(undefined); + useEffect(() => { + const signature = `${layoutMode}:${resolvedLayout}`; + if (reportedLayoutRef.current !== undefined && reportedLayoutRef.current !== signature) { + emitExtensionEvent(extensions, "layout_changed", { + mode: layoutMode, + layout: resolvedLayout, + }); + } + reportedLayoutRef.current = signature; + }, [extensions, layoutMode, resolvedLayout]); const sidebarLayout = useMemo( () => sidebarAreaVisible @@ -932,6 +979,14 @@ export function App({ setWrapLines((current) => !current); }; + const reportedThemeIdRef = useRef(undefined); + useEffect(() => { + if (reportedThemeIdRef.current !== undefined && reportedThemeIdRef.current !== themeId) { + emitExtensionEvent(extensions, "theme_changed", { themeId }); + } + reportedThemeIdRef.current = themeId; + }, [extensions, themeId]); + /** Switch the active theme. */ const selectTheme = useCallback( (nextThemeId: string) => { @@ -1206,6 +1261,7 @@ export function App({ useWatchedInput({ enabled: watchEnabled, input: bootstrap.input, + onReloadPending: () => emitExtensionEvent(extensions, "watch_reload_pending", {}), refresh: refreshWatchedInput, reloadContext: bootstrap.reloadContext, runtime: watchRuntime, @@ -1346,11 +1402,58 @@ export function App({ setFocusArea((current) => (current === "note" ? "files" : current)); }, []); + /** Convert a draft or saved UI note into the stable public event view. */ + const toExtensionReviewNote = useCallback( + ( + note: { + id: string; + fileId: string; + filePath: string; + hunkIndex: number; + side: "old" | "new"; + line: number; + body?: string; + summary?: string; + }, + draft: boolean, + ): ExtensionReviewNote => ({ + id: note.id, + fileId: note.fileId, + filePath: note.filePath, + hunkIndex: note.hunkIndex, + side: note.side, + line: note.line, + body: note.body ?? note.summary ?? "", + draft, + }), + [], + ); + /** Save the active draft note and return focus to review navigation. */ const saveDraftNote = useCallback(() => { - review.saveDraftNote(); + const draft = review.draftNote; + const saved = review.saveDraftNote(); + if (saved && draft) { + emitExtensionEvent(extensions, "note_created", { + note: toExtensionReviewNote({ ...saved, fileId: draft.fileId }, false), + }); + } setFocusArea("files"); - }, [review.saveDraftNote]); + }, [extensions, review.draftNote, review.saveDraftNote, toExtensionReviewNote]); + + /** Update a draft note and publish its current in-progress contents. */ + const updateDraftNote = useCallback( + (body: string) => { + const draft = review.draftNote; + review.updateDraftNote(body); + if (draft) { + emitExtensionEvent(extensions, "note_edited", { + note: toExtensionReviewNote({ ...draft, body }, true), + }); + } + }, + [extensions, review.draftNote, review.updateDraftNote, toExtensionReviewNote], + ); /** Cancel the active draft note and return focus to review navigation. */ const cancelDraftNote = useCallback(() => { @@ -1682,7 +1785,7 @@ export function App({ onRemoveUserNote={review.removeUserNote} onSaveDraftNote={saveDraftNote} onStartUserNoteAtHunk={startUserNote} - onUpdateDraftNote={review.updateDraftNote} + onUpdateDraftNote={updateDraftNote} onBlurDraftNote={blurDraftNote} onCancelDraftNote={cancelDraftNote} onFocusDraftNote={focusDraftNote} diff --git a/src/ui/AppHost.extensions.test.tsx b/src/ui/AppHost.extensions.test.tsx index c8e25e21..87f46f95 100644 --- a/src/ui/AppHost.extensions.test.tsx +++ b/src/ui/AppHost.extensions.test.tsx @@ -322,6 +322,55 @@ describe("reload keeps launch extension authority", () => { }); }); +describe("file_viewed events", () => { + test("fires again when a soft reload replaces the selected file with the same id", async () => { + const repo = createTestRepo("hunk-apphost-file-viewed-"); + const logPath = join(repo, "file-viewed.log"); + const extPath = join(repo, "file-viewed.ts"); + writeFileSync( + extPath, + `import { appendFileSync } from "node:fs"; +export default function (hunk) { + hunk.on("file_viewed", ({ file }) => appendFileSync(${JSON.stringify(logPath)}, file.id + "\\n")); +} +`, + ); + useTempConfigHome(); + + const bootstrap = await loadAppBootstrap( + { kind: "vcs", staged: false, options: { mode: "stack", extensionPaths: [extPath] } }, + { cwd: repo }, + ); + bootstrap.extensions = await loadStartupExtensions({ + extensions: { enabled: true, paths: [], repoPaths: [], extensionConfigs: {} }, + cwd: repo, + cliExtensionPaths: [extPath], + }); + + await withAppHost(bootstrap, async (setup) => { + await flushUntil( + setup, + () => readProbeLog(logPath).length === 1, + "the initial selected file to be reported", + ); + + // `r` requests a soft reload (`resetApp: false`), retaining selection and + // its stable id while replacing the underlying review file object. + await act(async () => { + await setup.mockInput.typeText("r"); + }); + await flushUntil( + setup, + () => readProbeLog(logPath).length === 2, + "the reloaded selected file to be reported", + ); + + const viewedIds = readProbeLog(logPath); + expect(viewedIds[1]).toBe(viewedIds[0]); + }); + }); +}); + /** * Answer a repo-extension trust prompt with `t` and return what the extension logged. * diff --git a/src/ui/AppHost.tsx b/src/ui/AppHost.tsx index 7f23868f..e1dae755 100644 --- a/src/ui/AppHost.tsx +++ b/src/ui/AppHost.tsx @@ -126,6 +126,14 @@ export function AppHost({ let reloadedExtensions = false; if (options?.reloadExtensions || cwd !== extensionsCwdRef.current) { + // A reloaded extension set owns a fresh ephemeral bus. Detach the old + // registry first so delayed callbacks from a retired extension cannot + // keep publishing into listeners that no longer belong to this session. + if (extensionsRef.current) { + extensionsRef.current.registry.emitCustomEvent = undefined; + extensionsRef.current.registry.eventBusPhase = "closed"; + extensionsRef.current.registry.pendingCustomEvents.length = 0; + } // Reuse the session's notification hub so the mounted toast surface keeps // receiving `ctx.notify` from the extensions this pass loads. extensionsRef.current = await loadStartupExtensions({ diff --git a/src/ui/hooks/useWatchedInput.ts b/src/ui/hooks/useWatchedInput.ts index 10b52102..a6deb632 100644 --- a/src/ui/hooks/useWatchedInput.ts +++ b/src/ui/hooks/useWatchedInput.ts @@ -23,17 +23,21 @@ export function useWatchedInput({ enabled, input, reloadContext, + onReloadPending, refresh, runtime = defaultRuntime, }: { enabled: boolean; input: CliInput; + onReloadPending?: () => void; reloadContext: ReloadContext; refresh: () => void | Promise; runtime?: WatchedInputRuntime; }) { const refreshRef = useRef(refresh); refreshRef.current = refresh; + const pendingRef = useRef(onReloadPending); + pendingRef.current = onReloadPending; useEffect(() => { if (!enabled) return; @@ -61,6 +65,7 @@ export function useWatchedInput({ createEventSource: eventSourceFactory, getSignature: () => getSignature(input, reloadContext), initialSignature, + onReloadPending: () => pendingRef.current?.(), pollOnly: plan.coverage === "poll-only", refresh: () => refreshRef.current(), reportError: (error) => console.error("Failed to auto-reload the current diff.", error),