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

@ -17,19 +17,50 @@ import {
MockTicketGateway,
} from "@/adapters/mock";
import type { Gateways } from "@/ports";
import { ViewWindow, parseViewWindowParams } from "./ViewWindow";
import {
PluginCommandRegistry,
PluginLayoutRegistry,
PluginMenuRegistry,
PluginRuntimeRegistry,
type LoadedPlugin,
type PluginLayoutProps,
} from "@/plugins/runtime";
import { PluginLayoutWindow, ViewWindow, parseViewWindowParams } from "./ViewWindow";
function b64url(value: string): string {
return btoa(value).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/g, "");
}
describe("parseViewWindowParams (#47)", () => {
it("parses a valid panel-only query (ignores any legacy project param)", () => {
expect(parseViewWindowParams("?panel=tickets")).toEqual({
kind: "panel",
panel: "tickets",
});
// A restored legacy URL that still carries `project` parses to panel only.
expect(parseViewWindowParams("?panel=tickets&project=p-1")).toEqual({
kind: "panel",
panel: "tickets",
});
});
it("parses a plugin layout window query from the backend base64url contract", () => {
expect(
parseViewWindowParams(
`?pluginLayout=1&pluginId=${b64url("dev.acme.plugin")}&layoutType=${b64url(
"dev.acme.plugin.dashboard",
)}&state=${b64url(JSON.stringify({ tab: "overview" }))}`,
),
).toEqual({
kind: "pluginLayout",
plugin: {
pluginId: "dev.acme.plugin",
layoutType: "dev.acme.plugin.dashboard",
state: { tab: "overview" },
},
});
});
it("rejects a missing panel or the non-detachable projects panel", () => {
expect(parseViewWindowParams("?project=p-1")).toBeNull();
expect(parseViewWindowParams("?panel=projects")).toBeNull();
@ -38,6 +69,84 @@ describe("parseViewWindowParams (#47)", () => {
});
});
function StubPluginLayout(props: PluginLayoutProps) {
return <div data-testid="plugin-window-state">{JSON.stringify(props.state)}</div>;
}
function pluginRegistry(): PluginRuntimeRegistry {
const registry = new PluginRuntimeRegistry();
const layouts = new PluginLayoutRegistry(
"dev.acme.plugin",
new Set(["dev.acme.plugin.dashboard"]),
);
layouts.register({
type: "dev.acme.plugin.dashboard",
component: StubPluginLayout,
});
const loaded: LoadedPlugin = {
pluginId: "dev.acme.plugin",
displayName: "Acme Plugin",
contributes: {
menus: [],
menuItems: [],
layouts: [
{
type: "dev.acme.plugin.dashboard",
label: "Dashboard",
component: "Dashboard",
},
],
mcpServers: [],
},
commands: new PluginCommandRegistry("dev.acme.plugin", new Set()),
layouts,
menu: new PluginMenuRegistry("dev.acme.plugin"),
dispose: async () => {},
};
registry.add(loaded);
return registry;
}
describe("PluginLayoutWindow (#143)", () => {
it("mounts the plugin layout for the focused project with URL state", async () => {
const system = new MockSystemGateway();
const project = new MockProjectGateway();
const created = await project.createProject("gamma", "/p/g");
const focusedProject = new MockFocusedProjectGateway();
await focusedProject.setFocusedProject({
id: created.id,
name: "gamma",
root: "/p/g",
});
const gateways = {
system,
project,
agent: new MockAgentGateway(),
ticket: new MockTicketGateway(system),
focusedProject,
} as unknown as Gateways;
render(
<DIProvider gateways={gateways}>
<PluginLayoutWindow
plugin={{
pluginId: "dev.acme.plugin",
layoutType: "dev.acme.plugin.dashboard",
state: { tab: "overview" },
}}
runtimeValue={{ registry: pluginRegistry(), failures: [], pending: [], loading: false }}
/>
</DIProvider>,
);
await waitFor(() => expect(screen.getByText("Dashboard")).toBeTruthy());
await waitFor(() => expect(screen.getByText("· gamma")).toBeTruthy());
expect(screen.getByTestId("plugin-window-state").textContent).toBe(
JSON.stringify({ tab: "overview" }),
);
});
});
describe("ViewWindow (#47)", () => {
it("shows the 'open a project' shell when no project is focused", async () => {
const system = new MockSystemGateway();

View File

@ -19,12 +19,19 @@
* and crucially no `openProject()` — opening a project is the main window's job.
*/
import { useEffect, useState } from "react";
import { useEffect, useMemo, useState } from "react";
import type { CustomPluginLayoutCell } from "@/domain";
import type { FocusedProject } from "@/ports";
import { Panel } from "@/shared";
import { PANEL_TITLE, type PanelId } from "@/features/projects";
import { ViewPanelBody, type ViewPanelId } from "@/features/projects";
import {
PluginLayoutCellView,
PluginRuntimeProvider,
usePluginRuntime,
type PluginRuntimeContextValue,
} from "@/features/plugins";
import { useGateways } from "./di";
/** Panels that may be shown in a detached window (everything but "projects"). */
@ -37,14 +44,63 @@ export interface ViewWindowParams {
panel: ViewPanelId;
}
export interface PluginLayoutWindowParams {
pluginId: string;
layoutType: string;
state: unknown;
}
export type DetachedWindowParams =
| { kind: "panel"; panel: ViewPanelId }
| { kind: "pluginLayout"; plugin: PluginLayoutWindowParams };
function decodeBase64UrlPart(value: string): string | null {
try {
const padded = value.replace(/-/g, "+").replace(/_/g, "/").padEnd(
Math.ceil(value.length / 4) * 4,
"=",
);
const binary = atob(padded);
const bytes = Uint8Array.from(binary, (char) => char.charCodeAt(0));
return new TextDecoder().decode(bytes);
} catch {
return null;
}
}
function decodeBase64UrlJson(value: string | null): unknown {
if (!value) return null;
const decoded = decodeBase64UrlPart(value);
if (!decoded) return null;
try {
return JSON.parse(decoded);
} catch {
return null;
}
}
/** Reads and validates the panel-only param from a query string (#47: no project). */
export function parseViewWindowParams(
search: string,
): ViewWindowParams | null {
): DetachedWindowParams | null {
const params = new URLSearchParams(search);
if (params.get("pluginLayout") === "1") {
const pluginId = decodeBase64UrlPart(params.get("pluginId") ?? "");
const layoutType = decodeBase64UrlPart(params.get("layoutType") ?? "");
if (!pluginId || !layoutType) return null;
return {
kind: "pluginLayout",
plugin: {
pluginId,
layoutType,
state: decodeBase64UrlJson(params.get("state")),
},
};
}
const panel = params.get("panel");
if (!panel || !DETACHABLE.has(panel)) return null;
return { panel: panel as ViewPanelId };
return { kind: "panel", panel: panel as ViewPanelId };
}
export interface ViewWindowProps {
@ -117,3 +173,94 @@ export function ViewWindow({ panel }: ViewWindowProps) {
</div>
);
}
export interface PluginLayoutWindowProps {
plugin: PluginLayoutWindowParams;
runtimeValue?: PluginRuntimeContextValue;
}
export function PluginLayoutWindow({ plugin, runtimeValue }: PluginLayoutWindowProps) {
return (
<PluginRuntimeProvider value={runtimeValue}>
<PluginLayoutWindowBody plugin={plugin} />
</PluginRuntimeProvider>
);
}
function PluginLayoutWindowBody({ plugin }: PluginLayoutWindowProps) {
const { focusedProject } = useGateways();
const { registry, loading } = usePluginRuntime();
const [focus, setFocus] = useState<FocusedProject | null | undefined>(undefined);
const [state, setState] = useState<unknown>(plugin.state);
const title =
registry
.get(plugin.pluginId)
?.contributes.layouts.find((layout) => layout.type === plugin.layoutType)?.label ??
plugin.layoutType;
const cell = useMemo<CustomPluginLayoutCell>(
() => ({
id: `plugin-window:${plugin.pluginId}:${plugin.layoutType}`,
pluginId: plugin.pluginId,
layoutType: plugin.layoutType,
state,
}),
[plugin.layoutType, plugin.pluginId, state],
);
useEffect(() => {
let cancelled = false;
let unsubscribe: (() => void) | undefined;
focusedProject
.getFocusedProject()
.then((project) => {
if (!cancelled) setFocus(project);
})
.catch(() => {
if (!cancelled) setFocus(null);
});
void focusedProject
.onFocusedProjectChanged((project) => {
if (!cancelled) setFocus(project);
})
.then((un) => {
if (cancelled) un();
else unsubscribe = un;
});
return () => {
cancelled = true;
unsubscribe?.();
};
}, [focusedProject]);
return (
<div className="flex h-full flex-col bg-canvas text-content">
<header className="flex shrink-0 items-center gap-2 border-b border-border px-4 py-2.5">
<h1 className="text-sm font-semibold text-content">{title}</h1>
<span className="truncate text-xs text-muted">
· {registry.get(plugin.pluginId)?.displayName ?? plugin.pluginId}
</span>
{focus && <span className="truncate text-xs text-muted">· {focus.name}</span>}
</header>
<main className="min-h-0 flex-1 overflow-auto p-4">
{focus ? (
<PluginLayoutCellView
projectId={focus.id}
cell={cell}
onStateChange={setState}
onOpenPlugins={() => {}}
onChooseAnotherLayout={() => {}}
/>
) : (
<Panel>
<p className="text-sm text-muted">
Ouvrez un projet dans la fenêtre principale pour afficher ce layout.
</p>
</Panel>
)}
{loading && (
<p className="mt-3 text-xs text-muted">Chargement des plugins...</p>
)}
</main>
</div>
);
}

View File

@ -4,7 +4,7 @@ import React from "react";
import ReactDOM from "react-dom/client";
import { App } from "./App";
import { ViewWindow, parseViewWindowParams } from "./ViewWindow";
import { PluginLayoutWindow, ViewWindow, parseViewWindowParams } from "./ViewWindow";
import { WebApp } from "@/features/web";
import { DIProvider, resolveTransport } from "./di";
import { RootErrorBoundary, installGlobalErrorLogging } from "./RootErrorBoundary";
@ -41,8 +41,10 @@ ReactDOM.createRoot(root).render(
<DIProvider>
{isWeb ? (
<WebApp />
) : viewParams ? (
) : viewParams?.kind === "panel" ? (
<ViewWindow panel={viewParams.panel} />
) : viewParams?.kind === "pluginLayout" ? (
<PluginLayoutWindow plugin={viewParams.plugin} />
) : (
<App />
)}