Files
IdeA/frontend/src/app/ViewWindow.tsx
Blomios d8df466fba 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>
2026-08-04 00:39:50 +02:00

267 lines
8.5 KiB
TypeScript

/**
* `ViewWindow` — the panel-only entry (ticket #23, reworked in #47).
*
* A detached OS window loads the same SPA with `?panel=<panel>` (no project id);
* {@link main} routes to this component instead of the full {@link App} when a
* `panel` param is present. It re-instantiates the DI (same adapters/gateways as
* the main window — views are gateway/read-model-driven, so no heavy state is
* transferred) and renders **only** the requested view for the project the main
* window currently has in focus.
*
* Focus, not embedding (#47): the window never carries — nor reopens — a project
* id. It reads the current focus from the {@link FocusedProjectGateway} at mount
* and then tracks {@link FocusedProjectGateway.onFocusedProjectChanged}. While no
* project is focused it mounts **no** project-scoped panel and shows an "open a
* project" shell, so a window restored at restart never surfaces a stale
* project's data.
*
* Pure presentation over the ports: no `invoke()`, no cross-window messaging,
* and crucially no `openProject()` — opening a project is the main window's job.
*/
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"). */
const DETACHABLE = new Set<string>(
(Object.keys(PANEL_TITLE) as PanelId[]).filter((p) => p !== "projects"),
);
/** Parsed `?panel=` params, or `null` when absent/invalid. */
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,
): 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 { kind: "panel", panel: panel as ViewPanelId };
}
export interface ViewWindowProps {
panel: ViewPanelId;
}
export function ViewWindow({ panel }: ViewWindowProps) {
const { focusedProject } = useGateways();
// The focused project this window follows. `undefined` = not yet resolved
// (initial read in flight); `null` = resolved, no project focused.
const [focus, setFocus] = useState<FocusedProject | null | undefined>(
undefined,
);
// Read the current focus at mount, then track changes. Never opens a project:
// the window is a pure follower of the main window's focus (#47).
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">
{PANEL_TITLE[panel]}
</h1>
{focus && (
<span className="truncate text-xs text-muted">· {focus.name}</span>
)}
</header>
<main className="min-h-0 flex-1 overflow-auto p-4">
{focus ? (
// Only mount a project-scoped panel when a project is actually focused
// — the panels require a real `projectRoot`, never a placeholder.
<ViewPanelBody
panel={panel}
projectId={focus.id}
projectRoot={focus.root}
/>
) : (
<Panel>
<p className="text-sm text-muted">
Ouvrez un projet dans la fenêtre principale pour afficher ce
panneau.
</p>
</Panel>
)}
</main>
</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>
);
}