Cause: update_project_settings remplaçait tout ProjectSettings avec la seule couleur fournie — champ absent et null indistinguables — et des réponses de chargement/sauvegarde obsolètes d'un projet précédent pouvaient réafficher/écraser la couleur lors d'un changement de projet rapide, d'où l'impression que la couleur ne prenait pas sur tous les projets. - DTO tri-état (absent = inchangé, null = défaut produit) avec Deserialize manuel et use case de mise à jour partielle du champ theme_color; commandes Tauri et web-server alignées sur le même contrat. - Garde-fous de course frontend: séquences load/save + révision thème par projet (pub/sub onProjectThemeChanged) dans ProjectSettingsPanel, garde de révision de focus dans ViewWindow. - Tests: infrastructure settings, contrats web-server (inchangé/reset), ProjectSettingsPanel, ViewWindow, projects. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
312 lines
10 KiB
TypeScript
312 lines
10 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, 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";
|
|
|
|
/** 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 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<FocusedProject | null | undefined>(
|
|
undefined,
|
|
);
|
|
const [themeColors, setThemeColors] = useState<Record<string, string | null>>({});
|
|
const themeRevisionRef = useRef<Record<string, number>>({});
|
|
|
|
// 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 (
|
|
<div
|
|
className="flex h-full flex-col bg-canvas text-content"
|
|
style={projectThemeStyle(focus ? themeColors[focus.id] : null)}
|
|
>
|
|
<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>
|
|
);
|
|
}
|