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,21 @@
export {
PluginCommandRegistry,
PluginLayoutRegistry,
PluginMenuRegistry,
PluginRuntimeRegistry,
type Disposable,
type LoadedPlugin,
type PluginCommandHandler,
type PluginGatewaySet,
type PluginLayoutDefinition,
type PluginLayoutProps,
} from "./registry";
export {
loadPlugins,
type IdeaPluginContext,
type IdeaPluginModule,
type PluginActivation,
type PluginLoadFailure,
type PluginLoadResult,
} from "./loader";
export { evaluateWhen, type WhenContext, type WhenEvalResult, type WhenVariable } from "./when";

View File

@ -0,0 +1,160 @@
/**
* F1 — plugin bootstrap loader tests (ticket #43, carnet §6/§10 F1 acceptance
* criteria: "plugins mock chargés, registration refusée si non déclarée,
* dispose appelé, disabled absent du bootstrap").
*
* Bundles are loaded via a real dynamic `import()` of `data:` URLs (supported
* by Node's ESM loader, which Vitest runs on) so the loader is exercised
* exactly as it runs against the `idea-plugin://...` protocol in production —
* no mocking of `import()` itself.
*/
import { describe, expect, it } from "vitest";
import type { PluginContributionDto, PluginRuntimePlugin } from "@/domain";
import { loadPlugins } from "./loader";
import type { PluginGatewaySet } from "./loader";
function dataUrl(source: string): string {
return `data:text/javascript;base64,${Buffer.from(source).toString("base64")}`;
}
function emptyContributes(): PluginContributionDto {
return { menus: [], menuItems: [], layouts: [], mcpServers: [] };
}
const gateways = {} as PluginGatewaySet;
function entry(overrides: Partial<PluginRuntimePlugin> & { bundleUrl: string }): PluginRuntimePlugin {
return {
id: "mock.plugin",
displayName: "Mock Plugin",
version: "1.0.0",
contentHash: "abc123",
contributes: emptyContributes(),
...overrides,
};
}
describe("loadPlugins", () => {
it("loads a well-formed plugin bundle and calls activate(ctx)", async () => {
const bundle = dataUrl(`
export function activate(ctx) {
globalThis.__activatedWith = ctx.pluginId;
return {};
}
`);
const { registry, failures } = await loadPlugins(
[entry({ id: "dev.acme.one", displayName: "One", bundleUrl: bundle })],
gateways,
);
expect(failures).toEqual([]);
expect(registry.list().map((p) => p.pluginId)).toEqual(["dev.acme.one"]);
expect((globalThis as Record<string, unknown>).__activatedWith).toBe("dev.acme.one");
});
it("refuses to register a command not declared in the manifest", async () => {
const bundle = dataUrl(`
export function activate(ctx) {
let caught = null;
try {
ctx.commands.register("dev.acme.undeclared.cmd", () => {});
} catch (e) {
caught = String(e);
}
globalThis.__registerError = caught;
return {};
}
`);
const { failures } = await loadPlugins(
[
entry({
id: "dev.acme.two",
displayName: "Two",
bundleUrl: bundle,
contributes: emptyContributes(),
}),
],
gateways,
);
expect(failures).toEqual([]);
expect((globalThis as Record<string, unknown>).__registerError).toMatch(
/not declared by any menu item/,
);
});
it("accepts registering a command declared via a menu item contribution", async () => {
const bundle = dataUrl(`
export function activate(ctx) {
ctx.commands.register("dev.acme.three.open", () => {
globalThis.__ranCommand = true;
});
return {};
}
`);
const { registry, failures } = await loadPlugins(
[
entry({
id: "dev.acme.three",
displayName: "Three",
bundleUrl: bundle,
contributes: {
...emptyContributes(),
menuItems: [
{
id: "dev.acme.three.item",
targetMenuId: "panels",
label: "Open",
command: "dev.acme.three.open",
},
],
},
}),
],
gateways,
);
expect(failures).toEqual([]);
await registry.runCommand("dev.acme.three", "dev.acme.three.open");
expect((globalThis as Record<string, unknown>).__ranCommand).toBe(true);
});
it("calls dispose() on removal (best-effort)", async () => {
const bundle = dataUrl(`
export function activate(ctx) {
return {
dispose: () => {
globalThis.__disposed = ctx.pluginId;
},
};
}
`);
const { registry } = await loadPlugins(
[entry({ id: "dev.acme.four", displayName: "Four", bundleUrl: bundle })],
gateways,
);
await registry.remove("dev.acme.four");
expect((globalThis as Record<string, unknown>).__disposed).toBe("dev.acme.four");
expect(registry.get("dev.acme.four")).toBeUndefined();
});
it("collects a failure instead of throwing when a bundle has no activate()", async () => {
const bundle = dataUrl(`export const notAPlugin = true;`);
const { registry, failures } = await loadPlugins(
[entry({ id: "dev.acme.five", displayName: "Five", bundleUrl: bundle })],
gateways,
);
expect(registry.list()).toEqual([]);
expect(failures).toEqual([
{ pluginId: "dev.acme.five", reason: expect.stringContaining("activate") },
]);
});
it("only loads what the catalog contains — a disabled/absent plugin is simply never in it", async () => {
// The backend contract (carnet §1.3) filters the catalog to
// `enabled && !pendingUninstall` before the loader ever sees it; the
// loader itself has nothing more to filter — an empty catalog loads
// nothing.
const { registry, failures } = await loadPlugins([], gateways);
expect(registry.list()).toEqual([]);
expect(failures).toEqual([]);
});
});

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 };
}

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);
}
}

