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.
This commit is contained in:
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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]);
|
||||
});
|
||||
});
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user