Book a Demo!
CoCalc Logo Icon
StoreFeaturesDocsShareSupportNewsAboutPoliciesSign UpSign In
microsoft
GitHub Repository: microsoft/vscode
Path: blob/main/src/vs/sessions/contrib/chat/browser/variableCompletions.ts
13401 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 { CancellationToken } from '../../../../base/common/cancellation.js';
7
import { Disposable } from '../../../../base/common/lifecycle.js';
8
import { isPatternInWord } from '../../../../base/common/filters.js';
9
import { Schemas } from '../../../../base/common/network.js';
10
import { ResourceSet } from '../../../../base/common/map.js';
11
import { basename, isEqualOrParent } from '../../../../base/common/resources.js';
12
import { URI } from '../../../../base/common/uri.js';
13
import { CodeEditorWidget } from '../../../../editor/browser/widget/codeEditor/codeEditorWidget.js';
14
import { ICodeEditorService } from '../../../../editor/browser/services/codeEditorService.js';
15
import { Position } from '../../../../editor/common/core/position.js';
16
import { Range } from '../../../../editor/common/core/range.js';
17
import { IWordAtPosition, getWordAtText } from '../../../../editor/common/core/wordHelper.js';
18
import { CompletionContext, CompletionItem, CompletionItemKind, CompletionList } from '../../../../editor/common/languages.js';
19
import { ITextModel } from '../../../../editor/common/model.js';
20
import { ILanguageFeaturesService } from '../../../../editor/common/services/languageFeatures.js';
21
import { localize } from '../../../../nls.js';
22
import { IConfigurationService } from '../../../../platform/configuration/common/configuration.js';
23
import { CommandsRegistry } from '../../../../platform/commands/common/commands.js';
24
import { FileKind, IFileService } from '../../../../platform/files/common/files.js';
25
import { ILabelService } from '../../../../platform/label/common/label.js';
26
import { ISearchService } from '../../../../workbench/services/search/common/search.js';
27
import { searchFilesAndFolders } from '../../../../workbench/contrib/search/browser/searchChatContext.js';
28
import { chatSlashCommandBackground, chatSlashCommandForeground } from '../../../../workbench/contrib/chat/common/widget/chatColors.js';
29
import { themeColorFromId } from '../../../../base/common/themables.js';
30
import { IDecorationOptions } from '../../../../editor/common/editorCommon.js';
31
import { IHistoryService } from '../../../../workbench/services/history/common/history.js';
32
import { isDiffEditorInput } from '../../../../workbench/common/editor.js';
33
import { isSupportedChatFileScheme } from '../../../../workbench/contrib/chat/common/constants.js';
34
import { IInstantiationService } from '../../../../platform/instantiation/common/instantiation.js';
35
import { NewChatContextAttachments } from './newChatContextAttachments.js';
36
37
const VARIABLE_LEADER = '#';
38
39
/**
40
* Command ID used by completion items to attach a file/folder reference
41
* to the sessions context attachments.
42
*/
43
const ADD_REFERENCE_COMMAND = 'sessions.chat.addVariableReference';
44
45
interface IReferenceArg {
46
readonly attachments: NewChatContextAttachments;
47
readonly entry: {
48
readonly id: string;
49
readonly name: string;
50
readonly value: URI;
51
readonly kind: 'file' | 'directory';
52
};
53
}
54
55
CommandsRegistry.registerCommand(ADD_REFERENCE_COMMAND, (_accessor, arg: IReferenceArg) => {
56
arg.attachments.addAttachments({
57
id: arg.entry.id,
58
name: arg.entry.name,
59
value: arg.entry.value,
60
kind: arg.entry.kind,
61
});
62
});
63
64
interface ICompletionRangeResult {
65
insert: Range;
66
replace: Range;
67
varWord: IWordAtPosition | null;
68
}
69
70
function computeRange(model: ITextModel, position: Position, reg: RegExp): ICompletionRangeResult | undefined {
71
const varWord = getWordAtText(position.column, reg, model.getLineContent(position.lineNumber), 0);
72
if (!varWord && model.getWordUntilPosition(position).word) {
73
return;
74
}
75
76
if (!varWord && position.column > 1) {
77
const textBefore = model.getValueInRange(new Range(position.lineNumber, position.column - 1, position.lineNumber, position.column));
78
if (textBefore !== ' ') {
79
return;
80
}
81
}
82
83
// Reject if there's a normal word right before our variable word
84
if (varWord) {
85
const wordBefore = model.getWordUntilPosition({ lineNumber: position.lineNumber, column: varWord.startColumn });
86
if (wordBefore.word) {
87
return;
88
}
89
}
90
91
let insert: Range;
92
let replace: Range;
93
if (!varWord) {
94
insert = replace = Range.fromPositions(position);
95
} else {
96
insert = new Range(position.lineNumber, varWord.startColumn, position.lineNumber, position.column);
97
replace = new Range(position.lineNumber, varWord.startColumn, position.lineNumber, varWord.endColumn);
98
}
99
100
return { insert, replace, varWord };
101
}
102
103
/**
104
* Provides `#file:` completions for files and folders in the sessions new-chat input,
105
* following the same pattern as {@link SlashCommandHandler}.
106
*
107
* Completions are scoped to the workspace selected in the workspace picker dropdown,
108
* matching the behaviour of the "Add Context..." attach button.
109
* For local/remote workspaces the search service is used; for virtual filesystems
110
* (e.g. `github-remote-file://`) the file service tree is walked directly.
111
*/
112
export class VariableCompletionHandler extends Disposable {
113
114
private static readonly _wordPattern = /#[^\s]*/g; // MUST use g-flag
115
private static readonly _decoType = 'sessions-variable-reference';
116
private static _decosRegistered = false;
117
118
constructor(
119
private readonly _editor: CodeEditorWidget,
120
private readonly _contextAttachments: NewChatContextAttachments,
121
private readonly _getWorkspaceUri: () => URI | undefined,
122
@ILanguageFeaturesService private readonly languageFeaturesService: ILanguageFeaturesService,
123
@ISearchService private readonly searchService: ISearchService,
124
@ILabelService private readonly labelService: ILabelService,
125
@IConfigurationService private readonly configurationService: IConfigurationService,
126
@ICodeEditorService private readonly codeEditorService: ICodeEditorService,
127
@IFileService private readonly fileService: IFileService,
128
@IHistoryService private readonly historyService: IHistoryService,
129
@IInstantiationService private readonly instantiationService: IInstantiationService,
130
) {
131
super();
132
this._registerFileCompletions();
133
this._registerDecorations();
134
}
135
136
// --- File & Folder completions ---
137
138
private _registerFileCompletions(): void {
139
const uri = this._editor.getModel()?.uri;
140
if (!uri) {
141
return;
142
}
143
144
this._register(this.languageFeaturesService.completionProvider.register({ scheme: uri.scheme, hasAccessToAllModels: true }, {
145
_debugDisplayName: 'sessionsVariableFileAndFolder',
146
triggerCharacters: [VARIABLE_LEADER],
147
provideCompletionItems: async (model: ITextModel, position: Position, _context: CompletionContext, token: CancellationToken) => {
148
const workspaceUri = this._getWorkspaceUri();
149
if (!workspaceUri) {
150
return null;
151
}
152
153
const range = computeRange(model, position, VariableCompletionHandler._wordPattern);
154
if (!range) {
155
return null;
156
}
157
158
const result: CompletionList = { suggestions: [], incomplete: true };
159
await this._addFileAndFolderEntries(workspaceUri, result, range, token);
160
return result;
161
}
162
}));
163
}
164
165
private async _addFileAndFolderEntries(workspaceUri: URI, result: CompletionList, info: ICompletionRangeResult, token: CancellationToken): Promise<void> {
166
const makeItem = (resource: URI, kind: FileKind, description?: string, boostPriority?: boolean): CompletionItem => {
167
const nameLabel = this.labelService.getUriBasenameLabel(resource);
168
const text = `${VARIABLE_LEADER}file:${nameLabel}`;
169
const uriLabel = this.labelService.getUriLabel(resource, { relative: true });
170
const labelDescription = description
171
? localize('fileEntryDescription', '{0} ({1})', uriLabel, description)
172
: uriLabel;
173
const sortText = boostPriority ? ' ' : '!';
174
175
return {
176
label: { label: nameLabel, description: labelDescription },
177
filterText: `${nameLabel} ${VARIABLE_LEADER}${nameLabel} ${uriLabel}`,
178
insertText: info.varWord?.endColumn === info.replace.endColumn ? `${text} ` : text,
179
range: info,
180
kind: kind === FileKind.FILE ? CompletionItemKind.File : CompletionItemKind.Folder,
181
sortText,
182
command: {
183
id: ADD_REFERENCE_COMMAND,
184
title: '',
185
arguments: [{
186
attachments: this._contextAttachments,
187
entry: {
188
id: resource.toString(),
189
name: nameLabel,
190
value: resource,
191
kind: kind === FileKind.FILE ? 'file' : 'directory',
192
},
193
} satisfies IReferenceArg],
194
}
195
};
196
};
197
198
let pattern: string | undefined;
199
if (info.varWord?.word && info.varWord.word.startsWith(VARIABLE_LEADER)) {
200
pattern = info.varWord.word.toLowerCase().slice(1); // remove leading #
201
}
202
203
const seen = new ResourceSet();
204
205
// HISTORY — always show recent files from editor history that are within the workspace
206
let historyCount = 0;
207
for (const [i, item] of this.historyService.getHistory().entries()) {
208
const resource = isDiffEditorInput(item) ? item.modified.resource : item.resource;
209
if (!resource || seen.has(resource) || !this.instantiationService.invokeFunction(accessor => isSupportedChatFileScheme(accessor, resource.scheme))) {
210
continue;
211
}
212
213
// Only include files within the selected workspace
214
if (!isEqualOrParent(resource, workspaceUri)) {
215
continue;
216
}
217
218
if (pattern) {
219
const uriLabel = this.labelService.getUriLabel(resource, { relative: true }).toLowerCase();
220
const baseName = this.labelService.getUriBasenameLabel(resource).toLowerCase();
221
const combined = `${baseName} ${uriLabel}`;
222
if (!isPatternInWord(pattern, 0, pattern.length, combined, 0, combined.length)) {
223
continue;
224
}
225
}
226
227
seen.add(resource);
228
result.suggestions.push(makeItem(resource, FileKind.FILE, i === 0 ? localize('activeFile', 'Active file') : undefined, i === 0));
229
if (++historyCount >= 5) {
230
break;
231
}
232
}
233
234
// SEARCH — always run to populate initial results (empty pattern returns scored files)
235
if (workspaceUri.scheme === Schemas.file || workspaceUri.scheme === Schemas.vscodeRemote) {
236
await this._addEntriesViaSearch(workspaceUri, pattern, seen, makeItem, result, token);
237
} else {
238
await this._addEntriesViaFileService(workspaceUri, pattern, seen, makeItem, result, token);
239
}
240
}
241
242
/**
243
* Uses the search service to find files/folders — works for `file://` and `vscodeRemote` schemes.
244
*/
245
private async _addEntriesViaSearch(
246
workspaceUri: URI,
247
pattern: string | undefined,
248
seen: ResourceSet,
249
makeItem: (resource: URI, kind: FileKind, description?: string, boostPriority?: boolean) => CompletionItem,
250
result: CompletionList,
251
token: CancellationToken,
252
): Promise<void> {
253
try {
254
const { files, folders } = await searchFilesAndFolders(workspaceUri, pattern || '', true, token, undefined, this.configurationService, this.searchService);
255
256
for (const file of files) {
257
if (!seen.has(file)) {
258
seen.add(file);
259
result.suggestions.push(makeItem(file, FileKind.FILE));
260
}
261
}
262
for (const folder of folders) {
263
if (!seen.has(folder)) {
264
seen.add(folder);
265
result.suggestions.push(makeItem(folder, FileKind.FOLDER));
266
}
267
}
268
} catch {
269
// search may fail or be cancelled
270
}
271
}
272
273
/**
274
* Walks the file tree via IFileService — used for virtual filesystems
275
* (e.g. `github-remote-file://`) that don't support the search service.
276
*/
277
private async _addEntriesViaFileService(
278
workspaceUri: URI,
279
pattern: string | undefined,
280
seen: ResourceSet,
281
makeItem: (resource: URI, kind: FileKind, description?: string, boostPriority?: boolean) => CompletionItem,
282
result: CompletionList,
283
token: CancellationToken,
284
): Promise<void> {
285
const maxResults = 100;
286
const maxDepth = 10;
287
const patternLower = pattern?.toLowerCase();
288
289
const collect = async (uri: URI, depth: number): Promise<void> => {
290
if (result.suggestions.length >= maxResults || depth > maxDepth || token.isCancellationRequested) {
291
return;
292
}
293
294
try {
295
const stat = await this.fileService.resolve(uri);
296
if (!stat.children) {
297
return;
298
}
299
300
for (const child of stat.children) {
301
if (result.suggestions.length >= maxResults || token.isCancellationRequested) {
302
break;
303
}
304
if (child.isDirectory) {
305
// Include matching folders as completions
306
if (!seen.has(child.resource)) {
307
const folderName = basename(child.resource).toLowerCase();
308
if (!patternLower || folderName.includes(patternLower)) {
309
seen.add(child.resource);
310
result.suggestions.push(makeItem(child.resource, FileKind.FOLDER));
311
}
312
}
313
await collect(child.resource, depth + 1);
314
} else {
315
if (!seen.has(child.resource)) {
316
const fileName = child.name.toLowerCase();
317
if (!patternLower || fileName.includes(patternLower)) {
318
seen.add(child.resource);
319
result.suggestions.push(makeItem(child.resource, FileKind.FILE));
320
}
321
}
322
}
323
}
324
} catch {
325
// ignore errors for individual directories
326
}
327
};
328
329
await collect(workspaceUri, 0);
330
}
331
332
// --- Decorations ---
333
334
private _registerDecorations(): void {
335
if (!VariableCompletionHandler._decosRegistered) {
336
VariableCompletionHandler._decosRegistered = true;
337
this.codeEditorService.registerDecorationType('sessions-chat', VariableCompletionHandler._decoType, {
338
color: themeColorFromId(chatSlashCommandForeground),
339
backgroundColor: themeColorFromId(chatSlashCommandBackground),
340
borderRadius: '3px',
341
});
342
}
343
344
this._register(this._editor.onDidChangeModelContent(() => this._updateDecorations()));
345
this._updateDecorations();
346
}
347
348
private _updateDecorations(): void {
349
const model = this._editor.getModel();
350
const value = model?.getValue() ?? '';
351
352
const decos: IDecorationOptions[] = [];
353
const regex = /#file:\S+/g;
354
let match: RegExpExecArray | null;
355
356
while ((match = regex.exec(value)) !== null) {
357
// Convert string offset to line/column position
358
const startOffset = match.index;
359
const endOffset = startOffset + match[0].length;
360
const startPos = model!.getPositionAt(startOffset);
361
const endPos = model!.getPositionAt(endOffset);
362
363
decos.push({
364
range: {
365
startLineNumber: startPos.lineNumber,
366
startColumn: startPos.column,
367
endLineNumber: endPos.lineNumber,
368
endColumn: endPos.column,
369
},
370
});
371
}
372
373
this._editor.setDecorationsByType('sessions-chat', VariableCompletionHandler._decoType, decos);
374
}
375
376
}
377
378
379