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:
21
frontend/src/plugins/runtime/index.ts
Normal file
21
frontend/src/plugins/runtime/index.ts
Normal 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";
|
||||
160
frontend/src/plugins/runtime/loader.test.ts
Normal file
160
frontend/src/plugins/runtime/loader.test.ts
Normal 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([]);
|
||||
});
|
||||
});
|
||||
150
frontend/src/plugins/runtime/loader.ts
Normal file
150
frontend/src/plugins/runtime/loader.ts
Normal 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 };
|
||||
}
|
||||
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);
|
||||
}
|
||||
}
|
||||
44
frontend/src/plugins/runtime/when.test.ts
Normal file
44
frontend/src/plugins/runtime/when.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
158
frontend/src/plugins/runtime/when.ts
Normal file
158
frontend/src/plugins/runtime/when.ts
Normal 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}` };
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user