diff --git a/src/renderer/src/lib/pane-manager/pane-dom-creation.ts b/src/renderer/src/lib/pane-manager/pane-dom-creation.ts index 3b1498b0a..7c2fa26fa 100644 --- a/src/renderer/src/lib/pane-manager/pane-dom-creation.ts +++ b/src/renderer/src/lib/pane-manager/pane-dom-creation.ts @@ -145,6 +145,7 @@ export function createPaneDOM( compositionHandler: null, focusClassSyncCleanup: null, terminalScrollIntentDisposable: null, + linkifierMouseLeaveResetDisposable: null, arabicShapingJoinerCleanup: null, pendingSplitScrollState: null, pendingSplitScrollRafIds: [], diff --git a/src/renderer/src/lib/pane-manager/pane-lifecycle.test.ts b/src/renderer/src/lib/pane-manager/pane-lifecycle.test.ts index 607ad1cdb..701482d16 100644 --- a/src/renderer/src/lib/pane-manager/pane-lifecycle.test.ts +++ b/src/renderer/src/lib/pane-manager/pane-lifecycle.test.ts @@ -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. diff --git a/src/renderer/src/lib/pane-manager/pane-lifecycle.ts b/src/renderer/src/lib/pane-manager/pane-lifecycle.ts index 59e244710..91cffb1e6 100644 --- a/src/renderer/src/lib/pane-manager/pane-lifecycle.ts +++ b/src/renderer/src/lib/pane-manager/pane-lifecycle.ts @@ -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?.() diff --git a/src/renderer/src/lib/pane-manager/pane-manager-types.ts b/src/renderer/src/lib/pane-manager/pane-manager-types.ts index ffe314f63..2c1fd67de 100644 --- a/src/renderer/src/lib/pane-manager/pane-manager-types.ts +++ b/src/renderer/src/lib/pane-manager/pane-manager-types.ts @@ -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 diff --git a/src/renderer/src/lib/pane-manager/terminal-linkifier-hover-reset-on-mouseleave.test.ts b/src/renderer/src/lib/pane-manager/terminal-linkifier-hover-reset-on-mouseleave.test.ts new file mode 100644 index 000000000..ab4b9fcf8 --- /dev/null +++ b/src/renderer/src/lib/pane-manager/terminal-linkifier-hover-reset-on-mouseleave.test.ts @@ -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() + }) +}) diff --git a/src/renderer/src/lib/pane-manager/terminal-linkifier-hover-reset-on-mouseleave.ts b/src/renderer/src/lib/pane-manager/terminal-linkifier-hover-reset-on-mouseleave.ts new file mode 100644 index 000000000..0a88c74cf --- /dev/null +++ b/src/renderer/src/lib/pane-manager/terminal-linkifier-hover-reset-on-mouseleave.ts @@ -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('.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) + } +} diff --git a/tests/e2e/terminal-link-hover-after-worktree-return.spec.ts b/tests/e2e/terminal-link-hover-after-worktree-return.spec.ts index b0bcd1bc4..70912388c 100644 --- a/tests/e2e/terminal-link-hover-after-worktree-return.spec.ts +++ b/tests/e2e/terminal-link-hover-after-worktree-return.spec.ts @@ -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 { + 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 { @@ -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 }) => {