mirror of
https://github.com/stablyai/orca.git
synced 2026-09-22 00:02:31 +00:00
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:
@@ -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
|
||||
)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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
-1
@@ -1,4 +1,4 @@
|
||||
import { fileUriToFilesystemPath } from '../../../../shared/file-uri-path'
|
||||
import { fileUriToFilesystemPath } from './file-uri-path'
|
||||
|
||||
export type TerminalFileUrlTarget = {
|
||||
filePath: string
|
||||
Reference in New Issue
Block a user