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:
76
frontend/src/adapters/focusedProject.test.ts
Normal file
76
frontend/src/adapters/focusedProject.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
48
frontend/src/adapters/focusedProject.ts
Normal file
48
frontend/src/adapters/focusedProject.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
@ -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,
|
||||
};
|
||||
|
||||
@ -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(),
|
||||
};
|
||||
}
|
||||
|
||||
@ -17,6 +17,7 @@ describe("createMockGateways", () => {
|
||||
"agent",
|
||||
"conversation",
|
||||
"embedder",
|
||||
"focusedProject",
|
||||
"git",
|
||||
"input",
|
||||
"layout",
|
||||
|
||||
@ -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();
|
||||
|
||||
@ -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;
|
||||
|
||||
Reference in New Issue
Block a user