From 1b7ec1f54caef0fe0899f03504d0973b8702ef71 Mon Sep 17 00:00:00 2001 From: Miguel Palhas Date: Wed, 9 Sep 2026 01:21:37 +0100 Subject: [PATCH] feat(maestro): port layout system (tabs, splits, multiview) to React Closes #249: layoutStore.ts wraps the existing layout.ts tree model in Zustand, persisting to localStorage on a 400ms debounce and adding boot reconciliation to prune leaves for sessions no longer live. MultiView/TabBar/PaneContainer/PaneTitle render tabs, nested splits with draggable dividers, drag-and-drop tab reordering, and dispatch each pane to TerminalPane or StructuredPane by session view. Preview mode (#251) and sidebar drag-to-dock stay out of scope here. --- web/src/components/layout/MultiView.test.tsx | 96 +++++++ web/src/components/layout/MultiView.tsx | 203 ++++++++++++++ .../components/layout/PaneContainer.test.tsx | 54 ++++ web/src/components/layout/PaneContainer.tsx | 49 ++++ web/src/components/layout/PaneTitle.test.tsx | 48 ++++ web/src/components/layout/PaneTitle.tsx | 39 +++ web/src/components/layout/TabBar.test.tsx | 88 ++++++ web/src/components/layout/TabBar.tsx | 91 +++++++ web/src/stores/layoutStore.test.ts | 162 +++++++++++ web/src/stores/layoutStore.ts | 253 ++++++++++++++++++ 10 files changed, 1083 insertions(+) create mode 100644 web/src/components/layout/MultiView.test.tsx create mode 100644 web/src/components/layout/MultiView.tsx create mode 100644 web/src/components/layout/PaneContainer.test.tsx create mode 100644 web/src/components/layout/PaneContainer.tsx create mode 100644 web/src/components/layout/PaneTitle.test.tsx create mode 100644 web/src/components/layout/PaneTitle.tsx create mode 100644 web/src/components/layout/TabBar.test.tsx create mode 100644 web/src/components/layout/TabBar.tsx create mode 100644 web/src/stores/layoutStore.test.ts create mode 100644 web/src/stores/layoutStore.ts diff --git a/web/src/components/layout/MultiView.test.tsx b/web/src/components/layout/MultiView.test.tsx new file mode 100644 index 0000000..1b49c4c --- /dev/null +++ b/web/src/components/layout/MultiView.test.tsx @@ -0,0 +1,96 @@ +// @vitest-environment happy-dom +import { cleanup, fireEvent, render, screen } from "@testing-library/react"; +import { afterEach, describe, expect, it, vi } from "vitest"; + +import { leaves } from "../../layout.js"; +import { createLayoutStore } from "../../stores/layoutStore.js"; +import type { Session } from "../../types.js"; +import { MultiView } from "./MultiView.js"; + +vi.mock("../terminal/TerminalPane.js", () => ({ + TerminalPane: ({ sessionId }: { sessionId: string }) =>
, +})); + +vi.mock("../structured/StructuredPane.js", () => ({ + StructuredPane: ({ sessionId }: { sessionId: string }) =>
, +})); + +afterEach(cleanup); + +function store() { + return createLayoutStore({ + load: () => null, + save: vi.fn(), + scheduler: { setTimer: () => 1, clearTimer: () => {} }, + }); +} + +function session(id: string, over: Partial = {}): Session { + return { + id, + cwd: "/home/n", + command: "claude", + env: {}, + metadata: {}, + status: "Running", + created_at: "2026-01-01T00:00:00Z", + ...over, + } as Session; +} + +describe("MultiView", () => { + it("shows the empty state when the active tab has no panes", () => { + const s = store(); + render(); + expect(screen.getByText(/No session open/)).toBeTruthy(); + }); + + it("renders a pane per leaf, dispatching to the terminal or structured body", () => { + const s = store(); + s.getState().openSession("s1"); + s.getState().openSessionBeside("s2"); + const sessions = new Map([ + ["s1", session("s1")], + ["s2", session("s2", { view: "Structured" })], + ]); + render(); + expect(screen.getByTestId("terminal-s1")).toBeTruthy(); + expect(screen.getByTestId("structured-s2")).toBeTruthy(); + }); + + it("split right adds a second pane to the tree", () => { + const s = store(); + s.getState().openSession("s1"); + render(); + fireEvent.click(screen.getByTitle("Split right")); + expect(screen.getAllByText("Empty pane")).toHaveLength(1); + }); + + it("close pane removes it", () => { + const s = store(); + s.getState().openSession("s1"); + const leafId = leaves(s.getState().layout.tabs[0].root)[0].id; + s.getState().splitFocused(leafId, "row"); // adds an empty leaf beside s1 + render(); + // splitFocused(after: true) puts the fresh empty leaf second — close it. + fireEvent.click(screen.getAllByTitle("Close pane")[1]); + expect(screen.queryByText("Empty pane")).toBeNull(); + expect(screen.getByTestId("terminal-s1")).toBeTruthy(); + }); + + it("prunes a leaf whose session no longer exists, once the live set is known", () => { + const s = store(); + s.getState().openSession("s1"); + s.getState().openSessionBeside("dead"); + render(); + expect(screen.getByTestId("terminal-s1")).toBeTruthy(); + expect(screen.queryByTestId("terminal-dead")).toBeNull(); + }); + + it("does not touch the layout while the live set is still unknown", () => { + const s = store(); + s.getState().openSession("s1"); + render(); + expect(screen.getByTestId("terminal-s1")).toBeTruthy(); + }); +}); diff --git a/web/src/components/layout/MultiView.tsx b/web/src/components/layout/MultiView.tsx new file mode 100644 index 0000000..de7acf8 --- /dev/null +++ b/web/src/components/layout/MultiView.tsx @@ -0,0 +1,203 @@ +// React port of multiview.ts's tab + split tree (issue #249). Preview mode +// (docs/PREVIEW.md) is issue #251; sidebar drag-to-dock and the mobile +// single-pane collapse stay with whichever issue wires this into the shell +// (#253) — this renders the tree itself: tabs, nested splits, pane chrome. + +import type { ReactNode } from "react"; +import { useEffect, useRef } from "react"; +import { useStore } from "zustand"; + +import { activeTab, type Dir, type Node } from "../../layout.js"; +import type { LayoutStore, LayoutStoreApi } from "../../stores/layoutStore.js"; +import type { Session, Uuid } from "../../types.js"; +import { PaneContainer } from "./PaneContainer.js"; +import { PaneTitle } from "./PaneTitle.js"; +import { TabBar } from "./TabBar.js"; + +export interface MultiViewProps { + store: LayoutStoreApi; + /** Live sessions, keyed by id — drives pane titles and (once known) boot + * reconciliation. Passing `undefined` defers reconciliation. */ + sessions?: ReadonlyMap; + onDisconnect?: (sessionId: Uuid) => void; + emptyState?: ReactNode; +} + +export function MultiView({ store, sessions, onDisconnect, emptyState }: MultiViewProps) { + const layout = useStore(store, (s: LayoutStore) => s.layout); + const tab = activeTab(layout); + + const reconciledRef = useRef(false); + useEffect(() => { + if (reconciledRef.current || !sessions) return; + reconciledRef.current = true; + store.getState().reconcile(new Set(sessions.keys())); + }, [sessions, store]); + + return ( +
+ +
+ {tab.root ? ( + + ) : ( + (emptyState ?? ( +
+ No session open — pick one from the sidebar, or spawn a new one. +
+ )) + )} +
+
+ ); +} + +interface TreeProps { + tab: { id: string; focusedLeafId: string | null }; + store: LayoutStoreApi; + sessions?: ReadonlyMap; + onDisconnect?: (sessionId: Uuid) => void; +} + +function SplitNode({ node, ...rest }: TreeProps & { node: Node }) { + if (node.kind === "leaf") { + return ; + } + return ; +} + +function SplitContainer({ node, ...rest }: TreeProps & { node: Extract }) { + const slotA = useRef(null); + const slotB = useRef(null); + const containerRef = useRef(null); + + const onDividerPointerDown = (e: React.PointerEvent) => { + e.preventDefault(); + const divider = e.currentTarget; + const container = containerRef.current; + if (!container) return; + divider.setPointerCapture(e.pointerId); + const rect = container.getBoundingClientRect(); + const move = (ev: PointerEvent) => { + const frac = + node.dir === "row" ? (ev.clientX - rect.left) / rect.width : (ev.clientY - rect.top) / rect.height; + node.ratio = Math.max(0.1, Math.min(0.9, frac)); + if (slotA.current) slotA.current.style.flexGrow = String(node.ratio); + if (slotB.current) slotB.current.style.flexGrow = String(1 - node.ratio); + }; + const up = (ev: PointerEvent) => { + try { + divider.releasePointerCapture(ev.pointerId); + } catch { + // already released + } + divider.removeEventListener("pointermove", move); + divider.removeEventListener("pointerup", up); + rest.store.getState().commitLayout(); + }; + divider.addEventListener("pointermove", move); + divider.addEventListener("pointerup", up); + }; + + return ( +
+
+ +
+
+
+ +
+
+ ); +} + +function Pane({ + leaf, + isFocused, + store, + sessions, + onDisconnect, +}: TreeProps & { leaf: Extract; isFocused: boolean }) { + const session = leaf.sessionId ? sessions?.get(leaf.sessionId) : undefined; + const splitFocused = (dir: Dir) => store.getState().splitFocused(leaf.id, dir); + + return ( +
store.getState().focusLeaf(leaf.id)} + > +
+ {leaf.sessionId ? ( + + ) : ( + Empty pane + )} +
+ {leaf.sessionId && onDisconnect && ( + + )} + + + +
+
+
+ {leaf.sessionId ? ( + store.getState().closeSession(leaf.sessionId!)} + /> + ) : ( +
+ Empty pane — pick a session from the sidebar, or spawn one. +
+ )} +
+
+ ); +} diff --git a/web/src/components/layout/PaneContainer.test.tsx b/web/src/components/layout/PaneContainer.test.tsx new file mode 100644 index 0000000..0837fce --- /dev/null +++ b/web/src/components/layout/PaneContainer.test.tsx @@ -0,0 +1,54 @@ +// @vitest-environment happy-dom +import { cleanup, render, screen } from "@testing-library/react"; +import { afterEach, describe, expect, it, vi } from "vitest"; + +import type { Session } from "../../types.js"; +import { PaneContainer } from "./PaneContainer.js"; + +vi.mock("../terminal/TerminalPane.js", () => ({ + TerminalPane: ({ sessionId, focus }: { sessionId: string; focus?: boolean }) => ( +
+ ), +})); + +vi.mock("../structured/StructuredPane.js", () => ({ + StructuredPane: ({ sessionId, focus }: { sessionId: string; focus?: boolean }) => ( +
+ ), +})); + +afterEach(cleanup); + +function session(over: Partial = {}): Session { + return { + id: "s1", + cwd: "/home/n", + command: "claude", + env: {}, + metadata: {}, + status: "Running", + created_at: "2026-01-01T00:00:00Z", + ...over, + } as Session; +} + +describe("PaneContainer", () => { + it("renders TerminalPane when the session has no view (defaults to Terminal)", () => { + render(); + expect(screen.getByTestId("terminal-pane")).toBeTruthy(); + expect(screen.queryByTestId("structured-pane")).toBeNull(); + }); + + it("renders TerminalPane when the session isn't known yet", () => { + render(); + expect(screen.getByTestId("terminal-pane")).toBeTruthy(); + }); + + it("renders StructuredPane for a Structured session", () => { + render(); + const pane = screen.getByTestId("structured-pane"); + expect(pane.dataset.sessionId).toBe("s1"); + expect(pane.dataset.focus).toBe("false"); + expect(screen.queryByTestId("terminal-pane")).toBeNull(); + }); +}); diff --git a/web/src/components/layout/PaneContainer.tsx b/web/src/components/layout/PaneContainer.tsx new file mode 100644 index 0000000..94047bf --- /dev/null +++ b/web/src/components/layout/PaneContainer.tsx @@ -0,0 +1,49 @@ +// Dispatches a leaf's session to the right body — TerminalPane for a PTY +// session, StructuredPane for an ACP one (session.view, #133/#140/#249). + +import { forwardRef, useImperativeHandle, useRef } from "react"; + +import type { Session, Uuid } from "../../types.js"; +import { StructuredPane, type StructuredPaneHandle } from "../structured/StructuredPane.js"; +import { TerminalPane, type TerminalPaneHandle } from "../terminal/TerminalPane.js"; + +export interface PaneContainerHandle { + focusTerminal(): void; + /** No-op for a structured pane — it has no PTY (pane.ts's PaneConnection contract). */ + sendInput(data: string): void; +} + +export interface PaneContainerProps { + sessionId: Uuid; + session?: Session; + /** Grabs the keyboard once connected. A sidebar preview passes `false`. */ + focus?: boolean; + onClosed?: () => void; + className?: string; +} + +export const PaneContainer = forwardRef(function PaneContainer( + { sessionId, session, focus, onClosed, className }, + ref, +) { + const terminalRef = useRef(null); + const structuredRef = useRef(null); + + useImperativeHandle( + ref, + () => ({ + focusTerminal: () => { + terminalRef.current?.focusTerminal(); + structuredRef.current?.focusComposer(); + }, + sendInput: (data: string) => terminalRef.current?.sendInput(data), + }), + [], + ); + + return session?.view === "Structured" ? ( + + ) : ( + + ); +}); diff --git a/web/src/components/layout/PaneTitle.test.tsx b/web/src/components/layout/PaneTitle.test.tsx new file mode 100644 index 0000000..cf82b4d --- /dev/null +++ b/web/src/components/layout/PaneTitle.test.tsx @@ -0,0 +1,48 @@ +// @vitest-environment happy-dom +import { cleanup, render, screen } from "@testing-library/react"; +import { afterEach, describe, expect, it } from "vitest"; + +import type { Session } from "../../types.js"; +import { PaneTitle } from "./PaneTitle.js"; + +afterEach(cleanup); + +function session(over: Partial = {}): Session { + return { + id: "s1", + cwd: "/home/n", + command: "claude", + env: {}, + metadata: {}, + status: "Running", + created_at: "2026-01-01T00:00:00Z", + ...over, + } as Session; +} + +describe("PaneTitle", () => { + it("falls back to a short session id when the session isn't known yet", () => { + render(); + expect(screen.getByText(/abcdef12/)).toBeTruthy(); + }); + + it("shows the name alone when the command matches it", () => { + render(); + expect(screen.getByText("claude")).toBeTruthy(); + expect(document.querySelector(".at-cmd")).toBeNull(); + }); + + it("shows the command alongside a custom name", () => { + render( + , + ); + expect(screen.getByText("editor")).toBeTruthy(); + expect(document.querySelector(".at-cmd")).not.toBeNull(); + }); + + it("never injects markup from a session name", () => { + render(" } })} />); + expect(document.querySelector("img")).toBeNull(); + expect(screen.getByText("")).toBeTruthy(); + }); +}); diff --git a/web/src/components/layout/PaneTitle.tsx b/web/src/components/layout/PaneTitle.tsx new file mode 100644 index 0000000..ba7fcb9 --- /dev/null +++ b/web/src/components/layout/PaneTitle.tsx @@ -0,0 +1,39 @@ +// React port of paneTitle.ts: a pane header's session identity — the name, +// then the command as its tool mark when recognised, same as the sidebar +// card's own CommandFragment (SessionCard.tsx). JSX escapes text nodes, so +// unlike the vanilla string-builder this needs no manual HTML escaping. + +import { splitCommand, toolIconSvg } from "../../toolIcon.js"; +import { sessionName, type Session, type Uuid } from "../../types.js"; + +export interface PaneTitleProps { + sessionId: Uuid; + session?: Session; + className?: string; +} + +export function PaneTitle({ sessionId, session, className }: PaneTitleProps) { + if (!session) { + return ( + + session {sessionId.slice(0, 8)} + + ); + } + + const name = sessionName(session); + const showCommand = Boolean(session.command) && session.command !== name; + const { tool, program } = splitCommand(session.command); + const icon = tool ? toolIconSvg(tool) : null; + + return ( + + {name} + {showCommand && ( + + {icon ? : {program}} + + )} + + ); +} diff --git a/web/src/components/layout/TabBar.test.tsx b/web/src/components/layout/TabBar.test.tsx new file mode 100644 index 0000000..4dc111a --- /dev/null +++ b/web/src/components/layout/TabBar.test.tsx @@ -0,0 +1,88 @@ +// @vitest-environment happy-dom +import { cleanup, fireEvent, render, screen } from "@testing-library/react"; +import { afterEach, describe, expect, it, vi } from "vitest"; + +import { createLayoutStore } from "../../stores/layoutStore.js"; +import { TabBar } from "./TabBar.js"; + +afterEach(cleanup); + +function store() { + return createLayoutStore({ + load: () => null, + save: vi.fn(), + scheduler: { setTimer: () => 1, clearTimer: () => {} }, + }); +} + +function dataTransfer() { + const data = new Map(); + return { + setData: (type: string, value: string) => data.set(type, value), + getData: (type: string) => data.get(type) ?? "", + effectAllowed: "", + }; +} + +describe("TabBar", () => { + it("renders one tab per layout tab, marking the active one", () => { + const s = store(); + s.getState().addTab(); + render(); + const tabs = screen.getAllByRole("tab"); + expect(tabs).toHaveLength(2); + expect(tabs[1].getAttribute("aria-selected")).toBe("true"); + expect(tabs[0].getAttribute("aria-selected")).toBe("false"); + }); + + it("shows the open-session count next to a tab's title", () => { + const s = store(); + s.getState().openSession("s1"); + render(); + expect(screen.getByText(/· 1/)).toBeTruthy(); + }); + + it("clicking a tab selects it", () => { + const s = store(); + s.getState().addTab(); + const [first] = s.getState().layout.tabs; + render(); + fireEvent.click(screen.getAllByRole("tab")[0]); + expect(s.getState().layout.activeTabId).toBe(first.id); + }); + + it("the + button adds a tab", () => { + const s = store(); + render(); + fireEvent.click(screen.getByTitle("New tab")); + expect(s.getState().layout.tabs).toHaveLength(2); + }); + + it("hides the close control on the last remaining tab", () => { + const s = store(); + render(); + expect(screen.queryByTitle("Close tab")).toBeNull(); + }); + + it("closing a tab removes it", () => { + const s = store(); + s.getState().addTab(); + render(); + fireEvent.click(screen.getAllByTitle("Close tab")[0]); + expect(s.getState().layout.tabs).toHaveLength(1); + }); + + it("dragging a tab onto another reorders them", () => { + const s = store(); + s.getState().addTab(); + s.getState().addTab(); + const [a, b, c] = s.getState().layout.tabs; + render(); + const tabs = screen.getAllByRole("tab"); + const dt = dataTransfer(); + fireEvent.dragStart(tabs[0], { dataTransfer: dt }); + fireEvent.dragOver(tabs[2], { dataTransfer: dt }); + fireEvent.drop(tabs[2], { dataTransfer: dt }); + expect(s.getState().layout.tabs.map((t) => t.id)).toEqual([b.id, c.id, a.id]); + }); +}); diff --git a/web/src/components/layout/TabBar.tsx b/web/src/components/layout/TabBar.tsx new file mode 100644 index 0000000..1a56185 --- /dev/null +++ b/web/src/components/layout/TabBar.tsx @@ -0,0 +1,91 @@ +// The tab strip above the canvas (issue #249): click to switch, drag to +// reorder, a close ✕ per tab (hidden on the last one), a trailing + to add. + +import { useState } from "react"; +import { useStore } from "zustand"; + +import { leaves } from "../../layout.js"; +import type { LayoutStore, LayoutStoreApi } from "../../stores/layoutStore.js"; + +export interface TabBarProps { + store: LayoutStoreApi; +} + +export function TabBar({ store }: TabBarProps) { + const layout = useStore(store, (s: LayoutStore) => s.layout); + const [draggingId, setDraggingId] = useState(null); + const [overId, setOverId] = useState(null); + + return ( +
+ {layout.tabs.map((tab) => { + const count = leaves(tab.root).filter((l) => l.sessionId).length; + const isActive = tab.id === layout.activeTabId; + const isDropTarget = overId === tab.id && draggingId !== null && draggingId !== tab.id; + return ( + + ); + })} + +
+ ); +} diff --git a/web/src/stores/layoutStore.test.ts b/web/src/stores/layoutStore.test.ts new file mode 100644 index 0000000..b089ada --- /dev/null +++ b/web/src/stores/layoutStore.test.ts @@ -0,0 +1,162 @@ +import { describe, expect, it, vi } from "vitest"; + +import { leaves } from "../layout.js"; +import { createLayoutStore, type LayoutStoreOptions } from "./layoutStore.js"; + +function fakeScheduler() { + const timers = new Map void>(); + let id = 0; + return { + scheduler: { + setTimer: (fn: () => void) => { + const h = ++id; + timers.set(h, fn); + return h; + }, + clearTimer: (h: number) => { + timers.delete(h); + }, + }, + flush() { + for (const fn of [...timers.values()]) fn(); + timers.clear(); + }, + }; +} + +function store(opts: Partial = {}) { + return createLayoutStore({ + load: () => null, + save: vi.fn(), + scheduler: fakeScheduler().scheduler, + ...opts, + }); +} + +describe("layoutStore", () => { + it("boots with a single empty tab when nothing is persisted", () => { + const { layout } = store().getState(); + expect(layout.tabs).toHaveLength(1); + expect(layout.tabs[0].root).toBeNull(); + }); + + it("boots from a persisted layout when one loads", () => { + const persisted = store().getState().layout; + persisted.tabs[0].title = "Restored"; + const s = store({ load: () => persisted }); + expect(s.getState().layout.tabs[0].title).toBe("Restored"); + }); + + it("openSession seeds the active tab from empty", () => { + const s = store(); + s.getState().openSession("s1"); + expect(leaves(s.getState().layout.tabs[0].root).map((l) => l.sessionId)).toEqual(["s1"]); + }); + + it("openSession focuses an already-open session instead of moving it", () => { + const s = store(); + s.getState().openSession("s1"); + s.getState().openSessionBeside("s2"); + s.getState().openSession("s1"); + const ids = leaves(s.getState().layout.tabs[0].root) + .map((l) => l.sessionId) + .sort(); + expect(ids).toEqual(["s1", "s2"]); + }); + + it("openSessionBeside splits next to the focused pane instead of replacing it", () => { + const s = store(); + s.getState().openSession("s1"); + s.getState().openSessionBeside("s2"); + expect(leaves(s.getState().layout.tabs[0].root)).toHaveLength(2); + }); + + it("openSessionBeside(newTab) opens its own tab", () => { + const s = store(); + s.getState().openSession("s1"); + s.getState().openSessionBeside("s2", { newTab: true }); + expect(s.getState().layout.tabs).toHaveLength(2); + expect(leaves(s.getState().layout.tabs[1].root).map((l) => l.sessionId)).toEqual(["s2"]); + }); + + it("closeSession removes the pane and closes a tab left with nothing else", () => { + const s = store(); + s.getState().openSession("s1"); + s.getState().addTab(); + s.getState().openSession("s2"); + s.getState().closeSession("s2"); + expect(s.getState().layout.tabs).toHaveLength(1); + expect(leaves(s.getState().layout.tabs[0].root).map((l) => l.sessionId)).toEqual(["s1"]); + }); + + it("splitFocused adds a leaf beside the given one and focuses it", () => { + const s = store(); + s.getState().openSession("s1"); + const first = leaves(s.getState().layout.tabs[0].root)[0].id; + s.getState().splitFocused(first, "row"); + const tab = s.getState().layout.tabs[0]; + expect(leaves(tab.root)).toHaveLength(2); + expect(tab.focusedLeafId).not.toBe(first); + }); + + it("closePane collapses the split back to the surviving leaf", () => { + const s = store(); + s.getState().openSession("s1"); + const first = leaves(s.getState().layout.tabs[0].root)[0].id; + s.getState().splitFocused(first, "row"); + s.getState().closePane(first); + const remaining = leaves(s.getState().layout.tabs[0].root); + expect(remaining).toHaveLength(1); + expect(remaining[0].sessionId).toBeNull(); + }); + + it("addTab/selectTab/closeTab manage the tab strip", () => { + const s = store(); + s.getState().addTab(); + expect(s.getState().layout.tabs).toHaveLength(2); + const [first, second] = s.getState().layout.tabs; + s.getState().selectTab(first.id); + expect(s.getState().layout.activeTabId).toBe(first.id); + s.getState().closeTab(second.id); + expect(s.getState().layout.tabs).toHaveLength(1); + }); + + it("reorderTab moves a tab to sit where another one is", () => { + const s = store(); + s.getState().addTab(); + s.getState().addTab(); + const [a, b, c] = s.getState().layout.tabs; + s.getState().reorderTab(c.id, a.id); + expect(s.getState().layout.tabs.map((t) => t.id)).toEqual([c.id, a.id, b.id]); + }); + + it("reconcile drops leaves for sessions no longer live, and closes a tab left with nothing else", () => { + const s = store(); + s.getState().openSession("s1"); + s.getState().addTab(); + s.getState().openSession("dead"); + s.getState().reconcile(new Set(["s1"])); + const { layout } = s.getState(); + expect(layout.tabs).toHaveLength(1); + expect(leaves(layout.tabs[0].root).map((l) => l.sessionId)).toEqual(["s1"]); + }); + + it("reconcile leaves an intentionally empty tab alone", () => { + const s = store(); + s.getState().addTab(); + s.getState().reconcile(new Set()); + expect(s.getState().layout.tabs).toHaveLength(2); + }); + + it("debounces persistence to a single write once idle", () => { + const save = vi.fn(); + const { scheduler, flush } = fakeScheduler(); + const s = store({ save, scheduler }); + s.getState().openSession("s1"); + s.getState().openSessionBeside("s2"); + expect(save).not.toHaveBeenCalled(); + flush(); + expect(save).toHaveBeenCalledTimes(1); + expect(save.mock.calls[0][0]).toBe(s.getState().layout); + }); +}); diff --git a/web/src/stores/layoutStore.ts b/web/src/stores/layoutStore.ts new file mode 100644 index 0000000..813e092 --- /dev/null +++ b/web/src/stores/layoutStore.ts @@ -0,0 +1,253 @@ +// Zustand wrapper around ../layout.js's layout tree model (issue #249). +// Actions mutate the tree in place via layout.js's own functions, then +// re-wrap it so `useStore` selectors relying on referential equality notice. + +import { create } from "zustand"; + +import { + activeTab, + addTab as addTabToLayout, + closeTab as closeTabInLayout, + emptyLayout, + equalizeRatios, + findLeaf, + leaves, + locateSession, + removeLeaf, + splitLeaf, + type Dir, + type Layout, +} from "../layout.js"; +import { loadLayout, saveLayout } from "../persistence.js"; +import type { Uuid } from "../types.js"; +import type { Scheduler } from "./catalogStore.js"; + +const SAVE_DEBOUNCE_MS = 400; + +const browserScheduler: Scheduler = { + setTimer: (fn, ms) => window.setTimeout(fn, ms), + clearTimer: (h) => window.clearTimeout(h), +}; + +export interface LayoutStoreOptions { + load?: () => Layout | null; + save?: (layout: Layout) => void; + scheduler?: Scheduler; +} + +export interface LayoutState { + layout: Layout; +} + +export interface LayoutActions { + /** Bring `sessionId` into view: focus it if already open, else fill the + * focused pane (VS-Code-style replace), or seed an empty tab. */ + openSession(sessionId: Uuid): void; + /** Same, but never evicts a running pane: reuses an empty leaf or splits + * beside the focused one. `newTab` opens its own tab instead. */ + openSessionBeside(sessionId: Uuid, opts?: { newTab?: boolean }): void; + /** Drop the pane showing `sessionId` (closed/killed elsewhere). */ + closeSession(sessionId: Uuid): void; + splitFocused(leafId: string, dir: Dir): void; + closePane(leafId: string): void; + focusLeaf(leafId: string): void; + selectTab(tabId: string): void; + addTab(): void; + closeTab(tabId: string): void; + /** Tab-strip drag-and-drop reordering: moves `fromId` to sit where `toId` + * currently is. */ + reorderTab(fromId: string, toId: string): void; + /** Persist whatever a divider drag left on a split's `ratio` — the drag + * itself mutates the node directly (same trade as the vanilla version), + * this just re-wraps and schedules the save. */ + commitLayout(): void; + /** Boot reconciliation: drop every leaf whose session isn't in `liveIds`, + * and close any tab that held nothing else. A session killed while its + * tab was closed is pruned instead of leaving a pane that can only ever + * show a dead socket. Tabs that were already empty are left alone. */ + reconcile(liveIds: ReadonlySet): void; +} + +export type LayoutStore = LayoutState & LayoutActions; +export type LayoutStoreApi = ReturnType; + +/** Builds an isolated layout store — load/save/scheduler are injectable + * (tests), or default to the real localStorage persistence.ts + browser + * timers. Same shape as {@link createCatalogStore}. */ +export function createLayoutStore(opts: LayoutStoreOptions = {}) { + const load = opts.load ?? loadLayout; + const save = opts.save ?? saveLayout; + const scheduler = opts.scheduler ?? browserScheduler; + + let saveTimer: number | null = null; + + return create((set, get) => { + function touch(): void { + // A shallow clone, not the same reference back: `useStore` (and any + // other Object.is-based selector) needs the identity to change to + // notice a mutation made in place on the tree underneath. + set({ layout: { ...get().layout } }); + } + + function schedulePersist(): void { + if (saveTimer !== null) scheduler.clearTimer(saveTimer); + saveTimer = scheduler.setTimer(() => { + saveTimer = null; + save(get().layout); + }, SAVE_DEBOUNCE_MS); + } + + function commit(): void { + touch(); + schedulePersist(); + } + + function mutate(fn: (layout: Layout) => void): void { + fn(get().layout); + commit(); + } + + return { + layout: load() ?? emptyLayout(), + + openSession(sessionId) { + mutate((layout) => { + const loc = locateSession(layout, sessionId); + if (loc) { + layout.activeTabId = loc.tab.id; + loc.tab.focusedLeafId = loc.leaf.id; + return; + } + const tab = activeTab(layout); + if (!tab.root) { + tab.focusedLeafId = splitLeaf(tab, "", sessionId, "row", true); + return; + } + const target = (tab.focusedLeafId && findLeaf(tab.root, tab.focusedLeafId)) || leaves(tab.root)[0]; + target.sessionId = sessionId; + tab.focusedLeafId = target.id; + }); + }, + + openSessionBeside(sessionId, placement) { + mutate((layout) => { + const loc = locateSession(layout, sessionId); + if (loc) { + layout.activeTabId = loc.tab.id; + loc.tab.focusedLeafId = loc.leaf.id; + return; + } + if (placement?.newTab) { + const fresh = addTabToLayout(layout); + fresh.focusedLeafId = splitLeaf(fresh, "", sessionId, "row", true); + return; + } + const tab = activeTab(layout); + if (!tab.root || !tab.focusedLeafId || !findLeaf(tab.root, tab.focusedLeafId)) { + tab.focusedLeafId = splitLeaf(tab, "", sessionId, "row", true); + return; + } + const target = findLeaf(tab.root, tab.focusedLeafId)!; + if (!target.sessionId) { + target.sessionId = sessionId; + } else { + tab.focusedLeafId = splitLeaf(tab, target.id, sessionId, "row", true); + equalizeRatios(tab.root); + } + }); + }, + + closeSession(sessionId) { + mutate((layout) => { + const loc = locateSession(layout, sessionId); + if (!loc) return; + removeLeaf(loc.tab, loc.leaf.id); + if (loc.tab.focusedLeafId === loc.leaf.id) { + loc.tab.focusedLeafId = leaves(loc.tab.root)[0]?.id ?? null; + } + if (!loc.tab.root) closeTabInLayout(layout, loc.tab.id); + }); + }, + + splitFocused(leafId, dir) { + mutate((layout) => { + const tab = activeTab(layout); + tab.focusedLeafId = splitLeaf(tab, leafId, null, dir, true); + equalizeRatios(tab.root); + }); + }, + + closePane(leafId) { + mutate((layout) => { + const tab = activeTab(layout); + removeLeaf(tab, leafId); + if (tab.focusedLeafId === leafId) { + tab.focusedLeafId = leaves(tab.root)[0]?.id ?? null; + } + }); + }, + + focusLeaf(leafId) { + mutate((layout) => { + const tab = activeTab(layout); + if (tab.focusedLeafId === leafId) return; + tab.focusedLeafId = leafId; + }); + }, + + selectTab(tabId) { + mutate((layout) => { + if (layout.tabs.some((t) => t.id === tabId)) layout.activeTabId = tabId; + }); + }, + + addTab() { + mutate((layout) => { + addTabToLayout(layout); + }); + }, + + closeTab(tabId) { + mutate((layout) => { + closeTabInLayout(layout, tabId); + }); + }, + + reorderTab(fromId, toId) { + mutate((layout) => { + if (fromId === toId) return; + const from = layout.tabs.findIndex((t) => t.id === fromId); + const to = layout.tabs.findIndex((t) => t.id === toId); + if (from < 0 || to < 0) return; + const [moved] = layout.tabs.splice(from, 1); + layout.tabs.splice(to, 0, moved); + }); + }, + + commitLayout() { + commit(); + }, + + reconcile(liveIds) { + mutate((layout) => { + const emptied: string[] = []; + for (const tab of [...layout.tabs]) { + let prunedAny = false; + for (const leaf of leaves(tab.root)) { + if (leaf.sessionId && !liveIds.has(leaf.sessionId)) { + removeLeaf(tab, leaf.id); + prunedAny = true; + } + } + if (!prunedAny) continue; + if (!tab.focusedLeafId || !findLeaf(tab.root, tab.focusedLeafId)) { + tab.focusedLeafId = leaves(tab.root)[0]?.id ?? null; + } + if (!tab.root) emptied.push(tab.id); + } + for (const id of emptied) closeTabInLayout(layout, id); + }); + }, + }; + }); +}