@xenosystem/tree-core
v0.1.0
Published
The source-agnostic tree engine shared by xeno.core.layers and xeno.core.tree — recursive walk, visible-row flatten, range selection, virtualization, and the descendant-drop refusal. Zero dependencies, zero DOM.
Readme
@xenosystem/tree-core
The source-agnostic tree engine shared by
xeno.core.layersandxeno.core.tree. The shared thing is the walk, not the panel.
Zero dependencies. Zero DOM. Zero React.
Why this package exists
Two reasons, both of them bugs that are on screen right now:
1. The shallow-walk class is the most-repeated bug in the engineering log — ~9 venues, 5
checklist entries, canonical statement: "any getLayers().map/forEach/some is guilty until it
recurses." It has silently dropped grouped nodes in export, PSD write, detection, load and crash
recovery. Two independent recursive walkers — one in Layers, one in Tree — is exactly how it
recurs a tenth time.
2. 🔴 Reparent-into-own-descendant silently deletes the subtree, live in two shipping apps.
xeno-3d/.../stores/sceneStore.ts:2851 and xeno-engine/.../stores/engineStore.ts:1745 both
reparent by detach-then-reattach. When the drop target is a descendant of the dragged node, the
target is gone from the remainder after the detach, the insert is a silent no-op, and the node
plus its entire subtree disappears — with an undo entry already pushed, labelled "Reparent
Object", so Ctrl+Z restores nothing. The only guard either has is id === newParentId, which
catches dropping a node onto itself and nothing else.
resolveDropTarget refuses that drop, with a reason, and it lives here rather than in a host bridge
so no adopting host can forget it.
What is in it
| Module | Exports |
|---|---|
| tree | walkTree · allIds · collectDescendants · getAncestors · isAncestorOf · displayOrder · flattenVisible · matchesFilter · rowIndexOf · rangeBetween |
| virtualize | computeWindow · scrollToIndex |
| dropZone | resolveDropTarget |
| throttle | createThrottle |
How it is generalized
Layers and Tree carry different node shapes — name vs label, storage order vs document order,
different capability vocabularies. Rather than force one shape on both, every function is generic
over a structural minimum and takes the shape-specific decisions as explicit accessors:
interface TreeNodeLike { id: string; parentId?: string | null; children?: readonly string[] }
interface TreeLike<N> { nodes: Record<string, N>; rootIds: readonly string[]; order?: 'bottom-first' | 'top-first' }flattenVisible(tree, {
collapsed,
filter: 'shadow',
labelOf: (n) => n.label, // `name` in Layers, `label` in Tree
isContainer: (n) => n.kind === 'folder' || Array.isArray(n.children),
})Both panels bind their accessors once and re-export the signatures they always had.
Two deliberate defaults:
orderabsent ⇒top-first. Document order is the common case; only stacking surfaces (Pixel, Canvas) store bottom-first, and those declare it. The flag is honoured, never guessed — guessing is how a layer stack renders upside down.labelOfabsent ⇒''. A caller that forgets it filters everything out rather than silently matching on a guessed field. Loud beats convenient.
children presence is load-bearing
children: [] is an empty container. children: undefined is a leaf. They render
differently (a twisty vs none) and they behave differently on drop. Nothing in this package
collapses the distinction, and isContainer is overridable precisely because an async source has a
third case — a container whose children are not loaded yet.
Scripts
npm run typecheck # tsc --noEmit
npm test # vitest run (51 tests)
npm run build # tsup → dist (esm + cjs + d.ts)Consumers
| Package | Uses |
|---|---|
| @xenosystem/panel-layers | Binds name + its cap vocabulary; re-exports every signature unchanged (the extraction was non-breaking — its 109 tests pass untouched). |
| @xenosystem/panel-tree | Binds label + async-aware isContainer, adds per-node child state on top. |
A host bridge should import from here too. The review gate is unchanged and applies upstream of the panel: every walk recurses.
