/** * `ViewWindow` — the panel-only entry (ticket #23, reworked in #47). * * A detached OS window loads the same SPA with `?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, useRef, 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 { onProjectThemeChanged, projectThemeStyle, validThemeColor, } from "@/features/projects/projectTheme"; import { PluginLayoutCellView, PluginRuntimeProvider, usePluginRuntime, type PluginRuntimeContextValue, } from "@/features/plugins"; import { useGateways } from "./di"; import { useI18n } from "@/i18n"; /** Panels that may be shown in a detached window (everything but "projects"). */ const DETACHABLE = new Set( (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 rawPanel = params.get("panel"); const panel = rawPanel === "context" ? "projectSettings" : rawPanel; 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, project } = useGateways(); // The focused project this window follows. `undefined` = not yet resolved // (initial read in flight); `null` = resolved, no project focused. const [focus, setFocus] = useState( undefined, ); const [themeColors, setThemeColors] = useState>({}); const themeRevisionRef = useRef>({}); // 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; let focusRevision = 0; const initialRevision = focusRevision; focusedProject .getFocusedProject() .then((project) => { if (!cancelled && focusRevision === initialRevision) setFocus(project); }) .catch(() => { if (!cancelled && focusRevision === initialRevision) setFocus(null); }); void focusedProject .onFocusedProjectChanged((project) => { focusRevision += 1; if (!cancelled) setFocus(project); }) .then((un) => { if (cancelled) un(); else unsubscribe = un; }); return () => { cancelled = true; unsubscribe?.(); }; }, [focusedProject]); useEffect(() => { if (!focus) return; let cancelled = false; const revision = themeRevisionRef.current[focus.id] ?? 0; void project.readProjectSettings(focus.id).then((settings) => { if ( !cancelled && (themeRevisionRef.current[focus.id] ?? 0) === revision ) { setThemeColors((current) => ({ ...current, [focus.id]: validThemeColor(settings.themeColor), })); } }).catch(() => { // Keep the last known colour; settings content owns its own retry state. }); return () => { cancelled = true; }; }, [focus?.id, project]); useEffect(() => onProjectThemeChanged(({ projectId, themeColor }) => { themeRevisionRef.current[projectId] = (themeRevisionRef.current[projectId] ?? 0) + 1; setThemeColors((current) => ({ ...current, [projectId]: validThemeColor(themeColor), })); }), []); return (

{PANEL_TITLE[panel]}

{focus && ( · {focus.name} )}
{focus ? ( // Only mount a project-scoped panel when a project is actually focused // — the panels require a real `projectRoot`, never a placeholder. ) : (

Ouvrez un projet dans la fenêtre principale pour afficher ce panneau.

)}
); } export interface PluginLayoutWindowProps { plugin: PluginLayoutWindowParams; runtimeValue?: PluginRuntimeContextValue; } export function PluginLayoutWindow({ plugin, runtimeValue }: PluginLayoutWindowProps) { return ( ); } function PluginLayoutWindowBody({ plugin }: PluginLayoutWindowProps) { const { tr } = useI18n(); 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={() => {}} /> ) : (

{tr("Ouvrez un projet dans la fenêtre principale pour afficher ce layout.", "Open a project in the main window to display this layout.")}

)} {loading && (

{tr("Chargement des plugins…", "Loading plugins…")}

)}
); }