Fix terminal pane drag cleanup (#7014)

* Fix terminal pane drag cleanup

* Harden terminal divider cancellation
This commit is contained in:
Jinjing 2026-07-01 03:32:52 -07:00 committed by GitHub
parent c1fae31a3f
commit 0dfd56af30
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
8 changed files with 635 additions and 258 deletions

View File

@ -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)
}

View File

@ -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> }
}

View File

@ -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)

View File

@ -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

View File

@ -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'])

View File

@ -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
})
}

View File

@ -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

View File

@ -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 })
)
})
})