fix(plugins): support export default { activate }, cleanup partiel et erreurs visibles

Le loader accepte désormais aussi la forme export default { activate }
en plus de l'export nommé, les subscriptions partiellement établies
sont nettoyées en cas d'échec d'activation, et une erreur de
runtime-catalog est remontée visuellement dans PluginsPanel avec un
badge Invalide/Isolé sur les plugins concernés (#116/#120).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-08-01 00:05:57 +02:00
parent 50b71f4ece
commit 46086af026
5 changed files with 144 additions and 7 deletions

View File

@ -52,6 +52,26 @@ describe("loadPlugins", () => {
expect((globalThis as Record<string, unknown>).__activatedWith).toBe("dev.acme.one");
});
it("loads an SDK-style default export containing activate(ctx)", async () => {
const bundle = dataUrl(`
export default {
activate(ctx) {
globalThis.__defaultExportActivatedWith = ctx.pluginId;
},
};
`);
const { registry, failures } = await loadPlugins(
[entry({ id: "com.example.hello-plugin", displayName: "Hello Plugin", bundleUrl: bundle })],
gateways,
);
expect(failures).toEqual([]);
expect(registry.list().map((p) => p.pluginId)).toEqual(["com.example.hello-plugin"]);
expect((globalThis as Record<string, unknown>).__defaultExportActivatedWith).toBe(
"com.example.hello-plugin",
);
});
it("refuses to register a command not declared in the manifest", async () => {
const bundle = dataUrl(`
export function activate(ctx) {
@ -259,6 +279,32 @@ describe("loadPlugins", () => {
]);
});
it("disposes partial subscriptions when activation fails", async () => {
const bundle = dataUrl(`
export function activate(ctx) {
ctx.subscriptions.push({
dispose() {
globalThis.__partialActivationDisposed = ctx.pluginId;
},
});
throw new Error("activation failed");
}
`);
const { registry, failures } = await loadPlugins(
[entry({ id: "dev.acme.partial", displayName: "Partial", bundleUrl: bundle })],
gateways,
);
expect(registry.list()).toEqual([]);
expect(failures).toEqual([
{ pluginId: "dev.acme.partial", reason: "activation failed" },
]);
expect((globalThis as Record<string, unknown>).__partialActivationDisposed).toBe(
"dev.acme.partial",
);
});
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

View File

@ -81,6 +81,12 @@ function isIdeaPluginModule(mod: unknown): mod is IdeaPluginModule {
);
}
function resolveIdeaPluginModule(mod: unknown): IdeaPluginModule | null {
if (isIdeaPluginModule(mod)) return mod;
const defaultExport = objectOrEmpty(mod).default;
return isIdeaPluginModule(defaultExport) ? defaultExport : null;
}
function createPluginLogger(entry: PluginRuntimePlugin): PluginLogger {
const prefix = `[plugin:${safePluginId(entry)}]`;
return {
@ -169,6 +175,8 @@ async function loadOne(
const pluginId = safePluginId(entry);
const displayName = nonEmptyString(entryObject.displayName) ?? pluginId;
const version = nonEmptyString(entryObject.version) ?? "";
let activation: void | PluginActivation = undefined;
const subscriptions: Disposable[] = [];
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
@ -178,8 +186,8 @@ async function loadOne(
throw new Error("missing plugin bundle URL");
}
const mod: unknown = await import(/* @vite-ignore */ bundleUrl);
if (!isIdeaPluginModule(mod)) {
const mod = resolveIdeaPluginModule(await import(/* @vite-ignore */ bundleUrl));
if (!mod) {
return {
failure: {
pluginId,
@ -194,7 +202,6 @@ async function loadOne(
const commands = new PluginCommandRegistry(pluginId, declaredCommandIds);
const layouts = new PluginLayoutRegistry(pluginId, declaredLayoutTypes);
const menu = new PluginMenuRegistry(pluginId);
const subscriptions: Disposable[] = [];
const ctx: IdeaPluginContext = {
pluginId,
@ -208,7 +215,7 @@ async function loadOne(
...gateways,
};
const activation = await mod.activate(ctx);
activation = await mod.activate(ctx);
const plugin: LoadedPlugin = {
pluginId,
@ -225,6 +232,7 @@ async function loadOne(
};
return { plugin };
} catch (e) {
await disposeAll(subscriptions, activation);
return {
failure: {
pluginId,