fix(terminal): restore link hover after mouseleave (#10903)

* fix(terminal): restore link hover after mouseleave

* test(terminal): verify mouseleave listener cleanup

* test(terminal): assert link hover listener wiring
This commit is contained in:
Neil 2026-07-27 03:29:35 -07:00 committed by GitHub
parent fb26254a02
commit 0e11ec38bb
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
7 changed files with 185 additions and 8 deletions

View File

@ -145,6 +145,7 @@ export function createPaneDOM(
compositionHandler: null,
focusClassSyncCleanup: null,
terminalScrollIntentDisposable: null,
linkifierMouseLeaveResetDisposable: null,
arabicShapingJoinerCleanup: null,
pendingSplitScrollState: null,
pendingSplitScrollRafIds: [],

View File

@ -623,6 +623,30 @@ describe('openTerminal — addon and provider wiring', () => {
expect(pane.linkifierHoverResetDisposable).toBeNull()
})
it('installs the mouseleave linkifier hover reset and disposes it', () => {
const { pane } = createOpenTerminalHarness()
const addEventListener = vi.fn()
const removeEventListener = vi.fn()
const screen = {
addEventListener,
removeEventListener
} as unknown as HTMLElement
vi.mocked(pane.terminal.element!.querySelector).mockReturnValueOnce(screen)
openTerminal(pane)
const disposable = pane.linkifierMouseLeaveResetDisposable
expect(disposable?.dispose).toBeTypeOf('function')
expect(addEventListener).toHaveBeenCalledWith('mouseleave', expect.any(Function))
const mouseLeaveHandler = addEventListener.mock.calls.find(
([eventName]) => eventName === 'mouseleave'
)?.[1]
expect(mouseLeaveHandler).toBeTypeOf('function')
disposePane(pane, new Map([[pane.id, pane]]))
expect(removeEventListener).toHaveBeenCalledWith('mouseleave', mouseLeaveHandler)
expect(pane.linkifierMouseLeaveResetDisposable).toBeNull()
})
// Why: the DOM renderer misrenders joined spans (per-character
// letter-spacing blowout), so the joiner must only join while this pane's
// WebGL addon is live — locked here against the real openTerminal wiring.

View File

@ -9,6 +9,7 @@ import { cancelDeferredScrollRestore } from './pane-scroll'
import { activateOrcaTerminalUnicodeProvider } from '../../../../shared/terminal-unicode-provider'
import { attachTerminalMouseWheelMultiplier } from './pane-terminal-mouse-wheel'
import { attachTerminalScrollIntentTracking } from './terminal-scroll-intent-dom-tracking'
import { installTerminalLinkifierHoverResetOnMouseLeave } from './terminal-linkifier-hover-reset-on-mouseleave'
import { installTerminalLinkifierHoverResetOnWrite } from './terminal-linkifier-hover-reset-on-write'
import { attachDomRendererFocusClassSync } from './pane-dom-focus-class-sync'
import { attachWebgl, cancelPendingWebglRefresh, disposeWebgl } from './pane-webgl-renderer'
@ -62,6 +63,7 @@ export function openTerminal(pane: ManagedPaneInternal): void {
// line; invalidate the linkifier hover cache when output lands so the next
// pointer move re-linkifies it.
pane.linkifierHoverResetDisposable = installTerminalLinkifierHoverResetOnWrite(terminal)
pane.linkifierMouseLeaveResetDisposable = installTerminalLinkifierHoverResetOnMouseLeave(terminal)
// Activate Orca's Unicode 11 width shim *before* any caller-driven write. CJK / emoji /
// ZWJ codepoints get baked into the buffer at the active unicode version on
@ -236,6 +238,8 @@ export function disposePane(
pane.terminalScrollIntentDisposable = null
pane.linkifierHoverResetDisposable?.dispose()
pane.linkifierHoverResetDisposable = null
pane.linkifierMouseLeaveResetDisposable?.dispose()
pane.linkifierMouseLeaveResetDisposable = null
// Deregister the RTL shaping joiner: terminal.dispose() below does not.
try {
pane.arabicShapingJoinerCleanup?.()

View File

@ -169,6 +169,8 @@ export type ManagedPaneInternal = {
// Stored so disposePane() can detach the streamed-output hover-cache reset
// that keeps freshly printed links linkifiable without a scroll.
linkifierHoverResetDisposable?: IDisposable | null
// Stored because mouseleave does not bubble from xterm's screen.
linkifierMouseLeaveResetDisposable?: IDisposable | null
// Stored so disposePane() can deregister the joiner; terminal.dispose()
// does not remove registered character joiners.
arabicShapingJoinerCleanup?: (() => void) | null

View File

@ -0,0 +1,82 @@
import type { Terminal } from '@xterm/xterm'
import { describe, expect, it, vi } from 'vitest'
import { installTerminalLinkifierHoverResetOnMouseLeave } from './terminal-linkifier-hover-reset-on-mouseleave'
type FakeLinkifier = { _lastBufferCell?: unknown; _activeLine?: number }
function createHarness(hasScreen = true) {
let mouseLeaveHandler: (() => void) | null = null
const addEventListener = vi.fn((_event: string, handler: () => void) => {
mouseLeaveHandler = handler
})
const removeEventListener = vi.fn((_event: string, handler: () => void) => {
if (mouseLeaveHandler === handler) {
mouseLeaveHandler = null
}
})
const screen = { addEventListener, removeEventListener }
const querySelector = vi.fn(() => (hasScreen ? screen : null))
const linkifier: FakeLinkifier = {
_lastBufferCell: { x: 2, y: 3 },
_activeLine: 3
}
const terminal = {
element: { querySelector },
_core: { linkifier }
} as unknown as Terminal
return {
terminal,
linkifier,
querySelector,
addEventListener,
removeEventListener,
dispatchMouseLeave: () => mouseLeaveHandler?.()
}
}
describe('installTerminalLinkifierHoverResetOnMouseLeave', () => {
it('resets the hover cache when the terminal surface loses the pointer', () => {
const harness = createHarness()
installTerminalLinkifierHoverResetOnMouseLeave(harness.terminal)
expect(harness.querySelector).toHaveBeenCalledWith('.xterm-screen')
expect(harness.addEventListener).toHaveBeenCalledWith('mouseleave', expect.any(Function))
harness.dispatchMouseLeave()
expect(harness.linkifier._lastBufferCell).toBeUndefined()
expect(harness.linkifier._activeLine).toBe(-1)
})
it('removes the listener on dispose', () => {
const harness = createHarness()
const disposable = installTerminalLinkifierHoverResetOnMouseLeave(harness.terminal)
const mouseLeaveHandler = harness.addEventListener.mock.calls.find(
([eventName]) => eventName === 'mouseleave'
)?.[1]
expect(mouseLeaveHandler).toBeTypeOf('function')
disposable.dispose()
expect(harness.removeEventListener).toHaveBeenCalledWith('mouseleave', mouseLeaveHandler)
harness.dispatchMouseLeave()
expect(harness.linkifier._lastBufferCell).toEqual({ x: 2, y: 3 })
expect(harness.linkifier._activeLine).toBe(3)
})
it('degrades to a no-op when the screen is unavailable', () => {
const harness = createHarness(false)
expect(() =>
installTerminalLinkifierHoverResetOnMouseLeave(harness.terminal).dispose()
).not.toThrow()
})
it('does not throw when xterm linkifier internals are unavailable', () => {
const harness = createHarness()
const terminal = harness.terminal as unknown as { _core?: unknown }
terminal._core = undefined
installTerminalLinkifierHoverResetOnMouseLeave(harness.terminal)
expect(harness.dispatchMouseLeave).not.toThrow()
})
})

View File

@ -0,0 +1,16 @@
import type { IDisposable, Terminal } from '@xterm/xterm'
import { resetTerminalLinkifierHoverState } from './terminal-linkifier-hover-reset'
export function installTerminalLinkifierHoverResetOnMouseLeave(terminal: Terminal): IDisposable {
const screen = terminal.element?.querySelector<HTMLElement>('.xterm-screen')
if (!screen) {
return { dispose: () => undefined }
}
const resetHover = (): void => resetTerminalLinkifierHoverState(terminal)
// Why: xterm clears its active link but keeps the cell cache on mouseleave.
screen.addEventListener('mouseleave', resetHover)
return {
dispose: () => screen.removeEventListener('mouseleave', resetHover)
}
}

View File

@ -98,14 +98,21 @@ async function hoverAndReadActiveLinkText(page: Page, probe: HoverProbe): Promis
new MouseEvent('mousemove', { bubbles: true, cancelable: true, clientX, clientY })
)
}, probe)
return page.evaluate(({ tabId }) => {
const manager = window.__paneManagers?.get(tabId)
const pane = manager?.getActivePane?.() ?? manager?.getPanes?.()[0] ?? null
const core = pane?.terminal as unknown as
| { _core?: { linkifier?: { currentLink?: { link?: { text?: string } } } } }
| undefined
return core?._core?.linkifier?.currentLink?.link?.text ?? null
}, probe)
return readActiveLinkText(page, probe.tabId)
}
async function readActiveLinkText(page: Page, tabId: string): Promise<string | null> {
return page.evaluate(
({ tabId }) => {
const manager = window.__paneManagers?.get(tabId)
const pane = manager?.getActivePane?.() ?? manager?.getPanes?.()[0] ?? null
const core = pane?.terminal as unknown as
| { _core?: { linkifier?: { currentLink?: { link?: { text?: string } } } } }
| undefined
return core?._core?.linkifier?.currentLink?.link?.text ?? null
},
{ tabId }
)
}
async function readTerminalCursor(page: Page, tabId: string): Promise<string | null> {
@ -241,6 +248,47 @@ test.describe('Terminal link hover after worktree return', () => {
await waitForSessionReady(orcaPage)
})
test('re-establishes a URL link on hover after the pointer leaves the terminal', async ({
orcaPage
}) => {
await ensureTerminalVisible(orcaPage)
await waitForActiveTerminalManager(orcaPage, 30_000)
const ptyId = await waitForActivePanePtyId(orcaPage)
await waitForPtyShellEcho(orcaPage, ptyId, 15_000)
const url = `https://example.com/orca-link-${randomUUID()}`
await sendToTerminal(orcaPage, ptyId, `echo ${url}\r`)
await expect
.poll(() => getTerminalContent(orcaPage, 4000), {
timeout: 10_000,
message: 'URL fixture did not reach the terminal buffer'
})
.toContain(url)
// Let the streamed-output reset finish before creating the hover cache
// state this mouseleave regression targets.
await orcaPage.waitForTimeout(300)
const probe = await locateHoverProbe(orcaPage, url)
await expect
.poll(() => hoverAndReadActiveLinkText(orcaPage, probe), {
timeout: 5_000,
message: 'baseline hover never established the URL link'
})
.toContain(url)
await dispatchScreenMouseLeave(orcaPage, probe.tabId)
await expect.poll(() => readActiveLinkText(orcaPage, probe.tabId)).toBeNull()
await expect.poll(() => readTerminalCursor(orcaPage, probe.tabId)).not.toBe('pointer')
await expect
.poll(() => hoverAndReadActiveLinkText(orcaPage, probe), {
timeout: 5_000,
message: 'URL link did not re-establish after terminal mouseleave'
})
.toContain(url)
await expect.poll(() => readTerminalCursor(orcaPage, probe.tabId)).toBe('pointer')
})
test('re-establishes a file-path link on hover after switching worktrees and back', async ({
orcaPage
}) => {