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:
228
frontend/src/plugins/runtime/registry.ts
Normal file
228
frontend/src/plugins/runtime/registry.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user