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:
@ -754,6 +754,7 @@ mod tests {
|
||||
icon: None,
|
||||
trust_level: PluginTrustLevel::Full,
|
||||
capabilities: Vec::new(),
|
||||
activation_scope: domain::PluginActivationScope::default(),
|
||||
contributes: PluginContributionSet::default(),
|
||||
})
|
||||
}
|
||||
|
||||
@ -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()
|
||||
|
||||
@ -153,6 +153,8 @@ pub struct PluginRuntimePlugin {
|
||||
pub content_hash: String,
|
||||
/// Public manifest capabilities.
|
||||
pub capabilities: Vec<domain::PluginCapability>,
|
||||
/// 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<String>,
|
||||
#[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,
|
||||
})
|
||||
}
|
||||
|
||||
@ -242,6 +242,8 @@ pub struct PluginRuntimePluginDto {
|
||||
pub content_hash: String,
|
||||
/// Public manifest capabilities.
|
||||
pub capabilities: Vec<domain::PluginCapability>,
|
||||
/// Manifest-declared activation scope.
|
||||
pub activation_scope: domain::PluginActivationScope,
|
||||
/// Contributions.
|
||||
pub contributes: domain::PluginContributionSet,
|
||||
}
|
||||
@ -269,6 +271,7 @@ impl From<application::PluginRuntimePlugin> for PluginRuntimePluginDto {
|
||||
icon_url: value.icon_url,
|
||||
content_hash: value.content_hash,
|
||||
capabilities: value.capabilities,
|
||||
activation_scope: value.activation_scope,
|
||||
contributes: value.contributes,
|
||||
}
|
||||
}
|
||||
|
||||
@ -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::{
|
||||
|
||||
@ -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<PluginCapability>,
|
||||
/// 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")
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -73,7 +73,7 @@ function renderCell(
|
||||
const gateways: Gateways = createMockGateways();
|
||||
return render(
|
||||
<DIProvider gateways={gateways}>
|
||||
<PluginRuntimeProvider value={{ registry, failures: [], loading: false }}>
|
||||
<PluginRuntimeProvider value={{ registry, failures: [], pending: [], loading: false }}>
|
||||
<PluginLayoutCellView
|
||||
projectId="proj-1"
|
||||
cell={props.cell ?? cell()}
|
||||
|
||||
@ -13,12 +13,19 @@
|
||||
|
||||
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 type { PluginRuntimePlugin, Unsubscribe } from "@/domain";
|
||||
|
||||
export interface PluginRuntimeContextValue {
|
||||
registry: PluginRuntimeRegistry;
|
||||
failures: PluginLoadFailure[];
|
||||
pending: PluginLoadPending[];
|
||||
/** True until the initial catalog fetch + bundle loads have settled. */
|
||||
loading: boolean;
|
||||
}
|
||||
@ -33,6 +40,7 @@ export interface PluginRuntimeContextValue {
|
||||
const EMPTY_PLUGIN_RUNTIME: PluginRuntimeContextValue = {
|
||||
registry: new PluginRuntimeRegistry(),
|
||||
failures: [],
|
||||
pending: [],
|
||||
loading: false,
|
||||
};
|
||||
|
||||
@ -57,6 +65,7 @@ export function PluginRuntimeProvider({ children, value: injected }: PluginRunti
|
||||
injected ?? {
|
||||
registry: new PluginRuntimeRegistry(),
|
||||
failures: [],
|
||||
pending: [],
|
||||
loading: true,
|
||||
},
|
||||
);
|
||||
@ -64,10 +73,7 @@ export function PluginRuntimeProvider({ children, value: injected }: PluginRunti
|
||||
useEffect(() => {
|
||||
if (injected) return;
|
||||
let cancelled = false;
|
||||
gateways.plugin
|
||||
.listRuntimeContributions()
|
||||
.then((catalog) =>
|
||||
loadPlugins(catalog.plugins, {
|
||||
const pluginGateways = {
|
||||
project: gateways.project,
|
||||
git: gateways.git,
|
||||
terminal: gateways.terminal,
|
||||
@ -81,11 +87,71 @@ export function PluginRuntimeProvider({ children, value: injected }: PluginRunti
|
||||
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(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: "<runtime-catalog>", 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.
|
||||
|
||||
@ -128,6 +128,23 @@ export function PluginsPanel() {
|
||||
</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 ? (
|
||||
<Panel>
|
||||
<p className="text-sm text-muted">Aucun plugin installé.</p>
|
||||
|
||||
@ -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(
|
||||
<DIProvider gateways={gateways}>
|
||||
<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 {
|
||||
async reviewPackage(input: ReviewPluginPackageInput): Promise<PluginReview> {
|
||||
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<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 () => {
|
||||
renderPanel();
|
||||
await screen.findByText("Aucun plugin installé.");
|
||||
|
||||
@ -118,7 +118,7 @@ function renderWithPlugin(git: GitGateway) {
|
||||
|
||||
return render(
|
||||
<DIProvider gateways={gateways}>
|
||||
<PluginRuntimeProvider value={{ registry, failures: [], loading: false }}>
|
||||
<PluginRuntimeProvider value={{ registry, failures: [], pending: [], loading: false }}>
|
||||
<ProjectsView />
|
||||
</PluginRuntimeProvider>
|
||||
</DIProvider>,
|
||||
|
||||
@ -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 };
|
||||
}
|
||||
|
||||
Submodule sdk/IdeaSDK updated: 6bca9cc4c0...e509e796b4
Reference in New Issue
Block a user