Détache les vues dans de vraies fenêtres système Tauri (multi-écran, fullscreen). Backend : commandes de gestion de WebviewWindow, capabilities et composition root. Frontend : nouveau port WindowGateway et son adaptateur window, entrée panel-only ViewWindow/ViewPanelBody, détachement câblé dans ProjectsView. QA vert : app-tauri 63, frontend typecheck + vitest 546/546. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
111 lines
3.6 KiB
TypeScript
111 lines
3.6 KiB
TypeScript
/**
|
|
* `ViewWindow` — the panel-only entry (ticket #23).
|
|
*
|
|
* A detached OS window loads the same SPA with `?panel=<panel>&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 given
|
|
* project.
|
|
*
|
|
* Pure presentation over the ports: it resolves the project through the
|
|
* {@link ProjectGateway} (for its name/root) and hands off to the shared
|
|
* {@link ViewPanelBody}. No `invoke()`, no cross-window messaging.
|
|
*/
|
|
|
|
import { useEffect, useState } from "react";
|
|
|
|
import type { Project } from "@/domain";
|
|
import { Panel, Spinner } 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=&project=` params, or `null` when absent/invalid. */
|
|
export interface ViewWindowParams {
|
|
panel: ViewPanelId;
|
|
projectId: string;
|
|
}
|
|
|
|
/** Reads and validates the panel-only params from a query string. */
|
|
export function parseViewWindowParams(
|
|
search: string,
|
|
): ViewWindowParams | null {
|
|
const params = new URLSearchParams(search);
|
|
const panel = params.get("panel");
|
|
const projectId = params.get("project");
|
|
if (!panel || !projectId || !DETACHABLE.has(panel)) return null;
|
|
return { panel: panel as ViewPanelId, projectId };
|
|
}
|
|
|
|
export interface ViewWindowProps {
|
|
panel: ViewPanelId;
|
|
projectId: string;
|
|
}
|
|
|
|
export function ViewWindow({ panel, projectId }: ViewWindowProps) {
|
|
const { project } = useGateways();
|
|
const [resolved, setResolved] = useState<Project | null>(null);
|
|
const [error, setError] = useState<string | null>(null);
|
|
|
|
// Resolve the project to get its root (the agents panel needs it) and name
|
|
// (window title). Opening is idempotent on the backend registry.
|
|
useEffect(() => {
|
|
let cancelled = false;
|
|
project
|
|
.openProject(projectId)
|
|
.then((p) => {
|
|
if (!cancelled) setResolved(p);
|
|
})
|
|
.catch((e: unknown) => {
|
|
if (!cancelled) setError(describeError(e));
|
|
});
|
|
return () => {
|
|
cancelled = true;
|
|
};
|
|
}, [project, projectId]);
|
|
|
|
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>
|
|
{resolved && (
|
|
<span className="truncate text-xs text-muted">· {resolved.name}</span>
|
|
)}
|
|
</header>
|
|
<main className="min-h-0 flex-1 overflow-auto p-4">
|
|
{error ? (
|
|
<Panel className="border-danger/40">
|
|
<p className="text-sm text-danger">Error: {error}</p>
|
|
</Panel>
|
|
) : resolved ? (
|
|
<ViewPanelBody
|
|
panel={panel}
|
|
projectId={resolved.id}
|
|
projectRoot={resolved.root}
|
|
/>
|
|
) : (
|
|
<div className="flex items-center gap-2 text-sm text-muted">
|
|
<Spinner size={14} />
|
|
<span>Loading…</span>
|
|
</div>
|
|
)}
|
|
</main>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function describeError(e: unknown): string {
|
|
if (e && typeof e === "object" && "message" in e) {
|
|
return String((e as { message: unknown }).message);
|
|
}
|
|
return String(e);
|
|
}
|