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:
@ -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,28 +73,85 @@ export function PluginRuntimeProvider({ children, value: injected }: PluginRunti
|
||||
useEffect(() => {
|
||||
if (injected) return;
|
||||
let cancelled = false;
|
||||
const pluginGateways = {
|
||||
project: gateways.project,
|
||||
git: gateways.git,
|
||||
terminal: gateways.terminal,
|
||||
agents: gateways.agent,
|
||||
system: gateways.system,
|
||||
workState: gateways.workState,
|
||||
focusedProject: gateways.focusedProject,
|
||||
pluginWorkspace: gateways.pluginWorkspace,
|
||||
pluginTask: gateways.pluginTask,
|
||||
pluginToolchain: gateways.pluginToolchain,
|
||||
pluginEvents: gateways.pluginEvents,
|
||||
pluginConfig: gateways.pluginConfig,
|
||||
pluginStorage: gateways.pluginStorage,
|
||||
};
|
||||
let activatedProjectScoped = false;
|
||||
let pendingProjectPlugins: PluginRuntimePlugin[] = [];
|
||||
let unsubscribeFocus: Unsubscribe | undefined;
|
||||
|
||||
void gateways.focusedProject
|
||||
.onFocusedProjectChanged(async (project) => {
|
||||
if (!project || activatedProjectScoped || pendingProjectPlugins.length === 0) return;
|
||||
activatedProjectScoped = true;
|
||||
const projectPlugins = pendingProjectPlugins;
|
||||
pendingProjectPlugins = [];
|
||||
try {
|
||||
const result = await loadPlugins(projectPlugins, pluginGateways);
|
||||
if (cancelled) return;
|
||||
setValue((prev) => {
|
||||
for (const plugin of result.registry.list()) prev.registry.add(plugin);
|
||||
return {
|
||||
registry: prev.registry,
|
||||
failures: [...prev.failures, ...result.failures],
|
||||
pending: prev.pending.filter(
|
||||
(p) => !projectPlugins.some((entry) => entry.id === p.pluginId),
|
||||
),
|
||||
loading: false,
|
||||
};
|
||||
});
|
||||
} catch (e: unknown) {
|
||||
if (cancelled) return;
|
||||
setValue((prev) => ({
|
||||
...prev,
|
||||
failures: [
|
||||
...prev.failures,
|
||||
...projectPlugins.map((plugin) => ({
|
||||
pluginId: plugin.id,
|
||||
reason: describeError(e),
|
||||
})),
|
||||
],
|
||||
pending: prev.pending.filter(
|
||||
(p) => !projectPlugins.some((entry) => entry.id === p.pluginId),
|
||||
),
|
||||
loading: false,
|
||||
}));
|
||||
}
|
||||
})
|
||||
.then((unsubscribe) => {
|
||||
if (cancelled) unsubscribe();
|
||||
else unsubscribeFocus = unsubscribe;
|
||||
});
|
||||
|
||||
gateways.plugin
|
||||
.listRuntimeContributions()
|
||||
.then((catalog) =>
|
||||
loadPlugins(catalog.plugins, {
|
||||
project: gateways.project,
|
||||
git: gateways.git,
|
||||
terminal: gateways.terminal,
|
||||
agents: gateways.agent,
|
||||
system: gateways.system,
|
||||
workState: gateways.workState,
|
||||
focusedProject: gateways.focusedProject,
|
||||
pluginWorkspace: gateways.pluginWorkspace,
|
||||
pluginTask: gateways.pluginTask,
|
||||
pluginToolchain: gateways.pluginToolchain,
|
||||
pluginEvents: gateways.pluginEvents,
|
||||
pluginConfig: gateways.pluginConfig,
|
||||
pluginStorage: gateways.pluginStorage,
|
||||
}),
|
||||
)
|
||||
.then(async (catalog) => {
|
||||
const result = await loadPlugins(catalog.plugins, pluginGateways);
|
||||
pendingProjectPlugins = catalog.plugins.filter((entry) =>
|
||||
result.pending.some((p) => p.pluginId === entry.id),
|
||||
);
|
||||
return result;
|
||||
})
|
||||
.then((result) => {
|
||||
if (cancelled) return;
|
||||
setValue({ registry: result.registry, failures: result.failures, loading: false });
|
||||
setValue({
|
||||
registry: result.registry,
|
||||
failures: result.failures,
|
||||
pending: result.pending,
|
||||
loading: false,
|
||||
});
|
||||
})
|
||||
.catch((e: unknown) => {
|
||||
// No plugin gateway / catalog fetch failed: run with zero plugins
|
||||
@ -97,12 +163,14 @@ export function PluginRuntimeProvider({ children, value: injected }: PluginRunti
|
||||
...prev.failures,
|
||||
{ pluginId: "<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>,
|
||||
|
||||
Reference in New Issue
Block a user