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; /**