Path: blob/main/extensions/copilot/src/extension/chatSessions/vscode-node/copilotCLITerminalLinkProvider.ts
13399 views
/*---------------------------------------------------------------------------------------------1* Copyright (c) Microsoft Corporation. All rights reserved.2* Licensed under the MIT License. See License.txt in the project root for license information.3*--------------------------------------------------------------------------------------------*/45import * as l10n from '@vscode/l10n';6import { homedir } from 'os';7import { CancellationToken, FileType, Range, Terminal, TerminalLink, TerminalLinkContext, TerminalLinkProvider, Uri, window, workspace } from 'vscode';8import { ILogService } from '../../../platform/log/common/logService';9import { IWorkspaceService } from '../../../platform/workspace/common/workspaceService';10import { extUriBiasedIgnorePathCase } from '../../../util/vs/base/common/resources';11import { getCopilotHome } from '../copilotcli/node/cliHelpers';1213const UNTRUSTED_COPILOT_HOME_MESSAGE = l10n.t('The Copilot home directory is not trusted. Please trust the directory to open this file.');1415/**16*17* We keep parsing in two phases to mirror VS Code's shape:18* 1) detect suffixes (e.g. :12:3, (12, 3)) and resolve the path before them19* 2) detect path-only links (no suffix)20*/21const EXCLUDED_PATH_CHARS = '[^\\0<>\\?\\s!`&*()\'":;\\\\]';22const EXCLUDED_START_PATH_CHARS = '[^\\0<>\\?\\s!`&*()\\[\\]\'":;\\\\]';23const EXCLUDED_STANDALONE_CHARS = '[^\\0<>\\?\\s!`&*()\'":;\\\\/]';24const EXCLUDED_START_STANDALONE_CHARS = '[^\\0<>\\?\\s!`&*()\\[\\]\'":;\\\\/]';2526const MAX_NESTED_LOOKUP_DIRS = 400;27const MAX_NESTED_LOOKUP_ENTRIES = 10000;2829const PATH_WITH_SEPARATOR_CLAUSE = '(?:(?:\\.\\.?|~)|(?:' + EXCLUDED_START_PATH_CHARS + EXCLUDED_PATH_CHARS + '*))?(?:[\\\\/](?:' + EXCLUDED_PATH_CHARS + ')+)+';30const STANDALONE_DOTTED_FILENAME_CLAUSE = '(?:' + EXCLUDED_START_STANDALONE_CHARS + EXCLUDED_STANDALONE_CHARS + '*\\.[^\\0<>\\?\\s!`&*()\'":;\\\\/.]+' + EXCLUDED_STANDALONE_CHARS + '*)';31const PATH_CLAUSE = '(?<path>(?:' + PATH_WITH_SEPARATOR_CLAUSE + ')|(?:' + STANDALONE_DOTTED_FILENAME_CLAUSE + '))';3233const PATH_REGEX = new RegExp(PATH_CLAUSE, 'g');34const PATH_BEFORE_SUFFIX_REGEX = new RegExp(PATH_CLAUSE + '$');35const LINK_SUFFIX_REGEX = /(?::(?<line>\d+)(?::(?<col>\d+))?|\((?<parenLine>\d+),\s*(?<parenCol>\d+)\))/g;3637interface DetectedLinkCandidate {38startIndex: number;39length: number;40pathText: string;41line?: number;42col?: number;43}4445interface CopilotCLITerminalLink extends TerminalLink {46uri?: Uri;47terminal: Terminal;48pathText: string;49line?: number;50col?: number;51}5253/**54* Returns session-state directories to try for a terminal.55*/56export type SessionDirResolver = (terminal: Terminal) => Promise<Uri[]>;5758/**59* Resolves relative file links in Copilot CLI terminal output.60*61* Copilot CLI paths are relative to the session-state directory, not the62* workspace root. VS Code's built-in detector cannot resolve that context.63*/64export class CopilotCLITerminalLinkProvider implements TerminalLinkProvider<CopilotCLITerminalLink> {6566private readonly _copilotTerminals = new WeakSet<Terminal>();67private readonly _terminalSessionDirs = new WeakMap<Terminal, Uri>();68private _sessionDirResolver: SessionDirResolver | undefined;6970constructor(71private readonly logService: ILogService,72private readonly workspaceService?: IWorkspaceService,73) { }7475/**76* Marks a terminal as a Copilot CLI terminal.77*/78registerTerminal(terminal: Terminal): void {79this._copilotTerminals.add(terminal);80}8182/**83* Sets a terminal's session-state directory for relative path resolution.84*/85setSessionDir(terminal: Terminal, sessionDir: Uri): void {86this._terminalSessionDirs.set(terminal, sessionDir);87}8889/**90* Sets a resolver used when no session directory is cached.91*/92setSessionDirResolver(resolver: SessionDirResolver): void {93this._sessionDirResolver = resolver;94}9596async provideTerminalLinks(context: TerminalLinkContext, token: CancellationToken): Promise<CopilotCLITerminalLink[]> {97const line = context.line;98// Match VS Code's built-in MaxLineLength limit (terminalLocalLinkDetector.ts).99if (!line.trim() || line.length > 2000) {100return [];101}102103const sessionDirs = await this._getSessionDirs(context.terminal);104if (!this._copilotTerminals.has(context.terminal) && sessionDirs.length === 0) {105return [];106}107const links: CopilotCLITerminalLink[] = [];108for (const candidate of this._detectLinkCandidates(line)) {109// Match VS Code's built-in MaxResolvedLinksInLine (terminalLocalLinkDetector.ts).110if (token.isCancellationRequested || links.length >= 10) {111break;112}113114let pathText = candidate.pathText;115if (!pathText || pathText.length < 3) {116continue;117}118119// Strip trailing punctuation that is unlikely part of the path.120// Mirrors VS Code's specialEndCharRegex (terminalLocalLinkDetector.ts).121let trimmed = 0;122while (pathText.length > 1 && /[\[\]"'.]$/.test(pathText)) {123pathText = pathText.slice(0, -1);124trimmed++;125}126127// Skip URLs.128if (pathText.includes('://')) {129continue;130}131132if (this._looksLikeNumericVersion(pathText)) {133continue;134}135136const lineNum = candidate.line;137const colNum = candidate.col;138139// Tilde paths: expand ~ to home directory (~/... or ~\... on Windows).140if (pathText.startsWith('~/') || pathText.startsWith('~\\')) {141const absoluteUri = Uri.file(homedir() + pathText.substring(1));142links.push({143startIndex: candidate.startIndex,144length: candidate.length - trimmed,145tooltip: absoluteUri.toString(true),146uri: absoluteUri,147terminal: context.terminal,148pathText,149line: lineNum,150col: colNum,151});152continue;153}154155// Skip absolute paths; the built-in detector handles them.156// Unix: /foo, Windows: C:\foo or \Users\foo157if (pathText.startsWith('/') || pathText.startsWith('\\') || /^[a-zA-Z]:[/\\]/.test(pathText)) {158continue;159}160161const resolved = await this._resolvePath(pathText, sessionDirs, token);162if (!resolved) {163continue;164}165166links.push({167startIndex: candidate.startIndex,168length: candidate.length - trimmed,169tooltip: resolved.toString(true),170uri: resolved,171terminal: context.terminal,172pathText,173line: lineNum,174col: colNum,175});176}177178return links;179}180181async handleTerminalLink(link: CopilotCLITerminalLink): Promise<void> {182try {183const sessionDirs = await this._getSessionDirs(link.terminal);184const resolvedCandidates = await this._resolveAllPaths(link.pathText, sessionDirs);185let uriToOpen = link.uri186?? resolvedCandidates[0]187?? this._getFallbackUri(link.pathText, sessionDirs);188189if (resolvedCandidates.length > 1) {190const pick = await window.showQuickPick(191resolvedCandidates.map(uri => ({192label: this._labelCandidate(uri, sessionDirs),193description: this._describeCandidate(uri, sessionDirs),194uri,195})),196{ placeHolder: l10n.t("Select which '{0}' to open", link.pathText) }197);198if (!pick) {199return;200}201uriToOpen = pick.uri;202}203204if (!uriToOpen) {205return;206}207208if (this.workspaceService && this._isInCopilotHome(uriToOpen)) {209const trusted = await this.workspaceService.requestResourceTrust({210uri: Uri.file(getCopilotHome()),211message: UNTRUSTED_COPILOT_HOME_MESSAGE,212});213if (!trusted) {214return;215}216}217218await window.showTextDocument(uriToOpen, {219selection: link.line !== undefined220? new Range(221link.line - 1,222(link.col ?? 1) - 1,223link.line - 1,224(link.col ?? 1) - 1225)226: undefined,227});228} catch (e) {229this.logService.error('Failed to open terminal link', e);230}231}232233/**234* Returns candidate session directories for a terminal.235*236* Resolver results (from active sessions) are tried first because the237* resolver can order them by terminal affinity — sessions that belong to238* THIS terminal come before unrelated sessions. A cached dir from239* {@link setSessionDir} is appended only as a last-resort fallback when it240* is no longer among the active sessions (i.e. the session ended but its241* files may still be on disk). See https://github.com/microsoft/vscode/issues/301594.242*/243private _isInCopilotHome(uri: Uri): boolean {244return extUriBiasedIgnorePathCase.isEqualOrParent(uri, Uri.file(getCopilotHome()));245}246247private async _getSessionDirs(terminal: Terminal): Promise<Uri[]> {248const cached = this._terminalSessionDirs.get(terminal);249250if (this._sessionDirResolver) {251const resolved = await this._sessionDirResolver(terminal);252const cachedFsPath = cached?.fsPath;253// Resolver results are already ordered by terminal affinity.254const dirs = [...resolved];255// If the cached dir is not among the active sessions it is stale256// (the session ended). Append it as a fallback instead of257// putting it first where it would shadow the current session.258if (cached && !resolved.some(dir => dir.fsPath === cachedFsPath)) {259dirs.push(cached);260}261return dirs;262}263264return cached ? [cached] : [];265}266267/**268* Resolves a relative path to the first existing file on disk. Used by269* {@link provideTerminalLinks} on hover where we only need to know that a270* match exists to underline the text. Returns early on first hit.271*272* Search order must stay consistent with {@link _resolveAllPaths}:273* 1. Each session-state directory (e.g., `~/.copilot/session-state/{uuid}/`)274* 2. Workspace folders275* 3. `files/` under each session dir (non-bare paths only)276*/277private async _resolvePath(pathText: string, sessionDirs: readonly Uri[], token?: CancellationToken): Promise<Uri | undefined> {278const isBareFilename = !pathText.includes('/') && !pathText.includes('\\');279const isDotRelative = pathText.startsWith('./') || pathText.startsWith('.\\') || pathText.startsWith('../') || pathText.startsWith('..\\');280const alreadyFilesRelative = pathText.startsWith('files/') || pathText.startsWith('files\\');281const shouldTryFilesFallback = !isBareFilename && !isDotRelative && !alreadyFilesRelative;282283// Session-state directories first; CLI paths are relative to them.284for (const sessionDir of sessionDirs) {285if (token?.isCancellationRequested) {286return undefined;287}288289if (await this._exists(Uri.joinPath(sessionDir, pathText))) {290return Uri.joinPath(sessionDir, pathText);291}292if (isBareFilename && await this._exists(Uri.joinPath(sessionDir, 'files', pathText))) {293return Uri.joinPath(sessionDir, 'files', pathText);294}295if (isBareFilename) {296const nested = await this._findNestedBareFilenameInSessionDir(sessionDir, pathText, token);297if (nested) {298return nested;299}300}301}302303// Workspace folders.304for (const folder of workspace.workspaceFolders ?? []) {305if (token?.isCancellationRequested) {306return undefined;307}308if (await this._exists(Uri.joinPath(folder.uri, pathText))) {309return Uri.joinPath(folder.uri, pathText);310}311}312313// files/<path> under session dirs for non-bare paths (the CLI sometimes314// emits workspace-relative paths that actually live under the session's315// files/ mirror).316if (shouldTryFilesFallback) {317for (const sessionDir of sessionDirs) {318if (token?.isCancellationRequested) {319return undefined;320}321if (await this._exists(Uri.joinPath(sessionDir, 'files', pathText))) {322return Uri.joinPath(sessionDir, 'files', pathText);323}324}325}326327return undefined;328}329330/**331* Resolves a relative path to every existing file on disk. Used by332* {@link handleTerminalLink} on click to populate a picker when the path333* is ambiguous (e.g. `plan.md` exists in both the session-state directory334* and the workspace root).335*336* Same search order as {@link _resolvePath} but collects all hits.337*/338private async _resolveAllPaths(pathText: string, sessionDirs: readonly Uri[]): Promise<Uri[]> {339const isBareFilename = !pathText.includes('/') && !pathText.includes('\\');340const isDotRelative = pathText.startsWith('./') || pathText.startsWith('.\\') || pathText.startsWith('../') || pathText.startsWith('..\\');341const alreadyFilesRelative = pathText.startsWith('files/') || pathText.startsWith('files\\');342const shouldTryFilesFallback = !isBareFilename && !isDotRelative && !alreadyFilesRelative;343344const resolved: Uri[] = [];345const seen = new Set<string>();346const addIfExists = async (candidate: Uri): Promise<void> => {347if (seen.has(candidate.fsPath)) {348return;349}350seen.add(candidate.fsPath);351if (await this._exists(candidate)) {352resolved.push(candidate);353}354};355356// Session-state directories first; CLI paths are relative to them.357for (const sessionDir of sessionDirs) {358await addIfExists(Uri.joinPath(sessionDir, pathText));359if (isBareFilename) {360await addIfExists(Uri.joinPath(sessionDir, 'files', pathText));361const nested = await this._findNestedBareFilenameInSessionDir(sessionDir, pathText);362if (nested && !seen.has(nested.fsPath)) {363seen.add(nested.fsPath);364resolved.push(nested);365}366}367}368369// Workspace folders.370for (const folder of workspace.workspaceFolders ?? []) {371await addIfExists(Uri.joinPath(folder.uri, pathText));372}373374// files/<path> under session dirs for non-bare paths.375if (shouldTryFilesFallback) {376for (const sessionDir of sessionDirs) {377await addIfExists(Uri.joinPath(sessionDir, 'files', pathText));378}379}380381return resolved;382}383384private async _exists(uri: Uri): Promise<boolean> {385try {386await workspace.fs.stat(uri);387return true;388} catch {389return false;390}391}392393private _labelCandidate(uri: Uri, sessionDirs: readonly Uri[]): string {394return this._relativeTo(uri, sessionDirs)395?? this._relativeTo(uri, workspace.workspaceFolders?.map(f => f.uri) ?? [])396?? uri.fsPath.split(/[\\/]/).pop()397?? uri.fsPath;398}399400private _describeCandidate(uri: Uri, sessionDirs: readonly Uri[]): string {401const normalizedCandidatePath = uri.fsPath.replace(/\\/g, '/');402for (const sessionDir of sessionDirs) {403const normalizedSessionPath = sessionDir.fsPath.replace(/\\/g, '/').replace(/\/$/, '');404if (normalizedCandidatePath.startsWith(`${normalizedSessionPath}/`)) {405const sessionId = normalizedSessionPath.split('/').pop();406return `session-state/${sessionId}`;407}408}409410if (this._relativeTo(uri, workspace.workspaceFolders?.map(f => f.uri) ?? [])) {411return 'workspace';412}413414return 'resolved path';415}416417/**418* Returns the path of `uri` relative to the first matching base directory,419* or `undefined` if `uri` is not inside any of them. Compares with420* normalized separators.421*/422private _relativeTo(uri: Uri, baseDirs: readonly Uri[]): string | undefined {423const normalizedCandidatePath = uri.fsPath.replace(/\\/g, '/');424for (const baseDir of baseDirs) {425const normalizedBasePath = baseDir.fsPath.replace(/\\/g, '/').replace(/\/$/, '');426const prefix = `${normalizedBasePath}/`;427if (normalizedCandidatePath.startsWith(prefix)) {428return normalizedCandidatePath.slice(prefix.length);429}430}431return undefined;432}433434private async _findNestedBareFilenameInSessionDir(sessionDir: Uri, basename: string, token?: CancellationToken): Promise<Uri | undefined> {435const queue: Uri[] = [sessionDir];436const matches: Uri[] = [];437const visited = new Set<string>();438let scannedDirCount = 0;439let scannedEntryCount = 0;440441for (let i = 0; i < queue.length; i++) {442if (token?.isCancellationRequested || scannedDirCount >= MAX_NESTED_LOOKUP_DIRS || scannedEntryCount >= MAX_NESTED_LOOKUP_ENTRIES) {443break;444}445446const dir = queue[i];447const normalizedDir = dir.fsPath.replace(/\\/g, '/');448if (visited.has(normalizedDir)) {449continue;450}451visited.add(normalizedDir);452scannedDirCount++;453454let entries: [string, FileType][];455try {456entries = await workspace.fs.readDirectory(dir);457} catch {458continue;459}460461for (const [name, type] of entries) {462scannedEntryCount++;463if (token?.isCancellationRequested || scannedEntryCount >= MAX_NESTED_LOOKUP_ENTRIES) {464break;465}466467const candidate = Uri.joinPath(dir, name);468if ((type & FileType.File) !== 0 && name === basename) {469matches.push(candidate);470continue;471}472473if ((type & FileType.Directory) !== 0 && (type & FileType.SymbolicLink) === 0) {474queue.push(candidate);475}476}477}478479if (matches.length === 0) {480return undefined;481}482483const normalizedSessionPath = sessionDir.fsPath.replace(/\\/g, '/').replace(/\/$/, '');484const sessionPathPrefix = `${normalizedSessionPath}/`;485matches.sort((a, b) => {486const pathA = a.fsPath.replace(/\\/g, '/');487const pathB = b.fsPath.replace(/\\/g, '/');488const relA = pathA.startsWith(sessionPathPrefix) ? pathA.slice(sessionPathPrefix.length) : pathA;489const relB = pathB.startsWith(sessionPathPrefix) ? pathB.slice(sessionPathPrefix.length) : pathB;490491const scoreA = this._nestedBareFilenameScore(relA, basename);492const scoreB = this._nestedBareFilenameScore(relB, basename);493if (scoreA !== scoreB) {494return scoreA - scoreB;495}496497return relA.localeCompare(relB);498});499500return matches[0];501}502503private _looksLikeNumericVersion(pathText: string): boolean {504// Avoid false-positive links for version-like tokens such as 1.2.505if (pathText.includes('/') || pathText.includes('\\')) {506return false;507}508509return /^\d+(?:\.\d+)+$/.test(pathText);510}511512private _nestedBareFilenameScore(relativePath: string, basename: string): number {513if (relativePath === `files/${basename}`) {514return 0;515}516517if (relativePath === basename) {518return 1;519}520521if (relativePath.startsWith('files/')) {522return 2;523}524525return 10 + relativePath.split('/').length;526}527528private _getFallbackUri(pathText: string, sessionDirs: readonly Uri[]): Uri | undefined {529const sessionDir = sessionDirs[0];530if (sessionDir) {531return Uri.joinPath(sessionDir, pathText);532}533534const workspaceFolder = workspace.workspaceFolders?.[0];535if (workspaceFolder) {536return Uri.joinPath(workspaceFolder.uri, pathText);537}538539return undefined;540}541542private _detectLinkCandidates(line: string): DetectedLinkCandidate[] {543const candidates: DetectedLinkCandidate[] = [];544545// Phase 1: Detect suffixes and resolve a path directly before each suffix.546const suffixRegex = new RegExp(LINK_SUFFIX_REGEX.source, LINK_SUFFIX_REGEX.flags);547for (const match of line.matchAll(suffixRegex)) {548const suffixStartIndex = match.index;549if (suffixStartIndex === undefined) {550continue;551}552553const beforeSuffix = line.slice(0, suffixStartIndex);554const pathMatch = beforeSuffix.match(PATH_BEFORE_SUFFIX_REGEX);555const pathText = pathMatch?.groups?.['path'];556if (!pathText) {557continue;558}559560const startIndex = suffixStartIndex - pathText.length;561const length = pathText.length + match[0].length;562const lineText = match.groups?.['line'] ?? match.groups?.['parenLine'];563const colText = match.groups?.['col'] ?? match.groups?.['parenCol'];564565candidates.push({566startIndex,567length,568pathText,569line: lineText ? parseInt(lineText, 10) : undefined,570col: colText ? parseInt(colText, 10) : undefined,571});572}573574// Phase 2: Detect path-only links and merge non-overlapping ranges.575const pathRegex = new RegExp(PATH_REGEX.source, PATH_REGEX.flags);576for (const match of line.matchAll(pathRegex)) {577const startIndex = match.index;578const pathText = match.groups?.['path'];579if (startIndex === undefined || !pathText) {580continue;581}582583const endIndex = startIndex + pathText.length;584if (candidates.some(candidate => this._rangesOverlap(startIndex, endIndex, candidate.startIndex, candidate.startIndex + candidate.length))) {585continue;586}587588candidates.push({589startIndex,590length: pathText.length,591pathText,592});593}594595candidates.sort((a, b) => a.startIndex - b.startIndex);596return candidates;597}598599private _rangesOverlap(startA: number, endA: number, startB: number, endB: number): boolean {600return startA < endB && startB < endA;601}602}603604605