From e2da2d911e8afb355746318959993fb1e4bae619 Mon Sep 17 00:00:00 2001 From: Blomios Date: Mon, 3 Aug 2026 14:44:08 +0200 Subject: [PATCH] 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 --- crates/app-tauri/src/plugins.rs | 1 + crates/app-tauri/tests/dto_plugins.rs | 2 + crates/application/src/plugin/mod.rs | 7 + crates/backend/src/dto.rs | 3 + crates/domain/src/lib.rs | 14 +- crates/domain/src/plugin.rs | 28 +++ frontend/src/domain/index.ts | 8 + .../plugins/PluginLayoutCellView.test.tsx | 2 +- .../plugins/PluginRuntimeProvider.tsx | 106 ++++++++-- .../src/features/plugins/PluginsPanel.tsx | 17 ++ .../src/features/plugins/plugins.test.tsx | 196 +++++++++++++++++- .../projects/pluginGitRepository.test.tsx | 2 +- frontend/src/plugins/runtime/index.ts | 1 + frontend/src/plugins/runtime/loader.test.ts | 124 +++++++++++ frontend/src/plugins/runtime/loader.ts | 34 ++- sdk/IdeaSDK | 2 +- 16 files changed, 513 insertions(+), 34 deletions(-) diff --git a/crates/app-tauri/src/plugins.rs b/crates/app-tauri/src/plugins.rs index c1e3f37..bb37a7b 100644 --- a/crates/app-tauri/src/plugins.rs +++ b/crates/app-tauri/src/plugins.rs @@ -754,6 +754,7 @@ mod tests { icon: None, trust_level: PluginTrustLevel::Full, capabilities: Vec::new(), + activation_scope: domain::PluginActivationScope::default(), contributes: PluginContributionSet::default(), }) } diff --git a/crates/app-tauri/tests/dto_plugins.rs b/crates/app-tauri/tests/dto_plugins.rs index 9485584..8e9b9cb 100644 --- a/crates/app-tauri/tests/dto_plugins.rs +++ b/crates/app-tauri/tests/dto_plugins.rs @@ -49,6 +49,7 @@ fn runtime_catalog_dto_carries_bundle_hash_and_contributions() { icon_url: None, content_hash: "abc".to_owned(), capabilities: vec![PluginCapability::Ui, PluginCapability::Tooling], + activation_scope: domain::PluginActivationScope::Project, contributes: PluginContributionSet::default(), }], }; @@ -63,6 +64,7 @@ fn runtime_catalog_dto_carries_bundle_hash_and_contributions() { value["plugins"][0]["capabilities"], serde_json::json!(["ui", "tooling"]) ); + assert_eq!(value["plugins"][0]["activationScope"], "project"); assert!(value["plugins"][0]["contributes"]["menus"] .as_array() .unwrap() diff --git a/crates/application/src/plugin/mod.rs b/crates/application/src/plugin/mod.rs index 6035ba2..b685b74 100644 --- a/crates/application/src/plugin/mod.rs +++ b/crates/application/src/plugin/mod.rs @@ -153,6 +153,8 @@ pub struct PluginRuntimePlugin { pub content_hash: String, /// Public manifest capabilities. pub capabilities: Vec, + /// Manifest-declared activation scope. + pub activation_scope: domain::PluginActivationScope, /// Contributions. pub contributes: PluginContributionSet, } @@ -2207,6 +2209,7 @@ impl ListPlugins { icon: None, trust_level: PluginTrustLevel::Full, capabilities: Vec::new(), + activation_scope: domain::PluginActivationScope::default(), contributes: PluginContributionSet::default(), }; out.push(admin_from_descriptor( @@ -2793,6 +2796,7 @@ async fn runtime_plugin_from_entry( icon_url, content_hash: descriptor.registry.content_hash.as_str().to_owned(), capabilities: descriptor.manifest.capabilities, + activation_scope: descriptor.manifest.activation_scope, contributes: descriptor.manifest.contributes, }) } @@ -3006,6 +3010,8 @@ struct RawManifest { trust_level: String, #[serde(default)] capabilities: Vec, + #[serde(default)] + activation_scope: domain::PluginActivationScope, contributes: RawContributes, } @@ -3158,6 +3164,7 @@ impl PluginManifestValidator for JsonPluginManifestValidator { icon, trust_level: PluginTrustLevel::Full, capabilities, + activation_scope: raw.activation_scope, contributes, }) } diff --git a/crates/backend/src/dto.rs b/crates/backend/src/dto.rs index 7755f13..5cab71d 100644 --- a/crates/backend/src/dto.rs +++ b/crates/backend/src/dto.rs @@ -242,6 +242,8 @@ pub struct PluginRuntimePluginDto { pub content_hash: String, /// Public manifest capabilities. pub capabilities: Vec, + /// Manifest-declared activation scope. + pub activation_scope: domain::PluginActivationScope, /// Contributions. pub contributes: domain::PluginContributionSet, } @@ -269,6 +271,7 @@ impl From for PluginRuntimePluginDto { icon_url: value.icon_url, content_hash: value.content_hash, capabilities: value.capabilities, + activation_scope: value.activation_scope, contributes: value.contributes, } } diff --git a/crates/domain/src/lib.rs b/crates/domain/src/lib.rs index f35b976..0f22fff 100644 --- a/crates/domain/src/lib.rs +++ b/crates/domain/src/lib.rs @@ -217,13 +217,13 @@ pub use system_permissions::{ }; pub use plugin::{ - ContentHash, CustomPluginLayout, PluginBundleUrl, PluginCapability, PluginCommandId, - PluginContributionSet, PluginDescriptor, PluginError, PluginId, PluginInstallSource, - PluginLayoutContribution, PluginLayoutType, PluginLifecycleState, PluginManifest, - PluginMcpServerContribution, PluginMcpServerId, PluginMcpServerSpec, PluginMcpStatus, - PluginMcpStatusSet, PluginMenuItemContribution, PluginPackageRef, PluginRegistry, - PluginRegistryEntry, PluginTopLevelMenuContribution, PluginTrustLevel, PluginVersion, - RelativePath, RemovalOutcome, StagedPluginPackage, + ContentHash, CustomPluginLayout, PluginActivationScope, PluginBundleUrl, PluginCapability, + PluginCommandId, PluginContributionSet, PluginDescriptor, PluginError, PluginId, + PluginInstallSource, PluginLayoutContribution, PluginLayoutType, PluginLifecycleState, + PluginManifest, PluginMcpServerContribution, PluginMcpServerId, PluginMcpServerSpec, + PluginMcpStatus, PluginMcpStatusSet, PluginMenuItemContribution, PluginPackageRef, + PluginRegistry, PluginRegistryEntry, PluginTopLevelMenuContribution, PluginTrustLevel, + PluginVersion, RelativePath, RemovalOutcome, StagedPluginPackage, }; pub use sandbox::{ diff --git a/crates/domain/src/plugin.rs b/crates/domain/src/plugin.rs index 558b64f..7b49d05 100644 --- a/crates/domain/src/plugin.rs +++ b/crates/domain/src/plugin.rs @@ -288,6 +288,22 @@ pub enum PluginCapability { Tooling, } +/// Manifest-declared runtime activation scope. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "lowercase")] +pub enum PluginActivationScope { + /// Activate at app bootstrap, without requiring a focused project. + App, + /// Wait until a project is focused before the first activation. + Project, +} + +impl Default for PluginActivationScope { + fn default() -> Self { + Self::App + } +} + /// Plugin command id. #[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)] #[serde(transparent)] @@ -495,6 +511,9 @@ pub struct PluginManifest { /// Capabilities. #[serde(default)] pub capabilities: Vec, + /// Activation scope. Missing in older manifests means app-level activation. + #[serde(default)] + pub activation_scope: PluginActivationScope, /// Contributions. pub contributes: PluginContributionSet, } @@ -693,4 +712,13 @@ mod tests { serde_json::json!(["ui", "mcp", "tooling"]) ); } + + #[test] + fn plugin_activation_scope_defaults_to_app_and_serializes_public_names() { + assert_eq!(PluginActivationScope::default(), PluginActivationScope::App); + assert_eq!( + serde_json::to_value(PluginActivationScope::Project).unwrap(), + serde_json::json!("project") + ); + } } diff --git a/frontend/src/domain/index.ts b/frontend/src/domain/index.ts index bb0fe73..71d18ba 100644 --- a/frontend/src/domain/index.ts +++ b/frontend/src/domain/index.ts @@ -1799,6 +1799,14 @@ export interface PluginRuntimePlugin { publisher?: string; version: string; capabilities?: string[]; + /** + * Runtime activation scope declared by the plugin manifest. + * + * Omitted by older manifests and treated as `"app"`: the bundle is activated + * immediately at app bootstrap. `"project"` plugins are held pending until a + * focused project exists, then activated once for the current app session. + */ + activationScope?: "app" | "project"; bundleUrl: string; iconUrl?: string; contentHash: string; diff --git a/frontend/src/features/plugins/PluginLayoutCellView.test.tsx b/frontend/src/features/plugins/PluginLayoutCellView.test.tsx index 2fa7531..c959f20 100644 --- a/frontend/src/features/plugins/PluginLayoutCellView.test.tsx +++ b/frontend/src/features/plugins/PluginLayoutCellView.test.tsx @@ -73,7 +73,7 @@ function renderCell( const gateways: Gateways = createMockGateways(); return render( - + { if (injected) return; let cancelled = false; + const pluginGateways = { + project: gateways.project, + git: gateways.git, + terminal: gateways.terminal, + agents: gateways.agent, + system: gateways.system, + workState: gateways.workState, + focusedProject: gateways.focusedProject, + pluginWorkspace: gateways.pluginWorkspace, + pluginTask: gateways.pluginTask, + pluginToolchain: gateways.pluginToolchain, + pluginEvents: gateways.pluginEvents, + pluginConfig: gateways.pluginConfig, + pluginStorage: gateways.pluginStorage, + }; + let activatedProjectScoped = false; + let pendingProjectPlugins: PluginRuntimePlugin[] = []; + let unsubscribeFocus: Unsubscribe | undefined; + + void gateways.focusedProject + .onFocusedProjectChanged(async (project) => { + if (!project || activatedProjectScoped || pendingProjectPlugins.length === 0) return; + activatedProjectScoped = true; + const projectPlugins = pendingProjectPlugins; + pendingProjectPlugins = []; + try { + const result = await loadPlugins(projectPlugins, pluginGateways); + if (cancelled) return; + setValue((prev) => { + for (const plugin of result.registry.list()) prev.registry.add(plugin); + return { + registry: prev.registry, + failures: [...prev.failures, ...result.failures], + pending: prev.pending.filter( + (p) => !projectPlugins.some((entry) => entry.id === p.pluginId), + ), + loading: false, + }; + }); + } catch (e: unknown) { + if (cancelled) return; + setValue((prev) => ({ + ...prev, + failures: [ + ...prev.failures, + ...projectPlugins.map((plugin) => ({ + pluginId: plugin.id, + reason: describeError(e), + })), + ], + pending: prev.pending.filter( + (p) => !projectPlugins.some((entry) => entry.id === p.pluginId), + ), + loading: false, + })); + } + }) + .then((unsubscribe) => { + if (cancelled) unsubscribe(); + else unsubscribeFocus = unsubscribe; + }); + gateways.plugin .listRuntimeContributions() - .then((catalog) => - loadPlugins(catalog.plugins, { - project: gateways.project, - git: gateways.git, - terminal: gateways.terminal, - agents: gateways.agent, - system: gateways.system, - workState: gateways.workState, - focusedProject: gateways.focusedProject, - pluginWorkspace: gateways.pluginWorkspace, - pluginTask: gateways.pluginTask, - pluginToolchain: gateways.pluginToolchain, - pluginEvents: gateways.pluginEvents, - pluginConfig: gateways.pluginConfig, - pluginStorage: gateways.pluginStorage, - }), - ) + .then(async (catalog) => { + const result = await loadPlugins(catalog.plugins, pluginGateways); + pendingProjectPlugins = catalog.plugins.filter((entry) => + result.pending.some((p) => p.pluginId === entry.id), + ); + return result; + }) .then((result) => { if (cancelled) return; - setValue({ registry: result.registry, failures: result.failures, loading: false }); + setValue({ + registry: result.registry, + failures: result.failures, + pending: result.pending, + loading: false, + }); }) .catch((e: unknown) => { // No plugin gateway / catalog fetch failed: run with zero plugins @@ -97,12 +163,14 @@ export function PluginRuntimeProvider({ children, value: injected }: PluginRunti ...prev.failures, { pluginId: "", reason: describeError(e) }, ], + pending: [], loading: false, })); } }); return () => { cancelled = true; + unsubscribeFocus?.(); }; // Gateways are a stable singleton for the app session (from `useGateways`); // re-running on every render would reload every plugin bundle. diff --git a/frontend/src/features/plugins/PluginsPanel.tsx b/frontend/src/features/plugins/PluginsPanel.tsx index 0fb76a7..0026b73 100644 --- a/frontend/src/features/plugins/PluginsPanel.tsx +++ b/frontend/src/features/plugins/PluginsPanel.tsx @@ -128,6 +128,23 @@ export function PluginsPanel() { )} + {pluginRuntime.pending.length > 0 && ( + +
+

+ Certains plugins attendent un projet actif. +

+
    + {pluginRuntime.pending.map((pending) => ( +
  • + {pending.displayName} +
  • + ))} +
+
+
+ )} + {vm.plugins.length === 0 ? (

Aucun plugin installé.

diff --git a/frontend/src/features/plugins/plugins.test.tsx b/frontend/src/features/plugins/plugins.test.tsx index 1c12f31..6b68c80 100644 --- a/frontend/src/features/plugins/plugins.test.tsx +++ b/frontend/src/features/plugins/plugins.test.tsx @@ -6,7 +6,12 @@ import { describe, it, expect } from "vitest"; import { render, screen, waitFor, fireEvent, within } from "@testing-library/react"; -import { MockPluginGateway, MockSystemGateway } from "@/adapters/mock"; +import { + createMockGateways, + MockFocusedProjectGateway, + MockPluginGateway, + MockSystemGateway, +} from "@/adapters/mock"; import type { PluginInstallResult, PluginReview, PluginRuntimeContributionCatalog } from "@/domain"; import type { Gateways, ReviewPluginPackageInput } from "@/ports"; import { DIProvider } from "@/app/di"; @@ -14,12 +19,17 @@ import { PluginRuntimeRegistry } from "@/plugins/runtime"; import { PluginsPanel } from "./PluginsPanel"; import { PluginRuntimeProvider, type PluginRuntimeContextValue } from "./PluginRuntimeProvider"; +function dataUrl(source: string): string { + return `data:text/javascript;base64,${Buffer.from(source).toString("base64")}`; +} + function renderPanel( plugin?: MockPluginGateway, system?: MockSystemGateway, runtimeValue: PluginRuntimeContextValue = { registry: new PluginRuntimeRegistry(), failures: [], + pending: [], loading: false, }, ) { @@ -40,7 +50,7 @@ function renderPanel( } function renderPanelWithLiveRuntime(plugin: MockPluginGateway, system = new MockSystemGateway()) { - const gateways = { plugin, system } as unknown as Gateways; + const gateways = { ...createMockGateways(), plugin, system }; return render( @@ -77,6 +87,63 @@ class FailingRuntimeCatalogPluginGateway extends MockPluginGateway { } } +class ProjectScopedRuntimeCatalogPluginGateway extends MockPluginGateway { + constructor(private readonly bundleUrl: string) { + super(); + } + + async listRuntimeContributions(): Promise { + return { + plugins: [ + { + id: "dev.acme.project-plugin", + displayName: "Project Plugin", + version: "1.0.0", + activationScope: "project", + bundleUrl: this.bundleUrl, + contentHash: "project-plugin", + contributes: { menus: [], menuItems: [], layouts: [], mcpServers: [] }, + }, + ], + }; + } +} + +class MixedActivationScopeRuntimeCatalogPluginGateway extends MockPluginGateway { + constructor( + private readonly failingAppBundleUrl: string, + private readonly projectBundleUrl: string, + ) { + super(); + } + + async listRuntimeContributions(): Promise { + return { + plugins: [ + { + id: "dev.acme.app-needs-project", + displayName: "App Needs Project", + version: "1.0.0", + capabilities: ["tooling"], + activationScope: "app", + bundleUrl: this.failingAppBundleUrl, + contentHash: "app-needs-project", + contributes: { menus: [], menuItems: [], layouts: [], mcpServers: [] }, + }, + { + id: "dev.acme.project-plugin", + displayName: "Project Plugin", + version: "1.0.0", + activationScope: "project", + bundleUrl: this.projectBundleUrl, + contentHash: "project-plugin", + contributes: { menus: [], menuItems: [], layouts: [], mcpServers: [] }, + }, + ], + }; + } +} + class BackendShapedReviewPluginGateway extends MockPluginGateway { async reviewPackage(input: ReviewPluginPackageInput): Promise { const label = input.path.split("/").pop() ?? input.path; @@ -106,6 +173,7 @@ describe("PluginsPanel", () => { renderPanel(undefined, undefined, { registry: new PluginRuntimeRegistry(), failures: [{ pluginId: "com.example.hello-plugin", reason: "Cannot use import statement outside a module" }], + pending: [], loading: false, }); @@ -124,6 +192,130 @@ describe("PluginsPanel", () => { expect(screen.getByText(/runtime catalog failed/)).toBeTruthy(); }); + it("renders runtime failures before pending plugins without repeating invariant pending reasons", async () => { + renderPanel(undefined, undefined, { + registry: new PluginRuntimeRegistry(), + failures: [{ pluginId: "dev.acme.failed", reason: "activation failed" }], + pending: [ + { + pluginId: "dev.acme.pending", + displayName: "Pending Plugin", + reason: "En attente d'un projet actif.", + }, + ], + loading: false, + }); + + expect(await screen.findByText("Aucun plugin installé.")).toBeTruthy(); + const failureTitle = screen.getByText("Certains plugins installés n'ont pas pu être chargés."); + const pendingTitle = screen.getByText("Certains plugins attendent un projet actif."); + expect( + failureTitle.compareDocumentPosition(pendingTitle) & Node.DOCUMENT_POSITION_FOLLOWING, + ).toBeTruthy(); + expect(screen.getByText("Pending Plugin")).toBeTruthy(); + expect(screen.queryByText("En attente d'un projet actif.")).toBeNull(); + }); + + it("shows project-scoped runtime plugins as pending until a project is focused", async () => { + delete (globalThis as Record).__projectPluginActivations; + delete (globalThis as Record).__projectPluginActivatedWith; + const bundle = dataUrl(` + export function activate(ctx) { + globalThis.__projectPluginActivations = (globalThis.__projectPluginActivations ?? 0) + 1; + globalThis.__projectPluginActivatedWith = ctx.pluginId; + } + `); + const focusedProject = new MockFocusedProjectGateway(); + const gateways = { + ...createMockGateways(), + focusedProject, + plugin: new ProjectScopedRuntimeCatalogPluginGateway(bundle), + }; + + render( + + + + + , + ); + + expect(await screen.findByText("Certains plugins attendent un projet actif.")).toBeTruthy(); + expect(screen.getByText("Project Plugin")).toBeTruthy(); + expect(screen.queryByText("Certains plugins installés n'ont pas pu être chargés.")).toBeNull(); + + await focusedProject.setFocusedProject({ id: "p1", name: "Alpha", root: "/tmp/alpha" }); + + await waitFor(() => { + expect(screen.queryByText("Certains plugins attendent un projet actif.")).toBeNull(); + expect((globalThis as Record).__projectPluginActivatedWith).toBe( + "dev.acme.project-plugin", + ); + }); + + await focusedProject.setFocusedProject({ id: "p2", name: "Beta", root: "/tmp/beta" }); + expect((globalThis as Record).__projectPluginActivations).toBe(1); + }); + + it("keeps app-scope failures distinct from project-scope pending plugins, without cross-blocking", async () => { + delete (globalThis as Record).__mixedProjectPluginActivations; + delete (globalThis as Record).__mixedProjectPluginActivatedWith; + const failingAppBundle = dataUrl(` + export async function activate(ctx) { + await ctx.services.workspace.getProjectRoot(); + } + `); + const projectBundle = dataUrl(` + export function activate(ctx) { + globalThis.__mixedProjectPluginActivations = + (globalThis.__mixedProjectPluginActivations ?? 0) + 1; + globalThis.__mixedProjectPluginActivatedWith = ctx.pluginId; + } + `); + const focusedProject = new MockFocusedProjectGateway(); + const gateways = { + ...createMockGateways(), + focusedProject, + plugin: new MixedActivationScopeRuntimeCatalogPluginGateway( + failingAppBundle, + projectBundle, + ), + }; + + render( + + + + + , + ); + + const failureTitle = await screen.findByText("Certains plugins installés n'ont pas pu être chargés."); + const failureSection = failureTitle.closest("section"); + expect(failureSection).not.toBeNull(); + expect(within(failureSection as HTMLElement).getByText("dev.acme.app-needs-project")).toBeTruthy(); + expect( + within(failureSection as HTMLElement).getByText((_, element) => + element?.tagName === "LI" && + (element.textContent?.includes("no current project is focused") ?? false), + ), + ).toBeTruthy(); + expect(screen.getByText("Certains plugins attendent un projet actif.")).toBeTruthy(); + expect(screen.getByText("Project Plugin")).toBeTruthy(); + + await focusedProject.setFocusedProject({ id: "p1", name: "Alpha", root: "/tmp/alpha" }); + + await waitFor(() => { + expect(screen.queryByText("Certains plugins attendent un projet actif.")).toBeNull(); + expect((globalThis as Record).__mixedProjectPluginActivatedWith).toBe( + "dev.acme.project-plugin", + ); + }); + + await focusedProject.setFocusedProject({ id: "p2", name: "Beta", root: "/tmp/beta" }); + expect((globalThis as Record).__mixedProjectPluginActivations).toBe(1); + }); + it("installs from an archive via the review dialog, mentioning full-trust", async () => { renderPanel(); await screen.findByText("Aucun plugin installé."); diff --git a/frontend/src/features/projects/pluginGitRepository.test.tsx b/frontend/src/features/projects/pluginGitRepository.test.tsx index 703ab4f..013b414 100644 --- a/frontend/src/features/projects/pluginGitRepository.test.tsx +++ b/frontend/src/features/projects/pluginGitRepository.test.tsx @@ -118,7 +118,7 @@ function renderWithPlugin(git: GitGateway) { return render( - + , diff --git a/frontend/src/plugins/runtime/index.ts b/frontend/src/plugins/runtime/index.ts index 9b4a43f..3ef0af8 100644 --- a/frontend/src/plugins/runtime/index.ts +++ b/frontend/src/plugins/runtime/index.ts @@ -16,6 +16,7 @@ export { type IdeaPluginModule, type PluginActivation, type PluginLoadFailure, + type PluginLoadPending, type PluginLoadResult, } from "./loader"; export { diff --git a/frontend/src/plugins/runtime/loader.test.ts b/frontend/src/plugins/runtime/loader.test.ts index 51f6a70..5a98158 100644 --- a/frontend/src/plugins/runtime/loader.test.ts +++ b/frontend/src/plugins/runtime/loader.test.ts @@ -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).__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).__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).__healthyAppPluginActivated).toBe( + "dev.acme.healthy-app", + ); + }); + it("loads the hello-plugin command and layout contribution shape", async () => { const bundle = dataUrl(` export function activate(ctx) { diff --git a/frontend/src/plugins/runtime/loader.ts b/frontend/src/plugins/runtime/loader.ts index 5e42323..7bd3a38 100644 --- a/frontend/src/plugins/runtime/loader.ts +++ b/frontend/src/plugins/runtime/loader.ts @@ -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(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 { try { await activation?.dispose?.(); @@ -324,13 +345,20 @@ export async function loadPlugins( ): Promise { const registry = new PluginRuntimeRegistry(); const failures: PluginLoadFailure[] = []; + const pending: PluginLoadPending[] = []; const resolvedOptions: Required = { 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 }; } diff --git a/sdk/IdeaSDK b/sdk/IdeaSDK index 6bca9cc..e509e79 160000 --- a/sdk/IdeaSDK +++ b/sdk/IdeaSDK @@ -1 +1 @@ -Subproject commit 6bca9cc4c0147a37199439a11cde081e7e313131 +Subproject commit e509e796b4acb18e90e6f159d702088789521dbb