fix(terminal): activate fresh OSC links on first click (#11453)

This commit is contained in:
Neil 2026-07-29 18:22:52 -07:00 committed by GitHub
parent cbe8635f46
commit eb58e00c19
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
5 changed files with 248 additions and 0 deletions

View File

@ -0,0 +1,155 @@
import type { Terminal } from '@xterm/xterm'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { installTerminalLinkifierClickPriming } from './terminal-linkifier-click-priming'
type ListenerRegistration = [string, EventListener, AddEventListenerOptions | boolean | undefined]
type FakeLinkifier = {
_activeLine?: number
_currentLink?: unknown
_handleMouseMove?: (event: MouseEvent) => void
_lastBufferCell?: unknown
}
function createTerminal(linkifier: FakeLinkifier | null | undefined): {
terminal: Terminal
registrations: ListenerRegistration[]
removeEventListener: ReturnType<typeof vi.fn>
} {
const registrations: ListenerRegistration[] = []
const removeEventListener = vi.fn()
const element = {
addEventListener: (
name: string,
listener: EventListener,
options?: AddEventListenerOptions | boolean
) => registrations.push([name, listener, options]),
removeEventListener
}
return {
terminal: {
_core: linkifier ? { linkifier } : undefined,
element
} as unknown as Terminal,
registrations,
removeEventListener
}
}
function modifierMouseDown(options: {
ctrlKey?: boolean
metaKey?: boolean
shiftKey?: boolean
}): MouseEvent {
return {
button: 0,
ctrlKey: options.ctrlKey ?? false,
metaKey: options.metaKey ?? false,
shiftKey: options.shiftKey ?? false
} as MouseEvent
}
function getMouseDownHandler(registrations: ListenerRegistration[]): EventListener {
const handler = registrations.find(
([name, _listener, options]) =>
name === 'mousedown' &&
typeof options === 'object' &&
options !== null &&
options.capture === true
)?.[1]
expect(handler).toBeDefined()
return handler!
}
describe('installTerminalLinkifierClickPriming', () => {
afterEach(() => {
vi.unstubAllGlobals()
})
it('primes a fresh OSC link before xterm snapshots a macOS mousedown', () => {
vi.stubGlobal('navigator', { userAgent: 'Macintosh' })
const order: string[] = []
const linkifier: FakeLinkifier = {
_activeLine: 14,
_lastBufferCell: { x: 8, y: 14 },
_handleMouseMove(event) {
expect(event.metaKey).toBe(true)
expect(this._activeLine).toBe(-1)
expect(this._lastBufferCell).toBeUndefined()
this._currentLink = { link: 'https://example.com/fresh' }
order.push('prime')
}
}
const { terminal, registrations } = createTerminal(linkifier)
installTerminalLinkifierClickPriming(terminal)
getMouseDownHandler(registrations)(modifierMouseDown({ metaKey: true }))
order.push(linkifier._currentLink ? 'snapshot-link' : 'snapshot-empty')
expect(order).toEqual(['prime', 'snapshot-link'])
})
it('uses Ctrl on non-Mac platforms and preserves Shift for routing', () => {
vi.stubGlobal('navigator', { userAgent: 'Windows' })
const handleMouseMove = vi.fn()
const { terminal, registrations } = createTerminal({ _handleMouseMove: handleMouseMove })
installTerminalLinkifierClickPriming(terminal)
const mouseDown = getMouseDownHandler(registrations)
mouseDown(modifierMouseDown({ ctrlKey: true, shiftKey: true }))
mouseDown(modifierMouseDown({ metaKey: true }))
expect(handleMouseMove).toHaveBeenCalledOnce()
expect(handleMouseMove.mock.calls[0][0].shiftKey).toBe(true)
})
it('does not clear established hover state before refreshing the click position', () => {
vi.stubGlobal('navigator', { userAgent: 'Macintosh' })
const currentLink = { link: 'https://example.com/hovered' }
const lastBufferCell = { x: 3, y: 5 }
const linkifier: FakeLinkifier = {
_activeLine: 5,
_currentLink: currentLink,
_lastBufferCell: lastBufferCell,
_handleMouseMove: vi.fn()
}
const { terminal, registrations } = createTerminal(linkifier)
installTerminalLinkifierClickPriming(terminal)
getMouseDownHandler(registrations)(modifierMouseDown({ metaKey: true }))
expect(linkifier._handleMouseMove).toHaveBeenCalledOnce()
expect(linkifier._currentLink).toBe(currentLink)
expect(linkifier._lastBufferCell).toBe(lastBufferCell)
expect(linkifier._activeLine).toBe(5)
})
it('ignores plain clicks and degrades safely when xterm internals are unavailable', () => {
vi.stubGlobal('navigator', { userAgent: 'Macintosh' })
const handleMouseMove = vi.fn()
const present = createTerminal({ _handleMouseMove: handleMouseMove })
const absent = createTerminal(null)
installTerminalLinkifierClickPriming(present.terminal)
installTerminalLinkifierClickPriming(absent.terminal)
getMouseDownHandler(present.registrations)(modifierMouseDown({}))
expect(() =>
getMouseDownHandler(absent.registrations)(modifierMouseDown({ metaKey: true }))
).not.toThrow()
expect(handleMouseMove).not.toHaveBeenCalled()
})
it('removes its capture listener on dispose', () => {
const { terminal, registrations, removeEventListener } = createTerminal({})
const disposable = installTerminalLinkifierClickPriming(terminal)
const mouseDown = getMouseDownHandler(registrations)
disposable.dispose()
expect(removeEventListener).toHaveBeenCalledWith(
'mousedown',
mouseDown,
expect.objectContaining({ capture: true })
)
})
})

View File

@ -0,0 +1,56 @@
import type { IDisposable, Terminal } from '@xterm/xterm'
import { isTerminalLinkActivation } from './terminal-link-activation'
const CAPTURE_LISTENER_OPTIONS = { capture: true } as const
type LinkifierClickPrimer = {
_activeLine?: number
_currentLink?: unknown
_handleMouseMove?: (event: MouseEvent) => void
_lastBufferCell?: unknown
}
type TerminalCoreWithLinkifier = {
_core?: {
linkifier?: LinkifierClickPrimer
}
}
function primeTerminalLinkifier(terminal: Terminal, event: MouseEvent): void {
try {
const linkifier = (terminal as unknown as TerminalCoreWithLinkifier)._core?.linkifier
if (!linkifier || typeof linkifier._handleMouseMove !== 'function') {
return
}
if (!linkifier._currentLink) {
if ('_lastBufferCell' in linkifier) {
linkifier._lastBufferCell = undefined
}
if ('_activeLine' in linkifier) {
linkifier._activeLine = -1
}
}
linkifier._handleMouseMove(event)
} catch {
/* xterm internals unavailable — hover still primes later clicks */
}
}
export function installTerminalLinkifierClickPriming(terminal: Terminal): IDisposable {
const terminalElement = terminal.element
const handleMouseDown = (event: MouseEvent): void => {
if (event.button !== 0 || !isTerminalLinkActivation(event)) {
return
}
// Why: xterm snapshots its current link on mousedown but otherwise resolves
// links only on mousemove, so output painted under a still pointer misses its first click.
primeTerminalLinkifier(terminal, event)
}
terminalElement?.addEventListener('mousedown', handleMouseDown, CAPTURE_LISTENER_OPTIONS)
return {
dispose: () => {
terminalElement?.removeEventListener('mousedown', handleMouseDown, CAPTURE_LISTENER_OPTIONS)
}
}
}

View File

@ -428,6 +428,24 @@ describe('hard-wrapped terminal HTTP clicks', () => {
disposable.dispose()
})
it('temporarily suppresses PTY mouse reporting for a primed OSC link', () => {
const { terminal, registrations } = makeTerminal({ urlRows: ['OSC label'] })
const terminalWithLinkifier = terminal as unknown as {
_core: { linkifier: { _currentLink: unknown } }
}
terminalWithLinkifier._core = {
linkifier: { _currentLink: { link: 'https://example.com/osc' } }
}
const disposable = installHttpLinkClickFallback(terminal, { worktreeId: 'wt-1' })
const mouseDown = registrations.find(([name]) => name === 'mousedown')?.[1]
mouseDown!(mouseEventForRow(0))
expect(terminal.options.mouseEventsRequireAlt).toBe(true)
disposable.dispose()
expect(terminal.options.mouseEventsRequireAlt).toBe(false)
})
it('leaves Alt-modified link gestures to the child TUI', () => {
const { terminal, registrations } = makeTerminal()
const disposable = installHttpLinkClickFallback(terminal, { worktreeId: 'wt-1' })

View File

@ -8,6 +8,7 @@ import { installTerminalLinkPtyMouseSuppression } from './terminal-link-pty-mous
import { getTerminalBufferPositionForMouseEvent } from './terminal-mouse-buffer-position'
import { TERMINAL_HTTP_URL_MAX_LENGTH } from './terminal-http-link-limits'
import { buildWrappedLogicalLine, rangeForParsedFileLink } from './wrapped-terminal-link-ranges'
import { isTerminalLinkifierHoverActive } from '@/lib/pane-manager/terminal-linkifier-hover-reset'
type UrlLinkHitTestDeps = {
worktreeId: string
@ -210,6 +211,9 @@ export function installHttpLinkClickFallback(
deps: UrlLinkClickFallbackDeps
): IDisposable {
const ptyMouseSuppression = installTerminalLinkPtyMouseSuppression(terminal, (event) => {
if (isTerminalLinkifierHoverActive(terminal)) {
return true
}
const position = getTerminalBufferPositionForMouseEvent(terminal, event)
return Boolean(
position && findHttpLinkAtBufferPosition(terminal.buffer.active, position, terminal.cols)

View File

@ -41,6 +41,7 @@ import {
installHttpLinkClickFallback,
type TerminalLinkRoutingPreferenceRequester
} from './terminal-url-link-hit-testing'
import { installTerminalLinkifierClickPriming } from './terminal-linkifier-click-priming'
import { resolveLocalhostHttpLinkDisplayUrl } from '@/lib/http-link-routing'
import type {
GlobalSettings,
@ -650,6 +651,7 @@ export function useTerminalPaneLifecycle({
const previousVisibleForReconcileRef = useRef<TerminalPaneVisibilitySnapshot | null>(null)
const linkProviderDisposablesRef = useRef(new Map<number, IDisposable>())
const terminalHandleLinkDisposablesRef = useRef(new Map<number, IDisposable>())
const linkifierClickPrimingDisposablesRef = useRef(new Map<number, IDisposable>())
const fileLinkClickFallbackDisposablesRef = useRef(new Map<number, IDisposable>())
const httpLinkClickFallbackDisposablesRef = useRef(new Map<number, IDisposable>())
// Why: read settingsRef at fire time so toggling "copy on select" applies without recreating panes.
@ -691,6 +693,7 @@ export function useTerminalPaneLifecycle({
const panePtyBindings = panePtyBindingsRef.current
const linkDisposables = linkProviderDisposablesRef.current
const terminalHandleLinkDisposables = terminalHandleLinkDisposablesRef.current
const linkifierClickPrimingDisposables = linkifierClickPrimingDisposablesRef.current
const fileLinkClickFallbackDisposables = fileLinkClickFallbackDisposablesRef.current
const httpLinkClickFallbackDisposables = httpLinkClickFallbackDisposablesRef.current
const selectionDisposables = selectionDisposablesRef.current
@ -1047,6 +1050,8 @@ export function useTerminalPaneLifecycle({
})
)
terminalHandleLinkDisposablesRef.current.set(pane.id, terminalHandleLinkDisposable)
const linkifierClickPrimingDisposable = installTerminalLinkifierClickPriming(pane.terminal)
linkifierClickPrimingDisposablesRef.current.set(pane.id, linkifierClickPrimingDisposable)
const fileLinkClickFallbackDisposable = installFilePathLinkClickFallback(
pane.id,
pane.terminal,
@ -1191,6 +1196,12 @@ export function useTerminalPaneLifecycle({
terminalHandleLinkDisposable.dispose()
terminalHandleLinkDisposablesRef.current.delete(paneId)
}
const linkifierClickPrimingDisposable =
linkifierClickPrimingDisposablesRef.current.get(paneId)
if (linkifierClickPrimingDisposable) {
linkifierClickPrimingDisposable.dispose()
linkifierClickPrimingDisposablesRef.current.delete(paneId)
}
const fileLinkClickFallbackDisposable =
fileLinkClickFallbackDisposablesRef.current.get(paneId)
if (fileLinkClickFallbackDisposable) {
@ -1679,6 +1690,10 @@ export function useTerminalPaneLifecycle({
disposable.dispose()
}
terminalHandleLinkDisposables.clear()
for (const disposable of linkifierClickPrimingDisposables.values()) {
disposable.dispose()
}
linkifierClickPrimingDisposables.clear()
for (const disposable of fileLinkClickFallbackDisposables.values()) {
disposable.dispose()
}