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

@ -754,6 +754,7 @@ mod tests {
icon: None, icon: None,
trust_level: PluginTrustLevel::Full, trust_level: PluginTrustLevel::Full,
capabilities: Vec::new(), capabilities: Vec::new(),
activation_scope: domain::PluginActivationScope::default(),
contributes: PluginContributionSet::default(), contributes: PluginContributionSet::default(),
}) })
} }

View File

@ -49,6 +49,7 @@ fn runtime_catalog_dto_carries_bundle_hash_and_contributions() {
icon_url: None, icon_url: None,
content_hash: "abc".to_owned(), content_hash: "abc".to_owned(),
capabilities: vec![PluginCapability::Ui, PluginCapability::Tooling], capabilities: vec![PluginCapability::Ui, PluginCapability::Tooling],
activation_scope: domain::PluginActivationScope::Project,
contributes: PluginContributionSet::default(), contributes: PluginContributionSet::default(),
}], }],
}; };
@ -63,6 +64,7 @@ fn runtime_catalog_dto_carries_bundle_hash_and_contributions() {
value["plugins"][0]["capabilities"], value["plugins"][0]["capabilities"],
serde_json::json!(["ui", "tooling"]) serde_json::json!(["ui", "tooling"])
); );
assert_eq!(value["plugins"][0]["activationScope"], "project");
assert!(value["plugins"][0]["contributes"]["menus"] assert!(value["plugins"][0]["contributes"]["menus"]
.as_array() .as_array()
.unwrap() .unwrap()

View File

@ -153,6 +153,8 @@ pub struct PluginRuntimePlugin {
pub content_hash: String, pub content_hash: String,
/// Public manifest capabilities. /// Public manifest capabilities.
pub capabilities: Vec<domain::PluginCapability>, pub capabilities: Vec<domain::PluginCapability>,
/// Manifest-declared activation scope.
pub activation_scope: domain::PluginActivationScope,
/// Contributions. /// Contributions.
pub contributes: PluginContributionSet, pub contributes: PluginContributionSet,
} }
@ -2207,6 +2209,7 @@ impl ListPlugins {
icon: None, icon: None,
trust_level: PluginTrustLevel::Full, trust_level: PluginTrustLevel::Full,
capabilities: Vec::new(), capabilities: Vec::new(),
activation_scope: domain::PluginActivationScope::default(),
contributes: PluginContributionSet::default(), contributes: PluginContributionSet::default(),
}; };
out.push(admin_from_descriptor( out.push(admin_from_descriptor(
@ -2793,6 +2796,7 @@ async fn runtime_plugin_from_entry(
icon_url, icon_url,
content_hash: descriptor.registry.content_hash.as_str().to_owned(), content_hash: descriptor.registry.content_hash.as_str().to_owned(),
capabilities: descriptor.manifest.capabilities, capabilities: descriptor.manifest.capabilities,
activation_scope: descriptor.manifest.activation_scope,
contributes: descriptor.manifest.contributes, contributes: descriptor.manifest.contributes,
}) })
} }
@ -3006,6 +3010,8 @@ struct RawManifest {
trust_level: String, trust_level: String,
#[serde(default)] #[serde(default)]
capabilities: Vec<String>, capabilities: Vec<String>,
#[serde(default)]
activation_scope: domain::PluginActivationScope,
contributes: RawContributes, contributes: RawContributes,
} }
@ -3158,6 +3164,7 @@ impl PluginManifestValidator for JsonPluginManifestValidator {
icon, icon,
trust_level: PluginTrustLevel::Full, trust_level: PluginTrustLevel::Full,
capabilities, capabilities,
activation_scope: raw.activation_scope,
contributes, contributes,
}) })
} }

View File

