Book a Demo!
CoCalc Logo Icon
StoreFeaturesDocsShareSupportNewsAboutPoliciesSign UpSign In
microsoft
GitHub Repository: microsoft/vscode
Path: blob/main/extensions/copilot/src/extension/chatSessions/vscode-node/copilotCLITerminalLinkProvider.ts
13399 views
1
/*---------------------------------------------------------------------------------------------
2
* Copyright (c) Microsoft Corporation. All rights reserved.
3
* Licensed under the MIT License. See License.txt in the project root for license information.
4
*--------------------------------------------------------------------------------------------*/
5
6
import * as l10n from '@vscode/l10n';
7
import { homedir } from 'os';
8
import { CancellationToken, FileType, Range, Terminal, TerminalLink, TerminalLinkContext, TerminalLinkProvider, Uri, window, workspace } from 'vscode';
9
import { ILogService } from '../../../platform/log/common/logService';
10
import { IWorkspaceService } from '../../../platform/workspace/common/workspaceService';
11
import { extUriBiasedIgnorePathCase } from '../../../util/vs/base/common/resources';
12
import { getCopilotHome } from '../copilotcli/node/cliHelpers';
13
14
const UNTRUSTED_COPILOT_HOME_MESSAGE = l10n.t('The Copilot home directory is not trusted. Please trust the directory to open this file.');
15
16
/**
17
*
18
* We keep parsing in two phases to mirror VS Code's shape:
19
* 1) detect suffixes (e.g. :12:3, (12, 3)) and resolve the path before them
20
* 2) detect path-only links (no suffix)
21
*/
22
const EXCLUDED_PATH_CHARS = '[^\\0<>\\?\\s!`&*()\'":;\\\\]';
23
const EXCLUDED_START_PATH_CHARS = '[^\\0<>\\?\\s!`&*()\\[\\]\'":;\\\\]';
24
const EXCLUDED_STANDALONE_CHARS = '[^\\0<>\\?\\s!`&*()\'":;\\\\/]';
25
const EXCLUDED_START_STANDALONE_CHARS = '[^\\0<>\\?\\s!`&*()\\[\\]\'":;\\\\/]';
26
27
const MAX_NESTED_LOOKUP_DIRS = 400;
28
const MAX_NESTED_LOOKUP_ENTRIES = 10000;
29
30
const PATH_WITH_SEPARATOR_CLAUSE = '(?:(?:\\.\\.?|~)|(?:' + EXCLUDED_START_PATH_CHARS + EXCLUDED_PATH_CHARS + '*))?(?:[\\\\/](?:' + EXCLUDED_PATH_CHARS + ')+)+';
31
const STANDALONE_DOTTED_FILENAME_CLAUSE = '(?:' + EXCLUDED_START_STANDALONE_CHARS + EXCLUDED_STANDALONE_CHARS + '*\\.[^\\0<>\\?\\s!`&*()\'":;\\\\/.]+' + EXCLUDED_STANDALONE_CHARS + '*)';
32
const PATH_CLAUSE = '(?<path>(?:' + PATH_WITH_SEPARATOR_CLAUSE + ')|(?:' + STANDALONE_DOTTED_FILENAME_CLAUSE + '))';
33
34
const PATH_REGEX = new RegExp(PATH_CLAUSE, 'g');
35
const PATH_BEFORE_SUFFIX_REGEX = new RegExp(PATH_CLAUSE + '$');
36
const LINK_SUFFIX_REGEX = /(?::(?<line>\d+)(?::(?<col>\d+))?|\((?<parenLine>\d+),\s*(?<parenCol>\d+)\))/g;
37
38
interface DetectedLinkCandidate {
39
startIndex: number;
40
length: number;
41
pathText: string;
42
line?: number;
43
col?: number;
44
}
45
46
interface CopilotCLITerminalLink extends TerminalLink {
47
uri?: Uri;
48
terminal: Terminal;
49
pathText: string;
50
line?: number;
51
col?: number;
52
}
53
54
/**
55
* Returns session-state directories to try for a terminal.
56
*/
57
export type SessionDirResolver = (terminal: Terminal) => Promise<Uri[]>;
58
59
/**
60
* Resolves relative file links in Copilot CLI terminal output.
61
*
62
* Copilot CLI paths are relative to the session-state directory, not the
63
* workspace root. VS Code's built-in detector cannot resolve that context.
64
*/
65
export class CopilotCLITerminalLinkProvider implements TerminalLinkProvider<CopilotCLITerminalLink> {
66
67
private readonly _copilotTerminals = new WeakSet<Terminal>();
68
private readonly _terminalSessionDirs = new WeakMap<Terminal, Uri>();
69
private _sessionDirResolver: SessionDirResolver | undefined;
70
71
constructor(
72
private readonly logService: ILogService,
73
private readonly workspaceService?: IWorkspaceService,
74
) { }
75
76
/**
77
* Marks a terminal as a Copilot CLI terminal.
78
*/
79
registerTerminal(terminal: Terminal): void {
80
this._copilotTerminals.add(terminal);
81
}
82
83
/**
84
* Sets a terminal's session-state directory for relative path resolution.
85
*/
86
setSessionDir(terminal: Terminal, sessionDir: Uri): void {
87
this._terminalSessionDirs.set(terminal, sessionDir);
88
}
89
90
/**
91
* Sets a resolver used when no session directory is cached.
92
*/
93
setSessionDirResolver(resolver: SessionDirResolver): void {
94
this._sessionDirResolver = resolver;
95
}
96
97
async provideTerminalLinks(context: TerminalLinkContext, token: CancellationToken): Promise<CopilotCLITerminalLink[]> {
98
const line = context.line;
99
// Match VS Code's built-in MaxLineLength limit (terminalLocalLinkDetector.ts).
100
if (!line.trim() || line.length > 2000) {
101
return [];
102
}
103
104
const sessionDirs = await this._getSessionDirs(context.terminal);
105
if (!this._copilotTerminals.has(context.terminal) && sessionDirs.length === 0) {
106
return [];
107
}
108
const links: CopilotCLITerminalLink[] = [];
109
for (const candidate of this._detectLinkCandidates(line)) {
110
// Match VS Code's built-in MaxResolvedLinksInLine (terminalLocalLinkDetector.ts).
111
if (token.isCancellationRequested || links.length >= 10) {
112
break;
113
}
114
115
let pathText = candidate.pathText;
116
if (!pathText || pathText.length < 3) {
117
continue;
118
}
119
120
// Strip trailing punctuation that is unlikely part of the path.
121
// Mirrors VS Code's specialEndCharRegex (terminalLocalLinkDetector.ts).
122
let trimmed = 0;
123
while (pathText.length > 1 && /[\[\]"'.]$/.test(pathText)) {
124
pathText = pathText.slice(0, -1);
125
trimmed++;
126
}
127
128
// Skip URLs.
129
if (pathText.includes('://')) {
130
continue;
131
}
132
133
if (this._looksLikeNumericVersion(pathText)) {
134
continue;
135
}
136
137
const lineNum = candidate.line;
138
const colNum = candidate.col;
139
140
// Tilde paths: expand ~ to home directory (~/... or ~\... on Windows).
141
if (pathText.startsWith('~/') || pathText.startsWith('~\\')) {
142
const absoluteUri = Uri.file(homedir() + pathText.substring(1));
143
links.push({
144
startIndex: candidate.startIndex,
145
length: candidate.length - trimmed,
146
tooltip: absoluteUri.toString(true),
147
uri: absoluteUri,
148
terminal: context.terminal,
149
pathText,
150
line: lineNum,
151
col: colNum,
152
});
153
continue;
154
}
155
156
// Skip absolute paths; the built-in detector handles them.
157
// Unix: /foo, Windows: C:\foo or \Users\foo
158
if (pathText.startsWith('/') || pathText.startsWith('\\') || /^[a-zA-Z]:[/\\]/.test(pathText)) {
159
continue;
160
}
161
162
const resolved = await this._resolvePath(pathText, sessionDirs, token);
163
if (!resolved) {
164
continue;
165
}
166
167
links.push({
168
startIndex: candidate.startIndex,
169
length: candidate.length - trimmed,
170
tooltip: resolved.toString(true),
171
uri: resolved,
172
terminal: context.terminal,
173
pathText,
174
line: lineNum,
175
col: colNum,
176
});
177
}
178
179
return links;
180
}
181
182
async handleTerminalLink(link: CopilotCLITerminalLink): Promise<void> {
183
try {
184
const sessionDirs = await this._getSessionDirs(link.terminal);
185
const resolvedCandidates = await this._resolveAllPaths(link.pathText, sessionDirs);
186
let uriToOpen = link.uri
187
?? resolvedCandidates[0]
188
?? this._getFallbackUri(link.pathText, sessionDirs);
189
190
if (resolvedCandidates.length > 1) {
191
const pick = await window.showQuickPick(
192
resolvedCandidates.map(uri => ({
193
label: this._labelCandidate(uri, sessionDirs),
194
description: this._describeCandidate(uri, sessionDirs),
195
uri,
196
})),
197
{ placeHolder: l10n.t("Select which '{0}' to open", link.pathText) }
198
);
199
if (!pick) {
200
return;
201
}
202
uriToOpen = pick.uri;
203
}
204
205
if (!uriToOpen) {
206
return;
207
}
208
209
if (this.workspaceService && this._isInCopilotHome(uriToOpen)) {
210
const trusted = await this.workspaceService.requestResourceTrust({
211
uri: Uri.file(getCopilotHome()),
212
message: UNTRUSTED_COPILOT_HOME_MESSAGE,
213
});
214
if (!trusted) {
215
return;
216
}
217
}
218
219
await window.showTextDocument(uriToOpen, {
220
selection: link.line !== undefined
221
? new Range(
222
link.line - 1,
223
(link.col ?? 1) - 1,
224
link.line - 1,
225
(link.col ?? 1) - 1
226
)
227
: undefined,
228
});
229
} catch (e) {
230
this.logService.error('Failed to open terminal link', e);
231
}
232
}
233
234
/**
235
* Returns candidate session directories for a terminal.
236
*
237
* Resolver results (from active sessions) are tried first because the
238
* resolver can order them by terminal affinity — sessions that belong to
239
* THIS terminal come before unrelated sessions. A cached dir from
240
* {@link setSessionDir} is appended only as a last-resort fallback when it
241
* is no longer among the active sessions (i.e. the session ended but its
242
* files may still be on disk). See https://github.com/microsoft/vscode/issues/301594.
243
*/
244
private _isInCopilotHome(uri: Uri): boolean {
245
return extUriBiasedIgnorePathCase.isEqualOrParent(uri, Uri.file(getCopilotHome()));
246
}
247
248
private async _getSessionDirs(terminal: Terminal): Promise<Uri[]> {
249
const cached = this._terminalSessionDirs.get(terminal);
250
251
if (this._sessionDirResolver) {
252
const resolved = await this._sessionDirResolver(terminal);
253
const cachedFsPath = cached?.fsPath;
254
// Resolver results are already ordered by terminal affinity.
255
const dirs = [...resolved];
256
// If the cached dir is not among the active sessions it is stale
257
// (the session ended). Append it as a fallback instead of
258
// putting it first where it would shadow the current session.
259
if (cached && !resolved.some(dir => dir.fsPath === cachedFsPath)) {
260
dirs.push(cached);
261
}
262
return dirs;
263
}
264
265
return cached ? [cached] : [];
266
}
267
268
/**
269
* Resolves a relative path to the first existing file on disk. Used by
270
* {@link provideTerminalLinks} on hover where we only need to know that a
271
* match exists to underline the text. Returns early on first hit.
272
*
273
* Search order must stay consistent with {@link _resolveAllPaths}:
274
* 1. Each session-state directory (e.g., `~/.copilot/session-state/{uuid}/`)
275
* 2. Workspace folders
276
* 3. `files/` under each session dir (non-bare paths only)
277
*/
278
private async _resolvePath(pathText: string, sessionDirs: readonly Uri[], token?: CancellationToken): Promise<Uri | undefined> {
279
const isBareFilename = !pathText.includes('/') && !pathText.includes('\\');
280
const isDotRelative = pathText.startsWith('./') || pathText.startsWith('.\\') || pathText.startsWith('../') || pathText.startsWith('..\\');
281
const alreadyFilesRelative = pathText.startsWith('files/') || pathText.startsWith('files\\');
282
const shouldTryFilesFallback = !isBareFilename && !isDotRelative && !alreadyFilesRelative;
283
284
// Session-state directories first; CLI paths are relative to them.
285
for (const sessionDir of sessionDirs) {
286
if (token?.isCancellationRequested) {
287
return undefined;
288
}
289
290
if (await this._exists(Uri.joinPath(sessionDir, pathText))) {
291
return Uri.joinPath(sessionDir, pathText);
292
}
293
if (isBareFilename && await this._exists(Uri.joinPath(sessionDir, 'files', pathText))) {
294
return Uri.joinPath(sessionDir, 'files', pathText);
295
}
296
if (isBareFilename) {
297
const nested = await this._findNestedBareFilenameInSessionDir(sessionDir, pathText, token);
298
if (nested) {
299
return nested;
300
}
301
}
302
}
303
304
// Workspace folders.
305
for (const folder of workspace.workspaceFolders ?? []) {
306
if (token?.isCancellationRequested) {
307
return undefined;
308
}
309
if (await this._exists(Uri.joinPath(folder.uri, pathText))) {
310
return Uri.joinPath(folder.uri, pathText);
311
}
312
}
313
314
// files/<path> under session dirs for non-bare paths (the CLI sometimes
315
// emits workspace-relative paths that actually live under the session's
316
// files/ mirror).
317
if (shouldTryFilesFallback) {
318
for (const sessionDir of sessionDirs) {
319
if (token?.isCancellationRequested) {
320
return undefined;
321
}
322
if (await this._exists(Uri.joinPath(sessionDir, 'files', pathText))) {
323
return Uri.joinPath(sessionDir, 'files', pathText);
324
}
325
}
326
}
327
328
return undefined;
329
}
330
331
/**
332
* Resolves a relative path to every existing file on disk. Used by
333
* {@link handleTerminalLink} on click to populate a picker when the path
334
* is ambiguous (e.g. `plan.md` exists in both the session-state directory
335
* and the workspace root).
336
*
337
* Same search order as {@link _resolvePath} but collects all hits.
338
*/
339
private async _resolveAllPaths(pathText: string, sessionDirs: readonly Uri[]): Promise<Uri[]> {
340
const isBareFilename = !pathText.includes('/') && !pathText.includes('\\');
341
const isDotRelative = pathText.startsWith('./') || pathText.startsWith('.\\') || pathText.startsWith('../') || pathText.startsWith('..\\');
342
const alreadyFilesRelative = pathText.startsWith('files/') || pathText.startsWith('files\\');
343
const shouldTryFilesFallback = !isBareFilename && !isDotRelative && !alreadyFilesRelative;
344
345
const resolved: Uri[] = [];
346
const seen = new Set<string>();
347
const addIfExists = async (candidate: Uri): Promise<void> => {
348
if (seen.has(candidate.fsPath)) {
349
return;
350
}
351
seen.add(candidate.fsPath);
352
if (await this._exists(candidate)) {
353
resolved.push(candidate);
354
}
355
};
356
357
// Session-state directories first; CLI paths are relative to them.
358
for (const sessionDir of sessionDirs) {
359
await addIfExists(Uri.joinPath(sessionDir, pathText));
360
if (isBareFilename) {
361
await addIfExists(Uri.joinPath(sessionDir, 'files', pathText));
362
const nested = await this._findNestedBareFilenameInSessionDir(sessionDir, pathText);
363
if (nested && !seen.has(nested.fsPath)) {
364
seen.add(nested.fsPath);
365
resolved.push(nested);
366
}
367
}
368
}
369
370
// Workspace folders.
371
for (const folder of workspace.workspaceFolders ?? []) {
372
await addIfExists(Uri.joinPath(folder.uri, pathText));
373
}
374
375
// files/<path> under session dirs for non-bare paths.
376
if (shouldTryFilesFallback) {
377
for (const sessionDir of sessionDirs) {
378
await addIfExists(Uri.joinPath(sessionDir, 'files', pathText));
379
}
380
}
381
382
return resolved;
383
}
384
385
private async _exists(uri: Uri): Promise<boolean> {
386
try {
387
await workspace.fs.stat(uri);
388
return true;
389
} catch {
390
return false;
391
}
392
}
393
394
private _labelCandidate(uri: Uri, sessionDirs: readonly Uri[]): string {
395
return this._relativeTo(uri, sessionDirs)
396
?? this._relativeTo(uri, workspace.workspaceFolders?.map(f => f.uri) ?? [])
397
?? uri.fsPath.split(/[\\/]/).pop()
398
?? uri.fsPath;
399
}
400
401
private _describeCandidate(uri: Uri, sessionDirs: readonly Uri[]): string {
402
const normalizedCandidatePath = uri.fsPath.replace(/\\/g, '/');
403
for (const sessionDir of sessionDirs) {
404
const normalizedSessionPath = sessionDir.fsPath.replace(/\\/g, '/').replace(/\/$/, '');
405
if (normalizedCandidatePath.startsWith(`${normalizedSessionPath}/`)) {
406
const sessionId = normalizedSessionPath.split('/').pop();
407
return `session-state/${sessionId}`;
408
}
409
}
410
411
if (this._relativeTo(uri, workspace.workspaceFolders?.map(f => f.uri) ?? [])) {
412
return 'workspace';
413
}
414
415
return 'resolved path';
416
}
417
418
/**
419
* Returns the path of `uri` relative to the first matching base directory,
420
* or `undefined` if `uri` is not inside any of them. Compares with
421
* normalized separators.
422
*/
423
private _relativeTo(uri: Uri, baseDirs: readonly Uri[]): string | undefined {
424
const normalizedCandidatePath = uri.fsPath.replace(/\\/g, '/');
425
for (const baseDir of baseDirs) {
426
const normalizedBasePath = baseDir.fsPath.replace(/\\/g, '/').replace(/\/$/, '');
427
const prefix = `${normalizedBasePath}/`;
428
if (normalizedCandidatePath.startsWith(prefix)) {
429
return normalizedCandidatePath.slice(prefix.length);
430
}
431
}
432
return undefined;
433
}
434
435
private async _findNestedBareFilenameInSessionDir(sessionDir: Uri, basename: string, token?: CancellationToken): Promise<Uri | undefined> {
436
const queue: Uri[] = [sessionDir];
437
const matches: Uri[] = [];
438
const visited = new Set<string>();
439
let scannedDirCount = 0;
440
let scannedEntryCount = 0;
441
442
for (let i = 0; i < queue.length; i++) {
443
if (token?.isCancellationRequested || scannedDirCount >= MAX_NESTED_LOOKUP_DIRS || scannedEntryCount >= MAX_NESTED_LOOKUP_ENTRIES) {
444
break;
445
}
446
447
const dir = queue[i];
448
const normalizedDir = dir.fsPath.replace(/\\/g, '/');
449
if (visited.has(normalizedDir)) {
450
continue;
451
}
452
visited.add(normalizedDir);
453
scannedDirCount++;
454
455
let entries: [string, FileType][];
456
try {
457
entries = await workspace.fs.readDirectory(dir);
458
} catch {
459
continue;
460
}
461
462
for (const [name, type] of entries) {
463
scannedEntryCount++;
464
if (token?.isCancellationRequested || scannedEntryCount >= MAX_NESTED_LOOKUP_ENTRIES) {
465
break;
466
}
467
468
const candidate = Uri.joinPath(dir, name);
469
if ((type & FileType.File) !== 0 && name === basename) {
470
matches.push(candidate);
471
continue;
472
}
473
474
if ((type & FileType.Directory) !== 0 && (type & FileType.SymbolicLink) === 0) {
475
queue.push(candidate);
476
}
477
}
478
}
479
480
if (matches.length === 0) {
481
return undefined;
482
}
483
484
const normalizedSessionPath = sessionDir.fsPath.replace(/\\/g, '/').replace(/\/$/, '');
485
const sessionPathPrefix = `${normalizedSessionPath}/`;
486
matches.sort((a, b) => {
487
const pathA = a.fsPath.replace(/\\/g, '/');
488
const pathB = b.fsPath.replace(/\\/g, '/');
489
const relA = pathA.startsWith(sessionPathPrefix) ? pathA.slice(sessionPathPrefix.length) : pathA;
490
const relB = pathB.startsWith(sessionPathPrefix) ? pathB.slice(sessionPathPrefix.length) : pathB;
491
492
const scoreA = this._nestedBareFilenameScore(relA, basename);
493
const scoreB = this._nestedBareFilenameScore(relB, basename);
494
if (scoreA !== scoreB) {
495
return scoreA - scoreB;
496
}
497
498
return relA.localeCompare(relB);
499
});
500
501
return matches[0];
502
}
503
504
private _looksLikeNumericVersion(pathText: string): boolean {
505
// Avoid false-positive links for version-like tokens such as 1.2.
506
if (pathText.includes('/') || pathText.includes('\\')) {
507
return false;
508
}
509
510
return /^\d+(?:\.\d+)+$/.test(pathText);
511
}
512
513
private _nestedBareFilenameScore(relativePath: string, basename: string): number {
514
if (relativePath === `files/${basename}`) {
515
return 0;
516
}
517
518
if (relativePath === basename) {
519
return 1;
520
}
521
522
if (relativePath.startsWith('files/')) {
523
return 2;
524
}
525
526
return 10 + relativePath.split('/').length;
527
}
528
529
private _getFallbackUri(pathText: string, sessionDirs: readonly Uri[]): Uri | undefined {
530
const sessionDir = sessionDirs[0];
531
if (sessionDir) {
532
return Uri.joinPath(sessionDir, pathText);
533
}
534
535
const workspaceFolder = workspace.workspaceFolders?.[0];
536
if (workspaceFolder) {
537
return Uri.joinPath(workspaceFolder.uri, pathText);
538
}
539
540
return undefined;
541
}
542
543
private _detectLinkCandidates(line: string): DetectedLinkCandidate[] {
544
const candidates: DetectedLinkCandidate[] = [];
545
546
// Phase 1: Detect suffixes and resolve a path directly before each suffix.
547
const suffixRegex = new RegExp(LINK_SUFFIX_REGEX.source, LINK_SUFFIX_REGEX.flags);
548
for (const match of line.matchAll(suffixRegex)) {
549
const suffixStartIndex = match.index;
550
if (suffixStartIndex === undefined) {
551
continue;
552
}
553
554
const beforeSuffix = line.slice(0, suffixStartIndex);
555
const pathMatch = beforeSuffix.match(PATH_BEFORE_SUFFIX_REGEX);
556
const pathText = pathMatch?.groups?.['path'];
557
if (!pathText) {
558
continue;
559
}
560
561
const startIndex = suffixStartIndex - pathText.length;
562
const length = pathText.length + match[0].length;
563
const lineText = match.groups?.['line'] ?? match.groups?.['parenLine'];
564
const colText = match.groups?.['col'] ?? match.groups?.['parenCol'];
565
566
candidates.push({
567
startIndex,
568
length,
569
pathText,
570
line: lineText ? parseInt(lineText, 10) : undefined,
571
col: colText ? parseInt(colText, 10) : undefined,
572
});
573
}
574
575
// Phase 2: Detect path-only links and merge non-overlapping ranges.
576
const pathRegex = new RegExp(PATH_REGEX.source, PATH_REGEX.flags);
577
for (const match of line.matchAll(pathRegex)) {
578
const startIndex = match.index;
579
const pathText = match.groups?.['path'];
580
if (startIndex === undefined || !pathText) {
581
continue;
582
}
583
584
const endIndex = startIndex + pathText.length;
585
if (candidates.some(candidate => this._rangesOverlap(startIndex, endIndex, candidate.startIndex, candidate.startIndex + candidate.length))) {
586
continue;
587
}
588
589
candidates.push({
590
startIndex,
591
length: pathText.length,
592
pathText,
593
});
594
}
595
596
candidates.sort((a, b) => a.startIndex - b.startIndex);
597
return candidates;
598
}
599
600
private _rangesOverlap(startA: number, endA: number, startB: number, endB: number): boolean {
601
return startA < endB && startB < endA;
602
}
603
}
604
605