Compare commits

...
Author SHA1 Message Date
Miguel Palhas 1b7ec1f54c 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.
2026-09-09 01:21:37 +01:00
10 changed files with 1083 additions and 0 deletions
@@ -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 }) => <div data-testid={`terminal-${sessionId}`} />,
}));
vi.mock("../structured/StructuredPane.js", () => ({
StructuredPane: ({ sessionId }: { sessionId: string }) => <div data-testid={`structured-${sessionId}`} />,
}));
afterEach(cleanup);
function store() {
return createLayoutStore({
load: () => null,
save: vi.fn(),
scheduler: { setTimer: () => 1, clearTimer: () => {} },
});
}
function session(id: string, over: Partial<Session> = {}): 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(<MultiView store={s} />);
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(<MultiView store={s} sessions={sessions} />);
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(<MultiView store={s} sessions={new Map([["s1", session("s1")]])} />);
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(<MultiView store={s} sessions={new Map([["s1", session("s1")]])} />);
// 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(<MultiView store={s} sessions={new Map([["s1", session("s1")]])} />);
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(<MultiView store={s} />);
expect(screen.getByTestId("terminal-s1")).toBeTruthy();
});
});
+203
View File
@@ -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<Uuid, Session>;
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 (
<div className="mv flex h-full min-h-0 flex-col">
<TabBar store={store} />
<div className="mv-canvas min-h-0 flex-1">
{tab.root ? (
<SplitNode node={tab.root} tab={tab} store={store} sessions={sessions} onDisconnect={onDisconnect} />
) : (
(emptyState ?? (
<div className="mv-empty flex h-full items-center justify-center p-sp-4 text-fs-13 text-faint">
No session open — pick one from the sidebar, or spawn a new one.
</div>
))
)}
</div>
</div>
);
}
interface TreeProps {
tab: { id: string; focusedLeafId: string | null };
store: LayoutStoreApi;
sessions?: ReadonlyMap<Uuid, Session>;
onDisconnect?: (sessionId: Uuid) => void;
}
function SplitNode({ node, ...rest }: TreeProps & { node: Node }) {
if (node.kind === "leaf") {
return <Pane leaf={node} isFocused={rest.tab.focusedLeafId === node.id} {...rest} />;
}
return <SplitContainer node={node} {...rest} />;
}
function SplitContainer({ node, ...rest }: TreeProps & { node: Extract<Node, { kind: "split" }> }) {
const slotA = useRef<HTMLDivElement | null>(null);
const slotB = useRef<HTMLDivElement | null>(null);
const containerRef = useRef<HTMLDivElement | null>(null);
const onDividerPointerDown = (e: React.PointerEvent<HTMLDivElement>) => {
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 (
<div
ref={containerRef}
className={`mv-split flex h-full min-h-0 w-full ${node.dir === "row" ? "flex-row" : "flex-col"}`}
>
<div ref={slotA} className="mv-slot min-h-0 min-w-0" style={{ flex: `${node.ratio} 1 0` }}>
<SplitNode node={node.a} {...rest} />
</div>
<div
className={
"mv-divider flex-none bg-border-strong hover:bg-accent " +
(node.dir === "row" ? "w-[3px] cursor-col-resize" : "h-[3px] cursor-row-resize")
}
onPointerDown={onDividerPointerDown}
/>
<div ref={slotB} className="mv-slot min-h-0 min-w-0" style={{ flex: `${1 - node.ratio} 1 0` }}>
<SplitNode node={node.b} {...rest} />
</div>
</div>
);
}
function Pane({
leaf,
isFocused,
store,
sessions,
onDisconnect,
}: TreeProps & { leaf: Extract<Node, { kind: "leaf" }>; isFocused: boolean }) {
const session = leaf.sessionId ? sessions?.get(leaf.sessionId) : undefined;
const splitFocused = (dir: Dir) => store.getState().splitFocused(leaf.id, dir);
return (
<div
className={
"mv-pane flex h-full min-h-0 min-w-0 flex-col border " +
(isFocused ? "border-accent" : "border-border-strong")
}
data-leaf-id={leaf.id}
data-session-id={leaf.sessionId ?? undefined}
onPointerDownCapture={() => store.getState().focusLeaf(leaf.id)}
>
<div className="mv-pane-header flex items-center justify-between gap-sp-2 border-b border-border-strong bg-surface px-sp-2 py-sp-1 text-fs-12">
{leaf.sessionId ? (
<PaneTitle sessionId={leaf.sessionId} session={session} />
) : (
<span className="text-faint">Empty pane</span>
)}
<div className="mv-pane-tools flex items-center gap-sp-1">
{leaf.sessionId && onDisconnect && (
<button
type="button"
className="mv-tool rounded-r-1 px-sp-1 text-faint hover:bg-surface-3 hover:text-ink"
title="Disconnect"
onClick={() => onDisconnect(leaf.sessionId!)}
>
⏻
</button>
)}
<button
type="button"
className="mv-tool rounded-r-1 px-sp-1 text-faint hover:bg-surface-3 hover:text-ink"
title="Split right"
onClick={() => splitFocused("row")}
>
▥
</button>
<button
type="button"
className="mv-tool rounded-r-1 px-sp-1 text-faint hover:bg-surface-3 hover:text-ink"
title="Split down"
onClick={() => splitFocused("col")}
>
▤
</button>
<button
type="button"
className="mv-tool rounded-r-1 px-sp-1 text-faint hover:bg-surface-3 hover:text-ink"
title="Close pane"
onClick={() => store.getState().closePane(leaf.id)}
>
✕
</button>
</div>
</div>
<div className="mv-pane-host min-h-0 flex-1">
{leaf.sessionId ? (
<PaneContainer
sessionId={leaf.sessionId}
session={session}
focus={isFocused}
onClosed={() => store.getState().closeSession(leaf.sessionId!)}
/>
) : (
<div className="mv-pane-empty flex h-full items-center justify-center p-sp-4 text-fs-13 text-faint">
Empty pane — pick a session from the sidebar, or spawn one.
</div>
)}
</div>
</div>
);
}
@@ -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 }) => (
<div data-testid="terminal-pane" data-session-id={sessionId} data-focus={String(focus)} />
),
}));
vi.mock("../structured/StructuredPane.js", () => ({
StructuredPane: ({ sessionId, focus }: { sessionId: string; focus?: boolean }) => (
<div data-testid="structured-pane" data-session-id={sessionId} data-focus={String(focus)} />
),
}));
afterEach(cleanup);
function session(over: Partial<Session> = {}): 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(<PaneContainer sessionId="s1" session={session()} />);
expect(screen.getByTestId("terminal-pane")).toBeTruthy();
expect(screen.queryByTestId("structured-pane")).toBeNull();
});
it("renders TerminalPane when the session isn't known yet", () => {
render(<PaneContainer sessionId="s1" />);
expect(screen.getByTestId("terminal-pane")).toBeTruthy();
});
it("renders StructuredPane for a Structured session", () => {
render(<PaneContainer sessionId="s1" session={session({ view: "Structured" })} focus={false} />);
const pane = screen.getByTestId("structured-pane");
expect(pane.dataset.sessionId).toBe("s1");
expect(pane.dataset.focus).toBe("false");
expect(screen.queryByTestId("terminal-pane")).toBeNull();
});
});
@@ -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<PaneContainerHandle, PaneContainerProps>(function PaneContainer(
{ sessionId, session, focus, onClosed, className },
ref,
) {
const terminalRef = useRef<TerminalPaneHandle>(null);
const structuredRef = useRef<StructuredPaneHandle>(null);
useImperativeHandle(
ref,
() => ({
focusTerminal: () => {
terminalRef.current?.focusTerminal();
structuredRef.current?.focusComposer();
},
sendInput: (data: string) => terminalRef.current?.sendInput(data),
}),
[],
);
return session?.view === "Structured" ? (
<StructuredPane ref={structuredRef} sessionId={sessionId} focus={focus} onClosed={onClosed} className={className} />
) : (
<TerminalPane ref={terminalRef} sessionId={sessionId} focus={focus} onClosed={onClosed} className={className} />
);
});
@@ -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> = {}): 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(<PaneTitle sessionId="abcdef12-3456-7890-abcd-ef1234567890" />);
expect(screen.getByText(/abcdef12/)).toBeTruthy();
});
it("shows the name alone when the command matches it", () => {
render(<PaneTitle sessionId="s1" session={session({ command: "claude", metadata: { name: "claude" } })} />);
expect(screen.getByText("claude")).toBeTruthy();
expect(document.querySelector(".at-cmd")).toBeNull();
});
it("shows the command alongside a custom name", () => {
render(
<PaneTitle sessionId="s1" session={session({ command: "hx src/main.rs", metadata: { name: "editor" } })} />,
);
expect(screen.getByText("editor")).toBeTruthy();
expect(document.querySelector(".at-cmd")).not.toBeNull();
});
it("never injects markup from a session name", () => {
render(<PaneTitle sessionId="s1" session={session({ metadata: { name: "<img onerror=x>" } })} />);
expect(document.querySelector("img")).toBeNull();
expect(screen.getByText("<img onerror=x>")).toBeTruthy();
});
});
+39
View File
@@ -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 (
<span className={className ?? "at-title"} title={sessionId}>
session <span className="sid">{sessionId.slice(0, 8)}</span>
</span>
);
}
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 (
<span className={className ?? "at-title"} title={`${name} · ${session.command} (${sessionId})`}>
<span className="at-name">{name}</span>
{showCommand && (
<span className="at-cmd">
{icon ? <span className="contents" dangerouslySetInnerHTML={{ __html: icon }} /> : <span>{program}</span>}
</span>
)}
</span>
);
}
+88
View File
@@ -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<string, string>();
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(<TabBar store={s} />);
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(<TabBar store={s} />);
expect(screen.getByText(/· 1/)).toBeTruthy();
});
it("clicking a tab selects it", () => {
const s = store();
s.getState().addTab();
const [first] = s.getState().layout.tabs;
render(<TabBar store={s} />);
fireEvent.click(screen.getAllByRole("tab")[0]);
expect(s.getState().layout.activeTabId).toBe(first.id);
});
it("the + button adds a tab", () => {
const s = store();
render(<TabBar store={s} />);
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(<TabBar store={s} />);
expect(screen.queryByTitle("Close tab")).toBeNull();
});
it("closing a tab removes it", () => {
const s = store();
s.getState().addTab();
render(<TabBar store={s} />);
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(<TabBar store={s} />);
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]);
});
});
+91
View File
@@ -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<string | null>(null);
const [overId, setOverId] = useState<string | null>(null);
return (
<div className="mv-tabs flex items-center gap-sp-1 overflow-x-auto border-b border-border-strong bg-surface px-sp-2 text-fs-12" role="tablist">
{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 (
<button
key={tab.id}
type="button"
role="tab"
aria-selected={isActive}
draggable
data-tab-id={tab.id}
className={
"mv-tab flex items-center gap-sp-1 rounded-t-r-1 px-sp-2 py-sp-1 " +
(isActive ? "bg-surface-2 text-ink" : "text-faint hover:text-ink") +
(isDropTarget ? " outline outline-2 outline-accent" : "")
}
onDragStart={(e) => {
setDraggingId(tab.id);
e.dataTransfer.setData("text/plain", tab.id);
e.dataTransfer.effectAllowed = "move";
}}
onDragOver={(e) => {
if (!draggingId || draggingId === tab.id) return;
e.preventDefault();
setOverId(tab.id);
}}
onDragLeave={() => setOverId((current) => (current === tab.id ? null : current))}
onDrop={(e) => {
e.preventDefault();
const fromId = e.dataTransfer.getData("text/plain") || draggingId;
setDraggingId(null);
setOverId(null);
if (fromId && fromId !== tab.id) store.getState().reorderTab(fromId, tab.id);
}}
onDragEnd={() => {
setDraggingId(null);
setOverId(null);
}}
onClick={() => store.getState().selectTab(tab.id)}
>
<span>
{tab.title}
{count ? ` · ${count}` : ""}
</span>
{layout.tabs.length > 1 && (
<span
className="mv-tab-close text-faint hover:text-ink"
title="Close tab"
onClick={(e) => {
e.stopPropagation();
store.getState().closeTab(tab.id);
}}
>
✕
</span>
)}
</button>
);
})}
<button
type="button"
className="mv-tab-add px-sp-2 py-sp-1 text-muted hover:text-ink"
title="New tab"
onClick={() => store.getState().addTab()}
>
+
</button>
</div>
);
}
+162
View File
@@ -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<number, () => 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<LayoutStoreOptions> = {}) {
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);
});
});
+253
View File
@@ -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<Uuid>): void;
}
export type LayoutStore = LayoutState & LayoutActions;
export type LayoutStoreApi = ReturnType<typeof createLayoutStore>;
/** 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<LayoutStore>((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);
});
},
};
});
}