feat(desktop): layout-tree model + store + workspace geometry

fix/verification-admin-route-recovery
Brooklyn Nicholson 2026-07-13 17:28:21 -04:00
parent 7ed7170960
commit c388daa665
3 changed files with 1932 additions and 0 deletions

View File

@ -0,0 +1,270 @@
/**
* Pane geometry AABB INTERSECTION WINDOW-CONTROL AWARENESS. The native
* window controls (macOS traffic lights top-left / Windows-WSLg overlay
* top-right) are a rectangle in viewport pixels. Any region whose rect
* intersects it must reserve that space and expose a drag strip. One
* `intersect()` call replaces per-layout inset special cases.
*
* Also publishes the WORKSPACE zone's edges as CSS vars (see
* publishWorkspaceGeometry) chrome that aligns to the main pane (titlebar
* title et al) consumes `var(--workspace-left/right)` in plain CSS instead of
* threading rects through React.
*/
import { type RefObject, useLayoutEffect, useState, useSyncExternalStore } from 'react'
import { $layoutTree } from '@/components/pane-shell/tree/store'
import { $connection } from '@/store/session'
// ---------------------------------------------------------------------------
// Rects
// ---------------------------------------------------------------------------
export interface Rect {
x: number
y: number
width: number
height: number
}
/** AABB intersection. Returns null when the rects don't overlap. */
export function intersect(a: Rect, b: Rect): Rect | null {
const x = Math.max(a.x, b.x)
const y = Math.max(a.y, b.y)
const right = Math.min(a.x + a.width, b.x + b.width)
const bottom = Math.min(a.y + a.height, b.y + b.height)
if (right <= x || bottom <= y) {
return null
}
return { x, y, width: right - x, height: bottom - y }
}
// ---------------------------------------------------------------------------
// Native window controls rect
// ---------------------------------------------------------------------------
/** Height of the band the native controls live in. */
const CONTROLS_BAND_HEIGHT = 34
/** Width of the macOS traffic-light cluster measured from the buttons' x. */
const MACOS_LIGHTS_WIDTH = 58
const MACOS_FALLBACK_BUTTON_X = 24
interface ConnectionLike {
windowButtonPosition?: { x: number; y: number } | null
nativeOverlayWidth?: number | null
isFullscreen?: boolean | null
}
/**
* The native window-control rectangle in viewport pixels, or null when there
* is nothing to dodge (fullscreen, plain browser, secondary windows with
* hidden controls).
*/
export function windowControlsRect(connection: ConnectionLike | null, viewportWidth: number): Rect | null {
const inElectron = typeof window !== 'undefined' && 'hermesDesktop' in window
if (!inElectron) {
return null
}
if (connection?.isFullscreen) {
return null
}
// Windows / WSLg: native overlay on the top-right.
const overlayWidth = connection?.nativeOverlayWidth ?? 0
if (overlayWidth > 0) {
return { x: viewportWidth - overlayWidth, y: 0, width: overlayWidth, height: CONTROLS_BAND_HEIGHT }
}
// macOS: traffic lights on the top-left. windowButtonPosition === null means
// the platform has no left-side controls at all (Windows/Linux w/o overlay).
const pos = connection?.windowButtonPosition
if (pos === null) {
return null
}
const isMac = typeof navigator !== 'undefined' && /mac/i.test(navigator.platform)
if (!pos && !isMac) {
return null
}
const x = pos?.x ?? MACOS_FALLBACK_BUTTON_X
return { x: 0, y: 0, width: x + MACOS_LIGHTS_WIDTH, height: CONTROLS_BAND_HEIGHT }
}
// ---------------------------------------------------------------------------
// Live hook
// ---------------------------------------------------------------------------
let cachedRect: Rect | null = null
let cachedKey = ''
function rectKey(r: Rect | null) {
return r ? `${r.x},${r.y},${r.width},${r.height}` : ''
}
function readControlsRect(): Rect | null {
const next = windowControlsRect($connection.get(), typeof window === 'undefined' ? 0 : window.innerWidth)
const key = rectKey(next)
// Referentially stable snapshot for useSyncExternalStore.
if (key !== cachedKey) {
cachedKey = key
cachedRect = next
}
return cachedRect
}
function subscribeControlsRect(cb: () => void) {
const unsubConnection = $connection.subscribe(() => cb())
window.addEventListener('resize', cb)
return () => {
unsubConnection()
window.removeEventListener('resize', cb)
}
}
/** Reactive native window-controls rect (connection + viewport aware). */
export function useWindowControlsRect(): Rect | null {
return useSyncExternalStore(subscribeControlsRect, readControlsRect, () => null)
}
// ---------------------------------------------------------------------------
// Per-element overlap
// ---------------------------------------------------------------------------
function sameRect(a: Rect | null, b: Rect | null) {
if (a === b) {return true}
if (!a || !b) {return false}
return a.x === b.x && a.y === b.y && a.width === b.width && a.height === b.height
}
// ---------------------------------------------------------------------------
// Workspace-edge CSS vars
// ---------------------------------------------------------------------------
/**
* Publish the workspace zone's viewport edges as root CSS vars:
* --workspace-left : px from the viewport's left to the main zone
* --workspace-right : px from the main zone's right to the viewport's right
*
* Measured once per relevant change (zone resize via ResizeObserver, layout
* mutations via $layoutTree, window resize) and consumed in plain CSS the
* measured-var idiom (--composer-measured-height), not per-component JS
* geometry. Call once from the tree root; returns the disposer.
*/
export function publishWorkspaceGeometry(): () => void {
const root = document.documentElement
let el: HTMLElement | null = null
let lastLeft = NaN
let lastRight = NaN
const ro = new ResizeObserver(() => measure())
const measure = () => {
const next = document.querySelector<HTMLElement>('[data-session-anchor="workspace"]')
if (next !== el) {
if (el) {
ro.unobserve(el)
}
el = next
if (el) {
ro.observe(el)
}
}
if (!el) {
return
}
const r = el.getBoundingClientRect()
const left = Math.round(r.left)
const right = Math.round(window.innerWidth - r.right)
// Skip unchanged writes: a sash drag fires the RO every frame, and each
// :root custom-property set dirties style for everything that reads them.
if (left !== lastLeft) {
lastLeft = left
root.style.setProperty('--workspace-left', `${left}px`)
}
if (right !== lastRight) {
lastRight = right
root.style.setProperty('--workspace-right', `${right}px`)
}
}
// Tree mutations move zones without resizing them (⌘\ flip) — re-measure a
// frame later, after the DOM committed. RO covers width changes (sash drags,
// side collapses); window resize covers the rest.
const unsubTree = $layoutTree.listen(() => requestAnimationFrame(measure))
window.addEventListener('resize', measure)
measure()
return () => {
unsubTree()
window.removeEventListener('resize', measure)
ro.disconnect()
root.style.removeProperty('--workspace-left')
root.style.removeProperty('--workspace-right')
}
}
/**
* Intersects an element's live viewport rect with the native window-controls
* rect. Returns the overlap in ELEMENT-LOCAL coordinates (null when clear), so
* the consumer can reserve the space and paint a drag strip without knowing
* anything about platform, fullscreen state, or layout position.
*/
export function useWindowControlsOverlap(ref: RefObject<HTMLElement | null>, enabled = true): Rect | null {
const controls = useWindowControlsRect()
const [overlap, setOverlap] = useState<Rect | null>(null)
useLayoutEffect(() => {
const el = ref.current
if (!enabled || !controls || !el) {
setOverlap(null)
return
}
const update = () => {
const r = el.getBoundingClientRect()
const hit = intersect(controls, { x: r.x, y: r.y, width: r.width, height: r.height })
const local = hit ? { x: hit.x - r.x, y: hit.y - r.y, width: hit.width, height: hit.height } : null
setOverlap(prev => (sameRect(prev, local) ? prev : local))
}
update()
// Size changes fire the observer; cross-window moves fire `resize`. A pane
// shifted only by a sibling's resize re-measures on its own grid reflow
// (its track width changes), so this covers the shell's real cases.
const ro = new ResizeObserver(update)
ro.observe(el)
window.addEventListener('resize', update)
return () => {
ro.disconnect()
window.removeEventListener('resize', update)
}
}, [controls, enabled, ref])
return overlap
}