@ -242,6 +242,8 @@ pub struct PluginRuntimePluginDto {
pub content_hash: String, pub content_hash: String,
/// Public manifest capabilities. /// Public manifest capabilities.
pub capabilities: Vec<domain::PluginCapability>, pub capabilities: Vec<domain::PluginCapability>,
/// Manifest-declared activation scope.
pub activation_scope: domain::PluginActivationScope,
/// Contributions. /// Contributions.
pub contributes: domain::PluginContributionSet, pub contributes: domain::PluginContributionSet,
} }
@ -269,6 +271,7 @@ impl From<application::PluginRuntimePlugin> for PluginRuntimePluginDto {
icon_url: value.icon_url, icon_url: value.icon_url,
content_hash: value.content_hash, content_hash: value.content_hash,
capabilities: value.capabilities, capabilities: value.capabilities,
activation_scope: value.activation_scope,
contributes: value.contributes, contributes: value.contributes,
} }
} }

View File

@ -217,13 +217,13 @@ pub use system_permissions::{
}; };
pub use plugin::{ pub use plugin::{
ContentHash, CustomPluginLayout, PluginBundleUrl, PluginCapability, PluginCommandId, ContentHash, CustomPluginLayout, PluginActivationScope, PluginBundleUrl, PluginCapability,
PluginContributionSet, PluginDescriptor, PluginError, PluginId, PluginInstallSource, PluginCommandId, PluginContributionSet, PluginDescriptor, PluginError, PluginId,
PluginLayoutContribution, PluginLayoutType, PluginLifecycleState, PluginManifest, PluginInstallSource, PluginLayoutContribution, PluginLayoutType, PluginLifecycleState,
PluginMcpServerContribution, PluginMcpServerId, PluginMcpServerSpec, PluginMcpStatus, PluginManifest, PluginMcpServerContribution, PluginMcpServerId, PluginMcpServerSpec,
PluginMcpStatusSet, PluginMenuItemContribution, PluginPackageRef, PluginRegistry, PluginMcpStatus, PluginMcpStatusSet, PluginMenuItemContribution, PluginPackageRef,
PluginRegistryEntry, PluginTopLevelMenuContribution, PluginTrustLevel, PluginVersion, PluginRegistry, PluginRegistryEntry, PluginTopLevelMenuContribution, PluginTrustLevel,
RelativePath, RemovalOutcome, StagedPluginPackage, PluginVersion, RelativePath, RemovalOutcome, StagedPluginPackage,
}; };
pub use sandbox::{ pub use sandbox::{

View File

@ -288,6 +288,22 @@ pub enum PluginCapability {
Tooling, 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. /// Plugin command id.
#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)] #[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(transparent)] #[serde(transparent)]
@ -495,6 +511,9 @@ pub struct PluginManifest {
/// Capabilities. /// Capabilities.
#[serde(default)] #[serde(default)]
pub capabilities: Vec<PluginCapability>, pub capabilities: Vec<PluginCapability>,
/// Activation scope. Missing in older manifests means app-level activation.
#[serde(default)]
pub activation_scope: PluginActivationScope,
/// Contributions. /// Contributions.
pub contributes: PluginContributionSet, pub contributes: PluginContributionSet,
} }
@ -693,4 +712,13 @@ mod tests {
serde_json::json!(["ui", "mcp", "tooling"]) 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")
);
}
} }

View File

