diff --git a/src/renderer/src/components/terminal-pane/terminal-linkifier-click-priming.test.ts b/src/renderer/src/components/terminal-pane/terminal-linkifier-click-priming.test.ts new file mode 100644 index 000000000..aa58f7736 --- /dev/null +++ b/src/renderer/src/components/terminal-pane/terminal-linkifier-click-priming.test.ts @@ -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 +} { + 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 }) + ) + }) +}) diff --git a/src/renderer/src/components/terminal-pane/terminal-linkifier-click-priming.ts b/src/renderer/src/components/terminal-pane/terminal-linkifier-click-priming.ts new file mode 100644 index 000000000..7965fa488 --- /dev/null +++ b/src/renderer/src/components/terminal-pane/terminal-linkifier-click-priming.ts @@ -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) + } + } +} diff --git a/src/renderer/src/components/terminal-pane/terminal-url-link-click.test.ts b/src/renderer/src/components/terminal-pane/terminal-url-link-click.test.ts index 36b38c09a..4c5184cc9 100644 --- a/src/renderer/src/components/terminal-pane/terminal-url-link-click.test.ts +++ b/src/renderer/src/components/terminal-pane/terminal-url-link-click.test.ts @@ -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' }) diff --git a/src/renderer/src/components/terminal-pane/terminal-url-link-hit-testing.ts b/src/renderer/src/components/terminal-pane/terminal-url-link-hit-testing.ts index d38ce6d93..277fee7fe 100644 --- a/src/renderer/src/components/terminal-pane/terminal-url-link-hit-testing.ts +++ b/src/renderer/src/components/terminal-pane/terminal-url-link-hit-testing.ts @@ -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) diff --git a/src/renderer/src/components/terminal-pane/use-terminal-pane-lifecycle.ts b/src/renderer/src/components/terminal-pane/use-terminal-pane-lifecycle.ts index 402aab3b3..92f877819 100644 --- a/src/renderer/src/components/terminal-pane/use-terminal-pane-lifecycle.ts +++ b/src/renderer/src/components/terminal-pane/use-terminal-pane-lifecycle.ts @@ -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(null) const linkProviderDisposablesRef = useRef(new Map()) const terminalHandleLinkDisposablesRef = useRef(new Map()) + const linkifierClickPrimingDisposablesRef = useRef(new Map()) const fileLinkClickFallbackDisposablesRef = useRef(new Map()) const httpLinkClickFallbackDisposablesRef = useRef(new Map()) // 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() }