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:
@ -79,6 +79,9 @@ export class WebWindowGateway implements WindowGateway {
|
||||
openViewWindow(): Promise<void> {
|
||||
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> {
|
||||
return unsupportedOnWeb("Closing a detached OS window");
|
||||
}
|
||||
|
||||
@ -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<string>();
|
||||
readonly pluginLayoutWindows = new Map<string, PluginLayoutWindowOpenResult>();
|
||||
private readonly listeners = new Set<(e: ViewWindowClosed) => void>();
|
||||
|
||||
async openViewWindow(panel: string): Promise<void> {
|
||||
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> {
|
||||
if (this.open.delete(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 () => {
|
||||
// Capture the raw Tauri listener so we can drive lifecycle events at will.
|
||||
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 {
|
||||
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<PluginLayoutWindowOpenResult> {
|
||||
return invoke<PluginLayoutWindowOpenResult>("open_plugin_layout_window", {
|
||||
input,
|
||||
});
|
||||
}
|
||||
|
||||
async closeViewWindow(panel: string): Promise<void> {
|
||||
await invoke("close_view_window", { panel });
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user