diff --git a/frontend/index.html b/frontend/index.html
index 61688b2..a6c9af1 100644
--- a/frontend/index.html
+++ b/frontend/index.html
@@ -11,6 +11,17 @@
content="width=device-width, initial-scale=1.0, viewport-fit=cover"
/>
IdeA
+
diff --git a/frontend/public/plugin-host/react-dom-client.js b/frontend/public/plugin-host/react-dom-client.js
new file mode 100644
index 0000000..bb3b06a
--- /dev/null
+++ b/frontend/public/plugin-host/react-dom-client.js
@@ -0,0 +1,5 @@
+const ReactDomClient = globalThis.__IDEA_PLUGIN_HOST_REACT_DOM_CLIENT__;
+
+export const createRoot = ReactDomClient.createRoot;
+export const hydrateRoot = ReactDomClient.hydrateRoot;
+export const version = ReactDomClient.version;
diff --git a/frontend/public/plugin-host/react-dom.js b/frontend/public/plugin-host/react-dom.js
new file mode 100644
index 0000000..137e55f
--- /dev/null
+++ b/frontend/public/plugin-host/react-dom.js
@@ -0,0 +1,16 @@
+const ReactDom = globalThis.__IDEA_PLUGIN_HOST_REACT_DOM__;
+
+export default ReactDom;
+export const createPortal = ReactDom.createPortal;
+export const flushSync = ReactDom.flushSync;
+export const preconnect = ReactDom.preconnect;
+export const prefetchDNS = ReactDom.prefetchDNS;
+export const preinit = ReactDom.preinit;
+export const preinitModule = ReactDom.preinitModule;
+export const preload = ReactDom.preload;
+export const preloadModule = ReactDom.preloadModule;
+export const requestFormReset = ReactDom.requestFormReset;
+export const unstable_batchedUpdates = ReactDom.unstable_batchedUpdates;
+export const useFormState = ReactDom.useFormState;
+export const useFormStatus = ReactDom.useFormStatus;
+export const version = ReactDom.version;
diff --git a/frontend/public/plugin-host/react-jsx-dev-runtime.js b/frontend/public/plugin-host/react-jsx-dev-runtime.js
new file mode 100644
index 0000000..9432770
--- /dev/null
+++ b/frontend/public/plugin-host/react-jsx-dev-runtime.js
@@ -0,0 +1,4 @@
+const Runtime = globalThis.__IDEA_PLUGIN_HOST_REACT_JSX_DEV_RUNTIME__;
+
+export const Fragment = Runtime.Fragment;
+export const jsxDEV = Runtime.jsxDEV;
diff --git a/frontend/public/plugin-host/react-jsx-runtime.js b/frontend/public/plugin-host/react-jsx-runtime.js
new file mode 100644
index 0000000..4ee696b
--- /dev/null
+++ b/frontend/public/plugin-host/react-jsx-runtime.js
@@ -0,0 +1,5 @@
+const Runtime = globalThis.__IDEA_PLUGIN_HOST_REACT_JSX_RUNTIME__;
+
+export const Fragment = Runtime.Fragment;
+export const jsx = Runtime.jsx;
+export const jsxs = Runtime.jsxs;
diff --git a/frontend/public/plugin-host/react.js b/frontend/public/plugin-host/react.js
new file mode 100644
index 0000000..ca49583
--- /dev/null
+++ b/frontend/public/plugin-host/react.js
@@ -0,0 +1,35 @@
+const React = globalThis.__IDEA_PLUGIN_HOST_REACT__;
+
+export default React;
+export const Children = React.Children;
+export const Component = React.Component;
+export const Fragment = React.Fragment;
+export const Profiler = React.Profiler;
+export const PureComponent = React.PureComponent;
+export const StrictMode = React.StrictMode;
+export const Suspense = React.Suspense;
+export const cloneElement = React.cloneElement;
+export const createContext = React.createContext;
+export const createElement = React.createElement;
+export const createRef = React.createRef;
+export const forwardRef = React.forwardRef;
+export const isValidElement = React.isValidElement;
+export const lazy = React.lazy;
+export const memo = React.memo;
+export const startTransition = React.startTransition;
+export const useCallback = React.useCallback;
+export const useContext = React.useContext;
+export const useDebugValue = React.useDebugValue;
+export const useDeferredValue = React.useDeferredValue;
+export const useEffect = React.useEffect;
+export const useId = React.useId;
+export const useImperativeHandle = React.useImperativeHandle;
+export const useInsertionEffect = React.useInsertionEffect;
+export const useLayoutEffect = React.useLayoutEffect;
+export const useMemo = React.useMemo;
+export const useReducer = React.useReducer;
+export const useRef = React.useRef;
+export const useState = React.useState;
+export const useSyncExternalStore = React.useSyncExternalStore;
+export const useTransition = React.useTransition;
+export const version = React.version;
diff --git a/frontend/src/adapters/http/unsupported.ts b/frontend/src/adapters/http/unsupported.ts
index 096944f..7142722 100644
--- a/frontend/src/adapters/http/unsupported.ts
+++ b/frontend/src/adapters/http/unsupported.ts
@@ -79,6 +79,9 @@ export class WebWindowGateway implements WindowGateway {
openViewWindow(): Promise {
return unsupportedOnWeb("Detaching a panel into an OS window");
}
+ openPluginLayoutWindow(): Promise {
+ return unsupportedOnWeb("Opening a plugin layout in an OS window");
+ }
closeViewWindow(): Promise {
return unsupportedOnWeb("Closing a detached OS window");
}
diff --git a/frontend/src/adapters/mock/index.ts b/frontend/src/adapters/mock/index.ts
index 9e9fe0a..f3e7058 100644
--- a/frontend/src/adapters/mock/index.ts
+++ b/frontend/src/adapters/mock/index.ts
@@ -162,6 +162,8 @@ import type {
ViewWindowClosed,
ViewWindowSnapshot,
WindowGateway,
+ PluginLayoutWindowOpenInput,
+ PluginLayoutWindowOpenResult,
FocusedProject,
FocusedProjectGateway,
WorkStateGateway,
@@ -1284,12 +1286,34 @@ class MockRemoteGateway implements RemoteGateway {
export class MockWindowGateway implements WindowGateway {
/** Currently-open detached windows, keyed by `panel`. */
readonly open = new Set();
+ readonly pluginLayoutWindows = new Map();
private readonly listeners = new Set<(e: ViewWindowClosed) => void>();
async openViewWindow(panel: string): Promise {
this.open.add(panel);
}
+ async openPluginLayoutWindow(
+ input: PluginLayoutWindowOpenInput,
+ ): Promise {
+ const label = `view-plugin-layout-${input.pluginId}.${input.layoutType}`;
+ const alreadyOpen = this.pluginLayoutWindows.has(label);
+ const result: PluginLayoutWindowOpenResult = {
+ label,
+ url: `index.html?pluginLayout=1&pluginId=${encodeURIComponent(input.pluginId)}&layoutType=${encodeURIComponent(input.layoutType)}`,
+ alreadyOpen,
+ providerPluginDisplayName: input.pluginId,
+ layoutLabel: input.layoutType,
+ surface: {
+ pluginId: input.pluginId,
+ layoutType: input.layoutType,
+ state: input.state ?? null,
+ },
+ };
+ this.pluginLayoutWindows.set(label, result);
+ return result;
+ }
+
async closeViewWindow(panel: string): Promise {
if (this.open.delete(panel)) {
this.emit({ panel });
diff --git a/frontend/src/adapters/window.test.ts b/frontend/src/adapters/window.test.ts
index 46d9a76..61f65a7 100644
--- a/frontend/src/adapters/window.test.ts
+++ b/frontend/src/adapters/window.test.ts
@@ -29,6 +29,41 @@ describe("TauriWindowGateway (#23)", () => {
});
});
+ it("opens plugin layout windows through the backend command", async () => {
+ invoke.mockResolvedValueOnce({
+ label: "view-plugin-layout",
+ url: "index.html?pluginLayout=1",
+ alreadyOpen: false,
+ providerPluginDisplayName: "Plugin",
+ layoutLabel: "Dashboard",
+ surface: {
+ pluginId: "dev.acme.plugin",
+ layoutType: "dev.acme.plugin.dashboard",
+ state: { tab: "overview" },
+ },
+ });
+ const gw = new TauriWindowGateway();
+
+ await expect(
+ gw.openPluginLayoutWindow({
+ pluginId: "dev.acme.plugin",
+ layoutType: "dev.acme.plugin.dashboard",
+ state: { tab: "overview" },
+ }),
+ ).resolves.toMatchObject({
+ label: "view-plugin-layout",
+ surface: { layoutType: "dev.acme.plugin.dashboard" },
+ });
+
+ expect(invoke).toHaveBeenCalledWith("open_plugin_layout_window", {
+ input: {
+ pluginId: "dev.acme.plugin",
+ layoutType: "dev.acme.plugin.dashboard",
+ state: { tab: "overview" },
+ },
+ });
+ });
+
it("onViewWindowClosed listens on the lifecycle channel and forwards only closes", async () => {
// Capture the raw Tauri listener so we can drive lifecycle events at will.
let raw: ((e: { payload: unknown }) => void) | undefined;
diff --git a/frontend/src/adapters/window.ts b/frontend/src/adapters/window.ts
index fcada39..739d77e 100644
--- a/frontend/src/adapters/window.ts
+++ b/frontend/src/adapters/window.ts
@@ -15,6 +15,8 @@ import { listen } from "@tauri-apps/api/event";
import type { Unsubscribe } from "@/domain";
import type {
+ PluginLayoutWindowOpenInput,
+ PluginLayoutWindowOpenResult,
ViewWindowClosed,
ViewWindowSnapshot,
WindowGateway,
@@ -41,6 +43,14 @@ export class TauriWindowGateway implements WindowGateway {
await invoke("open_view_window", { panel });
}
+ async openPluginLayoutWindow(
+ input: PluginLayoutWindowOpenInput,
+ ): Promise {
+ return invoke("open_plugin_layout_window", {
+ input,
+ });
+ }
+
async closeViewWindow(panel: string): Promise {
await invoke("close_view_window", { panel });
}
diff --git a/frontend/src/app/ViewWindow.test.tsx b/frontend/src/app/ViewWindow.test.tsx
index 41ab38e..95996a0 100644
--- a/frontend/src/app/ViewWindow.test.tsx
+++ b/frontend/src/app/ViewWindow.test.tsx
@@ -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 {JSON.stringify(props.state)}
;
+}
+
+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(
+
+
+ ,
+ );
+
+ 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();
diff --git a/frontend/src/app/ViewWindow.tsx b/frontend/src/app/ViewWindow.tsx
index 4a96bee..e5b732d 100644
--- a/frontend/src/app/ViewWindow.tsx
+++ b/frontend/src/app/ViewWindow.tsx
@@ -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) {
);
}
+
+export interface PluginLayoutWindowProps {
+ plugin: PluginLayoutWindowParams;
+ runtimeValue?: PluginRuntimeContextValue;
+}
+
+export function PluginLayoutWindow({ plugin, runtimeValue }: PluginLayoutWindowProps) {
+ return (
+
+
+
+ );
+}
+
+function PluginLayoutWindowBody({ plugin }: PluginLayoutWindowProps) {
+ const { focusedProject } = useGateways();
+ const { registry, loading } = usePluginRuntime();
+ const [focus, setFocus] = useState(undefined);
+ const [state, setState] = useState(plugin.state);
+ const title =
+ registry
+ .get(plugin.pluginId)
+ ?.contributes.layouts.find((layout) => layout.type === plugin.layoutType)?.label ??
+ plugin.layoutType;
+ const cell = useMemo(
+ () => ({
+ 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 (
+
+
+ {title}
+
+ · {registry.get(plugin.pluginId)?.displayName ?? plugin.pluginId}
+
+ {focus && · {focus.name}}
+
+
+ {focus ? (
+ {}}
+ onChooseAnotherLayout={() => {}}
+ />
+ ) : (
+
+
+ Ouvrez un projet dans la fenêtre principale pour afficher ce layout.
+
+
+ )}
+ {loading && (
+ Chargement des plugins...
+ )}
+
+
+ );
+}
diff --git a/frontend/src/app/main.tsx b/frontend/src/app/main.tsx
index ef62dd4..6b444dc 100644
--- a/frontend/src/app/main.tsx
+++ b/frontend/src/app/main.tsx
@@ -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(
{isWeb ? (
- ) : viewParams ? (
+ ) : viewParams?.kind === "panel" ? (
+ ) : viewParams?.kind === "pluginLayout" ? (
+
) : (
)}
diff --git a/frontend/src/features/plugins/PluginRuntimeProvider.tsx b/frontend/src/features/plugins/PluginRuntimeProvider.tsx
index 1b23b6f..be35fc9 100644
--- a/frontend/src/features/plugins/PluginRuntimeProvider.tsx
+++ b/frontend/src/features/plugins/PluginRuntimeProvider.tsx
@@ -79,6 +79,7 @@ export function PluginRuntimeProvider({ children, value: injected }: PluginRunti
terminal: gateways.terminal,
agents: gateways.agent,
system: gateways.system,
+ window: gateways.window,
workState: gateways.workState,
focusedProject: gateways.focusedProject,
pluginWorkspace: gateways.pluginWorkspace,
diff --git a/frontend/src/plugins/runtime/loader.test.ts b/frontend/src/plugins/runtime/loader.test.ts
index 5a98158..ea956a1 100644
--- a/frontend/src/plugins/runtime/loader.test.ts
+++ b/frontend/src/plugins/runtime/loader.test.ts
@@ -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).__workspaceServiceKeys).toEqual([
@@ -294,6 +296,94 @@ describe("loadPlugins", () => {
"open",
"reattach",
]);
+ expect((globalThis as Record).__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).__uiServicesAvailable).toBe(true);
+ expect((globalThis as Record).__pluginWindowResult).toMatchObject({
+ label: "view-plugin-layout",
+ surface: { layoutType: "dev.acme.react.dashboard" },
+ });
+ expect((globalThis as Record).__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 () => {
diff --git a/frontend/src/plugins/runtime/loader.ts b/frontend/src/plugins/runtime/loader.ts
index 7bd3a38..a8ba952 100644
--- a/frontend/src/plugins/runtime/loader.ts
+++ b/frontend/src/plugins/runtime/loader.ts
@@ -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(
promise: Promise,
@@ -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) ?? "";
}
@@ -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(
diff --git a/frontend/src/plugins/runtime/registry.ts b/frontend/src/plugins/runtime/registry.ts
index 4a27566..fe996ac 100644
--- a/frontend/src/plugins/runtime/registry.ts
+++ b/frontend/src/plugins/runtime/registry.ts
@@ -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;
diff --git a/frontend/src/plugins/runtime/services.test.ts b/frontend/src/plugins/runtime/services.test.ts
index 690b1df..63a4698 100644
--- a/frontend/src/plugins/runtime/services.test.ts
+++ b/frontend/src/plugins/runtime/services.test.ts
@@ -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;
workState?: Partial;
terminal?: Partial;
+ window?: Partial;
pluginWorkspace?: Partial;
pluginTask?: Partial;
pluginToolchain?: Partial;
@@ -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 {
}
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);
diff --git a/frontend/src/plugins/runtime/services.ts b/frontend/src/plugins/runtime/services.ts
index f5eb837..a0dea16 100644
--- a/frontend/src/plugins/runtime/services.ts
+++ b/frontend/src/plugins/runtime/services.ts
@@ -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;
}
+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;
+}
+
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;
+}
+
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 {
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 ?? ""}" 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 };
}
diff --git a/frontend/src/ports/index.ts b/frontend/src/ports/index.ts
index 000cfc1..28c8e31 100644
--- a/frontend/src/ports/index.ts
+++ b/frontend/src/ports/index.ts
@@ -1271,6 +1271,25 @@ export interface ViewWindowSnapshot {
visible: boolean;
}
+export interface PluginLayoutWindowOpenInput {
+ pluginId: string;
+ layoutType: string;
+ state?: JsonValue;
+}
+
+export interface PluginLayoutWindowOpenResult {
+ label: string;
+ url: string;
+ alreadyOpen: boolean;
+ providerPluginDisplayName: string;
+ layoutLabel: string;
+ surface: {
+ pluginId: string;
+ layoutType: string;
+ state: JsonValue;
+ };
+}
+
/**
* Detaching a View into its own OS window (ticket #23, reworked in #47).
*
@@ -1290,6 +1309,15 @@ export interface WindowGateway {
* main window's focused project; no project id is passed.
*/
openViewWindow(panel: string): Promise;
+ /**
+ * Opens — or focuses, if already open — a separate OS window rendering one
+ * plugin layout contribution declared in the provider plugin's
+ * `contributes.layouts`. The backend validates the provider is runtime-active
+ * and the layout type is declared before creating the window.
+ */
+ openPluginLayoutWindow(
+ input: PluginLayoutWindowOpenInput,
+ ): Promise;
/** Closes the detached window for `panel` if one is open. */
closeViewWindow(panel: string): Promise;
/**