From 624c8d4120cd355a336a02048ae5ce6fbe0b2d16 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Sun, 19 Jul 2026 15:21:06 -0700 Subject: [PATCH] Make plain-text file:// links clickable in the terminal (#9467) * Make plain-text file:// links clickable in the terminal Printed file:// URIs (e.g. a report path echoed by a tool or agent) were neither http links nor bare filesystem paths, so the terminal's URL and local-path detectors both skipped them and the link was dead. Orca already resolves and opens file:// URIs for OSC 8 hyperlinks. Reuse that exact resolver for plain-text URIs so a printed file:// behaves the same whether or not the emitter wrapped it in an escape sequence: - Promote the (dependency-pure) file-url target resolver into src/shared so the OSC path and the new plain-text path share one implementation. - Add a file:// detector that decodes the URI to a filesystem path and routes it through the existing file-link pipeline (existence probe + openDetectedFilePath), so line/col anchors, %20, Windows drive paths, html-in-browser, editor reveal, and SSH/runtime resolution all just work. Lines without file:// are unchanged: the pass short-circuits to the prior result, so only file://-bearing lines gain a link. - Add unit + integration coverage for detection, decoding, and no-double-link. * Harden plain-text file URI detection * Split terminal file link detection modules --- config/max-lines-baseline.txt | 1 - .../terminal-osc-link-routing.ts | 2 +- .../lib/terminal-bare-file-link-detection.ts | 68 +++++ .../terminal-file-link-detection-ranges.ts | 126 ++++++++ .../src/lib/terminal-file-uri-link.test.ts | 67 +++++ .../src/lib/terminal-file-uri-link.ts | 95 ++++++ src/renderer/src/lib/terminal-links.test.ts | 28 ++ src/renderer/src/lib/terminal-links.ts | 277 ++++-------------- .../terminal-file-url-target.test.ts | 0 .../terminal-file-url-target.ts | 2 +- 10 files changed, 438 insertions(+), 228 deletions(-) create mode 100644 src/renderer/src/lib/terminal-bare-file-link-detection.ts create mode 100644 src/renderer/src/lib/terminal-file-link-detection-ranges.ts create mode 100644 src/renderer/src/lib/terminal-file-uri-link.test.ts create mode 100644 src/renderer/src/lib/terminal-file-uri-link.ts rename src/{renderer/src/components/terminal-pane => shared}/terminal-file-url-target.test.ts (100%) rename src/{renderer/src/components/terminal-pane => shared}/terminal-file-url-target.ts (95%) diff --git a/config/max-lines-baseline.txt b/config/max-lines-baseline.txt index 543ff2e6a60..be7d1cd9080 100644 --- a/config/max-lines-baseline.txt +++ b/config/max-lines-baseline.txt @@ -280,7 +280,6 @@ inline src/renderer/src/lib/pane-manager/pane-manager.ts inline src/renderer/src/lib/pane-manager/pane-terminal-output-scheduler.test.ts inline src/renderer/src/lib/pane-manager/pane-terminal-output-scheduler.ts inline src/renderer/src/lib/pane-manager/pane-tree-ops.ts -inline src/renderer/src/lib/terminal-links.ts inline src/renderer/src/lib/worktree-activation.test.ts inline src/renderer/src/lib/worktree-activation.ts inline src/renderer/src/runtime/remote-runtime-terminal-multiplexer.ts diff --git a/src/renderer/src/components/terminal-pane/terminal-osc-link-routing.ts b/src/renderer/src/components/terminal-pane/terminal-osc-link-routing.ts index 2b043d648cf..cdea515c945 100644 --- a/src/renderer/src/components/terminal-pane/terminal-osc-link-routing.ts +++ b/src/renderer/src/components/terminal-pane/terminal-osc-link-routing.ts @@ -1,7 +1,7 @@ import { resolveTerminalFileLinkText } from '@/lib/terminal-links' import { isWindowsAbsolutePathLike } from '../../../../shared/cross-platform-path' import type { LinkHandlerDeps } from './terminal-link-handlers' -import { resolveTerminalFileUrlTarget } from './terminal-file-url-target' +import { resolveTerminalFileUrlTarget } from '../../../../shared/terminal-file-url-target' import { openDetectedFilePath } from './terminal-file-open-routing' import { isTerminalLinkActivation } from './terminal-link-activation' import { diff --git a/src/renderer/src/lib/terminal-bare-file-link-detection.ts b/src/renderer/src/lib/terminal-bare-file-link-detection.ts new file mode 100644 index 00000000000..364ebaffd6c --- /dev/null +++ b/src/renderer/src/lib/terminal-bare-file-link-detection.ts @@ -0,0 +1,68 @@ +import { + detectTerminalFileLinkRanges, + terminalFileLinkRangesOverlap, + toParsedTerminalFileLink +} from './terminal-file-link-detection-ranges' +import type { ParsedTerminalFileLink } from './terminal-links' + +// Mirrors VSCode's terminal word separators, with `:` handled by the existing +// line/column suffix parser instead of acting as a raw separator. +const WORD_TOKEN_REGEX = /[^\s()[\]{}'",;<>|`]+/g + +const EXTENSIONLESS_FILENAMES = new Set([ + 'Makefile', + 'Dockerfile', + 'Rakefile', + 'Gemfile', + 'Procfile', + 'LICENSE', + 'README', + 'CHANGELOG', + 'AUTHORS', + 'NOTICE', + 'CONTRIBUTING' +]) + +const BARE_FILENAME_PATTERN = /^[A-Za-z0-9_][A-Za-z0-9._+-]*$/ +const MAX_BARE_FILENAME_TOKEN_LENGTH = 120 + +function looksLikeFilename(token: string): boolean { + if (token.length < 2 || token.length > 100) { + return false + } + if (!BARE_FILENAME_PATTERN.test(token)) { + return false + } + if (/^\d+$/.test(token)) { + return false + } + if (token.includes('.')) { + return !/^\.+$/.test(token) + } + return EXTENSIONLESS_FILENAMES.has(token) +} + +// Bare words are filesystem-validated by the provider, so reject obvious prose +// before paying for a stat while retaining common extensionless project files. +export function detectBareFilenameLinks( + lineText: string, + claimedRanges: readonly [number, number][] +): ParsedTerminalFileLink[] { + const links: ParsedTerminalFileLink[] = [] + for (const range of detectTerminalFileLinkRanges(lineText, WORD_TOKEN_REGEX)) { + if (terminalFileLinkRangesOverlap(range, claimedRanges)) { + continue + } + // Why: huge terminal blobs can be one unbroken token; parse only bounded + // bare-filename candidates so hover link detection stays interactive. + if (range.text.length > MAX_BARE_FILENAME_TOKEN_LENGTH) { + continue + } + const link = toParsedTerminalFileLink(range) + if (!link || !looksLikeFilename(link.pathText)) { + continue + } + links.push(link) + } + return links +} diff --git a/src/renderer/src/lib/terminal-file-link-detection-ranges.ts b/src/renderer/src/lib/terminal-file-link-detection-ranges.ts new file mode 100644 index 00000000000..304a76482bc --- /dev/null +++ b/src/renderer/src/lib/terminal-file-link-detection-ranges.ts @@ -0,0 +1,126 @@ +import { parseExplicitFileLinkTarget } from './explicit-file-link-target' +import type { ParsedTerminalFileLink } from './terminal-links' + +const LEADING_TRIM_CHARS = new Set(['(', '[', '{', '"', "'"]) +const TRAILING_TRIM_CHARS = new Set([')', ']', '}', '"', "'", ',', ';', '.']) + +export type DetectedTerminalFileLinkRange = { + startIndex: number + endIndex: number + text: string +} + +function trimBoundaryPunctuation( + value: string, + startIndex: number +): DetectedTerminalFileLinkRange | null { + let start = 0 + let end = value.length + + while (start < end && LEADING_TRIM_CHARS.has(value[start])) { + start += 1 + } + while (end > start && TRAILING_TRIM_CHARS.has(value[end - 1])) { + end -= 1 + } + + if (start >= end) { + return null + } + + return { + text: value.slice(start, end), + startIndex: startIndex + start, + endIndex: startIndex + end + } +} + +export function* detectTerminalFileLinkRanges( + lineText: string, + regex: RegExp +): Generator { + for (const match of lineText.matchAll(regex)) { + const rawStart = match.index ?? 0 + const trimmed = trimBoundaryPunctuation(match[0], rawStart) + if (trimmed) { + yield trimmed + } + } +} + +export function mergeTerminalFileLinkRanges(ranges: [number, number][]): [number, number][] { + if (ranges.length <= 1) { + return ranges + } + const sorted = ranges.slice().sort((left, right) => left[0] - right[0] || left[1] - right[1]) + const merged: [number, number][] = [] + for (const range of sorted) { + const last = merged.at(-1) + if (!last || range[0] > last[1]) { + merged.push([range[0], range[1]]) + continue + } + last[1] = Math.max(last[1], range[1]) + } + return merged +} + +export function terminalFileLinkRangesOverlap( + range: DetectedTerminalFileLinkRange, + claimedRanges: readonly [number, number][] +): boolean { + // Why: generated terminal lines can contain thousands of file-looking tokens; + // overlap checks must stay logarithmic instead of scanning every prior range. + let low = 0 + let high = claimedRanges.length + while (low < high) { + const mid = Math.floor((low + high) / 2) + if (claimedRanges[mid][0] < range.endIndex) { + low = mid + 1 + } else { + high = mid + } + } + const previous = claimedRanges[low - 1] + return previous !== undefined && previous[1] > range.startIndex +} + +export function insertTerminalFileLinkClaimedRange( + claimedRanges: [number, number][], + range: [number, number] +): void { + const last = claimedRanges.at(-1) + if (!last || last[0] <= range[0]) { + claimedRanges.push(range) + return + } + + let low = 0 + let high = claimedRanges.length + while (low < high) { + const mid = Math.floor((low + high) / 2) + if (claimedRanges[mid][0] <= range[0]) { + low = mid + 1 + } else { + high = mid + } + } + claimedRanges.splice(low, 0, range) +} + +export function toParsedTerminalFileLink( + range: DetectedTerminalFileLinkRange +): ParsedTerminalFileLink | null { + const parsed = parseExplicitFileLinkTarget(range.text) + if (!parsed) { + return null + } + return { + pathText: parsed.pathText, + line: parsed.line, + column: parsed.column, + startIndex: range.startIndex, + endIndex: range.endIndex, + displayText: range.text + } +} diff --git a/src/renderer/src/lib/terminal-file-uri-link.test.ts b/src/renderer/src/lib/terminal-file-uri-link.test.ts new file mode 100644 index 00000000000..1e8504f69d3 --- /dev/null +++ b/src/renderer/src/lib/terminal-file-uri-link.test.ts @@ -0,0 +1,67 @@ +import { describe, expect, it } from 'vitest' +import { detectTerminalFileUriLinks } from './terminal-file-uri-link' + +describe('detectTerminalFileUriLinks', () => { + it('decodes a localhost-less file URI to an absolute path', () => { + const line = 'Report: file:///Users/dev/orca/report.html' + const [link] = detectTerminalFileUriLinks(line) + expect(link).toMatchObject({ + pathText: '/Users/dev/orca/report.html', + line: null, + column: null + }) + expect(line.slice(link.startIndex, link.endIndex)).toBe('file:///Users/dev/orca/report.html') + expect(link.displayText).toBe('file:///Users/dev/orca/report.html') + }) + + it('percent-decodes spaces in the path', () => { + const [link] = detectTerminalFileUriLinks('open file:///Users/dev/My%20Reports/out.html now') + expect(link.pathText).toBe('/Users/dev/My Reports/out.html') + }) + + it('keeps standard unescaped parentheses and apostrophes inside the path', () => { + const uri = "file:///tmp/Brennan's%20Report%20(final)" + const [link] = detectTerminalFileUriLinks(`open (${uri})`) + expect(link.pathText).toBe("/tmp/Brennan's Report (final)") + expect(link.displayText).toBe(uri) + }) + + it('carries a :line:col suffix', () => { + const [link] = detectTerminalFileUriLinks('file:///Users/dev/app.ts:12:3') + expect(link).toMatchObject({ pathText: '/Users/dev/app.ts', line: 12, column: 3 }) + }) + + it('carries an #Lline anchor', () => { + const [link] = detectTerminalFileUriLinks('file:///Users/dev/app.ts#L42') + expect(link).toMatchObject({ pathText: '/Users/dev/app.ts', line: 42, column: null }) + }) + + it('strips the WHATWG leading slash before a Windows drive path', () => { + const [link] = detectTerminalFileUriLinks('file:///C:/Users/dev/report.html') + expect(link.pathText).toBe('C:/Users/dev/report.html') + }) + + it('trims trailing sentence punctuation but keeps the extension', () => { + const [link] = detectTerminalFileUriLinks('see file:///tmp/out.html.') + expect(link.displayText).toBe('file:///tmp/out.html') + expect(link.pathText).toBe('/tmp/out.html') + }) + + it('rejects remote hosts (existence probing handles the path otherwise)', () => { + expect(detectTerminalFileUriLinks('file://build-server/var/log/out.txt')).toEqual([]) + }) + + it('ignores non-file schemes and malformed escapes', () => { + expect(detectTerminalFileUriLinks('https://example.com/x.html')).toEqual([]) + expect(detectTerminalFileUriLinks('file:///tmp/%E0%A4%A.txt')).toEqual([]) + }) + + it('finds multiple file URIs on one line', () => { + const links = detectTerminalFileUriLinks('a file:///tmp/a.txt b file:///tmp/b.txt') + expect(links.map((link) => link.pathText)).toEqual(['/tmp/a.txt', '/tmp/b.txt']) + }) + + it('does not expose oversized terminal tokens to filesystem probing', () => { + expect(detectTerminalFileUriLinks(`file:///tmp/${'a'.repeat(10_000)}`)).toEqual([]) + }) +}) diff --git a/src/renderer/src/lib/terminal-file-uri-link.ts b/src/renderer/src/lib/terminal-file-uri-link.ts new file mode 100644 index 00000000000..d9751ba24cf --- /dev/null +++ b/src/renderer/src/lib/terminal-file-uri-link.ts @@ -0,0 +1,95 @@ +import { resolveTerminalFileUrlTarget } from '../../../shared/terminal-file-url-target' +import type { ParsedTerminalFileLink } from './terminal-links' + +// Why: plain-text file URIs bypass both the HTTP and local-path detectors; use +// the OSC 8 resolver so both terminal representations open identically. + +const MAX_FILE_URI_LENGTH = 2048 +// Why: extraction runs on hover; cap before URL parsing and filesystem probes +// so a file:// prefix in a huge dumped token cannot block the renderer. +const FILE_URI_REGEX = /\bfile:\/\/[^\s"`<>|]{1,2049}/gi + +const TRAILING_PROSE_CHARS = new Set(['.', ',', ';', ':', '!', '?', '>', '"', "'", '`']) + +function trimTrailingProse(uriText: string): string { + let parentheses = 0 + let brackets = 0 + let braces = 0 + for (const char of uriText) { + parentheses += char === ')' ? 1 : char === '(' ? -1 : 0 + brackets += char === ']' ? 1 : char === '[' ? -1 : 0 + braces += char === '}' ? 1 : char === '{' ? -1 : 0 + } + + let end = uriText.length + while (end > 0) { + const char = uriText[end - 1] + if (TRAILING_PROSE_CHARS.has(char)) { + end -= 1 + continue + } + // Why: standard file URIs leave parentheses unescaped; trim only closing + // delimiters supplied by surrounding prose, not balanced filename text. + if (char === ')' && parentheses > 0) { + parentheses -= 1 + end -= 1 + continue + } + if (char === ']' && brackets > 0) { + brackets -= 1 + end -= 1 + continue + } + if (char === '}' && braces > 0) { + braces -= 1 + end -= 1 + continue + } + break + } + return uriText.slice(0, end) +} + +// Remote hosts are rejected: on a local pane a hostname'd URI would resolve to a +// path that does not exist, and the provider's existence probe would drop it +// anyway. Windows UNC support stays with the OSC path, which has the platform +// context this pure pass deliberately avoids. +function toFileUriLink(uriText: string, startIndex: number): ParsedTerminalFileLink | null { + let url: URL + try { + url = new URL(uriText) + } catch { + return null + } + const target = resolveTerminalFileUrlTarget(url) + if (!target) { + return null + } + return { + pathText: target.filePath, + line: target.line, + column: target.column, + startIndex, + endIndex: startIndex + uriText.length, + displayText: uriText + } +} + +export function detectTerminalFileUriLinks(lineText: string): ParsedTerminalFileLink[] { + const links: ParsedTerminalFileLink[] = [] + for (const match of lineText.matchAll(FILE_URI_REGEX)) { + const startIndex = match.index ?? 0 + if (match[0].length > MAX_FILE_URI_LENGTH) { + continue + } + const trimmed = trimTrailingProse(match[0]) + if (!trimmed) { + continue + } + const link = toFileUriLink(trimmed, startIndex) + if (link) { + links.push(link) + } + } + return links +} diff --git a/src/renderer/src/lib/terminal-links.test.ts b/src/renderer/src/lib/terminal-links.test.ts index f6aa3501a0a..66ff8d870a8 100644 --- a/src/renderer/src/lib/terminal-links.test.ts +++ b/src/renderer/src/lib/terminal-links.test.ts @@ -5,6 +5,7 @@ import { columnForTerminalFileLinkTap } from '../../../shared/terminal-file-link-conformance' import { + extractTerminalFileLinkCandidates, extractTerminalFileLinks, isPathInsideWorktree, resolveTerminalFileLink, @@ -316,4 +317,31 @@ describe('terminal path helpers', () => { it('does not resolve partial text as an OSC hyperlink target', () => { expect(resolveTerminalFileLinkText('open docs/README.md', '/repo')).toBeNull() }) + + describe('plain-text file:// URIs', () => { + it('extracts a printed file:// URI as a file link resolving to its path', () => { + const line = 'Report: file:///Users/dev/orca/report.html' + const link = extractTerminalFileLinks(line).find( + (candidate) => candidate.displayText === 'file:///Users/dev/orca/report.html' + ) + expect(link).toMatchObject({ pathText: '/Users/dev/orca/report.html' }) + expect(resolveTerminalFileLink(link!, '/Users/dev/orca')).toEqual({ + absolutePath: '/Users/dev/orca/report.html', + line: null, + column: null + }) + }) + + it('does not also emit a bare-path link for the URI body', () => { + const links = extractTerminalFileLinks('file:///Users/dev/orca/report.html') + expect(links.map((link) => link.displayText)).toEqual(['file:///Users/dev/orca/report.html']) + }) + + it('exposes file:// URIs to the hover candidate pass as well', () => { + const candidates = extractTerminalFileLinkCandidates('file:///tmp/out.txt:9') + expect(candidates.some((link) => link.pathText === '/tmp/out.txt' && link.line === 9)).toBe( + true + ) + }) + }) }) diff --git a/src/renderer/src/lib/terminal-links.ts b/src/renderer/src/lib/terminal-links.ts index 605360e4245..729f2d839e7 100644 --- a/src/renderer/src/lib/terminal-links.ts +++ b/src/renderer/src/lib/terminal-links.ts @@ -1,9 +1,15 @@ -/* eslint-disable max-lines -- Why: terminal link parsing depends on ordered passes sharing range state. */ import { normalizeAbsolutePath } from './terminal-path-normalization' +import { resolveExplicitFileLinkTarget } from './explicit-file-link-target' +import { detectBareFilenameLinks } from './terminal-bare-file-link-detection' import { - parseExplicitFileLinkTarget, - resolveExplicitFileLinkTarget -} from './explicit-file-link-target' + detectTerminalFileLinkRanges, + insertTerminalFileLinkClaimedRange, + mergeTerminalFileLinkRanges, + terminalFileLinkRangesOverlap, + toParsedTerminalFileLink, + type DetectedTerminalFileLinkRange +} from './terminal-file-link-detection-ranges' +import { detectTerminalFileUriLinks } from './terminal-file-uri-link' export type ParsedTerminalFileLink = { pathText: string @@ -52,63 +58,7 @@ const SPACED_LOCAL_PATH_REGEXES = [ LINE_ENDING_SPACED_PATH_REGEX ] -// Word separators used by the bare-filename pass. Mirrors the default set in -// VSCode's `terminal.integrated.wordSeparators` with the exception that we -// include `:` indirectly via the line:col suffix parser rather than as a -// raw separator. A word is any maximal run of non-separator characters. -// \s matches NBSP in modern JS; xterm powerline glyphs are in the PUA and -// never appear in filenames, so we don't list them explicitly. -const WORD_TOKEN_REGEX = /[^\s()[\]{}'",;<>|`]+/g - -const LEADING_TRIM_CHARS = new Set(['(', '[', '{', '"', "'"]) -const TRAILING_TRIM_CHARS = new Set([')', ']', '}', '"', "'", ',', ';', '.']) - -function trimBoundaryPunctuation( - value: string, - startIndex: number -): { text: string; startIndex: number; endIndex: number } | null { - let start = 0 - let end = value.length - - while (start < end && LEADING_TRIM_CHARS.has(value[start])) { - start += 1 - } - while (end > start && TRAILING_TRIM_CHARS.has(value[end - 1])) { - end -= 1 - } - - if (start >= end) { - return null - } - - return { - text: value.slice(start, end), - startIndex: startIndex + start, - endIndex: startIndex + end - } -} - -// Project files that look like filenames despite having no extension. The -// word detector otherwise requires a `.` in the token to keep noise down — -// without this list, `ls` output containing `Makefile` or `LICENSE` would -// not be clickable. -const EXTENSIONLESS_FILENAMES = new Set([ - 'Makefile', - 'Dockerfile', - 'Rakefile', - 'Gemfile', - 'Procfile', - 'LICENSE', - 'README', - 'CHANGELOG', - 'AUTHORS', - 'NOTICE', - 'CONTRIBUTING' -]) - -const BARE_FILENAME_PATTERN = /^[A-Za-z0-9_][A-Za-z0-9._+-]*$/ const URI_PREFIX_CHAR_PATTERN = /^[A-Za-z0-9+./:-]$/ -const MAX_BARE_FILENAME_TOKEN_LENGTH = 120 function hasPathSeparator(text: string): boolean { return text.includes('/') || text.includes('\\') @@ -147,41 +97,6 @@ function hasSpacedPathExtension(text: string): boolean { return /\s/.test(trimmedText) && /\.[A-Za-z0-9_+-]+(?::\d+)?(?::\d+)?$/.test(trimmedText) } -// Bare words are validated against the filesystem by the provider, so this -// filter's job is to reject tokens that are obviously not filenames before -// we pay for a stat. Plain words like `src` or `my-cli` are usually -// directories or binaries and produce more noise than value — users who -// really want to open them can prefix with `./`. -function looksLikeFilename(token: string): boolean { - if (token.length < 2 || token.length > 100) { - return false - } - if (!BARE_FILENAME_PATTERN.test(token)) { - return false - } - if (/^\d+$/.test(token)) { - return false - } - if (token.includes('.')) { - return !/^\.+$/.test(token) - } - return EXTENSIONLESS_FILENAMES.has(token) -} - -type DetectedRange = { startIndex: number; endIndex: number; text: string } -// Shared tokenization: run a regex over the line, trim boundary punctuation, -// hand each surviving range to the caller. Collapses the three near-copies -// of this loop the module had grown. -function* detectRanges(lineText: string, regex: RegExp): Generator { - for (const match of lineText.matchAll(regex)) { - const rawStart = match.index ?? 0 - const trimmed = trimBoundaryPunctuation(match[0], rawStart) - if (trimmed) { - yield trimmed - } - } -} - function getImmediateUriPrefix(lineText: string, endIndex: number): string { let start = endIndex while (start > 0 && URI_PREFIX_CHAR_PATTERN.test(lineText[start - 1])) { @@ -190,7 +105,7 @@ function getImmediateUriPrefix(lineText: string, endIndex: number): string { return lineText.slice(start, endIndex) } -function isInsideUriScheme(lineText: string, range: DetectedRange): boolean { +function isInsideUriScheme(lineText: string, range: DetectedTerminalFileLinkRange): boolean { const prefix = getImmediateUriPrefix(lineText, range.startIndex) // Why: local-path matching can start at the `//host/path` portion of a URL. return ( @@ -200,61 +115,9 @@ function isInsideUriScheme(lineText: string, range: DetectedRange): boolean { ) } -function mergeRanges(ranges: [number, number][]): [number, number][] { - if (ranges.length <= 1) { - return ranges - } - const sorted = ranges.slice().sort((left, right) => left[0] - right[0] || left[1] - right[1]) - const merged: [number, number][] = [] - for (const range of sorted) { - const last = merged.at(-1) - if (!last || range[0] > last[1]) { - merged.push([range[0], range[1]]) - continue - } - last[1] = Math.max(last[1], range[1]) - } - return merged -} - -function rangesOverlap(range: DetectedRange, claimedRanges: readonly [number, number][]): boolean { - // Why: generated terminal lines can contain thousands of file-looking tokens; - // overlap checks must stay logarithmic instead of scanning every prior range. - let low = 0 - let high = claimedRanges.length - while (low < high) { - const mid = Math.floor((low + high) / 2) - if (claimedRanges[mid][0] < range.endIndex) { - low = mid + 1 - } else { - high = mid - } - } - const previous = claimedRanges[low - 1] - return previous !== undefined && previous[1] > range.startIndex -} - -function insertClaimedRange(claimedRanges: [number, number][], range: [number, number]): void { - const last = claimedRanges.at(-1) - if (!last || last[0] <= range[0]) { - claimedRanges.push(range) - return - } - - let low = 0 - let high = claimedRanges.length - while (low < high) { - const mid = Math.floor((low + high) / 2) - if (claimedRanges[mid][0] <= range[0]) { - low = mid + 1 - } else { - high = mid - } - } - claimedRanges.splice(low, 0, range) -} - -function trimSpacedPathTrailingProse(range: DetectedRange): DetectedRange { +function trimSpacedPathTrailingProse( + range: DetectedTerminalFileLinkRange +): DetectedTerminalFileLinkRange { // Why: keep one extension-terminated path, but drop trailing prose or a // second unrelated path that the broad spaced-path scan also captured. A // line-end extension token only extends the span when the added segment is @@ -296,7 +159,9 @@ function countPathStarts(text: string): number { return count } -function trimTrailingWhitespace(range: DetectedRange): DetectedRange { +function trimTrailingWhitespace( + range: DetectedTerminalFileLinkRange +): DetectedTerminalFileLinkRange { const text = range.text.trimEnd() return { text, @@ -305,8 +170,10 @@ function trimTrailingWhitespace(range: DetectedRange): DetectedRange { } } -function buildLineEndingSpacedPathPrefixRanges(range: DetectedRange): DetectedRange[] { - const ranges: DetectedRange[] = [] +function buildLineEndingSpacedPathPrefixRanges( + range: DetectedTerminalFileLinkRange +): DetectedTerminalFileLinkRange[] { + const ranges: DetectedTerminalFileLinkRange[] = [] for (const match of range.text.matchAll(/\s+/g)) { const endIndex = match.index ?? 0 const text = range.text.slice(0, endIndex).trimEnd() @@ -321,25 +188,6 @@ function buildLineEndingSpacedPathPrefixRanges(range: DetectedRange): DetectedRa return ranges.toReversed() } -function toParsedLink(range: DetectedRange): ParsedTerminalFileLink | null { - const parsed = parseExplicitFileLinkTarget(range.text) - if (!parsed) { - return null - } - return { - pathText: parsed.pathText, - line: parsed.line, - column: parsed.column, - startIndex: range.startIndex, - endIndex: range.endIndex, - displayText: range.text - } -} - -function sortLinksByPosition(links: ParsedTerminalFileLink[]): ParsedTerminalFileLink[] { - return links.sort((a, b) => a.startIndex - b.startIndex || b.endIndex - a.endIndex) -} - // Ported from VSCode's TerminalLocalLinkDetector. Extracts anything that // contains a path separator, optionally with a `:line:col` suffix — covers // `./src/foo.ts`, `/abs/bar`, `src/foo.ts:12:3`, etc. @@ -353,14 +201,14 @@ function detectLocalPathLinks( const links: ParsedTerminalFileLink[] = [] const spacedLinks = detectSpacedLocalPathLinks(lineText, includeLineEndingPrefixCandidates) - const spacedRanges = mergeRanges( + const spacedRanges = mergeTerminalFileLinkRanges( spacedLinks.map(({ startIndex, endIndex }): [number, number] => [startIndex, endIndex]) ) for (const link of spacedLinks) { links.push(link) } - for (const range of detectRanges(lineText, LOCAL_PATH_REGEX)) { - if (rangesOverlap(range, spacedRanges)) { + for (const range of detectTerminalFileLinkRanges(lineText, LOCAL_PATH_REGEX)) { + if (terminalFileLinkRangesOverlap(range, spacedRanges)) { continue } if (isInsideUriScheme(lineText, range)) { @@ -369,12 +217,12 @@ function detectLocalPathLinks( if (!/[\\/]/.test(range.text)) { continue } - const link = toParsedLink(range) + const link = toParsedTerminalFileLink(range) if (link) { links.push(link) } } - return sortLinksByPosition(links) + return links.sort((a, b) => a.startIndex - b.startIndex || b.endIndex - a.endIndex) } function detectSpacedLocalPathLinks( @@ -384,7 +232,7 @@ function detectSpacedLocalPathLinks( const links: ParsedTerminalFileLink[] = [] const claimedRanges: [number, number][] = [] for (const regex of SPACED_LOCAL_PATH_REGEXES) { - for (const range of detectRanges(lineText, regex)) { + for (const range of detectTerminalFileLinkRanges(lineText, regex)) { if (regex === SPACED_PATH_WITH_SEPARATOR_REGEX && !hasSeparatorAfterWhitespace(range.text)) { continue } @@ -398,7 +246,10 @@ function detectSpacedLocalPathLinks( ) { continue } - if (rangesOverlap(range, claimedRanges) || isInsideUriScheme(lineText, range)) { + if ( + terminalFileLinkRangesOverlap(range, claimedRanges) || + isInsideUriScheme(lineText, range) + ) { continue } const candidateRanges = @@ -407,7 +258,9 @@ function detectSpacedLocalPathLinks( : [range] const candidateLinks = candidateRanges .map((candidateRange) => - toParsedLink(trimSpacedPathTrailingProse(trimTrailingWhitespace(candidateRange))) + toParsedTerminalFileLink( + trimSpacedPathTrailingProse(trimTrailingWhitespace(candidateRange)) + ) ) .filter((link): link is ParsedTerminalFileLink => link !== null) const link = candidateLinks[0] @@ -415,65 +268,39 @@ function detectSpacedLocalPathLinks( for (const candidateLink of candidateLinks) { links.push(candidateLink) } - insertClaimedRange(claimedRanges, [link.startIndex, link.endIndex]) + insertTerminalFileLinkClaimedRange(claimedRanges, [link.startIndex, link.endIndex]) } } } return links } -// Ported from VSCode's TerminalWordLinkDetector. Tokenizes the line on -// separators and emits filename-ish words so `ls` output becomes clickable. -// Skips ranges already claimed by the local-path pass to avoid double links -// when a bare filename happens to be a substring of a longer path. -function detectBareFilenameLinks( +// Runs the file-uri, local-path, and bare-filename passes in that precedence. +// `file://` and separator paths claim their ranges first so the bare-filename +// pass never re-links a token already covered by a longer explicit link. +function assembleFileLinks( lineText: string, - claimedRanges: readonly [number, number][] + includeLineEndingPrefixCandidates: boolean ): ParsedTerminalFileLink[] { - const links: ParsedTerminalFileLink[] = [] - for (const range of detectRanges(lineText, WORD_TOKEN_REGEX)) { - if (rangesOverlap(range, claimedRanges)) { - continue - } - // Why: huge terminal blobs can be one unbroken token; parse only bounded - // bare-filename candidates so hover link detection stays interactive. - if (range.text.length > MAX_BARE_FILENAME_TOKEN_LENGTH) { - continue - } - const link = toParsedLink(range) - if (!link) { - continue - } - if (!looksLikeFilename(link.pathText)) { - continue - } - links.push(link) + const uriLinks = detectTerminalFileUriLinks(lineText) + const pathLinks = detectLocalPathLinks(lineText, includeLineEndingPrefixCandidates) + const explicitLinks = uriLinks.length > 0 ? [...uriLinks, ...pathLinks] : pathLinks + const claimed = mergeTerminalFileLinkRanges( + explicitLinks.map(({ startIndex, endIndex }): [number, number] => [startIndex, endIndex]) + ) + const wordLinks = detectBareFilenameLinks(lineText, claimed) + for (const link of wordLinks) { + explicitLinks.push(link) } - return links + return explicitLinks } export function extractTerminalFileLinks(lineText: string): ParsedTerminalFileLink[] { - const pathLinks = detectLocalPathLinks(lineText) - const claimed = mergeRanges( - pathLinks.map(({ startIndex, endIndex }): [number, number] => [startIndex, endIndex]) - ) - const wordLinks = detectBareFilenameLinks(lineText, claimed) - for (const link of wordLinks) { - pathLinks.push(link) - } - return pathLinks + return assembleFileLinks(lineText, false) } export function extractTerminalFileLinkCandidates(lineText: string): ParsedTerminalFileLink[] { - const pathLinks = detectLocalPathLinks(lineText, true) - const claimed = mergeRanges( - pathLinks.map(({ startIndex, endIndex }): [number, number] => [startIndex, endIndex]) - ) - const wordLinks = detectBareFilenameLinks(lineText, claimed) - for (const link of wordLinks) { - pathLinks.push(link) - } - return pathLinks + return assembleFileLinks(lineText, true) } export function resolveTerminalFileLink( diff --git a/src/renderer/src/components/terminal-pane/terminal-file-url-target.test.ts b/src/shared/terminal-file-url-target.test.ts similarity index 100% rename from src/renderer/src/components/terminal-pane/terminal-file-url-target.test.ts rename to src/shared/terminal-file-url-target.test.ts diff --git a/src/renderer/src/components/terminal-pane/terminal-file-url-target.ts b/src/shared/terminal-file-url-target.ts similarity index 95% rename from src/renderer/src/components/terminal-pane/terminal-file-url-target.ts rename to src/shared/terminal-file-url-target.ts index f21b31ed7c4..941023181ee 100644 --- a/src/renderer/src/components/terminal-pane/terminal-file-url-target.ts +++ b/src/shared/terminal-file-url-target.ts @@ -1,4 +1,4 @@ -import { fileUriToFilesystemPath } from '../../../../shared/file-uri-path' +import { fileUriToFilesystemPath } from './file-uri-path' export type TerminalFileUrlTarget = { filePath: string