diff --git a/web/src/layout.ts b/web/src/layout.ts new file mode 100644 index 0000000..0e632e0 --- /dev/null +++ b/web/src/layout.ts @@ -0,0 +1,57 @@ +// 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 +// are sessions. Desktop renders the tree as nested splits; mobile projects its +// leaves to tabs. (Splits/tabs UI land in later issues — see epic #37. This +// issue introduces the model and renders a single-leaf tree at parity.) + +import type { Uuid } from "./types.js"; + +export type Dir = "row" | "col"; + +export type Node = + | { kind: "leaf"; id: string; sessionId: Uuid } + | { 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 keyboard focus / drives the sidebar highlight. */ + focusedLeafId: string | null; +} + +export interface Layout { + tabs: Tab[]; + activeTabId: string; +} + +let seq = 0; +/** Monotonic id for tabs and leaves (not persisted across reloads — restore + * assigns fresh ids). */ +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]; +} + +/** 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 }; +}