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

@ -28,6 +28,7 @@ import { TauriPermissionGateway } from "./permission";
import { TauriWorkStateGateway } from "./workState";
import { TauriConversationGateway } from "./conversation";
import { TauriTicketGateway } from "./ticket";
import { TauriWindowGateway } from "./window";
function notImplemented(what: string): never {
const err: GatewayError = {
@ -63,6 +64,7 @@ export function createTauriGateways(): Gateways {
workState: new TauriWorkStateGateway(),
conversation: new TauriConversationGateway(),
ticket: new TauriTicketGateway(),
window: new TauriWindowGateway(),
};
}
@ -83,4 +85,5 @@ export {
TauriWorkStateGateway,
TauriConversationGateway,
TauriTicketGateway,
TauriWindowGateway,
};

View File

@ -84,6 +84,8 @@ import type {
TicketListQuery,
CreateTicketInput,
UpdateTicketInput,
ViewWindowClosed,
WindowGateway,
WorkStateGateway,
} from "@/ports";
import { normalizeProjectWorkState } from "../workStateNormalization";
@ -1086,6 +1088,51 @@ class MockRemoteGateway implements RemoteGateway {
async connect(): Promise<void> {}
}
/**
* In-memory {@link WindowGateway} for tests/dev (#23). Tracks which views are
* "detached" in a set keyed by `panel|projectId`, and lets tests simulate the
* OS closing a window via {@link simulateOsClose}. `closeViewWindow` also emits
* the close event, mirroring the backend (a programmatic close still notifies).
*/
export class MockWindowGateway implements WindowGateway {
/** Currently-open detached windows, keyed `panel|projectId`. */
readonly open = new Set<string>();
private readonly listeners = new Set<(e: ViewWindowClosed) => void>();
private key(panel: string, projectId: string): string {
return `${panel}|${projectId}`;
}
async openViewWindow(panel: string, projectId: string): Promise<void> {
this.open.add(this.key(panel, projectId));
}
async closeViewWindow(panel: string, projectId: string): Promise<void> {
if (this.open.delete(this.key(panel, projectId))) {
this.emit({ panel, projectId });
}
}
async onViewWindowClosed(
handler: (event: ViewWindowClosed) => void,
): Promise<Unsubscribe> {
this.listeners.add(handler);
return () => {
this.listeners.delete(handler);
};
}
/** Test hook: simulate the user closing the OS window (fires the event). */
simulateOsClose(panel: string, projectId: string): void {
this.open.delete(this.key(panel, projectId));
this.emit({ panel, projectId });
}
private emit(event: ViewWindowClosed): void {
for (const l of this.listeners) l(event);
}
}
/**
* The pre-filled reference catalogue the mock serves — mirror of the backend's
* **selectable** catalogue (§17.3/D7): only profiles drivable in structured mode
@ -2400,6 +2447,7 @@ export function createMockGateways(): Gateways {
workState: new MockWorkStateGateway(),
conversation: new MockConversationGateway(),
ticket: new MockTicketGateway(systemGateway),
window: new MockWindowGateway(),
};
}

View File

@ -30,6 +30,7 @@ describe("createMockGateways", () => {
"template",
"terminal",
"ticket",
"window",
"workState",
]);
});

View File

@ -0,0 +1,71 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
const invoke = vi.fn();
const listen = vi.fn();
vi.mock("@tauri-apps/api/core", () => ({
invoke: (...args: unknown[]) => invoke(...args),
}));
vi.mock("@tauri-apps/api/event", () => ({
listen: (...args: unknown[]) => listen(...args),
}));
import { TauriWindowGateway } from "./window";
describe("TauriWindowGateway (#23)", () => {
beforeEach(() => {
invoke.mockReset().mockResolvedValue({});
listen.mockReset();
});
it("open/close relay camelCase payloads to the backend commands", async () => {
const gw = new TauriWindowGateway();
await gw.openViewWindow("tickets", "proj-1");
expect(invoke).toHaveBeenCalledWith("open_view_window", {
panel: "tickets",
projectId: "proj-1",
});
await gw.closeViewWindow("git", "proj-2");
expect(invoke).toHaveBeenCalledWith("close_view_window", {
panel: "git",
projectId: "proj-2",
});
});
it("onViewWindowClosed listens on the lifecycle channel and forwards only closes", async () => {
// Capture the raw Tauri listener so we can drive lifecycle events at will.
let raw: ((e: { payload: unknown }) => void) | undefined;
const unlisten = vi.fn();
listen.mockImplementation((_name: string, cb: typeof raw) => {
raw = cb;
return Promise.resolve(unlisten);
});
const gw = new TauriWindowGateway();
const handler = vi.fn();
const off = await gw.onViewWindowClosed(handler);
expect(listen).toHaveBeenCalledWith(
"view-window://lifecycle",
expect.any(Function),
);
// opened / focused are dropped…
raw?.({
payload: { kind: "opened", panel: "git", projectId: "p", label: "view-git-p" },
});
raw?.({
payload: { kind: "focused", panel: "git", projectId: "p", label: "view-git-p" },
});
expect(handler).not.toHaveBeenCalled();
// …only closed reaches the port handler, narrowed to { panel, projectId }.
raw?.({
payload: { kind: "closed", panel: "git", projectId: "p", label: "view-git-p" },
});
expect(handler).toHaveBeenCalledTimes(1);
expect(handler).toHaveBeenCalledWith({ panel: "git", projectId: "p" });
off();
expect(unlisten).toHaveBeenCalled();
});
});

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;
}
}