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
This commit is contained in:
Brennan Benson
2026-07-19 15:21:06 -07:00
committed by GitHub
parent 764cadafc7
commit 624c8d4120
10 changed files with 438 additions and 228 deletions
-1
View File
@@ -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
@@ -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 {
@@ -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
}
@@ -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<DetectedTerminalFileLinkRange> {
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
}
}
@@ -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([])
})
})
@@ -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
}
@@ -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
)
})
})
})
+52 -225
View File
@@ -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<DetectedRange> {
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(
@@ -1,4 +1,4 @@
import { fileUriToFilesystemPath } from '../../../../shared/file-uri-path'
import { fileUriToFilesystemPath } from './file-uri-path'
export type TerminalFileUrlTarget = {
filePath: string