259 lines
7.8 KiB
TypeScript
259 lines
7.8 KiB
TypeScript
/**
|
|
* 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,
|
|
FocusedProjectGateway,
|
|
GitGateway,
|
|
PluginConfigGateway,
|
|
PluginEventGateway,
|
|
PluginTaskGateway,
|
|
PluginToolchainGateway,
|
|
PluginWorkspaceGateway,
|
|
ProjectGateway,
|
|
SystemGateway,
|
|
TerminalGateway,
|
|
WorkStateGateway,
|
|
} from "@/ports";
|
|
|
|
/** Internal gateways used to build the public plugin service facade. */
|
|
export interface PluginGatewaySet {
|
|
project: ProjectGateway;
|
|
git: GitGateway;
|
|
terminal: TerminalGateway;
|
|
agents: AgentGateway;
|
|
system: SystemGateway;
|
|
workState: WorkStateGateway;
|
|
focusedProject: FocusedProjectGateway;
|
|
pluginWorkspace: PluginWorkspaceGateway;
|
|
pluginTask: PluginTaskGateway;
|
|
pluginToolchain: PluginToolchainGateway;
|
|
pluginEvents: PluginEventGateway;
|
|
pluginConfig: PluginConfigGateway;
|
|
}
|
|
|
|
/** 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;
|
|
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";
|
|
}
|
|
|
|
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>;
|
|
}
|
|
|
|
function arrayOrEmpty<T>(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<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) =>
|
|
arrayOrEmpty<PluginTopLevelMenuContribution>(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<PluginMenuItemContribution>(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) =>
|
|
arrayOrEmpty<PluginLayoutContribution>(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);
|
|
}
|
|
}
|