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:
@ -11,6 +11,17 @@
|
|||||||
content="width=device-width, initial-scale=1.0, viewport-fit=cover"
|
content="width=device-width, initial-scale=1.0, viewport-fit=cover"
|
||||||
/>
|
/>
|
||||||
<title>IdeA</title>
|
<title>IdeA</title>
|
||||||
|
<script type="importmap">
|
||||||
|
{
|
||||||
|
"imports": {
|
||||||
|
"react": "./plugin-host/react.js",
|
||||||
|
"react-dom": "./plugin-host/react-dom.js",
|
||||||
|
"react-dom/client": "./plugin-host/react-dom-client.js",
|
||||||
|
"react/jsx-runtime": "./plugin-host/react-jsx-runtime.js",
|
||||||
|
"react/jsx-dev-runtime": "./plugin-host/react-jsx-dev-runtime.js"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="root"></div>
|
<div id="root"></div>
|
||||||
|
|||||||
5
frontend/public/plugin-host/react-dom-client.js
vendored
Normal file
5
frontend/public/plugin-host/react-dom-client.js
vendored
Normal file
@ -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;
|
||||||
16
frontend/public/plugin-host/react-dom.js
vendored
Normal file
16
frontend/public/plugin-host/react-dom.js
vendored
Normal file
@ -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;
|
||||||
4
frontend/public/plugin-host/react-jsx-dev-runtime.js
vendored
Normal file
4
frontend/public/plugin-host/react-jsx-dev-runtime.js
vendored
Normal file
@ -0,0 +1,4 @@
|
|||||||
|
const Runtime = globalThis.__IDEA_PLUGIN_HOST_REACT_JSX_DEV_RUNTIME__;
|
||||||
|
|
||||||
|
export const Fragment = Runtime.Fragment;
|
||||||
|
export const jsxDEV = Runtime.jsxDEV;
|
||||||
5
frontend/public/plugin-host/react-jsx-runtime.js
vendored
Normal file
5
frontend/public/plugin-host/react-jsx-runtime.js
vendored
Normal file
@ -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;
|
||||||
35
frontend/public/plugin-host/react.js
vendored
Normal file
35
frontend/public/plugin-host/react.js
vendored
Normal file
@ -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;
|
||||||
@ -79,6 +79,9 @@ export class WebWindowGateway implements WindowGateway {
|
|||||||
openViewWindow(): Promise<void> {
|
openViewWindow(): Promise<void> {
|
||||||
return unsupportedOnWeb("Detaching a panel into an OS window");
|
return unsupportedOnWeb("Detaching a panel into an OS window");
|
||||||
}
|
}
|
||||||
|
openPluginLayoutWindow(): Promise<never> {
|
||||||
|
return unsupportedOnWeb("Opening a plugin layout in an OS window");
|
||||||
|
}
|
||||||
closeViewWindow(): Promise<void> {
|
closeViewWindow(): Promise<void> {
|
||||||
return unsupportedOnWeb("Closing a detached OS window");
|
return unsupportedOnWeb("Closing a detached OS window");
|
||||||
}
|
}
|
||||||
|
|||||||
@ -162,6 +162,8 @@ import type {
|
|||||||
ViewWindowClosed,
|
ViewWindowClosed,
|
||||||
ViewWindowSnapshot,
|
ViewWindowSnapshot,
|
||||||
WindowGateway,
|
WindowGateway,
|
||||||
|
PluginLayoutWindowOpenInput,
|
||||||
|
PluginLayoutWindowOpenResult,
|
||||||
FocusedProject,
|
FocusedProject,
|
||||||
FocusedProjectGateway,
|
FocusedProjectGateway,
|
||||||
WorkStateGateway,
|
WorkStateGateway,
|
||||||
@ -1284,12 +1286,34 @@ class MockRemoteGateway implements RemoteGateway {
|
|||||||
export class MockWindowGateway implements WindowGateway {
|
export class MockWindowGateway implements WindowGateway {
|
||||||
/** Currently-open detached windows, keyed by `panel`. */
|
/** Currently-open detached windows, keyed by `panel`. */
|
||||||
readonly open = new Set<string>();
|
readonly open = new Set<string>();
|
||||||
|
readonly pluginLayoutWindows = new Map<string, PluginLayoutWindowOpenResult>();
|
||||||
private readonly listeners = new Set<(e: ViewWindowClosed) => void>();
|
private readonly listeners = new Set<(e: ViewWindowClosed) => void>();
|
||||||
|
|
||||||
async openViewWindow(panel: string): Promise<void> {
|
async openViewWindow(panel: string): Promise<void> {
|
||||||
this.open.add(panel);
|
this.open.add(panel);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async openPluginLayoutWindow(
|
||||||
|
input: PluginLayoutWindowOpenInput,
|
||||||
|
): Promise<PluginLayoutWindowOpenResult> {
|
||||||
|
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<void> {
|
async closeViewWindow(panel: string): Promise<void> {
|
||||||
if (this.open.delete(panel)) {
|
if (this.open.delete(panel)) {
|
||||||
this.emit({ panel });
|
this.emit({ panel });
|
||||||
|
|||||||
@ -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 () => {
|
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.
|
// Capture the raw Tauri listener so we can drive lifecycle events at will.
|
||||||
let raw: ((e: { payload: unknown }) => void) | undefined;
|
let raw: ((e: { payload: unknown }) => void) | undefined;
|
||||||
|
|||||||
@ -15,6 +15,8 @@ import { listen } from "@tauri-apps/api/event";
|
|||||||
|
|
||||||
import type { Unsubscribe } from "@/domain";
|
import type { Unsubscribe } from "@/domain";
|
||||||
import type {
|
import type {
|
||||||
|
PluginLayoutWindowOpenInput,
|
||||||
|
PluginLayoutWindowOpenResult,
|
||||||
ViewWindowClosed,
|
ViewWindowClosed,
|
||||||
ViewWindowSnapshot,
|
ViewWindowSnapshot,
|
||||||
WindowGateway,
|
WindowGateway,
|
||||||
@ -41,6 +43,14 @@ export class TauriWindowGateway implements WindowGateway {
|
|||||||
await invoke("open_view_window", { panel });
|
await invoke("open_view_window", { panel });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async openPluginLayoutWindow(
|
||||||
|
input: PluginLayoutWindowOpenInput,
|
||||||
|
): Promise<PluginLayoutWindowOpenResult> {
|
||||||
|
return invoke<PluginLayoutWindowOpenResult>("open_plugin_layout_window", {
|
||||||
|
input,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
async closeViewWindow(panel: string): Promise<void> {
|
async closeViewWindow(panel: string): Promise<void> {
|
||||||
await invoke("close_view_window", { panel });
|
await invoke("close_view_window", { panel });
|
||||||
}
|
}
|
||||||
|
|||||||
@ -17,19 +17,50 @@ import {
|
|||||||
MockTicketGateway,
|
MockTicketGateway,
|
||||||
} from "@/adapters/mock";
|
} from "@/adapters/mock";
|
||||||
import type { Gateways } from "@/ports";
|
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)", () => {
|
describe("parseViewWindowParams (#47)", () => {
|
||||||
it("parses a valid panel-only query (ignores any legacy project param)", () => {
|
it("parses a valid panel-only query (ignores any legacy project param)", () => {
|
||||||
expect(parseViewWindowParams("?panel=tickets")).toEqual({
|
expect(parseViewWindowParams("?panel=tickets")).toEqual({
|
||||||
|
kind: "panel",
|
||||||
panel: "tickets",
|
panel: "tickets",
|
||||||
});
|
});
|
||||||
// A restored legacy URL that still carries `project` parses to panel only.
|
// A restored legacy URL that still carries `project` parses to panel only.
|
||||||
expect(parseViewWindowParams("?panel=tickets&project=p-1")).toEqual({
|
expect(parseViewWindowParams("?panel=tickets&project=p-1")).toEqual({
|
||||||
|
kind: "panel",
|
||||||
panel: "tickets",
|
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", () => {
|
it("rejects a missing panel or the non-detachable projects panel", () => {
|
||||||
expect(parseViewWindowParams("?project=p-1")).toBeNull();
|
expect(parseViewWindowParams("?project=p-1")).toBeNull();
|
||||||
expect(parseViewWindowParams("?panel=projects")).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)", () => {
|
describe("ViewWindow (#47)", () => {
|
||||||
it("shows the 'open a project' shell when no project is focused", async () => {
|
it("shows the 'open a project' shell when no project is focused", async () => {
|
||||||
const system = new MockSystemGateway();
|
const system = new MockSystemGateway();
|
||||||
|
|||||||
@ -19,12 +19,19 @@
|
|||||||
* and crucially no `openProject()` — opening a project is the main window's job.
|
* 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 type { FocusedProject } from "@/ports";
|
||||||
import { Panel } from "@/shared";
|
import { Panel } from "@/shared";
|
||||||
import { PANEL_TITLE, type PanelId } from "@/features/projects";
|
import { PANEL_TITLE, type PanelId } from "@/features/projects";
|
||||||
import { ViewPanelBody, type ViewPanelId } from "@/features/projects";
|
import { ViewPanelBody, type ViewPanelId } from "@/features/projects";
|
||||||
|
import {
|
||||||
|
PluginLayoutCellView,
|
||||||
|
PluginRuntimeProvider,
|
||||||
|
usePluginRuntime,
|
||||||
|
type PluginRuntimeContextValue,
|
||||||
|
} from "@/features/plugins";
|
||||||
import { useGateways } from "./di";
|
import { useGateways } from "./di";
|
||||||
|
|
||||||
/** Panels that may be shown in a detached window (everything but "projects"). */
|
/** Panels that may be shown in a detached window (everything but "projects"). */
|
||||||
@ -37,14 +44,63 @@ export interface ViewWindowParams {
|
|||||||
panel: ViewPanelId;
|
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). */
|
/** Reads and validates the panel-only param from a query string (#47: no project). */
|
||||||
export function parseViewWindowParams(
|
export function parseViewWindowParams(
|
||||||
search: string,
|
search: string,
|
||||||
): ViewWindowParams | null {
|
): DetachedWindowParams | null {
|
||||||
const params = new URLSearchParams(search);
|
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");
|
const panel = params.get("panel");
|
||||||
if (!panel || !DETACHABLE.has(panel)) return null;
|
if (!panel || !DETACHABLE.has(panel)) return null;
|
||||||
return { panel: panel as ViewPanelId };
|
return { kind: "panel", panel: panel as ViewPanelId };
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ViewWindowProps {
|
export interface ViewWindowProps {
|
||||||
@ -117,3 +173,94 @@ export function ViewWindow({ panel }: ViewWindowProps) {
|
|||||||
</div>
|
</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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
@ -4,7 +4,7 @@ import React from "react";
|
|||||||
import ReactDOM from "react-dom/client";
|
import ReactDOM from "react-dom/client";
|
||||||
|
|
||||||
import { App } from "./App";
|
import { App } from "./App";
|
||||||
import { ViewWindow, parseViewWindowParams } from "./ViewWindow";
|
import { PluginLayoutWindow, ViewWindow, parseViewWindowParams } from "./ViewWindow";
|
||||||
import { WebApp } from "@/features/web";
|
import { WebApp } from "@/features/web";
|
||||||
import { DIProvider, resolveTransport } from "./di";
|
import { DIProvider, resolveTransport } from "./di";
|
||||||
import { RootErrorBoundary, installGlobalErrorLogging } from "./RootErrorBoundary";
|
import { RootErrorBoundary, installGlobalErrorLogging } from "./RootErrorBoundary";
|
||||||
@ -41,8 +41,10 @@ ReactDOM.createRoot(root).render(
|
|||||||
<DIProvider>
|
<DIProvider>
|
||||||
{isWeb ? (
|
{isWeb ? (
|
||||||
<WebApp />
|
<WebApp />
|
||||||
) : viewParams ? (
|
) : viewParams?.kind === "panel" ? (
|
||||||
<ViewWindow panel={viewParams.panel} />
|
<ViewWindow panel={viewParams.panel} />
|
||||||
|
) : viewParams?.kind === "pluginLayout" ? (
|
||||||
|
<PluginLayoutWindow plugin={viewParams.plugin} />
|
||||||
) : (
|
) : (
|
||||||
<App />
|
<App />
|
||||||
)}
|
)}
|
||||||
|
|||||||
@ -79,6 +79,7 @@ export function PluginRuntimeProvider({ children, value: injected }: PluginRunti
|
|||||||
terminal: gateways.terminal,
|
terminal: gateways.terminal,
|
||||||
agents: gateways.agent,
|
agents: gateways.agent,
|
||||||
system: gateways.system,
|
system: gateways.system,
|
||||||
|
window: gateways.window,
|
||||||
workState: gateways.workState,
|
workState: gateways.workState,
|
||||||
focusedProject: gateways.focusedProject,
|
focusedProject: gateways.focusedProject,
|
||||||
pluginWorkspace: gateways.pluginWorkspace,
|
pluginWorkspace: gateways.pluginWorkspace,
|
||||||
|
|||||||
@ -219,6 +219,7 @@ describe("loadPlugins", () => {
|
|||||||
globalThis.__eventServiceKeys = Object.keys(ctx.services.events).sort();
|
globalThis.__eventServiceKeys = Object.keys(ctx.services.events).sort();
|
||||||
globalThis.__configServiceKeys = Object.keys(ctx.services.config).sort();
|
globalThis.__configServiceKeys = Object.keys(ctx.services.config).sort();
|
||||||
globalThis.__terminalServiceKeys = Object.keys(ctx.services.terminal).sort();
|
globalThis.__terminalServiceKeys = Object.keys(ctx.services.terminal).sort();
|
||||||
|
globalThis.__windowServiceKeys = Object.keys(ctx.services.windows).sort();
|
||||||
}
|
}
|
||||||
`);
|
`);
|
||||||
const { failures } = await loadPlugins(
|
const { failures } = await loadPlugins(
|
||||||
@ -253,6 +254,7 @@ describe("loadPlugins", () => {
|
|||||||
"tasks",
|
"tasks",
|
||||||
"terminal",
|
"terminal",
|
||||||
"tooling",
|
"tooling",
|
||||||
|
"windows",
|
||||||
"workspace",
|
"workspace",
|
||||||
]);
|
]);
|
||||||
expect((globalThis as Record<string, unknown>).__workspaceServiceKeys).toEqual([
|
expect((globalThis as Record<string, unknown>).__workspaceServiceKeys).toEqual([
|
||||||
@ -294,6 +296,94 @@ describe("loadPlugins", () => {
|
|||||||
"open",
|
"open",
|
||||||
"reattach",
|
"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 () => {
|
it("injects plugin-owned storage scoped to the activating plugin id", async () => {
|
||||||
|
|||||||
@ -15,6 +15,12 @@
|
|||||||
* collected, never thrown past `loadPlugins`.
|
* 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 type { JsonValue, PluginContributionDto, PluginRuntimePlugin } from "@/domain";
|
||||||
import {
|
import {
|
||||||
PluginCommandRegistry,
|
PluginCommandRegistry,
|
||||||
@ -93,6 +99,7 @@ export interface PluginLoadOptions {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const DEFAULT_PLUGIN_LOAD_TIMEOUT_MS = 10_000;
|
const DEFAULT_PLUGIN_LOAD_TIMEOUT_MS = 10_000;
|
||||||
|
let hostReactImportMapInstalled = false;
|
||||||
|
|
||||||
async function withTimeout<T>(
|
async function withTimeout<T>(
|
||||||
promise: Promise<T>,
|
promise: Promise<T>,
|
||||||
@ -161,6 +168,27 @@ function nonEmptyString(value: unknown): string | undefined {
|
|||||||
return typeof value === "string" && value.trim().length > 0 ? value : 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 {
|
function safePluginId(entry: unknown): string {
|
||||||
return nonEmptyString(objectOrEmpty(entry).id) ?? "<unknown-plugin>";
|
return nonEmptyString(objectOrEmpty(entry).id) ?? "<unknown-plugin>";
|
||||||
}
|
}
|
||||||
@ -247,6 +275,7 @@ async function loadOne(
|
|||||||
throw new Error("missing plugin bundle URL");
|
throw new Error("missing plugin bundle URL");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
installHostReactImportMap();
|
||||||
const mod = resolveIdeaPluginModule(
|
const mod = resolveIdeaPluginModule(
|
||||||
await withTimeout(
|
await withTimeout(
|
||||||
import(/* @vite-ignore */ bundleUrl),
|
import(/* @vite-ignore */ bundleUrl),
|
||||||
@ -282,8 +311,11 @@ async function loadOne(
|
|||||||
menu,
|
menu,
|
||||||
storage,
|
storage,
|
||||||
};
|
};
|
||||||
if (hasCapability(entry, "tooling")) {
|
if (hasCapability(entry, "tooling") || hasCapability(entry, "ui")) {
|
||||||
ctx.services = createPluginServices(gateways);
|
ctx.services = createPluginServices(gateways, {
|
||||||
|
pluginId,
|
||||||
|
declaredLayoutTypes,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
activation = await withTimeout(
|
activation = await withTimeout(
|
||||||
|
|||||||
@ -32,6 +32,7 @@ import type {
|
|||||||
ProjectGateway,
|
ProjectGateway,
|
||||||
SystemGateway,
|
SystemGateway,
|
||||||
TerminalGateway,
|
TerminalGateway,
|
||||||
|
WindowGateway,
|
||||||
WorkStateGateway,
|
WorkStateGateway,
|
||||||
} from "@/ports";
|
} from "@/ports";
|
||||||
|
|
||||||
@ -42,6 +43,7 @@ export interface PluginGatewaySet {
|
|||||||
terminal: TerminalGateway;
|
terminal: TerminalGateway;
|
||||||
agents: AgentGateway;
|
agents: AgentGateway;
|
||||||
system: SystemGateway;
|
system: SystemGateway;
|
||||||
|
window: WindowGateway;
|
||||||
workState: WorkStateGateway;
|
workState: WorkStateGateway;
|
||||||
focusedProject: FocusedProjectGateway;
|
focusedProject: FocusedProjectGateway;
|
||||||
pluginWorkspace: PluginWorkspaceGateway;
|
pluginWorkspace: PluginWorkspaceGateway;
|
||||||
|
|||||||
@ -13,6 +13,7 @@ import type {
|
|||||||
ReattachResult,
|
ReattachResult,
|
||||||
TerminalGateway,
|
TerminalGateway,
|
||||||
TerminalHandle,
|
TerminalHandle,
|
||||||
|
WindowGateway,
|
||||||
WorkStateGateway,
|
WorkStateGateway,
|
||||||
} from "@/ports";
|
} from "@/ports";
|
||||||
import { createPluginServices } from "./services";
|
import { createPluginServices } from "./services";
|
||||||
@ -32,6 +33,7 @@ function gateways(overrides: {
|
|||||||
project?: Partial<ProjectGateway>;
|
project?: Partial<ProjectGateway>;
|
||||||
workState?: Partial<WorkStateGateway>;
|
workState?: Partial<WorkStateGateway>;
|
||||||
terminal?: Partial<TerminalGateway>;
|
terminal?: Partial<TerminalGateway>;
|
||||||
|
window?: Partial<WindowGateway>;
|
||||||
pluginWorkspace?: Partial<PluginWorkspaceGateway>;
|
pluginWorkspace?: Partial<PluginWorkspaceGateway>;
|
||||||
pluginTask?: Partial<PluginTaskGateway>;
|
pluginTask?: Partial<PluginTaskGateway>;
|
||||||
pluginToolchain?: Partial<PluginToolchainGateway>;
|
pluginToolchain?: Partial<PluginToolchainGateway>;
|
||||||
@ -89,6 +91,25 @@ function gateways(overrides: {
|
|||||||
closeTerminal: vi.fn(),
|
closeTerminal: vi.fn(),
|
||||||
...overrides.terminal,
|
...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 = {
|
const pluginWorkspace: PluginWorkspaceGateway = {
|
||||||
readText: vi.fn(async ({ path }) => ({ path, content: "file text" })),
|
readText: vi.fn(async ({ path }) => ({ path, content: "file text" })),
|
||||||
readBinary: vi.fn(async ({ path }) => ({ path, bytes: new Uint8Array([67]) })),
|
readBinary: vi.fn(async ({ path }) => ({ path, bytes: new Uint8Array([67]) })),
|
||||||
@ -235,6 +256,7 @@ function gateways(overrides: {
|
|||||||
project,
|
project,
|
||||||
workState,
|
workState,
|
||||||
terminal,
|
terminal,
|
||||||
|
window,
|
||||||
pluginWorkspace,
|
pluginWorkspace,
|
||||||
pluginTask,
|
pluginTask,
|
||||||
pluginToolchain,
|
pluginToolchain,
|
||||||
@ -249,6 +271,37 @@ async function flushMicrotasks(): Promise<void> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
describe("createPluginServices", () => {
|
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 () => {
|
it("exposes focused workspace project helpers without leaking project gateway DTOs", async () => {
|
||||||
const g = gateways();
|
const g = gateways();
|
||||||
const services = createPluginServices(g);
|
const services = createPluginServices(g);
|
||||||
|
|||||||
@ -18,6 +18,7 @@ import type {
|
|||||||
PluginWorkspaceGateway,
|
PluginWorkspaceGateway,
|
||||||
ProjectGateway,
|
ProjectGateway,
|
||||||
TerminalGateway,
|
TerminalGateway,
|
||||||
|
WindowGateway,
|
||||||
WorkStateGateway,
|
WorkStateGateway,
|
||||||
} from "@/ports";
|
} from "@/ports";
|
||||||
|
|
||||||
@ -28,6 +29,7 @@ export interface PluginServices {
|
|||||||
events: EventService;
|
events: EventService;
|
||||||
config: ConfigDocumentService;
|
config: ConfigDocumentService;
|
||||||
terminal: TerminalService;
|
terminal: TerminalService;
|
||||||
|
windows: WindowService;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface WorkspaceProject {
|
export interface WorkspaceProject {
|
||||||
@ -370,9 +372,32 @@ export interface TerminalService {
|
|||||||
close(sessionId: string): Promise<void>;
|
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 {
|
interface PluginServiceGatewaySet {
|
||||||
project: ProjectGateway;
|
project: ProjectGateway;
|
||||||
terminal: TerminalGateway;
|
terminal: TerminalGateway;
|
||||||
|
window: WindowGateway;
|
||||||
workState: WorkStateGateway;
|
workState: WorkStateGateway;
|
||||||
focusedProject: FocusedProjectGateway;
|
focusedProject: FocusedProjectGateway;
|
||||||
pluginWorkspace: PluginWorkspaceGateway;
|
pluginWorkspace: PluginWorkspaceGateway;
|
||||||
@ -382,6 +407,11 @@ interface PluginServiceGatewaySet {
|
|||||||
pluginConfig: PluginConfigGateway;
|
pluginConfig: PluginConfigGateway;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface PluginServiceScope {
|
||||||
|
pluginId?: string;
|
||||||
|
declaredLayoutTypes?: ReadonlySet<string>;
|
||||||
|
}
|
||||||
|
|
||||||
const DEFAULT_ROWS = 24;
|
const DEFAULT_ROWS = 24;
|
||||||
const DEFAULT_COLS = 80;
|
const DEFAULT_COLS = 80;
|
||||||
const DEFAULT_EVENT_POLL_INTERVAL_MS = 1000;
|
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> {
|
async function currentProject(): Promise<WorkspaceProject | null> {
|
||||||
const focused = await gateways.focusedProject.getFocusedProject();
|
const focused = await gateways.focusedProject.getFocusedProject();
|
||||||
return focused ? toWorkspaceProject(focused) : null;
|
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 };
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1271,6 +1271,25 @@ export interface ViewWindowSnapshot {
|
|||||||
visible: boolean;
|
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).
|
* 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.
|
* main window's focused project; no project id is passed.
|
||||||
*/
|
*/
|
||||||
openViewWindow(panel: string): Promise<void>;
|
openViewWindow(panel: string): Promise<void>;
|
||||||
|
/**
|
||||||
|
* 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<PluginLayoutWindowOpenResult>;
|
||||||
/** Closes the detached window for `panel` if one is open. */
|
/** Closes the detached window for `panel` if one is open. */
|
||||||
closeViewWindow(panel: string): Promise<void>;
|
closeViewWindow(panel: string): Promise<void>;
|
||||||
/**
|
/**
|
||||||
|
|||||||
Reference in New Issue
Block a user