From c388daa665d22cffe6e90206e1441aac9325b413 Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Mon, 13 Jul 2026 17:28:21 -0400 Subject: [PATCH] feat(desktop): layout-tree model + store + workspace geometry --- .../src/components/pane-shell/geometry.ts | 270 +++++ .../src/components/pane-shell/tree/model.ts | 613 ++++++++++ .../src/components/pane-shell/tree/store.ts | 1049 +++++++++++++++++ 3 files changed, 1932 insertions(+) create mode 100644 apps/desktop/src/components/pane-shell/geometry.ts create mode 100644 apps/desktop/src/components/pane-shell/tree/model.ts create mode 100644 apps/desktop/src/components/pane-shell/tree/store.ts diff --git a/apps/desktop/src/components/pane-shell/geometry.ts b/apps/desktop/src/components/pane-shell/geometry.ts new file mode 100644 index 000000000..bfc05d35c --- /dev/null +++ b/apps/desktop/src/components/pane-shell/geometry.ts @@ -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('[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, enabled = true): Rect | null { + const controls = useWindowControlsRect() + const [overlap, setOverlap] = useState(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 +} diff --git a/apps/desktop/src/components/pane-shell/tree/model.ts b/apps/desktop/src/components/pane-shell/tree/model.ts new file mode 100644 index 000000000..8e037e927 --- /dev/null +++ b/apps/desktop/src/components/pane-shell/tree/model.ts @@ -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>): 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 = { 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): 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): 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 left↔right 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 top↔bottom 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 + + 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 +} diff --git a/apps/desktop/src/components/pane-shell/tree/store.ts b/apps/desktop/src/components/pane-shell/tree/store.ts new file mode 100644 index 000000000..fcde7f596 --- /dev/null +++ b/apps/desktop/src/components/pane-shell/tree/store.ts @@ -0,0 +1,1049 @@ +/** + * Layout tree store: one persisted tree replaces paneStates side/band + * overrides. The DEFAULT tree is declared by the app root (like config); + * the persisted tree is the user's customization; reset returns to default. + */ + +import { atom, computed } from 'nanostores' + +import { SIDEBAR_COLLAPSE_MEDIA_QUERY } from '@/app/layout-constants' +import { setPluginEnabled } from '@/contrib/plugins-store' +import { registry } from '@/contrib/registry' +import { translateNow } from '@/i18n' +import { readJson, readKey, writeJson, writeKey } from '@/lib/storage' +import { notify } from '@/store/notifications' +import { clearAllPaneSizeOverrides } from '@/store/panes' + +import { + allPaneIds, + type DropPosition, + findGroup, + findGroupOfPane, + groupLeafIds, + insertAtGroup, + isLayoutNode, + type LayoutNode, + mergeZonesWithPane as mergeZonesWithPaneOp, + mirrorTreeHorizontal, + movePane as movePaneOp, + normalize, + removePane, + reorderPaneInGroup as reorderPaneInGroupOp, + type RootEdge, + setActivePane as setActivePaneOp, + setGroupHeaderHidden as setGroupHeaderHiddenOp, + setGroupMinimized, + setSplitWeights as setSplitWeightsOp, + splitGroupZone as splitGroupZoneOp +} from './model' +import { rootChildSide } from './renderer/track-model' + +// v2: v1 trees were saved against placeholder panes with index-order zone +// assignment (chat could land in a corner cell). Retire them wholesale. +const STORAGE_KEY = 'hermes.desktop.layoutTree.v2' + +writeKey('hermes.desktop.layoutTree.v1', null) + +let defaultTree: LayoutNode | null = null + +function loadPersisted(): LayoutNode | null { + const parsed = readJson(STORAGE_KEY) + + // Canonicalize on load: strips stale attributes older code persisted + // (e.g. explicit headerHidden on lone-pane zones) and re-flattens. + return isLayoutNode(parsed) ? normalize(parsed) : null +} + +function persist(tree: LayoutNode | null) { + writeJson(STORAGE_KEY, tree) +} + +/** The live tree (null until a default is declared). */ +export const $layoutTree = atom(loadPersisted()) + +/** + * Which layout preset the current tree came from; `'custom'` after the user + * rearranges anything. Drives the picker's active highlight. + */ +export const $activePresetId = atom(readKey('hermes.desktop.layoutPreset.active') ?? 'default') + +export function markActivePreset(id: string) { + $activePresetId.set(id) + writeKey('hermes.desktop.layoutPreset.active', id) +} + +/** Pane id being dragged (tree drag session), null when idle. Also set to the + * SESSION_TILE_DRAG sentinel while a sidebar session is dragged over the tree, + * so the SAME zone overlay lights up (see session-tile-drop-bridge). */ +export const $treeDragging = atom(null) + +/** Sentinel `$treeDragging` value for a session (not a pane) drag — the zone + * overlay renders its normal targets, scoped to session-hosting zones. */ +export const SESSION_TILE_DRAG = '__session-tile-drag__' + +/** + * Panes hidden by app chrome toggles (titlebar sidebar / right-sidebar + * buttons). The tree KEEPS the zone and its mounted content; a zone whose + * every pane is hidden collapses to nothing until a toggle brings it back. + * Not persisted here — each binding's store owns persistence. + */ +export const $hiddenTreePanes = atom>(new Set()) + +/** Add/remove `item` in a readonly set, returning a fresh set — or null when + * membership already matches `present` (so callers can early-out on a no-op). */ +function toggledSet(set: ReadonlySet, item: T, present: boolean): Set | null { + if (set.has(item) === present) { + return null + } + + const next = new Set(set) + + if (present) { + next.add(item) + } else { + next.delete(item) + } + + return next +} + +export function setTreePaneHidden(paneId: string, hidden: boolean) { + const next = toggledSet($hiddenTreePanes.get(), paneId, hidden) + + if (!next) { + return + } + + $hiddenTreePanes.set(next) + + // Unhiding is an intent to SEE the pane — front it in its group. + if (!hidden) { + revealTreePane(paneId) + } +} + +/** + * CLOSE — the tab context menu's "Close". Two routes: + * - a registered closer (core panes whose visibility an app store owns: + * review/terminal/preview/sessions) closes through that store, so the + * titlebar/statusbar toggles stay truthful; + * - everything else (plugin panes, unbound core panes) is DISMISSED: removed + * from the tree and remembered so adoption doesn't re-add it. Reveal + * intent (a preview target, ⌘G) or a layout reset un-dismisses. + */ +const DISMISSED_KEY = 'hermes.desktop.dismissedPanes.v1' + +function loadDismissed(): ReadonlySet { + return new Set(readJson(DISMISSED_KEY) ?? []) +} + +export const $dismissedPanes = atom>(loadDismissed()) + +function saveDismissed(next: ReadonlySet) { + $dismissedPanes.set(next) + writeJson(DISMISSED_KEY, next.size === 0 ? null : [...next]) +} + +function setDismissed(paneId: string, dismissed: boolean) { + const next = toggledSet($dismissedPanes.get(), paneId, dismissed) + + if (next) { + saveDismissed(next) + } +} + +const paneClosers: Record void> = {} +const paneOpeners: Record void> = {} + +/** Route a pane's Close through the app store that owns its visibility. */ +export function registerPaneCloser(paneId: string, close: () => void) { + paneClosers[paneId] = close +} + +/** + * Route a pane's "show it" intent through the app store that owns its + * visibility — the mirror of `registerPaneCloser`, so a preset can reveal a + * toggle-gated pane (e.g. the terminal, whose visibility ⌃`/`$terminalTakeover` + * owns) while the toggle stays truthful. Only panes that opt in via + * `data.revealOnPreset` are opened on preset apply. + */ +export function registerPaneOpener(paneId: string, open: () => void) { + paneOpeners[paneId] = open +} + +const resetHandlers = new Set<() => void>() + +/** Run during a layout reset, BEFORE generic adoption — lets an owner + * pre-place its panes into the fresh default tree (session tiles collapse + * into main as tabs) so adoption sees them already placed and never scatters + * them to their old edges. */ +export function registerLayoutResetHandler(fn: () => void): () => void { + resetHandlers.add(fn) + + return () => { + resetHandlers.delete(fn) + } +} + +/** The zone the user last interacted with (clicked / focused into) — the ⌘W + * target when nothing is DOM-focused (activeElement is often `body` after a + * click lands on a non-focusable surface). Tracked by trackActiveTreeGroup. */ +export const $activeTreeGroup = atom(null) + +/** Record the interacted zone (pointerdown / focusin). Idempotent. */ +export function noteActiveTreeGroup(groupId: null | string) { + if (groupId !== $activeTreeGroup.get()) { + $activeTreeGroup.set(groupId) + } +} + +/** Install the active-zone tracker (call once from the tree root). Records the + * `[data-tree-group]` under each pointerdown / focusin so ⌘W knows which + * zone's tab to close even when nothing is DOM-focused. */ +export function trackActiveTreeGroup(): () => void { + const track = (event: Event) => { + const el = event.target instanceof HTMLElement ? event.target : null + const groupId = el?.closest('[data-tree-group]')?.dataset.treeGroup + + if (groupId) { + noteActiveTreeGroup(groupId) + } + } + + window.addEventListener('pointerdown', track, true) + window.addEventListener('focusin', track, true) + + return () => { + window.removeEventListener('pointerdown', track, true) + window.removeEventListener('focusin', track, true) + } +} + +const isUncloseablePane = (paneId: string): boolean => + Boolean((registry.getArea('panes').find(c => c.id === paneId)?.data as { uncloseable?: boolean } | undefined)?.uncloseable) + +/** ⌘W "main tabs always": close the MAIN (workspace) zone's active tab, unless + * it's the uncloseable workspace itself. Returns false when there's nothing to + * close, so ⌘W stays a no-op — it never closes the window. */ +export function closeWorkspaceTab(): boolean { + const tree = $layoutTree.get() + const active = tree ? findGroupOfPane(tree, 'workspace')?.active : null + + if (!active || isUncloseablePane(active)) { + return false + } + + closeTreePane(active) + + return true +} + +/** Closeable siblings of `paneId` within its group, split by position — powers + * the tab menu's Close-others / Close-to-the-right verbs (and their enablement). */ +function closeableTreeSiblings(paneId: string): { others: string[]; right: string[] } { + const tree = $layoutTree.get() + const panes = (tree ? findGroupOfPane(tree, paneId) : null)?.panes ?? [] + const idx = panes.indexOf(paneId) + + return { + others: panes.filter(id => id !== paneId && !isUncloseablePane(id)), + right: panes.filter((id, i) => i > idx && !isUncloseablePane(id)) + } +} + +/** Closeable-tab counts for a tab's menu enablement (`all` includes self). */ +export function treeTabCloseTargets(paneId: string): { all: number; others: number; right: number } { + const { others, right } = closeableTreeSiblings(paneId) + + return { all: others.length + (isUncloseablePane(paneId) ? 0 : 1), others: others.length, right: right.length } +} + +export function closeOtherTreeTabs(paneId: string): void { + closeableTreeSiblings(paneId).others.forEach(closeTreePane) +} + +export function closeTreeTabsToRight(paneId: string): void { + closeableTreeSiblings(paneId).right.forEach(closeTreePane) +} + +/** Close every closeable tab in `paneId`'s group (the uncloseable workspace stays). */ +export function closeAllTreeTabs(paneId: string): void { + const tree = $layoutTree.get() + const panes = (tree ? findGroupOfPane(tree, paneId) : null)?.panes ?? [] + + panes.filter(id => !isUncloseablePane(id)).forEach(closeTreePane) +} + +/** Pane ids in the tree under a `${prefix}:` namespace — lets a mirror prune + * panes the SHARED (cross-profile) tree persisted for tiles that no longer + * back the current profile (a profile switch reloads with the other profile's + * tile panes still stacked in). */ +export function treePanesWithPrefix(prefix: string): string[] { + const tree = $layoutTree.get() + + return tree ? allPaneIds(tree).filter(id => id.startsWith(prefix)) : [] +} + +/** ⌘1…⌘9: activate the Nth tab of the FOCUSED zone (the interaction tracker's + * group), but only when it's a real tab strip (≥2 panes). Returns false so the + * caller falls back to its default (profile switch) — the number keys mean + * "switch tab" only while a multi-tab zone holds focus. */ +export function activateTreeTabSlot(slot: number): boolean { + const groupId = $activeTreeGroup.get() + const tree = $layoutTree.get() + const panes = (groupId && tree ? findGroup(tree, groupId)?.panes : null) ?? [] + + if (panes.length < 2 || slot < 1 || slot > panes.length) { + return false + } + + activateTreePane(groupId!, panes[slot - 1]) + + return true +} + +/** ⌃Tab / ⌃⇧Tab: cycle the FOCUSED zone's tabs (wrapping) — but only a + * session/main strip with ≥2 tabs. Returns false so the caller falls back to + * the recent-session switcher when the focus isn't a chat tab strip. */ +export function cycleTreeTabInFocusedZone(direction: 1 | -1): boolean { + const groupId = $activeTreeGroup.get() + const tree = $layoutTree.get() + const group = groupId && tree ? findGroup(tree, groupId) : null + const panes = group?.panes ?? [] + + if (panes.length < 2 || !panes.some(id => id === 'workspace' || id.startsWith('session-tile:'))) { + return false + } + + const idx = Math.max(0, panes.indexOf(group!.active ?? '')) + activateTreePane(group!.id, panes[(idx + direction + panes.length) % panes.length]) + + return true +} + +/** Remove a pane from the tree WITHOUT a dismissal record — for surfaces + * whose lifecycle an owner store drives (session tiles): the owner removes + * the contribution too, and a later re-open must re-adopt cleanly. */ +export function removeTreePane(paneId: string) { + const tree = $layoutTree.get() + + if (tree) { + commit(removePane(tree, paneId)) + } +} + +/** Which root-row side a pane currently lives in, or null when it's nested + * with main (dragged into the middle) — where a side collapse can't hide it. + * Lets side-bound closers (files/sessions) fall back to dismissal. */ +export function paneRootSide(paneId: string): null | TreeSide { + const tree = $layoutTree.get() + + if (tree?.type !== 'split' || tree.orientation !== 'row') { + return null + } + + const panes = registry.getArea('panes') + const child = tree.children.find(c => allPaneIds(c).includes(paneId)) + + return child ? rootChildSide(child, id => panes.find(p => p.id === id)) : null +} + +/** The closer-less Close: dismiss the pane (removed + remembered; reveal + * intent or a layout reset un-dismisses). */ +export function dismissTreePane(paneId: string) { + const tree = $layoutTree.get() + + if (tree) { + setDismissed(paneId, true) + commit(removePane(tree, paneId)) + } +} + +export function closeTreePane(paneId: string) { + const closer = paneClosers[paneId] + + if (closer) { + closer() + + return + } + + // A plugin's pane: Close = DISABLE the plugin — the same switch as + // Settings → Plugins, so recovery is discoverable and symmetric. The + // contribution unregisters but the pane id STAYS in the tree, so + // re-enabling restores it exactly where it was. (Dismissal + removal + // would strand the pane with no way back short of a layout reset.) + const source = registry.getArea('panes').find(c => c.id === paneId)?.source + + if (source?.startsWith('plugin:')) { + const pluginId = source.slice('plugin:'.length) + void setPluginEnabled(pluginId, false) + notify({ + kind: 'info', + title: translateNow('zones.pluginDisabled', pluginId), + message: translateNow('zones.pluginDisabledBody') + }) + + return + } + + dismissTreePane(paneId) +} + +/** + * POSITIONAL side collapse — the titlebar's left/right sidebar toggles (and + * ⌘B / ⌘J). Everything on that side of the MAIN zone in the root row hides + * together, whatever panes live there (this is what makes the buttons agree + * with a rearranged layout; the flip derivation works the same way). An AND + * on top of per-pane visibility: zone shown ⇔ side open ∧ some pane shown. + */ +export type TreeSide = 'left' | 'right' + +export const $collapsedTreeSides = atom>(new Set()) + +// Side visibility is DERIVED from an app store (the binding owns persistence +// + button state); reveals flow back through its setter so they never +// disagree with the flag. +const sideOpeners: Partial void>> = {} + +export function setTreeSideCollapsed(side: TreeSide, collapsed: boolean) { + const next = toggledSet($collapsedTreeSides.get(), side, collapsed) + + if (next) { + $collapsedTreeSides.set(next) + } + + // Opening a side is an intent to SEE it — heal any pane of that side that a + // stale dismissal record removed from the tree, so ⌘B/⌘J can never press on + // nothing. Closing chrome panes is NEVER permanent (main parity). + if (!collapsed) { + restoreDismissedSidePanes(side) + } +} + +/** + * Does the layout have a collapsible root side of `side`? ⌘J's normal target is + * the right sidebar; a layout without one (e.g. a terminal-on-bottom preset) + * lets callers fall back to the terminal so ⌘J is never a dead key. Semantic — + * reuses `rootChildSide`, so it tracks a ⌘\ flip / drag like the toggles do. + */ +export function layoutHasRootSide(side: TreeSide): boolean { + const tree = $layoutTree.get() + + if (tree?.type !== 'split' || tree.orientation !== 'row') { + return false + } + + const panes = registry.getArea('panes') + + return tree.children.some(child => rootChildSide(child, id => panes.find(p => p.id === id)) === side) +} + +/** + * Un-dismiss + re-adopt every registered pane whose placement maps to `side` + * (the same semantic mapping as `rootChildSide`: 'left' panes ⇔ ⌘B, everything + * else non-main ⇔ ⌘J). Dismissal records for core chrome panes only exist as + * legacy state (they all register closers now), but they must not strand the + * pane where only a layout reset can recover it. + */ +function restoreDismissedSidePanes(side: TreeSide) { + const dismissed = $dismissedPanes.get() + + if (dismissed.size === 0) { + return + } + + let changed = false + + for (const pane of registry.getArea('panes')) { + if (!dismissed.has(pane.id)) { + continue + } + + const placement = (pane.data as { placement?: string } | undefined)?.placement + const paneSide = placement === 'left' ? 'left' : placement === 'main' ? null : 'right' + + if (paneSide === side) { + setDismissed(pane.id, false) + changed = true + } + } + + if (changed) { + adoptContributedPanes() + } +} + +/** Bind a side's visibility to an app store (mirror of bindPaneVisibility). */ +export function bindTreeSideVisibility( + side: TreeSide, + $open: { get(): boolean; listen(fn: (open: boolean) => void): void }, + setOpen: (open: boolean) => void +) { + sideOpeners[side] = setOpen + setTreeSideCollapsed(side, !$open.get()) + $open.listen(open => setTreeSideCollapsed(side, !open)) +} + +/** The chrome toggle owning `paneId`'s root-row column — SEMANTIC, matching + * the renderer's `rootChildSide`: ⌘B ⇔ the sessions column (left-placement + * panes) wherever it sits, ⌘J ⇔ the other side columns. Null for the main + * column (never side-collapsed). */ +export function treeSideOfPane(paneId: string): TreeSide | null { + const tree = $layoutTree.get() + + if (!tree || tree.type !== 'split' || tree.orientation !== 'row') { + return null + } + + const child = tree.children.find(node => allPaneIds(node).includes(paneId)) + + if (!child) { + return null + } + + const placementOf = (id: string) => + (registry.getArea('panes').find(c => c.id === id)?.data as { placement?: string } | undefined)?.placement + + const placements = allPaneIds(child).map(placementOf) + + if (placements.includes('main')) { + return null + } + + return placements.includes('left') ? 'left' : 'right' +} + +/** + * App intent "show pane X" (a preview target landed, ⌘G opened review, …): + * open its side, unhide it, and bring it to the front of its group. + */ +export function revealTreePane(paneId: string) { + // Reveal beats a Close: un-dismiss and let adoption put the pane back. + if ($dismissedPanes.get().has(paneId)) { + setDismissed(paneId, false) + adoptContributedPanes() + } + + const side = treeSideOfPane(paneId) + + if (side && $collapsedTreeSides.get().has(side)) { + const open = sideOpeners[side] + + // Through the bound store when there is one, so the toggle stays truthful. + if (open) { + open(true) + } else { + setTreeSideCollapsed(side, false) + } + } + + const hiddenNow = $hiddenTreePanes.get() + + if (hiddenNow.has(paneId)) { + setTreePaneHidden(paneId, false) + + return + } + + const tree = $layoutTree.get() + const group = tree ? findGroupOfPane(tree, paneId) : null + + if (tree && group && group.active !== paneId) { + commit(setActivePaneOp(tree, group.id, paneId)) + } +} + +/** + * Narrow viewport (the app's sidebar-collapse breakpoint): panes whose + * contribution declares `collapsible: true` leave the grid and become + * edge overlays (see NarrowOverlays in renderer.tsx). + */ +// Optional-chained + `typeof window` guarded like every other matchMedia call +// site: this module is imported by non-DOM code paths (session actions) whose +// test env has no `window`/`matchMedia` — an unguarded call throws at load. +const narrowQuery = typeof window !== 'undefined' ? window.matchMedia?.(SIDEBAR_COLLAPSE_MEDIA_QUERY) : undefined + +export const $narrowViewport = atom(Boolean(narrowQuery?.matches)) + +narrowQuery?.addEventListener('change', event => $narrowViewport.set(event.matches)) + +/** The titlebar flip toggle (⌘\): mirror the whole layout left↔right. */ +export function mirrorLayoutTree() { + const tree = $layoutTree.get() + + if (tree) { + commit(mirrorTreeHorizontal(tree)) + } +} + +export interface DropHint { + kind: 'group' + /** The zone a drop will land in (ClosestCenter among `groupIds`). */ + groupId?: string + /** Full highlighted set (multi-zone when Shift extends the range). */ + groupIds?: string[] + pos?: DropPosition + /** Hovering the target's TAB STRIP: the drop stacks at a specific slot — + * before this pane id, or at the end (`before: null`). The strip renders + * the insertion divider; the zone sheet stands down. */ + stack?: { before: null | string } +} + +/** Live drop target under the pointer while dragging. */ +export const $dropHint = atom(null) + +/** + * Derived session-drag booleans for HEAVY subscribers (the chat surfaces). + * `$dropHint` churns on every pointer-crossing during ANY drag; a chat surface + * subscribing to it raw re-renders its whole thread per hint change. These + * computeds collapse the churn to booleans that only notify on actual flips — + * and stay `false` throughout pane/tab drags, which chat never cares about. + */ +export const $sessionTileDragging = computed($treeDragging, dragging => dragging === SESSION_TILE_DRAG) + +/** True while a session drag aims at a zone EDGE (a tile split) or a tab + * strip (a stack) — the moments the chat surfaces' "link to chat" overlay + * must stand down. */ +export const $sessionTileEdgeHover = computed( + [$treeDragging, $dropHint], + (dragging, hint) => + dragging === SESSION_TILE_DRAG && ((hint?.pos !== undefined && hint.pos !== 'center') || hint?.stack !== undefined) +) + +/** + * Adopt panes present in `source` but missing from `target`: each joins the + * group its source siblings map to in the target (first group as a last + * resort). Layout changes never lose panes. + */ +function adoptMissingPanes(target: LayoutNode, source: LayoutNode): LayoutNode { + const have = new Set(allPaneIds(target)) + let next = target + + for (const paneId of allPaneIds(source)) { + if (have.has(paneId)) { + continue + } + + const sibling = findGroupOfPane(source, paneId)?.panes.find(p => have.has(p)) + const targetId = (sibling ? findGroupOfPane(next, sibling)?.id : undefined) ?? groupLeafIds(next)[0] + + if (targetId) { + next = insertAtGroup(next, targetId, paneId, 'center') ?? next + have.add(paneId) + } + } + + return next +} + +/** + * Declare the app's default tree. Adopted immediately when the user has no + * persisted customization; a persisted tree from an older default adopts any + * panes it's missing. + */ +export function declareDefaultTree(tree: LayoutNode) { + defaultTree = tree + const current = $layoutTree.get() + + if (!current) { + $layoutTree.set(tree) + + return + } + + const next = adoptMissingPanes(current, tree) + + if (next !== current) { + commit(next) + } +} + +/** + * LIVE pane adoption — a `panes` contribution that isn't in the tree yet + * (a plugin registered after boot, incl. runtime-loaded ones) joins the + * tree via the SAME primitive a human drag/drop commits with + * (`insertAtGroup`: anchor group + side). The pane's data supplies the + * gesture: + * + * - `dock: { pane, pos }` — "drop me on that edge of that pane". Any pane, + * any side, exactly what the drop chips do. + * - otherwise the semantic `placement` role infers the anchor: stack with + * a settled pane of the same placement, main zone as last resort. + * + * Happens once per pane lifetime (the committed tree remembers it across + * boots), so user rearrangement wins from then on and plugin reloads keep + * the pane where the user left it. + */ +interface PaneDockHint { + pane: string + pos: DropPosition + /** Center docks: stack BEFORE this pane id (the strip divider's slot). */ + before?: null | string +} + +function adoptContributedPanes(): void { + const tree = $layoutTree.get() + + if (!tree) { + return + } + + const panes = registry.getArea('panes') + + const dataOf = (paneId: string) => + panes.find(c => c.id === paneId)?.data as { placement?: string; dock?: PaneDockHint } | undefined + + const placementOf = (paneId: string) => dataOf(paneId)?.placement + const mainId = panes.find(c => placementOf(c.id) === 'main')?.id + const inTree = new Set(allPaneIds(tree)) + + // Plugin panes are never dismissed anymore (Close disables the plugin + // instead) — drop stale entries so panes stranded by the old behavior + // re-adopt on their own. + for (const pane of panes) { + if (pane.source?.startsWith('plugin:') && $dismissedPanes.get().has(pane.id)) { + setDismissed(pane.id, false) + } + } + + const dismissed = $dismissedPanes.get() + const missing = panes.filter(c => !inTree.has(c.id) && !dismissed.has(c.id)) + + if (missing.length === 0) { + return + } + + let next = tree + + for (const pane of missing) { + const dock = dataOf(pane.id)?.dock + const placement = placementOf(pane.id) ?? 'right' + + const anchor = + (dock && allPaneIds(next).includes(dock.pane) ? dock.pane : undefined) ?? + allPaneIds(next).find(id => id !== pane.id && placementOf(id) === placement) ?? + mainId + + const target = findGroupOfPane(next, anchor ?? '')?.id + + if (target) { + next = insertAtGroup(next, target, pane.id, dock?.pos ?? 'center', dock?.before) ?? next + + // An adopted pane ARRIVES with its chip showing — a surprise zone with + // zero chrome has no obvious handle to drag or close. (Explicit reveal; + // the next structural op returns lone panes to the auto-hide default.) + const landed = findGroupOfPane(next, pane.id) + + if (landed) { + next = setGroupHeaderHiddenOp(next, landed.id, false) + } + } + } + + if (next !== tree) { + commit(next) + } +} + +/** Adopt now + on every registry change (call once from the app root). */ +export function watchContributedPanes(): void { + adoptContributedPanes() + registry.subscribe(adoptContributedPanes) +} + +function commit(next: LayoutNode | null) { + if (!next) { + return + } + + $layoutTree.set(next) + persist(next) +} + +// --------------------------------------------------------------------------- +// USER-PLACED panes — "their spot wins". A pane the user has explicitly +// dragged (zone move / span / zone-menu split) keeps that placement; auto- +// docking (dockPaneBeside) only steers panes the user hasn't touched. +// Presets and resets hand placement back to the app. +// --------------------------------------------------------------------------- + +const USER_PLACED_KEY = 'hermes.desktop.userPlacedPanes.v1' + +export const $userPlacedPanes = atom>(new Set(readJson(USER_PLACED_KEY) ?? [])) + +function saveUserPlaced(next: ReadonlySet) { + $userPlacedPanes.set(next) + writeJson(USER_PLACED_KEY, next.size === 0 ? null : [...next]) +} + +function markPaneUserPlaced(paneId: string) { + const next = toggledSet($userPlacedPanes.get(), paneId, true) + + if (next) { + saveUserPlaced(next) + } +} + +/** + * Dock `paneId` directly beside `anchorPaneId` — the "preview opens NEXT TO + * the file tree" contract, position-aware: wherever the anchor lives (default + * rail, flipped via ⌘\, dragged into a stack, tabbed into main), the pane + * lands adjacent to it. Side rule: an anchor sitting right of the main zone + * gets the pane on its LEFT (the rail slides open toward the chat — main + * parity); an anchor left of main, stacked with it, or anywhere else gets it + * on the RIGHT. Skipped when the USER has placed the pane themselves, or the + * anchor isn't visible. Idempotent — a pane already beside its anchor is a + * shape no-op. + */ +export function dockPaneBeside(paneId: string, anchorPaneId: string) { + const tree = $layoutTree.get() + + if (!tree || $userPlacedPanes.get().has(paneId)) { + return + } + + const panes = registry.getArea('panes') + const anchor = findGroupOfPane(tree, anchorPaneId) + + // Anchor must be a live, shown pane — never dock beside a hidden file tree. + if (!anchor || $hiddenTreePanes.get().has(anchorPaneId) || !panes.some(c => c.id === anchorPaneId)) { + return + } + + // The uncloseable main workspace (session tiles are placement:'main' too, + // but closeable, so the uncloseable flag disambiguates). + const mainId = panes.find(c => { + const data = c.data as { placement?: string; uncloseable?: boolean } | undefined + + return data?.placement === 'main' && data.uncloseable + })?.id + + const order = allPaneIds(tree) + + const anchorRightOfMain = + !!mainId && !anchor.panes.includes(mainId) && order.indexOf(anchorPaneId) > order.indexOf(mainId) + + const pos: DropPosition = anchorRightOfMain ? 'left' : 'right' + + // A dismissed pane re-enters HERE (beside the anchor), not via adoption's + // placement fallback — clear the record so the two never disagree. + if ($dismissedPanes.get().has(paneId)) { + setDismissed(paneId, false) + } + + const next = findGroupOfPane(tree, paneId) + ? movePaneOp(tree, paneId, { groupId: anchor.id, pos }) + : insertAtGroup(tree, anchor.id, paneId, pos) + + if (next && next !== tree) { + commit(next) + } +} + +export function moveTreePane(paneId: string, target: { groupId: string; pos: DropPosition; before?: null | string }) { + const tree = $layoutTree.get() + + if (!tree) { + return + } + + const next = movePaneOp(tree, paneId, target) + + // movePane returns the SAME root for no-op drops ("stays here") — only a + // real move customizes the preset or pins the pane as user-placed. + if (next !== tree) { + commit(next) + markActivePreset('custom') + markPaneUserPlaced(paneId) + } +} + +/** + * Replace the whole tree (preset application). Panes living in the CURRENT + * tree that the preset doesn't know about (e.g. plugin panes vs a bundled + * preset) are adopted into the group their current siblings land in, so + * applying a preset never loses a pane. + */ +export function applyTree(tree: LayoutNode, presetId: string) { + const previous = $layoutTree.get() + + // A preset defines the layout's SIZES too — stale drag overrides from the + // previous arrangement would distort it. Same for user-placed pins: picking + // a layout hands pane placement back to the app (auto-docking resumes). + clearAllPaneSizeOverrides() + saveUserPlaced(new Set()) + commit(previous ? adoptMissingPanes(tree, previous) : tree) + markActivePreset(presetId) + + // Picking a named layout is an intent to SEE its panes. Toggle-gated panes + // (the terminal, whose visibility a store owns) would otherwise stay + // collapsed after the tree changes — so reveal the ones that opt in through + // their owning store, keeping the ⌃`/toggle state truthful. Iterate the + // preset's DECLARED panes (not the adopted result): logs is auto-adopted + // hidden into every tree, so only a preset that explicitly places it (Quad) + // should turn it on. + const panes = registry.getArea('panes') + + for (const paneId of allPaneIds(tree)) { + const data = panes.find(c => c.id === paneId)?.data as { revealOnPreset?: boolean } | undefined + + if (data?.revealOnPreset) { + paneOpeners[paneId]?.() + } + } +} + +/** + * Shift-drag span: merge the highlighted zones into one holding `paneId`. Falls + * back to a single-zone move at `fallbackGroupId` when the set can't merge + * (non-rectangular selection). + */ +export function mergeTreeZones(groupIds: string[], paneId: string, fallbackGroupId: string | null) { + const tree = $layoutTree.get() + + if (!tree) { + return + } + + const merged = mergeZonesWithPaneOp(tree, groupIds, paneId) + + if (merged) { + commit(merged) + markActivePreset('custom') + markPaneUserPlaced(paneId) + } else if (fallbackGroupId) { + moveTreePane(paneId, { groupId: fallbackGroupId, pos: 'center' }) + } +} + +export function activateTreePane(groupId: string, paneId: string) { + const tree = $layoutTree.get() + + if (tree) { + commit(setActivePaneOp(tree, groupId, paneId)) + } +} + +export function reorderTreePane(groupId: string, paneId: string, toIndex: number) { + const tree = $layoutTree.get() + + if (tree) { + commit(reorderPaneInGroupOp(tree, groupId, paneId, toIndex)) + markActivePreset('custom') + } +} + +/** Split a zone on `side`, moving `movePaneId` out of its stack into the new + * zone (VS Code split-and-move — the zone menu's Split actions). */ +export function splitTreeZone(groupId: string, side: RootEdge, movePaneId: string) { + const tree = $layoutTree.get() + + if (tree) { + commit(splitGroupZoneOp(tree, groupId, side, movePaneId)) + markActivePreset('custom') + markPaneUserPlaced(movePaneId) + } +} + +export function toggleTreeGroupMinimized(groupId: string, minimized: boolean) { + const tree = $layoutTree.get() + + if (tree) { + commit(setGroupMinimized(tree, groupId, minimized)) + } +} + +/** Hide/show a zone's header entirely (double-click gesture). */ +export function setTreeGroupHeaderHidden(groupId: string, headerHidden: boolean) { + const tree = $layoutTree.get() + + if (tree) { + commit(setGroupHeaderHiddenOp(tree, groupId, headerHidden)) + } +} + +export function setTreeSplitWeights(splitId: string, weights: number[]) { + const tree = $layoutTree.get() + + if (tree) { + // Weight drags are high-frequency: update live, persist on the trailing edge. + $layoutTree.set(setSplitWeightsOp(tree, splitId, weights)) + } +} + +function findSplitWeights(node: LayoutNode, splitId: string): number[] | null { + if (node.type !== 'split') { + return null + } + + if (node.id === splitId) { + return node.weights + } + + for (const child of node.children) { + const hit = findSplitWeights(child, splitId) + + if (hit) { + return hit + } + } + + return null +} + +/** + * The weights a layout preset declares for `splitId` — the ACTIVE preset + * first, then any other preset that knows the id. (Rearranging panes marks + * the active preset 'custom' but zone STRUCTURE — and so split ids — comes + * from whichever preset was applied, so the original baseline stays + * findable.) Null when no preset has a matching-shape split. + */ +export function presetSplitWeights(splitId: string, length: number): number[] | null { + const activeId = $activePresetId.get() + const presets = [...registry.getArea('layouts')].sort((a, b) => Number(b.id === activeId) - Number(a.id === activeId)) + + for (const preset of presets) { + const weights = preset.data && isLayoutNode(preset.data) ? findSplitWeights(preset.data, splitId) : null + + if (weights && weights.length === length) { + return [...weights] + } + } + + return null +} + +export function persistTree() { + persist($layoutTree.get()) +} + +export function resetLayoutTree() { + persist(null) + clearAllPaneSizeOverrides() + // Reset restores EVERYTHING — closed panes included — and hands pane + // placement back to the app (user-placed pins cleared). + saveDismissed(new Set()) + saveUserPlaced(new Set()) + $layoutTree.set(defaultTree) + markActivePreset('default') + // Owners PRE-PLACE their panes into the fresh default (session tiles stack + // into main as tabs) FIRST, so generic adoption sees them already in-tree + // and never scatters them to their old edges. + resetHandlers.forEach(fn => fn()) + // Everything still missing (plugin panes) adopts by placement. + adoptContributedPanes() +} + +// Dev hook for automation. +if (import.meta.env.DEV && typeof window !== 'undefined') { + ;(window as unknown as Record).__HERMES_LAYOUT_TREE__ = { + close: closeTreePane, + dismissed: () => $dismissedPanes.get(), + get: () => $layoutTree.get(), + move: moveTreePane, + registry, + reset: resetLayoutTree, + reveal: revealTreePane + } +}