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:
2026-08-04 00:39:50 +02:00
parent 551eb09ad2
commit d8df466fba
20 changed files with 678 additions and 10 deletions

View File

@ -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 () => {