// Multi-view layout model. The layout tree is the single source of truth for // what's on screen: a set of tabs, each holding a binary split tree whose leaves // hold a session (or nothing yet). Desktop renders the tree as nested splits; // mobile (issue #35) projects its leaves to tabs. // // A session lives in at most one leaf at a time (the connection pool is // one-socket-per-session), so a leaf's `sessionId` is unique across the whole // layout — or null for a freshly-split, not-yet-filled pane. import type { Uuid } from "./types.js"; export type Dir = "row" | "col"; /** Half-filled spawn-form state for a draft pane (issue #57): an empty leaf * renders the spawn form instead of a placeholder, and each pane's values * hang off its own leaf so two drafts stay independent. Never persisted — * a reload gives back an empty form (persistence strips it). */ export interface Draft { command: string; cwd: string; name: string; worktreeEnabled?: boolean; worktreeBranch?: string; worktreeCleanup?: boolean; /** Whether the cwd lives in the raw text input (vs a picked recent row). */ cwdRawVisible?: boolean; } export type Node = | { kind: "leaf"; id: string; sessionId: Uuid | null; draft?: Draft | null } | { kind: "split"; dir: Dir; a: Node; b: Node; ratio: number }; export type Leaf = Extract; export interface Tab { id: string; title: string; /** The split tree, or null for an empty tab. */ root: Node | null; /** Which leaf has focus (drives keyboard input + the strong highlight). */ focusedLeafId: string | null; } export interface Layout { tabs: Tab[]; activeTabId: string; } let seq = 0; export function freshId(prefix = "n"): string { return `${prefix}${++seq}`; } /** Every leaf under `node`, left-to-right. */ export function leaves(node: Node | null): Leaf[] { if (!node) return []; if (node.kind === "leaf") return [node]; return [...leaves(node.a), ...leaves(node.b)]; } export function findLeaf(node: Node | null, leafId: string): Leaf | null { return leaves(node).find((l) => l.id === leafId) ?? null; } export function activeTab(layout: Layout): Tab { return layout.tabs.find((t) => t.id === layout.activeTabId) ?? layout.tabs[0]; } /** Distinct sessions shown in any pane of any tab. */ export function openSessions(layout: Layout): Set { const out = new Set(); for (const t of layout.tabs) { for (const l of leaves(t.root)) if (l.sessionId) out.add(l.sessionId); } return out; } /** Find the tab + leaf currently showing `sessionId`, if any. */ export function locateSession( layout: Layout, sessionId: Uuid, ): { tab: Tab; leaf: Leaf } | null { for (const tab of layout.tabs) { const leaf = leaves(tab.root).find((l) => l.sessionId === sessionId); if (leaf) return { tab, leaf }; } return null; } /** A layout with one empty tab. */ export function emptyLayout(): Layout { const tab: Tab = { id: freshId("t"), title: "Tab 1", root: null, focusedLeafId: null }; return { tabs: [tab], activeTabId: tab.id }; } // ---- mutations (return the affected leaf id where useful) ---- /** Replace `targetId` with a split of [target, fresh] (or reversed when * `after` is false). Returns the new leaf's id. */ export function splitLeaf( tab: Tab, targetId: string, sessionId: Uuid | null, dir: Dir, after: boolean, ): string { const fresh: Leaf = { kind: "leaf", id: freshId("l"), sessionId, draft: null }; const rec = (n: Node): Node => { if (n.kind === "leaf") { if (n.id !== targetId) return n; return { kind: "split", dir, ratio: 0.5, a: after ? n : fresh, b: after ? fresh : n }; } n.a = rec(n.a); n.b = rec(n.b); return n; }; tab.root = tab.root ? rec(tab.root) : fresh; return fresh.id; } /** Remove a leaf, collapsing its parent split into the sibling. */ export function removeLeaf(tab: Tab, leafId: string): void { const rec = (n: Node): Node | null => { if (n.kind === "leaf") return n.id === leafId ? null : n; const a = rec(n.a); const b = rec(n.b); if (!a) return b; if (!b) return a; n.a = a; n.b = b; return n; }; tab.root = tab.root ? rec(tab.root) : null; } export function addTab(layout: Layout): Tab { const tab: Tab = { id: freshId("t"), title: `Tab ${layout.tabs.length + 1}`, root: null, focusedLeafId: null, }; layout.tabs.push(tab); layout.activeTabId = tab.id; return tab; } export function closeTab(layout: Layout, tabId: string): void { if (layout.tabs.length <= 1) { // Keep at least one tab; just empty it. const t = layout.tabs[0]; t.root = null; t.focusedLeafId = null; return; } const i = layout.tabs.findIndex((t) => t.id === tabId); if (i < 0) return; layout.tabs.splice(i, 1); if (layout.activeTabId === tabId) { layout.activeTabId = layout.tabs[Math.max(0, i - 1)].id; } }