Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
70 changes: 70 additions & 0 deletions lib/src/lib/terminal-mouse-router.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -330,4 +330,74 @@ describe('terminal-mouse-router: override suppression', () => {
expect(getMouseSelectionState('t1').selection).toBeNull();
cleanup();
});

function mousePointer(overrides: Partial<PointerEvent> = {}): 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();
});
});
71 changes: 69 additions & 2 deletions lib/src/lib/terminal-mouse-router.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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);
};

Expand All @@ -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;
Expand Down
32 changes: 32 additions & 0 deletions scripts/dogfood-vscode.mjs
Original file line number Diff line number Diff line change
@@ -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.');
2 changes: 1 addition & 1 deletion vscode-ext/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
},
Expand Down
Loading