diff --git a/.changeset/calm-extension-runtime.md b/.changeset/calm-extension-runtime.md new file mode 100644 index 00000000..a845151c --- /dev/null +++ b/.changeset/calm-extension-runtime.md @@ -0,0 +1,2 @@ +--- +--- diff --git a/src/ui/App.tsx b/src/ui/App.tsx index 3cdc58cc..79a18485 100644 --- a/src/ui/App.tsx +++ b/src/ui/App.tsx @@ -24,15 +24,7 @@ import { resolveExtensionLineHighlighters, resolveExtensionSessionOptions, } from "../extensions/apply"; -import { emitExtensionCustomEvent, toReadOnlyFileViews } from "../extensions/events"; -import { buildExtensionReviewSnapshot } from "../extensions/reviewSnapshot"; -import type { - ExtensionCommandContext, - ExtensionEventContext, - ExtensionNotifyType, - ExtensionLoadResult, - RegisteredCommand, -} from "../extensions/types"; +import type { ExtensionNotifyType, ExtensionLoadResult } from "../extensions/types"; import type { ReviewProducer } from "../app/review/producer"; import type { HunkSessionBrokerClient } from "../session/broker/brokerClient"; import type { ReloadedSessionResult, ReloadSessionOptions } from "../session/types"; @@ -51,10 +43,16 @@ import { } from "./diff/codeColumns"; import { useAppKeyboardShortcuts } from "./hooks/useAppKeyboardShortcuts"; import { useCurrentReviewRefreshController } from "./hooks/useCurrentReviewRefreshController"; +import { useExtensionCommandRunner } from "./hooks/useExtensionCommandRunner"; import { useExtensionDialogController } from "./hooks/useExtensionDialogController"; +import { useExtensionEventContextProvider } from "./hooks/useExtensionEventContextProvider"; import { useExtensionNotifications } from "./hooks/useExtensionNotifications"; import { useExtensionPaneController } from "./hooks/useExtensionPaneController"; import { useExtensionReviewEvents } from "./hooks/useExtensionReviewEvents"; +import { + useExtensionRuntimeBindings, + useExtensionRuntimeBridge, +} from "./hooks/useExtensionRuntimeBridge"; import { useExtensionTrustController } from "./hooks/useExtensionTrustController"; import { useExtensionWorkspaceControls, @@ -66,11 +64,7 @@ import { useMenuController } from "./hooks/useMenuController"; import { useThemeSelectorController } from "./hooks/useThemeSelectorController"; import { useTimedNotice } from "./hooks/useTimedNotice"; import { useUserNoteComposer } from "./hooks/useUserNoteComposer"; -import { - useTerminalReview, - type AgentNoteGeometrySnapshot, - type RevealedLineResult, -} from "./hooks/useTerminalReview"; +import { useTerminalReview, type AgentNoteGeometrySnapshot } from "./hooks/useTerminalReview"; import { useViewPreferenceQuitController } from "./hooks/useViewPreferenceQuitController"; import type { WatchedInputRuntime } from "./hooks/useWatchedInput"; import { agentNoteMarkupWidth } from "./lib/agentNoteGeometry"; @@ -79,16 +73,11 @@ import { builtinCommandKeyDefaults, builtinCommandMatchProbes, observeAppCommandDispatch, - type AppCommand, } from "./lib/appCommands"; import { buildAppMenus } from "./lib/appMenus"; import { buildExtensionAppCommands, extensionCommandKeyDefaults } from "./lib/extensionCommands"; -import { createExtensionCapabilityLease } from "./lib/extensionCapabilityLease"; -import { createExtensionCommandControls } from "./lib/extensionCommandControls"; -import { createGuardedReviewNavigation } from "./lib/extensionNavigation"; import type { CurrentLineAlignment } from "./lib/hunkScroll"; import type { LineCursor } from "./lib/lineCursors"; -import { buildExtensionReviewSelection } from "./lib/extensionSelection"; import { useFilePresentationController } from "./fileViews/useFilePresentationController"; import { useFilePresentationRendering } from "./fileViews/useFilePresentationRendering"; import { mergeLineHighlightMaps } from "./highlights/merge"; @@ -295,122 +284,29 @@ export function App({ : { transientViewPreferences: false }, [extensions], ); - // The one conversion of the visible review files into the frozen views every - // extension surface sees: sidebar props and command-handler selection both - // read from this list, so they can never describe the review differently. - // Computed on demand and cached per visible-files identity rather than - // eagerly memoized: `visibleFiles` gets a fresh identity on every selection - // change, so an eager memo would reconvert the whole list on each navigation - // keypress even in sessions where no pane is showing and no command fires. - const extensionViewsCacheRef = useRef<{ - source: typeof filteredFiles; - views: ReturnType; - } | null>(null); - const extensionSelectionInputsRef = useRef({ - filteredFiles, - getSelection: review.getSelection, - getActiveLineCursor: () => (cursorLine === "off" ? null : review.getLineCursor()), - }); - extensionSelectionInputsRef.current = { - filteredFiles, + const getActiveExtensionLineCursor = useCallback( + () => (cursorLine === "off" ? null : review.getLineCursor()), + [cursorLine, review.getLineCursor], + ); + const extensionRuntime = useExtensionRuntimeBridge({ + extensions, + files: filteredFiles, + getActiveLineCursor: getActiveExtensionLineCursor, getSelection: review.getSelection, - getActiveLineCursor: () => (cursorLine === "off" ? null : review.getLineCursor()), - }; - const getExtensionFileViews = useCallback(() => { - const source = extensionSelectionInputsRef.current.filteredFiles; - const cache = extensionViewsCacheRef.current; - if (cache && cache.source === source) { - return cache.views; - } - - const views = toReadOnlyFileViews(source); - extensionViewsCacheRef.current = { source, views }; - return views; - }, []); - // Navigation callbacks for extension command handlers. The focus and jump - // helpers they delegate to are defined further down the component, so the - // callbacks are assigned there each render and only ever read at command - // invocation, keeping the dispatch table free of their identities. - const extensionCommandNavigationRef = useRef({ - onSelectFile: (_fileId: string) => {}, - onSelectHunk: (_fileId: string, _hunkIndex: number) => {}, - onRevealLine: (_fileId: string, _side: "old" | "new", _line: number): RevealedLineResult => - "none", + reviewGeneration: bootstrap, + reviewProducer, }); - // A hard session reload (`resetApp`) remounts App under an in-flight async - // command handler, whose `ctx.navigation` closes over *this* instance's - // refs. Flipping this on unmount lets those closures refuse with an accurate - // warning instead of validating against the dead instance's file list or - // driving a controller whose state updates no longer render. - const appAliveForNavigationRef = useRef(true); - const extensionHostCommandsRef = useRef([]); - // A soft extension reload keeps App mounted but replaces the authority that - // created each handler. Retain the current registry separately so controls - // captured by a retired async handler cannot drive the replacement registry. - const activeExtensionRegistryRef = useRef(extensions?.registry); - const activeReviewGenerationRef = useRef(bootstrap); - useLayoutEffect(() => { - activeExtensionRegistryRef.current = extensions?.registry; - activeReviewGenerationRef.current = bootstrap; - }, [bootstrap, extensions?.registry]); - const extensionCommandControls = useMemo(() => { - const lease = createExtensionCapabilityLease({ - owningRegistry: extensions?.registry, - getActiveRegistry: () => activeExtensionRegistryRef.current, - isAppAlive: () => appAliveForNavigationRef.current, - }); - return createExtensionCommandControls({ - getCommands: () => extensionHostCommandsRef.current, - isLive: lease.isLive, - }); - }, [extensions?.registry]); - /** Mint controls that expire with their runtime, App instance, or review generation. */ - const createReviewCapabilityLease = useCallback( - () => - createExtensionCapabilityLease({ - owningRegistry: extensions?.registry, - getActiveRegistry: () => activeExtensionRegistryRef.current, - isAppAlive: () => appAliveForNavigationRef.current, - isReviewCurrent: () => activeReviewGenerationRef.current === bootstrap, - }), - [bootstrap, extensions?.registry], - ); - useEffect(() => { - // StrictMode replays setup/cleanup/setup while the same App remains mounted. - appAliveForNavigationRef.current = true; - return () => { - appAliveForNavigationRef.current = false; - }; - }, []); - - /** Build the selection snapshot a command handler receives, at invocation. */ - const getExtensionSelection = useCallback(() => { - const { getSelection, getActiveLineCursor } = extensionSelectionInputsRef.current; - const { fileId, hunkIndex } = getSelection(); - return buildExtensionReviewSelection({ - files: getExtensionFileViews(), - selectedFileId: fileId, - selectedHunkIndex: hunkIndex, - 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, - [], - ); + const { + commandControls: extensionCommandControls, + createNavigation: createExtensionNavigation, + createReviewCapabilityLease, + createReviewControls: createExtensionReviewControls, + getCommittedFileViews: getExtensionFileViews, + getRenderFileViews: getRenderExtensionFileViews, + getRenderSelection: getRenderExtensionSelection, + getSelectedFileId, + getSelection: getExtensionSelection, + } = extensionRuntime; const jumpToFile = useCallback( (fileId: string, options?: { alignFileHeaderTop?: boolean }) => { review.selectFile(fileId, { alignFileHeaderTop: options?.alignFileHeaderTop }); @@ -541,7 +437,7 @@ export function App({ updatePaneResize, } = useExtensionPaneController({ availabilityContext: { - files: getExtensionFileViews(), + files: getRenderExtensionFileViews(), selectedFileId, selectedHunkIndex, }, @@ -559,25 +455,6 @@ export function App({ responsiveShowsSidebar: responsiveLayout.showSidebar, }); - /** Build live, guarded review navigation for one extension-owned handler. */ - const createExtensionNavigation = useCallback( - (extensionId: string) => { - const lease = createReviewCapabilityLease(); - return createGuardedReviewNavigation({ - extensionId, - getFiles: () => extensionSelectionInputsRef.current.filteredFiles, - isLive: lease.isLive, - notify: (message, type) => extensions?.context.notify(message, type), - onSelectFile: (fileId) => extensionCommandNavigationRef.current.onSelectFile(fileId), - onSelectHunk: (fileId, hunkIndex) => - extensionCommandNavigationRef.current.onSelectHunk(fileId, hunkIndex), - onRevealLine: (fileId, side, line) => - extensionCommandNavigationRef.current.onRevealLine(fileId, side, line), - }); - }, - [createReviewCapabilityLease, extensions], - ); - const { accept: acceptExtensionDialog, cancel: cancelExtensionDialog, @@ -616,95 +493,26 @@ export function App({ workspaceFileWriter, }); - // Lifecycle and bus listeners receive the same pane, navigation, and dialog - // controls as commands, so onboarding can stay entirely in the public API. - if (extensions) { - extensions.eventContextProvider = (extensionId): ExtensionEventContext => { - const panes = createPaneControls(extensionId); - return { - cwd: extensions.context.cwd, - notify: (message, type) => extensions.context.notify(message, type), - panes, - sidebars: panes, - navigation: createExtensionNavigation(extensionId), - dialogs: createExtensionDialogs(extensionId), - events: { - emit(event, payload) { - emitExtensionCustomEvent(extensions, event, payload); - }, - }, - }; - }; - } - - /** Invoke one extension command with its context, containing any failure. */ - const runExtensionCommand = useCallback( - (registered: RegisteredCommand) => { - const report = (error: unknown) => { - extensions?.context.notify( - `Extension ${registered.extensionId} failed command "${registered.command.id}" • ` + - `${error instanceof Error ? error.message || error.name : String(error)}`, - "warning", - ); - }; - const panes = createPaneControls(registered.extensionId); - const ctx: ExtensionCommandContext = { - cwd: extensions?.context.cwd ?? process.cwd(), - commands: extensionCommandControls, - keyboardModes: createKeyboardModeControls(registered.extensionId, extensions?.registry), - notify: (message, type) => extensions?.context.notify(message, type), - panes, - 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. - selection: getExtensionSelection(), - // Bound to the requesting extension for attribution, and valid for the - // whole life of the handler's promise — a handler may ask several - // questions in sequence with work between them. - dialogs: createExtensionDialogs(registered.extensionId), - // Bound to the requesting extension the same way, because a write is a - // question first: the confirm it raises names this extension, and the - // review it may reload is read live rather than captured here. - workspace: extensionWorkspaceController.createWorkspaceControls(registered.extensionId), - // Live, unlike `selection`: reads the visible files and delegates to - // the same focus/jump callbacks a sidebar row click runs, so a handler - // that awaits a dialog before navigating still acts on the current - // review — validated, clamped, and warned exactly like sidebar actions. - navigation: createExtensionNavigation(registered.extensionId), - }; - - try { - const returned = registered.handler(ctx); - if (returned && typeof (returned as PromiseLike).then === "function") { - Promise.resolve(returned).catch(report); - } - } catch (error) { - report(error); - } - }, - // `getExtensionSelection` is identity-stable (it reads refs), so the - // dispatch table, keymap, and Extensions menu derived from this callback - // do not rebuild on every `[`/`]` press. - [ - createExtensionDialogs, - createExtensionNavigation, - createExtensionReviewControls, - createFileViewControls, - createKeyboardModeControls, - createLineHighlightControls, - createPaneControls, - extensionCommandControls, - extensionWorkspaceController.createWorkspaceControls, - extensions, - getExtensionSelection, - ], - ); + useExtensionEventContextProvider({ + createDialogs: createExtensionDialogs, + createNavigation: createExtensionNavigation, + createPaneControls, + extensions, + }); + + const runExtensionCommand = useExtensionCommandRunner({ + commandControls: extensionCommandControls, + createDialogs: createExtensionDialogs, + createFileViewControls, + createKeyboardModeControls, + createLineHighlightControls, + createNavigation: createExtensionNavigation, + createPaneControls, + createReviewControls: createExtensionReviewControls, + createWorkspaceControls: extensionWorkspaceController.createWorkspaceControls, + extensions, + getSelection: getExtensionSelection, + }); const registeredExtensionCommands = useMemo( () => (extensions ? resolveExtensionCommands(extensions.registry).commands : []), @@ -1106,25 +914,23 @@ export function App({ setFocusArea("filter"); }, []); - // Command-handler navigation lands here each render: the same focus and jump - // semantics the sidebar's onSelect handlers use, so a command's navigation is - // indistinguishable from a sidebar row click. Read through a ref because the - // command dispatch table is built above these helpers and must stay - // identity-stable while the review moves. - extensionCommandNavigationRef.current = { - onSelectFile: (fileId) => { - focusFiles(); - jumpToFile(fileId, { alignFileHeaderTop: true }); - }, - onSelectHunk: (fileId, hunkIndex) => { - focusFiles(); - review.selectHunk(fileId, hunkIndex); - }, - onRevealLine: (fileId, side, line) => { - focusFiles(); - return review.revealLine(fileId, side, line); - }, - }; + const extensionNavigationBindings = useMemo( + () => ({ + onSelectFile: (fileId: string) => { + focusFiles(); + jumpToFile(fileId, { alignFileHeaderTop: true }); + }, + onSelectHunk: (fileId: string, hunkIndex: number) => { + focusFiles(); + review.selectHunk(fileId, hunkIndex); + }, + onRevealLine: (fileId: string, side: "old" | "new", line: number) => { + focusFiles(); + return review.revealLine(fileId, side, line); + }, + }), + [focusFiles, jumpToFile, review.revealLine, review.selectHunk], + ); /** Toggle keyboard focus between the file list and the file filter. */ const toggleFocusArea = useCallback(() => { @@ -1204,7 +1010,11 @@ export function App({ ], publishCommandExecuted, ); - extensionHostCommandsRef.current = appCommands; + useExtensionRuntimeBindings({ + commands: appCommands, + navigation: extensionNavigationBindings, + runtime: extensionRuntime, + }); // Menus name commands rather than repeating them: every item's key hint and // action come from the table above, so a remapped shortcut shows its new key @@ -1313,7 +1123,7 @@ export function App({ /** Render one pane from the exact accepted host rectangle. */ const renderPane = (planned: PlannedPane) => { - const selection = getExtensionSelection(); + const selection = getRenderExtensionSelection(); const { bounds, pane } = planned; return ( panes, + extensions = createEmptyExtensionLoadResult("/repo"), +}: { + createPanes?: () => ExtensionPaneControls; + extensions?: ReturnType; +} = {}) { + let run!: (registered: RegisteredCommand) => void; + + function Harness() { + run = useExtensionCommandRunner({ + commandControls, + createDialogs: () => dialogs, + createFileViewControls: () => fileViews, + createKeyboardModeControls: () => keyboardModes, + createLineHighlightControls: () => highlights, + createNavigation: () => navigation, + createPaneControls: createPanes, + createReviewControls: () => review, + createWorkspaceControls: () => workspace, + extensions, + getSelection: () => selection, + }); + return runner; + } + + const setup = await testRender(, { width: 20, height: 2 }); + await act(async () => setup.renderOnce()); + return { current: () => run, extensions, setup }; +} + +/** Build one registered command around a test handler. */ +function command(handler: RegisteredCommand["handler"]): RegisteredCommand { + return { + extensionId: "probe", + command: { id: "run", title: "Run", key: "y" }, + handler, + }; +} + +describe("useExtensionCommandRunner", () => { + test("composes every public capability and freezes selection at invocation", async () => { + const harness = await renderRunner(); + let context: ExtensionCommandContext | undefined; + + try { + harness.current()( + command((ctx) => { + context = ctx; + }), + ); + + expect(context).toMatchObject({ + commands: commandControls, + dialogs, + fileViews, + highlights, + keyboardModes, + navigation, + panes, + review, + selection, + sidebars: panes, + workspace, + }); + expect(context?.cwd).toBe("/repo"); + expect(Object.isFrozen(context?.selection)).toBe(true); + } finally { + await act(async () => harness.setup.renderer.destroy()); + } + }); + + test("contains context-construction and handler throws with the attributed warning", async () => { + const harness = await renderRunner({ + createPanes: () => { + throw new Error("context boom"); + }, + }); + const notifications: Array<{ message: string; type: string }> = []; + const unsubscribe = harness.extensions.notifications.subscribe((notification) => + notifications.push(notification), + ); + + try { + expect(() => harness.current()(command(() => {}))).not.toThrow(); + expect(notifications.map(({ message, type }) => ({ message, type }))).toEqual([ + { + message: 'Extension probe failed command "run" • context boom', + type: "warning", + }, + ]); + } finally { + unsubscribe(); + await act(async () => harness.setup.renderer.destroy()); + } + }); + + test("contains synchronous handler throws with the same attributed warning", async () => { + const harness = await renderRunner(); + const notifications: Array<{ message: string; type: string }> = []; + const unsubscribe = harness.extensions.notifications.subscribe((notification) => + notifications.push(notification), + ); + + try { + expect(() => + harness.current()( + command(() => { + throw new Error("sync boom"); + }), + ), + ).not.toThrow(); + expect(notifications.map(({ message, type }) => ({ message, type }))).toEqual([ + { + message: 'Extension probe failed command "run" • sync boom', + type: "warning", + }, + ]); + } finally { + unsubscribe(); + await act(async () => harness.setup.renderer.destroy()); + } + }); + + test("contains rejected handler promises with the same attributed warning", async () => { + const harness = await renderRunner(); + const notifications: Array<{ message: string; type: string }> = []; + const unsubscribe = harness.extensions.notifications.subscribe((notification) => + notifications.push(notification), + ); + + try { + harness.current()(command(async () => Promise.reject("async boom"))); + await act(async () => Bun.sleep(0)); + expect(notifications.map(({ message, type }) => ({ message, type }))).toEqual([ + { + message: 'Extension probe failed command "run" • async boom', + type: "warning", + }, + ]); + } finally { + unsubscribe(); + await act(async () => harness.setup.renderer.destroy()); + } + }); +}); diff --git a/src/ui/hooks/useExtensionCommandRunner.ts b/src/ui/hooks/useExtensionCommandRunner.ts new file mode 100644 index 00000000..f1812d1c --- /dev/null +++ b/src/ui/hooks/useExtensionCommandRunner.ts @@ -0,0 +1,111 @@ +/** + * Builds extension command contexts and contains failures at the command boundary. + * + * Each invocation freezes selection and review-bound controls at the keypress that started it, + * while navigation and public Hunk commands continue reading committed App state after awaits. + * Context construction, synchronous handler throws, and rejected handler promises all report the + * existing attributed warning without escaping into keyboard or menu dispatch. + */ + +import { useCallback } from "react"; +import type { + ExtensionCommandContext, + ExtensionCommandControls, + ExtensionDialogs, + ExtensionFileViewControls, + ExtensionKeyboardModeControls, + ExtensionLineHighlightControls, + ExtensionPaneControls, + ExtensionReviewControls, + ExtensionReviewNavigation, + ExtensionReviewSelection, + ExtensionWorkspace, +} from "../../extension-api/types"; +import type { ExtensionLoadResult, RegisteredCommand } from "../../extensions/types"; + +/** Describe an extension command failure without assuming an Error instance. */ +function commandFailureMessage(registered: RegisteredCommand, error: unknown) { + const detail = error instanceof Error ? error.message || error.name : String(error); + return ( + `Extension ${registered.extensionId} failed command "${registered.command.id}" • ` + detail + ); +} + +/** Construct and invoke extension commands against the current committed runtime. */ +export function useExtensionCommandRunner({ + commandControls, + createDialogs, + createFileViewControls, + createKeyboardModeControls, + createLineHighlightControls, + createNavigation, + createPaneControls, + createReviewControls, + createWorkspaceControls, + extensions, + getSelection, +}: { + commandControls: ExtensionCommandControls; + createDialogs: (extensionId: string) => ExtensionDialogs; + createFileViewControls: (extensionId: string) => ExtensionFileViewControls; + createKeyboardModeControls: ( + extensionId: string, + registry: ExtensionLoadResult["registry"] | undefined, + ) => ExtensionKeyboardModeControls; + createLineHighlightControls: (extensionId: string) => ExtensionLineHighlightControls; + createNavigation: (extensionId: string) => ExtensionReviewNavigation; + createPaneControls: (extensionId: string) => ExtensionPaneControls; + createReviewControls: () => ExtensionReviewControls; + createWorkspaceControls: (extensionId: string) => ExtensionWorkspace; + extensions?: ExtensionLoadResult; + getSelection: () => ExtensionReviewSelection; +}) { + return useCallback( + (registered: RegisteredCommand) => { + const report = (error: unknown) => { + extensions?.context.notify(commandFailureMessage(registered, error), "warning"); + }; + + try { + const panes = createPaneControls(registered.extensionId); + // Build the complete context before invoking the handler; selection is frozen here. + const context: ExtensionCommandContext = { + cwd: extensions?.context.cwd ?? process.cwd(), + commands: commandControls, + keyboardModes: createKeyboardModeControls(registered.extensionId, extensions?.registry), + notify: (message, type) => extensions?.context.notify(message, type), + panes, + sidebars: panes, + fileViews: createFileViewControls(registered.extensionId), + highlights: createLineHighlightControls(registered.extensionId), + review: createReviewControls(), + selection: getSelection(), + dialogs: createDialogs(registered.extensionId), + workspace: createWorkspaceControls(registered.extensionId), + navigation: createNavigation(registered.extensionId), + }; + + const returned = registered.handler(context); + // Route async rejections through the same warning as synchronous failures. + if (returned && typeof (returned as PromiseLike).then === "function") { + Promise.resolve(returned).catch(report); + } + } catch (error) { + report(error); + } + }, + [ + commandControls, + createDialogs, + createFileViewControls, + createKeyboardModeControls, + createLineHighlightControls, + createNavigation, + createPaneControls, + createReviewControls, + createWorkspaceControls, + extensions, + getSelection, + ], + ); +} diff --git a/src/ui/hooks/useExtensionEventContextProvider.test.tsx b/src/ui/hooks/useExtensionEventContextProvider.test.tsx new file mode 100644 index 00000000..ec3e5130 --- /dev/null +++ b/src/ui/hooks/useExtensionEventContextProvider.test.tsx @@ -0,0 +1,133 @@ +import { describe, expect, test } from "bun:test"; +import { testRender } from "@opentui/react/test-utils"; +import { act, StrictMode, useLayoutEffect, useState } from "react"; +import type { + ExtensionDialogs, + ExtensionPaneControls, + ExtensionReviewNavigation, +} from "../../extension-api/types"; +import { createEmptyExtensionLoadResult } from "../../extensions/types"; +import { useExtensionEventContextProvider } from "./useExtensionEventContextProvider"; + +const dialogs = {} as ExtensionDialogs; +const navigation = {} as ExtensionReviewNavigation; +const panes = {} as ExtensionPaneControls; +const createDialogs = () => dialogs; +const createNavigation = () => navigation; +const createPaneControls = () => panes; + +/** Flush layout and passive work in an OpenTUI hook harness. */ +async function settle(setup: Awaited>) { + await act(async () => { + await setup.renderOnce(); + await Bun.sleep(0); + await setup.renderOnce(); + }); +} + +describe("useExtensionEventContextProvider", () => { + test("installs before later layout effects, survives StrictMode replay, and cleans up", async () => { + const extensions = createEmptyExtensionLoadResult("/repo"); + const layoutObservations: boolean[] = []; + + function Harness() { + useExtensionEventContextProvider({ + createDialogs, + createNavigation, + createPaneControls, + extensions, + }); + useLayoutEffect(() => { + layoutObservations.push(Boolean(extensions.eventContextProvider)); + }, []); + return provider; + } + + const setup = await testRender( + + + , + { width: 20, height: 2 }, + ); + await settle(setup); + + expect(layoutObservations.length).toBeGreaterThan(0); + expect(layoutObservations.every(Boolean)).toBe(true); + expect(extensions.eventContextProvider?.("probe")).toMatchObject({ + cwd: "/repo", + dialogs, + navigation, + panes, + sidebars: panes, + }); + + await act(async () => setup.renderer.destroy()); + expect(extensions.eventContextProvider).toBeUndefined(); + }); + + test("registry replacement removes the retired provider and installs its successor", async () => { + const first = createEmptyExtensionLoadResult("/repo/first"); + const second = createEmptyExtensionLoadResult("/repo/second"); + let replace!: () => void; + + function Harness() { + const [extensions, setExtensions] = useState(first); + replace = () => setExtensions(second); + useExtensionEventContextProvider({ + createDialogs, + createNavigation, + createPaneControls, + extensions, + }); + return {extensions.context.cwd}; + } + + const setup = await testRender(, { width: 30, height: 2 }); + await settle(setup); + const firstProvider = first.eventContextProvider; + + await act(async () => replace()); + await settle(setup); + + expect(first.eventContextProvider).toBeUndefined(); + expect(second.eventContextProvider).toBeDefined(); + expect(second.eventContextProvider).not.toBe(firstProvider); + await act(async () => setup.renderer.destroy()); + }); + + test("stale cleanup cannot clear a provider installed by a sibling", async () => { + const extensions = createEmptyExtensionLoadResult("/repo"); + let removeFirst!: () => void; + + function Provider({ marker }: { marker: string }) { + useExtensionEventContextProvider({ + createDialogs, + createNavigation, + createPaneControls, + extensions, + }); + return {marker}; + } + + function Harness() { + const [showFirst, setShowFirst] = useState(true); + removeFirst = () => setShowFirst(false); + return ( + + {showFirst ? : null} + + + ); + } + + const setup = await testRender(, { width: 30, height: 2 }); + await settle(setup); + const successor = extensions.eventContextProvider; + + await act(async () => removeFirst()); + await settle(setup); + + expect(extensions.eventContextProvider).toBe(successor); + await act(async () => setup.renderer.destroy()); + }); +}); diff --git a/src/ui/hooks/useExtensionEventContextProvider.ts b/src/ui/hooks/useExtensionEventContextProvider.ts new file mode 100644 index 00000000..52a8f653 --- /dev/null +++ b/src/ui/hooks/useExtensionEventContextProvider.ts @@ -0,0 +1,61 @@ +/** + * Installs the controls lifecycle and custom-event handlers receive after App commits. + * + * AppHost publishes startup and reload events from its parent layout effect, after this child + * effect has attached pane, navigation, dialog, and bus controls for the committed review. Registry + * replacement and unmount remove only the provider this hook installed, so stale cleanup cannot + * detach a successor runtime. + */ + +import { useLayoutEffect } from "react"; +import type { + ExtensionDialogs, + ExtensionEventContext, + ExtensionPaneControls, + ExtensionReviewNavigation, +} from "../../extension-api/types"; +import { emitExtensionCustomEvent } from "../../extensions/events"; +import type { ExtensionLoadResult } from "../../extensions/types"; + +/** Attach one committed extension event-context provider with identity-checked cleanup. */ +export function useExtensionEventContextProvider({ + createDialogs, + createNavigation, + createPaneControls, + extensions, +}: { + createDialogs: (extensionId: string) => ExtensionDialogs; + createNavigation: (extensionId: string) => ExtensionReviewNavigation; + createPaneControls: (extensionId: string) => ExtensionPaneControls; + extensions?: ExtensionLoadResult; +}) { + useLayoutEffect(() => { + if (!extensions) return; + + const provider = (extensionId: string): ExtensionEventContext => { + const panes = createPaneControls(extensionId); + return { + cwd: extensions.context.cwd, + notify: (message, type) => extensions.context.notify(message, type), + panes, + sidebars: panes, + navigation: createNavigation(extensionId), + dialogs: createDialogs(extensionId), + events: { + emit(event, payload) { + emitExtensionCustomEvent(extensions, event, payload); + }, + }, + }; + }; + + // Install after commit so lifecycle events cannot capture controls from abandoned renders. + extensions.eventContextProvider = provider; + return () => { + // Preserve a newer provider installed by a successor App instance. + if (extensions.eventContextProvider === provider) { + delete extensions.eventContextProvider; + } + }; + }, [createDialogs, createNavigation, createPaneControls, extensions]); +} diff --git a/src/ui/hooks/useExtensionRuntimeBridge.test.tsx b/src/ui/hooks/useExtensionRuntimeBridge.test.tsx new file mode 100644 index 00000000..57eff2b0 --- /dev/null +++ b/src/ui/hooks/useExtensionRuntimeBridge.test.tsx @@ -0,0 +1,312 @@ +import { describe, expect, test } from "bun:test"; +import { testRender } from "@opentui/react/test-utils"; +import { act, StrictMode, useState } from "react"; +import { createTestVcsAppBootstrap } from "../../../test/helpers/app-bootstrap"; +import { createTestDiffFile } from "../../../test/helpers/diff-helpers"; +import { createTestReviewState } from "../../../test/helpers/review-store-helpers"; +import type { AppBootstrap } from "../../core/bootstrap"; +import type { DiffFile } from "../../core/changeset/model"; +import { createEmptyExtensionLoadResult, type ExtensionLoadResult } from "../../extensions/types"; +import type { AppCommand } from "../lib/appCommands"; +import { + useExtensionRuntimeBindings, + useExtensionRuntimeBridge, + type ExtensionRuntimeBridge, +} from "./useExtensionRuntimeBridge"; + +interface RuntimeFacts { + extensions: ExtensionLoadResult; + files: DiffFile[]; + reviewGeneration: AppBootstrap; + selectedFileId: string | null; + selectedHunkIndex: number | null; +} + +/** Build one public host command for command-control liveness tests. */ +function createTestCommand(run: () => void): AppCommand { + return { + id: "hunk.test.run", + title: "Run test command", + keys: [], + keyLabels: [], + match: () => false, + publicToExtensions: true, + run, + }; +} + +/** Mount the runtime bridge with mutable registry, review, and selection facts. */ +async function renderRuntime(initialFacts: RuntimeFacts, strict = false) { + let runtime!: ExtensionRuntimeBridge; + let updateFacts!: (update: Partial) => void; + const navigationCalls: string[] = []; + let commandRuns = 0; + const commands = [createTestCommand(() => commandRuns++)]; + const reviewState = createTestReviewState([ + { key: "alpha", path: "alpha.ts", contentIdentity: "sha256:alpha" }, + ]); + + function Harness() { + const [facts, setFacts] = useState(initialFacts); + updateFacts = (update) => setFacts((current) => ({ ...current, ...update })); + runtime = useExtensionRuntimeBridge({ + extensions: facts.extensions, + files: facts.files, + getActiveLineCursor: () => null, + getSelection: () => ({ + fileId: facts.selectedFileId, + hunkIndex: facts.selectedHunkIndex, + }), + reviewGeneration: facts.reviewGeneration, + reviewProducer: { + getPositionedReviewState: () => ({ + generation: facts.reviewGeneration.changeset.id, + state: reviewState, + }), + }, + }); + useExtensionRuntimeBindings({ + commands, + navigation: { + onSelectFile: (fileId) => navigationCalls.push(`file:${fileId}`), + onSelectHunk: (fileId, hunkIndex) => navigationCalls.push(`hunk:${fileId}:${hunkIndex}`), + onRevealLine: (fileId, side, line) => { + navigationCalls.push(`line:${fileId}:${side}:${line}`); + return "line"; + }, + }, + runtime, + }); + return {facts.selectedFileId ?? "none"}; + } + + const tree = ; + const setup = await testRender(strict ? {tree} : tree, { + width: 40, + height: 2, + }); + const settle = async () => { + await act(async () => { + await setup.renderOnce(); + await Bun.sleep(0); + await setup.renderOnce(); + }); + }; + await settle(); + + return { + commandRuns: () => commandRuns, + current: () => runtime, + navigationCalls, + settle, + setup, + updateFacts, + }; +} + +/** Build one bootstrap identity for review-generation authority tests. */ +function createBootstrap(id: string): AppBootstrap { + return createTestVcsAppBootstrap({ + changesetId: id, + files: [createTestDiffFile({ id: "alpha", path: "alpha.ts" })], + initialMode: "stack", + }); +} + +/** Destroy a mounted runtime harness. */ +async function destroy(setup: Awaited>) { + await act(async () => setup.renderer.destroy()); +} + +describe("useExtensionRuntimeBridge", () => { + test("restores authority after StrictMode replay and revokes it during layout cleanup", async () => { + const extensions = createEmptyExtensionLoadResult("/repo"); + const bootstrap = createBootstrap("runtime:strict"); + const file = bootstrap.changeset.files[0]!; + const harness = await renderRuntime( + { + extensions, + files: [file], + reviewGeneration: bootstrap, + selectedFileId: file.id, + selectedHunkIndex: 0, + }, + true, + ); + const controls = harness.current().commandControls; + + expect(controls.isEnabled("hunk.test.run")).toBe(true); + expect(controls.execute("hunk.test.run")).toBe(true); + expect(harness.commandRuns()).toBe(1); + + await destroy(harness.setup); + expect(controls.isEnabled("hunk.test.run")).toBe(false); + expect(controls.execute("hunk.test.run")).toBe(false); + }); + + test("hard remount retires predecessor controls while the same-registry successor stays live", async () => { + const extensions = createEmptyExtensionLoadResult("/repo"); + const first = createBootstrap("runtime:hard:first"); + const second = createBootstrap("runtime:hard:second"); + const runtimes = new Map(); + let mountSuccessor!: () => void; + let commandRuns = 0; + const commands = [createTestCommand(() => commandRuns++)]; + + function RuntimeInstance({ generation }: { generation: AppBootstrap }) { + const runtime = useExtensionRuntimeBridge({ + extensions, + files: generation.changeset.files, + getActiveLineCursor: () => null, + getSelection: () => ({ fileId: "alpha", hunkIndex: 0 }), + reviewGeneration: generation, + }); + useExtensionRuntimeBindings({ + commands, + navigation: { + onSelectFile: () => {}, + onSelectHunk: () => {}, + onRevealLine: () => "line", + }, + runtime, + }); + runtimes.set(generation.changeset.id, runtime); + return {generation.changeset.id}; + } + + function Harness() { + const [successor, setSuccessor] = useState(false); + mountSuccessor = () => setSuccessor(true); + const generation = successor ? second : first; + return ; + } + + const setup = await testRender(, { width: 40, height: 2 }); + const settle = async () => { + await act(async () => { + await setup.renderOnce(); + await Bun.sleep(0); + await setup.renderOnce(); + }); + }; + await settle(); + const predecessorControls = runtimes.get(first.changeset.id)!.commandControls; + expect(predecessorControls.isEnabled("hunk.test.run")).toBe(true); + + await act(async () => mountSuccessor()); + await settle(); + + const successorControls = runtimes.get(second.changeset.id)!.commandControls; + expect(predecessorControls.isEnabled("hunk.test.run")).toBe(false); + expect(predecessorControls.execute("hunk.test.run")).toBe(false); + expect(successorControls.isEnabled("hunk.test.run")).toBe(true); + expect(successorControls.execute("hunk.test.run")).toBe(true); + expect(commandRuns).toBe(1); + await destroy(setup); + }); + + test("keeps runtime commands across content reloads while expiring review-bound controls", async () => { + const extensions = createEmptyExtensionLoadResult("/repo"); + const first = createBootstrap("runtime:first"); + const second = createBootstrap("runtime:second"); + const harness = await renderRuntime({ + extensions, + files: first.changeset.files, + reviewGeneration: first, + selectedFileId: "alpha", + selectedHunkIndex: 0, + }); + + try { + const commandControls = harness.current().commandControls; + const predecessorLease = harness.current().createReviewCapabilityLease(); + const predecessorNavigation = harness.current().createNavigation("probe"); + const predecessorReview = harness.current().createReviewControls(); + expect(predecessorReview.snapshot()?.generation).toBe(first.changeset.id); + + await act(async () => + harness.updateFacts({ + files: second.changeset.files, + reviewGeneration: second, + }), + ); + await harness.settle(); + + expect(commandControls.isEnabled("hunk.test.run")).toBe(true); + expect(predecessorLease.isLive()).toBe(false); + expect(predecessorReview.snapshot()).toBeNull(); + predecessorNavigation.selectFile("alpha"); + expect(harness.navigationCalls).toEqual([]); + + const successorLease = harness.current().createReviewCapabilityLease(); + const successorNavigation = harness.current().createNavigation("probe"); + const successorReview = harness.current().createReviewControls(); + expect(successorLease.isLive()).toBe(true); + expect(successorReview.snapshot()?.generation).toBe(second.changeset.id); + successorNavigation.selectFile("alpha"); + expect(harness.navigationCalls).toEqual(["file:alpha"]); + } finally { + await destroy(harness.setup); + } + }); + + test("retires controls from a replaced registry without letting them drive its successor", async () => { + const firstExtensions = createEmptyExtensionLoadResult("/repo/first"); + const secondExtensions = createEmptyExtensionLoadResult("/repo/second"); + const bootstrap = createBootstrap("runtime:registry"); + const harness = await renderRuntime({ + extensions: firstExtensions, + files: bootstrap.changeset.files, + reviewGeneration: bootstrap, + selectedFileId: "alpha", + selectedHunkIndex: 0, + }); + + try { + const stale = harness.current().commandControls; + await act(async () => harness.updateFacts({ extensions: secondExtensions })); + await harness.settle(); + + expect(stale.isEnabled("hunk.test.run")).toBe(false); + expect(harness.current().commandControls.isEnabled("hunk.test.run")).toBe(true); + } finally { + await destroy(harness.setup); + } + }); + + test("freezes invocation selection while navigation reads the latest committed bindings", async () => { + const extensions = createEmptyExtensionLoadResult("/repo"); + const bootstrap = createBootstrap("runtime:live"); + const alpha = createTestDiffFile({ id: "alpha", path: "alpha.ts" }); + const beta = createTestDiffFile({ id: "beta", path: "beta.ts" }); + const harness = await renderRuntime({ + extensions, + files: [alpha, beta], + reviewGeneration: bootstrap, + selectedFileId: "alpha", + selectedHunkIndex: 0, + }); + + try { + const selection = harness.current().getSelection(); + const navigation = harness.current().createNavigation("probe"); + + await act(async () => + harness.updateFacts({ + files: [beta], + selectedFileId: "beta", + selectedHunkIndex: 0, + }), + ); + await harness.settle(); + + expect(selection.file?.id).toBe("alpha"); + expect(Object.isFrozen(selection)).toBe(true); + expect(Object.isFrozen(selection.file)).toBe(true); + navigation.selectFile("beta"); + expect(harness.navigationCalls).toEqual(["file:beta"]); + } finally { + await destroy(harness.setup); + } + }); +}); diff --git a/src/ui/hooks/useExtensionRuntimeBridge.ts b/src/ui/hooks/useExtensionRuntimeBridge.ts new file mode 100644 index 00000000..ee432f13 --- /dev/null +++ b/src/ui/hooks/useExtensionRuntimeBridge.ts @@ -0,0 +1,264 @@ +/** + * Keeps extension capabilities aligned with the App and review generation that committed them. + * + * Commands, lifecycle events, panes, dialogs, and workspace operations all mint controls through + * this bridge. Runtime-level command controls survive content-only reloads, while review-bound + * controls expire when the mounted bootstrap changes. Hard remounts and registry replacement + * synchronously retire captured authority before AppHost publishes lifecycle events. + * + * App still composes commands and navigation behavior. This hook owns the committed refs, public + * review projections, capability leases, and liveness checks those surfaces share. + */ + +import { useCallback, useLayoutEffect, useMemo, useRef } from "react"; +import type { AppBootstrap } from "../../core/bootstrap"; +import type { DiffFile } from "../../core/changeset/model"; +import type { ReviewState } from "../../core/review/state"; +import type { + ExtensionCommandControls, + ExtensionReviewControls, + ExtensionReviewNavigation, + ExtensionReviewSelection, +} from "../../extension-api/types"; +import { toReadOnlyFileViews } from "../../extensions/events"; +import { buildExtensionReviewSnapshot } from "../../extensions/reviewSnapshot"; +import type { ExtensionLoadResult } from "../../extensions/types"; +import type { RevealedLineResult } from "./useTerminalReview"; +import type { AppCommand } from "../lib/appCommands"; +import { + createExtensionCapabilityLease, + type ExtensionCapabilityLease, +} from "../lib/extensionCapabilityLease"; +import { createExtensionCommandControls } from "../lib/extensionCommandControls"; +import { createGuardedReviewNavigation } from "../lib/extensionNavigation"; +import { buildExtensionReviewSelection } from "../lib/extensionSelection"; +import type { LineCursor } from "../lib/lineCursors"; + +export interface ExtensionRuntimeNavigationBindings { + onSelectFile: (fileId: string) => void; + onSelectHunk: (fileId: string, hunkIndex: number) => void; + onRevealLine: (fileId: string, side: "old" | "new", line: number) => RevealedLineResult; +} + +export interface ExtensionRuntimeBridge { + commandControls: ExtensionCommandControls; + /** Mint controls owned by the current registry and review generation. */ + createReviewCapabilityLease: () => ExtensionCapabilityLease; + /** Build live navigation whose targets are resolved after awaited extension work. */ + createNavigation: (extensionId: string) => ExtensionReviewNavigation; + /** Mint a review snapshot reader owned by the current review generation. */ + createReviewControls: () => ExtensionReviewControls; + /** Read public file views for the latest committed review. */ + getCommittedFileViews: () => ReturnType; + /** Project public file views for the render currently in progress. */ + getRenderFileViews: () => ReturnType; + /** Snapshot the latest committed semantic selection at command invocation. */ + getSelection: () => ExtensionReviewSelection; + /** Project selection for the render currently in progress. */ + getRenderSelection: () => ExtensionReviewSelection; + /** Read the latest committed internal selected file id. */ + getSelectedFileId: () => string | null; + /** Commit App-owned commands and navigation after the matching render succeeds. */ + commitBindings: ( + commands: readonly AppCommand[], + navigation: ExtensionRuntimeNavigationBindings, + ) => void; +} + +interface ReviewSnapshotProducer { + getPositionedReviewState: () => { generation: string; state: ReviewState } | undefined; +} + +interface SelectionInputs { + files: readonly DiffFile[]; + getSelection: () => { fileId: string | null; hunkIndex: number | null }; + getActiveLineCursor: () => Pick | null; +} + +const unavailableNavigation: ExtensionRuntimeNavigationBindings = { + onSelectFile: () => {}, + onSelectHunk: () => {}, + onRevealLine: () => "none", +}; + +/** Coordinate committed extension authority and public review projections. */ +export function useExtensionRuntimeBridge({ + extensions, + files, + getActiveLineCursor, + getSelection, + reviewGeneration, + reviewProducer, +}: { + extensions?: ExtensionLoadResult; + files: readonly DiffFile[]; + getActiveLineCursor: SelectionInputs["getActiveLineCursor"]; + getSelection: SelectionInputs["getSelection"]; + reviewGeneration: AppBootstrap; + reviewProducer?: ReviewSnapshotProducer; +}): ExtensionRuntimeBridge { + const appAliveRef = useRef(false); + const activeRegistryRef = useRef(extensions?.registry); + const activeReviewGenerationRef = useRef(reviewGeneration); + const committedSelectionRef = useRef({ + files, + getSelection, + getActiveLineCursor, + }); + const commandsRef = useRef([]); + const navigationRef = useRef(unavailableNavigation); + const fileViewsCacheRef = useRef<{ + source: readonly DiffFile[]; + views: ReturnType; + } | null>(null); + + // Cache public file objects until the underlying visible-file list changes. + const projectFileViews = useCallback((source: readonly DiffFile[]) => { + const cache = fileViewsCacheRef.current; + if (cache?.source === source) return cache.views; + + const views = toReadOnlyFileViews(source); + fileViewsCacheRef.current = { source, views }; + return views; + }, []); + + // Commit liveness and review facts before AppHost publishes lifecycle events. + useLayoutEffect(() => { + // Child layout effects commit before AppHost publishes startup/reload lifecycle events. + appAliveRef.current = true; + activeRegistryRef.current = extensions?.registry; + activeReviewGenerationRef.current = reviewGeneration; + committedSelectionRef.current = { files, getSelection, getActiveLineCursor }; + + return () => { + // Layout cleanup closes the hard-remount window before the parent can publish a successor. + appAliveRef.current = false; + }; + }, [extensions?.registry, files, getActiveLineCursor, getSelection, reviewGeneration]); + + const getCommittedFileViews = useCallback( + () => projectFileViews(committedSelectionRef.current.files), + [projectFileViews], + ); + const getRenderFileViews = useCallback(() => projectFileViews(files), [files, projectFileViews]); + + // Command controls survive content reloads but expire with the App or registry. + const commandControls = useMemo(() => { + const lease = createExtensionCapabilityLease({ + owningRegistry: extensions?.registry, + getActiveRegistry: () => activeRegistryRef.current, + isAppAlive: () => appAliveRef.current, + }); + return createExtensionCommandControls({ + getCommands: () => commandsRef.current, + isLive: lease.isLive, + }); + }, [extensions?.registry]); + + // Review controls also expire when the mounted review generation changes. + const createReviewCapabilityLease = useCallback( + () => + createExtensionCapabilityLease({ + owningRegistry: extensions?.registry, + getActiveRegistry: () => activeRegistryRef.current, + isAppAlive: () => appAliveRef.current, + isReviewCurrent: () => activeReviewGenerationRef.current === reviewGeneration, + }), + [extensions?.registry, reviewGeneration], + ); + + // Freeze public selection from the latest committed review when a command starts. + const getPublicSelection = useCallback(() => { + const current = committedSelectionRef.current; + const { fileId, hunkIndex } = current.getSelection(); + return buildExtensionReviewSelection({ + files: projectFileViews(current.files), + selectedFileId: fileId, + selectedHunkIndex: hunkIndex, + lineCursor: current.getActiveLineCursor(), + }); + }, [projectFileViews]); + + const getRenderSelection = useCallback(() => { + const { fileId, hunkIndex } = getSelection(); + return buildExtensionReviewSelection({ + files: projectFileViews(files), + selectedFileId: fileId, + selectedHunkIndex: hunkIndex, + lineCursor: getActiveLineCursor(), + }); + }, [files, getActiveLineCursor, getSelection, projectFileViews]); + + const getSelectedFileId = useCallback( + () => committedSelectionRef.current.getSelection().fileId, + [], + ); + + // Resolve navigation targets and callbacks at call time, including after awaits. + const createNavigation = useCallback( + (extensionId: string) => { + const lease = createReviewCapabilityLease(); + return createGuardedReviewNavigation({ + extensionId, + getFiles: () => committedSelectionRef.current.files, + isLive: lease.isLive, + notify: (message, type) => extensions?.context.notify(message, type), + onSelectFile: (fileId) => navigationRef.current.onSelectFile(fileId), + onSelectHunk: (fileId, hunkIndex) => navigationRef.current.onSelectHunk(fileId, hunkIndex), + onRevealLine: (fileId, side, line) => + navigationRef.current.onRevealLine(fileId, side, line), + }); + }, + [createReviewCapabilityLease, extensions], + ); + + // Read snapshots only while the captured review generation is still current. + const createReviewControls = useCallback(() => { + const lease = createReviewCapabilityLease(); + return Object.freeze({ + snapshot() { + if (!lease.isLive()) return null; + const positioned = reviewProducer?.getPositionedReviewState(); + if (!positioned) return null; + return buildExtensionReviewSnapshot(positioned.generation, positioned.state); + }, + }); + }, [createReviewCapabilityLease, reviewProducer]); + + // Publish App-owned commands and navigation only after their render commits. + const commitBindings = useCallback( + (commands: readonly AppCommand[], navigation: ExtensionRuntimeNavigationBindings) => { + commandsRef.current = commands; + navigationRef.current = navigation; + }, + [], + ); + + return { + commandControls, + createReviewCapabilityLease, + createNavigation, + createReviewControls, + getCommittedFileViews, + getRenderFileViews, + getSelection: getPublicSelection, + getRenderSelection, + getSelectedFileId, + commitBindings, + }; +} + +/** Commit App-owned command and navigation bindings after their render succeeds. */ +export function useExtensionRuntimeBindings({ + commands, + navigation, + runtime, +}: { + commands: readonly AppCommand[]; + navigation: ExtensionRuntimeNavigationBindings; + runtime: ExtensionRuntimeBridge; +}) { + useLayoutEffect(() => { + runtime.commitBindings(commands, navigation); + }, [commands, navigation, runtime]); +}