Something went wrong. Try again.
Monorepo for the nibling.run platform
Something went wrong. Try again.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398declare const brand: unique symbol;type Brand<T, B extends string> = T & { readonly [brand]: B };
export type PanelId = Brand<string, "PanelId">;export type NodeId = Brand<string, "NodeId">;
/** * Panel inputs by kind. Each kind's input says what the panel is a view of (D11). * Apps add their own kinds by augmenting this interface. */export interface PanelInputs { explorer: { readonly root: string }; editor: { readonly path: string }; preview: { readonly path: string }; terminal: { readonly cwd: string; readonly session: string }; agent: { readonly conversation: string }; /** The process table of a sandbox. There is one sandbox, "default", for now. */ processes: { readonly sandbox: string }; /** * A page a sandbox serves, by the address its server listens on: `http://localhost:3000/`. `null` is a * preview not bound to a server yet; the first port that listens binds it. */ browser: { readonly url: string | null };}
export type PanelKind = keyof PanelInputs;
export type Panel = { [K in PanelKind]: { readonly id: PanelId; readonly kind: K; readonly input: PanelInputs[K] };}[PanelKind];
/** * A slot names a stack's role, as VS Code's parts do. Opening a kind goes to its slot's stack, * and toggling a slot hides that stack without losing its size. `main` always exists. */export type Slot = "main" | "primary" | "secondary" | "panel";export type Axis = "row" | "column";export type Edge = "left" | "right" | "top" | "bottom";
export interface Stack { readonly type: "stack"; readonly id: NodeId; readonly slot?: Slot; readonly panels: readonly PanelId[]; readonly active: PanelId | null;}
export interface Split { readonly type: "split"; readonly id: NodeId; readonly axis: Axis; readonly children: readonly LayoutNode[]; /** Fractions of the split's length, one per child, summing to 1. */ readonly sizes: readonly number[];}
export type LayoutNode = Stack | Split;
/** One person's arrangement of panels. It is per viewer, not shared, so it is plain data, not a CRDT. */export interface Layout { readonly version: 1; readonly root: LayoutNode; readonly panels: Readonly<Record<PanelId, Panel>>; readonly focus: NodeId; readonly hidden: readonly Slot[]; /** * Stacks torn off into windows of their own, in the order they opened. A floating stack holds tabs * like any other but has no slot, never takes layout focus, and never splits. */ readonly floating: readonly Stack[];}
export type Placement = | { readonly slot: Slot } | { readonly stack: NodeId; readonly index?: number } | { readonly beside: NodeId; readonly edge: Edge };
export type OpenRequest = { [K in PanelKind]: { readonly kind: K; readonly input: PanelInputs[K] } }[PanelKind];
export type Command = | ({ readonly type: "open"; readonly at?: Placement } & OpenRequest) | { readonly type: "close"; readonly panel: PanelId } | { readonly type: "activate"; readonly panel: PanelId } /** Points a panel at another input of its kind, in place. A no-op when that view is open already. */ | ({ readonly type: "retarget"; readonly panel: PanelId } & OpenRequest) | { readonly type: "move"; readonly panel: PanelId; readonly to: Placement } | { readonly type: "float"; readonly panel: PanelId } | { readonly type: "resize"; readonly split: NodeId; readonly sizes: readonly number[] } | { readonly type: "toggle"; readonly slot: Exclude<Slot, "main">; readonly visible?: boolean } | { readonly type: "focus"; readonly stack: NodeId };
/** What the reducer needs from outside: fresh ids, and each kind's identity and home slot. */export interface Env { readonly id: () => string; /** Two panels of one kind with the same key are the same view; opening it again activates it. */ readonly key: (request: OpenRequest) => string; readonly slot: (kind: PanelKind) => Slot;}
/** Default share of the parent a slot gets when the shell recreates its stack. */const SLOT_SHARE: Record<Exclude<Slot, "main">, number> = { primary: 0.2, secondary: 0.25, panel: 0.3 };const SLOT_EDGE: Record<Exclude<Slot, "main">, Edge> = { primary: "left", secondary: "right", panel: "bottom" };
export function emptyLayout(env: Env): Layout { const main: Stack = { type: "stack", id: env.id() as NodeId, slot: "main", panels: [], active: null }; return { version: 1, root: main, panels: {}, focus: main.id, hidden: [], floating: [] };}
export function apply(layout: Layout, command: Command, env: Env): Layout { switch (command.type) { case "open": return open(layout, command, command.at ?? { slot: env.slot(command.kind) }, env); case "close": return command.panel in layout.panels ? normalize(removePanel(layout, command.panel)) : layout; case "activate": { const stack = owner(layout, command.panel); if (!stack) return layout; return reveal(withStack(layout, { ...stack, active: command.panel }), stack.id); } case "retarget": { const { type: _, panel: id, ...request } = command; const panel = layout.panels[id]; const key = env.key(request); if (panel?.kind !== request.kind || Object.values(layout.panels).some((p) => p.kind === request.kind && env.key(p) === key)) return layout; return { ...layout, panels: { ...layout.panels, [id]: { ...panel, input: request.input } as Panel } }; } case "move": return move(layout, command.panel, command.to, env); case "float": { const from = owner(layout, command.panel); if (!from) return layout; const window: Stack = { type: "stack", id: env.id() as NodeId, panels: [command.panel], active: command.panel }; const detached = withStack(layout, withoutPanel(from, command.panel)); return normalize({ ...detached, floating: [...detached.floating, window] }); } case "resize": { const split = findNode(layout.root, command.split); if (split?.type !== "split" || split.children.length !== command.sizes.length) return layout; return { ...layout, root: replaceNode(layout.root, split.id, { ...split, sizes: fractions(command.sizes) }) }; } case "toggle": { const visible = command.visible ?? layout.hidden.includes(command.slot); const hidden = layout.hidden.filter((slot) => slot !== command.slot); if (visible) return { ...layout, hidden }; const next = { ...layout, hidden: [...hidden, command.slot] }; const focused = findNode(layout.root, layout.focus); return focused?.type === "stack" && focused.slot === command.slot ? { ...next, focus: mainStack(next.root).id } : next; } case "focus": return findNode(layout.root, command.stack)?.type === "stack" ? reveal(layout, command.stack) : layout; }}
/** Every stack in document order, for renderers and tests. */export function stacks(node: LayoutNode): Stack[] { return node.type === "stack" ? [node] : node.children.flatMap(stacks);}
export function stackOf(node: LayoutNode, panel: PanelId): Stack | undefined { return stacks(node).find((stack) => stack.panels.includes(panel));}
export function findNode(node: LayoutNode, id: NodeId): LayoutNode | undefined { if (node.id === id) return node; if (node.type === "stack") return undefined; for (const child of node.children) { const found = findNode(child, id); if (found) return found; } return undefined;}
export function mainStack(node: LayoutNode): Stack { const main = stacks(node).find((stack) => stack.slot === "main"); if (!main) throw new Error("layout has no main stack"); return main;}
/** Whether a node renders: a stack in a hidden slot does not, and neither does a split whose children all don't. */export function isVisible(layout: Layout, node: LayoutNode): boolean { if (node.type === "stack") return !node.slot || !layout.hidden.includes(node.slot); return node.children.some((child) => isVisible(layout, child));}
function open(layout: Layout, request: Command & { type: "open" }, at: Placement, env: Env): Layout { const key = env.key(request); const existing = Object.values(layout.panels).find((panel) => panel.kind === request.kind && env.key(panel) === key); if (existing) return apply(layout, { type: "activate", panel: existing.id }, env); const panel = { id: env.id() as PanelId, kind: request.kind, input: request.input } as Panel; return normalize(place({ ...layout, panels: { ...layout.panels, [panel.id]: panel } }, panel.id, at, env));}
function move(layout: Layout, panel: PanelId, to: Placement, env: Env): Layout { const from = owner(layout, panel); if (!from) return layout; // Detach first so a reorder within one stack sees the right indices. An emptied source stack // stays in the tree until `normalize`, so placing beside it still finds it. const detached = withStack(layout, withoutPanel(from, panel)); return normalize(place(detached, panel, to, env));}
/** Puts an already-registered panel at a placement, creating a stack when the placement asks for one. */function place(layout: Layout, panel: PanelId, at: Placement, env: Env): Layout { if ("slot" in at) { const stack = stacks(layout.root).find((s) => s.slot === at.slot); if (stack) return place(layout, panel, { stack: stack.id }, env); // The slot's stack was closed. Recreate it where VS Code keeps that part. const slot = at.slot as Exclude<Slot, "main">; if (slot !== "panel") return insertStack(layout, panel, layout.root.id, SLOT_EDGE[slot], SLOT_SHARE[slot], env, slot); const area = editorArea(layout.root, env); return insertStack({ ...layout, root: area.root }, panel, area.id, SLOT_EDGE[slot], SLOT_SHARE[slot], env, slot); } if ("beside" in at) { const anchor = findStack(layout, at.beside); // Beside a stack that can't split that way joins it instead. if (anchor && !splitEdges(layout, anchor).includes(at.edge)) return place(layout, panel, { stack: anchor.id }, env); return insertStack(layout, panel, at.beside, at.edge, 0.5, env); } const stack = findStack(layout, at.stack); if (!stack) return place(layout, panel, { slot: "main" }, env); const panels = [...stack.panels]; panels.splice(Math.min(at.index ?? panels.length, panels.length), 0, panel); return reveal(withStack(layout, { ...stack, panels, active: panel }), stack.id);}
function insertStack( layout: Layout, panel: PanelId, anchor: NodeId, edge: Edge, share: number, env: Env, slot?: Slot,): Layout { const target = findNode(layout.root, anchor) ?? layout.root; const stack: Stack = { type: "stack", id: env.id() as NodeId, panels: [panel], active: panel, ...(slot && { slot }) }; const axis: Axis = edge === "left" || edge === "right" ? "row" : "column"; const before = edge === "left" || edge === "top"; const parent = parentOf(layout.root, target.id); let root: LayoutNode; if (parent && parent.axis === axis) { const at = parent.children.indexOf(target); const children = [...parent.children]; const sizes = [...parent.sizes]; const taken = sizes[at]! * share; sizes[at] = sizes[at]! - taken; children.splice(before ? at : at + 1, 0, stack); sizes.splice(before ? at : at + 1, 0, taken); root = replaceNode(layout.root, parent.id, { ...parent, children, sizes }); } else { const split: Split = { type: "split", id: env.id() as NodeId, axis, children: before ? [stack, target] : [target, stack], sizes: before ? [share, 1 - share] : [1 - share, share], }; root = replaceNode(layout.root, target.id, split); } return reveal({ ...layout, root }, stack.id);}
/** * Finds VS Code's editor area: the main stack plus the siblings around it that hold no other * slotted stack. When the area is a run of siblings rather than one node, it is grouped into its * own split so the panel slot can span it. */function editorArea(node: LayoutNode, env: Env): { root: LayoutNode; id: NodeId } { const clean = (n: LayoutNode) => stacks(n).every((stack) => !stack.slot || stack.slot === "main"); if (node.type === "stack" || clean(node)) return { root: node, id: node.id }; const at = node.children.findIndex((child) => stacks(child).some((stack) => stack.slot === "main")); const holder = node.children[at]!; if (!clean(holder)) { const inner = editorArea(holder, env); return { root: replaceNode(node, holder.id, inner.root), id: inner.id }; } let [lo, hi] = [at, at]; while (lo > 0 && clean(node.children[lo - 1]!)) lo--; while (hi < node.children.length - 1 && clean(node.children[hi + 1]!)) hi++; if (lo === hi) return { root: node, id: holder.id }; const group: Split = { type: "split", id: env.id() as NodeId, axis: node.axis, children: node.children.slice(lo, hi + 1), sizes: fractions(node.sizes.slice(lo, hi + 1)), }; const weight = node.sizes.slice(lo, hi + 1).reduce((sum, size) => sum + size, 0); const children = [...node.children.slice(0, lo), group, ...node.children.slice(hi + 1)]; const sizes = [...node.sizes.slice(0, lo), weight, ...node.sizes.slice(hi + 1)]; return { root: { ...node, children, sizes }, id: group.id };}
function removePanel(layout: Layout, panel: PanelId): Layout { const stack = owner(layout, panel); const { [panel]: _, ...panels } = layout.panels; if (!stack) return { ...layout, panels }; return { ...withStack(layout, withoutPanel(stack, panel)), panels };}
/** The stack showing a panel, in the layout tree or in a floating window. */export function owner(layout: Layout, panel: PanelId): Stack | undefined { return stackOf(layout.root, panel) ?? layout.floating.find((stack) => stack.panels.includes(panel));}
/** A stack by id, in the layout tree or in a floating window. */export function findStack(layout: Layout, id: NodeId): Stack | undefined { const node = findNode(layout.root, id); return node?.type === "stack" ? node : layout.floating.find((stack) => stack.id === id);}
/** The edges a stack splits toward. Side bars are narrow, so they only stack; a window is one stack and never splits. */export function splitEdges(layout: Layout, stack: Stack): readonly Edge[] { if (isFloating(layout, stack.id)) return []; if (stack.slot === "primary" || stack.slot === "secondary") return ["top", "bottom"]; return ["left", "right", "top", "bottom"];}
export function isFloating(layout: Layout, id: NodeId): boolean { return layout.floating.some((stack) => stack.id === id);}
function withoutPanel(stack: Stack, panel: PanelId): Stack { const at = stack.panels.indexOf(panel); const panels = stack.panels.filter((id) => id !== panel); const active = stack.active === panel ? (panels[Math.min(at, panels.length - 1)] ?? null) : stack.active; return { ...stack, panels, active };}
function withStack(layout: Layout, stack: Stack): Layout { if (isFloating(layout, stack.id)) return { ...layout, floating: layout.floating.map((s) => (s.id === stack.id ? stack : s)) }; return { ...layout, root: replaceNode(layout.root, stack.id, stack) };}
/** Focuses a stack and unhides its slot, since a focused stack must be on screen. A window has its own focus. */function reveal(layout: Layout, stack: NodeId): Layout { if (isFloating(layout, stack)) return layout; const node = findNode(layout.root, stack); const slot = node?.type === "stack" ? node.slot : undefined; return { ...layout, focus: stack, hidden: layout.hidden.filter((s) => s !== slot) };}
/** * Restores the invariants every command relies on: no empty stacks except `main`, no split with * fewer than two children, no split nested in one of the same axis, sizes that sum to 1, and a * focus that points at a live stack. */export function normalize(layout: Layout): Layout { const root = prune(layout.root) ?? { type: "stack", id: layout.focus, slot: "main", panels: [], active: null }; const live = new Set(stacks(root).map((stack) => stack.id)); const floating = layout.floating.filter((stack) => stack.panels.length > 0); return { ...layout, root, floating, focus: live.has(layout.focus) ? layout.focus : mainStack(root).id };}
function prune(node: LayoutNode): LayoutNode | undefined { if (node.type === "stack") return node.panels.length > 0 || node.slot === "main" ? node : undefined; const children: LayoutNode[] = []; const sizes: number[] = []; node.children.forEach((child, i) => { const kept = prune(child); if (!kept) return; if (kept.type === "split" && kept.axis === node.axis) { kept.children.forEach((grandchild, j) => { children.push(grandchild); sizes.push(node.sizes[i]! * kept.sizes[j]!); }); } else { children.push(kept); sizes.push(node.sizes[i]!); } }); if (children.length === 0) return undefined; if (children.length === 1) return children[0]; return { ...node, children, sizes: fractions(sizes) };}
function parentOf(node: LayoutNode, id: NodeId): Split | undefined { if (node.type === "stack") return undefined; if (node.children.some((child) => child.id === id)) return node; for (const child of node.children) { const found = parentOf(child, id); if (found) return found; } return undefined;}
function replaceNode(node: LayoutNode, id: NodeId, next: LayoutNode): LayoutNode { if (node.id === id) return next; if (node.type === "stack") return node; return { ...node, children: node.children.map((child) => replaceNode(child, id, next)) };}
function fractions(sizes: readonly number[]): number[] { const clamped = sizes.map((size) => (Number.isFinite(size) && size > 0 ? size : 0)); const total = clamped.reduce((sum, size) => sum + size, 0); return total > 0 ? clamped.map((size) => size / total) : sizes.map(() => 1 / sizes.length);}