Draft panes are gone: an empty pane is a placeholder again, and every spawn affordance opens a dialog around the shared spawn form. Escape, Cancel, the ✕ and a backdrop click dismiss it; a successful submit puts the session in a new tab (Spawn similar and the fast-forward fallback keep placing it beside the pane they acted on). Removes the per-leaf draft state, its persistence strip, and the form reuse/teardown machinery the panes needed. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
148 lines
4.2 KiB
TypeScript
148 lines
4.2 KiB
TypeScript
// 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";
|
|
|
|
export type Node =
|
|
| { kind: "leaf"; id: string; sessionId: Uuid | null }
|
|
| { kind: "split"; dir: Dir; a: Node; b: Node; ratio: number };
|
|
|
|
export type Leaf = Extract<Node, { kind: "leaf" }>;
|
|
|
|
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<Uuid> {
|
|
const out = new Set<Uuid>();
|
|
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 };
|
|
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;
|
|
}
|
|
}
|