View File

@ -0,0 +1,44 @@
import { describe, expect, it } from "vitest";
import { evaluateWhen, type WhenContext } from "./when";
const allFalse: WhenContext = {
projectOpen: false,
gitRepository: false,
agentSelected: false,
terminalFocused: false,
layoutCellFocused: false,
};
describe("evaluateWhen", () => {
it("treats an absent expression as always true", () => {
expect(evaluateWhen(undefined, allFalse)).toEqual({ ok: true, value: true });
});
it("reads a single variable", () => {
expect(evaluateWhen("projectOpen", { ...allFalse, projectOpen: true })).toEqual({
ok: true,
value: true,
});
expect(evaluateWhen("projectOpen", allFalse)).toEqual({ ok: true, value: false });
});
it("evaluates &&, ||, ! and parentheses with correct precedence", () => {
const ctx: WhenContext = { ...allFalse, projectOpen: true, gitRepository: false };
expect(evaluateWhen("projectOpen && gitRepository", ctx)).toEqual({ ok: true, value: false });
expect(evaluateWhen("projectOpen || gitRepository", ctx)).toEqual({ ok: true, value: true });
expect(evaluateWhen("!gitRepository && projectOpen", ctx)).toEqual({ ok: true, value: true });
expect(evaluateWhen("!(projectOpen && gitRepository)", ctx)).toEqual({ ok: true, value: true });
});
it("disables (does not crash) on an unknown variable", () => {
const result = evaluateWhen("somethingUnknown", allFalse);
expect(result.ok).toBe(false);
});
it("disables (does not crash) on malformed syntax", () => {
expect(evaluateWhen("projectOpen &&", allFalse).ok).toBe(false);
expect(evaluateWhen("(projectOpen", allFalse).ok).toBe(false);
expect(evaluateWhen("projectOpen $ gitRepository", allFalse).ok).toBe(false);
});
});

View File

