fix(terminal): keep divider resize active after capture loss (#9063)
This commit is contained in:
parent
1536171fdb
commit
e8a1674a75
|
|
@ -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()
|
||||
})
|
||||
})
|
||||
|
|
@ -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)
|
||||
})
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
})
|
||||
Loading…
Reference in New Issue