Partie frontend. Ajoute un adaptateur focusedProject et un port dédié : la ViewWindow détachée n'est plus liée à un project_id figé, elle s'abonne à l'event focused-project émis par la fenêtre principale et affiche le panneau du projet courant. ProjectsView propage le focus ; le détachement crée une fenêtre panel-only. Couvert par les tests window/ViewWindow/focusedProject/ ProjectsView.focus. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
120 lines
4.2 KiB
TypeScript
120 lines
4.2 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, useState } from "react";
|
|
|
|
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 { 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;
|
|
}
|
|
|
|
/** Reads and validates the panel-only param from a query string (#47: no project). */
|
|
export function parseViewWindowParams(
|
|
search: string,
|
|
): ViewWindowParams | null {
|
|
const params = new URLSearchParams(search);
|
|
const panel = params.get("panel");
|
|
if (!panel || !DETACHABLE.has(panel)) return null;
|
|
return { 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>
|
|
);
|
|
}
|