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