mirror of
https://github.com/hanzo-apps/tabs.git
synced 2026-09-15 19:32:29 +00:00
A standalone terminal workspace for the machines you have linked. Split, tile and sweep through every shell your coding agents are working in, from a laptop or a phone. THERE IS NO BACKEND, and that is the design rather than an omission. `hanzo link` already publishes a terminal from the machine over a zero-trust tunnel, already authenticated and already gated; the browser frames it directly and reads the machine registry with the caller's own token. A server here would be a third party to a conversation that has two, one more thing to keep running, and one more place a credential could sit. The whole site is static. A SHELL IS A URL. ttyd runs with --url-arg behind a wrapper that names a tmux session, so ?arg=build IS a shell called build. One link serves as many independent shells as asked for, and closing a pane loses nothing because reopening the name reopens the session with its scrollback. CARRIED OVER WHOLE, with its tests: the layout tree (split, close with sibling-promotion, resize, sweep order, derived geometry, and the phone projection where side-by-side splits become swipeable pages) and the binding model that separates where a pane sits from what runs in it. 39 tests, no browser required. NOT A FORK OF GGPrompts/Tabz. That repo carries NO LICENSE — no LICENSE file, and GitHub's license API 404s — so it is all-rights-reserved and cannot be forked into an OSS product. Its good ideas are ideas and are used freely: a tab strip, tmux operations without the Ctrl+B prefix, and agent status badges. Its architecture is not: it needs its own Node PTY backend, and `hanzo link` already is one. Verified against the live control plane in Chromium at 1440x900 and as an iPhone 14: the workspace renders a pane bound to the real machine (`ra · hanzo`), with New shell and both splits present, and no page errors. Co-authored-by: Hanzo Dev <[email protected]>
287 lines
12 KiB
TypeScript
287 lines
12 KiB
TypeScript
/**
|
|
* A terminal layout, as a value.
|
|
*
|
|
* A tiled workspace is a TREE, not a list of rectangles. The list is the shape
|
|
* that looks easier — every pane carries x/y/width/height — and then it cannot
|
|
* answer the one question that matters: close a pane, and who gets the space?
|
|
* With boxes you are left recomputing every neighbour and hoping the arithmetic
|
|
* closes. With a tree the answer is structural and exact: a pane's space belongs
|
|
* to its sibling, so closing it replaces the parent split WITH that sibling and
|
|
* nothing else moves.
|
|
*
|
|
* That is the whole reason for this file. Every operation here is a pure rewrite
|
|
* of one tree into another, so the interesting behaviour — split, close, resize,
|
|
* the order you sweep through panes in — is decided and tested without a browser,
|
|
* a layout engine, or a single pixel.
|
|
*
|
|
* `row` puts children side by side (the divider is vertical, it moves left/right).
|
|
* `col` stacks them (the divider is horizontal, it moves up/down). Those are the
|
|
* CSS flex-direction words, chosen so the renderer passes the value straight
|
|
* through rather than translating a private vocabulary into the platform's.
|
|
*/
|
|
|
|
export type Dir = 'row' | 'col';
|
|
|
|
export type Tile =
|
|
| { kind: 'pane'; id: string }
|
|
/** `ratio` is the fraction of the axis given to `a` (0..1). */
|
|
| { kind: 'split'; dir: Dir; ratio: number; a: Tile; b: Tile };
|
|
|
|
/** Which branch to take at each split, from the root. `[]` is the root itself. */
|
|
export type Path = ReadonlyArray<'a' | 'b'>;
|
|
|
|
/** A layout of one pane. */
|
|
export const pane = (id: string): Tile => ({ kind: 'pane', id });
|
|
|
|
/** How far a divider may be dragged, as a fraction of its axis.
|
|
*
|
|
* A pane squeezed to nothing is not a layout choice, it is a pane you can no
|
|
* longer find or drag back — so the split keeps a floor at both ends and a
|
|
* closed pane is the only way to reach zero. */
|
|
export const MIN_RATIO = 0.1;
|
|
export const MAX_RATIO = 0.9;
|
|
|
|
export const clampRatio = (r: number): number =>
|
|
Number.isFinite(r) ? Math.min(MAX_RATIO, Math.max(MIN_RATIO, r)) : 0.5;
|
|
|
|
/** Every pane id, left-to-right and top-to-bottom.
|
|
*
|
|
* This IS the sweep order — the sequence ⌥→ / ⌥← move through, and the order the
|
|
* roster lists. It falls out of the tree rather than being tracked beside it,
|
|
* which is why moving a pane cannot desynchronise the two. */
|
|
export function paneIds(t: Tile): string[] {
|
|
return t.kind === 'pane' ? [t.id] : [...paneIds(t.a), ...paneIds(t.b)];
|
|
}
|
|
|
|
export const hasPane = (t: Tile, id: string): boolean => paneIds(t).includes(id);
|
|
|
|
/** The pane after `id` in sweep order, wrapping. `step` of -1 goes back. */
|
|
export function neighbour(t: Tile, id: string, step: 1 | -1): string | null {
|
|
const ids = paneIds(t);
|
|
const i = ids.indexOf(id);
|
|
if (i < 0 || ids.length === 0) return ids[0] ?? null;
|
|
return ids[(i + step + ids.length) % ids.length]!;
|
|
}
|
|
|
|
/** Split the pane showing `targetId`, putting `newId` in the fresh half.
|
|
*
|
|
* The new pane takes the SECOND half so a split reads the way it is drawn: the
|
|
* thing you split stays where it was, and the new one appears to its right or
|
|
* below. A `targetId` no pane shows leaves the tree untouched — a split has to
|
|
* name where it happens, and inventing a location would put a terminal somewhere
|
|
* nobody asked for.
|
|
*/
|
|
export function splitPane(t: Tile, targetId: string, dir: Dir, newId: string): Tile {
|
|
if (t.kind === 'pane') {
|
|
return t.id === targetId
|
|
? { kind: 'split', dir, ratio: 0.5, a: t, b: pane(newId) }
|
|
: t;
|
|
}
|
|
return {
|
|
...t,
|
|
a: splitPane(t.a, targetId, dir, newId),
|
|
b: splitPane(t.b, targetId, dir, newId),
|
|
};
|
|
}
|
|
|
|
/** Remove a pane. Its sibling takes the space, which is what a tree is for.
|
|
*
|
|
* Returns `null` when the last pane goes — an empty layout is a real state (no
|
|
* terminals to show) and saying so with `null` is honest, where a tree with a
|
|
* zero-size pane in it would be a lie the renderer has to keep re-checking.
|
|
*/
|
|
export function closePane(t: Tile, id: string): Tile | null {
|
|
if (t.kind === 'pane') return t.id === id ? null : t;
|
|
const a = closePane(t.a, id);
|
|
const b = closePane(t.b, id);
|
|
if (a === null) return b; // the sibling is promoted into this split's place
|
|
if (b === null) return a;
|
|
return { ...t, a, b };
|
|
}
|
|
|
|
/** Set the ratio of the split at `path`. Out-of-range values are clamped. */
|
|
export function setRatio(t: Tile, path: Path, ratio: number): Tile {
|
|
if (t.kind !== 'split') return t; // a path that outruns the tree changes nothing
|
|
if (path.length === 0) return { ...t, ratio: clampRatio(ratio) };
|
|
const [head, ...rest] = path;
|
|
return head === 'a'
|
|
? { ...t, a: setRatio(t.a, rest, ratio) }
|
|
: { ...t, b: setRatio(t.b, rest, ratio) };
|
|
}
|
|
|
|
/** Keep only panes whose id still exists, and drop the layout when none do.
|
|
*
|
|
* A layout outlives the sessions in it: it is restored from storage into a page
|
|
* whose terminals have since ended, and a pane pointing at a session that is gone
|
|
* frames a tunnel that stopped answering. Reconciling on load — rather than
|
|
* rendering a dead frame and waiting for someone to notice — is the difference
|
|
* between a saved layout and a stale one.
|
|
*/
|
|
export function retain(t: Tile, live: ReadonlySet<string>): Tile | null {
|
|
if (t.kind === 'pane') return live.has(t.id) ? t : null;
|
|
const a = retain(t.a, live);
|
|
const b = retain(t.b, live);
|
|
if (a === null) return b;
|
|
if (b === null) return a;
|
|
return { ...t, a, b };
|
|
}
|
|
|
|
// ---- derived geometry ------------------------------------------------------
|
|
//
|
|
// The tree is the MODEL; rectangles are output. Deriving them here — rather than
|
|
// nesting the panes in the DOM the way they nest in the tree — is what keeps a
|
|
// terminal alive across a split.
|
|
//
|
|
// Nested flex boxes are the obvious rendering, and they reload every terminal
|
|
// the moment you split one: a pane that changes position in the React tree is a
|
|
// new element, so its <iframe> is torn down and its shell reconnects from
|
|
// scratch. A multiplexer whose panes reset when you rearrange them is not a
|
|
// multiplexer. So the panes render as ONE flat list that never reorders, each
|
|
// positioned absolutely from the numbers below — the DOM order stays fixed, the
|
|
// iframes stay mounted, and only their coordinates change.
|
|
|
|
/** A pane's box, in percent of the container. */
|
|
export interface Rect {
|
|
id: string;
|
|
left: number;
|
|
top: number;
|
|
width: number;
|
|
height: number;
|
|
}
|
|
|
|
/** A divider's box, plus what to resize when it is dragged. */
|
|
export interface Divider {
|
|
path: Path;
|
|
dir: Dir;
|
|
left: number;
|
|
top: number;
|
|
width: number;
|
|
height: number;
|
|
}
|
|
|
|
export interface Geometry {
|
|
rects: Rect[];
|
|
dividers: Divider[];
|
|
}
|
|
|
|
/** Project a layout onto the unit square, in percent. */
|
|
export function geometry(t: Tile): Geometry {
|
|
const rects: Rect[] = [];
|
|
const dividers: Divider[] = [];
|
|
|
|
const walk = (n: Tile, path: Path, left: number, top: number, w: number, h: number) => {
|
|
if (n.kind === 'pane') {
|
|
rects.push({ id: n.id, left, top, width: w, height: h });
|
|
return;
|
|
}
|
|
const r = clampRatio(n.ratio);
|
|
if (n.dir === 'row') {
|
|
const aw = w * r;
|
|
walk(n.a, [...path, 'a'], left, top, aw, h);
|
|
walk(n.b, [...path, 'b'], left + aw, top, w - aw, h);
|
|
// The divider straddles the seam; its thickness is the renderer's, so it
|
|
// is given a zero extent on the axis it splits and offset in CSS.
|
|
dividers.push({ path, dir: n.dir, left: left + aw, top, width: 0, height: h });
|
|
} else {
|
|
const ah = h * r;
|
|
walk(n.a, [...path, 'a'], left, top, w, ah);
|
|
walk(n.b, [...path, 'b'], left, top + ah, w, h - ah);
|
|
dividers.push({ path, dir: n.dir, left, top: top + ah, width: w, height: 0 });
|
|
}
|
|
};
|
|
|
|
walk(t, [], 0, 0, 100, 100);
|
|
return { rects, dividers };
|
|
}
|
|
|
|
/** A DOM order that only ever grows.
|
|
*
|
|
* The panes render as a flat list whose ORDER decides React's reconciliation, so
|
|
* that order must never depend on anything a server sorts. It did: the list came
|
|
* straight from the sessions array, which the control plane orders by recency —
|
|
* so a session updating its `cwd` could reorder the array, move an iframe among
|
|
* its siblings, and remount every terminal on the page. The invariant this file
|
|
* exists to protect was being broken by the thing it was protecting.
|
|
*
|
|
* Appending new ids and never moving or removing an existing one makes the order
|
|
* a fact about THIS browser session rather than about the server's opinion.
|
|
*/
|
|
export function stableOrder(prev: readonly string[], present: readonly string[]): string[] {
|
|
const seen = new Set(prev);
|
|
const added = present.filter((id) => !seen.has(id));
|
|
return added.length === 0 ? (prev as string[]) : [...prev, ...added];
|
|
}
|
|
|
|
// ---- the phone projection ---------------------------------------------------
|
|
//
|
|
// A phone does not tile side by side. 390px cannot hold two terminals and stay
|
|
// legible — Blink, Termius and Prompt all refuse to, and none of them is short of
|
|
// design effort. What a phone HAS is 844px of height and a swipe.
|
|
//
|
|
// So the same tree is projected differently rather than modelled twice: a `row`
|
|
// split (side by side) becomes two PAGES you swipe between, and a `col` split
|
|
// (stacked) stays stacked, because vertical space is the space a phone has. The
|
|
// renderer is unchanged — it still consumes `Rect[]`.
|
|
|
|
export interface PageGeometry extends Geometry {
|
|
/** How many pages the layout occupies. `left` is `page * 100 + offset`. */
|
|
pages: number;
|
|
/** Which page each pane is on, for the tab strip. */
|
|
page: Record<string, number>;
|
|
}
|
|
|
|
/**
|
|
* Project a layout onto N pages of the unit square.
|
|
*
|
|
* `maxStack` bounds how many panes may share one page's height; a `col` group
|
|
* deeper than that spills onto the next page rather than rendering slivers.
|
|
*/
|
|
export function pageGeometry(t: Tile, maxStack: number): PageGeometry {
|
|
const rects: Rect[] = [];
|
|
const dividers: Divider[] = [];
|
|
const page: Record<string, number> = {};
|
|
const cap = Math.max(1, maxStack);
|
|
let next = 0;
|
|
|
|
/** How many panes a subtree wants to stack, if it were all on one page. */
|
|
const stackDepth = (n: Tile): number =>
|
|
n.kind === 'pane' ? 1 : n.dir === 'col' ? stackDepth(n.a) + stackDepth(n.b) : 1;
|
|
|
|
const place = (n: Tile, path: Path, pg: number, top: number, h: number) => {
|
|
if (n.kind === 'pane') {
|
|
rects.push({ id: n.id, left: pg * 100, top, width: 100, height: h });
|
|
page[n.id] = pg;
|
|
return;
|
|
}
|
|
if (n.dir === 'row') {
|
|
// Side by side does not fit: each half becomes its own page.
|
|
place(n.a, [...path, 'a'], pg, top, h);
|
|
place(n.b, [...path, 'b'], next++, 0, 100);
|
|
return;
|
|
}
|
|
// Stacked, at its ratio — unless the stack is deeper than the page allows.
|
|
if (stackDepth(n) > cap) {
|
|
place(n.a, [...path, 'a'], pg, top, h);
|
|
place(n.b, [...path, 'b'], next++, 0, 100);
|
|
return;
|
|
}
|
|
const r = clampRatio(n.ratio);
|
|
const ah = h * r;
|
|
place(n.a, [...path, 'a'], pg, top, ah);
|
|
place(n.b, [...path, 'b'], pg, top + ah, h - ah);
|
|
dividers.push({ path, dir: 'col', left: pg * 100, top: top + ah, width: 100, height: 0 });
|
|
};
|
|
|
|
next = 1;
|
|
place(t, [], 0, 0, 100);
|
|
return { rects, dividers, pages: Math.max(next, 1), page };
|
|
}
|
|
|
|
/** How many panes may share one phone page, from the room actually available.
|
|
*
|
|
* Derived, not decreed: the legibility floor is ~20 rows, which at 13px mono plus
|
|
* a header is about 260px. A 844px phone therefore holds two; in landscape it
|
|
* holds one, which is the honest answer rather than three slivers. */
|
|
export const stackFor = (availableHeight: number): number =>
|
|
Math.min(3, Math.max(1, Math.floor(availableHeight / 260)));
|