fix(desktop): track the pointer when dragging a nested section's sash

The workspace | right-rail seam resizes the rail's inner review zone,
but the drag preview wrote that zone's px as the flex-basis of the whole
section wrapper, so the rail snapped narrower mid-drag and jumped on
release. The preview now grows each fixed track's wrapper by the zone's
delta from its own pointerdown width, sizes the inner zone item to the
planned px, and restores both on release. This applies per track, so a
cascade that reaches a nested section past the seam partner previews
correctly too.

Co-authored-by: David Metcalfe <80915+DavidMetcalfe@users.noreply.github.com>
This commit is contained in:
Hermes Agent
2026-09-24 18:56:16 -05:00
committed by brooklyn!
parent fe3c6837be
commit 4050c91580
2 changed files with 243 additions and 10 deletions

View File

@@ -0,0 +1,221 @@
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 } from '../model'
import { $hiddenTreePanes, $layoutTree } 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 })
// Flush the drag preview on the same turn so mid-drag styles are observable.
vi.stubGlobal('requestAnimationFrame', (cb: FrameRequestCallback) => {
cb(0)
return 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: 'workspace',
render: () => null,
title: 'Chat'
}),
registry.register({
area: 'panes',
data: { maxWidth: '320px', minWidth: '160px', placement: 'right', width: '237px' },
id: 'review',
render: () => null,
title: 'Review'
}),
registry.register({
area: 'panes',
data: { maxWidth: '320px', minWidth: '160px', placement: 'right', width: '237px' },
id: 'files',
render: () => null,
title: 'Files'
})
)
})
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) })
}
/** Default-tree shape: workspace | column[row[review|files]]. The seam's resize
* target is the inner review zone, not the section wrapper. */
function nestedRail() {
return split(
'row',
[
group(['workspace'], { id: 'grp-main' }),
split(
'column',
[
split(
'row',
[group(['review'], { id: 'grp-review' }), group(['files'], { id: 'grp-files' })],
[1, 1.2],
'spl-rail'
)
],
[1],
'spl-right'
)
],
[3.4, 1.25],
'spl-root'
)
}
describe('TreeSplit nested section sash preview', () => {
it('sizes the section wrapper from its pointerdown width and previews the inner zone', () => {
const tree = nestedRail()
$layoutTree.set(tree)
render(<TreeSplit node={tree} root rootRow />)
const container = document.querySelector<HTMLElement>('[data-tree-split="spl-root"]')!
const [workspace, section] = [...container.children] as HTMLElement[]
const review = document.querySelector<HTMLElement>('[data-tree-group="grp-review"]')!
const reviewItem = review.parentElement as HTMLElement
setWidth(container, 1000)
setWidth(workspace, 526)
setWidth(section, 474)
setWidth(review, 237)
setWidth(document.querySelector<HTMLElement>('[data-tree-group="grp-files"]')!, 237)
const sash = section.querySelector('[role="separator"]')!
fireEvent.pointerDown(sash, { button: 0, clientX: 700, pointerId: 1, pointerType: 'mouse' })
fireEvent.pointerMove(window, { clientX: 620, pointerId: 1, pointerType: 'mouse' })
// 474px section + 80px drag, not the 237px review zone wearing the section's basis.
expect(section.style.flexBasis).toBe('554px')
expect(reviewItem).not.toBe(section)
expect(reviewItem.style.flexBasis).toBe('317px')
fireEvent.pointerUp(window, { clientX: 620, pointerId: 1, pointerType: 'mouse' })
expect($paneStates.get().review?.widthOverride).toBe(317)
expect($paneStates.get().files?.widthOverride).toBeUndefined()
// React commits the section as a calc() of the zone override plus its
// siblings. A stuck preview pin would still be the raw `554px` basis.
expect(section.style.flexBasis).not.toBe('554px')
})
it('previews a nested section reached by a cascade past the seam partner', () => {
disposers.push(
registry.register({
area: 'panes',
data: { placement: 'main', width: '100px' },
id: 'cron',
render: () => null,
title: 'Cron'
})
)
const tree = split(
'row',
[
group(['workspace'], { id: 'grp-main' }),
group(['cron'], { id: 'grp-cron' }),
split(
'column',
[
split(
'row',
[group(['review'], { id: 'grp-review' }), group(['files'], { id: 'grp-files' })],
[1, 1.2],
'spl-rail'
)
],
[1],
'spl-right'
)
],
[3, 1, 1.25],
'spl-root'
)
$layoutTree.set(tree)
render(<TreeSplit node={tree} root rootRow />)
const container = document.querySelector<HTMLElement>('[data-tree-split="spl-root"]')!
const [workspace, cron, section] = [...container.children] as HTMLElement[]
const review = document.querySelector<HTMLElement>('[data-tree-group="grp-review"]')!
const reviewItem = review.parentElement as HTMLElement
setWidth(container, 1000)
setWidth(workspace, 426)
setWidth(cron, 100)
setWidth(document.querySelector<HTMLElement>('[data-tree-group="grp-cron"]')!, 100)
setWidth(section, 474)
setWidth(review, 237)
setWidth(document.querySelector<HTMLElement>('[data-tree-group="grp-files"]')!, 237)
const sash = cron.querySelector('[role="separator"]')!
fireEvent.pointerDown(sash, { button: 0, clientX: 426, pointerId: 1, pointerType: 'mouse' })
fireEvent.pointerMove(window, { clientX: 486, pointerId: 1, pointerType: 'mouse' })
// Cron gives 20px down to its 80px floor, then the section's review zone
// gives the other 40px: the wrapper shrinks from 474px, the zone from 237px.
expect(cron.style.flexBasis).toBe('80px')
expect(section.style.flexBasis).toBe('434px')
expect(reviewItem.style.flexBasis).toBe('197px')
fireEvent.pointerUp(window, { clientX: 486, pointerId: 1, pointerType: 'mouse' })
expect($paneStates.get().cron?.widthOverride).toBe(80)
expect($paneStates.get().review?.widthOverride).toBe(197)
expect($paneStates.get().files?.widthOverride).toBeUndefined()
expect(section.style.flexBasis).not.toBe('434px')
})
})

