fix(desktop): cascade a sash drag through sibling panes past their floors

A sash drag used to stop the moment its seam partner hit its min size, so a
row of panes behaved like unrelated boxes: growing the Browser tile could not
take space from Chat once the pane between them was at its floor. The drag
now plans the whole run from the pointerdown sizes — the partner donates
first, then its next visible sibling, and so on — and commits once on
release (fixed zones get px overrides, the flex run gets weights).

A reverse drag after a cascade stays local to the seam. Release only folds a
tool zone that THIS gesture took to its floor, so an unrelated rail already
resting there (a minimized Terminal) no longer cancels the Files/Chat commit.

Salvaged from #103166 (Jerry Gooch), trimmed to the sash-drag change; the
width/height lock feature, zone-body context menu and the restored-tool CSS
floor are held as separate decisions.
This commit is contained in:
Jerry Gooch
2026-09-06 02:54:55 -07:00
committed by Teknium
parent 03e9f4caee
commit e9c527eeb6
2 changed files with 367 additions and 86 deletions
@@ -0,0 +1,183 @@
import { cleanup, fireEvent, render } from '@testing-library/react'
import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'
import { registry } from '@/contrib/registry'
import { $paneStates } from '@/store/panes'
import { group, split, type SplitNode } from '../model'
import { $hiddenTreePanes, $layoutTree, markCollapsePane, setTreeGroupMinimized } from '../store'
import { TreeSplit } from './tree-split'
class TestResizeObserver {
observe() {}
unobserve() {}
disconnect() {}
}
const disposers: (() => void)[] = []
beforeAll(() => {
vi.stubGlobal('ResizeObserver', TestResizeObserver)
vi.stubGlobal('CSS', { ...globalThis.CSS, escape: (value: string) => value })
vi.stubGlobal('requestAnimationFrame', () => 1)
vi.stubGlobal('cancelAnimationFrame', () => undefined)
Element.prototype.hasPointerCapture ??= () => false
Element.prototype.setPointerCapture ??= () => undefined
Element.prototype.releasePointerCapture ??= () => undefined
})
beforeEach(() => {
window.localStorage.clear()
$hiddenTreePanes.set(new Set())
$paneStates.set({})
disposers.push(
registry.register({ area: 'panes', data: { placement: 'main' }, id: 'chat', render: () => null, title: 'Chat' }),
registry.register({
area: 'panes',
data: { placement: 'main', width: '100px' },
id: 'cron',
render: () => null,
title: 'Cron'
}),
registry.register({
area: 'panes',
data: { placement: 'main' },
id: 'browser',
render: () => null,
title: 'Browser'
})
)
})
afterEach(() => {
cleanup()
$layoutTree.set(null)
$paneStates.set({})
disposers.splice(0).forEach(dispose => dispose())
})
function rect(width: number): DOMRect {
return {
bottom: 600,
height: 600,
left: 0,
right: width,
toJSON: () => ({}),
top: 0,
width,
x: 0,
y: 0
} as DOMRect
}
function setWidth(element: HTMLElement, width: number) {
Object.defineProperty(element, 'getBoundingClientRect', { configurable: true, value: () => rect(width) })
}
function row(): SplitNode {
const tree = $layoutTree.get()
if (!tree || tree.type !== 'split') {
throw new Error('expected root row split')
}
return tree
}
describe('TreeSplit cascading expansion', () => {
it('grows Browser through Cron into Chat after Cron reaches its minimum', () => {
const tree = split(
'row',
[
group(['chat'], { id: 'chat-zone' }),
group(['cron'], { id: 'cron-zone' }),
group(['browser'], { id: 'browser-zone' })
],
[5, 1, 2],
'root-row'
)
$layoutTree.set(tree)
render(<TreeSplit node={tree} root rootRow />)
const container = document.querySelector<HTMLElement>('[data-tree-split="root-row"]')!
const [chat, cron, browser] = [...container.children] as HTMLElement[]
setWidth(container, 800)
setWidth(chat, 500)
setWidth(cron, 100)
setWidth(browser, 200)
setWidth(document.querySelector<HTMLElement>('[data-tree-group="cron-zone"]')!, 100)
const browserSash = document.querySelectorAll('[role="separator"]')[1]!
fireEvent.pointerDown(browserSash, { button: 0, clientX: 600, pointerId: 1, pointerType: 'mouse' })
fireEvent.pointerMove(window, { clientX: 300, pointerId: 1, pointerType: 'mouse' })
fireEvent.pointerUp(window, { clientX: 300, pointerId: 1, pointerType: 'mouse' })
// Browser's 300px requested growth first takes Cron from 100px to its
// 80px floor, then takes the remaining 280px from Chat. The browser gets
// every released pixel instead of stopping at Cron's local floor.
expect($paneStates.get().cron?.widthOverride).toBe(80)
expect(row().weights).toEqual([2.2, 1, 5])
})
it('commits a regular cascade when an unrelated tool rail is already minimized', () => {
markCollapsePane('terminal')
disposers.push(
registry.register({
area: 'panes',
data: { maxWidth: '600px', minWidth: '160px', placement: 'right', width: '200px' },
id: 'browser',
render: () => null,
title: 'Browser'
}),
registry.register({
area: 'panes',
data: { placement: 'bottom' },
id: 'terminal',
render: () => null,
title: 'Terminal'
})
)
const tree = split(
'row',
[
group(['chat'], { id: 'chat-zone' }),
group(['cron'], { id: 'cron-zone' }),
group(['browser'], { id: 'browser-zone' }),
group(['terminal'], { id: 'terminal-zone' })
],
[5, 1, 2, 0.28],
'root-row'
)
$layoutTree.set(tree)
$paneStates.set({ browser: { open: true, widthOverride: 200 } })
setTreeGroupMinimized('terminal-zone', true)
render(<TreeSplit node={row()} root rootRow />)
const container = document.querySelector<HTMLElement>('[data-tree-split="root-row"]')!
const [chat, cron, browser, terminal] = [...container.children] as HTMLElement[]
setWidth(container, 828)
setWidth(chat, 500)
setWidth(cron, 100)
setWidth(browser, 200)
setWidth(terminal, 28)
setWidth(document.querySelector<HTMLElement>('[data-tree-group="cron-zone"]')!, 100)
setWidth(document.querySelector<HTMLElement>('[data-tree-group="browser-zone"]')!, 200)
setWidth(document.querySelector<HTMLElement>('[data-tree-group="terminal-zone"]')!, 28)
const browserSash = document.querySelectorAll('[role="separator"]')[1]!
fireEvent.pointerDown(browserSash, { button: 0, clientX: 600, pointerId: 1, pointerType: 'mouse' })
fireEvent.pointerMove(window, { clientX: 300, pointerId: 1, pointerType: 'mouse' })
fireEvent.pointerUp(window, { clientX: 300, pointerId: 1, pointerType: 'mouse' })
expect($paneStates.get().cron?.widthOverride).toBe(80)
expect($paneStates.get().browser?.widthOverride).toBe(500)
expect(row().weights[0]).toBeCloseTo(2.2)
expect(row().children[3]).toMatchObject({ id: 'terminal-zone', minimized: true })
})
})
@@ -73,6 +73,7 @@ function useSubtreeOverrides(paneIds: readonly string[]): TrackContext['override
const snapshot = useCallback(() => {
const all = $paneStates.get()
const sig = paneIds.map(id => `${id}:${all[id]?.widthOverride ?? ''}:${all[id]?.heightOverride ?? ''}`).join('|')
if (cache.current.sig !== sig) {
@@ -245,15 +246,167 @@ export function TreeSplit({ node, root, rootRow }: { node: SplitNode; root?: boo
return
}
const a = sideFor(node.children[aIndex], kidA, 'end')
const b = sideFor(node.children[bIndex], kidB, 'start')
const a0px = a.fixed ? a.size : sizeOf(kidA)
const b0px = b.fixed ? b.size : sizeOf(kidB)
const lo = Math.max(a.min - a0px, b0px - b.max)
const hi = Math.min(a.max - a0px, b0px - b.min)
const tracks = node.children.map((child, index) => {
const element = container.children[index] as HTMLElement | undefined
if (!element) {
return null
}
const side = sideFor(child, element, index <= aIndex ? 'end' : 'start')
return {
...side,
element,
index,
initial: side.fixed ? side.size : sizeOf(element),
// A minimized rail is its 28px strip: it neither donates nor takes,
// and its remembered weight must survive the gesture so restoring
// it brings back the size it had before it was folded.
minimized: child.type === 'group' && Boolean(child.minimized),
visible: !isCollapsed(child)
}
})
if (tracks.some(track => !track)) {
return
}
const sashTracks = tracks as Array<NonNullable<(typeof tracks)[number]>>
const setOverride = horizontal ? setPaneWidthOverride : setPaneHeightOverride
// The seam is ONE boundary of a whole run: the side the pointer moves
// toward gives up space, its seam partner first. When the partner hits
// its floor the drag keeps going and takes from the partner's next
// visible sibling, and so on — a row behaves like one workspace instead
// of a set of unrelated boxes. Plans are absolute (from the sizes at
// pointerdown), so every frame is a pure function of the pointer.
const planFor = (requestedShift: number, allowCascade: boolean) => {
const toward = requestedShift >= 0 ? 1 : -1
const targetIndex = toward > 0 ? aIndex : bIndex
// The partner is the nearest VISIBLE sibling (`aIndex`/`bIndex`), not
// `target ± 1` — a hidden zone parked between them (the closed preview
// pane) is display:none and must neither donate nor block.
const partnerIndex = toward > 0 ? bIndex : aIndex
const target = sashTracks[targetIndex]
const next = sashTracks.map(track => track.initial)
let remaining = Math.min(Math.abs(requestedShift), Math.max(0, target.max - target.initial))
let transferred = 0
let cascaded = false
// A tool rail is locally resizable at its own seam, but must not make
// every unrelated seam in the row local-only. It also remains the
// terminal donor when reached only after another regular track.
const cascade = allowCascade && !target.collapseId
for (let donorIndex = partnerIndex; donorIndex >= 0 && donorIndex < sashTracks.length; donorIndex += toward) {
const donor = sashTracks[donorIndex]
if (!donor.visible) {
continue
}
if (donor.collapseId && donorIndex !== partnerIndex) {
break
}
const take = Math.min(remaining, Math.max(0, donor.initial - donor.min))
next[donorIndex] -= take
transferred += take
remaining -= take
if (take > 0 && donorIndex !== partnerIndex) {
cascaded = true
}
if (remaining === 0 || !cascade) {
break
}
}
next[targetIndex] += transferred
return { cascaded, moved: Math.sign(requestedShift) * transferred, sizes: next }
}
// One store commit on release: fixed zones get their px override
// (sidebar semantics), the flex run gets weights. Clamped px → weights
// so persisted weights match what's on screen.
const commitPlan = (plan: ReturnType<typeof planFor>) => {
const weights = [...node.weights]
let weightsChanged = false
sashTracks.forEach((track, index) => {
if (!track.visible || track.minimized) {
return
}
const px = Math.round(plan.sizes[index])
if (track.fixed) {
if (px !== Math.round(track.initial)) {
track.paneIds.forEach(id => setOverride(id, px))
}
} else {
weights[index] = Math.max(0.01, plan.sizes[index] / pxPerWeight)
weightsChanged = true
}
})
if (weightsChanged) {
setTreeSplitWeights(node.id, weights)
}
}
const styleSnapshots = sashTracks.map(track => track.element.getAttribute('style'))
const restoreStyles = () => {
sashTracks.forEach((track, index) => {
const style = styleSnapshots[index]
if (style === null) {
track.element.removeAttribute('style')
} else {
track.element.setAttribute('style', style)
}
})
}
// During the gesture the store is NOT written. setTreeSplitWeights /
// setPaneWidthOverride each mint a new tree/pane-state object, and the
// resulting commit walks every mounted pane — measured live on a real
// 2-session layout: 31 commits across a 58-frame drag, 20.7fps, with
// TreeNode at 490ms and Block/Ct re-parsing markdown for 620ms. The
// store is written ONCE on release; during the drag the run is
// previewed with inline styles on the same wrappers React sizes.
//
// Preview rules (learned the hard way — a wrong shape here left a
// phantom gap where a hidden sidebar lived):
// - a FIXED track gets ONLY a flex-basis override. Its wrapper renders
// as `flex: 0 1 <track>`, so basis is the whole difference; grow and
// shrink stay React's.
// - every visible flex track is pinned to `0 1 <px>`. The plan keeps
// the run's total px constant, so no leftover gap can open.
// - hidden (display:none) and minimized tracks are left untouched.
// - cleanup: a real drag commits the store once, and React's re-render
// rewrites the `flex` shorthand, which clears the overrides (writing
// the shorthand resets the longhands). A no-movement click restores
// the captured style attributes instead, since nothing re-renders.
const previewPlan = (plan: ReturnType<typeof planFor>) => {
sashTracks.forEach((track, index) => {
if (!track.visible || track.minimized) {
return
}
const px = plan.sizes[index]
if (track.fixed) {
track.element.style.flexBasis = `${px}px`
} else {
track.element.style.flex = `0 1 ${px}px`
}
})
}
try {
handle.setPointerCapture?.(pointerId)
} catch {
@@ -272,70 +425,26 @@ export function TreeSplit({ node, root, rootRow }: { node: SplitNode; root?: boo
// pointermove outpaces 60fps and each write relayouts the whole pane tree,
// so coalesce to one apply per frame (rafCoalesce commits on cleanup).
//
// During the gesture the store is NOT written. setTreeSplitWeights /
// setPaneWidthOverride each mint a new tree/pane-state object, and the
// resulting commit walks every mounted pane — measured live on a real
// 2-session layout: 31 commits across a 58-frame drag, 20.7fps, with
// TreeNode at 490ms and Block/Ct re-parsing markdown for 620ms. The
// store is written ONCE on release; during the drag the seam is
// previewed with inline styles on the same wrappers React sizes.
//
// Preview rules (learned the hard way — a wrong shape here left a
// phantom gap where a hidden sidebar lived):
// - a FIXED side gets ONLY a flex-basis override. Its wrapper renders
// as `flex: 0 1 <track>`, so basis is the whole difference; grow and
// shrink stay React's. Crucially the flex partner is left untouched,
// so it keeps absorbing the remainder and no leftover gap can open.
// - a flex-vs-flex seam pins both sides to `0 1 <px>`. Their combined
// px is constant, so sibling flex tracks see the same leftover.
// - cleanup: a real drag commits the store once, and React's re-render
// rewrites the `flex` shorthand, which clears the overrides (writing
// the shorthand resets the longhands). A no-movement click restores
// the captured style attribute instead, since nothing re-renders.
const applyShift = (shiftPx: number) => {
if (a.fixed) {
a.paneIds.forEach(id => setOverride(id, Math.round(a0px + shiftPx)))
}
if (b.fixed) {
b.paneIds.forEach(id => setOverride(id, Math.round(b0px - shiftPx)))
}
if (!a.fixed && !b.fixed) {
const weights = [...node.weights]
// Clamped px → weights so persisted weights match what's on screen.
weights[aIndex] = (a0px + shiftPx) / pxPerWeight
weights[bIndex] = (b0px - shiftPx) / pxPerWeight
setTreeSplitWeights(node.id, weights)
}
}
const styleA = kidA.getAttribute('style')
const styleB = kidB.getAttribute('style')
const previewSide = (el: HTMLElement, fixed: boolean, px: number) => {
if (fixed) {
el.style.flexBasis = `${px}px`
} else if (!a.fixed && !b.fixed) {
el.style.flex = `0 1 ${px}px`
}
// Mixed seam, flex side: untouched — it absorbs what the fixed side
// gives up, exactly as the track model would render it.
}
const previewShift = (shiftPx: number) => {
previewSide(kidA, a.fixed, a0px + shiftPx)
previewSide(kidB, b.fixed, b0px - shiftPx)
}
const resize = rafCoalesce(previewShift)
let lastShift: null | number = null
const resize = rafCoalesce(previewPlan)
// A return drag stays local only after this gesture has actually crossed
// a second donor. A one-pixel opposite wobble must not poison the real
// forward movement that follows.
let cascadeDirection: -1 | 1 | null = null
let lastPlan: null | ReturnType<typeof planFor> = null
let done = false
const onMove = (ev: PointerEvent) => {
lastShift = Math.max(lo, Math.min(hi, (horizontal ? ev.clientX : ev.clientY) - start))
resize.push(lastShift)
const shift = (horizontal ? ev.clientX : ev.clientY) - start
const direction = Math.sign(shift) as -1 | 0 | 1
const allowCascade = cascadeDirection === null || direction === cascadeDirection
lastPlan = planFor(shift, allowCascade)
if (cascadeDirection === null && direction !== 0 && lastPlan.cascaded) {
cascadeDirection = direction
}
resize.push(lastPlan)
}
// Ends through several racing paths (pointerup, pointercancel, window
@@ -349,36 +458,25 @@ export function TreeSplit({ node, root, rootRow }: { node: SplitNode; root?: boo
done = true
resize.finish()
if (lastShift !== null) {
if (lastPlan && lastPlan.moved !== 0) {
// Dragged a tool panel down to its collapsed header? Fold the zone
// to its rail instead of persisting a sliver — and DON'T write the
// sliver size, so restoring brings back the size it had before.
const collapsedSide =
(a.collapseId && a0px + lastShift <= a.floor && a.collapseId) ||
(b.collapseId && b0px - lastShift <= b.floor && b.collapseId) ||
null
// Only a track THIS gesture took to its floor counts: an unrelated
// rail already resting there must not cancel the commit.
const collapsedSide = sashTracks.find(
(track, index) => track.collapseId && track.initial > track.floor && lastPlan!.sizes[index] <= track.floor
)?.collapseId
if (collapsedSide) {
setTreeGroupMinimized(collapsedSide, true)
} else {
// One store commit; the re-render rewrites `flex` and clears the
// preview overrides.
applyShift(lastShift)
commitPlan(lastPlan)
}
} else {
// Click without movement: nothing will re-render, so put the
// wrappers' inline styles back exactly as React last wrote them.
if (styleA === null) {
kidA.removeAttribute('style')
} else {
kidA.setAttribute('style', styleA)
}
if (styleB === null) {
kidB.removeAttribute('style')
} else {
kidB.setAttribute('style', styleB)
}
restoreStyles()
}
// Geometry vars re-enable AFTER the final store commit above, so the