mirror of
https://github.com/stablyai/orca.git
synced 2026-10-22 00:03:47 +00:00
fix(terminal): prevent link action clicks reaching child (#13797)
(cherry picked from commit 6acd69c516)
This commit is contained in:
@@ -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()
|
||||
|
||||
+271
@@ -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')
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user