fix(terminal): activate fresh OSC links on first click (#11453)
This commit is contained in:
parent
cbe8635f46
commit
eb58e00c19
|
|
@ -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 })
|
||||
)
|
||||
})
|
||||
})
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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' })
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue