fix(plugins): isole les contributions plugin en erreur et durcit menus.ts

Le chargement de l'archive hello-plugin (build/hello-plugin-0.1.0.zip)
vidait la fenêtre principale : une contribution plugin fautive
remontait jusqu'au rendu global au lieu de rester locale à la cellule.
Ajoute un boundary local dans PluginLayoutCellView/PluginLayoutSelectorSection
et durcit menus.ts/loader.ts/registry.ts contre les entrées de menu ou
contributions malformées.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-30 09:08:14 +02:00
parent eec3e0a5bc
commit aa850374ce
9 changed files with 316 additions and 58 deletions

View File

@ -158,6 +158,43 @@ describe("loadPlugins", () => {
expect(registry.get("com.example.hello-plugin")).toBeUndefined();
});
it("loads the hello-plugin contribution shape with omitted optional arrays", async () => {
const bundle = dataUrl(`
export function activate(ctx) {
ctx.commands.registerCommand("hello-plugin.sayHello", () => {
globalThis.__helloArchiveCommandRan = true;
});
}
`);
const { registry, failures } = await loadPlugins(
[
entry({
id: "com.example.hello-plugin",
displayName: "Hello Plugin",
bundleUrl: bundle,
contributes: {
menus: [{ id: "hello-plugin.menu", label: "Hello", topLevel: true }],
menuItems: [
{
id: "hello-plugin.sayHello.item",
targetMenuId: "hello-plugin.menu",
label: "Say Hello",
command: "hello-plugin.sayHello",
},
],
} as unknown as PluginContributionDto,
}),
],
gateways,
);
expect(failures).toEqual([]);
expect(registry.get("com.example.hello-plugin")?.contributes.layouts).toEqual([]);
expect(registry.get("com.example.hello-plugin")?.contributes.mcpServers).toEqual([]);
await registry.runCommand("com.example.hello-plugin", "hello-plugin.sayHello");
expect((globalThis as Record<string, unknown>).__helloArchiveCommandRan).toBe(true);
});
it("calls dispose() on removal (best-effort)", async () => {
const bundle = dataUrl(`
export function activate(ctx) {

View File

@ -15,7 +15,7 @@
* collected, never thrown past `loadPlugins`.
*/
import type { PluginRuntimePlugin } from "@/domain";
import type { PluginContributionDto, PluginRuntimePlugin } from "@/domain";
import {
PluginCommandRegistry,
PluginLayoutRegistry,
@ -101,6 +101,20 @@ function createCommandContext(commands: PluginCommandRegistry): PluginCommandCon
};
}
function arrayOrEmpty<T>(value: unknown): T[] {
return Array.isArray(value) ? (value as T[]) : [];
}
function normalizeContributes(entry: PluginRuntimePlugin): PluginContributionDto {
const contributes = entry.contributes as Partial<PluginContributionDto> | null | undefined;
return {
menus: arrayOrEmpty(contributes?.menus),
menuItems: arrayOrEmpty(contributes?.menuItems),
layouts: arrayOrEmpty(contributes?.layouts),
mcpServers: arrayOrEmpty(contributes?.mcpServers),
};
}
async function disposeAll(disposables: Disposable[], activation?: void | PluginActivation): Promise<void> {
try {
await activation?.dispose?.();
@ -135,8 +149,9 @@ async function loadOne(
};
}
const declaredCommandIds = new Set(entry.contributes.menuItems.map((item) => item.command));
const declaredLayoutTypes = new Set(entry.contributes.layouts.map((layout) => layout.type));
const contributes = normalizeContributes(entry);
const declaredCommandIds = new Set(contributes.menuItems.map((item) => item.command));
const declaredLayoutTypes = new Set(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);
@ -159,7 +174,7 @@ async function loadOne(
const plugin: LoadedPlugin = {
pluginId: entry.id,
displayName: entry.displayName,
contributes: entry.contributes,
contributes,
commands,
layouts,
menu,

View File

@ -146,6 +146,10 @@ export interface LoadedPlugin {
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`);
@ -180,7 +184,7 @@ export class PluginRuntimeRegistry {
/** 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) => ({
arrayOrEmpty<PluginTopLevelMenuContribution>(p.contributes.menus).map((menu) => ({
pluginId: p.pluginId,
pluginDisplayName: p.displayName,
menu,
@ -191,7 +195,7 @@ export class PluginRuntimeRegistry {
/** 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) => ({
arrayOrEmpty<PluginMenuItemContribution>(p.contributes.menuItems).map((item) => ({
pluginId: p.pluginId,
pluginDisplayName: p.displayName,
item,
@ -214,7 +218,7 @@ export class PluginRuntimeRegistry {
layout: PluginLayoutContribution;
}> {
return this.list().flatMap((p) =>
p.contributes.layouts.map((layout) => ({
arrayOrEmpty<PluginLayoutContribution>(p.contributes.layouts).map((layout) => ({
pluginId: p.pluginId,
pluginDisplayName: p.displayName,
layout,