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:
parent
fb26254a02
commit
0e11ec38bb
|
|
@ -145,6 +145,7 @@ export function createPaneDOM(
|
|||
compositionHandler: null,
|
||||
focusClassSyncCleanup: null,
|
||||
terminalScrollIntentDisposable: null,
|
||||
linkifierMouseLeaveResetDisposable: null,
|
||||
arabicShapingJoinerCleanup: null,
|
||||
pendingSplitScrollState: null,
|
||||
pendingSplitScrollRafIds: [],
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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?.()
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
})
|
||||
})
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}) => {
|
||||
|
|
|
|||
Loading…
Reference in New Issue