fix(terminal): keep divider resize active after capture loss (#9063)

This commit is contained in:
Jinwoo Hong 2026-07-16 13:07:44 -07:00 committed by GitHub
parent 1536171fdb
commit e8a1674a75
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
3 changed files with 346 additions and 8 deletions

View File

@ -0,0 +1,154 @@
import { afterEach, describe, expect, it, vi } from 'vitest'
import { createDivider } from './pane-divider'
type PaneElement = HTMLElement & { style: Record<string, string> }
type DividerDragHarness = {
divider: HTMLElement
dividerListeners: Map<string, EventListener>
windowListeners: Map<string, EventListener>
capturedPointerIds: Set<number>
previousPane: PaneElement
nextPane: PaneElement
onLayoutChanged: ReturnType<typeof vi.fn>
flushAnimationFrames: () => void
}
function createPaneElement(width: number): PaneElement {
return {
style: {},
classList: { contains: vi.fn(() => false) },
dispatchEvent: vi.fn(() => true),
getBoundingClientRect: vi.fn(() => ({
left: 0,
top: 0,
right: width,
bottom: 200,
width,
height: 200
})),
querySelectorAll: vi.fn(() => [])
} as unknown as PaneElement
}
function createPointerEvent(args: Partial<PointerEvent>): PointerEvent {
return {
preventDefault: vi.fn(),
pointerId: 1,
clientX: 0,
clientY: 0,
...args
} as unknown as PointerEvent
}
function createDividerDragHarness(): DividerDragHarness {
const dividerListeners = new Map<string, EventListener>()
const windowListeners = new Map<string, EventListener>()
const capturedPointerIds = new Set<number>()
const animationFrames = new Map<number, FrameRequestCallback>()
const previousPane = createPaneElement(100)
const nextPane = createPaneElement(300)
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
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)
}
})
})
let nextFrameId = 0
vi.stubGlobal(
'requestAnimationFrame',
vi.fn((callback: FrameRequestCallback) => {
nextFrameId += 1
animationFrames.set(nextFrameId, callback)
return nextFrameId
})
)
vi.stubGlobal(
'cancelAnimationFrame',
vi.fn((frameId: number) => animationFrames.delete(frameId))
)
const onLayoutChanged = vi.fn()
createDivider(true, {}, { refitPanesUnder: vi.fn(), onLayoutChanged })
return {
divider,
dividerListeners,
windowListeners,
capturedPointerIds,
previousPane,
nextPane,
onLayoutChanged,
flushAnimationFrames: () => {
for (const [frameId, callback] of animationFrames) {
animationFrames.delete(frameId)
callback(16)
}
}
}
}
afterEach(() => {
vi.unstubAllGlobals()
})
describe('divider pointer capture loss', () => {
it('continues through window events and commits the final drag position', () => {
const harness = createDividerDragHarness()
harness.dividerListeners.get('pointerdown')?.(
createPointerEvent({ pointerId: 9, clientX: 100 })
)
harness.windowListeners.get('pointermove')?.(createPointerEvent({ pointerId: 9, clientX: 180 }))
harness.flushAnimationFrames()
expect(harness.previousPane.style.flex).toBe('180 1 0%')
expect(harness.dividerListeners.has('lostpointercapture')).toBe(false)
harness.capturedPointerIds.delete(9)
harness.windowListeners.get('pointermove')?.(createPointerEvent({ pointerId: 9, clientX: 220 }))
harness.flushAnimationFrames()
harness.windowListeners.get('pointerup')?.(createPointerEvent({ pointerId: 9, clientX: 220 }))
expect(harness.previousPane.style.flex).toBe('220 1 0%')
expect(harness.nextPane.style.flex).toBe('180 1 0%')
expect(harness.onLayoutChanged).toHaveBeenCalledTimes(1)
})
it('still restores the original layout when the window loses focus', () => {
const harness = createDividerDragHarness()
harness.previousPane.style.flex = '2 1 0%'
harness.nextPane.style.flex = '3 1 0%'
harness.dividerListeners.get('pointerdown')?.(
createPointerEvent({ pointerId: 9, clientX: 100 })
)
harness.windowListeners.get('pointermove')?.(createPointerEvent({ pointerId: 9, clientX: 180 }))
harness.flushAnimationFrames()
harness.windowListeners.get('blur')?.({} as Event)
expect(harness.previousPane.style.flex).toBe('2 1 0%')
expect(harness.nextPane.style.flex).toBe('3 1 0%')
expect(harness.onLayoutChanged).not.toHaveBeenCalled()
})
})

