diff --git a/src/renderer/src/lib/pane-manager/pane-divider-drag.ts b/src/renderer/src/lib/pane-manager/pane-divider-drag.ts new file mode 100644 index 000000000..c2c701bee --- /dev/null +++ b/src/renderer/src/lib/pane-manager/pane-divider-drag.ts @@ -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 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) +} diff --git a/src/renderer/src/lib/pane-manager/pane-divider.test.ts b/src/renderer/src/lib/pane-manager/pane-divider.test.ts index 1d8acf1d3..59d9f878f 100644 --- a/src/renderer/src/lib/pane-manager/pane-divider.test.ts +++ b/src/renderer/src/lib/pane-manager/pane-divider.test.ts @@ -47,6 +47,210 @@ describe('createDividerFlexFrameScheduler', () => { }) describe('disposeDivider', () => { + it('finishes an active resize from a window-level pointerup', () => { + const dividerListeners = new Map() + const windowListeners = new Map() + const capturedPointerIds = new Set() + 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() + const windowListeners = new Map() + const capturedPointerIds = new Set() + 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() + const windowListeners = new Map() + const capturedPointerIds = new Set() + 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() const divider = { @@ -96,3 +300,34 @@ describe('disposeDivider', () => { expect(divider.classList.remove).toHaveBeenCalledWith('is-dragging') }) }) + +function createPointerEvent(args: Partial): 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 } { + 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 } +} diff --git a/src/renderer/src/lib/pane-manager/pane-divider.ts b/src/renderer/src/lib/pane-manager/pane-divider.ts index 761df6490..4817fa05e 100644 --- a/src/renderer/src/lib/pane-manager/pane-divider.ts +++ b/src/renderer/src/lib/pane-manager/pane-divider.ts @@ -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 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) diff --git a/src/renderer/src/lib/pane-manager/pane-drag-pointer.ts b/src/renderer/src/lib/pane-manager/pane-drag-pointer.ts index e09553f49..45d71f151 100644 --- a/src/renderer/src/lib/pane-manager/pane-drag-pointer.ts +++ b/src/renderer/src/lib/pane-manager/pane-drag-pointer.ts @@ -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 diff --git a/src/renderer/src/lib/pane-manager/pane-drag-reorder.test.ts b/src/renderer/src/lib/pane-manager/pane-drag-reorder.test.ts index 83a0eb1da..dd81b2063 100644 --- a/src/renderer/src/lib/pane-manager/pane-drag-reorder.test.ts +++ b/src/renderer/src/lib/pane-manager/pane-drag-reorder.test.ts @@ -45,6 +45,7 @@ class FakeElement { readonly classList: FakeClassList readonly style: Record = {} readonly dataset: Record = {} + releasePointerCaptureError: Error | null = null private readonly listeners = new Map>() private readonly capturedPointerIds = new Set() 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([ + [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']) diff --git a/src/renderer/src/lib/pane-manager/pane-manager.ts b/src/renderer/src/lib/pane-manager/pane-manager.ts index 2778bd2fb..4824af7cf 100644 --- a/src/renderer/src/lib/pane-manager/pane-manager.ts +++ b/src/renderer/src/lib/pane-manager/pane-manager.ts @@ -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 }) } diff --git a/src/renderer/src/lib/pane-manager/pane-tree-ops.ts b/src/renderer/src/lib/pane-manager/pane-tree-ops.ts index 3725f1f4f..3b2af6fed 100644 --- a/src/renderer/src/lib/pane-manager/pane-tree-ops.ts +++ b/src/renderer/src/lib/pane-manager/pane-tree-ops.ts @@ -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 diff --git a/src/renderer/src/lib/pane-manager/pane-tree-reparent-frame.test.ts b/src/renderer/src/lib/pane-manager/pane-tree-reparent-frame.test.ts index 3208eb79d..4dff8e4f9 100644 --- a/src/renderer/src/lib/pane-manager/pane-tree-reparent-frame.test.ts +++ b/src/renderer/src/lib/pane-manager/pane-tree-reparent-frame.test.ts @@ -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 }) + ) + }) })