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:
@ -1,14 +1,17 @@
|
||||
/**
|
||||
* #23 — the panel-only entry. `parseViewWindowParams` validates the
|
||||
* `?panel=&project=` query, and `ViewWindow` resolves the project through the
|
||||
* gateway then renders only the requested view (here: the tickets view).
|
||||
* #23/#47 — the panel-only entry. `parseViewWindowParams` validates the
|
||||
* `?panel=` query (no project id anymore), and `ViewWindow` follows the main
|
||||
* window's focused project: it shows an "open a project" shell while no project
|
||||
* is focused and mounts the requested view once one is, without ever opening a
|
||||
* project itself.
|
||||
*/
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { render, screen, waitFor } from "@testing-library/react";
|
||||
import { render, screen, waitFor, act } from "@testing-library/react";
|
||||
|
||||
import { DIProvider } from "@/app/di";
|
||||
import {
|
||||
MockAgentGateway,
|
||||
MockFocusedProjectGateway,
|
||||
MockProjectGateway,
|
||||
MockSystemGateway,
|
||||
MockTicketGateway,
|
||||
@ -16,45 +19,124 @@ import {
|
||||
import type { Gateways } from "@/ports";
|
||||
import { ViewWindow, parseViewWindowParams } from "./ViewWindow";
|
||||
|
||||
describe("parseViewWindowParams (#23)", () => {
|
||||
it("parses a valid panel + project", () => {
|
||||
describe("parseViewWindowParams (#47)", () => {
|
||||
it("parses a valid panel-only query (ignores any legacy project param)", () => {
|
||||
expect(parseViewWindowParams("?panel=tickets")).toEqual({
|
||||
panel: "tickets",
|
||||
});
|
||||
// A restored legacy URL that still carries `project` parses to panel only.
|
||||
expect(parseViewWindowParams("?panel=tickets&project=p-1")).toEqual({
|
||||
panel: "tickets",
|
||||
projectId: "p-1",
|
||||
});
|
||||
});
|
||||
|
||||
it("rejects a missing project, missing panel, or the non-detachable projects panel", () => {
|
||||
expect(parseViewWindowParams("?panel=tickets")).toBeNull();
|
||||
it("rejects a missing panel or the non-detachable projects panel", () => {
|
||||
expect(parseViewWindowParams("?project=p-1")).toBeNull();
|
||||
expect(parseViewWindowParams("?panel=projects&project=p-1")).toBeNull();
|
||||
expect(parseViewWindowParams("?panel=bogus&project=p-1")).toBeNull();
|
||||
expect(parseViewWindowParams("?panel=projects")).toBeNull();
|
||||
expect(parseViewWindowParams("?panel=bogus")).toBeNull();
|
||||
expect(parseViewWindowParams("")).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("ViewWindow (#23)", () => {
|
||||
it("resolves the project and renders only the requested view", async () => {
|
||||
describe("ViewWindow (#47)", () => {
|
||||
it("shows the 'open a project' shell when no project is focused", async () => {
|
||||
const system = new MockSystemGateway();
|
||||
const gateways = {
|
||||
system,
|
||||
project: new MockProjectGateway(),
|
||||
agent: new MockAgentGateway(),
|
||||
ticket: new MockTicketGateway(system),
|
||||
focusedProject: new MockFocusedProjectGateway(),
|
||||
} as unknown as Gateways;
|
||||
|
||||
render(
|
||||
<DIProvider gateways={gateways}>
|
||||
<ViewWindow panel="tickets" />
|
||||
</DIProvider>,
|
||||
);
|
||||
|
||||
// The panel title chrome is present, but no project-scoped view mounts…
|
||||
expect(screen.getByRole("heading", { name: "Tickets" })).toBeTruthy();
|
||||
await waitFor(() =>
|
||||
expect(screen.getByText(/Ouvrez un projet/i)).toBeTruthy(),
|
||||
);
|
||||
expect(screen.queryByLabelText("search tickets")).toBeNull();
|
||||
});
|
||||
|
||||
it("mounts the view for the focused project and follows focus changes", async () => {
|
||||
const system = new MockSystemGateway();
|
||||
const project = new MockProjectGateway();
|
||||
const created = await project.createProject("alpha", "/p/a");
|
||||
const focusedProject = new MockFocusedProjectGateway();
|
||||
const gateways = {
|
||||
system,
|
||||
project,
|
||||
agent: new MockAgentGateway(),
|
||||
ticket: new MockTicketGateway(system),
|
||||
focusedProject,
|
||||
} as unknown as Gateways;
|
||||
|
||||
render(
|
||||
<DIProvider gateways={gateways}>
|
||||
<ViewWindow panel="tickets" projectId={created.id} />
|
||||
<ViewWindow panel="tickets" />
|
||||
</DIProvider>,
|
||||
);
|
||||
|
||||
// The window chrome shows the panel title and the resolved project name.
|
||||
expect(screen.getByRole("heading", { name: "Tickets" })).toBeTruthy();
|
||||
// No focus yet → shell.
|
||||
await waitFor(() =>
|
||||
expect(screen.getByText(/Ouvrez un projet/i)).toBeTruthy(),
|
||||
);
|
||||
|
||||
// The main window publishes a focused project → the window follows it.
|
||||
await act(async () => {
|
||||
await focusedProject.setFocusedProject({
|
||||
id: created.id,
|
||||
name: "alpha",
|
||||
root: "/p/a",
|
||||
});
|
||||
});
|
||||
|
||||
await waitFor(() => expect(screen.getByText("· alpha")).toBeTruthy());
|
||||
// The tickets view itself mounted (its search box is present).
|
||||
await waitFor(() =>
|
||||
expect(screen.getByLabelText("search tickets")).toBeTruthy(),
|
||||
);
|
||||
|
||||
// Focus cleared (project closed) → back to the shell, view unmounts.
|
||||
await act(async () => {
|
||||
await focusedProject.setFocusedProject(null);
|
||||
});
|
||||
await waitFor(() =>
|
||||
expect(screen.getByText(/Ouvrez un projet/i)).toBeTruthy(),
|
||||
);
|
||||
expect(screen.queryByLabelText("search tickets")).toBeNull();
|
||||
});
|
||||
|
||||
it("reads the current focus at mount (restored window with a live focus)", async () => {
|
||||
const system = new MockSystemGateway();
|
||||
const project = new MockProjectGateway();
|
||||
const created = await project.createProject("beta", "/p/b");
|
||||
const focusedProject = new MockFocusedProjectGateway();
|
||||
// Focus already set before the window mounts (main window opened first).
|
||||
await focusedProject.setFocusedProject({
|
||||
id: created.id,
|
||||
name: "beta",
|
||||
root: "/p/b",
|
||||
});
|
||||
const gateways = {
|
||||
system,
|
||||
project,
|
||||
agent: new MockAgentGateway(),
|
||||
ticket: new MockTicketGateway(system),
|
||||
focusedProject,
|
||||
} as unknown as Gateways;
|
||||
|
||||
render(
|
||||
<DIProvider gateways={gateways}>
|
||||
<ViewWindow panel="tickets" />
|
||||
</DIProvider>,
|
||||
);
|
||||
|
||||
await waitFor(() => expect(screen.getByText("· beta")).toBeTruthy());
|
||||
await waitFor(() =>
|
||||
expect(screen.getByLabelText("search tickets")).toBeTruthy(),
|
||||
);
|
||||
|
||||
@ -1,22 +1,28 @@
|
||||
/**
|
||||
* `ViewWindow` — the panel-only entry (ticket #23).
|
||||
* `ViewWindow` — the panel-only entry (ticket #23, reworked in #47).
|
||||
*
|
||||
* A detached OS window loads the same SPA with `?panel=<panel>&project=<id>`;
|
||||
* A detached OS window loads the same SPA with `?panel=<panel>` (no project id);
|
||||
* {@link main} routes to this component instead of the full {@link App} when a
|
||||
* `panel` param is present. It re-instantiates the DI (same adapters/gateways
|
||||
* as the main window — views are gateway/read-model-driven, so no heavy state
|
||||
* is transferred) and renders **only** the requested view for the given
|
||||
* project.
|
||||
* `panel` param is present. It re-instantiates the DI (same adapters/gateways as
|
||||
* the main window — views are gateway/read-model-driven, so no heavy state is
|
||||
* transferred) and renders **only** the requested view for the project the main
|
||||
* window currently has in focus.
|
||||
*
|
||||
* Pure presentation over the ports: it resolves the project through the
|
||||
* {@link ProjectGateway} (for its name/root) and hands off to the shared
|
||||
* {@link ViewPanelBody}. No `invoke()`, no cross-window messaging.
|
||||
* Focus, not embedding (#47): the window never carries — nor reopens — a project
|
||||
* id. It reads the current focus from the {@link FocusedProjectGateway} at mount
|
||||
* and then tracks {@link FocusedProjectGateway.onFocusedProjectChanged}. While no
|
||||
* project is focused it mounts **no** project-scoped panel and shows an "open a
|
||||
* project" shell, so a window restored at restart never surfaces a stale
|
||||
* project's data.
|
||||
*
|
||||
* Pure presentation over the ports: no `invoke()`, no cross-window messaging,
|
||||
* and crucially no `openProject()` — opening a project is the main window's job.
|
||||
*/
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
import type { Project } from "@/domain";
|
||||
import { Panel, Spinner } from "@/shared";
|
||||
import type { FocusedProject } from "@/ports";
|
||||
import { Panel } from "@/shared";
|
||||
import { PANEL_TITLE, type PanelId } from "@/features/projects";
|
||||
import { ViewPanelBody, type ViewPanelId } from "@/features/projects";
|
||||
import { useGateways } from "./di";
|
||||
@ -26,49 +32,59 @@ const DETACHABLE = new Set<string>(
|
||||
(Object.keys(PANEL_TITLE) as PanelId[]).filter((p) => p !== "projects"),
|
||||
);
|
||||
|
||||
/** Parsed `?panel=&project=` params, or `null` when absent/invalid. */
|
||||
/** Parsed `?panel=` params, or `null` when absent/invalid. */
|
||||
export interface ViewWindowParams {
|
||||
panel: ViewPanelId;
|
||||
projectId: string;
|
||||
}
|
||||
|
||||
/** Reads and validates the panel-only params from a query string. */
|
||||
/** Reads and validates the panel-only param from a query string (#47: no project). */
|
||||
export function parseViewWindowParams(
|
||||
search: string,
|
||||
): ViewWindowParams | null {
|
||||
const params = new URLSearchParams(search);
|
||||
const panel = params.get("panel");
|
||||
const projectId = params.get("project");
|
||||
if (!panel || !projectId || !DETACHABLE.has(panel)) return null;
|
||||
return { panel: panel as ViewPanelId, projectId };
|
||||
if (!panel || !DETACHABLE.has(panel)) return null;
|
||||
return { panel: panel as ViewPanelId };
|
||||
}
|
||||
|
||||
export interface ViewWindowProps {
|
||||
panel: ViewPanelId;
|
||||
projectId: string;
|
||||
}
|
||||
|
||||
export function ViewWindow({ panel, projectId }: ViewWindowProps) {
|
||||
const { project } = useGateways();
|
||||
const [resolved, setResolved] = useState<Project | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
export function ViewWindow({ panel }: ViewWindowProps) {
|
||||
const { focusedProject } = useGateways();
|
||||
// The focused project this window follows. `undefined` = not yet resolved
|
||||
// (initial read in flight); `null` = resolved, no project focused.
|
||||
const [focus, setFocus] = useState<FocusedProject | null | undefined>(
|
||||
undefined,
|
||||
);
|
||||
|
||||
// Resolve the project to get its root (the agents panel needs it) and name
|
||||
// (window title). Opening is idempotent on the backend registry.
|
||||
// Read the current focus at mount, then track changes. Never opens a project:
|
||||
// the window is a pure follower of the main window's focus (#47).
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
project
|
||||
.openProject(projectId)
|
||||
.then((p) => {
|
||||
if (!cancelled) setResolved(p);
|
||||
let unsubscribe: (() => void) | undefined;
|
||||
focusedProject
|
||||
.getFocusedProject()
|
||||
.then((project) => {
|
||||
if (!cancelled) setFocus(project);
|
||||
})
|
||||
.catch((e: unknown) => {
|
||||
if (!cancelled) setError(describeError(e));
|
||||
.catch(() => {
|
||||
if (!cancelled) setFocus(null);
|
||||
});
|
||||
void focusedProject
|
||||
.onFocusedProjectChanged((project) => {
|
||||
if (!cancelled) setFocus(project);
|
||||
})
|
||||
.then((un) => {
|
||||
if (cancelled) un();
|
||||
else unsubscribe = un;
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
unsubscribe?.();
|
||||
};
|
||||
}, [project, projectId]);
|
||||
}, [focusedProject]);
|
||||
|
||||
return (
|
||||
<div className="flex h-full flex-col bg-canvas text-content">
|
||||
@ -76,35 +92,28 @@ export function ViewWindow({ panel, projectId }: ViewWindowProps) {
|
||||
<h1 className="text-sm font-semibold text-content">
|
||||
{PANEL_TITLE[panel]}
|
||||
</h1>
|
||||
{resolved && (
|
||||
<span className="truncate text-xs text-muted">· {resolved.name}</span>
|
||||
{focus && (
|
||||
<span className="truncate text-xs text-muted">· {focus.name}</span>
|
||||
)}
|
||||
</header>
|
||||
<main className="min-h-0 flex-1 overflow-auto p-4">
|
||||
{error ? (
|
||||
<Panel className="border-danger/40">
|
||||
<p className="text-sm text-danger">Error: {error}</p>
|
||||
</Panel>
|
||||
) : resolved ? (
|
||||
{focus ? (
|
||||
// Only mount a project-scoped panel when a project is actually focused
|
||||
// — the panels require a real `projectRoot`, never a placeholder.
|
||||
<ViewPanelBody
|
||||
panel={panel}
|
||||
projectId={resolved.id}
|
||||
projectRoot={resolved.root}
|
||||
projectId={focus.id}
|
||||
projectRoot={focus.root}
|
||||
/>
|
||||
) : (
|
||||
<div className="flex items-center gap-2 text-sm text-muted">
|
||||
<Spinner size={14} />
|
||||
<span>Loading…</span>
|
||||
</div>
|
||||
<Panel>
|
||||
<p className="text-sm text-muted">
|
||||
Ouvrez un projet dans la fenêtre principale pour afficher ce
|
||||
panneau.
|
||||
</p>
|
||||
</Panel>
|
||||
)}
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function describeError(e: unknown): string {
|
||||
if (e && typeof e === "object" && "message" in e) {
|
||||
return String((e as { message: unknown }).message);
|
||||
}
|
||||
return String(e);
|
||||
}
|
||||
|
||||
@ -13,15 +13,16 @@ if (!root) {
|
||||
throw new Error("missing #root element");
|
||||
}
|
||||
|
||||
// #23: a detached view window loads the SPA with `?panel=&project=`. When those
|
||||
// params are present and valid, render only that view; otherwise the full app.
|
||||
// #23/#47: a detached view window loads the SPA with `?panel=` (panel-only, no
|
||||
// project id). When the param is present and valid, render only that view — it
|
||||
// follows the main window's focused project; otherwise the full app.
|
||||
const viewParams = parseViewWindowParams(window.location.search);
|
||||
|
||||
ReactDOM.createRoot(root).render(
|
||||
<React.StrictMode>
|
||||
<DIProvider>
|
||||
{viewParams ? (
|
||||
<ViewWindow panel={viewParams.panel} projectId={viewParams.projectId} />
|
||||
<ViewWindow panel={viewParams.panel} />
|
||||
) : (
|
||||
<App />
|
||||
)}
|
||||
|
||||
Reference in New Issue
Block a user