diff --git a/lib/src/lib/terminal-mouse-router.test.ts b/lib/src/lib/terminal-mouse-router.test.ts index 5923e2bd..65588963 100644 --- a/lib/src/lib/terminal-mouse-router.test.ts +++ b/lib/src/lib/terminal-mouse-router.test.ts @@ -330,4 +330,74 @@ describe('terminal-mouse-router: override suppression', () => { expect(getMouseSelectionState('t1').selection).toBeNull(); cleanup(); }); + + function mousePointer(overrides: Partial = {}): FakePointerEvent { + return pointerEvent({ pointerType: 'mouse', ...overrides }); + } + + // Drives a left-button mouse press into an active selection drag (capture + + // pendingDrag created on press, drag begun once movement crosses threshold). + function startMouseDrag(element: FakeElement) { + element.emit('pointerdown', mousePointer({ clientX: 5, clientY: 5 })); + element.emit('mousedown', mouseEvent({ clientX: 5, clientY: 5 })); + windowHost.emit('mousemove', mouseEvent({ clientX: 25, clientY: 15 })); + } + + it('captures the mouse pointer on a left-button press over terminal-owned content', () => { + const { cleanup, element } = createHarness(windowHost); + + element.emit('pointerdown', mousePointer({ clientX: 5, clientY: 5 })); + + expect(element.setPointerCapture).toHaveBeenCalledWith(1); + cleanup(); + }); + + it('does not capture the mouse pointer for non-left buttons', () => { + const { cleanup, element } = createHarness(windowHost); + + element.emit('pointerdown', mousePointer({ button: 2 })); + + expect(element.setPointerCapture).not.toHaveBeenCalled(); + cleanup(); + }); + + it('finalizes a mouse drag from a captured pointerup when the button is released outside the iframe', () => { + vi.useFakeTimers(); + const { cleanup, element } = createHarness(windowHost); + + startMouseDrag(element); + expect(getMouseSelectionState('t1').selection).toMatchObject({ dragging: true }); + + // Released outside the iframe: only the captured pointerup reaches us, never + // the compatibility mouseup. The deferred finalize ends the drag in place. + windowHost.emit('pointerup', mousePointer({ clientX: 500, clientY: 500 })); + expect(element.releasePointerCapture).toHaveBeenCalledWith(1); + expect(getMouseSelectionState('t1').selection).toMatchObject({ dragging: true }); + + vi.runAllTimers(); + expect(getMouseSelectionState('t1').selection).toMatchObject({ dragging: false }); + + vi.useRealTimers(); + cleanup(); + }); + + it('lets the inside mouseup finalize and no-ops the deferred pointerup finalize', () => { + vi.useFakeTimers(); + const { cleanup, element } = createHarness(windowHost); + + startMouseDrag(element); + + // Released inside: pointerup is followed by the compatibility mouseup, which + // finalizes through the normal path and cancels the deferred finalize. + windowHost.emit('pointerup', mousePointer({ clientX: 25, clientY: 15 })); + windowHost.emit('mouseup', mouseEvent({ clientX: 25, clientY: 15 })); + expect(getMouseSelectionState('t1').selection).toMatchObject({ dragging: false }); + + // The deferred finalize must be a harmless no-op now. + expect(() => vi.runAllTimers()).not.toThrow(); + expect(getMouseSelectionState('t1').selection).toMatchObject({ dragging: false }); + + vi.useRealTimers(); + cleanup(); + }); }); diff --git a/lib/src/lib/terminal-mouse-router.ts b/lib/src/lib/terminal-mouse-router.ts index 1d396655..2962a15c 100644 --- a/lib/src/lib/terminal-mouse-router.ts +++ b/lib/src/lib/terminal-mouse-router.ts @@ -91,6 +91,14 @@ export function attachTerminalMouseRouter({ let lastTouchTap: { time: number; x: number; y: number } | null = null; // True while the active drag is block-mode (Alt on desktop, double-tap on touch). let dragBlock = false; + // Set while we hold pointer capture for a mouse drag. Chromium delivers the + // captured pointerup across the iframe boundary even when the button is + // released over the host page, which lets us finalize an outside release at + // once instead of waiting for the window-mousemove heal. + let mouseDragPointerId: number | null = null; + // True between a captured mouse pointerup we saw and the compatibility mouseup + // we expect to follow it for an *inside* release; see onWindowPointerUp. + let awaitingOutsideMouseUp = false; const terminalOwnsEvent = (ev: MouseEvent | PointerEvent) => { const state = getMouseSelectionState(id); @@ -208,7 +216,26 @@ export function attachTerminalMouseRouter({ }; const onPointerDown = (ev: PointerEvent) => { - if (ev.pointerType === 'mouse') return; + if (ev.pointerType === 'mouse') { + // Capture the mouse pointer for left-button presses on terminal-owned + // content so a selection drag released *outside* our iframe still reports + // back. Chromium delivers the captured pointerup across the frame boundary + // even when the button comes up over the host page, letting onWindowPointerUp + // finalize the drag immediately rather than waiting for the cursor to wander + // back in (the window-mousemove heal). Engines that don't honor cross-frame + // capture get no such pointerup and fall back to that heal. + if (ev.button !== 0) return; + const { terminalOwns } = terminalOwnsEvent(ev); + if (!terminalOwns) return; + try { + element.setPointerCapture(ev.pointerId); + mouseDragPointerId = ev.pointerId; + } catch { + // Best-effort continuity aid; the heal still covers us if capture is rejected. + mouseDragPointerId = null; + } + return; + } if (!ev.isPrimary) return; // Double-tap = this press lands soon after, and near, the previous touch that // ended as a tap. Recording only on a tap release (not on a drag) keeps two @@ -241,10 +268,25 @@ export function attachTerminalMouseRouter({ }; const onWindowMouseMove = (ev: MouseEvent) => { + // Backstop for engines that don't deliver a cross-frame captured pointerup + // (see onPointerDown). A mouse drag is otherwise kept alive only by the + // window 'mouseup' below, and when the button is released outside our iframe + // that mouseup is delivered to the host document and never reaches us, + // leaving the drag stuck. The next move we see (e.g. when the pointer + // re-enters) reports no buttons held — treat that as the mouseup we missed + // and finalize the drag in place. A genuine drag that leaves and re-enters + // still holding the button reports buttons===1, so this never fires mid-drag. + if (ev.buttons === 0 && (pendingDrag || isDragging(id))) { + finishPendingOrActiveDrag(ev); + return; + } updatePendingOrActiveDrag(ev); }; const onWindowMouseUp = (ev: MouseEvent) => { + // The button came up inside the iframe; cancel any pending outside-release + // finalize (see onWindowPointerUp) and end the drag through the normal path. + awaitingOutsideMouseUp = false; finishPendingOrActiveDrag(ev); }; @@ -255,7 +297,32 @@ export function attachTerminalMouseRouter({ }; const onWindowPointerUp = (ev: PointerEvent) => { - if (ev.pointerType === 'mouse') return; + if (ev.pointerType === 'mouse') { + if (mouseDragPointerId !== ev.pointerId) return; + mouseDragPointerId = null; + // Capture auto-releases on pointerup, but be explicit. + try { + element.releasePointerCapture(ev.pointerId); + } catch { + // already released + } + if (!(pendingDrag || isDragging(id))) return; + // Defer to a macrotask, not a microtask: the compatibility mouseup for an + // inside release is dispatched in this same task (right after this + // pointerup), and a microtask would run before it. If that mouseup + // arrives, onWindowMouseUp finalizes through the established path and + // clears this flag; only when it doesn't — the button was released outside + // the iframe, where Chromium still delivers this captured pointerup — do we + // finalize here. + awaitingOutsideMouseUp = true; + const releaseEvent = ev; + setTimeout(() => { + if (!awaitingOutsideMouseUp) return; + awaitingOutsideMouseUp = false; + finishPendingOrActiveDrag(releaseEvent); + }, 0); + return; + } if (activePointerId !== ev.pointerId) return; finishPendingOrActiveDrag(ev); activePointerId = null; diff --git a/scripts/dogfood-vscode.mjs b/scripts/dogfood-vscode.mjs new file mode 100644 index 00000000..58430cca --- /dev/null +++ b/scripts/dogfood-vscode.mjs @@ -0,0 +1,32 @@ +import { spawnSync } from 'node:child_process'; +import { rmSync } from 'node:fs'; +import { dirname, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const scriptDir = dirname(fileURLToPath(import.meta.url)); +const extDir = resolve(scriptDir, '..', 'vscode-ext'); +const vsix = resolve(extDir, 'dormouse.vsix'); + +// The VS Code CLI emits internal Node deprecation warnings (e.g. DEP0169); +// silence them so dogfood output stays focused on what matters. +const codeEnv = { ...process.env, NODE_NO_WARNINGS: '1' }; + +// `code` and `pnpm` are `.cmd` shims on Windows, so run through a shell to +// resolve them on PATH. Args are static and shell-safe. +function run(command, { ignoreFailure = false, stdio = 'inherit', env = process.env } = {}) { + const result = spawnSync(command, { cwd: extDir, stdio, shell: true, env }); + if (!ignoreFailure && result.status !== 0) { + process.exit(result.status ?? 1); + } + return result; +} + +// Remove the legacy extension if present; ignore failure when it isn't installed. +run('code --uninstall-extension diffplug.mouseterm', { ignoreFailure: true, stdio: 'ignore', env: codeEnv }); + +run('pnpm package'); +run('code --install-extension dormouse.vsix --force', { env: codeEnv }); + +rmSync(vsix, { force: true }); + +console.log('Reload VSCode window (Cmd+Shift+P then Reload Window) to pick up the new extension.'); diff --git a/vscode-ext/package.json b/vscode-ext/package.json index e9bb77f8..c38e902b 100644 --- a/vscode-ext/package.json +++ b/vscode-ext/package.json @@ -105,7 +105,7 @@ "stage:dor-cli": "pnpm --filter dor build && node ../scripts/stage-dor-cli.mjs vscode-ext/dor-cli", "watch": "pnpm build --watch", "package": "vsce package --no-dependencies --out dormouse.vsix", - "dogfood": "code --uninstall-extension diffplug.mouseterm >/dev/null 2>&1 || true; pnpm package && code --install-extension dormouse.vsix --force && rm -f dormouse.vsix && echo '\\n✦ Reload VSCode window (Cmd+Shift+P → Reload Window) to pick up the new extension.'", + "dogfood": "node ../scripts/dogfood-vscode.mjs", "publish:marketplace": "vsce publish --no-dependencies", "publish:openvsx": "ovsx publish --no-dependencies" },