Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1b7ec1f54c |
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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);
|
||||||
|
});
|
||||||
|
},
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user