mirror of
https://github.com/stablyai/orca.git
synced 2026-09-23 00:02:29 +00:00
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 <help@stably.ai> --------- Co-authored-by: Jinwoo-H <jinwoo0825@gmail.com> Co-authored-by: Orca <help@stably.ai>
This commit is contained in:
co-authored by
Orca
Jinwoo-H
parent
e3c47eff17
commit
a67cbf2d24
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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<typeof translateLineWithColumns>
|
||||
|
||||
function buildCandidateFromStart(
|
||||
buffer: { getLine(y: number): IBufferLine | undefined },
|
||||
startY: number,
|
||||
currentY: number,
|
||||
translatedLines: Map<number, TranslatedLine>
|
||||
): 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<number, TranslatedLine>()
|
||||
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
|
||||
)
|
||||
}
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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))
|
||||
}
|
||||
@@ -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
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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<typeof vi.fn>
|
||||
} {
|
||||
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()
|
||||
})
|
||||
})
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user