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:
@ -16,6 +16,7 @@ export {
|
||||
type IdeaPluginModule,
|
||||
type PluginActivation,
|
||||
type PluginLoadFailure,
|
||||
type PluginLoadPending,
|
||||
type PluginLoadResult,
|
||||
} from "./loader";
|
||||
export {
|
||||
|
||||
@ -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) {
|
||||
|
||||
@ -76,9 +76,16 @@ export interface PluginLoadFailure {
|
||||
reason: string;
|
||||
}
|
||||
|
||||
export interface PluginLoadPending {
|
||||
pluginId: string;
|
||||
displayName: string;
|
||||
reason: string;
|
||||
}
|
||||
|
||||
export interface PluginLoadResult {
|
||||
registry: PluginRuntimeRegistry;
|
||||
failures: PluginLoadFailure[];
|
||||
pending: PluginLoadPending[];
|
||||
}
|
||||
|
||||
export interface PluginLoadOptions {
|
||||
@ -190,6 +197,20 @@ function hasCapability(entry: PluginRuntimePlugin, capability: string): boolean
|
||||
return arrayOrEmpty<string>(objectOrEmpty(entry).capabilities).includes(capability);
|
||||
}
|
||||
|
||||
function activationScope(entry: PluginRuntimePlugin): "app" | "project" {
|
||||
return objectOrEmpty(entry).activationScope === "project" ? "project" : "app";
|
||||
}
|
||||
|
||||
function pendingForProjectFocus(entry: PluginRuntimePlugin): PluginLoadPending {
|
||||
const entryObject = objectOrEmpty(entry);
|
||||
const pluginId = safePluginId(entry);
|
||||
return {
|
||||
pluginId,
|
||||
displayName: nonEmptyString(entryObject.displayName) ?? pluginId,
|
||||
reason: "En attente d'un projet actif.",
|
||||
};
|
||||
}
|
||||
|
||||
async function disposeAll(disposables: Disposable[], activation?: void | PluginActivation): Promise<void> {
|
||||
try {
|
||||
await activation?.dispose?.();
|
||||
@ -324,13 +345,20 @@ export async function loadPlugins(
|
||||
): Promise<PluginLoadResult> {
|
||||
const registry = new PluginRuntimeRegistry();
|
||||
const failures: PluginLoadFailure[] = [];
|
||||
const pending: PluginLoadPending[] = [];
|
||||
const resolvedOptions: Required<PluginLoadOptions> = {
|
||||
timeoutMs: options.timeoutMs ?? DEFAULT_PLUGIN_LOAD_TIMEOUT_MS,
|
||||
};
|
||||
|
||||
const entries = Array.isArray(catalogPlugins) ? catalogPlugins : [];
|
||||
const focus = await gateways.focusedProject?.getFocusedProject?.();
|
||||
const loadableEntries = entries.filter((entry) => {
|
||||
if (activationScope(entry) !== "project" || focus) return true;
|
||||
pending.push(pendingForProjectFocus(entry));
|
||||
return false;
|
||||
});
|
||||
const results = await Promise.all(
|
||||
entries.map((entry) => loadOne(entry, gateways, resolvedOptions)),
|
||||
loadableEntries.map((entry) => loadOne(entry, gateways, resolvedOptions)),
|
||||
);
|
||||
for (const result of results) {
|
||||
if ("failure" in result) {
|
||||
@ -345,9 +373,9 @@ export async function loadPlugins(
|
||||
|
||||
if (entries.length > 0) {
|
||||
console.info(
|
||||
`[plugins] load complete loaded=${registry.list().length} failed=${failures.length}`,
|
||||
`[plugins] load complete loaded=${registry.list().length} failed=${failures.length} pending=${pending.length}`,
|
||||
);
|
||||
}
|
||||
|
||||
return { registry, failures };
|
||||
return { registry, failures, pending };
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user