From e8a1674a75902680fe1f007615589d38bf0e19e9 Mon Sep 17 00:00:00 2001 From: Jinwoo Hong <73622457+Jinwoo-H@users.noreply.github.com> Date: Thu, 16 Jul 2026 13:07:44 -0700 Subject: [PATCH] fix(terminal): keep divider resize active after capture loss (#9063) --- .../pane-divider-capture-loss.test.ts | 154 ++++++++++++++ .../src/lib/pane-manager/pane-divider-drag.ts | 10 +- ...terminal-pane-divider-capture-loss.spec.ts | 190 ++++++++++++++++++ 3 files changed, 346 insertions(+), 8 deletions(-) create mode 100644 src/renderer/src/lib/pane-manager/pane-divider-capture-loss.test.ts create mode 100644 tests/e2e/terminal-pane-divider-capture-loss.spec.ts diff --git a/src/renderer/src/lib/pane-manager/pane-divider-capture-loss.test.ts b/src/renderer/src/lib/pane-manager/pane-divider-capture-loss.test.ts new file mode 100644 index 000000000..a863b503f --- /dev/null +++ b/src/renderer/src/lib/pane-manager/pane-divider-capture-loss.test.ts @@ -0,0 +1,154 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' +import { createDivider } from './pane-divider' + +type PaneElement = HTMLElement & { style: Record } + +type DividerDragHarness = { + divider: HTMLElement + dividerListeners: Map + windowListeners: Map + capturedPointerIds: Set + previousPane: PaneElement + nextPane: PaneElement + onLayoutChanged: ReturnType + 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 { + return { + preventDefault: vi.fn(), + pointerId: 1, + clientX: 0, + clientY: 0, + ...args + } as unknown as PointerEvent +} + +function createDividerDragHarness(): DividerDragHarness { + const dividerListeners = new Map() + const windowListeners = new Map() + const capturedPointerIds = new Set() + const animationFrames = new Map() + 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() + }) +}) diff --git a/src/renderer/src/lib/pane-manager/pane-divider-drag.ts b/src/renderer/src/lib/pane-manager/pane-divider-drag.ts index 0b552ccc4..8fd5d3443 100644 --- a/src/renderer/src/lib/pane-manager/pane-divider-drag.ts +++ b/src/renderer/src/lib/pane-manager/pane-divider-drag.ts @@ -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) }) } diff --git a/tests/e2e/terminal-pane-divider-capture-loss.spec.ts b/tests/e2e/terminal-pane-divider-capture-loss.spec.ts new file mode 100644 index 000000000..5f3bfca17 --- /dev/null +++ b/tests/e2e/terminal-pane-divider-capture-loss.spec.ts @@ -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 { + 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 { + 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 { + return page.evaluate(() => { + const divider = document.querySelector('.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('.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) +})