View File

@ -0,0 +1,613 @@
/**
* Layout tree model the Dockview-style structure that replaces the
* rails/bands grammar. Two node kinds:
*
* - `split`: children laid out along an orientation with fractional weights.
* - `group`: a stack of panes (tabs) with one active; may be minimized to
* its header strip (DetailPane semantics).
*
* Everything the old grammar special-cased is just tree shape here: a "top row
* spanning the right rail" is a column split; "a cell inside a column" is a
* stacked group; spans fall out of tree position. All operations are pure and
* return new trees; `normalize` keeps the structure canonical (no empty
* groups, no single-child or same-orientation nested splits).
*/
export type Orientation = 'row' | 'column'
export interface SplitNode {
type: 'split'
id: string
orientation: Orientation
children: LayoutNode[]
/** Parallel to children; relative flex weights. */
weights: number[]
}
export interface GroupNode {
type: 'group'
id: string
/** Pane ids stacked in this group (rendered as tabs when > 1). */
panes: string[]
/** The visible pane. */
active: string
/** Collapsed to header strip (chevron restores). */
minimized?: boolean
/**
* Header hidden entirely (double-click the header to hide, double-click the
* zone's top edge to bring it back). Minimize always shows the header
* a minimized group IS its header.
*/
headerHidden?: boolean
}
export type LayoutNode = SplitNode | GroupNode
/** Where a dragged pane lands relative to a target group. */
export type DropPosition = 'center' | 'left' | 'right' | 'top' | 'bottom'
export type RootEdge = 'left' | 'right' | 'top' | 'bottom'
let seq = 0
export const nodeId = (kind: string) => `${kind}-${Date.now().toString(36)}-${(seq++).toString(36)}`
export const group = (panes: string[], options?: Partial<Omit<GroupNode, 'type' | 'panes'>>): GroupNode => ({
type: 'group',
id: options?.id ?? nodeId('g'),
panes,
active: options?.active ?? panes[0] ?? '',
minimized: options?.minimized,
headerHidden: options?.headerHidden
})
export const split = (
orientation: Orientation,
children: LayoutNode[],
weights?: number[],
id?: string
): SplitNode => ({
type: 'split',
id: id ?? nodeId('s'),
orientation,
children,
weights: weights ?? children.map(() => 1)
})
// ---------------------------------------------------------------------------
// Queries
// ---------------------------------------------------------------------------
export function findGroup(node: LayoutNode, groupId: string): GroupNode | null {
if (node.type === 'group') {
return node.id === groupId ? node : null
}
for (const child of node.children) {
const hit = findGroup(child, groupId)
if (hit) {
return hit
}
}
return null
}
export function findGroupOfPane(node: LayoutNode, paneId: string): GroupNode | null {
if (node.type === 'group') {
return node.panes.includes(paneId) ? node : null
}
for (const child of node.children) {
const hit = findGroupOfPane(child, paneId)
if (hit) {
return hit
}
}
return null
}
export function allPaneIds(node: LayoutNode): string[] {
return node.type === 'group' ? [...node.panes] : node.children.flatMap(allPaneIds)
}
// ---------------------------------------------------------------------------
// Structural edits (pure)
// ---------------------------------------------------------------------------
/**
* Canonical form: unwrap single-child splits, flatten same-orientation
* nesting (weights scaled into the parent's slot), and PRUNE EMPTY GROUPS
* dragging the last pane out of a zone closes the zone and its siblings
* absorb the space (VS Code semantics). Keeping empties as "stable regions"
* (the original FancyZones rule) let invisible residue accumulate into
* corrupt-feeling structure (`row([]|[])` eating half a slot); authored
* empty zones still exist inside the zone editor's own grid model, and an
* editor-applied tree keeps them until the first structural op.
*/
export function normalize(node: LayoutNode): LayoutNode | null {
if (node.type === 'group') {
if (node.panes.length === 0) {
return null
}
const active = node.panes.includes(node.active) ? node.active : node.panes[0]
// A zone down to one pane clears a redundant HIDDEN override (the lone-pane
// default is already headerless) but KEEPS an explicit SHOWN override —
// once a zone has ever had a tab bar, closing back to one tab leaves it
// shown (sticky bar; the off switch is "Hide tab bar"). `false` survives.
const headerHidden = node.panes.length <= 1 && node.headerHidden !== false ? undefined : node.headerHidden
if (active === node.active && headerHidden === node.headerHidden) {
return node
}
return { ...node, active, headerHidden }
}
const children: LayoutNode[] = []
const weights: number[] = []
node.children.forEach((child, i) => {
const kept = normalize(child)
if (!kept) {
return
}
if (kept.type === 'split' && kept.orientation === node.orientation) {
// Flatten: distribute this slot's weight across the flattened children
// proportionally to their internal weights.
const total = kept.weights.reduce((a, b) => a + b, 0) || 1
kept.children.forEach((grandchild, j) => {
children.push(grandchild)
weights.push((node.weights[i] ?? 1) * ((kept.weights[j] ?? 1) / total))
})
return
}
children.push(kept)
weights.push(node.weights[i] ?? 1)
})
if (children.length === 0) {
return null
}
if (children.length === 1) {
return children[0]
}
return { ...node, children, weights }
}
/** Remove a pane wherever it lives. Closing the ACTIVE tab activates its
* previous neighbor (the next one when it was first) browser-tab feel,
* never a jump to the strip's start. */
export function removePane(node: LayoutNode, paneId: string): LayoutNode | null {
const walk = (n: LayoutNode): LayoutNode => {
if (n.type === 'group') {
const at = n.panes.indexOf(paneId)
if (at === -1) {
return n
}
const panes = n.panes.filter(p => p !== paneId)
return { ...n, panes, active: n.active === paneId ? panes[Math.max(0, at - 1)] : n.active }
}
return { ...n, children: n.children.map(walk) }
}
return normalize(walk(node))
}
/**
* Insert `paneId` at `target` group: `center` joins the stack (as a tab);
* an edge splits the group in that direction. If the neighboring split
* already runs in that orientation the new group is spliced in beside the
* target instead of nesting (normalize would flatten it anyway).
*/
export function insertAtGroup(
node: LayoutNode,
targetGroupId: string,
paneId: string,
pos: DropPosition,
/** Center drops only: stack BEFORE this pane id (`null`/omitted = append)
* the tab-strip insertion divider's slot. */
before?: null | string
): LayoutNode | null {
const walk = (n: LayoutNode): LayoutNode => {
if (n.type === 'group') {
if (n.id !== targetGroupId) {
return n
}
if (pos === 'center') {
const at = before ? n.panes.indexOf(before) : -1
const panes = at >= 0 ? [...n.panes.slice(0, at), paneId, ...n.panes.slice(at)] : [...n.panes, paneId]
// Gaining a pane pins the header EXPLICITLY shown (not just cleared):
// a stack you can't see is a trap, and once a zone has ever stacked
// the bar STAYS when it drops back to one tab — the auto-hide flicker
// while dragging tabs around felt broken. Hiding is the user's call
// (double-click / zone menu).
return { ...n, panes, active: paneId, headerHidden: false }
}
const orientation: Orientation = pos === 'left' || pos === 'right' ? 'row' : 'column'
const leading = pos === 'left' || pos === 'top'
const added = group([paneId])
const children = leading ? [added, n] : [n, added]
return split(orientation, children, [1, 1])
}
return { ...n, children: n.children.map(walk) }
}
return normalize(walk(node))
}
/**
* The tree's VISIBLE shape: pane stacks + split orientations, with empty
* groups skipped (editor-session trees may still hold them) and single-child
* runs unwrapped. Two trees with equal signatures are indistinguishable on
* screen regardless of node ids.
*/
function shapeSignature(node: LayoutNode): string {
if (node.type === 'group') {
return node.panes.length > 0 ? `[${node.panes.join(',')}]` : ''
}
const children = node.children.map(shapeSignature).filter(Boolean)
if (children.length === 0) {
return ''
}
return children.length === 1 ? children[0] : `${node.orientation}(${children.join('|')})`
}
/**
* Move = remove + insert. If the target group vanished during removal (the
* pane was its only occupant), the move is a no-op. A move whose result
* LOOKS identical to the current layout is also a no-op e.g. a "split
* bottom" drop onto the zone the pane already sits alone below would only
* rebuild the same arrangement under a fresh zone id.
*/
export function movePane(
root: LayoutNode,
paneId: string,
target: { groupId: string; pos: DropPosition; before?: null | string }
): LayoutNode {
const from = findGroupOfPane(root, paneId)
// No-op guards: dropping a pane onto its own single-pane group.
if (from && from.id === target.groupId && from.panes.length === 1) {
return root
}
const without = removePane(root, paneId)
if (!without) {
// The pane was the only thing in the tree.
return root
}
if (!findGroup(without, target.groupId)) {
return root
}
const next = insertAtGroup(without, target.groupId, paneId, target.pos, target.before) ?? root
return shapeSignature(next) === shapeSignature(root) ? root : next
}
/** Group ids of every leaf under a node, in tree order. */
export function groupLeafIds(node: LayoutNode): string[] {
return node.type === 'group' ? [node.id] : node.children.flatMap(groupLeafIds)
}
function pathToGroup(node: LayoutNode, groupId: string): LayoutNode[] | null {
if (node.type === 'group') {
return node.id === groupId ? [node] : null
}
for (const child of node.children) {
const sub = pathToGroup(child, groupId)
if (sub) {
return [node, ...sub]
}
}
return null
}
const OPPOSITE_EDGE: Record<RootEdge, RootEdge> = { bottom: 'top', left: 'right', right: 'left', top: 'bottom' }
/** The viable group touching `edge` of this subtree. Along the edge's axis
* children are scanned edge-first a non-viable zone is display:none, so the
* next sibling IS the visual edge; across it, every child touches the edge. */
function edgeGroup(node: LayoutNode, edge: RootEdge, viable: (g: GroupNode) => boolean): GroupNode | null {
if (node.type === 'group') {
return viable(node) ? node : null
}
const along = (node.orientation === 'row') === (edge === 'left' || edge === 'right')
const children = along && (edge === 'right' || edge === 'bottom') ? [...node.children].reverse() : node.children
for (const child of children) {
const hit = edgeGroup(child, edge, viable)
if (hit) {
return hit
}
}
return null
}
/**
* The viable zone VISUALLY adjacent to `groupId` on `side` (the target of the
* zone menu's "Move left/right/up/down"). Walks up to the nearest ancestor
* split running along that axis with a sibling on that side, then descends to
* the sibling's closest viable leaf; subtrees whose every zone fails `viable`
* (all panes hidden) are skipped, matching their collapsed rendering.
*/
export function adjacentGroup(
root: LayoutNode,
groupId: string,
side: RootEdge,
viable: (g: GroupNode) => boolean
): GroupNode | null {
const path = pathToGroup(root, groupId)
if (!path) {
return null
}
const orientation: Orientation = side === 'left' || side === 'right' ? 'row' : 'column'
const forward = side === 'right' || side === 'bottom'
for (let i = path.length - 2; i >= 0; i--) {
const parent = path[i]
if (parent.type !== 'split' || parent.orientation !== orientation) {
continue
}
const index = parent.children.indexOf(path[i + 1])
const siblings = forward
? parent.children.slice(index + 1)
: parent.children.slice(0, index).reverse()
for (const sibling of siblings) {
const hit = edgeGroup(sibling, OPPOSITE_EDGE[side], viable)
if (hit) {
return hit
}
}
}
return null
}
function sameSet(ids: string[], set: Set<string>): boolean {
return ids.length === set.size && ids.every(id => set.has(id))
}
/** The node whose complete leaf set equals `set` (a rectangular region in a
* guillotine tree is always exactly one subtree), or null. */
function findCover(node: LayoutNode, set: Set<string>): LayoutNode | null {
if (sameSet(groupLeafIds(node), set)) {
return node
}
if (node.type === 'split') {
for (const child of node.children) {
const hit = findCover(child, set)
if (hit) {
return hit
}
}
}
return null
}
/**
* FancyZones span: merge the highlighted zones into ONE group holding
* `paneId`, absorbing any panes that lived in those zones as tabs. Only works
* when the highlighted set forms a rectangular subtree (it always does for a
* combined zone range on a guillotine tree); returns null otherwise so the
* caller can fall back to a single-zone drop.
*/
export function mergeZonesWithPane(root: LayoutNode, groupIds: string[], paneId: string): LayoutNode | null {
const set = new Set(groupIds)
if (set.size <= 1 || !findCover(root, set)) {
return null
}
// Panes from the merged zones (tree order), minus the dragged one.
const panesInSet: string[] = []
const collect = (n: LayoutNode) => {
if (n.type === 'group') {
if (set.has(n.id)) {
panesInSet.push(...n.panes.filter(p => p !== paneId))
}
} else {
n.children.forEach(collect)
}
}
collect(root)
// If the dragged pane lives OUTSIDE the merged set, pull it from its origin
// first (leaving that origin an empty zone). Inside the set it's absorbed.
const origin = findGroupOfPane(root, paneId)
let working = root
if (origin && !set.has(origin.id)) {
working = removePane(root, paneId) ?? root
}
const merged = group([paneId, ...panesInSet])
const replace = (n: LayoutNode): LayoutNode => {
if (sameSet(groupLeafIds(n), set)) {
return merged
}
return n.type === 'split' ? { ...n, children: n.children.map(replace) } : n
}
return normalize(replace(working))
}
// ---------------------------------------------------------------------------
// Attribute edits
// ---------------------------------------------------------------------------
function mapGroups(node: LayoutNode, fn: (g: GroupNode) => GroupNode): LayoutNode {
return node.type === 'group' ? fn(node) : { ...node, children: node.children.map(c => mapGroups(c, fn)) }
}
export function setActivePane(root: LayoutNode, groupId: string, paneId: string): LayoutNode {
return mapGroups(root, g => (g.id === groupId && g.panes.includes(paneId) ? { ...g, active: paneId } : g))
}
/** Reorder a pane within its group's tab stack (browser-tab drag semantics). */
export function reorderPaneInGroup(root: LayoutNode, groupId: string, paneId: string, toIndex: number): LayoutNode {
return mapGroups(root, g => {
if (g.id !== groupId || !g.panes.includes(paneId)) {
return g
}
const without = g.panes.filter(p => p !== paneId)
const index = Math.max(0, Math.min(without.length, toIndex))
const panes = [...without.slice(0, index), paneId, ...without.slice(index)]
return { ...g, panes }
})
}
export function setGroupMinimized(root: LayoutNode, groupId: string, minimized: boolean): LayoutNode {
return mapGroups(root, g => (g.id === groupId ? { ...g, minimized } : g))
}
export function setGroupHeaderHidden(root: LayoutNode, groupId: string, headerHidden: boolean): LayoutNode {
return mapGroups(root, g => (g.id === groupId ? { ...g, headerHidden } : g))
}
function replaceNode(node: LayoutNode, id: string, make: (g: GroupNode) => LayoutNode): LayoutNode {
if (node.type === 'group') {
return node.id === id ? make(node) : node
}
return { ...node, children: node.children.map(c => replaceNode(c, id, make)) }
}
/**
* Split a zone: `movePaneId` (one of SEVERAL panes in the group) moves into
* the new zone on `side` VS Code "split right", split and move in one
* gesture. A lone pane can't split away from itself: no-op (normalize prunes
* the empty zone the split would have minted).
*/
export function splitGroupZone(root: LayoutNode, groupId: string, side: RootEdge, movePaneId: string): LayoutNode {
const orientation: Orientation = side === 'left' || side === 'right' ? 'row' : 'column'
const before = side === 'left' || side === 'top'
return (
normalize(
replaceNode(root, groupId, g => {
if (g.panes.length < 2 || !g.panes.includes(movePaneId)) {
return g
}
const added = group([movePaneId])
const remaining = { ...g, panes: g.panes.filter(p => p !== movePaneId) }
return split(orientation, before ? [added, remaining] : [remaining, added], [1, 1])
})
) ?? root
)
}
/** Mirror the layout HORIZONTALLY (the titlebar flip toggle / \): reverse
* every ROW split's child order at EVERY depth, so leftright flips
* everywhere. A right rail lands on the left with its OWN internal order
* mirrored too so preview stays directly beside the file tree instead of
* jumping to the far edge (a shallow root-only reverse left nested rails in
* place). COLUMN splits keep their topbottom order (the terminal stays at
* the bottom). Its own involution: flipping twice is the identity. */
export function mirrorTreeHorizontal(root: LayoutNode): LayoutNode {
if (root.type === 'group') {
return root
}
const children = root.children.map(mirrorTreeHorizontal)
return root.orientation === 'row'
? { ...root, children: children.reverse(), weights: [...root.weights].reverse() }
: { ...root, children }
}
export function setSplitWeights(root: LayoutNode, splitId: string, weights: number[]): LayoutNode {
if (root.type === 'split') {
if (root.id === splitId) {
return { ...root, weights }
}
return { ...root, children: root.children.map(c => setSplitWeights(c, splitId, weights)) }
}
return root
}
// ---------------------------------------------------------------------------
// Validation (persisted trees are untrusted)
// ---------------------------------------------------------------------------
export function isLayoutNode(value: unknown): value is LayoutNode {
if (!value || typeof value !== 'object') {
return false
}
const n = value as Record<string, unknown>
if (n.type === 'group') {
return (
typeof n.id === 'string' &&
Array.isArray(n.panes) &&
n.panes.every(p => typeof p === 'string') &&
typeof n.active === 'string'
)
}
if (n.type === 'split') {
return (
typeof n.id === 'string' &&
(n.orientation === 'row' || n.orientation === 'column') &&
Array.isArray(n.children) &&
n.children.length > 0 &&
n.children.every(isLayoutNode) &&
Array.isArray(n.weights) &&
n.weights.length === n.children.length &&
n.weights.every(w => typeof w === 'number' && Number.isFinite(w) && w > 0)
)
}
return false
}

File diff suppressed because it is too large Load Diff