feat(frontend): host les fenêtres plugin et l'API publique windows.open
Ajoute la vue window host qui charge le layout plugin dans une fenêtre OS détachée, expose react/react-dom/jsx-runtime hébergés sous frontend/public/plugin-host pour que le runtime SDK y résolve React, et câble services.windows.open() côté runtime plugin (loader/services/registry). npm test -- --run src/adapters/window.test.ts src/app/ViewWindow.test.tsx src/plugins/runtime/loader.test.ts src/plugins/runtime/services.test.ts : 39/39 npm run typecheck : OK npm run build : OK Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@ -219,6 +219,7 @@ describe("loadPlugins", () => {
|
||||
globalThis.__eventServiceKeys = Object.keys(ctx.services.events).sort();
|
||||
globalThis.__configServiceKeys = Object.keys(ctx.services.config).sort();
|
||||
globalThis.__terminalServiceKeys = Object.keys(ctx.services.terminal).sort();
|
||||
globalThis.__windowServiceKeys = Object.keys(ctx.services.windows).sort();
|
||||
}
|
||||
`);
|
||||
const { failures } = await loadPlugins(
|
||||
@ -253,6 +254,7 @@ describe("loadPlugins", () => {
|
||||
"tasks",
|
||||
"terminal",
|
||||
"tooling",
|
||||
"windows",
|
||||
"workspace",
|
||||
]);
|
||||
expect((globalThis as Record<string, unknown>).__workspaceServiceKeys).toEqual([
|
||||
@ -294,6 +296,94 @@ describe("loadPlugins", () => {
|
||||
"open",
|
||||
"reattach",
|
||||
]);
|
||||
expect((globalThis as Record<string, unknown>).__windowServiceKeys).toEqual([
|
||||
"open",
|
||||
]);
|
||||
});
|
||||
|
||||
it("injects services for UI plugins and validates plugin layout windows against declared layouts", async () => {
|
||||
const calls: unknown[] = [];
|
||||
const windowGateways = {
|
||||
...gateways,
|
||||
window: {
|
||||
async openViewWindow() {},
|
||||
async openPluginLayoutWindow(input: unknown) {
|
||||
calls.push(input);
|
||||
return {
|
||||
label: "view-plugin-layout",
|
||||
url: "index.html?pluginLayout=1",
|
||||
alreadyOpen: false,
|
||||
providerPluginDisplayName: "React Plugin",
|
||||
layoutLabel: "Dashboard",
|
||||
surface: {
|
||||
pluginId: "dev.acme.react",
|
||||
layoutType: "dev.acme.react.dashboard",
|
||||
state: { tab: "overview" },
|
||||
},
|
||||
};
|
||||
},
|
||||
async closeViewWindow() {},
|
||||
async listOpenViewWindows() {
|
||||
return [];
|
||||
},
|
||||
async onViewWindowClosed() {
|
||||
return () => {};
|
||||
},
|
||||
},
|
||||
} as PluginGatewaySet;
|
||||
const bundle = dataUrl(`
|
||||
export async function activate(ctx) {
|
||||
globalThis.__uiServicesAvailable = Boolean(ctx.services);
|
||||
globalThis.__pluginWindowResult = await ctx.services.windows.open({
|
||||
layoutType: "dev.acme.react.dashboard",
|
||||
state: { tab: "overview" },
|
||||
});
|
||||
try {
|
||||
await ctx.services.windows.open({ layoutType: "dev.acme.react.missing" });
|
||||
} catch (e) {
|
||||
globalThis.__pluginWindowValidationError = String(e);
|
||||
}
|
||||
}
|
||||
`);
|
||||
|
||||
const { failures } = await loadPlugins(
|
||||
[
|
||||
entry({
|
||||
id: "dev.acme.react",
|
||||
displayName: "React Plugin",
|
||||
capabilities: ["ui"],
|
||||
bundleUrl: bundle,
|
||||
contributes: {
|
||||
...emptyContributes(),
|
||||
layouts: [
|
||||
{
|
||||
type: "dev.acme.react.dashboard",
|
||||
label: "Dashboard",
|
||||
component: "Dashboard",
|
||||
},
|
||||
],
|
||||
},
|
||||
}),
|
||||
],
|
||||
windowGateways,
|
||||
);
|
||||
|
||||
expect(failures).toEqual([]);
|
||||
expect((globalThis as Record<string, unknown>).__uiServicesAvailable).toBe(true);
|
||||
expect((globalThis as Record<string, unknown>).__pluginWindowResult).toMatchObject({
|
||||
label: "view-plugin-layout",
|
||||
surface: { layoutType: "dev.acme.react.dashboard" },
|
||||
});
|
||||
expect((globalThis as Record<string, unknown>).__pluginWindowValidationError).toMatch(
|
||||
/undeclared layout/,
|
||||
);
|
||||
expect(calls).toEqual([
|
||||
{
|
||||
pluginId: "dev.acme.react",
|
||||
layoutType: "dev.acme.react.dashboard",
|
||||
state: { tab: "overview" },
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("injects plugin-owned storage scoped to the activating plugin id", async () => {
|
||||
|
||||
@ -15,6 +15,12 @@
|
||||
* collected, never thrown past `loadPlugins`.
|
||||
*/
|
||||
|
||||
import * as React from "react";
|
||||
import * as ReactDom from "react-dom";
|
||||
import * as ReactDomClient from "react-dom/client";
|
||||
import * as ReactJsxRuntime from "react/jsx-runtime";
|
||||
import * as ReactJsxDevRuntime from "react/jsx-dev-runtime";
|
||||
|
||||
import type { JsonValue, PluginContributionDto, PluginRuntimePlugin } from "@/domain";
|
||||
import {
|
||||
PluginCommandRegistry,
|
||||
@ -93,6 +99,7 @@ export interface PluginLoadOptions {
|
||||
}
|
||||
|
||||
const DEFAULT_PLUGIN_LOAD_TIMEOUT_MS = 10_000;
|
||||
let hostReactImportMapInstalled = false;
|
||||
|
||||
async function withTimeout<T>(
|
||||
promise: Promise<T>,
|
||||
@ -161,6 +168,27 @@ function nonEmptyString(value: unknown): string | undefined {
|
||||
return typeof value === "string" && value.trim().length > 0 ? value : undefined;
|
||||
}
|
||||
|
||||
function installHostReactImportMap(): void {
|
||||
if (hostReactImportMapInstalled) {
|
||||
hostReactImportMapInstalled = true;
|
||||
return;
|
||||
}
|
||||
|
||||
const globalHost = globalThis as unknown as {
|
||||
__IDEA_PLUGIN_HOST_REACT__?: typeof React;
|
||||
__IDEA_PLUGIN_HOST_REACT_DOM__?: typeof ReactDom;
|
||||
__IDEA_PLUGIN_HOST_REACT_DOM_CLIENT__?: typeof ReactDomClient;
|
||||
__IDEA_PLUGIN_HOST_REACT_JSX_RUNTIME__?: typeof ReactJsxRuntime;
|
||||
__IDEA_PLUGIN_HOST_REACT_JSX_DEV_RUNTIME__?: typeof ReactJsxDevRuntime;
|
||||
};
|
||||
globalHost.__IDEA_PLUGIN_HOST_REACT__ = React;
|
||||
globalHost.__IDEA_PLUGIN_HOST_REACT_DOM__ = ReactDom;
|
||||
globalHost.__IDEA_PLUGIN_HOST_REACT_DOM_CLIENT__ = ReactDomClient;
|
||||
globalHost.__IDEA_PLUGIN_HOST_REACT_JSX_RUNTIME__ = ReactJsxRuntime;
|
||||
globalHost.__IDEA_PLUGIN_HOST_REACT_JSX_DEV_RUNTIME__ = ReactJsxDevRuntime;
|
||||
hostReactImportMapInstalled = true;
|
||||
}
|
||||
|
||||
function safePluginId(entry: unknown): string {
|
||||
return nonEmptyString(objectOrEmpty(entry).id) ?? "<unknown-plugin>";
|
||||
}
|
||||
@ -247,6 +275,7 @@ async function loadOne(
|
||||
throw new Error("missing plugin bundle URL");
|
||||
}
|
||||
|
||||
installHostReactImportMap();
|
||||
const mod = resolveIdeaPluginModule(
|
||||
await withTimeout(
|
||||
import(/* @vite-ignore */ bundleUrl),
|
||||
@ -282,8 +311,11 @@ async function loadOne(
|
||||
menu,
|
||||
storage,
|
||||
};
|
||||
if (hasCapability(entry, "tooling")) {
|
||||
ctx.services = createPluginServices(gateways);
|
||||
if (hasCapability(entry, "tooling") || hasCapability(entry, "ui")) {
|
||||
ctx.services = createPluginServices(gateways, {
|
||||
pluginId,
|
||||
declaredLayoutTypes,
|
||||
});
|
||||
}
|
||||
|
||||
activation = await withTimeout(
|
||||
|
||||
@ -32,6 +32,7 @@ import type {
|
||||
ProjectGateway,
|
||||
SystemGateway,
|
||||
TerminalGateway,
|
||||
WindowGateway,
|
||||
WorkStateGateway,
|
||||
} from "@/ports";
|
||||
|
||||
@ -42,6 +43,7 @@ export interface PluginGatewaySet {
|
||||
terminal: TerminalGateway;
|
||||
agents: AgentGateway;
|
||||
system: SystemGateway;
|
||||
window: WindowGateway;
|
||||
workState: WorkStateGateway;
|
||||
focusedProject: FocusedProjectGateway;
|
||||
pluginWorkspace: PluginWorkspaceGateway;
|
||||
|
||||
@ -13,6 +13,7 @@ import type {
|
||||
ReattachResult,
|
||||
TerminalGateway,
|
||||
TerminalHandle,
|
||||
WindowGateway,
|
||||
WorkStateGateway,
|
||||
} from "@/ports";
|
||||
import { createPluginServices } from "./services";
|
||||
@ -32,6 +33,7 @@ function gateways(overrides: {
|
||||
project?: Partial<ProjectGateway>;
|
||||
workState?: Partial<WorkStateGateway>;
|
||||
terminal?: Partial<TerminalGateway>;
|
||||
window?: Partial<WindowGateway>;
|
||||
pluginWorkspace?: Partial<PluginWorkspaceGateway>;
|
||||
pluginTask?: Partial<PluginTaskGateway>;
|
||||
pluginToolchain?: Partial<PluginToolchainGateway>;
|
||||
@ -89,6 +91,25 @@ function gateways(overrides: {
|
||||
closeTerminal: vi.fn(),
|
||||
...overrides.terminal,
|
||||
};
|
||||
const window: WindowGateway = {
|
||||
openViewWindow: vi.fn(),
|
||||
openPluginLayoutWindow: vi.fn(async (input) => ({
|
||||
label: `view-plugin-layout-${input.pluginId}.${input.layoutType}`,
|
||||
url: "index.html?pluginLayout=1",
|
||||
alreadyOpen: false,
|
||||
providerPluginDisplayName: input.pluginId,
|
||||
layoutLabel: input.layoutType,
|
||||
surface: {
|
||||
pluginId: input.pluginId,
|
||||
layoutType: input.layoutType,
|
||||
state: input.state ?? null,
|
||||
},
|
||||
})),
|
||||
closeViewWindow: vi.fn(),
|
||||
listOpenViewWindows: vi.fn(async () => []),
|
||||
onViewWindowClosed: vi.fn(async () => () => {}),
|
||||
...overrides.window,
|
||||
};
|
||||
const pluginWorkspace: PluginWorkspaceGateway = {
|
||||
readText: vi.fn(async ({ path }) => ({ path, content: "file text" })),
|
||||
readBinary: vi.fn(async ({ path }) => ({ path, bytes: new Uint8Array([67]) })),
|
||||
@ -235,6 +256,7 @@ function gateways(overrides: {
|
||||
project,
|
||||
workState,
|
||||
terminal,
|
||||
window,
|
||||
pluginWorkspace,
|
||||
pluginTask,
|
||||
pluginToolchain,
|
||||
@ -249,6 +271,37 @@ async function flushMicrotasks(): Promise<void> {
|
||||
}
|
||||
|
||||
describe("createPluginServices", () => {
|
||||
it("opens only plugin-declared layout windows through the window gateway", async () => {
|
||||
const g = gateways();
|
||||
const services = createPluginServices(g, {
|
||||
pluginId: "dev.acme.plugin",
|
||||
declaredLayoutTypes: new Set(["dev.acme.plugin.dashboard"]),
|
||||
});
|
||||
|
||||
await expect(
|
||||
services.windows.open({
|
||||
layoutType: "dev.acme.plugin.dashboard",
|
||||
state: { tab: "overview" },
|
||||
}),
|
||||
).resolves.toMatchObject({
|
||||
label: "view-plugin-layout-dev.acme.plugin.dev.acme.plugin.dashboard",
|
||||
surface: {
|
||||
pluginId: "dev.acme.plugin",
|
||||
layoutType: "dev.acme.plugin.dashboard",
|
||||
state: { tab: "overview" },
|
||||
},
|
||||
});
|
||||
expect(g.window.openPluginLayoutWindow).toHaveBeenCalledWith({
|
||||
pluginId: "dev.acme.plugin",
|
||||
layoutType: "dev.acme.plugin.dashboard",
|
||||
state: { tab: "overview" },
|
||||
});
|
||||
|
||||
await expect(
|
||||
services.windows.open({ layoutType: "dev.acme.plugin.undeclared" }),
|
||||
).rejects.toThrow(/undeclared layout/);
|
||||
});
|
||||
|
||||
it("exposes focused workspace project helpers without leaking project gateway DTOs", async () => {
|
||||
const g = gateways();
|
||||
const services = createPluginServices(g);
|
||||
|
||||
@ -18,6 +18,7 @@ import type {
|
||||
PluginWorkspaceGateway,
|
||||
ProjectGateway,
|
||||
TerminalGateway,
|
||||
WindowGateway,
|
||||
WorkStateGateway,
|
||||
} from "@/ports";
|
||||
|
||||
@ -28,6 +29,7 @@ export interface PluginServices {
|
||||
events: EventService;
|
||||
config: ConfigDocumentService;
|
||||
terminal: TerminalService;
|
||||
windows: WindowService;
|
||||
}
|
||||
|
||||
export interface WorkspaceProject {
|
||||
@ -370,9 +372,32 @@ export interface TerminalService {
|
||||
close(sessionId: string): Promise<void>;
|
||||
}
|
||||
|
||||
export interface OpenPluginWindowOptions {
|
||||
layoutType: string;
|
||||
state?: JsonValue;
|
||||
}
|
||||
|
||||
export interface PluginWindow {
|
||||
label: string;
|
||||
url: string;
|
||||
alreadyOpen: boolean;
|
||||
providerPluginDisplayName: string;
|
||||
layoutLabel: string;
|
||||
surface: {
|
||||
pluginId: string;
|
||||
layoutType: string;
|
||||
state: JsonValue;
|
||||
};
|
||||
}
|
||||
|
||||
export interface WindowService {
|
||||
open(options: OpenPluginWindowOptions): Promise<PluginWindow>;
|
||||
}
|
||||
|
||||
interface PluginServiceGatewaySet {
|
||||
project: ProjectGateway;
|
||||
terminal: TerminalGateway;
|
||||
window: WindowGateway;
|
||||
workState: WorkStateGateway;
|
||||
focusedProject: FocusedProjectGateway;
|
||||
pluginWorkspace: PluginWorkspaceGateway;
|
||||
@ -382,6 +407,11 @@ interface PluginServiceGatewaySet {
|
||||
pluginConfig: PluginConfigGateway;
|
||||
}
|
||||
|
||||
export interface PluginServiceScope {
|
||||
pluginId?: string;
|
||||
declaredLayoutTypes?: ReadonlySet<string>;
|
||||
}
|
||||
|
||||
const DEFAULT_ROWS = 24;
|
||||
const DEFAULT_COLS = 80;
|
||||
const DEFAULT_EVENT_POLL_INTERVAL_MS = 1000;
|
||||
@ -462,7 +492,10 @@ function workspacePathMatches(watchedPath: string, eventPath: string): boolean {
|
||||
);
|
||||
}
|
||||
|
||||
export function createPluginServices(gateways: PluginServiceGatewaySet): PluginServices {
|
||||
export function createPluginServices(
|
||||
gateways: PluginServiceGatewaySet,
|
||||
scope: PluginServiceScope = {},
|
||||
): PluginServices {
|
||||
async function currentProject(): Promise<WorkspaceProject | null> {
|
||||
const focused = await gateways.focusedProject.getFocusedProject();
|
||||
return focused ? toWorkspaceProject(focused) : null;
|
||||
@ -724,5 +757,28 @@ export function createPluginServices(gateways: PluginServiceGatewaySet): PluginS
|
||||
},
|
||||
};
|
||||
|
||||
return { workspace, tasks, tooling, events, config, terminal };
|
||||
const windows: WindowService = {
|
||||
async open(options) {
|
||||
const layoutType = options.layoutType.trim();
|
||||
if (!layoutType) {
|
||||
throw new Error("layoutType is required");
|
||||
}
|
||||
if (scope.declaredLayoutTypes && !scope.declaredLayoutTypes.has(layoutType)) {
|
||||
throw new Error(
|
||||
`plugin "${scope.pluginId ?? "<unknown>"}" cannot open undeclared layout "${layoutType}"`,
|
||||
);
|
||||
}
|
||||
const pluginId = scope.pluginId;
|
||||
if (!pluginId) {
|
||||
throw new Error("plugin id is required to open a plugin layout window");
|
||||
}
|
||||
return gateways.window.openPluginLayoutWindow({
|
||||
pluginId,
|
||||
layoutType,
|
||||
state: options.state ?? null,
|
||||
});
|
||||
},
|
||||
};
|
||||
|
||||
return { workspace, tasks, tooling, events, config, terminal, windows };
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user