fix(terminal): prevent link action clicks reaching child (#13797)

(cherry picked from commit 6acd69c516)
This commit is contained in:
Jinwoo Hong
2026-08-12 17:13:26 -07:00
committed by Jinjing
parent 74d5b0c860
commit 61864ec1aa
13 changed files with 725 additions and 10 deletions
@@ -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.
@@ -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,
@@ -40,6 +40,7 @@ function context(request: ReturnType<typeof vi.fn>): TerminalLinkActionContext {
return {
paneId: 3,
pointerGesture: { canRequestAction: () => true, dispose: vi.fn() },
claimPtyMouse: vi.fn(() => true),
request: request as TerminalLinkActionContext['request'],
focusTerminal: vi.fn()
}
@@ -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()
})
@@ -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,
@@ -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()
@@ -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<ReturnType<typeof installTerminalLinkPtyMouseSuppression>>()
function createSuppression(
deferPlain = true,
shouldContinueDeferring: (event: MouseEvent) => boolean = () => true
): {
element: HTMLDivElement
suppression: ReturnType<typeof installTerminalLinkPtyMouseSuppression>
} {
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<void> {
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)
})
})
@@ -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)
@@ -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()
})
@@ -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)
@@ -668,7 +668,9 @@ export function useTerminalPaneLifecycle({
new Map<number, ReturnType<typeof installTerminalLinkPointerGesture>>()
)
const fileLinkClickFallbackDisposablesRef = useRef(new Map<number, IDisposable>())
const httpLinkClickFallbackDisposablesRef = useRef(new Map<number, IDisposable>())
const httpLinkClickFallbackDisposablesRef = useRef(
new Map<number, ReturnType<typeof installHttpLinkClickFallback>>()
)
// Why: read settingsRef at fire time so toggling "copy on select" applies without recreating panes.
const selectionDisposablesRef = useRef(new Map<number, IDisposable>())
const selectionCaptureTimersRef = useRef(new Map<number, number>())
@@ -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)
@@ -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)
@@ -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<HTMLElement>('.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<void> {
await expect
.poll(() => childMouseReportCount(mouseLogPath), { timeout: 5_000 })
.toBeGreaterThan(0)
}
async function expectOrcaOwnedMouseOutcome(mouseLogPath: string): Promise<void> {
await new Promise<void>((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')
})
})