import { isKind, keyOf, spec } from "./kinds.ts"; import { apply, emptyLayout, normalize, type Command, type Env, type Layout, type LayoutNode, type NodeId, type OpenRequest, type Panel, type PanelId, type PanelKind, type Placement, type Slot, type Stack, } from "./layout.ts"; const SLOTS: readonly Slot[] = ["main", "primary", "secondary", "panel"]; export type Unsubscribe = () => void; export interface WorkbenchOptions { readonly layout?: Layout; /** Id source. Tests pass a counter; the default is a random UUID. */ readonly id?: () => string; } /** * The shell's headless core (D12). It owns one viewer's layout, applies commands to it, and tells * subscribers when it changed. A renderer, a keybinding, a palette, and an agent all drive it with * the same `Command`s. */ export class Workbench { readonly env: Env; #layout: Layout; #listeners = new Set<(layout: Layout) => void>(); constructor({ layout, id = randomId }: WorkbenchOptions = {}) { this.env = { id, key: keyOf, slot: (kind) => spec(kind).slot }; // A workbench starts with no windows of its own, and a page can't reopen popups without a gesture, // so panels a stored layout has floating come back to their kinds' slots. this.#layout = (layout?.floating ?? []).flatMap((stack) => stack.panels).reduce( (docked, panel) => apply(docked, { type: "move", panel, to: { slot: spec(docked.panels[panel]!.kind).slot } }, this.env), layout ?? emptyLayout(this.env), ); } get layout(): Layout { return this.#layout; } dispatch(command: Command): void { const next = apply(this.#layout, command, this.env); if (next === this.#layout) return; this.#layout = next; for (const listener of this.#listeners) listener(next); } /** Opens a view, or activates it if it is already open, and returns its panel id. */ open(request: OpenRequest, at?: Placement): PanelId { this.dispatch({ type: "open", ...request, ...(at && { at }) } as Command); const key = keyOf(request); const panel = Object.values(this.#layout.panels).find((p) => p.kind === request.kind && keyOf(p) === key); if (!panel) throw new Error(`open did not create a ${request.kind} panel`); return panel.id; } /** The panels of one kind, for views that want to find their siblings (an explorer's open editors). */ panels(kind: K): Extract[] { return Object.values(this.#layout.panels).filter((p): p is Extract => p.kind === kind); } subscribe(listener: (layout: Layout) => void): Unsubscribe { this.#listeners.add(listener); return () => this.#listeners.delete(listener); } } /** * Parses a stored layout. Storage is a boundary: anything that fails a check returns `undefined`, * and the caller starts from a default layout instead. */ export function parseLayout(value: unknown): Layout | undefined { if (!isRecord(value) || value.version !== 1 || !isRecord(value.panels) || !Array.isArray(value.hidden)) return; const panels: Record = {}; for (const [id, panel] of Object.entries(value.panels)) { if (!isRecord(panel) || panel.id !== id || !isKind(panel.kind) || !isRecord(panel.input)) return; const input = panel.input; const fields = Object.entries(spec(panel.kind).fields) as [string, "string" | "string | null"][]; if (!fields.every(([field, type]) => typeof input[field] === "string" || (type === "string | null" && input[field] === null))) return; panels[id] = panel as unknown as Panel; } const seen = new Set(); const root = parseNode(value.root, panels, seen); if (!root || typeof value.focus !== "string") return; // Older layouts have no floating list. A floating stack is never slotted. const floating: Stack[] = []; for (const item of Array.isArray(value.floating) ? value.floating : []) { const node = parseNode(item, panels, seen); if (node?.type !== "stack") continue; const { slot: _, ...stack } = node; floating.push(stack); } const hidden = value.hidden.filter((slot): slot is Exclude => slot !== "main" && SLOTS.includes(slot)); // Panels no stack shows are dropped rather than rejected. A layout needs exactly one main stack. const shown = Object.fromEntries(Object.entries(panels).filter(([id]) => seen.has(id))) as Record; const layout: Layout = { version: 1, root, panels: shown, focus: value.focus as NodeId, hidden, floating }; return countMains(root) === 1 ? normalize(layout) : undefined; } function parseNode(value: unknown, panels: Record, seen: Set): LayoutNode | undefined { if (!isRecord(value) || typeof value.id !== "string") return; if (value.type === "stack") { if (!Array.isArray(value.panels)) return; // Ids are branded here, at the boundary, after the checks that make them real. const ids = value.panels.filter((id): id is PanelId => typeof id === "string" && id in panels && !seen.has(id)); ids.forEach((id) => seen.add(id)); const slot = SLOTS.find((s) => s === value.slot); const active = ids.find((id) => id === value.active) ?? ids[0] ?? null; return { type: "stack", id: value.id as NodeId, panels: ids, active, ...(slot && { slot }) }; } if (value.type === "split") { if (value.axis !== "row" && value.axis !== "column") return; if (!Array.isArray(value.children) || !Array.isArray(value.sizes)) return; if (value.children.length !== value.sizes.length) return; const children = value.children.map((child) => parseNode(child, panels, seen)); if (children.some((child) => !child)) return; const sizes = value.sizes.map((size) => (typeof size === "number" ? size : 0)); return { type: "split", id: value.id as NodeId, axis: value.axis, children: children as LayoutNode[], sizes }; } return; } function countMains(node: LayoutNode): number { if (node.type === "stack") return node.slot === "main" ? 1 : 0; return node.children.reduce((sum, child) => sum + countMains(child), 0); } /** * A random v4 UUID. crypto.randomUUID exists only in secure contexts, so a page served over plain * HTTP (a phone on the dev server's tailnet address, say) builds one from getRandomValues, which works anywhere. */ function randomId(): string { if (typeof crypto.randomUUID === "function") return crypto.randomUUID(); const bytes = crypto.getRandomValues(new Uint8Array(16)); bytes[6] = (bytes[6]! & 0x0f) | 0x40; bytes[8] = (bytes[8]! & 0x3f) | 0x80; const hex = [...bytes].map((b) => b.toString(16).padStart(2, "0")).join(""); return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20)}`; } function isRecord(value: unknown): value is Record { return typeof value === "object" && value !== null && !Array.isArray(value); }