From 61864ec1aa434a71fd3f9f9f3ceed2db186a37a1 Mon Sep 17 00:00:00 2001 From: Jinwoo Hong <73622457+Jinwoo-H@users.noreply.github.com> Date: Tue, 11 Aug 2026 12:27:54 -0700 Subject: [PATCH] fix(terminal): prevent link action clicks reaching child (#13797) (cherry picked from commit 6acd69c516e836e3def2617f28c65545a031ada6) --- .../terminal-pane/pty-connection-types.ts | 1 + .../terminal-pane/pty-connection.ts | 9 +- .../terminal-file-link-actions.test.ts | 1 + .../terminal-handle-links.test.ts | 1 + .../terminal-link-action-request.ts | 4 + .../terminal-link-action-routing.test.ts | 50 ++++ ...erminal-link-pty-mouse-suppression.test.ts | 271 ++++++++++++++++++ .../terminal-link-pty-mouse-suppression.ts | 146 +++++++++- .../terminal-url-link-click.test.ts | 1 + .../terminal-url-link-hit-testing.ts | 25 +- .../use-terminal-pane-lifecycle.ts | 16 +- .../terminal-link-mouse-owner-fixture.cjs | 49 ++++ .../e2e/terminal-link-click-ownership.spec.ts | 161 +++++++++++ 13 files changed, 725 insertions(+), 10 deletions(-) create mode 100644 src/renderer/src/components/terminal-pane/terminal-link-pty-mouse-suppression.test.ts create mode 100644 tests/e2e/fixtures/terminal-link-mouse-owner-fixture.cjs create mode 100644 tests/e2e/terminal-link-click-ownership.spec.ts diff --git a/src/renderer/src/components/terminal-pane/pty-connection-types.ts b/src/renderer/src/components/terminal-pane/pty-connection-types.ts index 50a5946e770..9c2f717b8a6 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection-types.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection-types.ts @@ -98,6 +98,7 @@ export type PtyConnectionDeps = { setCacheTimerStartedAt: (key: string, ts: number | null) => void syncPanePtyLayoutBinding: (paneId: number, ptyId: string | null) => void clearExitedPanePtyLayoutBinding: (paneId: number, exitedPtyId: string) => void + deferPtyInput?: (paneId: number, data: string, forward: (data: string) => void) => void /** Records a DECSET 2031 subscription answered from main's * '2031-subscribe' fact, mirroring the xterm CSI handler's registry write * (paneMode2031 + last replied theme) so later theme flips push CSI 997. diff --git a/src/renderer/src/components/terminal-pane/pty-connection.ts b/src/renderer/src/components/terminal-pane/pty-connection.ts index 41cc4cb1383..8c32b1e1984 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection.ts @@ -4078,7 +4078,7 @@ export function connectPanePty( } ) - const onDataDisposable = pane.terminal.onData((data) => { + const forwardPtyInput = (data: string): void => { // Why: xterm auto-replies to embedded query sequences (DA1, DECRQM, // OSC 10/11, focus, CPR) via onData. When we replay recorded PTY bytes // into xterm for scrollback/cold-restore/snapshot, those queries would @@ -4213,6 +4213,13 @@ export function connectPanePty( clearPendingTerminalInputIntent() requestRecoveryForUndeliverableInput() } + } + const onDataDisposable = pane.terminal.onData((data) => { + if (deps.deferPtyInput) { + deps.deferPtyInput(pane.id, data, forwardPtyInput) + return + } + forwardPtyInput(data) }) const imeCompositionRouteDisposable = installTerminalImeCompositionRoute({ terminalElement: pane.terminal.element, diff --git a/src/renderer/src/components/terminal-pane/terminal-file-link-actions.test.ts b/src/renderer/src/components/terminal-pane/terminal-file-link-actions.test.ts index 56332ab01aa..c75aad5ae12 100644 --- a/src/renderer/src/components/terminal-pane/terminal-file-link-actions.test.ts +++ b/src/renderer/src/components/terminal-pane/terminal-file-link-actions.test.ts @@ -40,6 +40,7 @@ function context(request: ReturnType): TerminalLinkActionContext { return { paneId: 3, pointerGesture: { canRequestAction: () => true, dispose: vi.fn() }, + claimPtyMouse: vi.fn(() => true), request: request as TerminalLinkActionContext['request'], focusTerminal: vi.fn() } diff --git a/src/renderer/src/components/terminal-pane/terminal-handle-links.test.ts b/src/renderer/src/components/terminal-pane/terminal-handle-links.test.ts index 3c042a12d24..ae9c5e0e4cc 100644 --- a/src/renderer/src/components/terminal-pane/terminal-handle-links.test.ts +++ b/src/renderer/src/components/terminal-pane/terminal-handle-links.test.ts @@ -311,6 +311,7 @@ describe('createTerminalHandleLinkProvider', () => { const links = await collectLinks([makeBufferLine('Worker: term_worker')], 1, null, { paneId: 4, pointerGesture: { canRequestAction: () => true, dispose: vi.fn() }, + claimPtyMouse: vi.fn(() => true), request, focusTerminal: vi.fn() }) diff --git a/src/renderer/src/components/terminal-pane/terminal-link-action-request.ts b/src/renderer/src/components/terminal-pane/terminal-link-action-request.ts index 653ab34597a..108a670939b 100644 --- a/src/renderer/src/components/terminal-pane/terminal-link-action-request.ts +++ b/src/renderer/src/components/terminal-pane/terminal-link-action-request.ts @@ -25,6 +25,7 @@ export type TerminalLinkActionRequester = (request: TerminalLinkActionRequest) = export type TerminalLinkActionContext = { paneId: number pointerGesture: TerminalLinkPointerGesture + claimPtyMouse: () => boolean request: TerminalLinkActionRequester focusTerminal: () => void } @@ -55,6 +56,9 @@ export function requestTerminalLinkAction( return false } + if (!context.claimPtyMouse()) { + return false + } event.preventDefault() context.request({ ...details, diff --git a/src/renderer/src/components/terminal-pane/terminal-link-action-routing.test.ts b/src/renderer/src/components/terminal-pane/terminal-link-action-routing.test.ts index 00cdcd3c687..5535e7bde9f 100644 --- a/src/renderer/src/components/terminal-pane/terminal-link-action-routing.test.ts +++ b/src/renderer/src/components/terminal-pane/terminal-link-action-routing.test.ts @@ -2,6 +2,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { registerHttpLinkStoreAccessor } from '@/lib/http-link-routing' import { closeTerminalLinkActionRequest, + requestTerminalLinkAction, type TerminalLinkActionContext, type TerminalLinkActionRequest } from './terminal-link-action-request' @@ -29,6 +30,7 @@ function actionContext(request = vi.fn()): TerminalLinkActionContext { return { paneId: 7, pointerGesture: { canRequestAction: () => true, dispose: vi.fn() }, + claimPtyMouse: vi.fn(() => true), request, focusTerminal: vi.fn() } @@ -58,6 +60,54 @@ describe('terminal link action routing', () => { expect(closeTerminalLinkActionRequest(first, first)).toBeNull() }) + it('claims PTY mouse ownership only after the pointer gesture is eligible', () => { + const claimPtyMouse = vi.fn(() => true) + const request = vi.fn() + const context = actionContext(request) + context.claimPtyMouse = claimPtyMouse + + expect( + requestTerminalLinkAction(plainEvent(), context, { + destination: 'https://example.com', + kind: 'url', + primary: { label: 'Open', run: vi.fn() } + }) + ).toBe(true) + expect(claimPtyMouse).toHaveBeenCalledOnce() + expect(claimPtyMouse.mock.invocationCallOrder[0]).toBeLessThan( + request.mock.invocationCallOrder[0] + ) + }) + + it('leaves PTY mouse ownership with an ineligible pointer gesture', () => { + const context = actionContext() + context.pointerGesture.canRequestAction = () => false + + expect( + requestTerminalLinkAction(plainEvent(), context, { + destination: 'https://example.com', + kind: 'url', + primary: { label: 'Open', run: vi.fn() } + }) + ).toBe(false) + expect(context.claimPtyMouse).not.toHaveBeenCalled() + expect(context.request).not.toHaveBeenCalled() + }) + + it('leaves the action closed when the child already owns the gesture', () => { + const context = actionContext() + context.claimPtyMouse = vi.fn(() => false) + + expect( + requestTerminalLinkAction(plainEvent(), context, { + destination: 'https://example.com', + kind: 'url', + primary: { label: 'Open', run: vi.fn() } + }) + ).toBe(false) + expect(context.request).not.toHaveBeenCalled() + }) + it('offers system browser first and Orca second when system browser is the default', () => { const request = vi.fn() const event = plainEvent() diff --git a/src/renderer/src/components/terminal-pane/terminal-link-pty-mouse-suppression.test.ts b/src/renderer/src/components/terminal-pane/terminal-link-pty-mouse-suppression.test.ts new file mode 100644 index 00000000000..97ff91ef8e3 --- /dev/null +++ b/src/renderer/src/components/terminal-pane/terminal-link-pty-mouse-suppression.test.ts @@ -0,0 +1,271 @@ +// @vitest-environment happy-dom +import type { Terminal } from '@xterm/xterm' +import { afterEach, describe, expect, it, vi } from 'vitest' +import { installTerminalLinkPtyMouseSuppression } from './terminal-link-pty-mouse-suppression' + +const activeSuppressions = new Set>() + +function createSuppression( + deferPlain = true, + shouldContinueDeferring: (event: MouseEvent) => boolean = () => true +): { + element: HTMLDivElement + suppression: ReturnType +} { + const element = document.createElement('div') + document.body.append(element) + const terminal = { + element, + options: { mouseEventsRequireAlt: false } + } as unknown as Terminal + const suppression = installTerminalLinkPtyMouseSuppression( + terminal, + () => true, + () => deferPlain, + shouldContinueDeferring + ) + activeSuppressions.add(suppression) + return { element, suppression } +} + +function mouseEvent( + type: 'mousedown' | 'mousemove' | 'mouseup', + init: MouseEventInit = {} +): MouseEvent { + return new MouseEvent(type, { bubbles: true, button: 0, ...init }) +} + +async function settleGesture(element: HTMLElement): Promise { + element.dispatchEvent(mouseEvent('mouseup')) + element.click() + await new Promise((resolve) => setTimeout(resolve, 0)) +} + +afterEach(() => { + for (const suppression of activeSuppressions) { + suppression.dispose() + } + activeSuppressions.clear() + document.body.replaceChildren() + vi.restoreAllMocks() +}) + +describe('terminal link PTY mouse suppression', () => { + it('drops deferred mouse input when the completed action claims it', async () => { + const { element, suppression } = createSuppression() + const forwarded: string[] = [] + const forward = (data: string): void => { + forwarded.push(data) + } + element.addEventListener('mousedown', () => suppression.handlePtyInput('\x1b[<0;1;1M', forward)) + element.addEventListener('mouseup', () => { + suppression.handlePtyInput('\x1b[<0;1;1m', forward) + suppression.claimAction() + }) + + element.dispatchEvent(mouseEvent('mousedown')) + await settleGesture(element) + + expect(forwarded).toEqual([]) + }) + + it('flushes deferred mouse input when the completed gesture stays child-owned', async () => { + const { element, suppression } = createSuppression() + const forwarded: string[] = [] + const forward = (data: string): void => { + forwarded.push(data) + } + element.addEventListener('mousedown', () => suppression.handlePtyInput('\x1b[<0;1;1M', forward)) + element.addEventListener('mouseup', () => suppression.handlePtyInput('\x1b[<0;1;1m', forward)) + + element.dispatchEvent(mouseEvent('mousedown')) + await settleGesture(element) + + expect(forwarded).toEqual(['\x1b[<0;1;1M', '\x1b[<0;1;1m']) + }) + + it('forwards plain mouse input immediately when actions are disabled', () => { + const { element, suppression } = createSuppression(false) + const forwarded: string[] = [] + element.addEventListener('mousedown', () => + suppression.handlePtyInput('\x1b[M !!', (data) => forwarded.push(data)) + ) + + element.dispatchEvent(mouseEvent('mousedown')) + + expect(forwarded).toEqual(['\x1b[M !!']) + }) + + it('flushes a dragged mouse sequence in order', async () => { + const { element, suppression } = createSuppression() + const forwarded: string[] = [] + const forward = (data: string): void => { + forwarded.push(data) + } + element.addEventListener('mousedown', () => suppression.handlePtyInput('\x1b[<0;1;1M', forward)) + document.addEventListener( + 'mousemove', + () => suppression.handlePtyInput('\x1b[<32;2;2M', forward), + { once: true } + ) + document.addEventListener( + 'mouseup', + () => suppression.handlePtyInput('\x1b[<0;2;2m', forward), + { once: true } + ) + + element.dispatchEvent(mouseEvent('mousedown')) + element.dispatchEvent(mouseEvent('mousemove', { buttons: 1, clientX: 10 })) + await settleGesture(element) + + expect(forwarded).toEqual(['\x1b[<0;1;1M', '\x1b[<32;2;2M', '\x1b[<0;2;2m']) + }) + + it('releases a drag as soon as pointer eligibility is lost', () => { + const { element, suppression } = createSuppression(true, (event) => event.clientX < 5) + const forwarded: string[] = [] + const forward = (data: string): void => { + forwarded.push(data) + } + element.addEventListener('mousedown', () => suppression.handlePtyInput('\x1b[<0;1;1M', forward)) + document.addEventListener( + 'mousemove', + () => suppression.handlePtyInput('\x1b[<32;2;2M', forward), + { once: true } + ) + + element.dispatchEvent(mouseEvent('mousedown')) + element.dispatchEvent(mouseEvent('mousemove', { buttons: 1, clientX: 10 })) + + expect(forwarded).toEqual(['\x1b[<0;1;1M', '\x1b[<32;2;2M']) + }) + + it('forwards keyboard input outside mouse dispatch while a click is pending', async () => { + const { element, suppression } = createSuppression() + const forwarded: string[] = [] + const forward = (data: string): void => { + forwarded.push(data) + } + element.addEventListener('mousedown', () => suppression.handlePtyInput('\x1b[<0;1;1M', forward)) + document.addEventListener( + 'mouseup', + () => suppression.handlePtyInput('\x1b[<0;1;1m', forward), + { once: true } + ) + + element.dispatchEvent(mouseEvent('mousedown')) + suppression.handlePtyInput('key', forward) + await settleGesture(element) + + expect(forwarded).toEqual(['key', '\x1b[<0;1;1M', '\x1b[<0;1;1m']) + }) + + it('flushes pending child input on blur', () => { + const { element, suppression } = createSuppression() + const forwarded: string[] = [] + element.addEventListener('mousedown', () => + suppression.handlePtyInput('\x1b[<0;1;1M', (data) => forwarded.push(data)) + ) + + element.dispatchEvent(mouseEvent('mousedown')) + window.dispatchEvent(new Event('blur')) + + expect(forwarded).toEqual(['\x1b[<0;1;1M']) + }) + + it('flushes pending child input when disposed', () => { + const { element, suppression } = createSuppression() + const forwarded: string[] = [] + element.addEventListener('mousedown', () => + suppression.handlePtyInput('\x1b[<0;1;1M', (data) => forwarded.push(data)) + ) + + element.dispatchEvent(mouseEvent('mousedown')) + suppression.dispose() + + expect(forwarded).toEqual(['\x1b[<0;1;1M']) + }) + + it('flushes an abandoned transaction before starting another gesture', () => { + const { element, suppression } = createSuppression() + const forwarded: string[] = [] + element.addEventListener('mousedown', () => + suppression.handlePtyInput('\x1b[<0;1;1M', (data) => forwarded.push(data)) + ) + + element.dispatchEvent(mouseEvent('mousedown')) + element.dispatchEvent(mouseEvent('mousedown')) + + expect(forwarded).toEqual(['\x1b[<0;1;1M']) + }) + + it('installs global listeners only while a deferred gesture is active', async () => { + const documentAdd = vi.spyOn(document, 'addEventListener') + const windowAdd = vi.spyOn(window, 'addEventListener') + const { element } = createSuppression() + + expect(documentAdd).not.toHaveBeenCalled() + expect(windowAdd).not.toHaveBeenCalled() + + element.dispatchEvent(mouseEvent('mousedown')) + + expect(documentAdd).toHaveBeenCalledWith('mousemove', expect.any(Function), { capture: true }) + expect(windowAdd).toHaveBeenCalledWith('mouseup', expect.any(Function)) + + await settleGesture(element) + documentAdd.mockClear() + windowAdd.mockClear() + window.dispatchEvent(new Event('blur')) + + expect(documentAdd).not.toHaveBeenCalled() + expect(windowAdd).not.toHaveBeenCalled() + }) + + it('fails open to the child when a deferred frame stream exceeds its budget', () => { + const { element, suppression } = createSuppression() + const forwarded: string[] = [] + const expected = Array.from({ length: 100 }, (_, index) => `\x1b[<32;${index};1M`) + element.addEventListener('mousedown', () => { + for (const frame of expected) { + suppression.handlePtyInput(frame, (data) => forwarded.push(data)) + } + }) + + element.dispatchEvent(mouseEvent('mousedown')) + + expect(forwarded).toEqual(expected) + expect(suppression.claimAction()).toBe(false) + }) + + it('forwards non-mouse focus reports during a claimed mouse gesture', async () => { + const { element, suppression } = createSuppression() + const forwarded: string[] = [] + const forward = (data: string): void => { + forwarded.push(data) + } + element.addEventListener('mousedown', () => { + suppression.handlePtyInput('\x1b[<0;1;1M', forward) + suppression.handlePtyInput('\x1b[I', forward) + }) + element.addEventListener('mouseup', () => suppression.claimAction()) + + element.dispatchEvent(mouseEvent('mousedown')) + await settleGesture(element) + + expect(forwarded).toEqual(['\x1b[I']) + }) + + it('flushes a pending press when the pointer leaves the document', () => { + const { element, suppression } = createSuppression() + const forwarded: string[] = [] + element.addEventListener('mousedown', () => + suppression.handlePtyInput('\x1b[<0;1;1M', (data) => forwarded.push(data)) + ) + + element.dispatchEvent(mouseEvent('mousedown')) + document.dispatchEvent(new MouseEvent('mouseleave')) + + expect(forwarded).toEqual(['\x1b[<0;1;1M']) + expect(suppression.claimAction()).toBe(false) + }) +}) diff --git a/src/renderer/src/components/terminal-pane/terminal-link-pty-mouse-suppression.ts b/src/renderer/src/components/terminal-pane/terminal-link-pty-mouse-suppression.ts index a325056759b..70cd81cf1e4 100644 --- a/src/renderer/src/components/terminal-pane/terminal-link-pty-mouse-suppression.ts +++ b/src/renderer/src/components/terminal-pane/terminal-link-pty-mouse-suppression.ts @@ -1,17 +1,45 @@ import type { IDisposable, Terminal } from '@xterm/xterm' -import { isTerminalLinkDirectActivation } from './terminal-link-activation' +import { + isTerminalLinkActionActivation, + isTerminalLinkDirectActivation +} from './terminal-link-activation' const CAPTURE_LISTENER_OPTIONS = { capture: true } as const +const MAX_DEFERRED_PTY_INPUT_FRAMES = 64 + +type DeferredPtyInput = { + data: string + forward: (data: string) => void +} + +export type TerminalLinkPtyMouseSuppression = IDisposable & { + claimAction: () => boolean + handlePtyInput: (data: string, forward: (data: string) => void) => void +} + +function isXtermMouseReport(data: string): boolean { + return ( + (data.startsWith('\x1b[M') && data.length === 6) || + (data.startsWith('\x1b[<') && /^\d+;\d+;\d+[Mm]$/.test(data.slice(3))) + ) +} export function installTerminalLinkPtyMouseSuppression( terminal: Terminal, - shouldSuppressMouseEvent: (event: MouseEvent) => boolean -): IDisposable { + shouldSuppressMouseEvent: (event: MouseEvent) => boolean, + shouldDeferPlainMouseEvent: (event: MouseEvent) => boolean = () => false, + shouldContinueDeferring: (event: MouseEvent) => boolean = () => true +): TerminalLinkPtyMouseSuppression { const terminalElement = terminal.element const ownerDocument = terminalElement?.ownerDocument const ownerWindow = ownerDocument?.defaultView let previousMouseEventsRequireAlt: boolean | null = null let restoreQueued = false + let deferredPtyInput: DeferredPtyInput[] | null = null + let capturesPtyInput = false + let actionClaimed = false + let deferredInputFallbackTimer: number | null = null + let deferredListenersInstalled = false const restore = (): void => { restoreQueued = false @@ -30,7 +58,34 @@ export function installTerminalLinkPtyMouseSuppression( restoreQueued = true queueMicrotask(restore) } + const finishDeferredInput = (): void => { + if (deferredInputFallbackTimer !== null) { + ownerWindow?.clearTimeout(deferredInputFallbackTimer) + deferredInputFallbackTimer = null + } + const deferred = deferredPtyInput + const shouldForward = deferred !== null && !actionClaimed + deferredPtyInput = null + capturesPtyInput = false + actionClaimed = false + removeDeferredListeners() + if (shouldForward) { + for (const input of deferred) { + input.forward(input.data) + } + } + } + const captureCurrentMouseEvent = (): void => { + capturesPtyInput = true + } const handleMouseDown = (event: MouseEvent): void => { + if (isTerminalLinkActionActivation(event) && shouldDeferPlainMouseEvent(event)) { + finishDeferredInput() + deferredPtyInput = [] + addDeferredListeners() + captureCurrentMouseEvent() + return + } if (!isTerminalLinkDirectActivation(event) || !shouldSuppressMouseEvent(event)) { return } @@ -41,11 +96,96 @@ export function installTerminalLinkPtyMouseSuppression( ownerDocument?.addEventListener('mouseup', queueRestore) ownerWindow?.addEventListener('blur', restore) } + const handleDeferredMouseEvent = (): void => { + if (deferredPtyInput !== null) { + captureCurrentMouseEvent() + } + } + const releaseIneligibleDrag = (event: MouseEvent): void => { + if (deferredPtyInput !== null && !shouldContinueDeferring(event)) { + finishDeferredInput() + } + } + const releaseCurrentMouseEvent = (): void => { + capturesPtyInput = false + } + const queueDeferredInputFallback = (): void => { + if (deferredPtyInput !== null) { + if (deferredInputFallbackTimer !== null) { + ownerWindow?.clearTimeout(deferredInputFallbackTimer) + } + deferredInputFallbackTimer = ownerWindow?.setTimeout(finishDeferredInput, 0) ?? null + } + } + const handleBlur = (): void => { + finishDeferredInput() + restore() + } + const addDeferredListeners = (): void => { + if (deferredListenersInstalled) { + return + } + deferredListenersInstalled = true + ownerDocument?.addEventListener('mousemove', handleDeferredMouseEvent, CAPTURE_LISTENER_OPTIONS) + ownerDocument?.addEventListener('mousemove', releaseIneligibleDrag) + ownerDocument?.addEventListener('mouseup', handleDeferredMouseEvent, CAPTURE_LISTENER_OPTIONS) + ownerDocument?.addEventListener('mouseleave', finishDeferredInput) + ownerWindow?.addEventListener('mousedown', releaseCurrentMouseEvent) + ownerWindow?.addEventListener('mousemove', releaseCurrentMouseEvent) + ownerWindow?.addEventListener('mouseup', releaseCurrentMouseEvent) + ownerWindow?.addEventListener('mouseup', queueDeferredInputFallback) + ownerWindow?.addEventListener('click', queueDeferredInputFallback) + ownerWindow?.addEventListener('blur', handleBlur) + } + const removeDeferredListeners = (): void => { + if (!deferredListenersInstalled) { + return + } + deferredListenersInstalled = false + ownerDocument?.removeEventListener( + 'mousemove', + handleDeferredMouseEvent, + CAPTURE_LISTENER_OPTIONS + ) + ownerDocument?.removeEventListener('mousemove', releaseIneligibleDrag) + ownerDocument?.removeEventListener( + 'mouseup', + handleDeferredMouseEvent, + CAPTURE_LISTENER_OPTIONS + ) + ownerDocument?.removeEventListener('mouseleave', finishDeferredInput) + ownerWindow?.removeEventListener('mousedown', releaseCurrentMouseEvent) + ownerWindow?.removeEventListener('mousemove', releaseCurrentMouseEvent) + ownerWindow?.removeEventListener('mouseup', releaseCurrentMouseEvent) + ownerWindow?.removeEventListener('mouseup', queueDeferredInputFallback) + ownerWindow?.removeEventListener('click', queueDeferredInputFallback) + ownerWindow?.removeEventListener('blur', handleBlur) + } terminalElement?.addEventListener('mousedown', handleMouseDown, CAPTURE_LISTENER_OPTIONS) terminalElement?.addEventListener('mouseup', queueRestore, CAPTURE_LISTENER_OPTIONS) return { + claimAction: () => { + if (deferredPtyInput === null) { + return false + } + actionClaimed = true + return true + }, + handlePtyInput: (data, forward) => { + if (deferredPtyInput !== null && capturesPtyInput && isXtermMouseReport(data)) { + if (deferredPtyInput.length >= MAX_DEFERRED_PTY_INPUT_FRAMES) { + finishDeferredInput() + forward(data) + return + } + deferredPtyInput.push({ data, forward }) + return + } + forward(data) + }, dispose: () => { + finishDeferredInput() restore() terminalElement?.removeEventListener('mousedown', handleMouseDown, CAPTURE_LISTENER_OPTIONS) terminalElement?.removeEventListener('mouseup', queueRestore, CAPTURE_LISTENER_OPTIONS) diff --git a/src/renderer/src/components/terminal-pane/terminal-url-link-click.test.ts b/src/renderer/src/components/terminal-pane/terminal-url-link-click.test.ts index 0461474dedd..c654363d4c8 100644 --- a/src/renderer/src/components/terminal-pane/terminal-url-link-click.test.ts +++ b/src/renderer/src/components/terminal-pane/terminal-url-link-click.test.ts @@ -494,6 +494,7 @@ describe('hard-wrapped terminal HTTP clicks', () => { getLinkActionContext: () => ({ paneId: 1, pointerGesture: { canRequestAction: () => true, dispose: vi.fn() }, + claimPtyMouse: vi.fn(() => true), request, focusTerminal: vi.fn() }) diff --git a/src/renderer/src/components/terminal-pane/terminal-url-link-hit-testing.ts b/src/renderer/src/components/terminal-pane/terminal-url-link-hit-testing.ts index c28ae97824c..98824d140f4 100644 --- a/src/renderer/src/components/terminal-pane/terminal-url-link-hit-testing.ts +++ b/src/renderer/src/components/terminal-pane/terminal-url-link-hit-testing.ts @@ -4,7 +4,10 @@ import { buildEdgeWrappedHttpLogicalLineCandidates } from './edge-wrapped-termin import { buildHardWrappedHttpLogicalLineCandidates } from './hard-wrapped-terminal-http-links' import { dedupeLogicalLines } from './terminal-file-link-hit-testing' import { isTerminalHttpLinkActivation } from './terminal-http-link-activation' -import { installTerminalLinkPtyMouseSuppression } from './terminal-link-pty-mouse-suppression' +import { + installTerminalLinkPtyMouseSuppression, + type TerminalLinkPtyMouseSuppression +} from './terminal-link-pty-mouse-suppression' import { getTerminalBufferPositionForMouseEvent } from './terminal-mouse-buffer-position' import { extractTerminalHttpLinks } from './terminal-http-url-extraction' import { buildWrappedLogicalLine, rangeForParsedFileLink } from './wrapped-terminal-link-ranges' @@ -39,6 +42,10 @@ type UrlLinkClickFallbackDeps = { getActionDestinations?: () => TerminalHttpLinkActionDestinations } +export type HttpLinkClickFallbackBinding = IDisposable & { + ptyMouseSuppression: TerminalLinkPtyMouseSuppression +} + export type TerminalHttpLinkDestination = 'orca' | 'system' export type TerminalHttpLinkActionDestinations = { @@ -154,8 +161,8 @@ export function findHttpLinkAtTerminalMouseEvent( export function installHttpLinkClickFallback( terminal: Terminal, deps: UrlLinkClickFallbackDeps -): IDisposable { - const ptyMouseSuppression = installTerminalLinkPtyMouseSuppression(terminal, (event) => { +): HttpLinkClickFallbackBinding { + const isLinkMouseEvent = (event: MouseEvent): boolean => { if (isTerminalLinkifierHoverActive(terminal)) { return true } @@ -163,7 +170,16 @@ export function installHttpLinkClickFallback( return Boolean( position && findHttpLinkAtBufferPosition(terminal.buffer.active, position, terminal.cols) ) - }) + } + const ptyMouseSuppression = installTerminalLinkPtyMouseSuppression( + terminal, + isLinkMouseEvent, + (event) => { + const context = deps.getLinkActionContext?.() + return Boolean(context?.pointerGesture.canRequestAction(event) && isLinkMouseEvent(event)) + }, + (event) => Boolean(deps.getLinkActionContext?.()?.pointerGesture.canRequestAction(event)) + ) const handleMouseUp = (event: MouseEvent): void => { if (!isDesktopHttpLinkFallbackActivation(event)) { return @@ -190,6 +206,7 @@ export function installHttpLinkClickFallback( const terminalElement = terminal.element terminalElement?.addEventListener('mouseup', handleMouseUp) return { + ptyMouseSuppression, dispose: () => { ptyMouseSuppression.dispose() terminalElement?.removeEventListener('mouseup', handleMouseUp) diff --git a/src/renderer/src/components/terminal-pane/use-terminal-pane-lifecycle.ts b/src/renderer/src/components/terminal-pane/use-terminal-pane-lifecycle.ts index 1b666e20a80..4e23a47538e 100644 --- a/src/renderer/src/components/terminal-pane/use-terminal-pane-lifecycle.ts +++ b/src/renderer/src/components/terminal-pane/use-terminal-pane-lifecycle.ts @@ -668,7 +668,9 @@ export function useTerminalPaneLifecycle({ new Map>() ) const fileLinkClickFallbackDisposablesRef = useRef(new Map()) - const httpLinkClickFallbackDisposablesRef = useRef(new Map()) + const httpLinkClickFallbackDisposablesRef = useRef( + new Map>() + ) // Why: read settingsRef at fire time so toggling "copy on select" applies without recreating panes. const selectionDisposablesRef = useRef(new Map()) const selectionCaptureTimersRef = useRef(new Map()) @@ -756,12 +758,14 @@ export function useTerminalPaneLifecycle({ } const pane = managerRef.current?.getPanes().find((candidate) => candidate.id === paneId) const pointerGesture = linkPointerGestures.get(paneId) - if (!pane || !pointerGesture) { + const ptyMouseSuppression = httpLinkClickFallbackDisposables.get(paneId)?.ptyMouseSuppression + if (!pane || !pointerGesture || !ptyMouseSuppression) { return null } return { paneId, pointerGesture, + claimPtyMouse: ptyMouseSuppression.claimAction, request: requestTerminalLinkAction, focusTerminal: () => pane.terminal.focus() } @@ -867,6 +871,14 @@ export function useTerminalPaneLifecycle({ setCacheTimerStartedAt, syncPanePtyLayoutBinding, clearExitedPanePtyLayoutBinding, + deferPtyInput: (paneId, data, forward) => { + const suppression = httpLinkClickFallbackDisposables.get(paneId)?.ptyMouseSuppression + if (!suppression) { + forward(data) + return + } + suppression.handlePtyInput(data, forward) + }, // Why: record the main-answered 2031 subscribe in the CSI handler's registries, else theme flips never push CSI 997. recordPaneMode2031Subscription: (paneId: number, repliedMode: 'dark' | 'light') => { paneMode2031Ref.current.set(paneId, true) diff --git a/tests/e2e/fixtures/terminal-link-mouse-owner-fixture.cjs b/tests/e2e/fixtures/terminal-link-mouse-owner-fixture.cjs new file mode 100644 index 00000000000..afb3bd9d0b8 --- /dev/null +++ b/tests/e2e/fixtures/terminal-link-mouse-owner-fixture.cjs @@ -0,0 +1,49 @@ +const fs = require('node:fs') + +const ESC = '\x1b' +const LINK = 'https://example.com/sta-3888' +const OSC_LINK_TEXT = 'STA_3888_OSC_LINK' +const SGR_MOUSE_REPORT_PATTERN = new RegExp(String.raw`\x1b\[<\d+;\d+;\d+[Mm]`, 'g') +const logPath = process.argv[2] +const linkMode = process.argv[3] ?? 'http' +let pending = '' + +if (!logPath) { + throw new Error('Expected a mouse-report log path') +} + +function cleanup() { + process.stdout.write(`${ESC}[?1000l${ESC}[?1006l${ESC}[?25h${ESC}[?1049l`) + process.exit(0) +} + +process.stdin.setEncoding('utf8') +if (process.stdin.isTTY) { + process.stdin.setRawMode(true) +} +process.stdin.resume() + +const renderedLink = + linkMode === 'osc' ? `${ESC}]8;;${LINK}\x07${OSC_LINK_TEXT}${ESC}]8;;\x07` : LINK +process.stdout.write( + `${ESC}[?1049h${ESC}[?1000h${ESC}[?1006h${ESC}[?25l${ESC}[2J${ESC}[H` + + `LINK_MOUSE_READY ${renderedLink}\r\nPress q to exit` +) + +process.stdin.on('data', (chunk) => { + if (chunk.includes('\x03') || chunk.includes('q')) { + cleanup() + } + + pending += String(chunk) + const reports = pending.match(SGR_MOUSE_REPORT_PATTERN) ?? [] + if (reports.length > 0) { + const encodedReports = reports.map((report) => Buffer.from(report).toString('hex')).join('\n') + fs.appendFileSync(logPath, `${encodedReports}\n`) + pending = pending.slice(pending.lastIndexOf(reports.at(-1)) + reports.at(-1).length) + } else { + pending = pending.slice(-64) + } +}) + +process.on('SIGINT', cleanup) diff --git a/tests/e2e/terminal-link-click-ownership.spec.ts b/tests/e2e/terminal-link-click-ownership.spec.ts new file mode 100644 index 00000000000..50b570b0a6d --- /dev/null +++ b/tests/e2e/terminal-link-click-ownership.spec.ts @@ -0,0 +1,161 @@ +import { existsSync, readFileSync } from 'node:fs' +import path from 'node:path' +import type { Page, TestInfo } from '@playwright/test' +import { test, expect } from './helpers/orca-app' +import { + execInTerminal, + sendToTerminal, + waitForActivePanePtyId, + waitForActiveTerminalManager, + waitForPaneCount, + waitForTerminalOutput +} from './helpers/terminal' +import { ensureTerminalVisible, waitForActiveWorktree, waitForSessionReady } from './helpers/store' + +const FIXTURE_PATH = path.join( + process.cwd(), + 'tests/e2e/fixtures/terminal-link-mouse-owner-fixture.cjs' +) +const LINK = 'https://example.com/sta-3888' +const OSC_LINK_TEXT = 'STA_3888_OSC_LINK' + +type LinkTarget = { x: number; y: number; mouseTrackingMode: string } +type LinkMode = 'http' | 'osc' + +async function startMouseAwareLinkFixture( + orcaPage: Page, + testInfo: TestInfo, + linkMode: LinkMode = 'http' +): Promise<{ mouseLogPath: string; ptyId: string; target: LinkTarget }> { + await waitForSessionReady(orcaPage) + await waitForActiveWorktree(orcaPage) + await ensureTerminalVisible(orcaPage) + await waitForActiveTerminalManager(orcaPage) + await waitForPaneCount(orcaPage, 1) + + const ptyId = await waitForActivePanePtyId(orcaPage) + const mouseLogPath = testInfo.outputPath('child-mouse-reports.log') + await execInTerminal( + orcaPage, + ptyId, + `node ${JSON.stringify(FIXTURE_PATH)} ${JSON.stringify(mouseLogPath)} ${linkMode}` + ) + const renderedLinkText = linkMode === 'osc' ? OSC_LINK_TEXT : LINK + await waitForTerminalOutput(orcaPage, 'LINK_MOUSE_READY') + + const target = await orcaPage.evaluate((linkText) => { + const state = window.__store?.getState() + const worktreeId = state?.activeWorktreeId + const tabId = worktreeId ? state?.activeTabIdByWorktree?.[worktreeId] : null + const manager = tabId ? window.__paneManagers?.get(tabId) : null + const pane = manager?.getActivePane?.() ?? manager?.getPanes?.()[0] ?? null + const screen = pane?.terminal.element?.querySelector('.xterm-screen') ?? null + if (!pane || !screen) { + throw new Error('Active terminal screen unavailable') + } + + const buffer = pane.terminal.buffer.active + for (let viewportRow = 0; viewportRow < pane.terminal.rows; viewportRow += 1) { + const text = buffer.getLine(buffer.viewportY + viewportRow)?.translateToString(false) + const column = text?.indexOf(linkText) ?? -1 + if (column < 0) { + continue + } + const rect = screen.getBoundingClientRect() + const cell = pane.terminal.dimensions?.css.cell + if (!cell?.width || !cell.height) { + throw new Error('Active terminal cell dimensions unavailable') + } + return { + x: rect.left + (column + linkText.length / 2) * cell.width, + y: rect.top + (viewportRow + 0.5) * cell.height, + mouseTrackingMode: pane.terminal.modes.mouseTrackingMode + } + } + throw new Error('Rendered fixture link unavailable') + }, renderedLinkText) + + expect(target.mouseTrackingMode).not.toBe('none') + return { mouseLogPath, ptyId, target } +} + +function childMouseReportCount(mouseLogPath: string): number { + if (!existsSync(mouseLogPath)) { + return 0 + } + return readFileSync(mouseLogPath, 'utf8').trim().split(/\s+/).filter(Boolean).length +} + +async function expectChildMouseReports(mouseLogPath: string): Promise { + await expect + .poll(() => childMouseReportCount(mouseLogPath), { timeout: 5_000 }) + .toBeGreaterThan(0) +} + +async function expectOrcaOwnedMouseOutcome(mouseLogPath: string): Promise { + await new Promise((resolve) => setTimeout(resolve, 1_000)) + expect(childMouseReportCount(mouseLogPath)).toBe(0) +} + +test.describe('terminal link click ownership', () => { + test('an Orca-owned plain link click emits no child PTY mouse frames', async ({ + orcaPage + }, testInfo) => { + const { mouseLogPath, ptyId, target } = await startMouseAwareLinkFixture(orcaPage, testInfo) + await orcaPage.mouse.click(target.x, target.y) + + await expect(orcaPage.locator('[data-terminal-link-action-popover]')).toBeVisible() + await expect(orcaPage.locator('[data-terminal-link-destination]')).toHaveText(LINK) + + await expectOrcaOwnedMouseOutcome(mouseLogPath) + + await sendToTerminal(orcaPage, ptyId, 'q') + }) + + test('an Orca-owned OSC link click emits no child PTY mouse frames', async ({ + orcaPage + }, testInfo) => { + const { mouseLogPath, ptyId, target } = await startMouseAwareLinkFixture( + orcaPage, + testInfo, + 'osc' + ) + await orcaPage.mouse.move(target.x, target.y) + await expect(orcaPage.locator('.xterm-hover')).toHaveCount(1) + await orcaPage.mouse.click(target.x, target.y) + + await expect(orcaPage.locator('[data-terminal-link-action-popover]')).toBeVisible() + await expect(orcaPage.locator('[data-terminal-link-destination]')).toHaveText(LINK) + await expectOrcaOwnedMouseOutcome(mouseLogPath) + + await sendToTerminal(orcaPage, ptyId, 'q') + }) + + test('a plain click stays child-owned when link actions are disabled', async ({ + orcaPage + }, testInfo) => { + const { mouseLogPath, ptyId, target } = await startMouseAwareLinkFixture(orcaPage, testInfo) + await orcaPage.evaluate(async () => { + await window.__store?.getState().updateSettings({ terminalLinkActionPopoverEnabled: false }) + }) + + await orcaPage.mouse.click(target.x, target.y) + + await expect(orcaPage.locator('[data-terminal-link-action-popover]')).toHaveCount(0) + await expectChildMouseReports(mouseLogPath) + await sendToTerminal(orcaPage, ptyId, 'q') + }) + + test('a drag across a link stays child-owned', async ({ orcaPage }, testInfo) => { + const { mouseLogPath, ptyId, target } = await startMouseAwareLinkFixture(orcaPage, testInfo) + + await orcaPage.mouse.move(target.x, target.y) + await orcaPage.mouse.down() + await orcaPage.mouse.move(target.x + 12, target.y + 12, { steps: 3 }) + await orcaPage.mouse.up() + + await expect(orcaPage.locator('[data-terminal-link-action-popover]')).toHaveCount(0) + await expectChildMouseReports(mouseLogPath) + await sendToTerminal(orcaPage, ptyId, 'q') + }) +})