fix(windows): fenêtres de panneau détachées suivent le projet en focus côté UI (#47)

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>
This commit is contained in:
2026-07-13 12:37:45 +02:00
parent 6387fac34f
commit 221cc8be78
14 changed files with 522 additions and 130 deletions

View File

@ -0,0 +1,76 @@
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 { TauriFocusedProjectGateway } from "./focusedProject";
describe("TauriFocusedProjectGateway (#47)", () => {
beforeEach(() => {
invoke.mockReset().mockResolvedValue(null);
listen.mockReset();
});
it("set/get relay to the backend focused-project commands", async () => {
const gw = new TauriFocusedProjectGateway();
await gw.setFocusedProject({ id: "p1", name: "alpha", root: "/p/a" });
expect(invoke).toHaveBeenCalledWith("set_focused_project", {
project: { id: "p1", name: "alpha", root: "/p/a" },
});
await gw.setFocusedProject(null);
expect(invoke).toHaveBeenCalledWith("set_focused_project", {
project: null,
});
invoke.mockResolvedValueOnce({ id: "p2", name: "beta", root: "/p/b" });
await expect(gw.getFocusedProject()).resolves.toEqual({
id: "p2",
name: "beta",
root: "/p/b",
});
expect(invoke).toHaveBeenCalledWith("get_focused_project");
// A `null`/undefined focus normalises to `null`.
invoke.mockResolvedValueOnce(null);
await expect(gw.getFocusedProject()).resolves.toBeNull();
});
it("onFocusedProjectChanged listens on the channel and forwards the project (or null)", async () => {
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 TauriFocusedProjectGateway();
const handler = vi.fn();
const off = await gw.onFocusedProjectChanged(handler);
expect(listen).toHaveBeenCalledWith(
"focused-project://changed",
expect.any(Function),
);
raw?.({ payload: { project: { id: "p1", name: "alpha", root: "/p/a" } } });
expect(handler).toHaveBeenLastCalledWith({
id: "p1",
name: "alpha",
root: "/p/a",
});
raw?.({ payload: { project: null } });
expect(handler).toHaveBeenLastCalledWith(null);
off();
expect(unlisten).toHaveBeenCalled();
});
});

View File

@ -0,0 +1,48 @@
/**
* Tauri adapter for {@link FocusedProjectGateway} (ticket #47). Bridges the
* main-window ⇄ detached-panel focused-project channel to the backend commands
* and event. 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 mirror the backend contract owned by
* DevBackend: `set_focused_project` / `get_focused_project` and the
* `focused-project://changed` event whose payload is `{ project: FocusedProject
* | null }`.
*/
import { invoke } from "@tauri-apps/api/core";
import { listen } from "@tauri-apps/api/event";
import type { Unsubscribe } from "@/domain";
import type { FocusedProject, FocusedProjectGateway } from "@/ports";
/** The Tauri event carrying focused-project transitions. */
const FOCUSED_PROJECT_CHANGED = "focused-project://changed";
/** Backend payload for `focused-project://changed`. */
interface FocusedProjectChanged {
project: FocusedProject | null;
}
export class TauriFocusedProjectGateway implements FocusedProjectGateway {
async setFocusedProject(project: FocusedProject | null): Promise<void> {
// The backend command takes `project: Option<FocusedProjectDto>`; `null`
// clears the focus (no project open).
await invoke("set_focused_project", { project });
}
async getFocusedProject(): Promise<FocusedProject | null> {
const project = await invoke<FocusedProject | null>("get_focused_project");
return project ?? null;
}
async onFocusedProjectChanged(
handler: (project: FocusedProject | null) => void,
): Promise<Unsubscribe> {
const unlisten = await listen<FocusedProjectChanged>(
FOCUSED_PROJECT_CHANGED,
(e) => handler(e.payload.project ?? null),
);
return unlisten;
}
}

View File

@ -30,6 +30,7 @@ import { TauriWorkStateGateway } from "./workState";
import { TauriConversationGateway } from "./conversation";
import { TauriTicketGateway } from "./ticket";
import { TauriWindowGateway } from "./window";
import { TauriFocusedProjectGateway } from "./focusedProject";
import { LocalStorageUiPreferencesGateway } from "./uiPreferences";
function notImplemented(what: string): never {
@ -68,6 +69,7 @@ export function createTauriGateways(): Gateways {
conversation: new TauriConversationGateway(),
ticket: new TauriTicketGateway(),
window: new TauriWindowGateway(),
focusedProject: new TauriFocusedProjectGateway(),
uiPreferences: new LocalStorageUiPreferencesGateway(),
};
}
@ -91,5 +93,6 @@ export {
TauriConversationGateway,
TauriTicketGateway,
TauriWindowGateway,
TauriFocusedProjectGateway,
LocalStorageUiPreferencesGateway,
};

View File

@ -91,6 +91,8 @@ import type {
UiPreferencesGateway,
ViewWindowClosed,
WindowGateway,
FocusedProject,
FocusedProjectGateway,
WorkStateGateway,
} from "@/ports";
import { normalizeProjectWorkState } from "../workStateNormalization";
@ -1094,27 +1096,25 @@ class MockRemoteGateway implements RemoteGateway {
}
/**
* 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).
* In-memory {@link WindowGateway} for tests/dev (#23, panel-only in #47). Tracks
* which views are "detached" in a set keyed by `panel` alone (a panel-only
* window follows the focused project, so no project id is part of the key), 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`. */
/** Currently-open detached windows, keyed by `panel`. */
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): Promise<void> {
this.open.add(panel);
}
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 closeViewWindow(panel: string): Promise<void> {
if (this.open.delete(panel)) {
this.emit({ panel });
}
}
@ -1128,9 +1128,9 @@ export class MockWindowGateway implements WindowGateway {
}
/** 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 });
simulateOsClose(panel: string): void {
this.open.delete(panel);
this.emit({ panel });
}
private emit(event: ViewWindowClosed): void {
@ -1138,6 +1138,38 @@ export class MockWindowGateway implements WindowGateway {
}
}
/**
* In-memory {@link FocusedProjectGateway} for tests/dev (#47). Holds the current
* focus in memory and fans changes out to subscribers — the mock counterpart to
* the backend's shared focused-project state + `focused-project://changed`
* event. `setFocusedProject` always notifies (mirrors the backend emit), so a
* detached-window test can drive a panel window's focus transitions.
*/
export class MockFocusedProjectGateway implements FocusedProjectGateway {
private focused: FocusedProject | null = null;
private readonly listeners = new Set<
(project: FocusedProject | null) => void
>();
async setFocusedProject(project: FocusedProject | null): Promise<void> {
this.focused = project;
for (const l of this.listeners) l(project);
}
async getFocusedProject(): Promise<FocusedProject | null> {
return this.focused;
}
async onFocusedProjectChanged(
handler: (project: FocusedProject | null) => void,
): Promise<Unsubscribe> {
this.listeners.add(handler);
return () => {
this.listeners.delete(handler);
};
}
}
/**
* The pre-filled reference catalogue the mock serves — mirror of the backend's
* **selectable** catalogue (§17.3/D7): only profiles drivable in structured mode
@ -2596,6 +2628,7 @@ export function createMockGateways(): Gateways {
conversation: new MockConversationGateway(),
ticket: new MockTicketGateway(systemGateway),
window: new MockWindowGateway(),
focusedProject: new MockFocusedProjectGateway(),
uiPreferences: new MockUiPreferencesGateway(),
};
}

View File

@ -17,6 +17,7 @@ describe("createMockGateways", () => {
"agent",
"conversation",
"embedder",
"focusedProject",
"git",
"input",
"layout",

View File

@ -17,17 +17,15 @@ describe("TauriWindowGateway (#23)", () => {
listen.mockReset();
});
it("open/close relay camelCase payloads to the backend commands", async () => {
it("open/close relay panel-only payloads to the backend commands (#47)", async () => {
const gw = new TauriWindowGateway();
await gw.openViewWindow("tickets", "proj-1");
await gw.openViewWindow("tickets");
expect(invoke).toHaveBeenCalledWith("open_view_window", {
panel: "tickets",
projectId: "proj-1",
});
await gw.closeViewWindow("git", "proj-2");
await gw.closeViewWindow("git");
expect(invoke).toHaveBeenCalledWith("close_view_window", {
panel: "git",
projectId: "proj-2",
});
});
@ -51,19 +49,19 @@ describe("TauriWindowGateway (#23)", () => {
// opened / focused are dropped…
raw?.({
payload: { kind: "opened", panel: "git", projectId: "p", label: "view-git-p" },
payload: { kind: "opened", panel: "git", label: "view-git" },
});
raw?.({
payload: { kind: "focused", panel: "git", projectId: "p", label: "view-git-p" },
payload: { kind: "focused", panel: "git", label: "view-git" },
});
expect(handler).not.toHaveBeenCalled();
// …only closed reaches the port handler, narrowed to { panel, projectId }.
// …only closed reaches the port handler, narrowed to { panel } (#47).
raw?.({
payload: { kind: "closed", panel: "git", projectId: "p", label: "view-git-p" },
payload: { kind: "closed", panel: "git", label: "view-git" },
});
expect(handler).toHaveBeenCalledTimes(1);
expect(handler).toHaveBeenCalledWith({ panel: "git", projectId: "p" });
expect(handler).toHaveBeenCalledWith({ panel: "git" });
off();
expect(unlisten).toHaveBeenCalled();

View File

@ -19,34 +19,38 @@ 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`). */
/**
* Backend lifecycle payload (discriminated on `kind`). Ticket #47 dropped the
* `projectId` field — a detached window is panel-only and follows the focused
* project, so lifecycle events carry only `panel` + `label`.
*/
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 openViewWindow(panel: string): Promise<void> {
// Panel-only window (#47): no project id — the window follows the focused
// project published on `focused-project://changed`.
await invoke("open_view_window", { panel });
}
async closeViewWindow(panel: string, projectId: string): Promise<void> {
await invoke("close_view_window", { panel, projectId });
async closeViewWindow(panel: string): Promise<void> {
await invoke("close_view_window", { panel });
}
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 }`.
// close, so drop the other transitions and forward `{ panel }`.
const unlisten = await listen<ViewWindowLifecycle>(
VIEW_WINDOW_LIFECYCLE,
(e) => {
if (e.payload.kind !== "closed") return;
handler({ panel: e.payload.panel, projectId: e.payload.projectId });
handler({ panel: e.payload.panel });
},
);
return unlisten;