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:
@ -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,
|
||||
};
|
||||
|
||||
@ -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(),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@ -30,6 +30,7 @@ describe("createMockGateways", () => {
|
||||
"template",
|
||||
"terminal",
|
||||
"ticket",
|
||||
"window",
|
||||
"workState",
|
||||
]);
|
||||
});
|
||||
|
||||
71
frontend/src/adapters/window.test.ts
Normal file
71
frontend/src/adapters/window.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
54
frontend/src/adapters/window.ts
Normal file
54
frontend/src/adapters/window.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user