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