feat(ui): fenêtres View système séparées — WebviewWindow Tauri + port WindowGateway (#23)

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>
This commit is contained in:
2026-07-07 16:53:43 +02:00
parent 2323a71f01
commit 69e785ec7e
21 changed files with 962 additions and 55 deletions

View File

@ -0,0 +1,54 @@
/**
* Tauri adapter for {@link WindowGateway} (ticket #23). Detaches a View into its
* own OS window by delegating to the backend's window-lifecycle commands. Like
* the sibling adapters, this is the only layer allowed to touch
* `@tauri-apps/api`; components reach it exclusively through the port.
*
* Command/event names and payloads below mirror the backend contract owned by
* DevBackend (build-verified). The backend exposes a single lifecycle channel
* `view-window://lifecycle`; this adapter narrows it to the port's close-only
* `onViewWindowClosed` by filtering on `kind === "closed"`.
*/
import { invoke } from "@tauri-apps/api/core";
import { listen } from "@tauri-apps/api/event";
import type { Unsubscribe } from "@/domain";
import type { ViewWindowClosed, WindowGateway } from "@/ports";
/** The single Tauri event carrying detached-window lifecycle transitions. */
const VIEW_WINDOW_LIFECYCLE = "view-window://lifecycle";
/** Backend lifecycle payload (discriminated on `kind`). */
interface ViewWindowLifecycle {
kind: "opened" | "focused" | "closed";
panel: string;
projectId: string;
label: string;
}
export class TauriWindowGateway implements WindowGateway {
async openViewWindow(panel: string, projectId: string): Promise<void> {
// camelCase args (Tauri maps them to the command's `panel` / `project_id`).
await invoke("open_view_window", { panel, projectId });
}
async closeViewWindow(panel: string, projectId: string): Promise<void> {
await invoke("close_view_window", { panel, projectId });
}
async onViewWindowClosed(
handler: (event: ViewWindowClosed) => void,
): Promise<Unsubscribe> {
// One backend channel for opened/focused/closed; the port only surfaces the
// close, so drop the other transitions and forward `{ panel, projectId }`.
const unlisten = await listen<ViewWindowLifecycle>(
VIEW_WINDOW_LIFECYCLE,
(e) => {
if (e.payload.kind !== "closed") return;
handler({ panel: e.payload.panel, projectId: e.payload.projectId });
},
);
return unlisten;
}
}