This repository has been archived on 2026-10-08. You can view files and clone it. You cannot open issues or pull requests or push a commit.
Files
maestro/web/src/layout.ts
T
Miguel PalhasandClaude Sonnet 5 0cc25d7e1c
ci / rust (push) Successful in 9m12s
ci / web (push) Successful in 33s
ci / flake (push) Successful in 2m37s
fix(maestro): equalize split widths after spawning a session
splitLeaf always halved just the target pane, so repeated splits left
one pane at 50% and the rest squeezed into shrinking fractions instead
of equal columns.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-04 13:29:03 +01:00

159 lines
4.6 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;
}
/** Reset every split ratio under `node` so all leaves get equal width,
* regardless of split direction or nesting order. Returns the leaf count. */
export function equalizeRatios(node: Node | null): number {
if (!node) return 0;
if (node.kind === "leaf") return 1;
const na = equalizeRatios(node.a);
const nb = equalizeRatios(node.b);
node.ratio = na / (na + nb);
return na + nb;
}
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;
}
}