From 73f80b7bbe8b9613f0787198de2d549eacba2eb0 Mon Sep 17 00:00:00 2001 From: Ned Date: Fri, 19 Jun 2026 12:18:06 -0700 Subject: [PATCH 1/4] fix: make `pnpm dogfood:vscode` work on Windows The dogfood script chained commands with `;`, but pnpm runs scripts through cmd.exe on Windows, where `;` is not a command separator. As a result `pnpm package` never ran, no .vsix was built, and the install failed with ENOENT. The `>/dev/null` redirect also errored under cmd. Convert the script to a cross-platform Node script (matching the repo's scripts/*.mjs convention) that uninstalls the legacy extension, packages, installs, cleans up the .vsix, and prints the reload reminder. Co-Authored-By: Claude Opus 4.8 (1M context) --- scripts/dogfood-vscode.mjs | 28 ++++++++++++++++++++++++++++ vscode-ext/package.json | 2 +- 2 files changed, 29 insertions(+), 1 deletion(-) create mode 100644 scripts/dogfood-vscode.mjs diff --git a/scripts/dogfood-vscode.mjs b/scripts/dogfood-vscode.mjs new file mode 100644 index 00000000..089d94b4 --- /dev/null +++ b/scripts/dogfood-vscode.mjs @@ -0,0 +1,28 @@ +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'); + +// `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' } = {}) { + const result = spawnSync(command, { cwd: extDir, stdio, shell: true }); + 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' }); + +run('pnpm package'); +run('code --install-extension dormouse.vsix --force'); + +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" }, From 7b5c79f75f3b01b7efff68e3717575dc2db8702e Mon Sep 17 00:00:00 2001 From: Ned Date: Fri, 19 Jun 2026 12:19:37 -0700 Subject: [PATCH 2/4] fix: silence VS Code CLI deprecation warnings in dogfood The `code` CLI emits internal Node deprecation warnings (e.g. DEP0169 for url.parse) during install. Set NODE_NO_WARNINGS for the code invocations so dogfood output stays focused. Co-Authored-By: Claude Opus 4.8 (1M context) --- scripts/dogfood-vscode.mjs | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/scripts/dogfood-vscode.mjs b/scripts/dogfood-vscode.mjs index 089d94b4..58430cca 100644 --- a/scripts/dogfood-vscode.mjs +++ b/scripts/dogfood-vscode.mjs @@ -7,10 +7,14 @@ 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' } = {}) { - const result = spawnSync(command, { cwd: extDir, stdio, shell: true }); +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); } @@ -18,10 +22,10 @@ function run(command, { ignoreFailure = false, stdio = 'inherit' } = {}) { } // Remove the legacy extension if present; ignore failure when it isn't installed. -run('code --uninstall-extension diffplug.mouseterm', { ignoreFailure: true, stdio: 'ignore' }); +run('code --uninstall-extension diffplug.mouseterm', { ignoreFailure: true, stdio: 'ignore', env: codeEnv }); run('pnpm package'); -run('code --install-extension dormouse.vsix --force'); +run('code --install-extension dormouse.vsix --force', { env: codeEnv }); rmSync(vsix, { force: true }); From 8ab3df5e0f56246a959ec376c43bccc19140111c Mon Sep 17 00:00:00 2001 From: Ned Date: Fri, 19 Jun 2026 12:46:23 -0700 Subject: [PATCH 3/4] fix: finalize a stuck terminal drag when the button is released outside the iframe A mouse drag is kept alive only by the window 'mouseup'. When the button is released outside our iframe, that mouseup goes to the host document and never reaches us, leaving the drag stuck. Treat the next zero-button mousemove (e.g. when the pointer re-enters) as the mouseup we missed and finalize the drag in place. Co-Authored-By: Claude Opus 4.8 (1M context) --- lib/src/lib/terminal-mouse-router.ts | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/lib/src/lib/terminal-mouse-router.ts b/lib/src/lib/terminal-mouse-router.ts index 1d396655..8bb3ccbd 100644 --- a/lib/src/lib/terminal-mouse-router.ts +++ b/lib/src/lib/terminal-mouse-router.ts @@ -241,6 +241,17 @@ export function attachTerminalMouseRouter({ }; const onWindowMouseMove = (ev: MouseEvent) => { + // A mouse drag is kept alive only by the window 'mouseup' below. 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); }; From 39923a046ec8937cca6b4a01faca9c6d827ef358 Mon Sep 17 00:00:00 2001 From: Ned Date: Fri, 19 Jun 2026 12:46:58 -0700 Subject: [PATCH 4/4] fix: capture the mouse pointer so outside-iframe drag releases finalize at once Layer pointer capture on top of the window-mousemove heal. On a left-button mouse press over terminal-owned content, setPointerCapture so Chromium keeps delivering the captured pointerup across the iframe boundary even when the button is released over the host page. We then finalize the drag the instant it ends rather than waiting for the cursor to re-enter. The pointerup finalize is deferred one macrotask: for an inside release the compatibility mouseup follows in the same task and finalizes through the existing path (preserving the xterm-report / temporary-override timing), cancelling the deferred finalize. Only an outside release, which produces no such mouseup, falls through to it. Engines that don't honor cross-frame capture get no captured pointerup and still rely on the heal. Co-Authored-By: Claude Opus 4.8 (1M context) --- lib/src/lib/terminal-mouse-router.test.ts | 70 +++++++++++++++++++++ lib/src/lib/terminal-mouse-router.ts | 74 ++++++++++++++++++++--- 2 files changed, 135 insertions(+), 9 deletions(-) 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 8bb3ccbd..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,13 +268,14 @@ export function attachTerminalMouseRouter({ }; const onWindowMouseMove = (ev: MouseEvent) => { - // A mouse drag is kept alive only by the window 'mouseup' below. 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. + // 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; @@ -256,6 +284,9 @@ export function attachTerminalMouseRouter({ }; 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); }; @@ -266,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;