/** * 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; /** Commands a plugin registers in `activate(ctx)`, dispatched by menu items. */ export class PluginCommandRegistry { private handlers = new Map(); constructor( private readonly pluginId: string, private readonly declaredCommandIds: ReadonlySet, ) {} 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 { const handler = this.handlers.get(commandId); if (!handler) return; try { await handler(...args); } catch (e) { console.error( `[plugin:${this.pluginId}] command "${commandId}" failed`, e, ); } } 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; } /** Custom React layout components a plugin registers, keyed by declared `type`. */ export class PluginLayoutRegistry { private components = new Map>(); constructor( private readonly pluginId: string, private readonly declaredLayoutTypes: ReadonlySet, ) {} 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 | 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; } function arrayOrEmpty(value: unknown): T[] { return Array.isArray(value) ? (value as T[]) : []; } /** * 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(); 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 { 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) => arrayOrEmpty(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) => arrayOrEmpty(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 | 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) => arrayOrEmpty(p.contributes.layouts).map((layout) => ({ pluginId: p.pluginId, pluginDisplayName: p.displayName, layout, })), ); } async runCommand(pluginId: string, commandId: string, ...args: unknown[]): Promise { await this.loaded.get(pluginId)?.commands.run(commandId, ...args); } }