@ -1799,6 +1799,14 @@ export interface PluginRuntimePlugin {
publisher?: string; publisher?: string;
version: string; version: string;
capabilities?: 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; bundleUrl: string;
iconUrl?: string; iconUrl?: string;
contentHash: string; contentHash: string;

View File

@ -73,7 +73,7 @@ function renderCell(
const gateways: Gateways = createMockGateways(); const gateways: Gateways = createMockGateways();
return render( return render(
<DIProvider gateways={gateways}> <DIProvider gateways={gateways}>
<PluginRuntimeProvider value={{ registry, failures: [], loading: false }}> <PluginRuntimeProvider value={{ registry, failures: [], pending: [], loading: false }}>
<PluginLayoutCellView <PluginLayoutCellView
projectId="proj-1" projectId="proj-1"
cell={props.cell ?? cell()} cell={props.cell ?? cell()}

View File

@ -13,12 +13,19 @@
import { createContext, useContext, useEffect, useState, type ReactNode } from "react"; import { createContext, useContext, useEffect, useState, type ReactNode } from "react";
import { loadPlugins, PluginRuntimeRegistry, type PluginLoadFailure } from "@/plugins/runtime"; import {
loadPlugins,
PluginRuntimeRegistry,
type PluginLoadFailure,
type PluginLoadPending,
} from "@/plugins/runtime";
import { useGateways } from "@/app/di"; import { useGateways } from "@/app/di";
import type { PluginRuntimePlugin, Unsubscribe } from "@/domain";
export interface PluginRuntimeContextValue { export interface PluginRuntimeContextValue {
registry: PluginRuntimeRegistry; registry: PluginRuntimeRegistry;
failures: PluginLoadFailure[]; failures: PluginLoadFailure[];
pending: PluginLoadPending[];
/** True until the initial catalog fetch + bundle loads have settled. */ /** True until the initial catalog fetch + bundle loads have settled. */
loading: boolean; loading: boolean;
} }
@ -33,6 +40,7 @@ export interface PluginRuntimeContextValue {
const EMPTY_PLUGIN_RUNTIME: PluginRuntimeContextValue = { const EMPTY_PLUGIN_RUNTIME: PluginRuntimeContextValue = {
registry: new PluginRuntimeRegistry(), registry: new PluginRuntimeRegistry(),
failures: [], failures: [],
pending: [],
loading: false, loading: false,
}; };
@ -57,6 +65,7 @@ export function PluginRuntimeProvider({ children, value: injected }: PluginRunti
injected ?? { injected ?? {
registry: new PluginRuntimeRegistry(), registry: new PluginRuntimeRegistry(),
failures: [], failures: [],
pending: [],
loading: true, loading: true,
}, },
); );
@ -64,28 +73,85 @@ export function PluginRuntimeProvider({ children, value: injected }: PluginRunti
useEffect(() => { useEffect(() => {
if (injected) return; if (injected) return;
let cancelled = false; 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 gateways.plugin
.listRuntimeContributions() .listRuntimeContributions()
.then((catalog) => .then(async (catalog) => {
loadPlugins(catalog.plugins, { const result = await loadPlugins(catalog.plugins, pluginGateways);
project: gateways.project, pendingProjectPlugins = catalog.plugins.filter((entry) =>
git: gateways.git, result.pending.some((p) => p.pluginId === entry.id),
terminal: gateways.terminal, );
agents: gateways.agent, return result;
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((result) => { .then((result) => {
if (cancelled) return; 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) => { .catch((e: unknown) => {
// No plugin gateway / catalog fetch failed: run with zero plugins // No plugin gateway / catalog fetch failed: run with zero plugins
@ -97,12 +163,14 @@ export function PluginRuntimeProvider({ children, value: injected }: PluginRunti
...prev.failures, ...prev.failures,
{ pluginId: "<runtime-catalog>", reason: describeError(e) }, { pluginId: "<runtime-catalog>", reason: describeError(e) },
], ],
pending: [],
loading: false, loading: false,
})); }));
} }
}); });
return () => { return () => {
cancelled = true; cancelled = true;
unsubscribeFocus?.();
}; };
// Gateways are a stable singleton for the app session (from `useGateways`); // Gateways are a stable singleton for the app session (from `useGateways`);
// re-running on every render would reload every plugin bundle. // re-running on every render would reload every plugin bundle.

View File

@ -128,6 +128,23 @@ export function PluginsPanel() {
</Panel> </Panel>
)} )}
{pluginRuntime.pending.length > 0 && (
<Panel>
<div className="flex flex-col gap-1">
<p className="text-sm font-medium text-content">
Certains plugins attendent un projet actif.
</p>
<ul className="flex flex-col gap-0.5">
{pluginRuntime.pending.map((pending) => (
<li key={pending.pluginId} className="text-xs text-muted">
<span className="font-medium text-content">{pending.displayName}</span>
</li>
))}
</ul>
</div>
</Panel>
)}
{vm.plugins.length === 0 ? ( {vm.plugins.length === 0 ? (
<Panel> <Panel>
<p className="text-sm text-muted">Aucun plugin installé.</p> <p className="text-sm text-muted">Aucun plugin installé.</p>

View File

@ -6,7 +6,12 @@
import { describe, it, expect } from "vitest"; import { describe, it, expect } from "vitest";
import { render, screen, waitFor, fireEvent, within } from "@testing-library/react"; 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 { PluginInstallResult, PluginReview, PluginRuntimeContributionCatalog } from "@/domain";
import type { Gateways, ReviewPluginPackageInput } from "@/ports"; import type { Gateways, ReviewPluginPackageInput } from "@/ports";
import { DIProvider } from "@/app/di"; import { DIProvider } from "@/app/di";
@ -14,12 +19,17 @@ import { PluginRuntimeRegistry } from "@/plugins/runtime";
import { PluginsPanel } from "./PluginsPanel"; import { PluginsPanel } from "./PluginsPanel";
import { PluginRuntimeProvider, type PluginRuntimeContextValue } from "./PluginRuntimeProvider"; import { PluginRuntimeProvider, type PluginRuntimeContextValue } from "./PluginRuntimeProvider";
function dataUrl(source: string): string {
return `data:text/javascript;base64,${Buffer.from(source).toString("base64")}`;
}
function renderPanel( function renderPanel(
plugin?: MockPluginGateway, plugin?: MockPluginGateway,
system?: MockSystemGateway, system?: MockSystemGateway,
runtimeValue: PluginRuntimeContextValue = { runtimeValue: PluginRuntimeContextValue = {
registry: new PluginRuntimeRegistry(), registry: new PluginRuntimeRegistry(),
failures: [], failures: [],
pending: [],
loading: false, loading: false,
}, },
) { ) {
@ -40,7 +50,7 @@ function renderPanel(
} }
function renderPanelWithLiveRuntime(plugin: MockPluginGateway, system = new MockSystemGateway()) { function renderPanelWithLiveRuntime(plugin: MockPluginGateway, system = new MockSystemGateway()) {
const gateways = { plugin, system } as unknown as Gateways; const gateways = { ...createMockGateways(), plugin, system };
return render( return render(
<DIProvider gateways={gateways}> <DIProvider gateways={gateways}>
<PluginRuntimeProvider> <PluginRuntimeProvider>
@ -77,6 +87,63 @@ class FailingRuntimeCatalogPluginGateway extends MockPluginGateway {
} }
} }
class ProjectScopedRuntimeCatalogPluginGateway extends MockPluginGateway {
constructor(private readonly bundleUrl: string) {
super();
}
async listRuntimeContributions(): Promise<PluginRuntimeContributionCatalog> {
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<PluginRuntimeContributionCatalog> {
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 { class BackendShapedReviewPluginGateway extends MockPluginGateway {
async reviewPackage(input: ReviewPluginPackageInput): Promise<PluginReview> { async reviewPackage(input: ReviewPluginPackageInput): Promise<PluginReview> {
const label = input.path.split("/").pop() ?? input.path; const label = input.path.split("/").pop() ?? input.path;
@ -106,6 +173,7 @@ describe("PluginsPanel", () => {
renderPanel(undefined, undefined, { renderPanel(undefined, undefined, {
registry: new PluginRuntimeRegistry(), registry: new PluginRuntimeRegistry(),
failures: [{ pluginId: "com.example.hello-plugin", reason: "Cannot use import statement outside a module" }], failures: [{ pluginId: "com.example.hello-plugin", reason: "Cannot use import statement outside a module" }],
pending: [],
loading: false, loading: false,
}); });
@ -124,6 +192,130 @@ describe("PluginsPanel", () => {
expect(screen.getByText(/runtime catalog failed/)).toBeTruthy(); 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<string, unknown>).__projectPluginActivations;
delete (globalThis as Record<string, unknown>).__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(
<DIProvider gateways={gateways}>
<PluginRuntimeProvider>
<PluginsPanel />
</PluginRuntimeProvider>
</DIProvider>,
);
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<string, unknown>).__projectPluginActivatedWith).toBe(
"dev.acme.project-plugin",
);
});
await focusedProject.setFocusedProject({ id: "p2", name: "Beta", root: "/tmp/beta" });
expect((globalThis as Record<string, unknown>).__projectPluginActivations).toBe(1);
});
it("keeps app-scope failures distinct from project-scope pending plugins, without cross-blocking", async () => {
delete (globalThis as Record<string, unknown>).__mixedProjectPluginActivations;
delete (globalThis as Record<string, unknown>).__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(
<DIProvider gateways={gateways}>
<PluginRuntimeProvider>
<PluginsPanel />
</PluginRuntimeProvider>
</DIProvider>,
);
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<string, unknown>).__mixedProjectPluginActivatedWith).toBe(
"dev.acme.project-plugin",
);
});
await focusedProject.setFocusedProject({ id: "p2", name: "Beta", root: "/tmp/beta" });
expect((globalThis as Record<string, unknown>).__mixedProjectPluginActivations).toBe(1);
});
it("installs from an archive via the review dialog, mentioning full-trust", async () => { it("installs from an archive via the review dialog, mentioning full-trust", async () => {
renderPanel(); renderPanel();
await screen.findByText("Aucun plugin installé."); await screen.findByText("Aucun plugin installé.");

View File

@ -118,7 +118,7 @@ function renderWithPlugin(git: GitGateway) {
return render( return render(
<DIProvider gateways={gateways}> <DIProvider gateways={gateways}>
<PluginRuntimeProvider value={{ registry, failures: [], loading: false }}> <PluginRuntimeProvider value={{ registry, failures: [], pending: [], loading: false }}>
<ProjectsView /> <ProjectsView />
</PluginRuntimeProvider> </PluginRuntimeProvider>
</DIProvider>, </DIProvider>,

View File

@ -16,6 +16,7 @@ export {
type IdeaPluginModule, type IdeaPluginModule,
type PluginActivation, type PluginActivation,
type PluginLoadFailure, type PluginLoadFailure,
type PluginLoadPending,
type PluginLoadResult, type PluginLoadResult,
} from "./loader"; } from "./loader";
export { export {

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 () => { it("loads the hello-plugin command and layout contribution shape", async () => {
const bundle = dataUrl(` const bundle = dataUrl(`
export function activate(ctx) { export function activate(ctx) {

View File

@ -76,9 +76,16 @@ export interface PluginLoadFailure {
reason: string; reason: string;
} }
export interface PluginLoadPending {
pluginId: string;
displayName: string;
reason: string;
}
export interface PluginLoadResult { export interface PluginLoadResult {
registry: PluginRuntimeRegistry; registry: PluginRuntimeRegistry;
failures: PluginLoadFailure[]; failures: PluginLoadFailure[];
pending: PluginLoadPending[];
} }
export interface PluginLoadOptions { export interface PluginLoadOptions {
@ -190,6 +197,20 @@ function hasCapability(entry: PluginRuntimePlugin, capability: string): boolean
return arrayOrEmpty<string>(objectOrEmpty(entry).capabilities).includes(capability); 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> { async function disposeAll(disposables: Disposable[], activation?: void | PluginActivation): Promise<void> {
try { try {
await activation?.dispose?.(); await activation?.dispose?.();
@ -324,13 +345,20 @@ export async function loadPlugins(
): Promise<PluginLoadResult> { ): Promise<PluginLoadResult> {
const registry = new PluginRuntimeRegistry(); const registry = new PluginRuntimeRegistry();
const failures: PluginLoadFailure[] = []; const failures: PluginLoadFailure[] = [];
const pending: PluginLoadPending[] = [];
const resolvedOptions: Required<PluginLoadOptions> = { const resolvedOptions: Required<PluginLoadOptions> = {
timeoutMs: options.timeoutMs ?? DEFAULT_PLUGIN_LOAD_TIMEOUT_MS, timeoutMs: options.timeoutMs ?? DEFAULT_PLUGIN_LOAD_TIMEOUT_MS,
}; };
const entries = Array.isArray(catalogPlugins) ? catalogPlugins : []; 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( const results = await Promise.all(
entries.map((entry) => loadOne(entry, gateways, resolvedOptions)), loadableEntries.map((entry) => loadOne(entry, gateways, resolvedOptions)),
); );
for (const result of results) { for (const result of results) {
if ("failure" in result) { if ("failure" in result) {
@ -345,9 +373,9 @@ export async function loadPlugins(
if (entries.length > 0) { if (entries.length > 0) {
console.info( 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 };
} }