From a67cbf2d24e2c844e4fb426374f50ef88e23bf5d Mon Sep 17 00:00:00 2001 From: SeongHoon Ryu <4997174+ryush00@users.noreply.github.com> Date: Mon, 13 Jul 2026 13:36:13 +0900 Subject: [PATCH] fix(terminal): open hard-wrapped HTTP links exactly once (#8339) * fix(terminal): open hard-wrapped HTTP links exactly once * fix(terminal): narrow wrapped URL click handling * fix(terminal): harden wrapped URL activation Co-authored-by: Orca --------- Co-authored-by: Jinwoo-H Co-authored-by: Orca --- .../hard-wrapped-terminal-http-links.test.ts | 128 ++++++ .../hard-wrapped-terminal-http-links.ts | 148 ++++++ .../terminal-http-link-activation.test.ts | 30 ++ .../terminal-http-link-activation.ts | 7 + .../terminal-http-link-limits.ts | 5 + .../terminal-link-pty-mouse-suppression.ts | 59 +++ .../terminal-mouse-buffer-position.ts | 29 ++ .../terminal-url-link-click.test.ts | 429 ++++++++++++++++++ .../terminal-url-link-hit-testing.ts | 97 ++-- .../terminal-pane/terminal-web-link-click.ts | 50 ++ .../use-terminal-pane-lifecycle.ts | 17 +- .../wrapped-terminal-link-ranges.ts | 2 +- 12 files changed, 943 insertions(+), 58 deletions(-) create mode 100644 src/renderer/src/components/terminal-pane/hard-wrapped-terminal-http-links.test.ts create mode 100644 src/renderer/src/components/terminal-pane/hard-wrapped-terminal-http-links.ts create mode 100644 src/renderer/src/components/terminal-pane/terminal-http-link-activation.test.ts create mode 100644 src/renderer/src/components/terminal-pane/terminal-http-link-activation.ts create mode 100644 src/renderer/src/components/terminal-pane/terminal-http-link-limits.ts create mode 100644 src/renderer/src/components/terminal-pane/terminal-link-pty-mouse-suppression.ts create mode 100644 src/renderer/src/components/terminal-pane/terminal-mouse-buffer-position.ts create mode 100644 src/renderer/src/components/terminal-pane/terminal-url-link-click.test.ts create mode 100644 src/renderer/src/components/terminal-pane/terminal-web-link-click.ts diff --git a/src/renderer/src/components/terminal-pane/hard-wrapped-terminal-http-links.test.ts b/src/renderer/src/components/terminal-pane/hard-wrapped-terminal-http-links.test.ts new file mode 100644 index 00000000000..f38f3bda8bc --- /dev/null +++ b/src/renderer/src/components/terminal-pane/hard-wrapped-terminal-http-links.test.ts @@ -0,0 +1,128 @@ +import type { IBufferLine } from '@xterm/xterm' +import { describe, expect, it, vi } from 'vitest' +import { buildHardWrappedHttpLogicalLineCandidates } from './hard-wrapped-terminal-http-links' +import { + TERMINAL_HTTP_URL_MAX_HARD_WRAPPED_ROWS, + TERMINAL_HTTP_URL_MAX_LENGTH +} from './terminal-http-link-limits' + +function bufferLine(text: string, onTranslateWithColumns: () => void): IBufferLine { + return { + isWrapped: false, + length: text.length, + translateToString: ( + _trimRight?: boolean, + startColumn = 0, + endColumn = text.length, + outColumns?: number[] + ) => { + if (outColumns) { + onTranslateWithColumns() + } + outColumns?.push(...Array.from({ length: text.length + 1 }, (_value, index) => index)) + return text.slice(startColumn, endColumn) + } + } as IBufferLine +} + +function bufferLineWithCellColumns(text: string): IBufferLine { + const columns: number[] = [] + let column = 0 + for (const character of text) { + columns.push(column) + column += character === '你' ? 2 : 1 + } + columns.push(column) + return { + isWrapped: false, + length: column, + translateToString: ( + _trimRight?: boolean, + startColumn = 0, + endColumn = text.length, + outColumns?: number[] + ) => { + outColumns?.push(...columns) + return text.slice(startColumn, endColumn) + } + } as IBufferLine +} + +describe('hard-wrapped terminal HTTP candidate bounds', () => { + it('builds each required column map once and rejects an overlength candidate early', () => { + const onTranslateWithColumns = vi.fn() + const rows = Array.from({ length: TERMINAL_HTTP_URL_MAX_HARD_WRAPPED_ROWS }, (_value, index) => + bufferLine(`| ${index === 0 ? 'http://x' : 'aaaaaaaa'} |`, onTranslateWithColumns) + ) + + expect( + buildHardWrappedHttpLogicalLineCandidates( + { getLine: (y) => rows[y] }, + TERMINAL_HTTP_URL_MAX_HARD_WRAPPED_ROWS + ) + ).toEqual([]) + expect(onTranslateWithColumns).toHaveBeenCalledTimes( + Math.floor(TERMINAL_HTTP_URL_MAX_LENGTH / 'aaaaaaaa'.length) + 1 + ) + }) + + it('does not build column maps while rejecting rows without an HTTP scheme', () => { + const onTranslateWithColumns = vi.fn() + const rows = Array.from({ length: TERMINAL_HTTP_URL_MAX_HARD_WRAPPED_ROWS }, () => + bufferLine(`| ${'a'.repeat(500)} |`, onTranslateWithColumns) + ) + + expect( + buildHardWrappedHttpLogicalLineCandidates( + { getLine: (y) => rows[y] }, + TERMINAL_HTTP_URL_MAX_HARD_WRAPPED_ROWS + ) + ).toEqual([]) + expect(onTranslateWithColumns).not.toHaveBeenCalled() + }) + + it('reconstructs framed URL rows aligned by cells when a fragment has wide characters', () => { + const rowTexts = ['|http://a/ab|', '|a你你你你你|', '|tailzzzzzzz|'] + const rows = rowTexts.map(bufferLineWithCellColumns) + + const candidates = buildHardWrappedHttpLogicalLineCandidates( + { getLine: (y) => rows[y] }, + rows.length + ) + + expect(candidates[0]?.text).toBe('http://a/aba你你你你你tailzzzzzzz') + }) + + it('reconstructs a maximum-length wide-character URL beyond the ASCII row bound', () => { + const scheme = 'http://' + const continuation = '你'.repeat(TERMINAL_HTTP_URL_MAX_LENGTH - scheme.length) + const rows = [ + bufferLineWithCellColumns(`|${scheme}|`), + ...continuation + .match(/.{1,3}/g)! + .map((fragment) => + bufferLineWithCellColumns(`|${fragment}${' '.repeat(7 - fragment.length * 2)}|`) + ) + ] + + expect(rows.length).toBeGreaterThan(Math.ceil(TERMINAL_HTTP_URL_MAX_LENGTH / scheme.length)) + expect( + buildHardWrappedHttpLogicalLineCandidates({ getLine: (y) => rows[y] }, rows.length)[0]?.text + ).toBe(scheme + continuation) + }) + + it('reconstructs a maximum-length URL with one-character continuation rows', () => { + const firstFragment = 'http://a' + const continuation = '/'.repeat(TERMINAL_HTTP_URL_MAX_LENGTH - firstFragment.length) + const rows = [ + bufferLineWithCellColumns(`|${firstFragment}|`), + ...continuation.split('').map((fragment) => bufferLineWithCellColumns(`|${fragment} |`)) + ] + const fullUrl = firstFragment + continuation + + expect(() => new URL(fullUrl)).not.toThrow() + expect( + buildHardWrappedHttpLogicalLineCandidates({ getLine: (y) => rows[y] }, rows.length)[0]?.text + ).toBe(fullUrl) + }) +}) diff --git a/src/renderer/src/components/terminal-pane/hard-wrapped-terminal-http-links.ts b/src/renderer/src/components/terminal-pane/hard-wrapped-terminal-http-links.ts new file mode 100644 index 00000000000..30b1d3a9f3c --- /dev/null +++ b/src/renderer/src/components/terminal-pane/hard-wrapped-terminal-http-links.ts @@ -0,0 +1,148 @@ +import type { IBufferLine } from '@xterm/xterm' +import { + TERMINAL_HTTP_URL_MAX_HARD_WRAPPED_ROWS, + TERMINAL_HTTP_URL_MAX_LENGTH +} from './terminal-http-link-limits' +import { translateLineWithColumns, type WrappedLogicalLine } from './wrapped-terminal-link-ranges' + +const HTTP_SCHEME_PATTERN = /https?:\/\//i +const HTTP_SCHEME_START_PATTERN = /^https?:\/\//i +const HTTP_FRAGMENT_PATTERN = /^[^\s"'!*(){}|\\^<>`│┃║╎╏┆┇┊┋]*/ +const VERTICAL_LAYOUT_FRAME_PATTERN = /[│┃║╎╏┆┇┊┋|]/ +const NON_LAYOUT_SUFFIX_PATTERN = /[^\s│┃║╎╏┆┇┊┋|]/ +const HARD_WRAP_CONTINUATION_SUFFIX_PATTERN = /[/?&=#%+:-]$/ +const MIN_HARD_WRAPPED_HTTP_ROWS = 3 +const MIN_HARD_WRAP_FILL_RATIO = 0.8 + +type TranslatedLine = ReturnType + +function buildCandidateFromStart( + buffer: { getLine(y: number): IBufferLine | undefined }, + startY: number, + currentY: number, + translatedLines: Map +): WrappedLogicalLine | null { + const startLine = buffer.getLine(startY) + if (!startLine) { + return null + } + const cachedStart = translatedLines.get(startY) + const startText = cachedStart?.text ?? startLine.translateToString(false) + const schemeIndex = startText.search(HTTP_SCHEME_PATTERN) + if (schemeIndex === -1 || !VERTICAL_LAYOUT_FRAME_PATTERN.test(startText.slice(0, schemeIndex))) { + return null + } + const translatedStart = cachedStart ?? translateLineWithColumns(startLine) + translatedLines.set(startY, translatedStart) + const schemeColumn = translatedStart.columns[schemeIndex] + if (schemeColumn === undefined) { + return null + } + + const continuationPrefix = translatedStart.text.slice(0, schemeIndex) + let text = '' + let rightFrameColumn: number | null = null + let previousRowCanContinue = true + let startRowFilled = false + const rows: WrappedLogicalLine['rows'] = [] + + for (let rowY = startY; rowY < startY + TERMINAL_HTTP_URL_MAX_HARD_WRAPPED_ROWS; rowY++) { + if (rowY > startY && !previousRowCanContinue) { + break + } + const line = buffer.getLine(rowY) + if (!line) { + break + } + const translated = + rowY === startY + ? translatedStart + : (translatedLines.get(rowY) ?? translateLineWithColumns(line)) + translatedLines.set(rowY, translated) + if (rowY > startY && translated.text.slice(0, schemeIndex) !== continuationPrefix) { + break + } + + const fragment = translated.text.slice(schemeIndex).match(HTTP_FRAGMENT_PATTERN)?.[0] ?? '' + if (!fragment || (rowY > startY && HTTP_SCHEME_START_PATTERN.test(fragment))) { + break + } + const fragmentEnd = schemeIndex + fragment.length + const layoutSuffix = translated.text.slice(fragmentEnd) + const rightFrameOffset = layoutSuffix.search(VERTICAL_LAYOUT_FRAME_PATTERN) + const currentRightFrameIndex = rightFrameOffset === -1 ? -1 : fragmentEnd + rightFrameOffset + const currentRightFrameColumn = translated.columns[currentRightFrameIndex] + if ( + currentRightFrameIndex === -1 || + currentRightFrameColumn === undefined || + (rightFrameColumn !== null && currentRightFrameColumn !== rightFrameColumn) || + NON_LAYOUT_SUFFIX_PATTERN.test(layoutSuffix) + ) { + break + } + rightFrameColumn ??= currentRightFrameColumn + + if (text.length + fragment.length > TERMINAL_HTTP_URL_MAX_LENGTH) { + return null + } + + rows.push({ + y: rowY, + text: fragment, + sourceText: translated.text, + columns: translated.columns.slice(schemeIndex, fragmentEnd + 1), + startIndex: text.length, + isWrapped: line.isWrapped, + lineLength: line.length + }) + text += fragment + + const contentWidth = currentRightFrameColumn - schemeColumn + const fragmentWidth = translated.columns[fragmentEnd]! - schemeColumn + const fillsRow = contentWidth > 0 && fragmentWidth / contentWidth >= MIN_HARD_WRAP_FILL_RATIO + if (rowY === startY) { + startRowFilled = fillsRow + } + previousRowCanContinue = HARD_WRAP_CONTINUATION_SUFFIX_PATTERN.test(fragment) || fillsRow + } + + if (rows.length > 1 && (rows.length < MIN_HARD_WRAPPED_HTTP_ROWS || !startRowFilled)) { + // Why: a short complete URL can legitimately end in `/`; one adjacent + // framed token is not enough evidence that the URL continued onto it. + rows.splice(1) + text = rows[0]?.text ?? '' + } + if (rows.at(-1)?.y === undefined || rows.at(-1)!.y < currentY) { + return null + } + return { + text, + rows, + fingerprint: `hard-http:${rows.map((row) => `${row.y}:${row.sourceText}`).join('\0')}` + } +} + +export function buildHardWrappedHttpLogicalLineCandidates( + buffer: { getLine(y: number): IBufferLine | undefined }, + bufferLineNumber: number +): WrappedLogicalLine[] { + const currentY = bufferLineNumber - 1 + const currentLine = buffer.getLine(currentY) + if (!currentLine || !VERTICAL_LAYOUT_FRAME_PATTERN.test(currentLine.translateToString(false))) { + // Why: cursor-positioned hard wraps require a stable vertical frame; most + // terminal clicks can avoid the bounded backward scan entirely. + return [] + } + const candidates: WrappedLogicalLine[] = [] + const translatedLines = new Map() + const minY = Math.max(0, currentY - TERMINAL_HTTP_URL_MAX_HARD_WRAPPED_ROWS + 1) + for (let startY = currentY; startY >= minY; startY--) { + const candidate = buildCandidateFromStart(buffer, startY, currentY, translatedLines) + if (candidate) { + candidates.push(candidate) + } + } + return candidates.sort( + (left, right) => right.rows.length - left.rows.length || right.text.length - left.text.length + ) +} diff --git a/src/renderer/src/components/terminal-pane/terminal-http-link-activation.test.ts b/src/renderer/src/components/terminal-pane/terminal-http-link-activation.test.ts new file mode 100644 index 00000000000..ca11b713450 --- /dev/null +++ b/src/renderer/src/components/terminal-pane/terminal-http-link-activation.test.ts @@ -0,0 +1,30 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' +import { isTerminalHttpLinkActivation } from './terminal-http-link-activation' + +afterEach(() => { + vi.unstubAllGlobals() +}) + +describe('isTerminalHttpLinkActivation', () => { + it('leaves Alt+Cmd gestures to the child TUI on macOS', () => { + vi.stubGlobal('navigator', { userAgent: 'Macintosh' }) + + expect( + isTerminalHttpLinkActivation({ metaKey: true, ctrlKey: false, altKey: false } as MouseEvent) + ).toBe(true) + expect( + isTerminalHttpLinkActivation({ metaKey: true, ctrlKey: false, altKey: true } as MouseEvent) + ).toBe(false) + }) + + it('leaves Alt+Ctrl gestures to the child TUI on other platforms', () => { + vi.stubGlobal('navigator', { userAgent: 'Windows' }) + + expect( + isTerminalHttpLinkActivation({ metaKey: false, ctrlKey: true, altKey: false } as MouseEvent) + ).toBe(true) + expect( + isTerminalHttpLinkActivation({ metaKey: false, ctrlKey: true, altKey: true } as MouseEvent) + ).toBe(false) + }) +}) diff --git a/src/renderer/src/components/terminal-pane/terminal-http-link-activation.ts b/src/renderer/src/components/terminal-pane/terminal-http-link-activation.ts new file mode 100644 index 00000000000..c9e941c874d --- /dev/null +++ b/src/renderer/src/components/terminal-pane/terminal-http-link-activation.ts @@ -0,0 +1,7 @@ +import { isTerminalLinkActivation } from './terminal-link-activation' + +export function isTerminalHttpLinkActivation(event: MouseEvent | undefined): boolean { + // Why: xterm deliberately forwards Alt-modified mouse gestures to the PTY, + // so plain HTTP link handling must leave those gestures to the child TUI. + return Boolean(event && !event.altKey && isTerminalLinkActivation(event)) +} diff --git a/src/renderer/src/components/terminal-pane/terminal-http-link-limits.ts b/src/renderer/src/components/terminal-pane/terminal-http-link-limits.ts new file mode 100644 index 00000000000..52cc2cf1e93 --- /dev/null +++ b/src/renderer/src/components/terminal-pane/terminal-http-link-limits.ts @@ -0,0 +1,5 @@ +export const TERMINAL_HTTP_URL_MAX_LENGTH = 2048 + +// Why: punctuation can form a valid one-character continuation row, so the +// URL length limit is also the only complete finite bound on framed rows. +export const TERMINAL_HTTP_URL_MAX_HARD_WRAPPED_ROWS = TERMINAL_HTTP_URL_MAX_LENGTH diff --git a/src/renderer/src/components/terminal-pane/terminal-link-pty-mouse-suppression.ts b/src/renderer/src/components/terminal-pane/terminal-link-pty-mouse-suppression.ts new file mode 100644 index 00000000000..e595974bc18 --- /dev/null +++ b/src/renderer/src/components/terminal-pane/terminal-link-pty-mouse-suppression.ts @@ -0,0 +1,59 @@ +import type { IDisposable, Terminal } from '@xterm/xterm' +import { isTerminalHttpLinkActivation } from './terminal-http-link-activation' + +const CAPTURE_LISTENER_OPTIONS = { capture: true } as const + +export function installTerminalLinkPtyMouseSuppression( + terminal: Terminal, + shouldSuppressMouseEvent: (event: MouseEvent) => boolean +): IDisposable { + const terminalElement = terminal.element + const ownerDocument = terminalElement?.ownerDocument + const ownerWindow = ownerDocument?.defaultView + let previousMouseEventsRequireAlt: boolean | null = null + let restoreQueued = false + + const restore = (): void => { + restoreQueued = false + if (previousMouseEventsRequireAlt === null) { + return + } + terminal.options.mouseEventsRequireAlt = previousMouseEventsRequireAlt + previousMouseEventsRequireAlt = null + ownerDocument?.removeEventListener('mouseup', queueRestore) + ownerWindow?.removeEventListener('blur', restore) + } + const queueRestore = (): void => { + if (restoreQueued || previousMouseEventsRequireAlt === null) { + return + } + restoreQueued = true + queueMicrotask(restore) + } + const handleMouseDown = (event: MouseEvent): void => { + if ( + event.button !== 0 || + !isTerminalHttpLinkActivation(event) || + !shouldSuppressMouseEvent(event) + ) { + return + } + restore() + previousMouseEventsRequireAlt = Boolean(terminal.options.mouseEventsRequireAlt) + // Why: xterm otherwise forwards the same Cmd/Ctrl link gesture to a mouse-aware + // TUI, letting the terminal and the child process both open the URL. + terminal.options.mouseEventsRequireAlt = true + ownerDocument?.addEventListener('mouseup', queueRestore) + ownerWindow?.addEventListener('blur', restore) + } + + terminalElement?.addEventListener('mousedown', handleMouseDown, CAPTURE_LISTENER_OPTIONS) + terminalElement?.addEventListener('mouseup', queueRestore, CAPTURE_LISTENER_OPTIONS) + return { + dispose: () => { + restore() + terminalElement?.removeEventListener('mousedown', handleMouseDown, CAPTURE_LISTENER_OPTIONS) + terminalElement?.removeEventListener('mouseup', queueRestore, CAPTURE_LISTENER_OPTIONS) + } + } +} diff --git a/src/renderer/src/components/terminal-pane/terminal-mouse-buffer-position.ts b/src/renderer/src/components/terminal-pane/terminal-mouse-buffer-position.ts new file mode 100644 index 00000000000..ee741dbbc4f --- /dev/null +++ b/src/renderer/src/components/terminal-pane/terminal-mouse-buffer-position.ts @@ -0,0 +1,29 @@ +import type { Terminal } from '@xterm/xterm' + +export function getTerminalBufferPositionForMouseEvent( + terminal: Terminal, + event: MouseEvent +): { x: number; y: number } | null { + const screenElement = terminal.element?.querySelector('.xterm-screen') + if (!screenElement || terminal.cols <= 0 || terminal.rows <= 0) { + return null + } + + const rect = screenElement.getBoundingClientRect() + const relativeX = event.clientX - rect.left + const relativeY = event.clientY - rect.top + if (relativeX < 0 || relativeY < 0 || relativeX >= rect.width || relativeY >= rect.height) { + return null + } + + const cellWidth = rect.width / terminal.cols + const cellHeight = rect.height / terminal.rows + if (cellWidth <= 0 || cellHeight <= 0) { + return null + } + + return { + x: Math.floor(relativeX / cellWidth) + 1, + y: Math.floor(relativeY / cellHeight) + terminal.buffer.active.viewportY + 1 + } +} 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 new file mode 100644 index 00000000000..c8e85950da3 --- /dev/null +++ b/src/renderer/src/components/terminal-pane/terminal-url-link-click.test.ts @@ -0,0 +1,429 @@ +import type { IBufferLine, Terminal } from '@xterm/xterm' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { registerHttpLinkStoreAccessor } from '@/lib/http-link-routing' +import { handleTerminalWebLinkClick } from './terminal-web-link-click' +import { installHttpLinkClickFallback } from './terminal-url-link-hit-testing' + +const COLS = 157 +const ROWS = 59 +const INDENT = '' +const FULL_URL = [ + 'http://127.0.0.1:8765/orca-double-open-repro-wrapped/', + Array.from({ length: 79 }, (_value, index) => `seg${String(index + 1).padStart(4, '0')}`).join( + '/' + ), + '?marker=wrap-test&n=001&pad=', + 'x'.repeat(120) +].join('') +const URL_ROWS = Array.from({ length: Math.ceil(FULL_URL.length / COLS) }, (_value, index) => + FULL_URL.slice(index * COLS, (index + 1) * COLS) +) +const FRAMED_ROW_STARTS = [ + 0, + FULL_URL.indexOf('seg0008/'), + FULL_URL.indexOf('seg0022/'), + FULL_URL.indexOf('seg0036/'), + FULL_URL.indexOf('seg0050/'), + FULL_URL.indexOf('seg0064/'), + FULL_URL.indexOf('seg0078/'), + FULL_URL.indexOf('test&n=001'), + FULL_URL.length - 19 +] +const FRAMED_URL_ROWS = FRAMED_ROW_STARTS.map((start, index) => + FULL_URL.slice(start, FRAMED_ROW_STARTS[index + 1]) +) + +const openUrlMock = vi.fn() + +type ListenerRegistration = [string, EventListener, AddEventListenerOptions | boolean | undefined] + +function makeBufferLine( + fragment: string, + options: { cols?: number; prefix?: string; suffix?: string; isWrapped?: boolean } = {} +): IBufferLine { + const cols = options.cols ?? COLS + const prefix = options.prefix ?? INDENT + const suffix = options.suffix ?? '' + const text = `${prefix}${fragment}`.padEnd(cols - suffix.length) + suffix + return { + isWrapped: options.isWrapped ?? false, + length: cols, + translateToString: ( + _trimRight?: boolean, + startColumn = 0, + endColumn = text.length, + outColumns?: number[] + ) => { + if (outColumns) { + outColumns.splice( + 0, + outColumns.length, + ...Array.from( + { length: endColumn - startColumn + 1 }, + (_value, index) => index + startColumn + ) + ) + } + return text.slice(startColumn, endColumn) + } + } as IBufferLine +} + +function makeTerminal(options?: { + cols?: number + rows?: number + urlRows?: string[] + linePrefix?: string + lineSuffix?: string + softWrapped?: boolean +}): { + terminal: Terminal + registrations: ListenerRegistration[] + clearSelection: ReturnType +} { + const cols = options?.cols ?? COLS + const rows = options?.rows ?? ROWS + const urlRows = options?.urlRows ?? URL_ROWS + const registrations: ListenerRegistration[] = [] + const ownerWindow = { + addEventListener: vi.fn(), + removeEventListener: vi.fn() + } + const ownerDocument = { + defaultView: ownerWindow, + addEventListener: vi.fn(), + removeEventListener: vi.fn() + } + const screen = { + getBoundingClientRect: () => ({ left: 0, top: 0, width: cols * 10, height: rows * 10 }) + } + const element = { + ownerDocument, + querySelector: vi.fn(() => screen), + addEventListener: vi.fn( + (name: string, listener: EventListener, options?: AddEventListenerOptions | boolean) => { + registrations.push([name, listener, options]) + } + ), + removeEventListener: vi.fn() + } + const clearSelection = vi.fn() + return { + terminal: { + cols, + rows, + options: { mouseEventsRequireAlt: false }, + element, + buffer: { + active: { + viewportY: 0, + getLine: (y: number) => + urlRows[y] && + makeBufferLine(urlRows[y], { + cols, + prefix: options?.linePrefix, + suffix: options?.lineSuffix, + isWrapped: (options?.softWrapped ?? true) && y > 0 + }) + } + }, + clearSelection + } as unknown as Terminal, + registrations, + clearSelection + } +} + +function mouseEventForRow(row: number, options: { altKey?: boolean } = {}): MouseEvent { + let defaultPrevented = false + return { + button: 0, + metaKey: true, + ctrlKey: false, + altKey: options.altKey ?? false, + shiftKey: true, + get defaultPrevented() { + return defaultPrevented + }, + clientX: 150, + clientY: row * 10 + 5, + preventDefault: vi.fn(() => { + defaultPrevented = true + }) + } as unknown as MouseEvent +} + +describe('hard-wrapped terminal HTTP clicks', () => { + beforeEach(() => { + vi.stubGlobal('navigator', { userAgent: 'Macintosh' }) + vi.stubGlobal('window', { api: { shell: { openUrl: openUrlMock } } }) + registerHttpLinkStoreAccessor(() => ({ + settings: { openLinksInApp: false }, + setActiveWorktree: vi.fn(), + createBrowserTab: vi.fn() + })) + openUrlMock.mockReset() + }) + + afterEach(() => { + vi.unstubAllGlobals() + }) + + it('opens the full logical URL once when WebLinksAddon reports only the first row', () => { + const { terminal, registrations, clearSelection } = makeTerminal() + const disposable = installHttpLinkClickFallback(terminal, { worktreeId: 'wt-1' }) + const event = mouseEventForRow(0) + + expect( + handleTerminalWebLinkClick(URL_ROWS[0], event, { + terminal, + worktreeId: 'wt-1', + worktreePath: '/tmp', + startupCwd: '/tmp' + }) + ).toBe(true) + + const fallback = registrations.find( + ([name, _listener, options]) => name === 'mouseup' && options === undefined + )?.[1] + expect(fallback).toBeDefined() + fallback!(event) + + expect(openUrlMock).toHaveBeenCalledTimes(1) + expect(openUrlMock).toHaveBeenCalledWith(FULL_URL) + expect(new URL(URL_ROWS[0]).pathname).toHaveLength(136) + expect(`${new URL(FULL_URL).pathname}${new URL(FULL_URL).search}`).toHaveLength(811) + expect(event.preventDefault).toHaveBeenCalled() + expect(clearSelection).toHaveBeenCalled() + disposable.dispose() + }) + + it('opens the same full URL from a continuation-row fallback click', () => { + const { terminal, registrations } = makeTerminal() + const disposable = installHttpLinkClickFallback(terminal, { worktreeId: 'wt-1' }) + const event = mouseEventForRow(3) + const fallback = registrations.find( + ([name, _listener, options]) => name === 'mouseup' && options === undefined + )?.[1] + + fallback!(event) + + expect(openUrlMock).toHaveBeenCalledTimes(1) + expect(openUrlMock).toHaveBeenCalledWith(FULL_URL) + disposable.dispose() + }) + + it('reconstructs a URL split across cursor-positioned rows inside a TUI frame', () => { + const { terminal } = makeTerminal({ + cols: 135, + urlRows: FRAMED_URL_ROWS, + linePrefix: ' │ ', + lineSuffix: '│ ', + softWrapped: false + }) + const event = mouseEventForRow(0) + + expect( + handleTerminalWebLinkClick(FRAMED_URL_ROWS[0], event, { + terminal, + worktreeId: 'wt-1', + worktreePath: '/tmp', + startupCwd: '/tmp' + }) + ).toBe(true) + + expect(openUrlMock).toHaveBeenCalledTimes(1) + expect(openUrlMock).toHaveBeenCalledWith(FULL_URL) + expect(new URL(FRAMED_URL_ROWS[0]).pathname).toHaveLength(88) + }) + + it('reconstructs a URL that fills each cursor-positioned TUI row up to its frame', () => { + const cols = 135 + const linePrefix = ' │ ' + const lineSuffix = '│ ' + const contentWidth = cols - linePrefix.length - lineSuffix.length + const fullWidthRows = Array.from( + { length: Math.ceil(FULL_URL.length / contentWidth) }, + (_value, index) => FULL_URL.slice(index * contentWidth, (index + 1) * contentWidth) + ) + const { terminal } = makeTerminal({ + cols, + urlRows: fullWidthRows, + linePrefix, + lineSuffix, + softWrapped: false + }) + + expect( + handleTerminalWebLinkClick(fullWidthRows[0], mouseEventForRow(0), { + terminal, + worktreeId: 'wt-1', + worktreePath: '/tmp', + startupCwd: '/tmp' + }) + ).toBe(true) + + expect(openUrlMock).toHaveBeenCalledTimes(1) + expect(openUrlMock).toHaveBeenCalledWith(FULL_URL) + }) + + it('reconstructs supported URLs spanning more than twenty framed rows', () => { + const cols = 80 + const linePrefix = ' │ ' + const lineSuffix = '│ ' + const contentWidth = cols - linePrefix.length - lineSuffix.length + const longUrl = `http://example.com/${'a'.repeat(contentWidth * 20)}` + const urlRows = Array.from( + { length: Math.ceil(longUrl.length / contentWidth) }, + (_value, index) => longUrl.slice(index * contentWidth, (index + 1) * contentWidth) + ) + const { terminal } = makeTerminal({ + cols, + urlRows, + linePrefix, + lineSuffix, + softWrapped: false + }) + + expect(urlRows).toHaveLength(21) + expect( + handleTerminalWebLinkClick(urlRows[0], mouseEventForRow(0), { + terminal, + worktreeId: 'wt-1', + worktreePath: '/tmp', + startupCwd: '/tmp' + }) + ).toBe(true) + expect(openUrlMock).toHaveBeenCalledWith(longUrl) + }) + + it('keeps nested HTTP URLs inside a wrapped query parameter', () => { + const cols = 80 + const linePrefix = ' │ ' + const lineSuffix = '│ ' + const contentWidth = cols - linePrefix.length - lineSuffix.length + const firstRow = `http://example.com/${'a'.repeat(contentWidth - 'http://example.com/'.length)}` + const nestedQuery = 'segment?redirect=https://nested.example/path' + const secondRow = `${nestedQuery}${'b'.repeat(contentWidth - nestedQuery.length)}` + const urlRows = [firstRow, secondRow, 'tail'] + const fullUrl = urlRows.join('') + const { terminal } = makeTerminal({ + cols, + urlRows, + linePrefix, + lineSuffix, + softWrapped: false + }) + + expect( + handleTerminalWebLinkClick(firstRow, mouseEventForRow(0), { + terminal, + worktreeId: 'wt-1', + worktreePath: '/tmp', + startupCwd: '/tmp' + }) + ).toBe(true) + expect(openUrlMock).toHaveBeenCalledWith(fullUrl) + }) + + it('does not append an unrelated aligned TUI row to a complete URL', () => { + const { terminal, registrations } = makeTerminal({ + cols: 135, + urlRows: ['http://example.com/', 'next-token'], + linePrefix: ' │ ', + lineSuffix: '│ ', + softWrapped: false + }) + const disposable = installHttpLinkClickFallback(terminal, { worktreeId: 'wt-1' }) + + expect( + handleTerminalWebLinkClick('http://example.com/', mouseEventForRow(0), { + terminal, + worktreeId: 'wt-1', + worktreePath: '/tmp', + startupCwd: '/tmp' + }) + ).toBe(true) + expect(openUrlMock).toHaveBeenCalledOnce() + expect(openUrlMock).toHaveBeenCalledWith('http://example.com/') + + openUrlMock.mockReset() + const fallback = registrations.find( + ([name, _listener, options]) => name === 'mouseup' && options === undefined + )?.[1] + fallback!(mouseEventForRow(1)) + expect(openUrlMock).not.toHaveBeenCalled() + disposable.dispose() + }) + + it('does not join a complete URL to multiple unrelated framed rows', () => { + const unrelatedFilledRow = 'a'.repeat(103) + const { terminal } = makeTerminal({ + cols: 110, + urlRows: ['http://example.com/', unrelatedFilledRow, 'unrelated'], + linePrefix: ' │ ', + lineSuffix: '│ ', + softWrapped: false + }) + + expect( + handleTerminalWebLinkClick('http://example.com/', mouseEventForRow(0), { + terminal, + worktreeId: 'wt-1', + worktreePath: '/tmp', + startupCwd: '/tmp' + }) + ).toBe(true) + + expect(openUrlMock).toHaveBeenCalledOnce() + expect(openUrlMock).toHaveBeenCalledWith('http://example.com/') + }) + + it('does not suppress a modifier-click when the buffer position is not an HTTP link', () => { + const { terminal, registrations } = makeTerminal({ urlRows: ['not-a-link'] }) + const disposable = installHttpLinkClickFallback(terminal, { worktreeId: 'wt-1' }) + const mouseDown = registrations.find(([name]) => name === 'mousedown')?.[1] + + mouseDown!(mouseEventForRow(0)) + + expect(terminal.options.mouseEventsRequireAlt).toBe(false) + disposable.dispose() + }) + + it('leaves Alt-modified link gestures to the child TUI', () => { + const { terminal, registrations } = makeTerminal() + const disposable = installHttpLinkClickFallback(terminal, { worktreeId: 'wt-1' }) + const mouseDown = registrations.find(([name]) => name === 'mousedown')?.[1] + const event = mouseEventForRow(0, { altKey: true }) + + mouseDown!(event) + expect(terminal.options.mouseEventsRequireAlt).toBe(false) + expect( + handleTerminalWebLinkClick(URL_ROWS[0], event, { + terminal, + worktreeId: 'wt-1', + worktreePath: '/tmp', + startupCwd: '/tmp' + }) + ).toBe(false) + expect(openUrlMock).not.toHaveBeenCalled() + disposable.dispose() + }) + + it('temporarily suppresses PTY mouse reporting at an HTTP link position', async () => { + const { terminal, registrations } = makeTerminal() + const disposable = installHttpLinkClickFallback(terminal, { worktreeId: 'wt-1' }) + const mouseDown = registrations.find(([name]) => name === 'mousedown')?.[1] + const mouseUp = registrations.find( + ([name, _listener, options]) => name === 'mouseup' && options !== undefined + )?.[1] + + mouseDown!(mouseEventForRow(0)) + expect(terminal.options.mouseEventsRequireAlt).toBe(true) + + mouseUp!(mouseEventForRow(0)) + await Promise.resolve() + expect(terminal.options.mouseEventsRequireAlt).toBe(false) + disposable.dispose() + }) +}) 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 67505d5f622..bfbbe29f723 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 @@ -1,8 +1,15 @@ import type { IBufferLine, IBufferRange, IDisposable, Terminal } from '@xterm/xterm' import { openHttpLink } from '@/lib/http-link-routing' -import { buildCandidateLogicalLinesForBufferPosition } from './terminal-file-link-hit-testing' -import { isTerminalLinkActivation } from './terminal-link-activation' -import { rangeForParsedFileLink } from './wrapped-terminal-link-ranges' +import { + buildCandidateLogicalLinesForBufferPosition, + dedupeLogicalLines +} from './terminal-file-link-hit-testing' +import { buildHardWrappedHttpLogicalLineCandidates } from './hard-wrapped-terminal-http-links' +import { isTerminalHttpLinkActivation } from './terminal-http-link-activation' +import { installTerminalLinkPtyMouseSuppression } from './terminal-link-pty-mouse-suppression' +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' type UrlLinkHitTestDeps = { worktreeId: string @@ -26,7 +33,7 @@ type ParsedTerminalHttpLink = { } const HTTP_SCHEME_PREFIXES = ['https://', 'http://'] as const -export const TERMINAL_HTTP_URL_MAX_LENGTH = 2048 +export { TERMINAL_HTTP_URL_MAX_LENGTH } from './terminal-http-link-limits' export function extractTerminalHttpLinks(lineText: string): ParsedTerminalHttpLink[] { const links: ParsedTerminalHttpLink[] = [] @@ -56,7 +63,7 @@ function isDesktopHttpLinkFallbackActivation(event: MouseEvent): boolean { // Why: desktop terminal links require an intentional Cmd/Ctrl gesture so // plain clicks remain available for cursor placement and selection. Mobile // tap routing is handled separately under mobile/src/terminal. - return isTerminalLinkActivation(event) + return isTerminalHttpLinkActivation(event) } function* iterateTerminalHttpUrlCandidates( @@ -185,56 +192,40 @@ function isAsciiWordCode(code: number): boolean { ) } -function getTerminalScreenElement(terminal: Terminal): HTMLElement | null { - return terminal.element?.querySelector('.xterm-screen') ?? null -} - -function getBufferPositionForTerminalMouseEvent( +export function openHttpLinkAtTerminalMouseEvent( terminal: Terminal, - event: MouseEvent -): { x: number; y: number } | null { - const screenElement = getTerminalScreenElement(terminal) - if (!screenElement || terminal.cols <= 0 || terminal.rows <= 0) { - return null + event: MouseEvent, + deps: UrlLinkHitTestDeps +): boolean { + if (event.button !== 0 || !isTerminalHttpLinkActivation(event)) { + return false } - - const rect = screenElement.getBoundingClientRect() - const relativeX = event.clientX - rect.left - const relativeY = event.clientY - rect.top - if (relativeX < 0 || relativeY < 0 || relativeX >= rect.width || relativeY >= rect.height) { - return null - } - - const cellWidth = rect.width / terminal.cols - const cellHeight = rect.height / terminal.rows - if (cellWidth <= 0 || cellHeight <= 0) { - return null - } - - return { - x: Math.floor(relativeX / cellWidth) + 1, - y: Math.floor(relativeY / cellHeight) + terminal.buffer.active.viewportY + 1 + const position = getTerminalBufferPositionForMouseEvent(terminal, event) + if (!position) { + return false } + return openHttpLinkAtBufferPosition(terminal.buffer.active, position, terminal.cols, deps) } export function installHttpLinkClickFallback( terminal: Terminal, deps: UrlLinkClickFallbackDeps ): IDisposable { + const ptyMouseSuppression = installTerminalLinkPtyMouseSuppression(terminal, (event) => { + const position = getTerminalBufferPositionForMouseEvent(terminal, event) + return Boolean( + position && findHttpLinkAtBufferPosition(terminal.buffer.active, position, terminal.cols) + ) + }) const handleMouseUp = (event: MouseEvent): void => { if (!isDesktopHttpLinkFallbackActivation(event)) { return } - const position = getBufferPositionForTerminalMouseEvent(terminal, event) - if (!position) { - return - } - // Why: xterm's WebLinksAddon only activates after hover state exists. This // direct mouseup fallback preserves modifier-clicks when the hover link was // never established, while defaultPrevented avoids duplicate opens. - const opened = openHttpLinkAtBufferPosition(terminal.buffer.active, position, terminal.cols, { + const opened = openHttpLinkAtTerminalMouseEvent(terminal, event, { worktreeId: deps.worktreeId, forceSystemBrowser: event.shiftKey, requestOpenLinksInAppPreference: deps.requestOpenLinksInAppPreference @@ -249,6 +240,7 @@ export function installHttpLinkClickFallback( terminalElement?.addEventListener('mouseup', handleMouseUp) return { dispose: () => { + ptyMouseSuppression.dispose() terminalElement?.removeEventListener('mouseup', handleMouseUp) } } @@ -260,10 +252,30 @@ export function openHttpLinkAtBufferPosition( terminalColumns: number, deps: UrlLinkHitTestDeps ): boolean { - const logicalLines = buildCandidateLogicalLinesForBufferPosition(buffer, position.y) - if (logicalLines.length === 0) { + const url = findHttpLinkAtBufferPosition(buffer, position, terminalColumns) + if (!url) { return false } + openTerminalHttpLink(url, deps) + return true +} + +function findHttpLinkAtBufferPosition( + buffer: { getLine(y: number): IBufferLine | undefined }, + position: { x: number; y: number }, + terminalColumns: number +): string | null { + const nativeWrappedLogicalLine = buildWrappedLogicalLine(buffer, position.y) + const logicalLines = dedupeLogicalLines([ + ...(nativeWrappedLogicalLine && nativeWrappedLogicalLine.rows.length > 1 + ? [nativeWrappedLogicalLine] + : []), + ...buildHardWrappedHttpLogicalLineCandidates(buffer, position.y), + ...buildCandidateLogicalLinesForBufferPosition(buffer, position.y) + ]) + if (logicalLines.length === 0) { + return null + } for (const logicalLine of logicalLines) { for (const parsed of extractTerminalHttpLinks(logicalLine.text)) { @@ -271,12 +283,11 @@ export function openHttpLinkAtBufferPosition( if (!range || !rangeContainsBufferPosition(range, position, terminalColumns)) { continue } - openTerminalHttpLink(parsed.url, deps) - return true + return parsed.url } } - return false + return null } export function openTerminalHttpLink(url: string, deps: UrlLinkHitTestDeps): void { diff --git a/src/renderer/src/components/terminal-pane/terminal-web-link-click.ts b/src/renderer/src/components/terminal-pane/terminal-web-link-click.ts new file mode 100644 index 00000000000..c512cc84126 --- /dev/null +++ b/src/renderer/src/components/terminal-pane/terminal-web-link-click.ts @@ -0,0 +1,50 @@ +import type { Terminal } from '@xterm/xterm' +import type { LinkHandlerDeps } from './terminal-link-handlers' +import { isTerminalHttpLinkActivation } from './terminal-http-link-activation' +import { handleOscLink } from './terminal-osc-link-routing' +import { + openHttpLinkAtTerminalMouseEvent, + type TerminalLinkRoutingPreferenceRequester +} from './terminal-url-link-hit-testing' + +type TerminalWebLinkClickDeps = Pick< + LinkHandlerDeps, + 'worktreeId' | 'worktreePath' | 'startupCwd' | 'runtimeEnvironmentId' | 'terminalHomePath' +> & { + terminal: Terminal | null + requestOpenLinksInAppPreference?: TerminalLinkRoutingPreferenceRequester +} + +export function handleTerminalWebLinkClick( + url: string, + event: MouseEvent | undefined, + deps: TerminalWebLinkClickDeps +): boolean { + if (!event || !isTerminalHttpLinkActivation(event)) { + return false + } + + let handled: boolean + if ( + deps.terminal && + openHttpLinkAtTerminalMouseEvent(deps.terminal, event, { + worktreeId: deps.worktreeId, + forceSystemBrowser: Boolean(event.shiftKey), + requestOpenLinksInAppPreference: deps.requestOpenLinksInAppPreference + }) + ) { + // Why: WebLinksAddon only knows the physical row; Orca's logical hit-test + // preserves the complete URL rendered across hard-wrapped TUI rows. + event.preventDefault() + handled = true + } else { + handled = handleOscLink(url, event, deps) + } + + if (handled) { + // Why: link navigation can steal focus before xterm's mouseup cleanup; + // clearing selection also detaches its pending drag-selection listeners. + deps.terminal?.clearSelection() + } + return handled +} 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 5f68494ba15..5a64694000e 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 @@ -31,6 +31,7 @@ import { import { createTerminalHandleLinkProvider } from './terminal-handle-links' import type { LinkHandlerDeps } from './terminal-link-handlers' import { handleOscLink } from './terminal-osc-link-routing' +import { handleTerminalWebLinkClick } from './terminal-web-link-click' import { installHttpLinkClickFallback, type TerminalLinkRoutingPreferenceRequester @@ -1480,28 +1481,16 @@ export function useTerminalPaneLifecycle({ terminalTuiScrollSensitivity: () => normalizeTerminalTuiMouseWheelMultiplier(settingsRef.current?.terminalTuiScrollSensitivity), onLinkClick: (event, url) => { - if (!event) { - return - } const activePane = managerRef.current?.getActivePane() - const handled = handleOscLink(url, event, { + handleTerminalWebLinkClick(url, event, { ...linkDeps, + terminal: activePane?.terminal ?? null, startupCwd: activePane ? getPaneLinkCwd(activePane.id) : startupCwd, runtimeEnvironmentId: activePane ? (linkDeps.getRuntimeEnvironmentIdForPane?.(activePane.id) ?? null) : null, requestOpenLinksInAppPreference }) - // Why: Cmd/Ctrl+click on a plain-text URL (WebLinksAddon) takes focus - // away from the terminal before the click's mouseup reaches - // ownerDocument. That leaves xterm's SelectionService drag-select - // mousemove listener attached, so subsequent mouse motion extends a - // phantom selection until the next click/Esc. Explicitly clearing the - // selection also detaches those listeners (see - // SelectionService._removeMouseDownListeners). - if (handled) { - managerRef.current?.getActivePane()?.terminal.clearSelection() - } }, formatLinkTooltip: (url, openLinkHint) => formatTerminalUrlTooltip(url, openLinkHint), // Why: TerminalPane instances stay mounted for hidden visited worktrees diff --git a/src/renderer/src/components/terminal-pane/wrapped-terminal-link-ranges.ts b/src/renderer/src/components/terminal-pane/wrapped-terminal-link-ranges.ts index b648950bcd0..b94affd030b 100644 --- a/src/renderer/src/components/terminal-pane/wrapped-terminal-link-ranges.ts +++ b/src/renderer/src/components/terminal-pane/wrapped-terminal-link-ranges.ts @@ -65,7 +65,7 @@ function translateLineWithCells(line: IBufferLine): { text: string; columns: num return { text, columns } } -function translateLineWithColumns(line: IBufferLine): { text: string; columns: number[] } { +export function translateLineWithColumns(line: IBufferLine): { text: string; columns: number[] } { const columns: number[] = [] const text = (line as TerminalBufferLineWithColumns).translateToString( false,