View File

@ -93,6 +93,8 @@ export function attachDividerDrag(
if (windowListenersAttached || typeof window === 'undefined') {
return
}
// Why: Chromium can transiently lose capture while the button remains held,
// so window events keep ownership until pointerup, pointercancel, or blur.
windowListenersAttached = true
window.addEventListener('pointermove', onPointerMove, true)
window.addEventListener('pointerup', onPointerUp, true)
@ -264,12 +266,6 @@ export function attachDividerDrag(
}
}
const onLostPointerCapture = (): void => {
if (dragging) {
finishActiveDrag(false)
}
}
const onWindowBlur = (): void => {
finishActiveDrag(false)
}
@ -278,7 +274,6 @@ export function attachDividerDrag(
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)
@ -286,7 +281,6 @@ export function attachDividerDrag(
divider.removeEventListener('pointermove', onPointerMove)
divider.removeEventListener('pointerup', onPointerUp)
divider.removeEventListener('pointercancel', onPointerCancel)
divider.removeEventListener('lostpointercapture', onLostPointerCapture)
divider.removeEventListener('dblclick', onDoubleClick)
})
}

View File

@ -0,0 +1,190 @@
import type { ElectronApplication, Page } from '@stablyai/playwright-test'
import { test, expect } from './helpers/orca-app'
import {
splitActiveTerminalPane,
waitForActiveTerminalManager,
waitForPaneCount
} from './helpers/terminal'
import { ensureTerminalVisible } from './helpers/store'
type PaneGeometry = {
width: number
flex: string
cols: number | null
rows: number | null
proposed: { cols: number; rows: number } | null
}
type DividerGeometry = {
first: PaneGeometry
second: PaneGeometry
}
test.use({ seedTestRepo: false })
async function setFullscreen(electronApp: ElectronApplication, page: Page): Promise<void> {
await expect
.poll(async () => {
try {
return await electronApp.evaluate(({ BrowserWindow }) => {
const window = BrowserWindow.getAllWindows()[0]
if (!window) {
return false
}
if (window.isMinimized()) {
window.restore()
}
window.show()
window.focus()
window.setFullScreen(true)
return window.isFullScreen()
})
} catch {
return false
}
})
.toBe(true)
await expect.poll(() => page.evaluate(() => innerWidth >= 1000 && innerHeight >= 700)).toBe(true)
await page.waitForTimeout(1200)
}
async function addTestRepo(page: Page, repoPath: string): Promise<void> {
const repoId = await page.evaluate(async (path) => {
const result = await window.api.repos.add({ path })
if ('error' in result) {
throw new Error(result.error)
}
return result.repo.id
}, repoPath)
await expect
.poll(() =>
page.evaluate(async (id) => {
const store = window.__store
if (!store) {
return false
}
await store.getState().fetchRepos()
await store.getState().fetchWorktrees(id)
const worktree = store
.getState()
.worktreesByRepo[id]?.find((candidate) => candidate.isMainWorktree)
if (!worktree) {
return false
}
store.getState().setActiveWorktree(worktree.id)
return true
}, repoId)
)
.toBe(true)
}
async function readDividerGeometry(page: Page): Promise<DividerGeometry> {
return page.evaluate(() => {
const divider = document.querySelector<HTMLElement>('.pane-divider.is-vertical')
const firstElement = divider?.previousElementSibling as HTMLElement | null
const secondElement = divider?.nextElementSibling as HTMLElement | null
if (!divider || !firstElement || !secondElement) {
throw new Error('Divider unavailable')
}
const readPane = (element: HTMLElement): PaneGeometry => {
const paneElement = element.matches('.pane[data-pty-id]')
? element
: element.querySelector<HTMLElement>('.pane[data-pty-id]')
const ptyId = paneElement?.dataset.ptyId
const pane = ptyId
? Array.from(window.__paneManagers?.values() ?? [])
.flatMap((manager) => manager.getPanes())
.find((candidate) => candidate.container.dataset.ptyId === ptyId)
: null
let proposed = null
try {
proposed = pane?.fitAddon.proposeDimensions() ?? null
} catch {
proposed = null
}
return {
width: element.getBoundingClientRect().width,
flex: element.style.flex,
cols: pane?.terminal.cols ?? null,
rows: pane?.terminal.rows ?? null,
proposed
}
}
return { first: readPane(firstElement), second: readPane(secondElement) }
})
}
function gridsMatch(geometry: DividerGeometry): boolean {
return [geometry.first, geometry.second].every(
(pane) =>
pane.proposed !== null && pane.cols === pane.proposed.cols && pane.rows === pane.proposed.rows
)
}
test('@headful keeps resizing after the divider loses pointer capture', async ({
electronApp,
orcaPage,
testRepoPath
}, testInfo) => {
await setFullscreen(electronApp, orcaPage)
await addTestRepo(orcaPage, testRepoPath)
await ensureTerminalVisible(orcaPage, 30_000)
await waitForActiveTerminalManager(orcaPage, 30_000)
await splitActiveTerminalPane(orcaPage, 'vertical')
await waitForPaneCount(orcaPage, 2, 30_000)
const divider = orcaPage.locator('.pane-divider.is-vertical').first()
await expect(divider).toBeVisible()
const box = await divider.boundingBox()
if (!box) {
throw new Error('Divider has no bounding box')
}
await divider.evaluate((element) => {
element.dataset.captureLossCount = '0'
element.addEventListener('pointerdown', (event) => {
element.dataset.captureLossPointerId = String(event.pointerId)
})
element.addEventListener('lostpointercapture', () => {
element.dataset.captureLossCount = String(Number(element.dataset.captureLossCount ?? '0') + 1)
})
})
const before = await readDividerGeometry(orcaPage)
const startX = box.x + box.width / 2
const startY = box.y + box.height / 2
await orcaPage.mouse.move(startX, startY)
await orcaPage.mouse.down()
await orcaPage.mouse.move(startX + 140, startY, { steps: 10 })
await expect
.poll(async () =>
Math.abs((await readDividerGeometry(orcaPage)).first.width - before.first.width)
)
.toBeGreaterThan(80)
// Why: this is the real browser event observed during field failures; later
// window-level pointer events must remain authoritative after capture drops.
await divider.evaluate((element) => {
const pointerId = Number(element.dataset.captureLossPointerId)
if (!Number.isInteger(pointerId) || !element.hasPointerCapture(pointerId)) {
throw new Error('Divider did not acquire pointer capture')
}
element.releasePointerCapture(pointerId)
})
await expect
.poll(() => divider.evaluate((element) => Number(element.dataset.captureLossCount ?? '0')))
.toBe(1)
await orcaPage.mouse.move(startX + 260, startY, { steps: 10 })
await orcaPage.mouse.up()
await expect.poll(async () => gridsMatch(await readDividerGeometry(orcaPage))).toBe(true)
const after = await readDividerGeometry(orcaPage)
await testInfo.attach('divider-capture-loss-geometry', {
body: Buffer.from(JSON.stringify({ before, after }, null, 2)),
contentType: 'application/json'
})
expect(Math.abs(after.first.width - before.first.width)).toBeGreaterThan(180)
expect(Math.abs(after.second.width - before.second.width)).toBeGreaterThan(180)
expect(after.first.flex).not.toBe(before.first.flex)
expect(after.second.flex).not.toBe(before.second.flex)
})