feat(frontend): système de plugins — runtime, menus, layouts custom (#43)

Lots F1-F4 : runtime de chargement/registre plugin, extension des menus
existants, panneau de gestion des plugins, types de layout custom
(sélecteur, fallback, cellule dédiée) branchés sur le port plugin.
Suite npm typecheck/test verte (947/947).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-22 07:37:11 +02:00
parent bb35641715
commit ac726d075e
41 changed files with 3245 additions and 24 deletions

View File

@ -0,0 +1,150 @@
/**
* Plugin bootstrap loader (ticket #43, F1, carnet §1.3/§6).
*
* At UI bootstrap, the app calls {@link loadPlugins} once with the catalog
* from `PluginGateway.listRuntimeContributions()` (already filtered by the
* backend to `enabled && !pendingUninstall`, carnet §1.3) and the stable
* gateways the plugin context exposes. For each entry it dynamically imports
* the bundle URL, validates the module shape, and calls `activate(ctx)`,
* scoping the command/layout registries to exactly the ids declared in that
* plugin's manifest (enforced by {@link PluginCommandRegistry}/
* {@link PluginLayoutRegistry} themselves).
*
* A single plugin failing to load/activate must not break the rest of the
* app or the other plugins — each is loaded independently and failures are
* collected, never thrown past `loadPlugins`.
*/
import type { PluginRuntimePlugin } from "@/domain";
import {
PluginCommandRegistry,
PluginLayoutRegistry,
PluginMenuRegistry,
PluginRuntimeRegistry,
type LoadedPlugin,
type PluginGatewaySet,
} from "./registry";
export type { PluginGatewaySet } from "./registry";
export interface IdeaPluginContext extends PluginGatewaySet {
pluginId: string;
pluginDisplayName: string;
version: string;
commands: PluginCommandRegistry;
layouts: PluginLayoutRegistry;
menu: PluginMenuRegistry;
}
export interface PluginActivation {
dispose?: () => void | Promise<void>;
}
export interface IdeaPluginModule {
activate(ctx: IdeaPluginContext): PluginActivation | Promise<PluginActivation>;
}
export interface PluginLoadFailure {
pluginId: string;
reason: string;
}
export interface PluginLoadResult {
registry: PluginRuntimeRegistry;
failures: PluginLoadFailure[];
}
function isIdeaPluginModule(mod: unknown): mod is IdeaPluginModule {
return (
typeof mod === "object" &&
mod !== null &&
"activate" in mod &&
typeof (mod as { activate: unknown }).activate === "function"
);
}
async function loadOne(
entry: PluginRuntimePlugin,
gateways: PluginGatewaySet,
): Promise<{ plugin: LoadedPlugin } | { failure: PluginLoadFailure }> {
try {
// The bundle URL is a plugin-scoped, content-hashed local protocol URL
// served by the backend (carnet §1.3) — never a disk path or arbitrary
// remote URL, and the content hash busts the module cache after updates.
const mod: unknown = await import(/* @vite-ignore */ entry.bundleUrl);
if (!isIdeaPluginModule(mod)) {
return {
failure: {
pluginId: entry.id,
reason: `bundle does not export an "activate(ctx)" function`,
},
};
}
const declaredCommandIds = new Set(entry.contributes.menuItems.map((item) => item.command));
const declaredLayoutTypes = new Set(entry.contributes.layouts.map((layout) => layout.type));
const commands = new PluginCommandRegistry(entry.id, declaredCommandIds);
const layouts = new PluginLayoutRegistry(entry.id, declaredLayoutTypes);
const menu = new PluginMenuRegistry(entry.id);
const ctx: IdeaPluginContext = {
pluginId: entry.id,
pluginDisplayName: entry.displayName,
version: entry.version,
commands,
layouts,
menu,
...gateways,
};
const activation = await mod.activate(ctx);
const plugin: LoadedPlugin = {
pluginId: entry.id,
displayName: entry.displayName,
contributes: entry.contributes,
commands,
layouts,
menu,
dispose: async () => {
// Best-effort, full-trust (carnet §1.3) — a broken `dispose()` must not
// prevent removing the plugin from the runtime registry.
try {
await activation.dispose?.();
} catch {
/* best-effort */
}
},
};
return { plugin };
} catch (e) {
return {
failure: {
pluginId: entry.id,
reason: e instanceof Error ? e.message : String(e),
},
};
}
}
/**
* Loads every plugin in the catalog into a fresh {@link PluginRuntimeRegistry}.
* Called once at app bootstrap (and never again in the same session — carnet
* §1.3: disable/uninstall only masks contributions in-session, the actual
* unload happens on next restart via a fresh `loadPlugins` call).
*/
export async function loadPlugins(
catalogPlugins: PluginRuntimePlugin[],
gateways: PluginGatewaySet,
): Promise<PluginLoadResult> {
const registry = new PluginRuntimeRegistry();
const failures: PluginLoadFailure[] = [];
const results = await Promise.all(catalogPlugins.map((entry) => loadOne(entry, gateways)));
for (const result of results) {
if ("failure" in result) failures.push(result.failure);
else registry.add(result.plugin);
}
return { registry, failures };
}