View File

@@ -232,8 +232,9 @@ export function TreeSplit({
const zone = fixed ? edgeFixedZone(child, edge, axis, trackCtx) : null
const zoneEl = zone ? container.querySelector<HTMLElement>(`[data-tree-group="${zone.id}"]`) : null
// Clamps live on the zone's split-child WRAPPER (where we render them).
const el = zoneEl?.parentElement ?? wrapper
const cs = window.getComputedStyle(el)
// For a nested section this is the INNER flex item, not the seam partner.
const zoneItem = zoneEl?.parentElement ?? wrapper
const cs = window.getComputedStyle(zoneItem)
// A tool panel (terminal / logs) may be dragged down to its collapsed
// header — the generic 80px floor is not its floor. Below that the
// release minimizes the zone instead of leaving a useless sliver.
@@ -251,7 +252,10 @@ export function TreeSplit({
min: toolZone ? floor : Math.max(floor, computedPx(horizontal ? cs.minWidth : cs.minHeight, 0)),
max: computedPx(horizontal ? cs.maxWidth : cs.maxHeight, Number.POSITIVE_INFINITY),
collapseId: toolZone ? (zone?.id ?? groupIdOf(child)) : null,
floor
floor,
// The flex item the release commit resizes: the seam partner itself
// for a direct group, the inner zone wrapper for a nested section.
zoneItem
}
}
@@ -276,6 +280,9 @@ export function TreeSplit({
element,
index,
initial: side.fixed ? side.size : sizeOf(element),
// Seam-partner width at pointerdown. A nested section is wider than
// its edge zone, so the preview grows the wrapper from this width.
wrapperSize: 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.
@@ -373,16 +380,17 @@ export function TreeSplit({
}
}
const styleSnapshots = sashTracks.map(track => track.element.getAttribute('style'))
// Nested sections also preview their inner zone wrapper, so snapshot it too.
const styleSnapshots = [...new Set(sashTracks.flatMap(track => [track.element, track.zoneItem]))].map(
el => [el, el.getAttribute('style')] as const
)
const restoreStyles = () => {
sashTracks.forEach((track, index) => {
const style = styleSnapshots[index]
styleSnapshots.forEach(([el, style]) => {
if (style === null) {
track.element.removeAttribute('style')
el.removeAttribute('style')
} else {
track.element.setAttribute('style', style)
el.setAttribute('style', style)
}
})
}
@@ -414,7 +422,11 @@ export function TreeSplit({
const px = plan.sizes[index]
if (track.fixed) {
track.element.style.flexBasis = `${px}px`
// Fixed tracks plan in zone space. A nested section's wrapper moves
// by the zone's delta from its own width. For a direct group both
// are one element, and the second write leaves it at `px`.
track.element.style.flexBasis = `${track.wrapperSize + px - track.initial}px`
track.zoneItem.style.flexBasis = `${px}px`
} else {
track.element.style.flex = `0 1 ${px}px`
}