declare const brand: unique symbol; type Brand = T & { readonly [brand]: B }; export type PanelId = Brand; export type NodeId = Brand; /** * 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>; 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; 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, number> = { primary: 0.2, secondary: 0.25, panel: 0.3 }; const SLOT_EDGE: Record, 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; 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); }