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,
|
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(),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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()
|
||||||
|
|||||||
@ -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,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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::{
|
||||||
|
|||||||
@ -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")
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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;
|
||||||
|
|||||||
@ -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()}
|
||||||
|
|||||||
@ -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,10 +73,7 @@ export function PluginRuntimeProvider({ children, value: injected }: PluginRunti
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (injected) return;
|
if (injected) return;
|
||||||
let cancelled = false;
|
let cancelled = false;
|
||||||
gateways.plugin
|
const pluginGateways = {
|
||||||
.listRuntimeContributions()
|
|
||||||
.then((catalog) =>
|
|
||||||
loadPlugins(catalog.plugins, {
|
|
||||||
project: gateways.project,
|
project: gateways.project,
|
||||||
git: gateways.git,
|
git: gateways.git,
|
||||||
terminal: gateways.terminal,
|
terminal: gateways.terminal,
|
||||||
@ -81,11 +87,71 @@ export function PluginRuntimeProvider({ children, value: injected }: PluginRunti
|
|||||||
pluginEvents: gateways.pluginEvents,
|
pluginEvents: gateways.pluginEvents,
|
||||||
pluginConfig: gateways.pluginConfig,
|
pluginConfig: gateways.pluginConfig,
|
||||||
pluginStorage: gateways.pluginStorage,
|
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) => {
|
.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.
|
||||||
|
|||||||
@ -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>
|
||||||
|
|||||||
@ -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é.");
|
||||||
|
|||||||
@ -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>,
|
||||||
|
|||||||
@ -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 {
|
||||||
|
|||||||
@ -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) {
|
||||||
|
|||||||
@ -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 };
|
||||||
}
|
}
|
||||||
|
|||||||
Submodule sdk/IdeaSDK updated: 6bca9cc4c0...e509e796b4
Reference in New Issue
Block a user