feat(web): persist workspace presentation across browser hosts — #205
This commit is contained in:
@ -7,7 +7,7 @@
|
||||
* that selecting an entry routes the main surface without ever crashing on a
|
||||
* desktop-only action.
|
||||
*/
|
||||
import { describe, it, expect, vi } from "vitest";
|
||||
import { beforeEach, describe, it, expect, vi } from "vitest";
|
||||
import { fireEvent, render, screen, within } from "@testing-library/react";
|
||||
|
||||
import type { Gateways } from "@/ports";
|
||||
@ -34,6 +34,11 @@ const okFetch: FetchLike = async () => ({
|
||||
text: async () => "{}",
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
window.localStorage.clear();
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
async function seededGateways(): Promise<Gateways> {
|
||||
const gateways = createMockGateways();
|
||||
await gateways.project.createProject("Demo", "/srv/demo");
|
||||
@ -89,6 +94,24 @@ describe("WebWorkspace « Panneaux » menu (#90)", () => {
|
||||
open.mockRestore();
|
||||
});
|
||||
|
||||
it("restores the #205 presentation state per project", async () => {
|
||||
const gateways = await seededGateways();
|
||||
const first = renderPairedWebApp(gateways);
|
||||
|
||||
fireEvent.click(await screen.findByText("Demo"));
|
||||
fireEvent.click(await screen.findByRole("button", { name: "Surface Tickets" }));
|
||||
fireEvent.click(screen.getByRole("button", { name: "Rail gauche" }));
|
||||
fireEvent.click(screen.getByRole("button", { name: "Rail droit" }));
|
||||
|
||||
first.unmount();
|
||||
renderPairedWebApp(gateways);
|
||||
|
||||
fireEvent.click(await screen.findByText("Demo"));
|
||||
expect(await screen.findByText("Surface Tickets")).toBeTruthy();
|
||||
expect(screen.getByRole("button", { name: "Rail gauche" }).getAttribute("aria-pressed")).toBe("false");
|
||||
expect(screen.getByRole("button", { name: "Rail droit" }).getAttribute("aria-pressed")).toBe("false");
|
||||
});
|
||||
|
||||
it("only enables Projets while no project is open, with a help note", async () => {
|
||||
renderPairedWebApp(await seededGateways());
|
||||
|
||||
|
||||
@ -21,7 +21,7 @@
|
||||
* re-synchronises the read-model after a WS outage.
|
||||
*/
|
||||
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
|
||||
import type {
|
||||
AgentWorkState,
|
||||
@ -59,6 +59,15 @@ import {
|
||||
openWebSurfaceInBrowser,
|
||||
type WebSurfaceOpenTarget,
|
||||
} from "./webSurfaceNavigation";
|
||||
import {
|
||||
DEFAULT_WEB_WORKSPACE_PRESENTATION,
|
||||
loadWebWorkspacePresentation,
|
||||
saveWebWorkspacePresentation,
|
||||
withOpenedSurface,
|
||||
type WebSurfacePlacement,
|
||||
type WebSurfacePlacements,
|
||||
} from "./webWorkspaceSession";
|
||||
import { createWebWorkspaceSyncBus, type WebWorkspaceSyncProject } from "./webWorkspaceSync";
|
||||
|
||||
function describe(e: unknown): string {
|
||||
if (e && typeof e === "object" && "message" in e) {
|
||||
@ -74,6 +83,11 @@ export function WebWorkspace() {
|
||||
const [openId, setOpenId] = useState<string | null>(null);
|
||||
const [panel, setPanel] = useState<WebProjectPanelId>("projects");
|
||||
const [openNotice, setOpenNotice] = useState<string | null>(null);
|
||||
const [primaryRailOpen, setPrimaryRailOpen] = useState(true);
|
||||
const [secondaryRailOpen, setSecondaryRailOpen] = useState(true);
|
||||
const [openedSurfaces, setOpenedSurfaces] = useState<WebSurfacePlacements>({});
|
||||
const syncBus = useMemo(() => createWebWorkspaceSyncBus(), []);
|
||||
const projectsRef = useRef<Project[] | null>(null);
|
||||
// Set by the Sprints panel's "Voir tickets"; consumed once by the Tickets panel.
|
||||
const [focusSprintId, setFocusSprintId] = useState<string | null>(null);
|
||||
|
||||
@ -82,6 +96,33 @@ export function WebWorkspace() {
|
||||
() => projects?.find((p) => p.id === openId) ?? null,
|
||||
[openId, projects],
|
||||
);
|
||||
projectsRef.current = projects;
|
||||
|
||||
const loadPresentationForProject = useCallback((projectId: string) => {
|
||||
const saved = loadWebWorkspacePresentation(projectId);
|
||||
setPanel(saved.activePanel);
|
||||
setPrimaryRailOpen(saved.primaryRailOpen);
|
||||
setSecondaryRailOpen(saved.secondaryRailOpen);
|
||||
setOpenedSurfaces(saved.openedSurfaces);
|
||||
return saved;
|
||||
}, []);
|
||||
|
||||
const syncProject = useCallback((p: Project): WebWorkspaceSyncProject => ({
|
||||
id: p.id,
|
||||
name: p.name,
|
||||
root: p.root,
|
||||
}), []);
|
||||
|
||||
const publishFocus = useCallback(
|
||||
(p: Project, activePanel: WebProjectPanelId) => {
|
||||
syncBus.publish({
|
||||
type: "focus",
|
||||
project: syncProject(p),
|
||||
activePanel,
|
||||
});
|
||||
},
|
||||
[syncBus, syncProject],
|
||||
);
|
||||
|
||||
const refresh = useCallback(async () => {
|
||||
setError(null);
|
||||
@ -96,6 +137,47 @@ export function WebWorkspace() {
|
||||
void refresh();
|
||||
}, [refresh]);
|
||||
|
||||
useEffect(() => () => syncBus.close(), [syncBus]);
|
||||
|
||||
useEffect(() => {
|
||||
return syncBus.subscribe((event) => {
|
||||
if (event.type === "focus") {
|
||||
const knownProject = projectsRef.current?.find((p) => p.id === event.project.id);
|
||||
if (!knownProject) return;
|
||||
void project.openProject(knownProject.id).then(() => {
|
||||
setOpenId(knownProject.id);
|
||||
loadPresentationForProject(knownProject.id);
|
||||
setPanel(event.activePanel);
|
||||
}).catch(() => {});
|
||||
return;
|
||||
}
|
||||
const sameProjectOpen = openId === event.project.id;
|
||||
if (event.type === "surface-opened" && sameProjectOpen) {
|
||||
setOpenedSurfaces((cur) => withOpenedSurface(cur, event.panel, event.placement));
|
||||
}
|
||||
});
|
||||
}, [loadPresentationForProject, openId, project, syncBus]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!openProject) return;
|
||||
saveWebWorkspacePresentation(openProject.id, {
|
||||
version: DEFAULT_WEB_WORKSPACE_PRESENTATION.version,
|
||||
activePanel: panel === "projects" ? DEFAULT_WEB_WORKSPACE_PRESENTATION.activePanel : panel,
|
||||
primaryRailOpen,
|
||||
secondaryRailOpen,
|
||||
openedSurfaces,
|
||||
});
|
||||
}, [openProject, openedSurfaces, panel, primaryRailOpen, secondaryRailOpen]);
|
||||
|
||||
const selectPanel = useCallback(
|
||||
(nextPanel: WebProjectPanelId) => {
|
||||
setPanel(nextPanel);
|
||||
setOpenNotice(null);
|
||||
if (openProject) publishFocus(openProject, nextPanel);
|
||||
},
|
||||
[openProject, publishFocus],
|
||||
);
|
||||
|
||||
const openReadOnly = useCallback(
|
||||
async (projectId: string) => {
|
||||
setError(null);
|
||||
@ -104,13 +186,15 @@ export function WebWorkspace() {
|
||||
// Read-only: resolve the project server-side; the live panel then streams
|
||||
// its work-state. No layout/PTY is mounted here.
|
||||
await project.openProject(projectId);
|
||||
const saved = loadPresentationForProject(projectId);
|
||||
setOpenId(projectId);
|
||||
setPanel("work");
|
||||
const nextProject = projectsRef.current?.find((p) => p.id === projectId);
|
||||
if (nextProject) publishFocus(nextProject, saved.activePanel);
|
||||
} catch (e) {
|
||||
setError(describe(e));
|
||||
}
|
||||
},
|
||||
[project],
|
||||
[loadPresentationForProject, project, publishFocus],
|
||||
);
|
||||
|
||||
const createProject = useCallback(
|
||||
@ -142,13 +226,23 @@ export function WebWorkspace() {
|
||||
},
|
||||
target,
|
||||
);
|
||||
if (outcome.result === "tab" || outcome.result === "window") {
|
||||
const placement: WebSurfacePlacement = outcome.result;
|
||||
setOpenedSurfaces((cur) => withOpenedSurface(cur, panel, placement));
|
||||
syncBus.publish({
|
||||
type: "surface-opened",
|
||||
project: syncProject(openProject),
|
||||
panel,
|
||||
placement,
|
||||
});
|
||||
}
|
||||
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],
|
||||
[focusSprintId, openProject, panel, syncBus, syncProject],
|
||||
);
|
||||
|
||||
return (
|
||||
@ -162,17 +256,30 @@ export function WebWorkspace() {
|
||||
activePanel={panel}
|
||||
openProject={openProject}
|
||||
projectCount={projects?.length ?? null}
|
||||
onShowProjects={() => setPanel("projects")}
|
||||
primaryRailOpen={primaryRailOpen}
|
||||
secondaryRailOpen={secondaryRailOpen}
|
||||
onTogglePrimaryRail={() => setPrimaryRailOpen((open) => !open)}
|
||||
onToggleSecondaryRail={() => setSecondaryRailOpen((open) => !open)}
|
||||
onShowProjects={() => selectPanel("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]">
|
||||
<div
|
||||
className={cn(
|
||||
"flex min-h-0 flex-1 overflow-hidden md:grid",
|
||||
primaryRailOpen && secondaryRailOpen && "md:grid-cols-[13rem_minmax(0,1fr)_20rem]",
|
||||
primaryRailOpen && !secondaryRailOpen && "md:grid-cols-[13rem_minmax(0,1fr)]",
|
||||
!primaryRailOpen && secondaryRailOpen && "md:grid-cols-[minmax(0,1fr)_20rem]",
|
||||
!primaryRailOpen && !secondaryRailOpen && "md:grid-cols-[minmax(0,1fr)]",
|
||||
)}
|
||||
>
|
||||
<WebPrimaryRail
|
||||
activePanel={panel}
|
||||
hasProject={Boolean(openId)}
|
||||
onSelect={setPanel}
|
||||
open={primaryRailOpen}
|
||||
onSelect={selectPanel}
|
||||
/>
|
||||
|
||||
<main
|
||||
@ -185,7 +292,7 @@ export function WebWorkspace() {
|
||||
<WebPanelMenuButton
|
||||
activePanel={panel}
|
||||
hasProject={Boolean(openId)}
|
||||
onSelect={setPanel}
|
||||
onSelect={selectPanel}
|
||||
/>
|
||||
</div>
|
||||
{openNotice && (
|
||||
@ -208,7 +315,7 @@ export function WebWorkspace() {
|
||||
onRefreshProjects={() => void refresh()}
|
||||
onViewSprintTickets={(sprintId) => {
|
||||
setFocusSprintId(sprintId);
|
||||
setPanel("tickets");
|
||||
selectPanel("tickets");
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
@ -219,9 +326,12 @@ export function WebWorkspace() {
|
||||
openId={openId}
|
||||
openProject={openProject}
|
||||
activePanel={panel}
|
||||
open={secondaryRailOpen}
|
||||
openedSurfaces={openedSurfaces}
|
||||
onOpenProject={(id) => void openReadOnly(id)}
|
||||
onRefreshProjects={() => void refresh()}
|
||||
onSelectPanel={setPanel}
|
||||
onSelectPanel={selectPanel}
|
||||
onOpenSurfaceHere={selectPanel}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
@ -289,6 +399,10 @@ function WebDesktopTopBar({
|
||||
activePanel,
|
||||
openProject,
|
||||
projectCount,
|
||||
primaryRailOpen,
|
||||
secondaryRailOpen,
|
||||
onTogglePrimaryRail,
|
||||
onToggleSecondaryRail,
|
||||
onShowProjects,
|
||||
onOpenHere,
|
||||
onOpenTab,
|
||||
@ -297,6 +411,10 @@ function WebDesktopTopBar({
|
||||
activePanel: WebProjectPanelId;
|
||||
openProject: Project | null;
|
||||
projectCount: number | null;
|
||||
primaryRailOpen: boolean;
|
||||
secondaryRailOpen: boolean;
|
||||
onTogglePrimaryRail: () => void;
|
||||
onToggleSecondaryRail: () => void;
|
||||
onShowProjects: () => void;
|
||||
onOpenHere: () => void;
|
||||
onOpenTab: () => void;
|
||||
@ -317,6 +435,22 @@ function WebDesktopTopBar({
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex shrink-0 items-center gap-2 text-xs text-muted">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
aria-pressed={primaryRailOpen}
|
||||
onClick={onTogglePrimaryRail}
|
||||
>
|
||||
Rail gauche
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
aria-pressed={secondaryRailOpen}
|
||||
onClick={onToggleSecondaryRail}
|
||||
>
|
||||
Rail droit
|
||||
</Button>
|
||||
<WebSurfaceOpenControls
|
||||
disabled={!canOpenSurface}
|
||||
onOpenHere={onOpenHere}
|
||||
@ -385,17 +519,19 @@ function WebSurfaceOpenControls({
|
||||
function WebPrimaryRail({
|
||||
activePanel,
|
||||
hasProject,
|
||||
open,
|
||||
onSelect,
|
||||
}: {
|
||||
activePanel: WebProjectPanelId;
|
||||
hasProject: boolean;
|
||||
open: boolean;
|
||||
onSelect: (panel: WebProjectPanelId) => void;
|
||||
}) {
|
||||
return (
|
||||
<nav
|
||||
data-testid="web-shell-primary-rail"
|
||||
aria-label="Surfaces principales"
|
||||
className="hidden min-h-0 flex-col overflow-y-auto bg-surface p-2 md:flex"
|
||||
className={cn("hidden min-h-0 flex-col overflow-y-auto bg-surface p-2", open && "md:flex")}
|
||||
>
|
||||
<div className="px-2 pb-2 pt-1 text-[11px] font-semibold uppercase text-faint">
|
||||
Surfaces
|
||||
@ -443,26 +579,35 @@ function WebSecondaryRail({
|
||||
openId,
|
||||
openProject,
|
||||
activePanel,
|
||||
open,
|
||||
openedSurfaces,
|
||||
onOpenProject,
|
||||
onRefreshProjects,
|
||||
onSelectPanel,
|
||||
onOpenSurfaceHere,
|
||||
}: {
|
||||
projects: Project[] | null;
|
||||
openId: string | null;
|
||||
openProject: Project | null;
|
||||
activePanel: WebProjectPanelId;
|
||||
open: boolean;
|
||||
openedSurfaces: WebSurfacePlacements;
|
||||
onOpenProject: (projectId: string) => void;
|
||||
onRefreshProjects: () => void;
|
||||
onSelectPanel: (panel: WebProjectPanelId) => void;
|
||||
onOpenSurfaceHere: (panel: WebProjectPanelId) => void;
|
||||
}) {
|
||||
const secondaryPanels = WEB_PANEL_ORDER.filter(
|
||||
(id) => WEB_PANEL_META[id].placement === "secondary",
|
||||
);
|
||||
const openedSurfaceEntries = Object.entries(openedSurfaces) as Array<
|
||||
[WebOpenablePanelId, WebSurfacePlacement]
|
||||
>;
|
||||
|
||||
return (
|
||||
<aside
|
||||
data-testid="web-shell-secondary-rail"
|
||||
className="hidden min-h-0 flex-col gap-3 overflow-y-auto bg-surface p-3 md:flex"
|
||||
className={cn("hidden min-h-0 flex-col gap-3 overflow-y-auto bg-surface p-3", open && "md:flex")}
|
||||
>
|
||||
<section className="rounded-md border border-border bg-canvas p-3">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
@ -542,6 +687,30 @@ function WebSecondaryRail({
|
||||
})}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="rounded-md border border-border bg-canvas p-3">
|
||||
<h2 className="text-xs font-semibold uppercase text-faint">Vues ouvertes</h2>
|
||||
{openedSurfaceEntries.length === 0 ? (
|
||||
<p className="mt-2 text-xs text-muted">Aucune vue ouverte.</p>
|
||||
) : (
|
||||
<ul className="mt-2 flex flex-col gap-1.5">
|
||||
{openedSurfaceEntries.map(([surface, placement]) => (
|
||||
<li key={surface}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onOpenSurfaceHere(surface)}
|
||||
className="flex w-full items-center justify-between gap-2 rounded-md px-2.5 py-2 text-left text-xs text-muted transition-colors hover:bg-raised hover:text-content"
|
||||
>
|
||||
<span>{WEB_PANEL_META[surface].label}</span>
|
||||
<span className="shrink-0 rounded bg-raised px-1.5 py-0.5 text-[11px] text-faint">
|
||||
{placement === "window" ? "fenêtre" : "onglet"}
|
||||
</span>
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</section>
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
|
||||
48
frontend/src/features/web/webWorkspaceSession.test.ts
Normal file
48
frontend/src/features/web/webWorkspaceSession.test.ts
Normal file
@ -0,0 +1,48 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import {
|
||||
DEFAULT_WEB_WORKSPACE_PRESENTATION,
|
||||
parseWebWorkspacePresentationState,
|
||||
withOpenedSurface,
|
||||
} from "./webWorkspaceSession";
|
||||
|
||||
describe("web workspace presentation persistence (#205)", () => {
|
||||
it("parses the persisted presentation state and filters unknown opened surfaces", () => {
|
||||
expect(
|
||||
parseWebWorkspacePresentationState(
|
||||
JSON.stringify({
|
||||
version: 1,
|
||||
activePanel: "tickets",
|
||||
primaryRailOpen: false,
|
||||
secondaryRailOpen: true,
|
||||
openedSurfaces: {
|
||||
tickets: "tab",
|
||||
git: "window",
|
||||
projects: "tab",
|
||||
bogus: "window",
|
||||
agents: "dock",
|
||||
},
|
||||
}),
|
||||
),
|
||||
).toEqual({
|
||||
version: 1,
|
||||
activePanel: "tickets",
|
||||
primaryRailOpen: false,
|
||||
secondaryRailOpen: true,
|
||||
openedSurfaces: {
|
||||
tickets: "tab",
|
||||
git: "window",
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("falls back to null for invalid or old state", () => {
|
||||
expect(parseWebWorkspacePresentationState("not-json")).toBeNull();
|
||||
expect(parseWebWorkspacePresentationState(JSON.stringify({ version: 0 }))).toBeNull();
|
||||
});
|
||||
|
||||
it("records only openable project surfaces", () => {
|
||||
expect(withOpenedSurface({}, "tickets", "tab")).toEqual({ tickets: "tab" });
|
||||
expect(withOpenedSurface(DEFAULT_WEB_WORKSPACE_PRESENTATION.openedSurfaces, "projects", "tab")).toEqual({});
|
||||
});
|
||||
});
|
||||
102
frontend/src/features/web/webWorkspaceSession.ts
Normal file
102
frontend/src/features/web/webWorkspaceSession.ts
Normal file
@ -0,0 +1,102 @@
|
||||
import {
|
||||
isWebOpenablePanel,
|
||||
WEB_OPENABLE_PANEL_ORDER,
|
||||
WEB_PANEL_ORDER,
|
||||
type WebOpenablePanelId,
|
||||
type WebProjectPanelId,
|
||||
} from "./webSurfaceCatalog";
|
||||
import type { WebSurfaceOpenResult } from "./webSurfaceNavigation";
|
||||
|
||||
const VERSION = 1;
|
||||
const KEY_PREFIX = "idea:web-workspace:";
|
||||
|
||||
export type WebSurfacePlacement = Extract<WebSurfaceOpenResult, "tab" | "window">;
|
||||
|
||||
export type WebSurfacePlacements = Partial<Record<WebOpenablePanelId, WebSurfacePlacement>>;
|
||||
|
||||
export interface WebWorkspacePresentationState {
|
||||
version: typeof VERSION;
|
||||
activePanel: WebProjectPanelId;
|
||||
primaryRailOpen: boolean;
|
||||
secondaryRailOpen: boolean;
|
||||
openedSurfaces: WebSurfacePlacements;
|
||||
}
|
||||
|
||||
export const DEFAULT_WEB_WORKSPACE_PRESENTATION: WebWorkspacePresentationState = {
|
||||
version: VERSION,
|
||||
activePanel: "work",
|
||||
primaryRailOpen: true,
|
||||
secondaryRailOpen: true,
|
||||
openedSurfaces: {},
|
||||
};
|
||||
|
||||
function storageKey(projectId: string): string {
|
||||
return `${KEY_PREFIX}${projectId}`;
|
||||
}
|
||||
|
||||
function readStorage(): Storage | null {
|
||||
return typeof window === "undefined" ? null : window.localStorage;
|
||||
}
|
||||
|
||||
function isWebProjectPanel(value: unknown): value is WebProjectPanelId {
|
||||
return typeof value === "string" && (WEB_PANEL_ORDER as string[]).includes(value);
|
||||
}
|
||||
|
||||
function parseOpenedSurfaces(value: unknown): WebSurfacePlacements {
|
||||
if (!value || typeof value !== "object") return {};
|
||||
const next: WebSurfacePlacements = {};
|
||||
for (const panel of WEB_OPENABLE_PANEL_ORDER) {
|
||||
const placement = (value as Record<string, unknown>)[panel];
|
||||
if (placement === "tab" || placement === "window") {
|
||||
next[panel] = placement;
|
||||
}
|
||||
}
|
||||
return next;
|
||||
}
|
||||
|
||||
export function parseWebWorkspacePresentationState(
|
||||
value: string | null,
|
||||
): WebWorkspacePresentationState | null {
|
||||
if (!value) return null;
|
||||
try {
|
||||
const parsed = JSON.parse(value) as Record<string, unknown>;
|
||||
if (parsed.version !== VERSION) return null;
|
||||
const activePanel = parsed.activePanel;
|
||||
return {
|
||||
version: VERSION,
|
||||
activePanel: isWebProjectPanel(activePanel) ? activePanel : "work",
|
||||
primaryRailOpen:
|
||||
typeof parsed.primaryRailOpen === "boolean" ? parsed.primaryRailOpen : true,
|
||||
secondaryRailOpen:
|
||||
typeof parsed.secondaryRailOpen === "boolean" ? parsed.secondaryRailOpen : true,
|
||||
openedSurfaces: parseOpenedSurfaces(parsed.openedSurfaces),
|
||||
};
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function loadWebWorkspacePresentation(
|
||||
projectId: string,
|
||||
): WebWorkspacePresentationState {
|
||||
const parsed = parseWebWorkspacePresentationState(
|
||||
readStorage()?.getItem(storageKey(projectId)) ?? null,
|
||||
);
|
||||
return parsed ?? DEFAULT_WEB_WORKSPACE_PRESENTATION;
|
||||
}
|
||||
|
||||
export function saveWebWorkspacePresentation(
|
||||
projectId: string,
|
||||
state: WebWorkspacePresentationState,
|
||||
): void {
|
||||
readStorage()?.setItem(storageKey(projectId), JSON.stringify(state));
|
||||
}
|
||||
|
||||
export function withOpenedSurface(
|
||||
state: WebSurfacePlacements,
|
||||
panel: WebProjectPanelId,
|
||||
placement: WebSurfacePlacement,
|
||||
): WebSurfacePlacements {
|
||||
if (!isWebOpenablePanel(panel)) return state;
|
||||
return { ...state, [panel]: placement };
|
||||
}
|
||||
62
frontend/src/features/web/webWorkspaceSync.test.ts
Normal file
62
frontend/src/features/web/webWorkspaceSync.test.ts
Normal file
@ -0,0 +1,62 @@
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { createWebWorkspaceSyncBus } from "./webWorkspaceSync";
|
||||
|
||||
class FakeBroadcastChannel {
|
||||
static channels = new Map<string, Set<FakeBroadcastChannel>>();
|
||||
onmessage: ((event: MessageEvent) => void) | null = null;
|
||||
|
||||
constructor(private readonly name: string) {
|
||||
const peers = FakeBroadcastChannel.channels.get(name) ?? new Set();
|
||||
peers.add(this);
|
||||
FakeBroadcastChannel.channels.set(name, peers);
|
||||
}
|
||||
|
||||
postMessage(data: unknown): void {
|
||||
for (const peer of FakeBroadcastChannel.channels.get(this.name) ?? []) {
|
||||
if (peer !== this) {
|
||||
peer.onmessage?.({ data } as MessageEvent);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
close(): void {
|
||||
FakeBroadcastChannel.channels.get(this.name)?.delete(this);
|
||||
}
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
FakeBroadcastChannel.channels.clear();
|
||||
});
|
||||
|
||||
describe("web workspace sync bus (#205)", () => {
|
||||
it("broadcasts focus changes to other browser hosts, not itself", () => {
|
||||
vi.stubGlobal("BroadcastChannel", FakeBroadcastChannel);
|
||||
const first = createWebWorkspaceSyncBus();
|
||||
const second = createWebWorkspaceSyncBus();
|
||||
const firstHandler = vi.fn();
|
||||
const secondHandler = vi.fn();
|
||||
first.subscribe(firstHandler);
|
||||
second.subscribe(secondHandler);
|
||||
|
||||
first.publish({
|
||||
type: "focus",
|
||||
project: { id: "p-1", name: "Demo", root: "/srv/demo" },
|
||||
activePanel: "tickets",
|
||||
});
|
||||
|
||||
expect(firstHandler).not.toHaveBeenCalled();
|
||||
expect(secondHandler).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
type: "focus",
|
||||
sourceId: first.sourceId,
|
||||
project: { id: "p-1", name: "Demo", root: "/srv/demo" },
|
||||
activePanel: "tickets",
|
||||
}),
|
||||
);
|
||||
|
||||
first.close();
|
||||
second.close();
|
||||
});
|
||||
});
|
||||
125
frontend/src/features/web/webWorkspaceSync.ts
Normal file
125
frontend/src/features/web/webWorkspaceSync.ts
Normal file
@ -0,0 +1,125 @@
|
||||
import type { WebOpenablePanelId, WebProjectPanelId } from "./webSurfaceCatalog";
|
||||
import type { WebSurfacePlacement } from "./webWorkspaceSession";
|
||||
|
||||
const CHANNEL_NAME = "idea:web-workspace-sync:v1";
|
||||
const STORAGE_KEY = "idea:web-workspace-sync:last";
|
||||
|
||||
export interface WebWorkspaceSyncProject {
|
||||
id: string;
|
||||
name: string;
|
||||
root: string;
|
||||
}
|
||||
|
||||
export type WebWorkspaceSyncEvent =
|
||||
| {
|
||||
type: "focus";
|
||||
sourceId: string;
|
||||
project: WebWorkspaceSyncProject;
|
||||
activePanel: WebProjectPanelId;
|
||||
at: number;
|
||||
}
|
||||
| {
|
||||
type: "surface-opened";
|
||||
sourceId: string;
|
||||
project: WebWorkspaceSyncProject;
|
||||
panel: WebOpenablePanelId;
|
||||
placement: WebSurfacePlacement;
|
||||
at: number;
|
||||
};
|
||||
|
||||
export type WebWorkspaceSyncPublishEvent =
|
||||
| Omit<Extract<WebWorkspaceSyncEvent, { type: "focus" }>, "sourceId" | "at">
|
||||
| Omit<Extract<WebWorkspaceSyncEvent, { type: "surface-opened" }>, "sourceId" | "at">;
|
||||
|
||||
export interface WebWorkspaceSyncBus {
|
||||
sourceId: string;
|
||||
publish(event: WebWorkspaceSyncPublishEvent): void;
|
||||
subscribe(handler: (event: WebWorkspaceSyncEvent) => void): () => void;
|
||||
close(): void;
|
||||
}
|
||||
|
||||
function makeSourceId(): string {
|
||||
const cryptoApi = typeof crypto !== "undefined" ? crypto : null;
|
||||
return cryptoApi?.randomUUID?.() ?? `web-${Date.now()}-${Math.random().toString(36).slice(2)}`;
|
||||
}
|
||||
|
||||
function parseSyncEvent(value: unknown): WebWorkspaceSyncEvent | null {
|
||||
if (!value || typeof value !== "object") return null;
|
||||
const event = value as Partial<WebWorkspaceSyncEvent>;
|
||||
if (
|
||||
typeof event.sourceId !== "string" ||
|
||||
typeof event.at !== "number" ||
|
||||
!event.project ||
|
||||
typeof event.project !== "object"
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
if (event.type === "focus" && typeof event.activePanel === "string") {
|
||||
return event as WebWorkspaceSyncEvent;
|
||||
}
|
||||
if (
|
||||
event.type === "surface-opened" &&
|
||||
typeof event.panel === "string" &&
|
||||
(event.placement === "tab" || event.placement === "window")
|
||||
) {
|
||||
return event as WebWorkspaceSyncEvent;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export function createWebWorkspaceSyncBus(): WebWorkspaceSyncBus {
|
||||
const sourceId = makeSourceId();
|
||||
const handlers = new Set<(event: WebWorkspaceSyncEvent) => void>();
|
||||
const channel =
|
||||
typeof BroadcastChannel !== "undefined" ? new BroadcastChannel(CHANNEL_NAME) : null;
|
||||
|
||||
const dispatch = (event: WebWorkspaceSyncEvent) => {
|
||||
if (event.sourceId === sourceId) return;
|
||||
for (const handler of handlers) handler(event);
|
||||
};
|
||||
|
||||
const onStorage = (event: StorageEvent) => {
|
||||
if (event.key !== STORAGE_KEY || !event.newValue) return;
|
||||
try {
|
||||
dispatch(JSON.parse(event.newValue) as WebWorkspaceSyncEvent);
|
||||
} catch {
|
||||
// Ignore malformed cross-tab noise.
|
||||
}
|
||||
};
|
||||
|
||||
if (channel) {
|
||||
channel.onmessage = (event: MessageEvent) => {
|
||||
const parsed = parseSyncEvent(event.data);
|
||||
if (parsed) dispatch(parsed);
|
||||
};
|
||||
} else if (typeof window !== "undefined") {
|
||||
window.addEventListener("storage", onStorage);
|
||||
}
|
||||
|
||||
return {
|
||||
sourceId,
|
||||
publish(event) {
|
||||
const payload = { ...event, sourceId, at: Date.now() } as WebWorkspaceSyncEvent;
|
||||
if (channel) {
|
||||
channel.postMessage(payload);
|
||||
return;
|
||||
}
|
||||
if (typeof window !== "undefined") {
|
||||
window.localStorage.setItem(STORAGE_KEY, JSON.stringify(payload));
|
||||
}
|
||||
},
|
||||
subscribe(handler) {
|
||||
handlers.add(handler);
|
||||
return () => {
|
||||
handlers.delete(handler);
|
||||
};
|
||||
},
|
||||
close() {
|
||||
handlers.clear();
|
||||
channel?.close();
|
||||
if (!channel && typeof window !== "undefined") {
|
||||
window.removeEventListener("storage", onStorage);
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user