feat(plugins): load activation scope from plugin manifest

Plugins can now declare activationScope ("app" | "project") in their
manifest; loader/runtime honor it to defer activation of project-scoped
plugins until a project is focused instead of activating everything at
app bootstrap. Bumps sdk/IdeaSDK to the commit that adds the field.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-03 14:44:08 +02:00
parent 863d9b7277
commit e2da2d911e
16 changed files with 513 additions and 34 deletions

View File

@ -349,6 +349,130 @@ describe("loadPlugins", () => {
]);
});
it("leaves project-scoped plugins pending without a focused project", async () => {
const bundle = dataUrl(`
export function activate(ctx) {
globalThis.__pendingProjectPluginActivated = ctx.pluginId;
}
`);
const noFocusedProjectGateways = {
...gateways,
focusedProject: {
async getFocusedProject() {
return null;
},
},
} as PluginGatewaySet;
const { registry, failures, pending } = await loadPlugins(
[
entry({
id: "dev.acme.project-only",
displayName: "Project Only",
activationScope: "project",
bundleUrl: bundle,
}),
],
noFocusedProjectGateways,
);
expect(registry.list()).toEqual([]);
expect(failures).toEqual([]);
expect(pending).toEqual([
{
pluginId: "dev.acme.project-only",
displayName: "Project Only",
reason: "En attente d'un projet actif.",
},
]);
expect((globalThis as Record<string, unknown>).__pendingProjectPluginActivated).toBeUndefined();
});
it("activates project-scoped plugins when a project is already focused", async () => {
const bundle = dataUrl(`
export function activate(ctx) {
globalThis.__focusedProjectPluginActivated = ctx.pluginId;
}
`);
const focusedProjectGateways = {
...gateways,
focusedProject: {
async getFocusedProject() {
return { id: "p1", name: "Alpha", root: "/tmp/alpha" };
},
},
} as PluginGatewaySet;
const { registry, failures, pending } = await loadPlugins(
[
entry({
id: "dev.acme.project-focused",
displayName: "Project Focused",
activationScope: "project",
bundleUrl: bundle,
}),
],
focusedProjectGateways,
);
expect(failures).toEqual([]);
expect(pending).toEqual([]);
expect(registry.list().map((p) => p.pluginId)).toEqual(["dev.acme.project-focused"]);
expect((globalThis as Record<string, unknown>).__focusedProjectPluginActivated).toBe(
"dev.acme.project-focused",
);
});
it("treats omitted activationScope as app and isolates a project-required activation failure", async () => {
const projectDependentBundle = dataUrl(`
export async function activate(ctx) {
await ctx.services.workspace.getProjectRoot();
}
`);
const healthyBundle = dataUrl(`
export function activate(ctx) {
globalThis.__healthyAppPluginActivated = ctx.pluginId;
}
`);
const noFocusedProjectGateways = {
...gateways,
focusedProject: {
async getFocusedProject() {
return null;
},
},
} as PluginGatewaySet;
const { registry, failures, pending } = await loadPlugins(
[
entry({
id: "dev.acme.default-app-scope",
displayName: "Default App Scope",
capabilities: ["tooling"],
bundleUrl: projectDependentBundle,
}),
entry({
id: "dev.acme.healthy-app",
displayName: "Healthy App",
bundleUrl: healthyBundle,
}),
],
noFocusedProjectGateways,
);
expect(registry.list().map((p) => p.pluginId)).toEqual(["dev.acme.healthy-app"]);
expect(failures).toEqual([
{
pluginId: "dev.acme.default-app-scope",
reason: "no current project is focused",
},
]);
expect(pending).toEqual([]);
expect((globalThis as Record<string, unknown>).__healthyAppPluginActivated).toBe(
"dev.acme.healthy-app",
);
});
it("loads the hello-plugin command and layout contribution shape", async () => {
const bundle = dataUrl(`
export function activate(ctx) {