fix(#105): corrige le contexte plugin pour hello-plugin
Ajoute logger, subscriptions et commandes scindées au contexte plugin activé. Les plugins full-trust s'attendent à ce shape public.
This commit is contained in:
@ -13,6 +13,7 @@ import { useState } from "react";
|
||||
import type { PluginAdmin, PluginLifecycleState, PluginReview } from "@/domain";
|
||||
import { Button, Panel } from "@/shared";
|
||||
import { PluginConfirmDialog as ConfirmDialog } from "./PluginConfirmDialog";
|
||||
import { usePluginRuntime } from "./PluginRuntimeProvider";
|
||||
import { usePlugins } from "./usePlugins";
|
||||
|
||||
const STATE_LABEL: Record<PluginLifecycleState, string> = {
|
||||
@ -36,6 +37,7 @@ interface InstallFlow {
|
||||
|
||||
export function PluginsPanel() {
|
||||
const vm = usePlugins();
|
||||
const pluginRuntime = usePluginRuntime();
|
||||
const [pendingAction, setPendingAction] = useState<PendingAction | null>(null);
|
||||
const [installFlow, setInstallFlow] = useState<InstallFlow | null>(null);
|
||||
const [installFlowError, setInstallFlowError] = useState<string | null>(null);
|
||||
@ -108,6 +110,24 @@ export function PluginsPanel() {
|
||||
</p>
|
||||
)}
|
||||
|
||||
{pluginRuntime.failures.length > 0 && (
|
||||
<Panel className="border-warning/40">
|
||||
<div className="flex flex-col gap-1">
|
||||
<p role="alert" className="text-sm font-medium text-warning">
|
||||
Certains plugins installés n'ont pas pu être chargés.
|
||||
</p>
|
||||
<ul className="flex flex-col gap-0.5">
|
||||
{pluginRuntime.failures.map((failure) => (
|
||||
<li key={`${failure.pluginId}:${failure.reason}`} className="text-xs text-muted">
|
||||
<span className="font-medium text-content">{failure.pluginId}</span> :{" "}
|
||||
{failure.reason}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
</Panel>
|
||||
)}
|
||||
|
||||
{vm.plugins.length === 0 ? (
|
||||
<Panel>
|
||||
<p className="text-sm text-muted">Aucun plugin installé.</p>
|
||||
|
||||
@ -9,9 +9,19 @@ import { render, screen, waitFor, fireEvent, within } from "@testing-library/rea
|
||||
import { MockPluginGateway, MockSystemGateway } from "@/adapters/mock";
|
||||
import type { Gateways } from "@/ports";
|
||||
import { DIProvider } from "@/app/di";
|
||||
import { PluginRuntimeRegistry } from "@/plugins/runtime";
|
||||
import { PluginsPanel } from "./PluginsPanel";
|
||||
import { PluginRuntimeProvider, type PluginRuntimeContextValue } from "./PluginRuntimeProvider";
|
||||
|
||||
function renderPanel(plugin?: MockPluginGateway, system?: MockSystemGateway) {
|
||||
function renderPanel(
|
||||
plugin?: MockPluginGateway,
|
||||
system?: MockSystemGateway,
|
||||
runtimeValue: PluginRuntimeContextValue = {
|
||||
registry: new PluginRuntimeRegistry(),
|
||||
failures: [],
|
||||
loading: false,
|
||||
},
|
||||
) {
|
||||
const p = plugin ?? new MockPluginGateway();
|
||||
const s = system ?? new MockSystemGateway();
|
||||
const gateways = { plugin: p, system: s } as unknown as Gateways;
|
||||
@ -20,7 +30,9 @@ function renderPanel(plugin?: MockPluginGateway, system?: MockSystemGateway) {
|
||||
system: s,
|
||||
...render(
|
||||
<DIProvider gateways={gateways}>
|
||||
<PluginsPanel />
|
||||
<PluginRuntimeProvider value={runtimeValue}>
|
||||
<PluginsPanel />
|
||||
</PluginRuntimeProvider>
|
||||
</DIProvider>,
|
||||
),
|
||||
};
|
||||
@ -32,6 +44,19 @@ describe("PluginsPanel", () => {
|
||||
expect(await screen.findByText("Aucun plugin installé.")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("keeps the Plugins panel rendered when a runtime bundle fails to load", async () => {
|
||||
renderPanel(undefined, undefined, {
|
||||
registry: new PluginRuntimeRegistry(),
|
||||
failures: [{ pluginId: "com.example.hello-plugin", reason: "Cannot use import statement outside a module" }],
|
||||
loading: false,
|
||||
});
|
||||
|
||||
expect(await screen.findByText("Aucun plugin installé.")).toBeTruthy();
|
||||
expect(screen.getByText("Certains plugins installés n'ont pas pu être chargés.")).toBeTruthy();
|
||||
expect(screen.getByText("com.example.hello-plugin")).toBeTruthy();
|
||||
expect(screen.getByText(/Cannot use import statement outside a module/)).toBeTruthy();
|
||||
});
|
||||
|
||||
it("installs from an archive via the review dialog, mentioning full-trust", async () => {
|
||||
renderPanel();
|
||||
await screen.findByText("Aucun plugin installé.");
|
||||
|
||||
@ -117,6 +117,47 @@ describe("loadPlugins", () => {
|
||||
expect((globalThis as Record<string, unknown>).__ranCommand).toBe(true);
|
||||
});
|
||||
|
||||
it("loads plugins built against the public SDK context shape", async () => {
|
||||
const bundle = dataUrl(`
|
||||
export function activate(ctx) {
|
||||
ctx.logger.info("hello");
|
||||
const disposable = ctx.commands.registerCommand("hello-plugin.sayHello", () => {
|
||||
globalThis.__helloCommandRan = true;
|
||||
return "Hello from IdeA";
|
||||
});
|
||||
ctx.subscriptions.push(disposable);
|
||||
}
|
||||
`);
|
||||
const { registry, failures } = await loadPlugins(
|
||||
[
|
||||
entry({
|
||||
id: "com.example.hello-plugin",
|
||||
displayName: "Hello Plugin",
|
||||
bundleUrl: bundle,
|
||||
contributes: {
|
||||
...emptyContributes(),
|
||||
menuItems: [
|
||||
{
|
||||
id: "hello-plugin.sayHello.item",
|
||||
targetMenuId: "plugin:hello-plugin.menu",
|
||||
label: "Say Hello",
|
||||
command: "hello-plugin.sayHello",
|
||||
},
|
||||
],
|
||||
},
|
||||
}),
|
||||
],
|
||||
gateways,
|
||||
);
|
||||
|
||||
expect(failures).toEqual([]);
|
||||
await registry.runCommand("com.example.hello-plugin", "hello-plugin.sayHello");
|
||||
expect((globalThis as Record<string, unknown>).__helloCommandRan).toBe(true);
|
||||
|
||||
await registry.remove("com.example.hello-plugin");
|
||||
expect(registry.get("com.example.hello-plugin")).toBeUndefined();
|
||||
});
|
||||
|
||||
it("calls dispose() on removal (best-effort)", async () => {
|
||||
const bundle = dataUrl(`
|
||||
export function activate(ctx) {
|
||||
|
||||
@ -21,6 +21,7 @@ import {
|
||||
PluginLayoutRegistry,
|
||||
PluginMenuRegistry,
|
||||
PluginRuntimeRegistry,
|
||||
type Disposable,
|
||||
type LoadedPlugin,
|
||||
type PluginGatewaySet,
|
||||
} from "./registry";
|
||||
@ -31,7 +32,9 @@ export interface IdeaPluginContext extends PluginGatewaySet {
|
||||
pluginId: string;
|
||||
pluginDisplayName: string;
|
||||
version: string;
|
||||
commands: PluginCommandRegistry;
|
||||
logger: PluginLogger;
|
||||
subscriptions: Disposable[];
|
||||
commands: PluginCommandContext;
|
||||
layouts: PluginLayoutRegistry;
|
||||
menu: PluginMenuRegistry;
|
||||
}
|
||||
@ -40,8 +43,23 @@ export interface PluginActivation {
|
||||
dispose?: () => void | Promise<void>;
|
||||
}
|
||||
|
||||
export interface PluginLogger {
|
||||
debug(message: string, ...args: unknown[]): void;
|
||||
info(message: string, ...args: unknown[]): void;
|
||||
warn(message: string, ...args: unknown[]): void;
|
||||
error(message: string, ...args: unknown[]): void;
|
||||
}
|
||||
|
||||
export interface PluginCommandContext {
|
||||
register(commandId: string, handler: (...args: unknown[]) => void | Promise<void>): Disposable;
|
||||
registerCommand(
|
||||
commandId: string,
|
||||
handler: (...args: unknown[]) => unknown | Promise<unknown>,
|
||||
): Disposable;
|
||||
}
|
||||
|
||||
export interface IdeaPluginModule {
|
||||
activate(ctx: IdeaPluginContext): PluginActivation | Promise<PluginActivation>;
|
||||
activate(ctx: IdeaPluginContext): void | PluginActivation | Promise<void | PluginActivation>;
|
||||
}
|
||||
|
||||
export interface PluginLoadFailure {
|
||||
@ -63,6 +81,42 @@ function isIdeaPluginModule(mod: unknown): mod is IdeaPluginModule {
|
||||
);
|
||||
}
|
||||
|
||||
function createPluginLogger(entry: PluginRuntimePlugin): PluginLogger {
|
||||
const prefix = `[plugin:${entry.id}]`;
|
||||
return {
|
||||
debug: (message, ...args) => console.debug(prefix, message, ...args),
|
||||
info: (message, ...args) => console.info(prefix, message, ...args),
|
||||
warn: (message, ...args) => console.warn(prefix, message, ...args),
|
||||
error: (message, ...args) => console.error(prefix, message, ...args),
|
||||
};
|
||||
}
|
||||
|
||||
function createCommandContext(commands: PluginCommandRegistry): PluginCommandContext {
|
||||
return {
|
||||
register: (commandId, handler) => commands.register(commandId, handler),
|
||||
registerCommand: (commandId, handler) =>
|
||||
commands.register(commandId, async (...args) => {
|
||||
await handler(...args);
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
async function disposeAll(disposables: Disposable[], activation?: void | PluginActivation): Promise<void> {
|
||||
try {
|
||||
await activation?.dispose?.();
|
||||
} catch {
|
||||
/* best-effort */
|
||||
}
|
||||
|
||||
for (const disposable of disposables.splice(0).reverse()) {
|
||||
try {
|
||||
disposable.dispose();
|
||||
} catch {
|
||||
/* best-effort */
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function loadOne(
|
||||
entry: PluginRuntimePlugin,
|
||||
gateways: PluginGatewaySet,
|
||||
@ -86,12 +140,15 @@ async function loadOne(
|
||||
const commands = new PluginCommandRegistry(entry.id, declaredCommandIds);
|
||||
const layouts = new PluginLayoutRegistry(entry.id, declaredLayoutTypes);
|
||||
const menu = new PluginMenuRegistry(entry.id);
|
||||
const subscriptions: Disposable[] = [];
|
||||
|
||||
const ctx: IdeaPluginContext = {
|
||||
pluginId: entry.id,
|
||||
pluginDisplayName: entry.displayName,
|
||||
version: entry.version,
|
||||
commands,
|
||||
logger: createPluginLogger(entry),
|
||||
subscriptions,
|
||||
commands: createCommandContext(commands),
|
||||
layouts,
|
||||
menu,
|
||||
...gateways,
|
||||
@ -107,13 +164,9 @@ async function loadOne(
|
||||
layouts,
|
||||
menu,
|
||||
dispose: async () => {
|
||||
// Best-effort, full-trust (carnet §1.3) — a broken `dispose()` must not
|
||||
// prevent removing the plugin from the runtime registry.
|
||||
try {
|
||||
await activation.dispose?.();
|
||||
} catch {
|
||||
/* best-effort */
|
||||
}
|
||||
// Best-effort, full-trust (carnet §1.3) — a broken `dispose()` or
|
||||
// subscription must not prevent removing the plugin from the registry.
|
||||
await disposeAll(subscriptions, activation);
|
||||
},
|
||||
};
|
||||
return { plugin };
|
||||
|
||||
Reference in New Issue
Block a user