mirror of
https://github.com/stablyai/orca.git
synced 2026-10-30 16:47:34 +00:00
fix(terminal): let file existence pick spaced path readings so prose cannot hide a path (#34281)
* fix(terminal): let file existence pick spaced path readings so prose cannot hide a path * refactor(terminal): let the host's file check decide spaced paths and replay phone taps that name no file Spaced readings no longer guess where a path ends: a rooted path offers one reading per following word (up to ten), and a relative path grows only to its first filename so dense path lists stay cheap. A phone tap whose readings name no file replays the ordinary tap instead of reporting a failed open, and checks up to 16 readings at once. * fix(terminal): drop a phone tap's replay after a newer tap, and accept digit-led extensions * fix(terminal): drop a phone tap's replay after typing, URL taps or selection, and accept Unicode extensions * refactor(mobile): decide terminal file taps from the text under the finger instead of replaying withheld taps A tap is a file tap only when it lands on a path itself; prose beside a path stays an ordinary tap at once. This removes the withheld-tap fallback and its stale-tap counters. * refactor(terminal): return spaced path guesses apart from written paths so only a host check can act on a guess Hover and chat check every reading as before. A click opens a guess only after the host finds its file, and never past an external share. A phone tap is a file tap only when a written path is under the finger, so a markdown destination with spaces counts. OSC 8 targets are parsed as written. Phone file checks return to 8 at a time. * fix(terminal): leave a click on prose beside a path to the terminal unless a written path or a confirmed guess covers it * fix(terminal): fall back to the shortest checked written path when a click's file checks fail * docs(terminal): say relative paths grow only to their first filename
This commit is contained in:
@@ -39,10 +39,10 @@ export function notifyTerminalSurfaceTap(
|
||||
notify(scope, { type: 'open-url', url })
|
||||
return
|
||||
}
|
||||
const candidates = uniqueFileTapCandidates(
|
||||
findTerminalFileLinksAtBufferPosition(buffer, position, term.cols)
|
||||
)
|
||||
if (candidates.length > 0) {
|
||||
const { links, guesses } = findTerminalFileLinksAtBufferPosition(buffer, position, term.cols)
|
||||
// Why: a tap decides now, so only a path as written makes it a file tap; the host checks the guesses.
|
||||
if (links.length > 0) {
|
||||
const candidates = uniqueFileTapCandidates([...links, ...guesses])
|
||||
notify(scope, { type: 'terminal-file-tap', candidates })
|
||||
return
|
||||
}
|
||||
|
||||
@@ -267,6 +267,32 @@ describe('terminal WebView tap routing', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('keeps a tap on prose beside a path an ordinary tap', async () => {
|
||||
const line = 'me@host:~/proj$ git status'
|
||||
const { posted } = boot(line)
|
||||
await settle()
|
||||
|
||||
fireTouch('touchstart', [{ x: screenXForCol(line.indexOf('git') + 1), y: tapY }])
|
||||
fireTouch('touchend', [])
|
||||
|
||||
expect(posted.find((m) => m.type === 'terminal-file-tap')).toBeUndefined()
|
||||
expect(posted.find((m) => m.type === 'terminal-tap')).toBeDefined()
|
||||
})
|
||||
|
||||
it('opens a markdown link whose destination has spaces', async () => {
|
||||
const line = 'see [PDF](</tmp/folder with spaces/report.pdf>)'
|
||||
const { posted } = boot(line)
|
||||
await settle()
|
||||
|
||||
fireTouch('touchstart', [{ x: screenXForCol(line.indexOf('with') + 1), y: tapY }])
|
||||
fireTouch('touchend', [])
|
||||
|
||||
expect(posted.find((m) => m.type === 'terminal-file-tap')).toEqual({
|
||||
type: 'terminal-file-tap',
|
||||
candidates: [{ pathText: '/tmp/folder with spaces/report.pdf', line: null, column: null }]
|
||||
})
|
||||
})
|
||||
|
||||
it('opens plain file URLs through terminal file taps', async () => {
|
||||
const line = 'open file:///tmp/result.json#L12C3 now'
|
||||
const { posted } = boot(line)
|
||||
|
||||
@@ -7,9 +7,11 @@ import {
|
||||
parseFileLinkLocation
|
||||
} from '../../../../shared/file-link-location'
|
||||
import {
|
||||
extractTerminalFileLinkReadings,
|
||||
extractTerminalFileLinks,
|
||||
type ParsedTerminalFileLink
|
||||
} from '../../../../shared/terminal-links'
|
||||
import { ROOTED_PATH_START_PATTERN } from '../../../../shared/terminal-file-link-detection-ranges'
|
||||
import { preferLongestNonOverlappingMatches } from '@/lib/longest-non-overlapping-matches'
|
||||
|
||||
type MarkdownNode = {
|
||||
@@ -19,13 +21,11 @@ type MarkdownNode = {
|
||||
children?: MarkdownNode[]
|
||||
}
|
||||
|
||||
const ROOTED_PATH_PREFIX_PATTERN = /^(?:~[\\/]|\.{1,2}[\\/]|[\\/]|[A-Za-z]:[\\/])/
|
||||
|
||||
/** Whether a detected path names a file on the chat's host; the terminal's own check. */
|
||||
export type FileLinkExists = (link: ParsedTerminalFileLink) => boolean
|
||||
|
||||
function looksLikeFilePath(link: ParsedTerminalFileLink, isProse: boolean): boolean {
|
||||
const hasRootedPrefix = ROOTED_PATH_PREFIX_PATTERN.test(link.pathText)
|
||||
const hasRootedPrefix = ROOTED_PATH_START_PATTERN.test(link.pathText)
|
||||
const hasLineSuffix = link.line !== null || link.column !== null
|
||||
const hasAlphabeticExtension = /\.[\p{L}][\p{L}\p{N}\p{M}_+-]*$/u.test(link.pathText)
|
||||
const hasPathExtension = /\.[\p{L}\p{N}][\p{L}\p{N}\p{M}_+-]*$/u.test(link.pathText)
|
||||
@@ -110,54 +110,12 @@ function createFileLinkNode(link: ParsedTerminalFileLink, child: MarkdownNode):
|
||||
}
|
||||
}
|
||||
|
||||
function isTrailingPunctuation(token: string, endIndex: number): boolean {
|
||||
return isSafeTrailingBoundary(token, endIndex) && /^\p{P}+$/u.test(token.slice(endIndex))
|
||||
}
|
||||
|
||||
/** `endsSentence`: a token may end in sentence punctuation, which stays outside the link. */
|
||||
function linkableTokens(
|
||||
link: ParsedTerminalFileLink,
|
||||
endsSentence: boolean
|
||||
): ParsedTerminalFileLink[] {
|
||||
const tokenLinks: ParsedTerminalFileLink[] = []
|
||||
for (const match of link.displayText.matchAll(/\S+/g)) {
|
||||
const token = match[0]
|
||||
const exactLink = extractTerminalFileLinks(token).find(
|
||||
(candidate) =>
|
||||
candidate.startIndex === 0 &&
|
||||
(candidate.endIndex === token.length ||
|
||||
(endsSentence && isTrailingPunctuation(token, candidate.endIndex)))
|
||||
)
|
||||
if (exactLink && isLinkifiableFile(exactLink, true)) {
|
||||
const startIndex = link.startIndex + (match.index ?? 0)
|
||||
tokenLinks.push({ ...exactLink, startIndex, endIndex: startIndex + exactLink.endIndex })
|
||||
}
|
||||
}
|
||||
return tokenLinks
|
||||
}
|
||||
|
||||
// Prose can join paths into one extracted span; split path-like tokens while letting longer real spaced paths win existence checks.
|
||||
function splitProseJoinedLinks(link: ParsedTerminalFileLink): ParsedTerminalFileLink[] {
|
||||
if (!/\s/.test(link.displayText)) {
|
||||
return [link]
|
||||
}
|
||||
if (ROOTED_PATH_PREFIX_PATTERN.test(link.pathText)) {
|
||||
return [link, ...linkableTokens(link, true)]
|
||||
}
|
||||
// Preserve unrooted token punctuation to avoid linking cases the extractor intentionally leaves plain.
|
||||
const tokenLinks = linkableTokens(link, false)
|
||||
const hasBareWord = Array.from(link.displayText.matchAll(/\S+/g)).some(
|
||||
(match) => !/[\\/.]/.test(match[0])
|
||||
)
|
||||
return hasBareWord || tokenLinks.length > 1 ? tokenLinks : [link]
|
||||
}
|
||||
|
||||
function splitTextSegment(value: string, exists: FileLinkExists): MarkdownNode[] {
|
||||
const { links: textLinks, guesses } = extractTerminalFileLinkReadings(value)
|
||||
const links = selectExistingLinks(
|
||||
extractTerminalFileLinks(value)
|
||||
[...textLinks, ...guesses]
|
||||
.filter((link) => !hasPartialPathBoundary(value, link))
|
||||
.filter((link) => isLinkifiableFile(link, true))
|
||||
.flatMap(splitProseJoinedLinks),
|
||||
.filter((link) => isLinkifiableFile(link, true)),
|
||||
exists
|
||||
)
|
||||
if (links.length === 0) {
|
||||
@@ -213,7 +171,7 @@ function exactFileLink(value: string, exists: FileLinkExists): ParsedTerminalFil
|
||||
return null
|
||||
}
|
||||
const looksLikePath =
|
||||
ROOTED_PATH_PREFIX_PATTERN.test(parsed.pathText) ||
|
||||
ROOTED_PATH_START_PATTERN.test(parsed.pathText) ||
|
||||
/[\\/]/.test(parsed.pathText) ||
|
||||
/\.[\p{L}][\p{L}\p{N}\p{M}_+-]*$/u.test(parsed.pathText)
|
||||
if (!looksLikePath) {
|
||||
|
||||
@@ -42,6 +42,8 @@ type ClickedFileLink = FileLinkTarget & {
|
||||
pathText: string
|
||||
cachedExists: boolean | undefined
|
||||
hasOscLink: boolean
|
||||
/** Opens only once the host has found its file. */
|
||||
guess: boolean
|
||||
}
|
||||
|
||||
// Why: a slow probe from an earlier click must not open its file after a later click.
|
||||
@@ -54,25 +56,30 @@ export function openFilePathLinkAtBufferPosition(
|
||||
deps: FileLinkHitTestDeps
|
||||
): boolean {
|
||||
const gesture = ++latestClickGesture
|
||||
const host: FileLinkHost = { ...deps, cwd: deps.startupCwd, homePath: deps.terminalHomePath }
|
||||
const readings = findTerminalFileLinksAtBufferPosition(buffer, position, terminalColumns)
|
||||
const links: ClickedFileLink[] = []
|
||||
for (const parsed of findTerminalFileLinksAtBufferPosition(buffer, position, terminalColumns)) {
|
||||
const target = resolveFileLinkTarget(parsed, {
|
||||
cwd: deps.startupCwd,
|
||||
homePath: deps.terminalHomePath,
|
||||
worktreeId: deps.worktreeId,
|
||||
worktreePath: deps.worktreePath,
|
||||
runtimeEnvironmentId: deps.runtimeEnvironmentId,
|
||||
wslDistro: deps.wslDistro
|
||||
})
|
||||
if (!target) {
|
||||
for (const [parsed, guess] of [
|
||||
...readings.links.map((link) => [link, false] as const),
|
||||
...readings.guesses.map((link) => [link, true] as const)
|
||||
]) {
|
||||
const target = resolveFileLinkTarget(parsed, host)
|
||||
const cachedExists =
|
||||
target && deps.pathExistsCache
|
||||
? readTerminalPathExistence(deps.pathExistsCache, target.cacheKey)
|
||||
: undefined
|
||||
// Why: a guess the host may not be asked about (an external share) can never be confirmed.
|
||||
if (
|
||||
!target ||
|
||||
(guess && cachedExists !== true && !mayCheckFileLinkTargetUnprompted(target, host))
|
||||
) {
|
||||
continue
|
||||
}
|
||||
links.push({
|
||||
...target,
|
||||
pathText: parsed.pathText,
|
||||
cachedExists: deps.pathExistsCache
|
||||
? readTerminalPathExistence(deps.pathExistsCache, target.cacheKey)
|
||||
: undefined,
|
||||
cachedExists,
|
||||
guess,
|
||||
hasOscLink: terminalLogicalLinkHasOscLink(
|
||||
buffer,
|
||||
parsed.logicalLine,
|
||||
@@ -82,8 +89,12 @@ export function openFilePathLinkAtBufferPosition(
|
||||
})
|
||||
}
|
||||
|
||||
// Why: a failure names the path the user sees, not a longer joined guess.
|
||||
const shown = shortestPath(links)
|
||||
// Why: only a written path or a confirmed guess takes the click; prose beside a path stays ordinary text.
|
||||
if (!links.some((link) => !link.guess || link.cachedExists === true)) {
|
||||
return false
|
||||
}
|
||||
// Why: a failure names the path the user sees, never a guess.
|
||||
const shown = shortestPath(links.filter((link) => !link.guess))
|
||||
const open = (link: ClickedFileLink, verified = link.cachedExists === true): void => {
|
||||
const { onOpenFailure } = deps
|
||||
// Why: a bare dotted word (`console.log`, `example.com`) is not a claim that a file exists.
|
||||
@@ -97,8 +108,7 @@ export function openFilePathLinkAtBufferPosition(
|
||||
onOpenFailure: reportsFailure ? (failure) => onOpenFailure(reportedPath, failure) : undefined
|
||||
})
|
||||
}
|
||||
const host: FileLinkHost = { ...deps, cwd: deps.startupCwd, homePath: deps.terminalHomePath }
|
||||
// Never stat external shares unprompted; open the guess as-is and let the host resolve it.
|
||||
// Never stat external shares unprompted; open the path as written and let the host resolve it.
|
||||
const settled = (link: ClickedFileLink): boolean =>
|
||||
link.cachedExists === true ||
|
||||
link.isKnownWorktreeRoot ||
|
||||
@@ -114,19 +124,17 @@ export function openFilePathLinkAtBufferPosition(
|
||||
!settled(link) &&
|
||||
(!best || link.pathText.length > best.pathText.length)
|
||||
)
|
||||
if (toProbe.length === 0 || (!best && toProbe.length === 1)) {
|
||||
// Why the visible path last: a click on a path already known missing still says so.
|
||||
// An OSC 8 hyperlink there is xterm's to open.
|
||||
const knownMissing =
|
||||
shown && !shown.hasOscLink && /[\\/]/.test(shown.pathText) ? shown : undefined
|
||||
const link = best ?? toProbe[0] ?? knownMissing
|
||||
// Why the visible path last: a click on a missing path still says so. An OSC 8 hyperlink there is xterm's to open.
|
||||
const visible = shown && !shown.hasOscLink && /[\\/]/.test(shown.pathText) ? shown : undefined
|
||||
if (toProbe.length === 0 || (!best && toProbe.length === 1 && !toProbe[0].guess)) {
|
||||
const link = best ?? toProbe[0] ?? visible
|
||||
if (link) {
|
||||
open(link)
|
||||
}
|
||||
return link !== undefined
|
||||
}
|
||||
|
||||
// Wait for hover probes before choosing longer hard-wrap guesses; otherwise a bad join can hide the real path.
|
||||
// Probe before choosing a longer hard-wrap or spaced reading; otherwise a bad join can hide the real path.
|
||||
const isLatestOpen = reserveDetectedFilePathOpenTurn()
|
||||
const clickedHost = fileHostFingerprint(toProbe[0].fileContext)
|
||||
void probeClickedFileLinks(toProbe, deps).then((existing) => {
|
||||
@@ -138,8 +146,8 @@ export function openFilePathLinkAtBufferPosition(
|
||||
return
|
||||
}
|
||||
const found = longestPath(existing)
|
||||
// Without a verified candidate, let opening the shortest report the real failure instead of guessing absence.
|
||||
const link = found ?? best ?? shortestPath(toProbe)
|
||||
// Without a verified reading, opening the shortest unchecked path reports the real failure.
|
||||
const link = found ?? best ?? shortestPath(toProbe.filter((link) => !link.guess)) ?? visible
|
||||
if (link) {
|
||||
open(link, link === found || link.cachedExists === true)
|
||||
}
|
||||
|
||||
@@ -1,11 +1,7 @@
|
||||
import { dedupeLogicalLines } from '../../../../shared/terminal-link-logical-line-deduplication'
|
||||
import type { TerminalPathExistsCache } from './terminal-path-exists-cache'
|
||||
import { createTerminalPathExistenceBatch } from './terminal-path-existence-batch'
|
||||
import type { IDisposable, ILink, ILinkProvider, Terminal } from '@xterm/xterm'
|
||||
import {
|
||||
extractTerminalFileLinkCandidates,
|
||||
extractTerminalFileLinks
|
||||
} from '../../../../shared/terminal-links'
|
||||
import { extractTerminalFileLinkReadings } from '../../../../shared/terminal-links'
|
||||
import { preferLongestNonOverlappingMatches } from '@/lib/longest-non-overlapping-matches'
|
||||
import type { PaneManager } from '@/lib/pane-manager/pane-manager'
|
||||
import { buildCandidateLogicalLinesForBufferPosition } from '../../../../shared/terminal-link-buffer-hit-testing'
|
||||
@@ -14,8 +10,6 @@ import { shouldOpenTerminalFileWithSystemDefault } from './terminal-file-open-ro
|
||||
import { isHtmlFilePath, localHtmlFileDestinations } from './local-html-file-destinations'
|
||||
import { fileLinkTargetExists, resolveFileLinkTarget } from './terminal-file-link-target'
|
||||
import {
|
||||
buildHardWrappedPathLogicalLineCandidates,
|
||||
buildWrappedLogicalLine,
|
||||
rangeForParsedFileLink,
|
||||
type WrappedLogicalLine
|
||||
} from '../../../../shared/wrapped-terminal-link-ranges'
|
||||
@@ -95,19 +89,21 @@ export function createFilePathLinkProvider(
|
||||
}
|
||||
|
||||
const buffer = pane.terminal.buffer.active
|
||||
const softWrappedLogicalLine = buildWrappedLogicalLine(buffer, bufferLineNumber)
|
||||
const logicalLines = dedupeLogicalLines([
|
||||
...buildHardWrappedPathLogicalLineCandidates(buffer, bufferLineNumber),
|
||||
...(softWrappedLogicalLine ? [softWrappedLogicalLine] : [])
|
||||
])
|
||||
if (logicalLines.every((logicalLine) => !logicalLine.text)) {
|
||||
callback(undefined)
|
||||
return
|
||||
}
|
||||
|
||||
if (
|
||||
logicalLines.every((logicalLine) => extractTerminalFileLinks(logicalLine.text).length === 0)
|
||||
) {
|
||||
// Why: xterm hit-tests only the hovered row, so checking the rest of a long line wastes host stats.
|
||||
const rowLinks = buildCandidateLogicalLinesForBufferPosition(
|
||||
buffer,
|
||||
bufferLineNumber
|
||||
).flatMap((logicalLine) => {
|
||||
const { links, guesses } = extractTerminalFileLinkReadings(logicalLine.text)
|
||||
// Why guesses too: every reading below is shown only once the host finds its file.
|
||||
return [...links, ...guesses].flatMap((parsed) => {
|
||||
const range = rangeForParsedFileLink(logicalLine, parsed.startIndex, parsed.endIndex)
|
||||
return range && range.start.y <= bufferLineNumber && range.end.y >= bufferLineNumber
|
||||
? [{ logicalLine, parsed, range }]
|
||||
: []
|
||||
})
|
||||
})
|
||||
if (rowLinks.length === 0) {
|
||||
callback(undefined)
|
||||
return
|
||||
}
|
||||
@@ -115,86 +111,78 @@ export function createFilePathLinkProvider(
|
||||
const pathExists = createTerminalPathExistenceBatch()
|
||||
// A rejected wrapped-path guess must not hide verified sibling paths.
|
||||
void Promise.allSettled(
|
||||
logicalLines.flatMap((logicalLine) =>
|
||||
extractTerminalFileLinkCandidates(logicalLine.text).map(
|
||||
async (parsed): Promise<ProvidedFileLink | null> => {
|
||||
const runtimeEnvironmentId =
|
||||
deps.getRuntimeEnvironmentIdForPane?.(paneId) ?? deps.runtimeEnvironmentId ?? null
|
||||
const target = resolveFileLinkTarget(parsed, {
|
||||
cwd: deps.getPaneLinkCwd?.(paneId) ?? startupCwd,
|
||||
homePath: deps.terminalHomePath,
|
||||
worktreeId,
|
||||
worktreePath,
|
||||
runtimeEnvironmentId,
|
||||
wslDistro: deps.wslDistro
|
||||
})
|
||||
const range = rangeForParsedFileLink(logicalLine, parsed.startIndex, parsed.endIndex)
|
||||
if (!target || !range) {
|
||||
return null
|
||||
}
|
||||
const terminalHandle = deps.getRuntimeTerminalHandleForPane?.(paneId) ?? null
|
||||
if (
|
||||
!(await fileLinkTargetExists(target, pathExistsCache, pathExists, terminalHandle))
|
||||
) {
|
||||
return null
|
||||
}
|
||||
const { absolutePath: mappedPath, fileContext } = target
|
||||
rowLinks.map(async ({ logicalLine, parsed, range }): Promise<ProvidedFileLink | null> => {
|
||||
const runtimeEnvironmentId =
|
||||
deps.getRuntimeEnvironmentIdForPane?.(paneId) ?? deps.runtimeEnvironmentId ?? null
|
||||
const target = resolveFileLinkTarget(parsed, {
|
||||
cwd: deps.getPaneLinkCwd?.(paneId) ?? startupCwd,
|
||||
homePath: deps.terminalHomePath,
|
||||
worktreeId,
|
||||
worktreePath,
|
||||
runtimeEnvironmentId,
|
||||
wslDistro: deps.wslDistro
|
||||
})
|
||||
if (!target) {
|
||||
return null
|
||||
}
|
||||
const terminalHandle = deps.getRuntimeTerminalHandleForPane?.(paneId) ?? null
|
||||
if (!(await fileLinkTargetExists(target, pathExistsCache, pathExists, terminalHandle))) {
|
||||
return null
|
||||
}
|
||||
const { absolutePath: mappedPath, fileContext } = target
|
||||
|
||||
return {
|
||||
logicalLine,
|
||||
link: {
|
||||
range,
|
||||
text: parsed.displayText,
|
||||
activate: (event) => {
|
||||
if (
|
||||
handleTerminalFileLink(
|
||||
mappedPath,
|
||||
target.line,
|
||||
target.column,
|
||||
event,
|
||||
{
|
||||
worktreeId,
|
||||
worktreePath,
|
||||
runtimeEnvironmentId,
|
||||
wslDistro: deps.wslDistro,
|
||||
terminalHandle,
|
||||
sourceTabId: deps.sourceTabId,
|
||||
onOpenFailure: (failure) =>
|
||||
showFileLinkOpenFailureToast(mappedPath, failure)
|
||||
},
|
||||
deps.getLinkActionContext?.(paneId)
|
||||
)
|
||||
) {
|
||||
pane.terminal.clearSelection?.()
|
||||
}
|
||||
},
|
||||
hover: () => {
|
||||
// Only local paths can use the Shift+modifier OS escape hatch.
|
||||
const canOpenWithSystemDefault =
|
||||
shouldOpenTerminalFileWithSystemDefault(fileContext)
|
||||
const showActions = deps.getLinkActionContext
|
||||
? deps.getLinkActionContext(paneId) !== null
|
||||
: true
|
||||
const hint = target.isKnownWorktreeRoot
|
||||
? getTerminalWorktreePathOpenHint(canOpenWithSystemDefault, showActions)
|
||||
: canOpenWithSystemDefault
|
||||
? isHtmlFilePath(mappedPath)
|
||||
? getTerminalHtmlFileOpenHint(localHtmlFileDestinations(), showActions)
|
||||
: showActions
|
||||
? openLinkHint
|
||||
: getTerminalFileOpenHint(false)
|
||||
: getTerminalOrcaFileOpenHint(showActions)
|
||||
linkTooltip.textContent = `${mappedPath} (${hint})`
|
||||
linkTooltip.style.display = ''
|
||||
},
|
||||
leave: () => {
|
||||
linkTooltip.style.display = 'none'
|
||||
}
|
||||
return {
|
||||
logicalLine,
|
||||
link: {
|
||||
range,
|
||||
text: parsed.displayText,
|
||||
activate: (event) => {
|
||||
if (
|
||||
handleTerminalFileLink(
|
||||
mappedPath,
|
||||
target.line,
|
||||
target.column,
|
||||
event,
|
||||
{
|
||||
worktreeId,
|
||||
worktreePath,
|
||||
runtimeEnvironmentId,
|
||||
wslDistro: deps.wslDistro,
|
||||
terminalHandle,
|
||||
sourceTabId: deps.sourceTabId,
|
||||
onOpenFailure: (failure) => showFileLinkOpenFailureToast(mappedPath, failure)
|
||||
},
|
||||
deps.getLinkActionContext?.(paneId)
|
||||
)
|
||||
) {
|
||||
pane.terminal.clearSelection?.()
|
||||
}
|
||||
},
|
||||
hover: () => {
|
||||
// Only local paths can use the Shift+modifier OS escape hatch.
|
||||
const canOpenWithSystemDefault =
|
||||
shouldOpenTerminalFileWithSystemDefault(fileContext)
|
||||
const showActions = deps.getLinkActionContext
|
||||
? deps.getLinkActionContext(paneId) !== null
|
||||
: true
|
||||
const hint = target.isKnownWorktreeRoot
|
||||
? getTerminalWorktreePathOpenHint(canOpenWithSystemDefault, showActions)
|
||||
: canOpenWithSystemDefault
|
||||
? isHtmlFilePath(mappedPath)
|
||||
? getTerminalHtmlFileOpenHint(localHtmlFileDestinations(), showActions)
|
||||
: showActions
|
||||
? openLinkHint
|
||||
: getTerminalFileOpenHint(false)
|
||||
: getTerminalOrcaFileOpenHint(showActions)
|
||||
linkTooltip.textContent = `${mappedPath} (${hint})`
|
||||
linkTooltip.style.display = ''
|
||||
},
|
||||
leave: () => {
|
||||
linkTooltip.style.display = 'none'
|
||||
}
|
||||
}
|
||||
)
|
||||
)
|
||||
}
|
||||
})
|
||||
)
|
||||
.then((settledLinks) => {
|
||||
const latestFingerprints = new Set(
|
||||
|
||||
@@ -341,6 +341,80 @@ describe('a modifier-click on a paired-server path followed by more paths', () =
|
||||
expect(asked).toEqual(['//server/share/other/report.txt'])
|
||||
})
|
||||
|
||||
it.each([
|
||||
['report', ['//server/share/report.md']],
|
||||
['details', []]
|
||||
])('never opens an unchecked guess past a share path when clicking %s', async (word, opened) => {
|
||||
setPlatform('Windows')
|
||||
runtimeEnvironmentCallMock.mockResolvedValue({
|
||||
id: 'rpc-1',
|
||||
ok: true,
|
||||
result: { worktree: 'wt-1', relativePath: null, absolutePath: null, exists: false },
|
||||
_meta: { runtimeId: 'remote-runtime' }
|
||||
})
|
||||
const line = 'See //server/share/report.md for details'
|
||||
const buffer = makeBuffer([makeBufferLine(line)])
|
||||
|
||||
openFilePathLinkAtBufferPosition(buffer, { x: line.indexOf(word) + 1, y: 1 }, 80, {
|
||||
startupCwd: '/srv/repo',
|
||||
worktreeId: 'wt-1',
|
||||
worktreePath: '/srv/repo',
|
||||
runtimeEnvironmentId: 'env-1',
|
||||
terminalHandle: 'term-1',
|
||||
pathExistsCache: new Map()
|
||||
})
|
||||
await flushDoubleRaf()
|
||||
|
||||
const asked = runtimeEnvironmentCallMock.mock.calls.map(([args]) => args.params?.pathText)
|
||||
expect(asked).toEqual(opened)
|
||||
})
|
||||
|
||||
it('leaves a click on prose beside a path to the terminal', async () => {
|
||||
setPlatform('Macintosh')
|
||||
const line = '/tmp/report.md completed successfully'
|
||||
const buffer = makeBuffer([makeBufferLine(line)])
|
||||
|
||||
const handled = openFilePathLinkAtBufferPosition(
|
||||
buffer,
|
||||
{ x: line.indexOf('completed') + 1, y: 1 },
|
||||
80,
|
||||
{
|
||||
startupCwd: '/srv/repo',
|
||||
worktreeId: 'wt-1',
|
||||
worktreePath: '/srv/repo',
|
||||
runtimeEnvironmentId: 'env-1',
|
||||
terminalHandle: 'term-1',
|
||||
pathExistsCache: new Map()
|
||||
}
|
||||
)
|
||||
await flushDoubleRaf()
|
||||
|
||||
expect(handled).toBe(false)
|
||||
expect(runtimeEnvironmentCallMock).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('opens the joined path when its checks fail and the visible prefix is known missing', async () => {
|
||||
setPlatform('Macintosh')
|
||||
hostAnswers((path) => ({ exists: path === 'report.txt' }))
|
||||
const answerOpen = runtimeEnvironmentCallMock.getMockImplementation()!
|
||||
runtimeEnvironmentCallMock.mockImplementation((args) =>
|
||||
args.method === 'files.pathsExist' ? Promise.reject(new Error('host busy')) : answerOpen(args)
|
||||
)
|
||||
const buffer = makeBuffer([
|
||||
makeBufferLine('/srv/repo/report'),
|
||||
makeBufferLine('.txt'),
|
||||
makeBufferLine('.map')
|
||||
])
|
||||
|
||||
clickPairedPath(buffer, makeExistsCache([[runtimeKey('/srv/repo/report'), false]]))
|
||||
await flushDoubleRaf()
|
||||
|
||||
expect(openFileMock).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ filePath: '/srv/repo/report.txt' }),
|
||||
expect.anything()
|
||||
)
|
||||
})
|
||||
|
||||
it('opens a wrapped share path whole even when its prefix is a known file', async () => {
|
||||
setPlatform('Windows')
|
||||
runtimeEnvironmentCallMock.mockResolvedValue({
|
||||
|
||||
@@ -4,30 +4,33 @@ import type { ParsedTerminalFileLink } from './terminal-links'
|
||||
const LEADING_TRIM_CHARS = new Set(['(', '[', '{', '"', "'"])
|
||||
const TRAILING_TRIM_CHARS = new Set([')', ']', '}', '"', "'", ',', ';', '.'])
|
||||
|
||||
export const ROOTED_PATH_START_PATTERN = /^(?:~[\\/]|[\\/]|\.{1,2}[\\/]|[A-Za-z]:[\\/])/
|
||||
|
||||
export type DetectedTerminalFileLinkRange = {
|
||||
startIndex: number
|
||||
endIndex: number
|
||||
text: string
|
||||
}
|
||||
|
||||
function trimBoundaryPunctuation(
|
||||
export function hasPathSeparator(text: string): boolean {
|
||||
return text.includes('/') || text.includes('\\')
|
||||
}
|
||||
|
||||
export function trimTerminalPathBoundaryPunctuation(
|
||||
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,
|
||||
@@ -41,7 +44,7 @@ export function* detectTerminalFileLinkRanges(
|
||||
): Generator<DetectedTerminalFileLinkRange> {
|
||||
for (const match of lineText.matchAll(regex)) {
|
||||
const rawStart = match.index ?? 0
|
||||
const trimmed = trimBoundaryPunctuation(match[0], rawStart)
|
||||
const trimmed = trimTerminalPathBoundaryPunctuation(match[0], rawStart)
|
||||
if (trimmed) {
|
||||
yield trimmed
|
||||
}
|
||||
@@ -85,30 +88,6 @@ export function terminalFileLinkRangesOverlap(
|
||||
return previous !== undefined && previous[1] > range.startIndex
|
||||
}
|
||||
|
||||
export function insertTerminalFileLinkClaimedRange(
|
||||
claimedRanges: [number, number][],
|
||||
range: [number, number]
|
||||
): void {
|
||||
// oxlint-disable-next-line unicorn/prefer-at -- the phone's terminal WebView (Chrome 74) has no Array#at
|
||||
const last: [number, number] | undefined = claimedRanges[claimedRanges.length - 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 {
|
||||
|
||||
@@ -2,7 +2,7 @@ import { buildTerminalHttpLogicalLines, hasHardWrappedRows } from './terminal-ht
|
||||
import { extractTerminalHttpLinks } from './terminal-http-url-extraction'
|
||||
import { dedupeLogicalLines } from './terminal-link-logical-line-deduplication'
|
||||
import { rangeForParsedLinkOnRow } from './terminal-link-row-range'
|
||||
import { extractTerminalFileLinkCandidates, type ParsedTerminalFileLink } from './terminal-links'
|
||||
import { extractTerminalFileLinkReadings, type ParsedTerminalFileLink } from './terminal-links'
|
||||
import { terminalLogicalLinkHasOscLink } from './terminal-osc-link-cells'
|
||||
import {
|
||||
buildHardWrappedPathLogicalLineCandidates,
|
||||
@@ -60,23 +60,28 @@ export function buildCandidateLogicalLinesForBufferPosition(
|
||||
return dedupeLogicalLines(candidates)
|
||||
}
|
||||
|
||||
/** Every file-link reading that covers the cell; callers choose among soft/hard-wrap guesses. */
|
||||
/** The readings covering the cell; a guess needs the host to find its file before anyone acts on it. */
|
||||
export function findTerminalFileLinksAtBufferPosition(
|
||||
buffer: TerminalLinkBuffer,
|
||||
position: TerminalLinkBufferPosition,
|
||||
terminalColumns: number
|
||||
): TerminalFileLinkAtBufferPosition[] {
|
||||
): { links: TerminalFileLinkAtBufferPosition[]; guesses: TerminalFileLinkAtBufferPosition[] } {
|
||||
const links: TerminalFileLinkAtBufferPosition[] = []
|
||||
const guesses: TerminalFileLinkAtBufferPosition[] = []
|
||||
for (const logicalLine of buildCandidateLogicalLinesForBufferPosition(buffer, position.y)) {
|
||||
for (const parsed of extractTerminalFileLinkCandidates(logicalLine.text)) {
|
||||
const readings = extractTerminalFileLinkReadings(logicalLine.text)
|
||||
const covering = (parsed: ParsedTerminalFileLink): boolean => {
|
||||
const range = rangeForParsedFileLink(logicalLine, parsed.startIndex, parsed.endIndex)
|
||||
if (!range || !rangeContainsBufferPosition(range, position, terminalColumns)) {
|
||||
continue
|
||||
}
|
||||
return range !== null && rangeContainsBufferPosition(range, position, terminalColumns)
|
||||
}
|
||||
for (const parsed of readings.links.filter(covering)) {
|
||||
links.push({ ...parsed, logicalLine })
|
||||
}
|
||||
for (const parsed of readings.guesses.filter(covering)) {
|
||||
guesses.push({ ...parsed, logicalLine })
|
||||
}
|
||||
}
|
||||
return links
|
||||
return { links, guesses }
|
||||
}
|
||||
|
||||
function rangeContainsBufferPosition(
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { extractTerminalFileLinks, resolveTerminalFileLink } from './terminal-links'
|
||||
import {
|
||||
extractTerminalFileLinkReadings,
|
||||
extractTerminalFileLinks,
|
||||
resolveTerminalFileLink
|
||||
} from './terminal-links'
|
||||
|
||||
describe('CJK terminal path names', () => {
|
||||
for (const character of ['・', '・', '〜', '~']) {
|
||||
@@ -13,9 +17,11 @@ describe('CJK terminal path names', () => {
|
||||
'/tmp/前CHAR後/space name/a.txt'
|
||||
])(`keeps ${character} in %s`, (template) => {
|
||||
const filePath = template.replace('CHAR', character)
|
||||
const links = extractTerminalFileLinks(filePath)
|
||||
expect(links.map((link) => link.pathText)).toEqual([filePath])
|
||||
expect(links[0]).toMatchObject({ startIndex: 0, endIndex: filePath.length })
|
||||
const { links, guesses } = extractTerminalFileLinkReadings(filePath)
|
||||
expect([...links, ...guesses].find((link) => link.pathText === filePath)).toMatchObject({
|
||||
startIndex: 0,
|
||||
endIndex: filePath.length
|
||||
})
|
||||
})
|
||||
|
||||
it(`preserves location and normalized Windows target for ${character}`, () => {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { performance } from 'node:perf_hooks'
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { extractTerminalFileLinks, extractTerminalFileLinkCandidates } from './terminal-links'
|
||||
import { extractTerminalFileLinkReadings } from './terminal-links'
|
||||
|
||||
// Large space-padded newline-free ConPTY dashboards once caused overlapping-whitespace backtracking (#5970); bound fixture size without hiding the regression.
|
||||
describe('terminal-links ReDoS guard (#5970)', () => {
|
||||
@@ -15,15 +15,14 @@ describe('terminal-links ReDoS guard (#5970)', () => {
|
||||
for (const [name, line] of cases) {
|
||||
it(`scans "${name}" in roughly linear time`, () => {
|
||||
const start = performance.now()
|
||||
extractTerminalFileLinks(line)
|
||||
extractTerminalFileLinkCandidates(line)
|
||||
extractTerminalFileLinkReadings(line)
|
||||
const elapsedMs = performance.now() - start
|
||||
expect(elapsedMs).toBeLessThan(500)
|
||||
})
|
||||
}
|
||||
|
||||
it('still detects separator paths that contain spaces', () => {
|
||||
const links = extractTerminalFileLinks('/Users/a/Foo Bar/file.ts')
|
||||
expect(links.some((link) => link.pathText === '/Users/a/Foo Bar/file.ts')).toBe(true)
|
||||
const { guesses } = extractTerminalFileLinkReadings('/Users/a/Foo Bar/file.ts')
|
||||
expect(guesses.some((link) => link.pathText === '/Users/a/Foo Bar/file.ts')).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -3,32 +3,41 @@ import {
|
||||
TERMINAL_FILE_LINK_TAP_CONFORMANCE_CASES,
|
||||
columnForTerminalFileLinkTap
|
||||
} from './terminal-file-link-conformance'
|
||||
import { classifyTerminalOscLinkTarget } from './terminal-osc-link-target'
|
||||
import {
|
||||
extractTerminalFileLinkReadings,
|
||||
extractTerminalFileLinks,
|
||||
findExactTerminalFileLink,
|
||||
isPathInsideWorktree,
|
||||
resolveTerminalFileLink,
|
||||
toWorktreeRelativePath
|
||||
} from './terminal-links'
|
||||
|
||||
function extractTerminalFileLinkAtColumn(lineText: string, column: number) {
|
||||
return (
|
||||
extractTerminalFileLinks(lineText).find(
|
||||
(link) => column >= link.startIndex && column < link.endIndex
|
||||
) ?? null
|
||||
)
|
||||
// Every reading a host that checks files is offered.
|
||||
function allReadings(lineText: string) {
|
||||
const { links, guesses } = extractTerminalFileLinkReadings(lineText)
|
||||
return [...links, ...guesses]
|
||||
}
|
||||
|
||||
function readingsAt(lineText: string, column: number) {
|
||||
return allReadings(lineText)
|
||||
.filter((link) => column >= link.startIndex && column < link.endIndex)
|
||||
.map(({ pathText, line, column }) => ({ pathText, line, column }))
|
||||
}
|
||||
|
||||
function pathTexts(lineText: string): string[] {
|
||||
return allReadings(lineText).map((link) => link.pathText)
|
||||
}
|
||||
|
||||
describe('terminal path helpers', () => {
|
||||
describe('shared terminal file-link tap conformance', () => {
|
||||
// Desktop offers every reading at the tap and lets the host's existence check choose.
|
||||
it.each(TERMINAL_FILE_LINK_TAP_CONFORMANCE_CASES)('$name', (testCase) => {
|
||||
const link = extractTerminalFileLinkAtColumn(
|
||||
testCase.lineText,
|
||||
columnForTerminalFileLinkTap(testCase)
|
||||
)
|
||||
expect(
|
||||
link ? { pathText: link.pathText, line: link.line, column: link.column } : null
|
||||
).toEqual(testCase.expected)
|
||||
const readings = readingsAt(testCase.lineText, columnForTerminalFileLinkTap(testCase))
|
||||
if (testCase.expected) {
|
||||
expect(readings).toContainEqual(testCase.expected)
|
||||
} else {
|
||||
expect(readings).toEqual([])
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
@@ -58,8 +67,9 @@ describe('terminal path helpers', () => {
|
||||
})
|
||||
|
||||
it('does not double-link bare tokens that are part of an already-matched path', () => {
|
||||
const links = extractTerminalFileLinks('./src/file.ts is the entry point')
|
||||
expect(links.map((link) => link.displayText)).toEqual(['./src/file.ts'])
|
||||
const paths = pathTexts('./src/file.ts is the entry point')
|
||||
expect(paths).toContain('./src/file.ts')
|
||||
expect(paths).not.toContain('file.ts')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -73,18 +83,14 @@ describe('terminal path helpers', () => {
|
||||
).toEqual(['docs/café/report.pdf'])
|
||||
})
|
||||
|
||||
it('detects an extensionless absolute path ending in a spaced segment', () => {
|
||||
const links = extractTerminalFileLinks('/Users/alice/My Folder')
|
||||
expect(links).toHaveLength(1)
|
||||
expect(links[0]).toMatchObject({
|
||||
pathText: '/Users/alice/My Folder',
|
||||
displayText: '/Users/alice/My Folder'
|
||||
})
|
||||
it('offers absolute paths with spaces before the filename', () => {
|
||||
expect(pathTexts('/Users/Path/FolderName with Space/content.js')).toContain(
|
||||
'/Users/Path/FolderName with Space/content.js'
|
||||
)
|
||||
})
|
||||
|
||||
it('does not treat mid-line command arguments as line-ending spaced paths', () => {
|
||||
const links = extractTerminalFileLinks('run /usr/bin/env node, then continue')
|
||||
expect(links.map((link) => link.pathText)).not.toContain('/usr/bin/env node')
|
||||
it('offers an extensionless absolute path ending in a spaced segment', () => {
|
||||
expect(pathTexts('/Users/alice/My Folder')).toContain('/Users/alice/My Folder')
|
||||
})
|
||||
|
||||
it('does not linkify root-only or relative trailing separator tokens', () => {
|
||||
@@ -96,6 +102,44 @@ describe('terminal path helpers', () => {
|
||||
expect(extractTerminalFileLinks('C:\\')).toEqual([])
|
||||
})
|
||||
|
||||
it('offers an extensionless relative path ending in a spaced segment', () => {
|
||||
expect(pathTexts('./My Folder')).toContain('./My Folder')
|
||||
})
|
||||
|
||||
// A spaced guess must never hide the plain path; the host keeps whichever exists.
|
||||
it.each([
|
||||
[
|
||||
'~/orca-lanes/webhooks-research/bots/bots-ux-outline.md. It covers setup and/or teardown.',
|
||||
'~/orca-lanes/webhooks-research/bots/bots-ux-outline.md'
|
||||
],
|
||||
['Wrote ~/notes/plan.md. Next: src/foo.ts', '~/notes/plan.md'],
|
||||
['Wrote ~/notes/plan.md and src/foo.ts covers the rest', '~/notes/plan.md'],
|
||||
['See /Users/me/a/b.md. Then C:\\Users\\me\\x.md.', '/Users/me/a/b.md'],
|
||||
['See /home/me/x.md. Then the pane/tab work.', '/home/me/x.md'],
|
||||
['See C:\\Users\\me\\x.md. Then the pane/tab work.', 'C:\\Users\\me\\x.md']
|
||||
])('offers the plain path in %j', (line, path) => {
|
||||
expect(pathTexts(line)).toContain(path)
|
||||
})
|
||||
|
||||
it.each([
|
||||
'/Users/a/C# Projects/x.cs',
|
||||
'/Users/a/Tom & Jerry/x.ts',
|
||||
'/Users/a/Notes - Draft/x.md',
|
||||
'C:\\Users\\Me\\Q&A Docs\\x.txt',
|
||||
'/tmp/Foo.bar Docs/report.md',
|
||||
'/tmp/My File.123',
|
||||
'docs/My Archive.7z',
|
||||
'docs/My 檔案.文件'
|
||||
])('offers spaced folder names with symbols: %s', (path) => {
|
||||
expect(pathTexts(`open ${path}, then continue`)).toContain(path)
|
||||
})
|
||||
|
||||
it('still offers the spaced reading so an existing spaced path can win', () => {
|
||||
expect(pathTexts('Saved /Users/me/My Docs/plan.md. Next: src/foo.ts')).toEqual(
|
||||
expect.arrayContaining(['/Users/me/My', '/Users/me/My Docs/plan.md'])
|
||||
)
|
||||
})
|
||||
|
||||
it('detects framework route paths with bracket and paren segments', () => {
|
||||
const links = extractTerminalFileLinks(
|
||||
'Error in app/(shop)/products/[productId]/page.tsx:42:7'
|
||||
@@ -108,6 +152,20 @@ describe('terminal path helpers', () => {
|
||||
displayText: 'app/(shop)/products/[productId]/page.tsx:42:7'
|
||||
})
|
||||
})
|
||||
|
||||
it('handles large spaced path lists in linear time', () => {
|
||||
const line = Array.from({ length: 20_000 }, () => '/tmp/Foo Bar/file').join(', ')
|
||||
|
||||
const paths = pathTexts(line)
|
||||
|
||||
expect(paths.filter((path) => path === '/tmp/Foo Bar/file')).toHaveLength(20_000)
|
||||
})
|
||||
|
||||
it('grows a spaced reading over a bounded number of words', () => {
|
||||
const paths = pathTexts(`/tmp/root ${'part/x '.repeat(20_000)}`)
|
||||
|
||||
expect(paths.filter((path) => path.startsWith('/tmp/root'))).toHaveLength(11)
|
||||
})
|
||||
})
|
||||
|
||||
it('resolves tilde-prefixed paths against explicit terminal home when cwd is outside home', () => {
|
||||
@@ -131,16 +189,32 @@ describe('terminal path helpers', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('keeps line and column suffixes from exact OSC hyperlink text', () => {
|
||||
expect(findExactTerminalFileLink('docs/README.md:12:3')).toMatchObject({
|
||||
pathText: 'docs/README.md',
|
||||
line: 12,
|
||||
column: 3
|
||||
})
|
||||
it('keeps a path spanning prose apart from the paths written in it', () => {
|
||||
const { links, guesses } = extractTerminalFileLinkReadings(
|
||||
'● Wrote ~/x/notes.md. It covers setup and/or teardown.'
|
||||
)
|
||||
expect(links.map((link) => link.pathText)).toEqual(['~/x/notes.md', 'and/or'])
|
||||
expect(guesses.map((link) => link.pathText)).toContain('~/x/notes.md. It covers setup')
|
||||
})
|
||||
|
||||
it('does not take partial text as an OSC hyperlink target', () => {
|
||||
expect(findExactTerminalFileLink('open docs/README.md')).toBeNull()
|
||||
it.each([
|
||||
['docs/README.md:12:3', 'docs/README.md', 12],
|
||||
['/Users/a/My Folder/file.ts:3', '/Users/a/My Folder/file.ts', 3],
|
||||
['docs/My Folder/file.ts', 'docs/My Folder/file.ts', null]
|
||||
])('takes OSC hyperlink target %s as written', (target, pathText, line) => {
|
||||
const osc = classifyTerminalOscLinkTarget(target, {
|
||||
windowsPathsFirst: false,
|
||||
allowUncHost: false
|
||||
})
|
||||
expect(osc).toMatchObject({ kind: 'path', link: { pathText, line } })
|
||||
})
|
||||
|
||||
it('does not read a malformed URL OSC hyperlink target as a path', () => {
|
||||
const osc = classifyTerminalOscLinkTarget('https://exa mple.com', {
|
||||
windowsPathsFirst: false,
|
||||
allowUncHost: false
|
||||
})
|
||||
expect(osc).toBeNull()
|
||||
})
|
||||
|
||||
describe('plain-text file:// URIs', () => {
|
||||
|
||||
+47
-218
@@ -2,13 +2,13 @@ import { normalizeAbsolutePath } from './terminal-path-normalization'
|
||||
import { resolveExplicitFileLinkTarget } from './explicit-file-link-target'
|
||||
import { detectBareFilenameLinks } from './terminal-bare-file-link-detection'
|
||||
import {
|
||||
detectTerminalFileLinkRanges,
|
||||
insertTerminalFileLinkClaimedRange,
|
||||
hasPathSeparator,
|
||||
mergeTerminalFileLinkRanges,
|
||||
terminalFileLinkRangesOverlap,
|
||||
toParsedTerminalFileLink,
|
||||
trimTerminalPathBoundaryPunctuation,
|
||||
type DetectedTerminalFileLinkRange
|
||||
} from './terminal-file-link-detection-ranges'
|
||||
import { growSpacedPathRanges } from './terminal-spaced-path-candidates'
|
||||
import { detectTerminalFileUriLinks } from './terminal-file-uri-link'
|
||||
import { detectTerminalMarkdownFileLinks } from './terminal-markdown-file-links'
|
||||
|
||||
@@ -34,62 +34,8 @@ export type ResolvedTerminalFileLink = Pick<ParsedTerminalFileLink, 'line' | 'co
|
||||
const LOCAL_PATH_REGEX =
|
||||
/(?:~[\\/]|[\\/]|\.{1,2}[\\/]|[A-Za-z]:[\\/]|[\p{L}\p{N}\p{M}._-]+[\\/])[\p{L}\p{N}\p{M}._~\-/%+@\\()[\]\u30FB\uFF65\u301C\uFF5E]*(?::\d+)?(?::\d+)?/gu
|
||||
|
||||
// Claim spaced paths before ordinary paths to avoid splitting /Users/A/Foo Bar/file.ts. Broad linear scans avoid overlapping-whitespace backtracking on ConPTY dashboards; filter candidates in code.
|
||||
const SPACED_PATH_WITH_SEPARATOR_REGEX =
|
||||
/(?:~[\\/]|[\\/]|\.{1,2}[\\/]|[A-Za-z]:[\\/]|[A-Za-z0-9._-]+[\\/])[^()[\]{}'",;<>|`\r\n]+(?::\d+)?(?::\d+)?/g
|
||||
// Why this shares the broad candidate shape: extension paths with prose after
|
||||
// them still need trimming, but the whitespace/extension test stays in code.
|
||||
const SPACED_PATH_WITH_EXTENSION_REGEX =
|
||||
/(?:~[\\/]|[\\/]|\.{1,2}[\\/]|[A-Za-z]:[\\/]|[A-Za-z0-9._-]+[\\/])[^()[\]{}'",;<>|`\r\n]+(?::\d+)?(?::\d+)?/g
|
||||
// Why this is also broad: the candidates path runs on hover, including huge
|
||||
// space-padded TUI lines, so reject line-ending spaced paths outside the regex.
|
||||
const LINE_ENDING_SPACED_PATH_REGEX =
|
||||
/(?:~[\\/]|[\\/]|\.{1,2}[\\/]|[A-Za-z]:[\\/]|[A-Za-z0-9._-]+[\\/])[^()[\]{}'",;<>|`\r\n]+(?::\d+)?(?::\d+)?/g
|
||||
const SPACED_LOCAL_PATH_REGEXES = [
|
||||
SPACED_PATH_WITH_SEPARATOR_REGEX,
|
||||
SPACED_PATH_WITH_EXTENSION_REGEX,
|
||||
LINE_ENDING_SPACED_PATH_REGEX
|
||||
]
|
||||
|
||||
const URI_PREFIX_CHAR_PATTERN = /^[A-Za-z0-9+./:-]$/
|
||||
|
||||
function hasPathSeparator(text: string): boolean {
|
||||
return text.includes('/') || text.includes('\\')
|
||||
}
|
||||
|
||||
function hasSeparatorAfterWhitespace(text: string): boolean {
|
||||
let sawWhitespace = false
|
||||
for (const char of text) {
|
||||
if (/\s/.test(char)) {
|
||||
sawWhitespace = true
|
||||
continue
|
||||
}
|
||||
if (sawWhitespace && (char === '/' || char === '\\')) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
function hasInternalWhitespaceBeforeTrimmedEnd(text: string): boolean {
|
||||
const trimmed = text.trimEnd()
|
||||
return /\s/.test(trimmed)
|
||||
}
|
||||
|
||||
function isAtTrimmedLineEnd(lineText: string, endIndex: number): boolean {
|
||||
return lineText.slice(endIndex).trim().length === 0
|
||||
}
|
||||
|
||||
function hasSpacedPathExtension(text: string): boolean {
|
||||
const trimmedRange = trimSpacedPathTrailingProse({
|
||||
text,
|
||||
startIndex: 0,
|
||||
endIndex: text.length
|
||||
})
|
||||
const trimmedText = trimmedRange.text.trimEnd()
|
||||
return /\s/.test(trimmedText) && /\.[A-Za-z0-9_+-]+(?::\d+)?(?::\d+)?$/.test(trimmedText)
|
||||
}
|
||||
|
||||
function getImmediateUriPrefix(lineText: string, endIndex: number): string {
|
||||
let start = endIndex
|
||||
while (start > 0 && URI_PREFIX_CHAR_PATTERN.test(lineText[start - 1])) {
|
||||
@@ -108,183 +54,72 @@ function isInsideUriScheme(lineText: string, range: DetectedTerminalFileLinkRang
|
||||
)
|
||||
}
|
||||
|
||||
function trimSpacedPathTrailingProse(
|
||||
range: DetectedTerminalFileLinkRange
|
||||
): DetectedTerminalFileLinkRange {
|
||||
// Trim prose/unrelated paths after extension-terminated candidates. Extend only path-like added segments (v1.2 reports/result.json), never prose such as failed to start app.py.
|
||||
let selected: string | null = null
|
||||
const extensionPrefixPattern = /\.[A-Za-z0-9_+-]+(?::\d+)?(?::\d+)?(?=\s+|$)/g
|
||||
const pathStartPattern = /(?:^|\s)(?:~[\\/]|[\\/]|\.{1,2}[\\/]|[A-Za-z]:[\\/])/g
|
||||
let pathStartCount = 0
|
||||
let nextPathStart = pathStartPattern.exec(range.text)
|
||||
let match: RegExpExecArray | null
|
||||
while ((match = extensionPrefixPattern.exec(range.text)) !== null) {
|
||||
const end = match.index + match[0].length
|
||||
const text = range.text.slice(0, end)
|
||||
while (nextPathStart && nextPathStart.index + nextPathStart[0].length <= end) {
|
||||
pathStartCount += 1
|
||||
nextPathStart = pathStartPattern.exec(range.text)
|
||||
}
|
||||
if (pathStartCount > 1) {
|
||||
continue
|
||||
}
|
||||
if (
|
||||
end < range.text.length ||
|
||||
selected === null ||
|
||||
/[\\/]/.test(range.text.slice(selected.length, end))
|
||||
) {
|
||||
selected = text
|
||||
}
|
||||
}
|
||||
if (!selected) {
|
||||
return range
|
||||
}
|
||||
return {
|
||||
text: selected,
|
||||
startIndex: range.startIndex,
|
||||
endIndex: range.startIndex + selected.length
|
||||
}
|
||||
}
|
||||
|
||||
function trimTrailingWhitespace(
|
||||
range: DetectedTerminalFileLinkRange
|
||||
): DetectedTerminalFileLinkRange {
|
||||
const text = range.text.trimEnd()
|
||||
return {
|
||||
text,
|
||||
startIndex: range.startIndex,
|
||||
endIndex: range.startIndex + text.length
|
||||
}
|
||||
}
|
||||
|
||||
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()
|
||||
if (text.includes(' ')) {
|
||||
ranges.push({
|
||||
text,
|
||||
startIndex: range.startIndex,
|
||||
endIndex: range.startIndex + text.length
|
||||
})
|
||||
}
|
||||
}
|
||||
// oxlint-disable-next-line unicorn/no-array-reverse -- the phone's terminal WebView (Chrome 74) has no Array#toReversed; ranges is local
|
||||
return ranges.reverse()
|
||||
}
|
||||
|
||||
/** Detect separator paths with optional :line:col, ported from VS Code TerminalLocalLinkDetector. */
|
||||
function detectLocalPathLinks(
|
||||
lineText: string,
|
||||
includeLineEndingPrefixCandidates = false
|
||||
): ParsedTerminalFileLink[] {
|
||||
/** Separator paths plus their spaced extensions, ported from VS Code TerminalLocalLinkDetector. */
|
||||
function detectLocalPathLinks(lineText: string): {
|
||||
paths: ParsedTerminalFileLink[]
|
||||
spacedPaths: ParsedTerminalFileLink[]
|
||||
} {
|
||||
const paths: ParsedTerminalFileLink[] = []
|
||||
const spacedPaths: ParsedTerminalFileLink[] = []
|
||||
if (!hasPathSeparator(lineText)) {
|
||||
return []
|
||||
return { paths, spacedPaths }
|
||||
}
|
||||
|
||||
const links: ParsedTerminalFileLink[] = []
|
||||
const spacedLinks = detectSpacedLocalPathLinks(lineText, includeLineEndingPrefixCandidates)
|
||||
const spacedRanges = mergeTerminalFileLinkRanges(
|
||||
spacedLinks.map(({ startIndex, endIndex }): [number, number] => [startIndex, endIndex])
|
||||
)
|
||||
for (const link of spacedLinks) {
|
||||
links.push(link)
|
||||
}
|
||||
for (const range of detectTerminalFileLinkRanges(lineText, LOCAL_PATH_REGEX)) {
|
||||
if (terminalFileLinkRangesOverlap(range, spacedRanges)) {
|
||||
continue
|
||||
}
|
||||
if (isInsideUriScheme(lineText, range)) {
|
||||
continue
|
||||
}
|
||||
if (!/[\\/]/.test(range.text)) {
|
||||
let grownWordEndIndex = 0
|
||||
for (const match of lineText.matchAll(LOCAL_PATH_REGEX)) {
|
||||
const rawStart = match.index ?? 0
|
||||
const range = trimTerminalPathBoundaryPunctuation(match[0], rawStart)
|
||||
if (!range || !hasPathSeparator(range.text) || isInsideUriScheme(lineText, range)) {
|
||||
continue
|
||||
}
|
||||
const link = toParsedTerminalFileLink(range)
|
||||
if (link) {
|
||||
links.push(link)
|
||||
paths.push(link)
|
||||
}
|
||||
}
|
||||
return links.sort((a, b) => a.startIndex - b.startIndex || b.endIndex - a.endIndex)
|
||||
}
|
||||
|
||||
function detectSpacedLocalPathLinks(
|
||||
lineText: string,
|
||||
includeLineEndingPrefixCandidates = false
|
||||
): ParsedTerminalFileLink[] {
|
||||
const links: ParsedTerminalFileLink[] = []
|
||||
const claimedRanges: [number, number][] = []
|
||||
for (const regex of SPACED_LOCAL_PATH_REGEXES) {
|
||||
for (const range of detectTerminalFileLinkRanges(lineText, regex)) {
|
||||
if (regex === SPACED_PATH_WITH_SEPARATOR_REGEX && !hasSeparatorAfterWhitespace(range.text)) {
|
||||
continue
|
||||
}
|
||||
if (regex === SPACED_PATH_WITH_EXTENSION_REGEX && !hasSpacedPathExtension(range.text)) {
|
||||
continue
|
||||
}
|
||||
if (
|
||||
regex === LINE_ENDING_SPACED_PATH_REGEX &&
|
||||
(!hasInternalWhitespaceBeforeTrimmedEnd(range.text) ||
|
||||
!isAtTrimmedLineEnd(lineText, range.endIndex))
|
||||
) {
|
||||
continue
|
||||
}
|
||||
if (
|
||||
terminalFileLinkRangesOverlap(range, claimedRanges) ||
|
||||
isInsideUriScheme(lineText, range)
|
||||
) {
|
||||
continue
|
||||
}
|
||||
const candidateRanges =
|
||||
includeLineEndingPrefixCandidates && regex === LINE_ENDING_SPACED_PATH_REGEX
|
||||
? [range, ...buildLineEndingSpacedPathPrefixRanges(range)]
|
||||
: [range]
|
||||
const candidateLinks = candidateRanges
|
||||
.map((candidateRange) =>
|
||||
toParsedTerminalFileLink(
|
||||
trimSpacedPathTrailingProse(trimTrailingWhitespace(candidateRange))
|
||||
)
|
||||
)
|
||||
.filter((link): link is ParsedTerminalFileLink => link !== null)
|
||||
const link = candidateLinks[0]
|
||||
if (link) {
|
||||
for (const candidateLink of candidateLinks) {
|
||||
links.push(candidateLink)
|
||||
}
|
||||
insertTerminalFileLinkClaimedRange(claimedRanges, [link.startIndex, link.endIndex])
|
||||
// Why: a path inside an earlier path's word (`a!b/c`) shares its growth; regrowing would rescan the word.
|
||||
if (rawStart < grownWordEndIndex) {
|
||||
continue
|
||||
}
|
||||
const grown = growSpacedPathRanges(lineText, range, rawStart + match[0].length)
|
||||
grownWordEndIndex = grown.wordEndIndex
|
||||
for (const spacedRange of grown.ranges) {
|
||||
const spacedLink = toParsedTerminalFileLink(spacedRange)
|
||||
if (spacedLink) {
|
||||
spacedPaths.push(spacedLink)
|
||||
}
|
||||
}
|
||||
}
|
||||
return links
|
||||
return { paths, spacedPaths }
|
||||
}
|
||||
|
||||
/** Claim Markdown destinations/file URIs/paths before bare filenames so longer explicit links keep their ranges. */
|
||||
function assembleFileLinks(
|
||||
lineText: string,
|
||||
includeLineEndingPrefixCandidates: boolean
|
||||
): ParsedTerminalFileLink[] {
|
||||
export type TerminalFileLinkReadings = {
|
||||
/** Paths as written: markdown destinations, file URIs, unspaced paths, bare filenames. */
|
||||
links: ParsedTerminalFileLink[]
|
||||
/** A path continued across the following words; act on one only after the host finds it exists. */
|
||||
guesses: ParsedTerminalFileLink[]
|
||||
}
|
||||
|
||||
export function extractTerminalFileLinkReadings(lineText: string): TerminalFileLinkReadings {
|
||||
const markdown = detectTerminalMarkdownFileLinks(lineText)
|
||||
const uriLinks = detectTerminalFileUriLinks(markdown.remainingText)
|
||||
const pathLinks = detectLocalPathLinks(markdown.remainingText, includeLineEndingPrefixCandidates)
|
||||
const explicitLinks = [...markdown.links, ...uriLinks, ...pathLinks]
|
||||
const { paths, spacedPaths } = detectLocalPathLinks(markdown.remainingText)
|
||||
const claimingLinks = [...markdown.links, ...uriLinks, ...paths]
|
||||
const claimed = mergeTerminalFileLinkRanges(
|
||||
explicitLinks.map(({ startIndex, endIndex }): [number, number] => [startIndex, endIndex])
|
||||
claimingLinks.map(({ startIndex, endIndex }): [number, number] => [startIndex, endIndex])
|
||||
)
|
||||
for (const link of detectBareFilenameLinks(markdown.remainingText, claimed)) {
|
||||
explicitLinks.push(link)
|
||||
return {
|
||||
links: [...claimingLinks, ...detectBareFilenameLinks(markdown.remainingText, claimed)].sort(
|
||||
byStart
|
||||
),
|
||||
guesses: spacedPaths
|
||||
}
|
||||
return explicitLinks.sort((a, b) => a.startIndex - b.startIndex || b.endIndex - a.endIndex)
|
||||
}
|
||||
|
||||
export function extractTerminalFileLinks(lineText: string): ParsedTerminalFileLink[] {
|
||||
return assembleFileLinks(lineText, false)
|
||||
return extractTerminalFileLinkReadings(lineText).links
|
||||
}
|
||||
|
||||
export function extractTerminalFileLinkCandidates(lineText: string): ParsedTerminalFileLink[] {
|
||||
return assembleFileLinks(lineText, true)
|
||||
function byStart(a: ParsedTerminalFileLink, b: ParsedTerminalFileLink): number {
|
||||
return a.startIndex - b.startIndex || b.endIndex - a.endIndex
|
||||
}
|
||||
|
||||
export function resolveTerminalFileLink(
|
||||
@@ -295,12 +130,6 @@ export function resolveTerminalFileLink(
|
||||
return resolveExplicitFileLinkTarget(parsed, cwd, homePath)
|
||||
}
|
||||
|
||||
/** The file link spanning all of `linkText`, as an OSC 8 target or other whole-text link names it. */
|
||||
export function findExactTerminalFileLink(linkText: string): ParsedTerminalFileLink | null {
|
||||
const links = extractTerminalFileLinks(linkText)
|
||||
return links.find((link) => link.startIndex === 0 && link.endIndex === linkText.length) ?? null
|
||||
}
|
||||
|
||||
export function isPathInsideWorktree(filePath: string, worktreePath: string): boolean {
|
||||
const normalizedFile = normalizeAbsolutePath(filePath)
|
||||
const normalizedWorktree = normalizeAbsolutePath(worktreePath)
|
||||
|
||||
@@ -1,90 +1,88 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { extractTerminalFileLinkCandidates, extractTerminalFileLinks } from './terminal-links'
|
||||
import { extractTerminalFileLinks } from './terminal-links'
|
||||
|
||||
for (const extract of [extractTerminalFileLinks, extractTerminalFileLinkCandidates]) {
|
||||
describe(extract.name, () => {
|
||||
it.each([
|
||||
[
|
||||
'[/tmp/Каталог/отчет.pdf](/tmp/Каталог/отчет.pdf)',
|
||||
'/tmp/Каталог/отчет.pdf',
|
||||
'/tmp/Каталог/отчет.pdf'
|
||||
],
|
||||
['[/wrong/file.pdf](/tmp/right.pdf)', '/tmp/right.pdf', '/tmp/right.pdf'],
|
||||
[
|
||||
'[PDF](/tmp/folder%20with%20spaces/report.pdf)',
|
||||
'/tmp/folder%20with%20spaces/report.pdf',
|
||||
'/tmp/folder with spaces/report.pdf'
|
||||
],
|
||||
[
|
||||
'[PDF](/tmp/literal%2520/report.pdf)',
|
||||
'/tmp/literal%2520/report.pdf',
|
||||
'/tmp/literal%20/report.pdf'
|
||||
],
|
||||
[
|
||||
'[PDF](</tmp/folder with spaces/report.pdf>)',
|
||||
'/tmp/folder with spaces/report.pdf',
|
||||
'/tmp/folder with spaces/report.pdf'
|
||||
],
|
||||
[
|
||||
'[route](app/(shop)/products/[id]/page.tsx:12:3 "source")',
|
||||
'app/(shop)/products/[id]/page.tsx:12:3',
|
||||
'app/(shop)/products/[id]/page.tsx'
|
||||
],
|
||||
[
|
||||
'[route](/tmp/report\\(final\\).pdf)',
|
||||
'/tmp/report\\(final\\).pdf',
|
||||
'/tmp/report(final).pdf'
|
||||
],
|
||||
[
|
||||
'[file](file:///tmp/folder%20name/report.pdf#L12C3)',
|
||||
'file:///tmp/folder%20name/report.pdf#L12C3',
|
||||
'/tmp/folder name/report.pdf'
|
||||
],
|
||||
[
|
||||
'[source](C:\\repo\\src\\file.ts:12:3)',
|
||||
'C:\\repo\\src\\file.ts:12:3',
|
||||
'C:\\repo\\src\\file.ts'
|
||||
],
|
||||
[
|
||||
'[cfg](C:\\Users\\me\\.config\\app.json)',
|
||||
'C:\\Users\\me\\.config\\app.json',
|
||||
'C:\\Users\\me\\.config\\app.json'
|
||||
],
|
||||
['[ci](src\\.github\\ci.yml)', 'src\\.github\\ci.yml', 'src\\.github\\ci.yml'],
|
||||
['[source](README.md:12:3)', 'README.md:12:3', 'README.md'],
|
||||
['[PDF](/tmp/100%/report.pdf)', '/tmp/100%/report.pdf', '/tmp/100%/report.pdf']
|
||||
])('extracts only destination from %s', (text, displayText, pathText) => {
|
||||
const links = extract(`before ${text} after`)
|
||||
expect(links).toHaveLength(1)
|
||||
expect(links[0]).toMatchObject({ pathText, displayText })
|
||||
expect(`before ${text} after`.slice(links[0].startIndex, links[0].endIndex)).toBe(displayText)
|
||||
if (text.includes(':12:3') || text.includes('#L12C3')) {
|
||||
expect(links[0]).toMatchObject({ line: 12, column: 3 })
|
||||
}
|
||||
})
|
||||
|
||||
it('preserves bare percent filenames and route punctuation beside Markdown', () => {
|
||||
expect(
|
||||
extract('/tmp/literal%20/file.pdf [PDF](/tmp/report.pdf) app/(shop)/[id]/page.tsx').map(
|
||||
(link) => link.pathText
|
||||
)
|
||||
).toEqual(['/tmp/literal%20/file.pdf', '/tmp/report.pdf', 'app/(shop)/[id]/page.tsx'])
|
||||
})
|
||||
|
||||
it('does not turn an external link label into a local file target', () => {
|
||||
expect(extract('[/tmp/misleading.pdf](https://example.com/report.pdf)')).toEqual([])
|
||||
})
|
||||
|
||||
it('preserves adjacent route bracket and parenthesis segments in bare paths', () => {
|
||||
expect(extract('/tmp/[id](group)/page.tsx').map((link) => link.pathText)).toEqual([
|
||||
'/tmp/[id](group)/page.tsx'
|
||||
])
|
||||
})
|
||||
|
||||
it('finds adjacent Markdown destinations independently', () => {
|
||||
expect(
|
||||
extract('[one](/tmp/one.pdf)[two](/tmp/two.pdf)').map((link) => link.pathText)
|
||||
).toEqual(['/tmp/one.pdf', '/tmp/two.pdf'])
|
||||
})
|
||||
describe('extractTerminalFileLinks', () => {
|
||||
it.each([
|
||||
[
|
||||
'[/tmp/Каталог/отчет.pdf](/tmp/Каталог/отчет.pdf)',
|
||||
'/tmp/Каталог/отчет.pdf',
|
||||
'/tmp/Каталог/отчет.pdf'
|
||||
],
|
||||
['[/wrong/file.pdf](/tmp/right.pdf)', '/tmp/right.pdf', '/tmp/right.pdf'],
|
||||
[
|
||||
'[PDF](/tmp/folder%20with%20spaces/report.pdf)',
|
||||
'/tmp/folder%20with%20spaces/report.pdf',
|
||||
'/tmp/folder with spaces/report.pdf'
|
||||
],
|
||||
[
|
||||
'[PDF](/tmp/literal%2520/report.pdf)',
|
||||
'/tmp/literal%2520/report.pdf',
|
||||
'/tmp/literal%20/report.pdf'
|
||||
],
|
||||
[
|
||||
'[PDF](</tmp/folder with spaces/report.pdf>)',
|
||||
'/tmp/folder with spaces/report.pdf',
|
||||
'/tmp/folder with spaces/report.pdf'
|
||||
],
|
||||
[
|
||||
'[route](app/(shop)/products/[id]/page.tsx:12:3 "source")',
|
||||
'app/(shop)/products/[id]/page.tsx:12:3',
|
||||
'app/(shop)/products/[id]/page.tsx'
|
||||
],
|
||||
['[route](/tmp/report\\(final\\).pdf)', '/tmp/report\\(final\\).pdf', '/tmp/report(final).pdf'],
|
||||
[
|
||||
'[file](file:///tmp/folder%20name/report.pdf#L12C3)',
|
||||
'file:///tmp/folder%20name/report.pdf#L12C3',
|
||||
'/tmp/folder name/report.pdf'
|
||||
],
|
||||
[
|
||||
'[source](C:\\repo\\src\\file.ts:12:3)',
|
||||
'C:\\repo\\src\\file.ts:12:3',
|
||||
'C:\\repo\\src\\file.ts'
|
||||
],
|
||||
[
|
||||
'[cfg](C:\\Users\\me\\.config\\app.json)',
|
||||
'C:\\Users\\me\\.config\\app.json',
|
||||
'C:\\Users\\me\\.config\\app.json'
|
||||
],
|
||||
['[ci](src\\.github\\ci.yml)', 'src\\.github\\ci.yml', 'src\\.github\\ci.yml'],
|
||||
['[source](README.md:12:3)', 'README.md:12:3', 'README.md'],
|
||||
['[PDF](/tmp/100%/report.pdf)', '/tmp/100%/report.pdf', '/tmp/100%/report.pdf']
|
||||
])('extracts only destination from %s', (text, displayText, pathText) => {
|
||||
const links = extractTerminalFileLinks(`before ${text} after`)
|
||||
expect(links).toHaveLength(1)
|
||||
expect(links[0]).toMatchObject({ pathText, displayText })
|
||||
expect(`before ${text} after`.slice(links[0].startIndex, links[0].endIndex)).toBe(displayText)
|
||||
if (text.includes(':12:3') || text.includes('#L12C3')) {
|
||||
expect(links[0]).toMatchObject({ line: 12, column: 3 })
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
it('preserves bare percent filenames and route punctuation beside Markdown', () => {
|
||||
expect(
|
||||
extractTerminalFileLinks(
|
||||
'/tmp/literal%20/file.pdf [PDF](/tmp/report.pdf) app/(shop)/[id]/page.tsx'
|
||||
).map((link) => link.pathText)
|
||||
).toEqual(['/tmp/literal%20/file.pdf', '/tmp/report.pdf', 'app/(shop)/[id]/page.tsx'])
|
||||
})
|
||||
|
||||
it('does not turn an external link label into a local file target', () => {
|
||||
expect(
|
||||
extractTerminalFileLinks('[/tmp/misleading.pdf](https://example.com/report.pdf)')
|
||||
).toEqual([])
|
||||
})
|
||||
|
||||
it('preserves adjacent route bracket and parenthesis segments in bare paths', () => {
|
||||
expect(
|
||||
extractTerminalFileLinks('/tmp/[id](group)/page.tsx').map((link) => link.pathText)
|
||||
).toEqual(['/tmp/[id](group)/page.tsx'])
|
||||
})
|
||||
|
||||
it('finds adjacent Markdown destinations independently', () => {
|
||||
expect(
|
||||
extractTerminalFileLinks('[one](/tmp/one.pdf)[two](/tmp/two.pdf)').map(
|
||||
(link) => link.pathText
|
||||
)
|
||||
).toEqual(['/tmp/one.pdf', '/tmp/two.pdf'])
|
||||
})
|
||||
})
|
||||
|
||||
@@ -3,7 +3,8 @@ import {
|
||||
resolveTerminalFileUrlTarget,
|
||||
type TerminalFileUrlTarget
|
||||
} from './terminal-file-url-target'
|
||||
import { findExactTerminalFileLink, type ParsedTerminalFileLink } from './terminal-links'
|
||||
import { toParsedTerminalFileLink } from './terminal-file-link-detection-ranges'
|
||||
import type { ParsedTerminalFileLink } from './terminal-links'
|
||||
|
||||
export type TerminalOscLinkTarget =
|
||||
| { kind: 'url'; url: string }
|
||||
@@ -21,8 +22,11 @@ export function classifyTerminalOscLinkTarget(
|
||||
rawText: string,
|
||||
options: TerminalOscLinkTargetOptions
|
||||
): TerminalOscLinkTarget | null {
|
||||
// Why: a hyperlink names its whole target, so the path is taken as written, never detected in it.
|
||||
const pathTarget = (): TerminalOscLinkTarget | null => {
|
||||
const link = findExactTerminalFileLink(rawText)
|
||||
const link = rawText.includes('://')
|
||||
? null
|
||||
: toParsedTerminalFileLink({ text: rawText, startIndex: 0, endIndex: rawText.length })
|
||||
return link ? { kind: 'path', link } : null
|
||||
}
|
||||
const windowsPath =
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
import {
|
||||
ROOTED_PATH_START_PATTERN,
|
||||
hasPathSeparator,
|
||||
trimTerminalPathBoundaryPunctuation,
|
||||
type DetectedTerminalFileLinkRange
|
||||
} from './terminal-file-link-detection-ranges'
|
||||
|
||||
// Spaced readings are guesses the host's existence check settles, so no rule predicts where a path ends.
|
||||
const MAX_SPACED_PATH_WORDS = 10
|
||||
// Why: the path regex stops at `#&=!`, which spaced folder names (`C# Projects`) may contain.
|
||||
const PATH_WORD_CONTINUATION_REGEX = /[^\s()[\]{}'",;<>|`]*/y
|
||||
// One single-spaced word; quotes and brackets end a spaced path.
|
||||
const SPACED_PATH_WORD_REGEX = / ([^\s()[\]{}'",;<>|`]+)/y
|
||||
const FILENAME_PATTERN = /\.[\p{L}\p{N}][\p{L}\p{N}\p{M}_+-]*(?::\d+)?(?::\d+)?[.,;:!?]*$/u
|
||||
|
||||
/** Rooted: one reading per following word, up to ten; relative: its first filename. `wordEndIndex` ends the path's own word. */
|
||||
export function growSpacedPathRanges(
|
||||
lineText: string,
|
||||
path: DetectedTerminalFileLinkRange,
|
||||
rawEndIndex: number
|
||||
): { ranges: DetectedTerminalFileLinkRange[]; wordEndIndex: number } {
|
||||
PATH_WORD_CONTINUATION_REGEX.lastIndex = rawEndIndex
|
||||
const continuation = PATH_WORD_CONTINUATION_REGEX.exec(lineText)?.[0] ?? ''
|
||||
const wordEndIndex = rawEndIndex + continuation.length
|
||||
const ranges: DetectedTerminalFileLinkRange[] = []
|
||||
// Why: a trimmed closer (`(src/a.ts) now`) ends the path, a URL is a different link, and
|
||||
// a word that continues the path past unsupported punctuation (`/tmp/$draft/a.md`) is never one link.
|
||||
if (
|
||||
/[^.]/.test(lineText.slice(path.endIndex, rawEndIndex)) ||
|
||||
continuation.includes('://') ||
|
||||
hasPathSeparator(continuation)
|
||||
) {
|
||||
return { ranges, wordEndIndex }
|
||||
}
|
||||
// Why: relative paths come in dense lists (`src/a.ts src/b.ts`), so they grow only to their first filename.
|
||||
const relative = !ROOTED_PATH_START_PATTERN.test(path.text)
|
||||
if (relative && FILENAME_PATTERN.test(path.text + continuation)) {
|
||||
return { ranges, wordEndIndex }
|
||||
}
|
||||
SPACED_PATH_WORD_REGEX.lastIndex = wordEndIndex
|
||||
let match: RegExpExecArray | null
|
||||
for (
|
||||
let words = 0;
|
||||
words < MAX_SPACED_PATH_WORDS &&
|
||||
(match = SPACED_PATH_WORD_REGEX.exec(lineText)) !== null &&
|
||||
// Why: `./a.ts ./b.ts` names two paths, and a `scheme://` word starts a URL.
|
||||
!ROOTED_PATH_START_PATTERN.test(match[1]) &&
|
||||
!match[1].includes('://');
|
||||
words += 1
|
||||
) {
|
||||
const filename = FILENAME_PATTERN.test(match[1])
|
||||
if (relative && !filename) {
|
||||
continue
|
||||
}
|
||||
const range = trimTerminalPathBoundaryPunctuation(
|
||||
lineText.slice(path.startIndex, SPACED_PATH_WORD_REGEX.lastIndex),
|
||||
path.startIndex
|
||||
)
|
||||
if (range) {
|
||||
ranges.push(range)
|
||||
}
|
||||
if (relative) {
|
||||
break
|
||||
}
|
||||
}
|
||||
return { ranges, wordEndIndex }
|
||||
}
|
||||
Reference in New Issue
Block a user