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,228 @@
/**
* Plugin runtime registries (ticket #43, F1, carnet §6/§7) — the in-memory,
* per-session home for whatever a loaded plugin bundle registers via its
* `activate(ctx)` call. Rebuilt from scratch on every app boot (carnet §1.3):
* nothing here is persisted, the domain only persists declarative contribution
* metadata and (for layouts) opaque cell state.
*
* Each registry enforces the "declared ids only" rule (carnet §6): a plugin can
* only register a command/menu item/layout whose id was declared in its own
* manifest `contributes`. This is the runtime half of that contract; the other
* half (manifest validation) lives in the backend.
*/
import type { ComponentType } from "react";
import type {
PluginContributionDto,
PluginLayoutContribution,
PluginMenuItemContribution,
PluginTopLevelMenuContribution,
} from "@/domain";
import type { AgentGateway, GitGateway, ProjectGateway, SystemGateway, TerminalGateway } from "@/ports";
/** The stable gateways a plugin's `activate(ctx)` is allowed to reach (carnet §6). */
export interface PluginGatewaySet {
project: ProjectGateway;
git: GitGateway;
terminal: TerminalGateway;
agents: AgentGateway;
system: SystemGateway;
}
/** A disposable handle returned by every `register*` call. */
export interface Disposable {
dispose(): void;
}
export type PluginCommandHandler = (...args: unknown[]) => void | Promise<void>;
/** Commands a plugin registers in `activate(ctx)`, dispatched by menu items. */
export class PluginCommandRegistry {
private handlers = new Map<string, PluginCommandHandler>();
constructor(
private readonly pluginId: string,
private readonly declaredCommandIds: ReadonlySet<string>,
) {}
register(commandId: string, handler: PluginCommandHandler): Disposable {
if (!this.declaredCommandIds.has(commandId)) {
throw new Error(
`plugin "${this.pluginId}" tried to register command "${commandId}" ` +
"which is not declared by any menu item in its manifest",
);
}
this.handlers.set(commandId, handler);
return {
dispose: () => {
if (this.handlers.get(commandId) === handler) this.handlers.delete(commandId);
},
};
}
/** Runs a registered command; a no-op (never throws) if none is registered. */
async run(commandId: string, ...args: unknown[]): Promise<void> {
const handler = this.handlers.get(commandId);
if (!handler) return;
await handler(...args);
}
has(commandId: string): boolean {
return this.handlers.has(commandId);
}
}
export interface PluginLayoutProps {
projectId: string;
nodeId: string;
layoutType: string;
state: unknown;
setState(next: unknown): void;
availability: "available";
gateways: PluginGatewaySet;
}
export interface PluginLayoutDefinition {
type: string;
component: ComponentType<PluginLayoutProps>;
}
/** Custom React layout components a plugin registers, keyed by declared `type`. */
export class PluginLayoutRegistry {
private components = new Map<string, ComponentType<PluginLayoutProps>>();
constructor(
private readonly pluginId: string,
private readonly declaredLayoutTypes: ReadonlySet<string>,
) {}
register(def: PluginLayoutDefinition): Disposable {
if (!this.declaredLayoutTypes.has(def.type)) {
throw new Error(
`plugin "${this.pluginId}" tried to register layout type "${def.type}" ` +
"which is not declared in its manifest",
);
}
this.components.set(def.type, def.component);
return {
dispose: () => {
if (this.components.get(def.type) === def.component) {
this.components.delete(def.type);
}
},
};
}
get(layoutType: string): ComponentType<PluginLayoutProps> | undefined {
return this.components.get(layoutType);
}
}
/**
* Menu registration surface handed to `activate(ctx)`. V1 scope: a plugin
* declares its menus/items in the manifest (carnet §7.1/§7.2); nothing dynamic
* is registered here at runtime beyond commands, so this is intentionally a
* thin marker object today — kept as its own class so a future dynamic-menu
* capability doesn't change the `IdeaPluginContext` shape.
*/
export class PluginMenuRegistry {
constructor(private readonly pluginId: string) {}
/** Present for parity with the carnet's `ctx.menu` — no dynamic ops in V1. */
get ownerPluginId(): string {
return this.pluginId;
}
}
/** One loaded plugin's registries + the manifest contribution it was scoped to. */
export interface LoadedPlugin {
pluginId: string;
displayName: string;
contributes: PluginContributionDto;
commands: PluginCommandRegistry;
layouts: PluginLayoutRegistry;
menu: PluginMenuRegistry;
dispose(): Promise<void>;
}
/**
* Aggregate, session-scoped registry every loaded plugin's contributions land
* in. `PluginRuntimeRegistry` itself never imports bundles (see `loader.ts`);
* it just holds what has already been loaded and offers lookup/removal.
*/
export class PluginRuntimeRegistry {
private loaded = new Map<string, LoadedPlugin>();
add(plugin: LoadedPlugin): void {
this.loaded.set(plugin.pluginId, plugin);
}
/** Best-effort: calls `dispose()` then removes the plugin from the registry. */
async remove(pluginId: string): Promise<void> {
const plugin = this.loaded.get(pluginId);
if (!plugin) return;
try {
await plugin.dispose();
} finally {
this.loaded.delete(pluginId);
}
}
get(pluginId: string): LoadedPlugin | undefined {
return this.loaded.get(pluginId);
}
list(): LoadedPlugin[] {
return [...this.loaded.values()];
}
/** All top-level menu contributions across every loaded plugin. */
topLevelMenus(): Array<{ pluginId: string; pluginDisplayName: string; menu: PluginTopLevelMenuContribution }> {
return this.list().flatMap((p) =>
p.contributes.menus.map((menu) => ({
pluginId: p.pluginId,
pluginDisplayName: p.displayName,
menu,
})),
);
}
/** All menu-item contributions across every loaded plugin. */
menuItems(): Array<{ pluginId: string; pluginDisplayName: string; item: PluginMenuItemContribution }> {
return this.list().flatMap((p) =>
p.contributes.menuItems.map((item) => ({
pluginId: p.pluginId,
pluginDisplayName: p.displayName,
item,
})),
);
}
/** Layout component for a given `(pluginId, layoutType)` pair, if loaded. */
layoutComponent(
pluginId: string,
layoutType: string,
): ComponentType<PluginLayoutProps> | undefined {
return this.loaded.get(pluginId)?.layouts.get(layoutType);
}
/** All layout contributions across every loaded plugin (for the layout selector). */
layoutContributions(): Array<{
pluginId: string;
pluginDisplayName: string;
layout: PluginLayoutContribution;
}> {
return this.list().flatMap((p) =>
p.contributes.layouts.map((layout) => ({
pluginId: p.pluginId,
pluginDisplayName: p.displayName,
layout,
})),
);
}
async runCommand(pluginId: string, commandId: string, ...args: unknown[]): Promise<void> {
await this.loaded.get(pluginId)?.commands.run(commandId, ...args);
}
}