@tanstack/devtools
Version:
TanStack Devtools is a set of tools for building advanced devtools for your application.
720 lines (657 loc) • 23.5 kB
text/typescript
/**
* The plugin workspace layout, as a tree of splits and tab groups, plus every
* operation that can change it.
*
* Everything here is pure and imports nothing: no DOM, no Solid, no store. That
* is deliberate. jsdom has no layout engine, so `getBoundingClientRect` returns
* zeros and rect maths verified through the DOM would only be verifying its own
* mocks. Keeping the maths here means it is exhaustively testable, and it keeps
* the components free of layout logic.
*
* Invariants every returned tree upholds:
* 1. a group has at least one tab
* 2. a split has at least two children
* 3. `sizes.length === children.length`, every size > 0, and they sum to 1
* 4. `active` indexes a real tab
* 5. a plugin id appears at most once in the tree
*/
/** Where on a pane a drop landed. `center` stacks, the four edges split. */
export type DropZone = 'left' | 'right' | 'top' | 'bottom' | 'center'
/** Internal: nothing outside this module names it, so it stays unexported. */
type Size = { w: number; h: number }
export type Rect = { left: number; top: number; width: number; height: number }
export type GroupNode = {
kind: 'group'
id: string
/** Plugin ids, in tab order. Never empty. */
tabs: Array<string>
/** Index into `tabs` of the visible one. */
active: number
}
export type SplitNode = {
kind: 'split'
/** `row` lays children out left to right, `col` top to bottom. */
dir: 'row' | 'col'
/** Fractions summing to 1, one per child. */
sizes: Array<number>
children: Array<LayoutNode>
}
export type LayoutNode = GroupNode | SplitNode
/** Path from the root to a node, as the child index taken at each split. */
export type Path = Array<number>
const EPSILON = 1e-9
/**
* Depth ceiling when reading a layout out of storage. Nine panes cannot nest
* deeper than nine, so anything past this is either corrupt or a self-
* referencing object, and recursing into it would blow the stack.
*/
const MAX_STORED_DEPTH = 32
const isGroup = (node: LayoutNode): node is GroupNode => node.kind === 'group'
const isSplit = (node: LayoutNode): node is SplitNode => node.kind === 'split'
/** Every plugin id in visual order: left to right, top to bottom. */
export const flattenTabs = (tree: LayoutNode | null): Array<string> =>
tree === null
? []
: isGroup(tree)
? [...tree.tabs]
: tree.children.flatMap(flattenTabs)
export const allGroups = (tree: LayoutNode | null): Array<GroupNode> =>
tree === null ? [] : isGroup(tree) ? [tree] : tree.children.flatMap(allGroups)
export const findGroupOfTab = (
tree: LayoutNode | null,
tabId: string,
): GroupNode | null =>
allGroups(tree).find((group) => group.tabs.includes(tabId)) ?? null
const findGroupById = (
tree: LayoutNode | null,
groupId: string,
): GroupNode | null =>
allGroups(tree).find((group) => group.id === groupId) ?? null
/**
* A group id no other group is using. Derived from the ids already in the tree
* rather than a counter or a random value, so the same inputs always give the
* same output and tests stay readable.
*/
export const nextGroupId = (tree: LayoutNode | null): string => {
let highest = -1
for (const group of allGroups(tree)) {
const match = /^g(\d+)$/.exec(group.id)
if (match) highest = Math.max(highest, Number(match[1]))
}
return `g${highest + 1}`
}
export const singleGroup = (
tabs: Array<string>,
id = 'g0',
): LayoutNode | null =>
tabs.length === 0 ? null : { kind: 'group', id, tabs: [...tabs], active: 0 }
/** Scale a list of weights so it sums to 1. Falls back to equal shares. */
const normalise = (sizes: Array<number>, count: number): Array<number> => {
const usable =
sizes.length === count && sizes.every((n) => Number.isFinite(n) && n > 0)
? sizes
: Array.from({ length: count }, () => 1)
const total = usable.reduce((sum, n) => sum + n, 0)
return total > EPSILON
? usable.map((n) => n / total)
: Array.from({ length: count }, () => 1 / count)
}
const split = (
dir: SplitNode['dir'],
children: Array<LayoutNode>,
sizes?: Array<number>,
): SplitNode => ({
kind: 'split',
dir,
sizes: normalise(sizes ?? [], children.length),
children,
})
/**
* Restore invariants 1-4 bottom up: drop empty groups, unwrap single-child
* splits, flatten a split directly inside a same-direction split, and clamp
* `active`. Returns null when nothing is left.
*/
const prune = (node: LayoutNode | null): LayoutNode | null => {
if (node === null) return null
if (isGroup(node)) {
if (node.tabs.length === 0) return null
const active = Math.min(Math.max(node.active, 0), node.tabs.length - 1)
return active === node.active ? node : { ...node, active }
}
const kept: Array<LayoutNode> = []
const keptSizes: Array<number> = []
node.children.forEach((child, index) => {
const pruned = prune(child)
if (pruned === null) return
// A same-direction split nested directly inside this one is the same layout
// written two ways; splicing it in keeps depth (and gutter count) honest.
if (isSplit(pruned) && pruned.dir === node.dir) {
const share = node.sizes[index] ?? 1 / node.children.length
pruned.children.forEach((grandchild, inner) => {
kept.push(grandchild)
keptSizes.push(share * (pruned.sizes[inner] ?? 0))
})
return
}
kept.push(pruned)
keptSizes.push(node.sizes[index] ?? 1 / node.children.length)
})
if (kept.length === 0) return null
if (kept.length === 1) return kept[0]!
return split(node.dir, kept, keptSizes)
}
/** Remove a tab wherever it is, then restore invariants. */
export const closeTab = (
tree: LayoutNode | null,
tabId: string,
): LayoutNode | null => {
const strip = (node: LayoutNode): LayoutNode => {
if (isGroup(node)) {
const index = node.tabs.indexOf(tabId)
if (index === -1) return node
const tabs = node.tabs.filter((id) => id !== tabId)
// Keep the neighbour that took its place selected, as an editor would.
const active = node.active > index ? node.active - 1 : node.active
return { ...node, tabs, active }
}
return { ...node, children: node.children.map(strip) }
}
return tree === null ? null : prune(strip(tree))
}
/** Remove a whole group and everything in it. */
export const closeGroup = (
tree: LayoutNode | null,
groupId: string,
): LayoutNode | null => {
const group = findGroupById(tree, groupId)
if (group === null) return tree
return group.tabs.reduce<LayoutNode | null>(
(acc, tabId) => closeTab(acc, tabId),
tree,
)
}
/**
* Replace a group's tab order wholesale, keeping the same tab selected.
*
* This is the seam a sortable tab bar reports into: it hands back the new order
* and this writes it to the tree, which stays the source of truth. Ids that are
* not already in the group are ignored — a transfer is `moveTab`, not this.
*/
export const setTabs = (
tree: LayoutNode | null,
groupId: string,
tabIds: Array<string>,
): LayoutNode | null => {
const group = findGroupById(tree, groupId)
if (tree === null || group === null) return tree
const existing = new Set(group.tabs)
const seen = new Set<string>()
const reordered = tabIds.filter((id) => {
if (!existing.has(id) || seen.has(id)) return false
seen.add(id)
return true
})
// Anything the caller dropped stays, appended, so a partial report cannot
// silently close a pane.
const tabs = [...reordered, ...group.tabs.filter((id) => !seen.has(id))]
if (tabs.length === 0) return tree
const activeId = group.tabs[group.active]
const active = Math.max(
tabs.findIndex((id) => id === activeId),
0,
)
const visit = (node: LayoutNode): LayoutNode => {
if (isGroup(node)) {
return node.id === groupId ? { ...node, tabs, active } : node
}
return { ...node, children: node.children.map(visit) }
}
return visit(tree)
}
/** Show a tab that is already open, without moving it. */
export const activateTab = (
tree: LayoutNode | null,
tabId: string,
): LayoutNode | null => {
if (tree === null) return null
const visit = (node: LayoutNode): LayoutNode => {
if (isGroup(node)) {
const index = node.tabs.indexOf(tabId)
return index === -1 || index === node.active
? node
: { ...node, active: index }
}
return { ...node, children: node.children.map(visit) }
}
return visit(tree)
}
/**
* Put `tabId` into `groupId` at `index`, removing it from wherever it was. Used
* for reordering inside one group and for moving between groups; they are the
* same operation with a different target.
*/
export const moveTab = (
tree: LayoutNode | null,
tabId: string,
groupId: string,
index: number,
): LayoutNode | null => {
if (tree === null) return null
const target = findGroupById(tree, groupId)
if (target === null) return tree
const source = findGroupOfTab(tree, tabId)
// Moving within one group is a reorder, so compute the destination against
// the list as it looks with the tab already lifted out.
const withoutTab = source === null ? tree : (closeTab(tree, tabId) ?? null)
// Closing the tab may have pruned the target group out of existence, which
// happens when it was that group's only tab. Then there is nothing to move.
if (withoutTab === null) return singleGroup([tabId], groupId)
if (findGroupById(withoutTab, groupId) === null) return tree
const insert = (node: LayoutNode): LayoutNode => {
if (isGroup(node)) {
if (node.id !== groupId) return node
const at = Math.min(Math.max(index, 0), node.tabs.length)
const tabs = [...node.tabs.slice(0, at), tabId, ...node.tabs.slice(at)]
return { ...node, tabs, active: at }
}
return { ...node, children: node.children.map(insert) }
}
return prune(insert(withoutTab))
}
/** Add a tab to a group as a new stacked tab, and select it. */
export const stackInto = (
tree: LayoutNode | null,
groupId: string,
tabId: string,
): LayoutNode | null => {
const group = findGroupById(tree, groupId)
return group === null
? tree
: moveTab(tree, tabId, groupId, group.tabs.length)
}
const zoneAxis = (zone: DropZone): SplitNode['dir'] =>
zone === 'left' || zone === 'right' ? 'row' : 'col'
const zoneLeads = (zone: DropZone): boolean => zone === 'left' || zone === 'top'
/**
* Split the group under `groupId`, putting `tabId` in a new group on the given
* side. `center` is not a split; callers should use `stackInto` for that.
*/
export const splitAt = (
tree: LayoutNode | null,
groupId: string,
zone: DropZone,
tabId: string,
): LayoutNode | null => {
if (tree === null) return singleGroup([tabId], 'g0')
if (zone === 'center') return stackInto(tree, groupId, tabId)
if (findGroupById(tree, groupId) === null) return tree
// Lift the tab out first: it may currently live in the very group we are
// about to split, and it must not end up on both sides.
const lifted = closeTab(tree, tabId)
if (lifted === null) return singleGroup([tabId], groupId)
const host = findGroupById(lifted, groupId)
// The group vanished when the dragged tab was its only occupant, so a split
// would just be the tab beside itself.
if (host === null) return tree
const newGroup: GroupNode = {
kind: 'group',
id: nextGroupId(lifted),
tabs: [tabId],
active: 0,
}
const dir = zoneAxis(zone)
const place = (node: LayoutNode): LayoutNode => {
if (isGroup(node)) {
if (node.id !== groupId) return node
return split(dir, zoneLeads(zone) ? [newGroup, node] : [node, newGroup])
}
return { ...node, children: node.children.map(place) }
}
return prune(place(lifted))
}
/**
* Add a pane alongside the existing ones, all sharing the space equally.
*
* This is what opening a plugin from the strip does, and it is deliberately not
* `splitAt`: splitting the last pane halves *it*, so opening three would give
* 1/2, 1/4, 1/4. Panes opened side by side should be the same size.
*/
export const appendPane = (
tree: LayoutNode | null,
tabId: string,
dir: SplitNode['dir'] = 'row',
): LayoutNode | null => {
if (tree === null) return singleGroup([tabId])
const lifted = closeTab(tree, tabId)
if (lifted === null) return singleGroup([tabId])
const newGroup: GroupNode = {
kind: 'group',
id: nextGroupId(lifted),
tabs: [tabId],
active: 0,
}
const children =
isSplit(lifted) && lifted.dir === dir
? [...lifted.children, newGroup]
: [lifted, newGroup]
// No sizes argument, so `normalise` gives every child an equal share.
return prune(split(dir, children))
}
/** Locate a node by walking child indices from the root. */
const nodeAtPath = (tree: LayoutNode | null, path: Path): LayoutNode | null => {
let node = tree
for (const index of path) {
if (node === null || !isSplit(node)) return null
node = node.children[index] ?? null
}
return node
}
/**
* Move `delta` (a fraction of the split's main axis) across the gutter between
* children `gutterIndex` and `gutterIndex + 1`. One grows by exactly what the
* other loses, and neither drops below `minFraction`, so the total stays 1 and
* no other pane in the split is disturbed.
*/
export const resize = (
tree: LayoutNode | null,
path: Path,
gutterIndex: number,
delta: number,
minFraction = 0,
): LayoutNode | null => {
const target = nodeAtPath(tree, path)
if (tree === null || target === null || !isSplit(target)) return tree
const before = target.sizes[gutterIndex]
const after = target.sizes[gutterIndex + 1]
if (before === undefined || after === undefined) return tree
const budget = before + after
// A min bigger than half the pair cannot be honoured for both, so fall back
// to an even share instead of letting one side go negative.
const min = Math.min(minFraction, budget / 2)
const nextBefore = Math.min(Math.max(before + delta, min), budget - min)
if (Math.abs(nextBefore - before) < EPSILON) return tree
const sizes = [...target.sizes]
sizes[gutterIndex] = nextBefore
sizes[gutterIndex + 1] = budget - nextBefore
const replace = (node: LayoutNode, depth: number): LayoutNode => {
if (depth === path.length) return { ...(node as SplitNode), sizes }
const index = path[depth]!
const children = [...(node as SplitNode).children]
children[index] = replace(children[index]!, depth + 1)
return { ...(node as SplitNode), children }
}
return replace(tree, 0)
}
/**
* Rect per group, from a walk of the tree. Tabs in the same group share its
* rect because only the active one is displayed.
*/
export const layoutRects = (
tree: LayoutNode | null,
box: Size,
gutter = 0,
): Record<string, Rect> => {
const out: Record<string, Rect> = {}
const walk = (node: LayoutNode, rect: Rect): void => {
if (isGroup(node)) {
out[node.id] = rect
return
}
const horizontal = node.dir === 'row'
const gutters = gutter * (node.children.length - 1)
const available = Math.max(
(horizontal ? rect.width : rect.height) - gutters,
0,
)
let offset = horizontal ? rect.left : rect.top
node.children.forEach((child, index) => {
const extent = available * (node.sizes[index] ?? 0)
walk(
child,
horizontal
? { left: offset, top: rect.top, width: extent, height: rect.height }
: { left: rect.left, top: offset, width: rect.width, height: extent },
)
offset += extent + gutter
})
}
if (tree !== null) {
walk(tree, { left: 0, top: 0, width: box.w, height: box.h })
}
return out
}
/** One draggable gutter: where it sits and which sizes it moves. */
export type SplitterHandle = {
/** Path to the split this gutter belongs to. */
path: Path
/** Gutter between children `gutterIndex` and `gutterIndex + 1`. */
gutterIndex: number
dir: SplitNode['dir']
rect: Rect
/**
* The split's usable extent along its own axis, in px, excluding gutters.
* Converts a pointer delta into a size fraction.
*/
extent: number
}
/** Every gutter in the tree, positioned in the same space as the pane rects. */
export const splitterHandles = (
tree: LayoutNode | null,
box: Size,
gutter = 0,
): Array<SplitterHandle> => {
const handles: Array<SplitterHandle> = []
const walk = (node: LayoutNode, rect: Rect, path: Path): void => {
if (isGroup(node)) return
const horizontal = node.dir === 'row'
const gutters = gutter * (node.children.length - 1)
const available = Math.max(
(horizontal ? rect.width : rect.height) - gutters,
0,
)
let offset = horizontal ? rect.left : rect.top
node.children.forEach((child, index) => {
const extent = available * (node.sizes[index] ?? 0)
const childRect: Rect = horizontal
? { left: offset, top: rect.top, width: extent, height: rect.height }
: { left: rect.left, top: offset, width: rect.width, height: extent }
walk(child, childRect, [...path, index])
offset += extent
if (index < node.children.length - 1) {
handles.push({
path,
gutterIndex: index,
dir: node.dir,
extent: available,
rect: horizontal
? {
left: offset,
top: rect.top,
width: gutter,
height: rect.height,
}
: {
left: rect.left,
top: offset,
width: rect.width,
height: gutter,
},
})
offset += gutter
}
})
}
if (tree !== null) {
walk(tree, { left: 0, top: 0, width: box.w, height: box.h }, [])
}
return handles
}
/** Rect per plugin id, which is what the panes are keyed by. */
export const paneRects = (
tree: LayoutNode | null,
box: Size,
gutter = 0,
): Record<string, Rect> => {
const byGroup = layoutRects(tree, box, gutter)
const out: Record<string, Rect> = {}
for (const group of allGroups(tree)) {
const rect = byGroup[group.id]
if (rect) for (const tabId of group.tabs) out[tabId] = rect
}
return out
}
/**
* Whether splitting a group leaves both halves at least `min`. The workspace is
* short, so a split that would produce an unreadable cell is refused and the
* caller stacks instead.
*/
export const canSplit = (
tree: LayoutNode | null,
groupId: string,
zone: DropZone,
min: Size,
box: Size,
gutter = 0,
): boolean => {
if (zone === 'center') return true
const rect = layoutRects(tree, box, gutter)[groupId]
if (!rect) return false
return zoneAxis(zone) === 'row'
? (rect.width - gutter) / 2 >= min.w
: (rect.height - gutter) / 2 >= min.h
}
/** Which zone a pointer at `point` is in, given the pane's rect. */
export const zoneAt = (
point: { x: number; y: number },
rect: Rect,
edge = 0.25,
): DropZone => {
const x = rect.width > 0 ? (point.x - rect.left) / rect.width : 0.5
const y = rect.height > 0 ? (point.y - rect.top) / rect.height : 0.5
const distances: Array<[DropZone, number]> = [
['left', x],
['right', 1 - x],
['top', y],
['bottom', 1 - y],
]
const [zone, distance] = distances.reduce((best, entry) =>
entry[1] < best[1] ? entry : best,
)
return distance <= edge ? zone : 'center'
}
/** Insertion index for a tab dropped at `x` over a row of tab rects. */
export const insertionIndex = (x: number, tabs: Array<Rect>): number => {
let index = 0
for (const rect of tabs) {
if (x < rect.left + rect.width / 2) break
index += 1
}
return index
}
const isRawGroup = (value: Record<string, unknown>): boolean =>
value.kind === 'group' &&
typeof value.id === 'string' &&
Array.isArray(value.tabs) &&
value.tabs.every((tab) => typeof tab === 'string')
const isRawSplit = (value: Record<string, unknown>): boolean =>
value.kind === 'split' &&
(value.dir === 'row' || value.dir === 'col') &&
Array.isArray(value.children)
/**
* Turn whatever was in storage into a tree that upholds every invariant, or
* null. Never throws: a malformed layout is a data problem, the same as the
* unknown plugin ids that are already pruned on load, and it must not stop the
* panel from opening. Storage *access* errors still propagate to the caller.
*/
export const repairLayout = (
raw: unknown,
known: ReadonlySet<string>,
): LayoutNode | null => {
const seen = new Set<string>()
const rebuild = (value: unknown, depth = 0): LayoutNode | null => {
if (depth > MAX_STORED_DEPTH) return null
if (typeof value !== 'object' || value === null) return null
const record = value as Record<string, unknown>
if (isRawGroup(record)) {
const tabs = (record.tabs as Array<string>).filter((tab) => {
if (!known.has(tab) || seen.has(tab)) return false
seen.add(tab)
return true
})
if (tabs.length === 0) return null
const active =
typeof record.active === 'number' && Number.isInteger(record.active)
? Math.min(Math.max(record.active, 0), tabs.length - 1)
: 0
return { kind: 'group', id: String(record.id), tabs, active }
}
if (isRawSplit(record)) {
const children = (record.children as Array<unknown>)
.map((child) => rebuild(child, depth + 1))
.filter((child): child is LayoutNode => child !== null)
if (children.length === 0) return null
const rawSizes = Array.isArray(record.sizes)
? (record.sizes as Array<unknown>).filter(
(size): size is number => typeof size === 'number',
)
: []
return split(record.dir as SplitNode['dir'], children, rawSizes)
}
return null
}
const rebuilt = prune(rebuild(raw))
if (rebuilt !== null) return dedupeIds(rebuilt)
// Unsalvageable shape. Recover whatever plugin ids are in there so a bad
// write costs the arrangement but not the open plugins.
const salvaged = collectKnownIds(raw, known)
return singleGroup(salvaged)
}
/** Every string in `raw` that names a known plugin, in encounter order. */
const collectKnownIds = (
raw: unknown,
known: ReadonlySet<string>,
): Array<string> => {
const found: Array<string> = []
const seen = new Set<string>()
// Storage can hold a self-referencing object, so remember what has been
// visited rather than trusting the shape to be a tree.
const visited = new WeakSet<object>()
const walk = (value: unknown): void => {
if (typeof value === 'string') {
if (known.has(value) && !seen.has(value)) {
seen.add(value)
found.push(value)
}
return
}
if (typeof value !== 'object' || value === null) return
if (visited.has(value)) return
visited.add(value)
if (Array.isArray(value)) {
value.forEach(walk)
return
}
Object.values(value).forEach(walk)
}
walk(raw)
return found
}
/** Give any groups that share an id a fresh one, so lookups stay unambiguous. */
const dedupeIds = (tree: LayoutNode): LayoutNode => {
const used = new Set<string>()
let counter = 0
const visit = (node: LayoutNode): LayoutNode => {
if (isGroup(node)) {
if (!used.has(node.id)) {
used.add(node.id)
return node
}
let id = `g${counter++}`
while (used.has(id)) id = `g${counter++}`
used.add(id)
return { ...node, id }
}
return { ...node, children: node.children.map(visit) }
}
return visit(tree)
}