Fix terminal pane drag cleanup (#7014)
* Fix terminal pane drag cleanup * Harden terminal divider cancellation
This commit is contained in:
parent
c1fae31a3f
commit
0dfd56af30
|
|
@ -0,0 +1,294 @@
|
|||
import { holdPtyResizesForPaneSubtrees } from './pane-pty-resize-hold'
|
||||
|
||||
export type DividerCallbacks = {
|
||||
refitPanesUnder: (el: HTMLElement) => void
|
||||
onLayoutChanged?: () => void
|
||||
onDragActiveChange?: (active: boolean) => void
|
||||
}
|
||||
|
||||
type DividerFlexFrameScheduler = {
|
||||
schedule: (prevFlex: number, nextFlex: number) => void
|
||||
flush: () => void
|
||||
cancel: () => void
|
||||
}
|
||||
|
||||
const MIN_PANE_SIZE = 50
|
||||
const dividerDragCleanups = new WeakMap<HTMLElement, () => void>()
|
||||
|
||||
export function createDividerFlexFrameScheduler({
|
||||
apply,
|
||||
requestFrame = requestAnimationFrame,
|
||||
cancelFrame = cancelAnimationFrame
|
||||
}: {
|
||||
apply: (prevFlex: number, nextFlex: number) => void
|
||||
requestFrame?: (callback: FrameRequestCallback) => number
|
||||
cancelFrame?: (handle: number) => void
|
||||
}): DividerFlexFrameScheduler {
|
||||
let frameId: number | null = null
|
||||
let pending: { prevFlex: number; nextFlex: number } | null = null
|
||||
|
||||
const applyPending = (): void => {
|
||||
frameId = null
|
||||
const next = pending
|
||||
pending = null
|
||||
if (!next) {
|
||||
return
|
||||
}
|
||||
apply(next.prevFlex, next.nextFlex)
|
||||
}
|
||||
|
||||
return {
|
||||
schedule(prevFlex, nextFlex) {
|
||||
pending = { prevFlex, nextFlex }
|
||||
if (frameId !== null) {
|
||||
return
|
||||
}
|
||||
frameId = requestFrame(applyPending)
|
||||
},
|
||||
flush() {
|
||||
if (frameId !== null) {
|
||||
cancelFrame(frameId)
|
||||
frameId = null
|
||||
}
|
||||
applyPending()
|
||||
},
|
||||
cancel() {
|
||||
if (frameId !== null) {
|
||||
cancelFrame(frameId)
|
||||
frameId = null
|
||||
}
|
||||
pending = null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function attachDividerDrag(
|
||||
divider: HTMLElement,
|
||||
isVertical: boolean,
|
||||
callbacks: DividerCallbacks
|
||||
): void {
|
||||
let dragging = false
|
||||
let didMove = false
|
||||
let startPos = 0
|
||||
let prevFlex = 0
|
||||
let totalSize = 0
|
||||
let prevEl: HTMLElement | null = null
|
||||
let nextEl: HTMLElement | null = null
|
||||
let prevInitialFlex = ''
|
||||
let nextInitialFlex = ''
|
||||
let activePointerId: number | null = null
|
||||
let releasePtyResizeHold: { flush: () => void; cancel: () => void } | null = null
|
||||
let windowListenersAttached = false
|
||||
const flexScheduler = createDividerFlexFrameScheduler({
|
||||
apply: (newPrev, newNext) => {
|
||||
if (!prevEl || !nextEl) {
|
||||
return
|
||||
}
|
||||
prevEl.style.flex = `${newPrev} 1 0%`
|
||||
nextEl.style.flex = `${newNext} 1 0%`
|
||||
}
|
||||
})
|
||||
|
||||
const addWindowListeners = (): void => {
|
||||
if (windowListenersAttached || typeof window === 'undefined') {
|
||||
return
|
||||
}
|
||||
windowListenersAttached = true
|
||||
window.addEventListener('pointermove', onPointerMove, true)
|
||||
window.addEventListener('pointerup', onPointerUp, true)
|
||||
window.addEventListener('pointercancel', onPointerCancel, true)
|
||||
window.addEventListener('blur', onWindowBlur, true)
|
||||
}
|
||||
|
||||
const removeWindowListeners = (): void => {
|
||||
if (!windowListenersAttached || typeof window === 'undefined') {
|
||||
return
|
||||
}
|
||||
windowListenersAttached = false
|
||||
window.removeEventListener('pointermove', onPointerMove, true)
|
||||
window.removeEventListener('pointerup', onPointerUp, true)
|
||||
window.removeEventListener('pointercancel', onPointerCancel, true)
|
||||
window.removeEventListener('blur', onWindowBlur, true)
|
||||
}
|
||||
|
||||
const releasePointerCaptureIfHeld = (pointerId: number | null): void => {
|
||||
if (pointerId === null) {
|
||||
return
|
||||
}
|
||||
try {
|
||||
if (divider.hasPointerCapture(pointerId)) {
|
||||
divider.releasePointerCapture(pointerId)
|
||||
}
|
||||
} catch {
|
||||
// Best effort: capture may already be gone after crossing native chrome/webviews.
|
||||
}
|
||||
}
|
||||
|
||||
const finishActiveDrag = (commitLayout: boolean): void => {
|
||||
if (!dragging) {
|
||||
removeWindowListeners()
|
||||
releasePointerCaptureIfHeld(activePointerId)
|
||||
activePointerId = null
|
||||
return
|
||||
}
|
||||
|
||||
const pointerId = activePointerId
|
||||
dragging = false
|
||||
activePointerId = null
|
||||
removeWindowListeners()
|
||||
|
||||
if (commitLayout) {
|
||||
flexScheduler.flush()
|
||||
} else {
|
||||
flexScheduler.cancel()
|
||||
if (didMove && prevEl && nextEl) {
|
||||
prevEl.style.flex = prevInitialFlex
|
||||
nextEl.style.flex = nextInitialFlex
|
||||
}
|
||||
}
|
||||
|
||||
releasePointerCaptureIfHeld(pointerId)
|
||||
divider.classList.remove('is-dragging')
|
||||
callbacks.onDragActiveChange?.(false)
|
||||
|
||||
const shouldRefit = didMove || commitLayout
|
||||
if (shouldRefit && prevEl) {
|
||||
callbacks.refitPanesUnder(prevEl)
|
||||
}
|
||||
if (shouldRefit && nextEl) {
|
||||
callbacks.refitPanesUnder(nextEl)
|
||||
}
|
||||
if (shouldRefit) {
|
||||
releasePtyResizeHold?.flush()
|
||||
} else {
|
||||
releasePtyResizeHold?.cancel()
|
||||
}
|
||||
releasePtyResizeHold = null
|
||||
prevEl = null
|
||||
nextEl = null
|
||||
prevInitialFlex = ''
|
||||
nextInitialFlex = ''
|
||||
|
||||
if (didMove && commitLayout) {
|
||||
callbacks.onLayoutChanged?.()
|
||||
}
|
||||
didMove = false
|
||||
}
|
||||
|
||||
const onPointerDown = (e: PointerEvent): void => {
|
||||
e.preventDefault()
|
||||
flexScheduler.cancel()
|
||||
finishActiveDrag(false)
|
||||
divider.setPointerCapture(e.pointerId)
|
||||
activePointerId = e.pointerId
|
||||
divider.classList.add('is-dragging')
|
||||
dragging = true
|
||||
didMove = false
|
||||
callbacks.onDragActiveChange?.(true)
|
||||
addWindowListeners()
|
||||
|
||||
startPos = isVertical ? e.clientX : e.clientY
|
||||
prevEl = divider.previousElementSibling as HTMLElement | null
|
||||
nextEl = divider.nextElementSibling as HTMLElement | null
|
||||
|
||||
if (!prevEl || !nextEl) {
|
||||
return
|
||||
}
|
||||
prevInitialFlex = prevEl.style.flex
|
||||
nextInitialFlex = nextEl.style.flex
|
||||
|
||||
// Why: shells redraw prompts on every PTY SIGWINCH. During a divider drag
|
||||
// we still fit xterm locally, but forward only the final PTY size on drop.
|
||||
releasePtyResizeHold = holdPtyResizesForPaneSubtrees([prevEl, nextEl])
|
||||
|
||||
const prevRect = prevEl.getBoundingClientRect()
|
||||
const nextRect = nextEl.getBoundingClientRect()
|
||||
const prevSize = isVertical ? prevRect.width : prevRect.height
|
||||
const nextSize = isVertical ? nextRect.width : nextRect.height
|
||||
totalSize = prevSize + nextSize
|
||||
prevFlex = prevSize
|
||||
}
|
||||
|
||||
const onPointerMove = (e: PointerEvent): void => {
|
||||
if (!dragging || e.pointerId !== activePointerId || !prevEl || !nextEl) {
|
||||
return
|
||||
}
|
||||
didMove = true
|
||||
|
||||
const currentPos = isVertical ? e.clientX : e.clientY
|
||||
const delta = currentPos - startPos
|
||||
|
||||
const effectiveMinPaneSize = Math.min(MIN_PANE_SIZE, totalSize / 2)
|
||||
const maxPrev = totalSize - effectiveMinPaneSize
|
||||
// Why: tiny restored/SSH layouts can be smaller than two minimum panes;
|
||||
// clamping both sides to 50px there would create invalid negative flex.
|
||||
const newPrev = Math.min(Math.max(prevFlex + delta, effectiveMinPaneSize), maxPrev)
|
||||
const newNext = totalSize - newPrev
|
||||
|
||||
// Why: pointermove can outpace paint during split resizing. Coalescing the
|
||||
// flex writes keeps drag reflow to one update per frame.
|
||||
flexScheduler.schedule(newPrev, newNext)
|
||||
}
|
||||
|
||||
const onPointerUp = (e: PointerEvent): void => {
|
||||
if (e.pointerId === activePointerId) {
|
||||
finishActiveDrag(true)
|
||||
}
|
||||
}
|
||||
|
||||
const onDoubleClick = (): void => {
|
||||
const prev = divider.previousElementSibling as HTMLElement | null
|
||||
const next = divider.nextElementSibling as HTMLElement | null
|
||||
if (!prev || !next) {
|
||||
return
|
||||
}
|
||||
|
||||
prev.style.flex = '1 1 0%'
|
||||
next.style.flex = '1 1 0%'
|
||||
|
||||
callbacks.refitPanesUnder(prev)
|
||||
callbacks.refitPanesUnder(next)
|
||||
callbacks.onLayoutChanged?.()
|
||||
}
|
||||
|
||||
const onPointerCancel = (e: PointerEvent): void => {
|
||||
if (e.pointerId === activePointerId) {
|
||||
finishActiveDrag(false)
|
||||
}
|
||||
}
|
||||
|
||||
const onLostPointerCapture = (): void => {
|
||||
if (dragging) {
|
||||
finishActiveDrag(false)
|
||||
}
|
||||
}
|
||||
|
||||
const onWindowBlur = (): void => {
|
||||
finishActiveDrag(false)
|
||||
}
|
||||
|
||||
divider.addEventListener('pointerdown', onPointerDown)
|
||||
divider.addEventListener('pointermove', onPointerMove)
|
||||
divider.addEventListener('pointerup', onPointerUp)
|
||||
divider.addEventListener('pointercancel', onPointerCancel)
|
||||
divider.addEventListener('lostpointercapture', onLostPointerCapture)
|
||||
divider.addEventListener('dblclick', onDoubleClick)
|
||||
dividerDragCleanups.set(divider, () => {
|
||||
finishActiveDrag(false)
|
||||
divider.removeEventListener('pointerdown', onPointerDown)
|
||||
divider.removeEventListener('pointermove', onPointerMove)
|
||||
divider.removeEventListener('pointerup', onPointerUp)
|
||||
divider.removeEventListener('pointercancel', onPointerCancel)
|
||||
divider.removeEventListener('lostpointercapture', onLostPointerCapture)
|
||||
divider.removeEventListener('dblclick', onDoubleClick)
|
||||
})
|
||||
}
|
||||
|
||||
export function disposeDividerDrag(divider: HTMLElement): void {
|
||||
const cleanup = dividerDragCleanups.get(divider)
|
||||
if (!cleanup) {
|
||||
return
|
||||
}
|
||||
cleanup()
|
||||
dividerDragCleanups.delete(divider)
|
||||
}
|
||||
|
|
@ -47,6 +47,210 @@ describe('createDividerFlexFrameScheduler', () => {
|
|||
})
|
||||
|
||||
describe('disposeDivider', () => {
|
||||
it('finishes an active resize from a window-level pointerup', () => {
|
||||
const dividerListeners = new Map<string, EventListener>()
|
||||
const windowListeners = new Map<string, EventListener>()
|
||||
const capturedPointerIds = new Set<number>()
|
||||
const previousPane = createSizedPaneElement({ width: 100, height: 200 })
|
||||
const nextPane = createSizedPaneElement({ width: 300, height: 200 })
|
||||
const divider = {
|
||||
style: {
|
||||
setProperty: vi.fn()
|
||||
},
|
||||
classList: {
|
||||
add: vi.fn(),
|
||||
remove: vi.fn()
|
||||
},
|
||||
addEventListener: vi.fn((event: string, listener: EventListener) => {
|
||||
dividerListeners.set(event, listener)
|
||||
}),
|
||||
removeEventListener: vi.fn((event: string, listener: EventListener) => {
|
||||
if (dividerListeners.get(event) === listener) {
|
||||
dividerListeners.delete(event)
|
||||
}
|
||||
}),
|
||||
setPointerCapture: vi.fn((pointerId: number) => {
|
||||
capturedPointerIds.add(pointerId)
|
||||
}),
|
||||
hasPointerCapture: vi.fn((pointerId: number) => capturedPointerIds.has(pointerId)),
|
||||
releasePointerCapture: vi.fn((pointerId: number) => {
|
||||
capturedPointerIds.delete(pointerId)
|
||||
}),
|
||||
previousElementSibling: previousPane,
|
||||
nextElementSibling: nextPane
|
||||
} as unknown as HTMLElement
|
||||
const refitPanesUnder = vi.fn()
|
||||
const onLayoutChanged = vi.fn()
|
||||
vi.stubGlobal('document', {
|
||||
createElement: vi.fn(() => divider)
|
||||
})
|
||||
vi.stubGlobal('window', {
|
||||
addEventListener: vi.fn((event: string, listener: EventListener) => {
|
||||
windowListeners.set(event, listener)
|
||||
}),
|
||||
removeEventListener: vi.fn((event: string, listener: EventListener) => {
|
||||
if (windowListeners.get(event) === listener) {
|
||||
windowListeners.delete(event)
|
||||
}
|
||||
})
|
||||
})
|
||||
vi.stubGlobal(
|
||||
'requestAnimationFrame',
|
||||
vi.fn(() => 7)
|
||||
)
|
||||
vi.stubGlobal('cancelAnimationFrame', vi.fn())
|
||||
|
||||
createDivider(true, {}, { refitPanesUnder, onLayoutChanged })
|
||||
dividerListeners.get('pointerdown')?.(
|
||||
createPointerEvent({ pointerId: 9, clientX: 100, clientY: 0 })
|
||||
)
|
||||
windowListeners.get('pointermove')?.(
|
||||
createPointerEvent({ pointerId: 9, clientX: 180, clientY: 0 })
|
||||
)
|
||||
|
||||
const windowPointerUp = windowListeners.get('pointerup')
|
||||
expect(windowPointerUp).toBeTypeOf('function')
|
||||
windowPointerUp?.(createPointerEvent({ pointerId: 9, clientX: 180, clientY: 0 }))
|
||||
|
||||
expect(previousPane.style.flex).toBe('180 1 0%')
|
||||
expect(nextPane.style.flex).toBe('220 1 0%')
|
||||
expect(refitPanesUnder).toHaveBeenCalledWith(previousPane)
|
||||
expect(refitPanesUnder).toHaveBeenCalledWith(nextPane)
|
||||
expect(onLayoutChanged).toHaveBeenCalledTimes(1)
|
||||
expect(divider.classList.remove).toHaveBeenCalledWith('is-dragging')
|
||||
expect(divider.releasePointerCapture).toHaveBeenCalledWith(9)
|
||||
expect(windowListeners.has('pointermove')).toBe(false)
|
||||
expect(windowListeners.has('pointerup')).toBe(false)
|
||||
})
|
||||
|
||||
it('keeps flex bases nonnegative when panes are smaller than combined minimums', () => {
|
||||
const dividerListeners = new Map<string, EventListener>()
|
||||
const windowListeners = new Map<string, EventListener>()
|
||||
const capturedPointerIds = new Set<number>()
|
||||
const previousPane = createSizedPaneElement({ width: 30, height: 200 })
|
||||
const nextPane = createSizedPaneElement({ width: 40, height: 200 })
|
||||
const divider = {
|
||||
style: {
|
||||
setProperty: vi.fn()
|
||||
},
|
||||
classList: {
|
||||
add: vi.fn(),
|
||||
remove: vi.fn()
|
||||
},
|
||||
addEventListener: vi.fn((event: string, listener: EventListener) => {
|
||||
dividerListeners.set(event, listener)
|
||||
}),
|
||||
removeEventListener: vi.fn(),
|
||||
setPointerCapture: vi.fn((pointerId: number) => {
|
||||
capturedPointerIds.add(pointerId)
|
||||
}),
|
||||
hasPointerCapture: vi.fn((pointerId: number) => capturedPointerIds.has(pointerId)),
|
||||
releasePointerCapture: vi.fn((pointerId: number) => {
|
||||
capturedPointerIds.delete(pointerId)
|
||||
}),
|
||||
previousElementSibling: previousPane,
|
||||
nextElementSibling: nextPane
|
||||
} as unknown as HTMLElement
|
||||
vi.stubGlobal('document', {
|
||||
createElement: vi.fn(() => divider)
|
||||
})
|
||||
vi.stubGlobal('window', {
|
||||
addEventListener: vi.fn((event: string, listener: EventListener) => {
|
||||
windowListeners.set(event, listener)
|
||||
}),
|
||||
removeEventListener: vi.fn()
|
||||
})
|
||||
vi.stubGlobal(
|
||||
'requestAnimationFrame',
|
||||
vi.fn(() => 7)
|
||||
)
|
||||
vi.stubGlobal('cancelAnimationFrame', vi.fn())
|
||||
|
||||
createDivider(true, {}, { refitPanesUnder: vi.fn(), onLayoutChanged: vi.fn() })
|
||||
dividerListeners.get('pointerdown')?.(
|
||||
createPointerEvent({ pointerId: 9, clientX: 0, clientY: 0 })
|
||||
)
|
||||
windowListeners.get('pointermove')?.(
|
||||
createPointerEvent({ pointerId: 9, clientX: 200, clientY: 0 })
|
||||
)
|
||||
windowListeners.get('pointerup')?.(createPointerEvent({ pointerId: 9, clientX: 200 }))
|
||||
|
||||
expect(previousPane.style.flex).toBe('35 1 0%')
|
||||
expect(nextPane.style.flex).toBe('35 1 0%')
|
||||
})
|
||||
|
||||
it('restores original flex styles when an active resize is cancelled', () => {
|
||||
const dividerListeners = new Map<string, EventListener>()
|
||||
const windowListeners = new Map<string, EventListener>()
|
||||
const capturedPointerIds = new Set<number>()
|
||||
const queuedFrames: FrameRequestCallback[] = []
|
||||
const previousPane = createSizedPaneElement({ width: 100, height: 200 })
|
||||
const nextPane = createSizedPaneElement({ width: 300, height: 200 })
|
||||
previousPane.style.flex = '2 1 0%'
|
||||
nextPane.style.flex = '3 1 0%'
|
||||
const divider = {
|
||||
style: {
|
||||
setProperty: vi.fn()
|
||||
},
|
||||
classList: {
|
||||
add: vi.fn(),
|
||||
remove: vi.fn()
|
||||
},
|
||||
addEventListener: vi.fn((event: string, listener: EventListener) => {
|
||||
dividerListeners.set(event, listener)
|
||||
}),
|
||||
removeEventListener: vi.fn(),
|
||||
setPointerCapture: vi.fn((pointerId: number) => {
|
||||
capturedPointerIds.add(pointerId)
|
||||
}),
|
||||
hasPointerCapture: vi.fn((pointerId: number) => capturedPointerIds.has(pointerId)),
|
||||
releasePointerCapture: vi.fn((pointerId: number) => {
|
||||
capturedPointerIds.delete(pointerId)
|
||||
}),
|
||||
previousElementSibling: previousPane,
|
||||
nextElementSibling: nextPane
|
||||
} as unknown as HTMLElement
|
||||
const refitPanesUnder = vi.fn()
|
||||
const onLayoutChanged = vi.fn()
|
||||
vi.stubGlobal('document', {
|
||||
createElement: vi.fn(() => divider)
|
||||
})
|
||||
vi.stubGlobal('window', {
|
||||
addEventListener: vi.fn((event: string, listener: EventListener) => {
|
||||
windowListeners.set(event, listener)
|
||||
}),
|
||||
removeEventListener: vi.fn()
|
||||
})
|
||||
vi.stubGlobal(
|
||||
'requestAnimationFrame',
|
||||
vi.fn((callback: FrameRequestCallback) => {
|
||||
queuedFrames.push(callback)
|
||||
return queuedFrames.length
|
||||
})
|
||||
)
|
||||
vi.stubGlobal('cancelAnimationFrame', vi.fn())
|
||||
|
||||
createDivider(true, {}, { refitPanesUnder, onLayoutChanged })
|
||||
dividerListeners.get('pointerdown')?.(
|
||||
createPointerEvent({ pointerId: 9, clientX: 100, clientY: 0 })
|
||||
)
|
||||
windowListeners.get('pointermove')?.(
|
||||
createPointerEvent({ pointerId: 9, clientX: 180, clientY: 0 })
|
||||
)
|
||||
queuedFrames[0]?.(16)
|
||||
|
||||
expect(previousPane.style.flex).toBe('180 1 0%')
|
||||
expect(nextPane.style.flex).toBe('220 1 0%')
|
||||
|
||||
windowListeners.get('pointercancel')?.(createPointerEvent({ pointerId: 9 }))
|
||||
|
||||
expect(previousPane.style.flex).toBe('2 1 0%')
|
||||
expect(nextPane.style.flex).toBe('3 1 0%')
|
||||
expect(refitPanesUnder).toHaveBeenCalledWith(previousPane)
|
||||
expect(refitPanesUnder).toHaveBeenCalledWith(nextPane)
|
||||
expect(onLayoutChanged).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('removes divider-local drag listeners and releases active pointer capture', () => {
|
||||
const listeners = new Map<string, EventListener>()
|
||||
const divider = {
|
||||
|
|
@ -96,3 +300,34 @@ describe('disposeDivider', () => {
|
|||
expect(divider.classList.remove).toHaveBeenCalledWith('is-dragging')
|
||||
})
|
||||
})
|
||||
|
||||
function createPointerEvent(args: Partial<PointerEvent>): PointerEvent {
|
||||
return {
|
||||
preventDefault: vi.fn(),
|
||||
pointerId: 1,
|
||||
clientX: 0,
|
||||
clientY: 0,
|
||||
...args
|
||||
} as unknown as PointerEvent
|
||||
}
|
||||
|
||||
function createSizedPaneElement(rect: {
|
||||
width: number
|
||||
height: number
|
||||
}): HTMLElement & { style: Record<string, string> } {
|
||||
return {
|
||||
style: {},
|
||||
classList: {
|
||||
contains: vi.fn(() => false)
|
||||
},
|
||||
getBoundingClientRect: vi.fn(() => ({
|
||||
left: 0,
|
||||
top: 0,
|
||||
right: rect.width,
|
||||
bottom: rect.height,
|
||||
width: rect.width,
|
||||
height: rect.height
|
||||
})),
|
||||
querySelectorAll: vi.fn(() => [])
|
||||
} as unknown as HTMLElement & { style: Record<string, string> }
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
import type { PaneStyleOptions, ManagedPaneInternal } from './pane-manager-types'
|
||||
import { holdPtyResizesForPaneSubtrees } from './pane-pty-resize-hold'
|
||||
import { attachDividerDrag, disposeDividerDrag, type DividerCallbacks } from './pane-divider-drag'
|
||||
export { createDividerFlexFrameScheduler } from './pane-divider-drag'
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Divider creation & drag-to-resize
|
||||
|
|
@ -12,66 +13,6 @@ export function getDividerHitSize(styleOptions: PaneStyleOptions): number {
|
|||
return thickness + HIT_PADDING * 2
|
||||
}
|
||||
|
||||
type DividerCallbacks = {
|
||||
refitPanesUnder: (el: HTMLElement) => void
|
||||
onLayoutChanged?: () => void
|
||||
}
|
||||
|
||||
type DividerFlexFrameScheduler = {
|
||||
schedule: (prevFlex: number, nextFlex: number) => void
|
||||
flush: () => void
|
||||
cancel: () => void
|
||||
}
|
||||
|
||||
const dividerDragCleanups = new WeakMap<HTMLElement, () => void>()
|
||||
|
||||
export function createDividerFlexFrameScheduler({
|
||||
apply,
|
||||
requestFrame = requestAnimationFrame,
|
||||
cancelFrame = cancelAnimationFrame
|
||||
}: {
|
||||
apply: (prevFlex: number, nextFlex: number) => void
|
||||
requestFrame?: (callback: FrameRequestCallback) => number
|
||||
cancelFrame?: (handle: number) => void
|
||||
}): DividerFlexFrameScheduler {
|
||||
let frameId: number | null = null
|
||||
let pending: { prevFlex: number; nextFlex: number } | null = null
|
||||
|
||||
const applyPending = (): void => {
|
||||
frameId = null
|
||||
const next = pending
|
||||
pending = null
|
||||
if (!next) {
|
||||
return
|
||||
}
|
||||
apply(next.prevFlex, next.nextFlex)
|
||||
}
|
||||
|
||||
return {
|
||||
schedule(prevFlex, nextFlex) {
|
||||
pending = { prevFlex, nextFlex }
|
||||
if (frameId !== null) {
|
||||
return
|
||||
}
|
||||
frameId = requestFrame(applyPending)
|
||||
},
|
||||
flush() {
|
||||
if (frameId !== null) {
|
||||
cancelFrame(frameId)
|
||||
frameId = null
|
||||
}
|
||||
applyPending()
|
||||
},
|
||||
cancel() {
|
||||
if (frameId !== null) {
|
||||
cancelFrame(frameId)
|
||||
frameId = null
|
||||
}
|
||||
pending = null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function createDivider(
|
||||
isVertical: boolean,
|
||||
styleOptions: PaneStyleOptions,
|
||||
|
|
@ -99,12 +40,7 @@ export function createDivider(
|
|||
}
|
||||
|
||||
export function disposeDivider(divider: HTMLElement): void {
|
||||
const cleanup = dividerDragCleanups.get(divider)
|
||||
if (!cleanup) {
|
||||
return
|
||||
}
|
||||
cleanup()
|
||||
dividerDragCleanups.delete(divider)
|
||||
disposeDividerDrag(divider)
|
||||
}
|
||||
|
||||
export function disposeDividersIn(root: HTMLElement): void {
|
||||
|
|
@ -114,193 +50,6 @@ export function disposeDividersIn(root: HTMLElement): void {
|
|||
}
|
||||
}
|
||||
|
||||
function attachDividerDrag(
|
||||
divider: HTMLElement,
|
||||
isVertical: boolean,
|
||||
callbacks: DividerCallbacks
|
||||
): void {
|
||||
const MIN_PANE_SIZE = 50
|
||||
|
||||
let dragging = false
|
||||
let didMove = false
|
||||
let startPos = 0
|
||||
let prevFlex = 0
|
||||
let nextFlex = 0
|
||||
let totalSize = 0
|
||||
let prevEl: HTMLElement | null = null
|
||||
let nextEl: HTMLElement | null = null
|
||||
let activePointerId: number | null = null
|
||||
let releasePtyResizeHold: { flush: () => void; cancel: () => void } | null = null
|
||||
const flexScheduler = createDividerFlexFrameScheduler({
|
||||
apply: (newPrev, newNext) => {
|
||||
if (!prevEl || !nextEl) {
|
||||
return
|
||||
}
|
||||
prevEl.style.flex = `${newPrev} 1 0%`
|
||||
nextEl.style.flex = `${newNext} 1 0%`
|
||||
}
|
||||
})
|
||||
|
||||
const onPointerDown = (e: PointerEvent): void => {
|
||||
e.preventDefault()
|
||||
flexScheduler.cancel()
|
||||
divider.setPointerCapture(e.pointerId)
|
||||
activePointerId = e.pointerId
|
||||
divider.classList.add('is-dragging')
|
||||
dragging = true
|
||||
didMove = false
|
||||
|
||||
startPos = isVertical ? e.clientX : e.clientY
|
||||
|
||||
// Find previous and next pane/split siblings
|
||||
prevEl = divider.previousElementSibling as HTMLElement | null
|
||||
nextEl = divider.nextElementSibling as HTMLElement | null
|
||||
|
||||
if (!prevEl || !nextEl) {
|
||||
return
|
||||
}
|
||||
// Why: shells redraw prompts on every PTY SIGWINCH. During a divider drag
|
||||
// we still fit xterm locally, but forward only the final PTY size on drop.
|
||||
releasePtyResizeHold = holdPtyResizesForPaneSubtrees([prevEl, nextEl])
|
||||
|
||||
const prevRect = prevEl.getBoundingClientRect()
|
||||
const nextRect = nextEl.getBoundingClientRect()
|
||||
const prevSize = isVertical ? prevRect.width : prevRect.height
|
||||
const nextSize = isVertical ? nextRect.width : nextRect.height
|
||||
totalSize = prevSize + nextSize
|
||||
|
||||
// Store current proportions as flex-basis values
|
||||
prevFlex = prevSize
|
||||
nextFlex = nextSize
|
||||
}
|
||||
|
||||
const onPointerMove = (e: PointerEvent): void => {
|
||||
if (!dragging || !prevEl || !nextEl) {
|
||||
return
|
||||
}
|
||||
didMove = true
|
||||
|
||||
const currentPos = isVertical ? e.clientX : e.clientY
|
||||
const delta = currentPos - startPos
|
||||
|
||||
let newPrev = prevFlex + delta
|
||||
let newNext = nextFlex - delta
|
||||
|
||||
// Enforce minimum pane size
|
||||
if (newPrev < MIN_PANE_SIZE) {
|
||||
newPrev = MIN_PANE_SIZE
|
||||
newNext = totalSize - MIN_PANE_SIZE
|
||||
}
|
||||
if (newNext < MIN_PANE_SIZE) {
|
||||
newNext = MIN_PANE_SIZE
|
||||
newPrev = totalSize - MIN_PANE_SIZE
|
||||
}
|
||||
|
||||
// Why: pointermove can outpace paint during split resizing. Coalescing the
|
||||
// flex writes keeps drag reflow to one update per frame.
|
||||
flexScheduler.schedule(newPrev, newNext)
|
||||
}
|
||||
|
||||
const onPointerUp = (e: PointerEvent): void => {
|
||||
if (!dragging) {
|
||||
return
|
||||
}
|
||||
dragging = false
|
||||
flexScheduler.flush()
|
||||
activePointerId = null
|
||||
if (divider.hasPointerCapture(e.pointerId)) {
|
||||
divider.releasePointerCapture(e.pointerId)
|
||||
}
|
||||
divider.classList.remove('is-dragging')
|
||||
// Final refit at the exact drop position.
|
||||
if (prevEl) {
|
||||
callbacks.refitPanesUnder(prevEl)
|
||||
}
|
||||
if (nextEl) {
|
||||
callbacks.refitPanesUnder(nextEl)
|
||||
}
|
||||
releasePtyResizeHold?.flush()
|
||||
releasePtyResizeHold = null
|
||||
prevEl = null
|
||||
nextEl = null
|
||||
|
||||
// Persist updated ratios after a real drag
|
||||
if (didMove) {
|
||||
callbacks.onLayoutChanged?.()
|
||||
}
|
||||
}
|
||||
|
||||
// Ghostty-style: double-click divider to equalize sibling panes
|
||||
const onDoubleClick = (): void => {
|
||||
const prev = divider.previousElementSibling as HTMLElement | null
|
||||
const next = divider.nextElementSibling as HTMLElement | null
|
||||
if (!prev || !next) {
|
||||
return
|
||||
}
|
||||
|
||||
prev.style.flex = '1 1 0%'
|
||||
next.style.flex = '1 1 0%'
|
||||
|
||||
callbacks.refitPanesUnder(prev)
|
||||
callbacks.refitPanesUnder(next)
|
||||
callbacks.onLayoutChanged?.()
|
||||
}
|
||||
|
||||
const cancelActiveDrag = (): void => {
|
||||
dragging = false
|
||||
flexScheduler.cancel()
|
||||
releasePtyResizeHold?.cancel()
|
||||
releasePtyResizeHold = null
|
||||
activePointerId = null
|
||||
prevEl = null
|
||||
nextEl = null
|
||||
divider.classList.remove('is-dragging')
|
||||
}
|
||||
|
||||
const onPointerCancel = (): void => {
|
||||
cancelActiveDrag()
|
||||
}
|
||||
|
||||
const onLostPointerCapture = (): void => {
|
||||
if (!dragging) {
|
||||
return
|
||||
}
|
||||
cancelActiveDrag()
|
||||
}
|
||||
|
||||
divider.addEventListener('pointerdown', onPointerDown)
|
||||
divider.addEventListener('pointermove', onPointerMove)
|
||||
divider.addEventListener('pointerup', onPointerUp)
|
||||
divider.addEventListener('pointercancel', onPointerCancel)
|
||||
divider.addEventListener('lostpointercapture', onLostPointerCapture)
|
||||
divider.addEventListener('dblclick', onDoubleClick)
|
||||
dividerDragCleanups.set(divider, () => {
|
||||
flexScheduler.cancel()
|
||||
releasePtyResizeHold?.cancel()
|
||||
releasePtyResizeHold = null
|
||||
if (activePointerId !== null) {
|
||||
try {
|
||||
if (divider.hasPointerCapture(activePointerId)) {
|
||||
divider.releasePointerCapture(activePointerId)
|
||||
}
|
||||
} catch {
|
||||
// Best effort: the captured pointer may already be gone during teardown.
|
||||
}
|
||||
}
|
||||
activePointerId = null
|
||||
dragging = false
|
||||
prevEl = null
|
||||
nextEl = null
|
||||
divider.classList.remove('is-dragging')
|
||||
divider.removeEventListener('pointerdown', onPointerDown)
|
||||
divider.removeEventListener('pointermove', onPointerMove)
|
||||
divider.removeEventListener('pointerup', onPointerUp)
|
||||
divider.removeEventListener('pointercancel', onPointerCancel)
|
||||
divider.removeEventListener('lostpointercapture', onLostPointerCapture)
|
||||
divider.removeEventListener('dblclick', onDoubleClick)
|
||||
})
|
||||
}
|
||||
|
||||
export function applyDividerStyles(root: HTMLElement, styleOptions: PaneStyleOptions): void {
|
||||
const thickness = styleOptions.dividerThicknessPx ?? 4
|
||||
const hitSize = getDividerHitSize(styleOptions)
|
||||
|
|
|
|||
|
|
@ -45,8 +45,15 @@ export function beginPaneDragFromPointerDown(
|
|||
if (state.cleanupActiveDrag === cleanupDrag) {
|
||||
state.cleanupActiveDrag = null
|
||||
}
|
||||
if (pointerId !== null && handle.hasPointerCapture(pointerId)) {
|
||||
handle.releasePointerCapture(pointerId)
|
||||
if (pointerId !== null) {
|
||||
try {
|
||||
if (handle.hasPointerCapture(pointerId)) {
|
||||
handle.releasePointerCapture(pointerId)
|
||||
}
|
||||
} catch {
|
||||
// Best effort: Electron/Chromium can drop capture before our cleanup
|
||||
// runs, but the terminal must never stay pointer-inert.
|
||||
}
|
||||
}
|
||||
if (!dragging) {
|
||||
return
|
||||
|
|
|
|||
|
|
@ -45,6 +45,7 @@ class FakeElement {
|
|||
readonly classList: FakeClassList
|
||||
readonly style: Record<string, string> = {}
|
||||
readonly dataset: Record<string, string> = {}
|
||||
releasePointerCaptureError: Error | null = null
|
||||
private readonly listeners = new Map<string, Set<FakeListener>>()
|
||||
private readonly capturedPointerIds = new Set<number>()
|
||||
removed = false
|
||||
|
|
@ -81,6 +82,9 @@ class FakeElement {
|
|||
}
|
||||
|
||||
releasePointerCapture(pointerId: number): void {
|
||||
if (this.releasePointerCaptureError) {
|
||||
throw this.releasePointerCaptureError
|
||||
}
|
||||
this.capturedPointerIds.delete(pointerId)
|
||||
}
|
||||
|
||||
|
|
@ -231,6 +235,63 @@ describe('attachPaneDrag', () => {
|
|||
expect(insertPaneNextTo).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('cleans pane drag state if releasing pointer capture fails during drop', () => {
|
||||
const handle = new FakeElement()
|
||||
const root = new FakeElement(['pane-manager-root'])
|
||||
const sourceContainer = new FakeElement(['pane'], {
|
||||
left: 0,
|
||||
top: 0,
|
||||
right: 100,
|
||||
bottom: 100,
|
||||
width: 100,
|
||||
height: 100
|
||||
})
|
||||
const targetContainer = new FakeElement(['pane'], {
|
||||
left: 0,
|
||||
top: 100,
|
||||
right: 100,
|
||||
bottom: 200,
|
||||
width: 100,
|
||||
height: 100
|
||||
})
|
||||
const sourcePane = createPane(1, sourceContainer)
|
||||
const targetPane = createPane(2, targetContainer)
|
||||
const panes = new Map<number, ManagedPaneInternal>([
|
||||
[sourcePane.id, sourcePane],
|
||||
[targetPane.id, targetPane]
|
||||
])
|
||||
const onDragActiveChange = vi.fn()
|
||||
const state = createDragReorderState()
|
||||
|
||||
attachPaneDrag(handle as unknown as HTMLElement, sourcePane.id, state, {
|
||||
getPanes: () => panes,
|
||||
getRoot: () => root as unknown as HTMLElement,
|
||||
getStyleOptions: () => ({}),
|
||||
isDestroyed: () => false,
|
||||
safeFit: vi.fn(),
|
||||
applyPaneOpacity: vi.fn(),
|
||||
applyDividerStyles: vi.fn(),
|
||||
refitPanesUnder: vi.fn(),
|
||||
onDragActiveChange
|
||||
})
|
||||
|
||||
handle.dispatchPointer('pointerdown', pointerEvent({ clientX: 10, clientY: 10 }))
|
||||
handle.dispatchPointer('pointermove', pointerEvent({ clientX: 50, clientY: 150 }))
|
||||
handle.releasePointerCaptureError = new Error('release failed')
|
||||
|
||||
expect(() => {
|
||||
handle.dispatchPointer('pointerup', pointerEvent({ pointerId: 1 }))
|
||||
}).not.toThrow()
|
||||
|
||||
expect(root.classList.contains('is-pane-dragging')).toBe(false)
|
||||
expect(sourceContainer.classList.contains('is-drag-source')).toBe(false)
|
||||
expect(appendedElements[0]?.removed).toBe(true)
|
||||
expect(state.dragSourcePaneId).toBeNull()
|
||||
expect(state.currentDropTarget).toBeNull()
|
||||
expect(state.cleanupActiveDrag).toBeNull()
|
||||
expect(onDragActiveChange).toHaveBeenLastCalledWith(false)
|
||||
})
|
||||
|
||||
it('returns cleanup that removes handle listeners and cancels active drag capture', () => {
|
||||
const handle = new FakeElement()
|
||||
const root = new FakeElement(['pane-manager-root'])
|
||||
|
|
|
|||
|
|
@ -376,7 +376,8 @@ export class PaneManager {
|
|||
private createDividerWrapped(isVertical: boolean): HTMLElement {
|
||||
return createDivider(isVertical, this.styleOptions, {
|
||||
refitPanesUnder: (el) => refitPanesUnder(el, this.panes),
|
||||
onLayoutChanged: this.options.onLayoutChanged
|
||||
onLayoutChanged: this.options.onLayoutChanged,
|
||||
onDragActiveChange: this.options.onPaneDragActiveChange
|
||||
})
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -25,6 +25,7 @@ type TreeOpsCallbacks = {
|
|||
safeFit: (pane: ManagedPane) => void
|
||||
refitPanesUnder: (el: HTMLElement) => void
|
||||
onLayoutChanged?: () => void
|
||||
onDragActiveChange?: (active: boolean) => void
|
||||
isDestroyed?: () => boolean
|
||||
requestPaneReparentFrame?: (callback: FrameRequestCallback) => void
|
||||
}
|
||||
|
|
@ -223,7 +224,8 @@ export function insertPaneNextTo(
|
|||
// Create divider
|
||||
const divider = createDivider(isVertical, callbacks.getStyleOptions(), {
|
||||
refitPanesUnder: callbacks.refitPanesUnder,
|
||||
onLayoutChanged: callbacks.onLayoutChanged
|
||||
onLayoutChanged: callbacks.onLayoutChanged,
|
||||
onDragActiveChange: callbacks.onDragActiveChange
|
||||
})
|
||||
|
||||
// Apply flex styles to both panes
|
||||
|
|
|
|||
|
|
@ -155,4 +155,32 @@ describe('insertPaneNextTo reparent frame', () => {
|
|||
expect(webglRendererMock.attachWebgl).not.toHaveBeenCalled()
|
||||
expect(safeFit).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('passes pane drag active callbacks into dividers created during reorder', async () => {
|
||||
setupDocument()
|
||||
const [{ insertPaneNextTo }, { createDivider }] = await Promise.all([
|
||||
import('./pane-tree-ops'),
|
||||
import('./pane-divider')
|
||||
])
|
||||
const parent = createMockElement('pane-split')
|
||||
const source = createPane(1)
|
||||
const target = createPane(2)
|
||||
const onDragActiveChange = vi.fn()
|
||||
parent.appendChild(target.container as TestElement)
|
||||
|
||||
insertPaneNextTo(source, target, 'right', {
|
||||
getRoot: () => parent,
|
||||
getStyleOptions: () => ({}),
|
||||
safeFit: vi.fn(),
|
||||
refitPanesUnder: vi.fn(),
|
||||
onDragActiveChange,
|
||||
requestPaneReparentFrame: vi.fn()
|
||||
})
|
||||
|
||||
expect(createDivider).toHaveBeenCalledWith(
|
||||
true,
|
||||
{},
|
||||
expect.objectContaining({ onDragActiveChange })
|
||||
)
|
||||
})
|
||||
})
|
||||
|
|
|
|||
Loading…
Reference in New Issue