/** * `PluginRuntimeProvider` — bootstraps the plugin runtime once per app session * (ticket #43, F1, carnet §1.3): fetches the runtime catalog (already filtered * to `enabled && !pendingUninstall` by the backend) and loads every bundle via * {@link loadPlugins}, then exposes the resulting {@link PluginRuntimeRegistry} * to the rest of the tree (menus in F3, layouts in F4). * * Mounted once near the app root, inside `` (it reads gateways via * `useGateways()`). Never re-runs within a session — carnet §1.3: disable/ * uninstall only mask contributions in-session; the actual reload happens on * the next app start (a fresh mount of this provider). */ import { createContext, useContext, useEffect, useState, type ReactNode } from "react"; import { loadPlugins, PluginRuntimeRegistry, type PluginLoadFailure } from "@/plugins/runtime"; import { useGateways } from "@/app/di"; export interface PluginRuntimeContextValue { registry: PluginRuntimeRegistry; failures: PluginLoadFailure[]; /** True until the initial catalog fetch + bundle loads have settled. */ loading: boolean; } /** * Default value used outside a `` (e.g. `ProjectsView` * rendered directly in tests/Storybook without the full app shell): an empty, * already-settled registry rather than a hard requirement to wrap every call * site. Plugin contributions are strictly additive, so their absence must * never be a reason a surface fails to render. */ const EMPTY_PLUGIN_RUNTIME: PluginRuntimeContextValue = { registry: new PluginRuntimeRegistry(), failures: [], loading: false, }; const PluginRuntimeContext = createContext(EMPTY_PLUGIN_RUNTIME); function describeError(e: unknown): string { if (e && typeof e === "object" && "message" in e) { return String((e as { message: unknown }).message); } return String(e); } interface PluginRuntimeProviderProps { children: ReactNode; /** Test/Storybook escape hatch — skips the gateway fetch and uses this value as-is. */ value?: PluginRuntimeContextValue; } export function PluginRuntimeProvider({ children, value: injected }: PluginRuntimeProviderProps) { const gateways = useGateways(); const [value, setValue] = useState( injected ?? { registry: new PluginRuntimeRegistry(), failures: [], loading: true, }, ); useEffect(() => { if (injected) return; let cancelled = false; gateways.plugin .listRuntimeContributions() .then((catalog) => loadPlugins(catalog.plugins, { project: gateways.project, git: gateways.git, terminal: gateways.terminal, agents: gateways.agent, system: gateways.system, workState: gateways.workState, focusedProject: gateways.focusedProject, pluginWorkspace: gateways.pluginWorkspace, pluginTask: gateways.pluginTask, pluginToolchain: gateways.pluginToolchain, pluginEvents: gateways.pluginEvents, pluginConfig: gateways.pluginConfig, }), ) .then((result) => { if (cancelled) return; setValue({ registry: result.registry, failures: result.failures, loading: false }); }) .catch((e: unknown) => { // No plugin gateway / catalog fetch failed: run with zero plugins // rather than blocking the app (full-trust plugins are additive). if (!cancelled) { setValue((prev) => ({ ...prev, failures: [ ...prev.failures, { pluginId: "", reason: describeError(e) }, ], loading: false, })); } }); return () => { cancelled = true; }; // Gateways are a stable singleton for the app session (from `useGateways`); // re-running on every render would reload every plugin bundle. // eslint-disable-next-line react-hooks/exhaustive-deps }, []); return ( {children} ); } /** * Reads the loaded plugin runtime registry. Outside a `` * this is the empty registry (see {@link EMPTY_PLUGIN_RUNTIME}), never a throw. */ export function usePluginRuntime(): PluginRuntimeContextValue { return useContext(PluginRuntimeContext); }