@ -0,0 +1,158 @@
/**
* `when` mini-language evaluator (ticket #43, carnet §7.2) — deliberately
* limited, evaluated by IdeA itself (never handed to the plugin as code):
* boolean variables (`projectOpen`, `gitRepository`, `agentSelected`,
* `terminalFocused`, `layoutCellFocused`), `&&`/`||`/`!`, and parentheses.
*
* An invalid expression never throws past this module: the contribution using
* it is disabled with a diagnostic reason instead (carnet: "Toute expression
* invalide => contribution disabled + raison diagnostic, pas crash").
*/
export type WhenVariable =
| "projectOpen"
| "gitRepository"
| "agentSelected"
| "terminalFocused"
| "layoutCellFocused";
const VARIABLES: ReadonlySet<string> = new Set([
"projectOpen",
"gitRepository",
"agentSelected",
"terminalFocused",
"layoutCellFocused",
]);
export type WhenContext = Record<WhenVariable, boolean>;
export type WhenEvalResult =
| { ok: true; value: boolean }
| { ok: false; reason: string };
type Token =
| { kind: "var"; name: string }
| { kind: "and" | "or" | "not" | "lparen" | "rparen" };
function tokenize(expr: string): Token[] | null {
const tokens: Token[] = [];
let i = 0;
while (i < expr.length) {
const ch = expr[i];
if (ch === " " || ch === "\t") {
i += 1;
continue;
}
if (ch === "(") {
tokens.push({ kind: "lparen" });
i += 1;
continue;
}
if (ch === ")") {
tokens.push({ kind: "rparen" });
i += 1;
continue;
}
if (ch === "!") {
tokens.push({ kind: "not" });
i += 1;
continue;
}
if (expr.startsWith("&&", i)) {
tokens.push({ kind: "and" });
i += 2;
continue;
}
if (expr.startsWith("||", i)) {
tokens.push({ kind: "or" });
i += 2;
continue;
}
const match = /^[A-Za-z][A-Za-z0-9]*/.exec(expr.slice(i));
if (match) {
tokens.push({ kind: "var", name: match[0] });
i += match[0].length;
continue;
}
return null;
}
return tokens;
}
/** Recursive-descent parser/evaluator, precedence: `!` > `&&` > `||`. */
class Parser {
private pos = 0;
constructor(private readonly tokens: Token[]) {}
private peek(): Token | undefined {
return this.tokens[this.pos];
}
private next(): Token | undefined {
return this.tokens[this.pos++];
}
parseOr(ctx: WhenContext): boolean {
let value = this.parseAnd(ctx);
while (this.peek()?.kind === "or") {
this.next();
const rhs = this.parseAnd(ctx);
value = value || rhs;
}
return value;
}
private parseAnd(ctx: WhenContext): boolean {
let value = this.parseUnary(ctx);
while (this.peek()?.kind === "and") {
this.next();
const rhs = this.parseUnary(ctx);
value = value && rhs;
}
return value;
}
private parseUnary(ctx: WhenContext): boolean {
if (this.peek()?.kind === "not") {
this.next();
return !this.parseUnary(ctx);
}
return this.parseAtom(ctx);
}
private parseAtom(ctx: WhenContext): boolean {
const tok = this.next();
if (!tok) throw new Error("unexpected end of expression");
if (tok.kind === "lparen") {
const value = this.parseOr(ctx);
const close = this.next();
if (close?.kind !== "rparen") throw new Error("expected \")\"");
return value;
}
if (tok.kind === "var") {
if (!VARIABLES.has(tok.name)) throw new Error(`unknown variable "${tok.name}"`);
return ctx[tok.name as WhenVariable];
}
throw new Error("expected a variable, \"!\" or \"(\"");
}
finished(): boolean {
return this.pos >= this.tokens.length;
}
}
/** Evaluates a `when` expression; `undefined` means "no condition" ⇒ always true. */
export function evaluateWhen(expr: string | undefined, ctx: WhenContext): WhenEvalResult {
if (expr === undefined || expr.trim() === "") return { ok: true, value: true };
const tokens = tokenize(expr);
if (!tokens) return { ok: false, reason: `invalid "when" expression: ${expr}` };
try {
const parser = new Parser(tokens);
const value = parser.parseOr(ctx);
if (!parser.finished()) throw new Error("unexpected trailing tokens");
return { ok: true, value };
} catch (e) {
const reason = e instanceof Error ? e.message : String(e);
return { ok: false, reason: `invalid "when" expression "${expr}": ${reason}` };
}
}