Files
IdeA/frontend/src/features/plugins/PluginRuntimeProvider.tsx
Blomios 171c6c923c feat(wave): #119/#122/#131/#132 verts + sprint plugins ESM/persistance #135/#136/#139
État d'intégration confiné à la branche batch. Les tickets #119 (skills →
capacités agent découvrables), #122 (override permissions par défaut), #131
(effort par agent/presets) et #132 (outil MCP d'édition du contexte projet)
sont verts en périmètre. Le sprint plugins multi-fichiers ESM / persistance
plugin-owned (#135/#136/#139) est co-implémenté dans les MÊMES fichiers de
câblage (frontend/src/ports/index.ts, backend/src/lib.rs, domain/ports.rs,
backend/dto.rs), inséparable sans staging interactif (indisponible ici).

Commit unique volontaire : préserve l'état vert QA sans découpe hunk risquée.
NON mergé vers develop tant que #137 (QA e2e plugins) n'est pas vert.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-03 11:06:23 +02:00

124 lines
4.4 KiB
TypeScript

/**
* `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 `<DIProvider>` (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 `<PluginRuntimeProvider>` (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<PluginRuntimeContextValue>(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<PluginRuntimeContextValue>(
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,
pluginStorage: gateways.pluginStorage,
}),
)
.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: "<runtime-catalog>", 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 (
<PluginRuntimeContext.Provider value={value}>{children}</PluginRuntimeContext.Provider>
);
}
/**
* Reads the loaded plugin runtime registry. Outside a `<PluginRuntimeProvider>`
* this is the empty registry (see {@link EMPTY_PLUGIN_RUNTIME}), never a throw.
*/
export function usePluginRuntime(): PluginRuntimeContextValue {
return useContext(PluginRuntimeContext);
}