merge(web): surfaces ouvrables ici/onglet/fenêtre — #204 (QA verte)
This commit is contained in:
@ -5,7 +5,7 @@ import ReactDOM from "react-dom/client";
|
||||
|
||||
import { App } from "./App";
|
||||
import { PluginLayoutWindow, ViewWindow, parseViewWindowParams } from "./ViewWindow";
|
||||
import { WebApp } from "@/features/web";
|
||||
import { WebApp, WebSurfaceWindow, parseWebSurfaceWindowParams } from "@/features/web";
|
||||
import { DIProvider, resolveTransport } from "./di";
|
||||
import { RootErrorBoundary, installGlobalErrorLogging } from "./RootErrorBoundary";
|
||||
import "@/shared/styles/theme.css";
|
||||
@ -19,6 +19,7 @@ if (!root) {
|
||||
// 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);
|
||||
const webSurfaceParams = parseWebSurfaceWindowParams(window.location.search);
|
||||
|
||||
// Ticket #74, F1: publish the transport this bundle was built for. `define`
|
||||
// inlines it as a literal, and assigning it is a side effect, so it survives
|
||||
@ -39,7 +40,9 @@ ReactDOM.createRoot(root).render(
|
||||
<React.StrictMode>
|
||||
<RootErrorBoundary>
|
||||
<DIProvider>
|
||||
{isWeb ? (
|
||||
{isWeb && webSurfaceParams ? (
|
||||
<WebSurfaceWindow params={webSurfaceParams} />
|
||||
) : isWeb ? (
|
||||
<WebApp />
|
||||
) : viewParams?.kind === "panel" ? (
|
||||
<ViewWindow panel={viewParams.panel} />
|
||||
|
||||
@ -7,7 +7,7 @@
|
||||
* that selecting an entry routes the main surface without ever crashing on a
|
||||
* desktop-only action.
|
||||
*/
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { describe, it, expect, vi } from "vitest";
|
||||
import { fireEvent, render, screen, within } from "@testing-library/react";
|
||||
|
||||
import type { Gateways } from "@/ports";
|
||||
@ -68,6 +68,27 @@ describe("WebWorkspace « Panneaux » menu (#90)", () => {
|
||||
expect(screen.getByRole("button", { name: "Surface Travail" })).toBeTruthy();
|
||||
});
|
||||
|
||||
it("offers #204 open here/tab/window controls and falls back to a tab", async () => {
|
||||
const open = vi
|
||||
.spyOn(window, "open")
|
||||
.mockReturnValueOnce(null)
|
||||
.mockReturnValueOnce({ closed: false } as Window);
|
||||
renderPairedWebApp(await seededGateways());
|
||||
|
||||
fireEvent.click(await screen.findByText("Demo"));
|
||||
expect(await screen.findByText("Surface Travail")).toBeTruthy();
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "Fenêtre" }));
|
||||
|
||||
expect(open).toHaveBeenCalledTimes(2);
|
||||
expect(open.mock.calls[0][0]).toContain("webPanel=work");
|
||||
expect(open.mock.calls[0][1]).toBe("idea-web-work");
|
||||
expect(open.mock.calls[1][1]).toBe("_blank");
|
||||
expect(await screen.findByText("Fenêtre bloquée — ouverture en onglet.")).toBeTruthy();
|
||||
|
||||
open.mockRestore();
|
||||
});
|
||||
|
||||
it("only enables Projets while no project is open, with a help note", async () => {
|
||||
renderPairedWebApp(await seededGateways());
|
||||
|
||||
|
||||
71
frontend/src/features/web/WebSurfaceWindow.tsx
Normal file
71
frontend/src/features/web/WebSurfaceWindow.tsx
Normal file
@ -0,0 +1,71 @@
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
|
||||
import { Panel } from "@/shared";
|
||||
import { disconnectWebLive, getWebSession, type WebSession } from "@/adapters/http";
|
||||
import { PairingScreen } from "./PairingScreen";
|
||||
import { WebProjectPanelBody } from "./WebWorkspace";
|
||||
import { WEB_PANEL_META, type WebOpenablePanelId } from "./webSurfaceCatalog";
|
||||
import type { WebSurfaceWindowParams } from "./webSurfaceNavigation";
|
||||
|
||||
export interface WebSurfaceWindowProps {
|
||||
params: WebSurfaceWindowParams;
|
||||
session?: WebSession;
|
||||
}
|
||||
|
||||
export function WebSurfaceWindow({ params, session }: WebSurfaceWindowProps) {
|
||||
const webSession = session ?? getWebSession();
|
||||
const [paired, setPaired] = useState(() => webSession.isPaired());
|
||||
const [panel, setPanel] = useState<WebOpenablePanelId>(params.panel);
|
||||
const [focusSprintId, setFocusSprintId] = useState<string | null>(
|
||||
params.focusSprintId ?? null,
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
return webSession.onUnauthorized(() => {
|
||||
disconnectWebLive();
|
||||
setPaired(false);
|
||||
});
|
||||
}, [webSession]);
|
||||
|
||||
const viewSprintTickets = useCallback((sprintId: string) => {
|
||||
setFocusSprintId(sprintId);
|
||||
setPanel("tickets");
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="flex h-full flex-col bg-canvas text-content">
|
||||
<header className="flex shrink-0 items-center justify-between gap-3 border-b border-border bg-surface px-4 py-2.5">
|
||||
<div className="min-w-0">
|
||||
<h1 className="truncate text-sm font-semibold text-content">
|
||||
{WEB_PANEL_META[panel].label}
|
||||
</h1>
|
||||
<p className="truncate text-xs text-muted">{params.projectName}</p>
|
||||
</div>
|
||||
<span className="shrink-0 rounded-md bg-raised px-1.5 py-0.5 text-[0.65rem] font-medium uppercase text-muted">
|
||||
web
|
||||
</span>
|
||||
</header>
|
||||
<main className="min-h-0 flex-1 overflow-y-auto p-4 sm:p-5">
|
||||
{!paired ? (
|
||||
<div className="mx-auto w-full max-w-xl">
|
||||
<PairingScreen session={webSession} onPaired={() => setPaired(true)} />
|
||||
</div>
|
||||
) : params.projectId && params.projectRoot ? (
|
||||
<WebProjectPanelBody
|
||||
panel={panel}
|
||||
projectId={params.projectId}
|
||||
root={params.projectRoot}
|
||||
focusSprintId={focusSprintId}
|
||||
onViewSprintTickets={viewSprintTickets}
|
||||
/>
|
||||
) : (
|
||||
<Panel>
|
||||
<p className="text-sm text-muted">
|
||||
Cette surface web ne contient pas de projet valide.
|
||||
</p>
|
||||
</Panel>
|
||||
)}
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -1,14 +1,15 @@
|
||||
/**
|
||||
* Live web workspace — ticket #13/#86, extended by #90 (missing menus)
|
||||
* and #203 (desktop shell parity).
|
||||
* and #203/#204 (desktop shell parity + browser-openable surfaces).
|
||||
*
|
||||
* After pairing: list/create projects (web variant — no native folder browse,
|
||||
* see {@link WebProjectsPanel}), open one read-only, and route the main surface
|
||||
* through the **"Panneaux" menu** (#90) across the transport-neutral panels also
|
||||
* used by the desktop `ProjectsView` (context/tickets/sprints/agents/templates/
|
||||
* skills/permissions/memory/git/projects) plus the web-specific live work-state
|
||||
* view below. Selecting a panel replaces the main surface — never a floating
|
||||
* window or dock (desktop-only chrome, out of scope for web, per Architect).
|
||||
* view below. Selecting a panel replaces the main surface; project-scoped
|
||||
* surfaces can also be opened into browser tabs/windows (#204), without using
|
||||
* the desktop-only Tauri window gateway.
|
||||
*
|
||||
* The live work-state ("Travail") panel keeps its own bespoke, mobile-adapted
|
||||
* implementation rather than the desktop `ProjectWorkStatePanel`: that component's
|
||||
@ -46,6 +47,18 @@ import { WebTicketsView } from "./tickets/WebTicketsView";
|
||||
import { WebSprintsView } from "./tickets/WebSprintsView";
|
||||
import { WebProjectsPanel } from "./WebProjectsPanel";
|
||||
import { WebMenuSheet } from "./WebMenuSheet";
|
||||
import {
|
||||
WEB_PANEL_LABEL,
|
||||
WEB_PANEL_META,
|
||||
WEB_PANEL_ORDER,
|
||||
isWebOpenablePanel,
|
||||
type WebOpenablePanelId,
|
||||
type WebProjectPanelId,
|
||||
} from "./webSurfaceCatalog";
|
||||
import {
|
||||
openWebSurfaceInBrowser,
|
||||
type WebSurfaceOpenTarget,
|
||||
} from "./webSurfaceNavigation";
|
||||
|
||||
function describe(e: unknown): string {
|
||||
if (e && typeof e === "object" && "message" in e) {
|
||||
@ -54,135 +67,13 @@ function describe(e: unknown): string {
|
||||
return String(e);
|
||||
}
|
||||
|
||||
/** The surfaces reachable from the web shell navigation (#90/#203). */
|
||||
export type WebProjectPanelId =
|
||||
| "context"
|
||||
| "work"
|
||||
| "tickets"
|
||||
| "sprints"
|
||||
| "agents"
|
||||
| "templates"
|
||||
| "skills"
|
||||
| "permissions"
|
||||
| "memory"
|
||||
| "git"
|
||||
| "projects";
|
||||
|
||||
type WebPanelPlacement = "primary" | "secondary";
|
||||
|
||||
interface WebPanelMeta {
|
||||
id: WebProjectPanelId;
|
||||
label: string;
|
||||
shortLabel: string;
|
||||
description: string;
|
||||
placement: WebPanelPlacement;
|
||||
}
|
||||
|
||||
/** Menu order + French labels (ticket #78 — the human UI defaults to French). */
|
||||
const WEB_PANEL_ORDER: WebProjectPanelId[] = [
|
||||
"context",
|
||||
"work",
|
||||
"tickets",
|
||||
"sprints",
|
||||
"agents",
|
||||
"templates",
|
||||
"skills",
|
||||
"permissions",
|
||||
"memory",
|
||||
"git",
|
||||
"projects",
|
||||
];
|
||||
|
||||
const WEB_PANEL_META: Record<WebProjectPanelId, WebPanelMeta> = {
|
||||
context: {
|
||||
id: "context",
|
||||
label: "Contexte projet",
|
||||
shortLabel: "Contexte",
|
||||
description: "Contexte partagé du projet ouvert",
|
||||
placement: "secondary",
|
||||
},
|
||||
work: {
|
||||
id: "work",
|
||||
label: "Travail",
|
||||
shortLabel: "Travail",
|
||||
description: "État live des agents et tâches",
|
||||
placement: "primary",
|
||||
},
|
||||
tickets: {
|
||||
id: "tickets",
|
||||
label: "Tickets",
|
||||
shortLabel: "Tickets",
|
||||
description: "Suivi et détail des tickets",
|
||||
placement: "primary",
|
||||
},
|
||||
sprints: {
|
||||
id: "sprints",
|
||||
label: "Sprints",
|
||||
shortLabel: "Sprints",
|
||||
description: "Organisation des sprints",
|
||||
placement: "primary",
|
||||
},
|
||||
agents: {
|
||||
id: "agents",
|
||||
label: "Agents",
|
||||
shortLabel: "Agents",
|
||||
description: "Catalogue et lancement des agents",
|
||||
placement: "primary",
|
||||
},
|
||||
templates: {
|
||||
id: "templates",
|
||||
label: "Templates",
|
||||
shortLabel: "Templates",
|
||||
description: "Templates d'agents",
|
||||
placement: "secondary",
|
||||
},
|
||||
skills: {
|
||||
id: "skills",
|
||||
label: "Skills",
|
||||
shortLabel: "Skills",
|
||||
description: "Skills assignés et éditables",
|
||||
placement: "secondary",
|
||||
},
|
||||
permissions: {
|
||||
id: "permissions",
|
||||
label: "Permissions",
|
||||
shortLabel: "Perms",
|
||||
description: "Permissions MCP et sandbox",
|
||||
placement: "secondary",
|
||||
},
|
||||
memory: {
|
||||
id: "memory",
|
||||
label: "Mémoire",
|
||||
shortLabel: "Mémoire",
|
||||
description: "Mémoire projet et embedder",
|
||||
placement: "secondary",
|
||||
},
|
||||
git: {
|
||||
id: "git",
|
||||
label: "Git",
|
||||
shortLabel: "Git",
|
||||
description: "État Git du projet",
|
||||
placement: "primary",
|
||||
},
|
||||
projects: {
|
||||
id: "projects",
|
||||
label: "Projets",
|
||||
shortLabel: "Explorer",
|
||||
description: "Liste et création de projets",
|
||||
placement: "primary",
|
||||
},
|
||||
};
|
||||
|
||||
const WEB_PANEL_LABEL: Record<WebProjectPanelId, string> = Object.fromEntries(
|
||||
WEB_PANEL_ORDER.map((id) => [id, WEB_PANEL_META[id].label]),
|
||||
) as Record<WebProjectPanelId, string>;
|
||||
|
||||
export function WebWorkspace() {
|
||||
const { project } = useGateways();
|
||||
const [projects, setProjects] = useState<Project[] | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [openId, setOpenId] = useState<string | null>(null);
|
||||
const [panel, setPanel] = useState<WebProjectPanelId>("projects");
|
||||
const [openNotice, setOpenNotice] = useState<string | null>(null);
|
||||
// Set by the Sprints panel's "Voir tickets"; consumed once by the Tickets panel.
|
||||
const [focusSprintId, setFocusSprintId] = useState<string | null>(null);
|
||||
|
||||
@ -237,6 +128,29 @@ export function WebWorkspace() {
|
||||
[project, refresh],
|
||||
);
|
||||
|
||||
const openSurface = useCallback(
|
||||
(target: WebSurfaceOpenTarget) => {
|
||||
setOpenNotice(null);
|
||||
if (!openProject || !isWebOpenablePanel(panel)) return;
|
||||
const outcome = openWebSurfaceInBrowser(
|
||||
{
|
||||
panel,
|
||||
projectId: openProject.id,
|
||||
projectRoot: openProject.root,
|
||||
projectName: openProject.name,
|
||||
focusSprintId: panel === "tickets" ? focusSprintId : null,
|
||||
},
|
||||
target,
|
||||
);
|
||||
if (target === "window" && outcome.result === "tab") {
|
||||
setOpenNotice("Fenêtre bloquée — ouverture en onglet.");
|
||||
} else if (outcome.result === "blocked") {
|
||||
setOpenNotice("Le navigateur a bloqué l'ouverture de la surface.");
|
||||
}
|
||||
},
|
||||
[focusSprintId, openProject, panel],
|
||||
);
|
||||
|
||||
return (
|
||||
<div
|
||||
data-testid="web-workspace"
|
||||
@ -249,6 +163,9 @@ export function WebWorkspace() {
|
||||
openProject={openProject}
|
||||
projectCount={projects?.length ?? null}
|
||||
onShowProjects={() => setPanel("projects")}
|
||||
onOpenHere={() => setOpenNotice(null)}
|
||||
onOpenTab={() => openSurface("tab")}
|
||||
onOpenWindow={() => openSurface("window")}
|
||||
/>
|
||||
|
||||
<div className="flex min-h-0 flex-1 overflow-hidden md:grid md:grid-cols-[13rem_minmax(0,1fr)_20rem]">
|
||||
@ -271,6 +188,14 @@ export function WebWorkspace() {
|
||||
onSelect={setPanel}
|
||||
/>
|
||||
</div>
|
||||
{openNotice && (
|
||||
<div
|
||||
role="status"
|
||||
className="rounded-md border border-warning/40 bg-warning/10 px-3 py-2 text-xs text-warning"
|
||||
>
|
||||
{openNotice}
|
||||
</div>
|
||||
)}
|
||||
{error && <WebWorkspaceError message={error} />}
|
||||
<WebWorkspaceSurface
|
||||
panel={panel}
|
||||
@ -342,7 +267,7 @@ function WebWorkspaceSurface({
|
||||
}
|
||||
|
||||
return (
|
||||
<WebPanelBody
|
||||
<WebProjectPanelBody
|
||||
panel={panel}
|
||||
projectId={openId}
|
||||
root={openRoot}
|
||||
@ -365,12 +290,19 @@ function WebDesktopTopBar({
|
||||
openProject,
|
||||
projectCount,
|
||||
onShowProjects,
|
||||
onOpenHere,
|
||||
onOpenTab,
|
||||
onOpenWindow,
|
||||
}: {
|
||||
activePanel: WebProjectPanelId;
|
||||
openProject: Project | null;
|
||||
projectCount: number | null;
|
||||
onShowProjects: () => void;
|
||||
onOpenHere: () => void;
|
||||
onOpenTab: () => void;
|
||||
onOpenWindow: () => void;
|
||||
}) {
|
||||
const canOpenSurface = openProject !== null && isWebOpenablePanel(activePanel);
|
||||
return (
|
||||
<div
|
||||
data-testid="web-shell-topbar"
|
||||
@ -385,6 +317,12 @@ function WebDesktopTopBar({
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex shrink-0 items-center gap-2 text-xs text-muted">
|
||||
<WebSurfaceOpenControls
|
||||
disabled={!canOpenSurface}
|
||||
onOpenHere={onOpenHere}
|
||||
onOpenTab={onOpenTab}
|
||||
onOpenWindow={onOpenWindow}
|
||||
/>
|
||||
<span>
|
||||
{projectCount === null ? "Projets…" : `${projectCount} projet${projectCount > 1 ? "s" : ""}`}
|
||||
</span>
|
||||
@ -396,6 +334,54 @@ function WebDesktopTopBar({
|
||||
);
|
||||
}
|
||||
|
||||
function WebSurfaceOpenControls({
|
||||
disabled,
|
||||
onOpenHere,
|
||||
onOpenTab,
|
||||
onOpenWindow,
|
||||
}: {
|
||||
disabled: boolean;
|
||||
onOpenHere: () => void;
|
||||
onOpenTab: () => void;
|
||||
onOpenWindow: () => void;
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
aria-label="Ouvrir la surface"
|
||||
className="flex items-center overflow-hidden rounded-md border border-border bg-canvas"
|
||||
>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
aria-pressed="true"
|
||||
disabled={disabled}
|
||||
className="rounded-none border-r border-border"
|
||||
onClick={onOpenHere}
|
||||
>
|
||||
Ici
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
disabled={disabled}
|
||||
className="rounded-none border-r border-border"
|
||||
onClick={onOpenTab}
|
||||
>
|
||||
Onglet
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
disabled={disabled}
|
||||
className="rounded-none"
|
||||
onClick={onOpenWindow}
|
||||
>
|
||||
Fenêtre
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function WebPrimaryRail({
|
||||
activePanel,
|
||||
hasProject,
|
||||
@ -604,14 +590,14 @@ function WebPanelMenuButton({
|
||||
}
|
||||
|
||||
/** Renders the requested panel's body for the opened project. */
|
||||
function WebPanelBody({
|
||||
export function WebProjectPanelBody({
|
||||
panel,
|
||||
projectId,
|
||||
root,
|
||||
focusSprintId,
|
||||
onViewSprintTickets,
|
||||
}: {
|
||||
panel: Exclude<WebProjectPanelId, "projects">;
|
||||
panel: WebOpenablePanelId;
|
||||
projectId: string;
|
||||
root: string | null;
|
||||
focusSprintId: string | null;
|
||||
|
||||
@ -6,5 +6,11 @@
|
||||
export { WebApp } from "./WebApp";
|
||||
export { PairingScreen } from "./PairingScreen";
|
||||
export { WebWorkspace } from "./WebWorkspace";
|
||||
export { WebSurfaceWindow } from "./WebSurfaceWindow";
|
||||
export {
|
||||
buildWebSurfaceWindowUrl,
|
||||
openWebSurfaceInBrowser,
|
||||
parseWebSurfaceWindowParams,
|
||||
} from "./webSurfaceNavigation";
|
||||
export { WebAgentCell } from "./WebAgentCell";
|
||||
export { TerminalKeyBar } from "./TerminalKeyBar";
|
||||
|
||||
133
frontend/src/features/web/webSurfaceCatalog.ts
Normal file
133
frontend/src/features/web/webSurfaceCatalog.ts
Normal file
@ -0,0 +1,133 @@
|
||||
/** Shared catalogue for web shell surfaces (#203/#204). */
|
||||
|
||||
export type WebProjectPanelId =
|
||||
| "context"
|
||||
| "work"
|
||||
| "tickets"
|
||||
| "sprints"
|
||||
| "agents"
|
||||
| "templates"
|
||||
| "skills"
|
||||
| "permissions"
|
||||
| "memory"
|
||||
| "git"
|
||||
| "projects";
|
||||
|
||||
export type WebOpenablePanelId = Exclude<WebProjectPanelId, "projects">;
|
||||
|
||||
export type WebPanelPlacement = "primary" | "secondary";
|
||||
|
||||
export interface WebPanelMeta {
|
||||
id: WebProjectPanelId;
|
||||
label: string;
|
||||
shortLabel: string;
|
||||
description: string;
|
||||
placement: WebPanelPlacement;
|
||||
}
|
||||
|
||||
/** Menu order + French labels (ticket #78 — the human UI defaults to French). */
|
||||
export const WEB_PANEL_ORDER: WebProjectPanelId[] = [
|
||||
"context",
|
||||
"work",
|
||||
"tickets",
|
||||
"sprints",
|
||||
"agents",
|
||||
"templates",
|
||||
"skills",
|
||||
"permissions",
|
||||
"memory",
|
||||
"git",
|
||||
"projects",
|
||||
];
|
||||
|
||||
export const WEB_PANEL_META: Record<WebProjectPanelId, WebPanelMeta> = {
|
||||
context: {
|
||||
id: "context",
|
||||
label: "Contexte projet",
|
||||
shortLabel: "Contexte",
|
||||
description: "Contexte partagé du projet ouvert",
|
||||
placement: "secondary",
|
||||
},
|
||||
work: {
|
||||
id: "work",
|
||||
label: "Travail",
|
||||
shortLabel: "Travail",
|
||||
description: "État live des agents et tâches",
|
||||
placement: "primary",
|
||||
},
|
||||
tickets: {
|
||||
id: "tickets",
|
||||
label: "Tickets",
|
||||
shortLabel: "Tickets",
|
||||
description: "Suivi et détail des tickets",
|
||||
placement: "primary",
|
||||
},
|
||||
sprints: {
|
||||
id: "sprints",
|
||||
label: "Sprints",
|
||||
shortLabel: "Sprints",
|
||||
description: "Organisation des sprints",
|
||||
placement: "primary",
|
||||
},
|
||||
agents: {
|
||||
id: "agents",
|
||||
label: "Agents",
|
||||
shortLabel: "Agents",
|
||||
description: "Catalogue et lancement des agents",
|
||||
placement: "primary",
|
||||
},
|
||||
templates: {
|
||||
id: "templates",
|
||||
label: "Templates",
|
||||
shortLabel: "Templates",
|
||||
description: "Templates d'agents",
|
||||
placement: "secondary",
|
||||
},
|
||||
skills: {
|
||||
id: "skills",
|
||||
label: "Skills",
|
||||
shortLabel: "Skills",
|
||||
description: "Skills assignés et éditables",
|
||||
placement: "secondary",
|
||||
},
|
||||
permissions: {
|
||||
id: "permissions",
|
||||
label: "Permissions",
|
||||
shortLabel: "Perms",
|
||||
description: "Permissions MCP et sandbox",
|
||||
placement: "secondary",
|
||||
},
|
||||
memory: {
|
||||
id: "memory",
|
||||
label: "Mémoire",
|
||||
shortLabel: "Mémoire",
|
||||
description: "Mémoire projet et embedder",
|
||||
placement: "secondary",
|
||||
},
|
||||
git: {
|
||||
id: "git",
|
||||
label: "Git",
|
||||
shortLabel: "Git",
|
||||
description: "État Git du projet",
|
||||
placement: "primary",
|
||||
},
|
||||
projects: {
|
||||
id: "projects",
|
||||
label: "Projets",
|
||||
shortLabel: "Explorer",
|
||||
description: "Liste et création de projets",
|
||||
placement: "primary",
|
||||
},
|
||||
};
|
||||
|
||||
export const WEB_PANEL_LABEL: Record<WebProjectPanelId, string> = Object.fromEntries(
|
||||
WEB_PANEL_ORDER.map((id) => [id, WEB_PANEL_META[id].label]),
|
||||
) as Record<WebProjectPanelId, string>;
|
||||
|
||||
export const WEB_OPENABLE_PANEL_ORDER: WebOpenablePanelId[] = WEB_PANEL_ORDER.filter(
|
||||
(id): id is WebOpenablePanelId => id !== "projects",
|
||||
);
|
||||
|
||||
export function isWebOpenablePanel(panel: string): panel is WebOpenablePanelId {
|
||||
return (WEB_OPENABLE_PANEL_ORDER as string[]).includes(panel);
|
||||
}
|
||||
64
frontend/src/features/web/webSurfaceNavigation.test.ts
Normal file
64
frontend/src/features/web/webSurfaceNavigation.test.ts
Normal file
@ -0,0 +1,64 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
import {
|
||||
buildWebSurfaceWindowUrl,
|
||||
openWebSurfaceInBrowser,
|
||||
parseWebSurfaceWindowParams,
|
||||
} from "./webSurfaceNavigation";
|
||||
|
||||
describe("web surface navigation (#204)", () => {
|
||||
it("builds and parses a detached web surface URL", () => {
|
||||
const url = buildWebSurfaceWindowUrl({
|
||||
baseHref: "https://idea.local/workspace?old=1#ignored",
|
||||
panel: "tickets",
|
||||
projectId: "p-1",
|
||||
projectRoot: "/srv/demo project",
|
||||
projectName: "Demo",
|
||||
focusSprintId: "s-1",
|
||||
});
|
||||
|
||||
expect(url).toBe(
|
||||
"https://idea.local/workspace?webPanel=tickets&projectId=p-1&projectRoot=%2Fsrv%2Fdemo+project&projectName=Demo&focusSprintId=s-1",
|
||||
);
|
||||
expect(parseWebSurfaceWindowParams(new URL(url).search)).toEqual({
|
||||
panel: "tickets",
|
||||
projectId: "p-1",
|
||||
projectRoot: "/srv/demo project",
|
||||
projectName: "Demo",
|
||||
focusSprintId: "s-1",
|
||||
});
|
||||
});
|
||||
|
||||
it("rejects non-openable or incomplete detached surface URLs", () => {
|
||||
expect(parseWebSurfaceWindowParams("?webPanel=projects&projectId=p&projectRoot=/x&projectName=X")).toBeNull();
|
||||
expect(parseWebSurfaceWindowParams("?webPanel=tickets&projectId=p&projectRoot=/x")).toBeNull();
|
||||
});
|
||||
|
||||
it("falls back from browser window to tab when the popup is blocked", () => {
|
||||
const open = vi
|
||||
.fn((..._args: Parameters<Window["open"]>): ReturnType<Window["open"]> => null)
|
||||
.mockReturnValueOnce(null)
|
||||
.mockReturnValueOnce({ closed: false } as Window);
|
||||
|
||||
const outcome = openWebSurfaceInBrowser(
|
||||
{
|
||||
baseHref: "https://idea.local/",
|
||||
panel: "git",
|
||||
projectId: "p-1",
|
||||
projectRoot: "/srv/demo",
|
||||
projectName: "Demo",
|
||||
},
|
||||
"window",
|
||||
open,
|
||||
);
|
||||
|
||||
expect(outcome.result).toBe("tab");
|
||||
expect(open).toHaveBeenNthCalledWith(
|
||||
1,
|
||||
outcome.url,
|
||||
"idea-web-git",
|
||||
"popup,width=1280,height=900,noopener,noreferrer",
|
||||
);
|
||||
expect(open).toHaveBeenNthCalledWith(2, outcome.url, "_blank", "noopener,noreferrer");
|
||||
});
|
||||
});
|
||||
97
frontend/src/features/web/webSurfaceNavigation.ts
Normal file
97
frontend/src/features/web/webSurfaceNavigation.ts
Normal file
@ -0,0 +1,97 @@
|
||||
import {
|
||||
isWebOpenablePanel,
|
||||
type WebOpenablePanelId,
|
||||
} from "./webSurfaceCatalog";
|
||||
|
||||
export type WebSurfaceOpenTarget = "tab" | "window";
|
||||
export type WebSurfaceOpenResult = "tab" | "window" | "blocked";
|
||||
|
||||
export interface WebSurfaceWindowParams {
|
||||
panel: WebOpenablePanelId;
|
||||
projectId: string;
|
||||
projectRoot: string;
|
||||
projectName: string;
|
||||
focusSprintId?: string | null;
|
||||
}
|
||||
|
||||
export interface OpenWebSurfaceInput extends WebSurfaceWindowParams {
|
||||
baseHref?: string;
|
||||
}
|
||||
|
||||
export interface OpenWebSurfaceOutcome {
|
||||
result: WebSurfaceOpenResult;
|
||||
url: string;
|
||||
}
|
||||
|
||||
type BrowserOpen = Window["open"];
|
||||
|
||||
const QUERY_PANEL = "webPanel";
|
||||
const QUERY_PROJECT_ID = "projectId";
|
||||
const QUERY_PROJECT_ROOT = "projectRoot";
|
||||
const QUERY_PROJECT_NAME = "projectName";
|
||||
const QUERY_FOCUS_SPRINT = "focusSprintId";
|
||||
|
||||
export function parseWebSurfaceWindowParams(search: string): WebSurfaceWindowParams | null {
|
||||
const params = new URLSearchParams(search);
|
||||
const panel = params.get(QUERY_PANEL);
|
||||
const projectId = params.get(QUERY_PROJECT_ID);
|
||||
const projectRoot = params.get(QUERY_PROJECT_ROOT);
|
||||
const projectName = params.get(QUERY_PROJECT_NAME);
|
||||
|
||||
if (!panel || !isWebOpenablePanel(panel) || !projectId || !projectRoot || !projectName) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
panel,
|
||||
projectId,
|
||||
projectRoot,
|
||||
projectName,
|
||||
focusSprintId: params.get(QUERY_FOCUS_SPRINT),
|
||||
};
|
||||
}
|
||||
|
||||
export function buildWebSurfaceWindowUrl(input: OpenWebSurfaceInput): string {
|
||||
const href =
|
||||
input.baseHref ??
|
||||
(typeof window !== "undefined" && window.location
|
||||
? window.location.href
|
||||
: "http://localhost/");
|
||||
const url = new URL(href);
|
||||
url.hash = "";
|
||||
url.search = "";
|
||||
url.searchParams.set(QUERY_PANEL, input.panel);
|
||||
url.searchParams.set(QUERY_PROJECT_ID, input.projectId);
|
||||
url.searchParams.set(QUERY_PROJECT_ROOT, input.projectRoot);
|
||||
url.searchParams.set(QUERY_PROJECT_NAME, input.projectName);
|
||||
if (input.focusSprintId) {
|
||||
url.searchParams.set(QUERY_FOCUS_SPRINT, input.focusSprintId);
|
||||
}
|
||||
return url.toString();
|
||||
}
|
||||
|
||||
export function openWebSurfaceInBrowser(
|
||||
input: OpenWebSurfaceInput,
|
||||
target: WebSurfaceOpenTarget,
|
||||
open: BrowserOpen = window.open.bind(window),
|
||||
): OpenWebSurfaceOutcome {
|
||||
const url = buildWebSurfaceWindowUrl(input);
|
||||
if (target === "tab") {
|
||||
return {
|
||||
url,
|
||||
result: open(url, "_blank", "noopener,noreferrer") ? "tab" : "blocked",
|
||||
};
|
||||
}
|
||||
|
||||
const popup = open(
|
||||
url,
|
||||
`idea-web-${input.panel}`,
|
||||
"popup,width=1280,height=900,noopener,noreferrer",
|
||||
);
|
||||
if (popup) return { url, result: "window" };
|
||||
|
||||
return {
|
||||
url,
|
||||
result: open(url, "_blank", "noopener,noreferrer") ? "tab" : "blocked",
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user