Path: blob/main/src/vs/sessions/contrib/chat/browser/variableCompletions.ts
13401 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 { CancellationToken } from '../../../../base/common/cancellation.js';6import { Disposable } from '../../../../base/common/lifecycle.js';7import { isPatternInWord } from '../../../../base/common/filters.js';8import { Schemas } from '../../../../base/common/network.js';9import { ResourceSet } from '../../../../base/common/map.js';10import { basename, isEqualOrParent } from '../../../../base/common/resources.js';11import { URI } from '../../../../base/common/uri.js';12import { CodeEditorWidget } from '../../../../editor/browser/widget/codeEditor/codeEditorWidget.js';13import { ICodeEditorService } from '../../../../editor/browser/services/codeEditorService.js';14import { Position } from '../../../../editor/common/core/position.js';15import { Range } from '../../../../editor/common/core/range.js';16import { IWordAtPosition, getWordAtText } from '../../../../editor/common/core/wordHelper.js';17import { CompletionContext, CompletionItem, CompletionItemKind, CompletionList } from '../../../../editor/common/languages.js';18import { ITextModel } from '../../../../editor/common/model.js';19import { ILanguageFeaturesService } from '../../../../editor/common/services/languageFeatures.js';20import { localize } from '../../../../nls.js';21import { IConfigurationService } from '../../../../platform/configuration/common/configuration.js';22import { CommandsRegistry } from '../../../../platform/commands/common/commands.js';23import { FileKind, IFileService } from '../../../../platform/files/common/files.js';24import { ILabelService } from '../../../../platform/label/common/label.js';25import { ISearchService } from '../../../../workbench/services/search/common/search.js';26import { searchFilesAndFolders } from '../../../../workbench/contrib/search/browser/searchChatContext.js';27import { chatSlashCommandBackground, chatSlashCommandForeground } from '../../../../workbench/contrib/chat/common/widget/chatColors.js';28import { themeColorFromId } from '../../../../base/common/themables.js';29import { IDecorationOptions } from '../../../../editor/common/editorCommon.js';30import { IHistoryService } from '../../../../workbench/services/history/common/history.js';31import { isDiffEditorInput } from '../../../../workbench/common/editor.js';32import { isSupportedChatFileScheme } from '../../../../workbench/contrib/chat/common/constants.js';33import { IInstantiationService } from '../../../../platform/instantiation/common/instantiation.js';34import { NewChatContextAttachments } from './newChatContextAttachments.js';3536const VARIABLE_LEADER = '#';3738/**39* Command ID used by completion items to attach a file/folder reference40* to the sessions context attachments.41*/42const ADD_REFERENCE_COMMAND = 'sessions.chat.addVariableReference';4344interface IReferenceArg {45readonly attachments: NewChatContextAttachments;46readonly entry: {47readonly id: string;48readonly name: string;49readonly value: URI;50readonly kind: 'file' | 'directory';51};52}5354CommandsRegistry.registerCommand(ADD_REFERENCE_COMMAND, (_accessor, arg: IReferenceArg) => {55arg.attachments.addAttachments({56id: arg.entry.id,57name: arg.entry.name,58value: arg.entry.value,59kind: arg.entry.kind,60});61});6263interface ICompletionRangeResult {64insert: Range;65replace: Range;66varWord: IWordAtPosition | null;67}6869function computeRange(model: ITextModel, position: Position, reg: RegExp): ICompletionRangeResult | undefined {70const varWord = getWordAtText(position.column, reg, model.getLineContent(position.lineNumber), 0);71if (!varWord && model.getWordUntilPosition(position).word) {72return;73}7475if (!varWord && position.column > 1) {76const textBefore = model.getValueInRange(new Range(position.lineNumber, position.column - 1, position.lineNumber, position.column));77if (textBefore !== ' ') {78return;79}80}8182// Reject if there's a normal word right before our variable word83if (varWord) {84const wordBefore = model.getWordUntilPosition({ lineNumber: position.lineNumber, column: varWord.startColumn });85if (wordBefore.word) {86return;87}88}8990let insert: Range;91let replace: Range;92if (!varWord) {93insert = replace = Range.fromPositions(position);94} else {95insert = new Range(position.lineNumber, varWord.startColumn, position.lineNumber, position.column);96replace = new Range(position.lineNumber, varWord.startColumn, position.lineNumber, varWord.endColumn);97}9899return { insert, replace, varWord };100}101102/**103* Provides `#file:` completions for files and folders in the sessions new-chat input,104* following the same pattern as {@link SlashCommandHandler}.105*106* Completions are scoped to the workspace selected in the workspace picker dropdown,107* matching the behaviour of the "Add Context..." attach button.108* For local/remote workspaces the search service is used; for virtual filesystems109* (e.g. `github-remote-file://`) the file service tree is walked directly.110*/111export class VariableCompletionHandler extends Disposable {112113private static readonly _wordPattern = /#[^\s]*/g; // MUST use g-flag114private static readonly _decoType = 'sessions-variable-reference';115private static _decosRegistered = false;116117constructor(118private readonly _editor: CodeEditorWidget,119private readonly _contextAttachments: NewChatContextAttachments,120private readonly _getWorkspaceUri: () => URI | undefined,121@ILanguageFeaturesService private readonly languageFeaturesService: ILanguageFeaturesService,122@ISearchService private readonly searchService: ISearchService,123@ILabelService private readonly labelService: ILabelService,124@IConfigurationService private readonly configurationService: IConfigurationService,125@ICodeEditorService private readonly codeEditorService: ICodeEditorService,126@IFileService private readonly fileService: IFileService,127@IHistoryService private readonly historyService: IHistoryService,128@IInstantiationService private readonly instantiationService: IInstantiationService,129) {130super();131this._registerFileCompletions();132this._registerDecorations();133}134135// --- File & Folder completions ---136137private _registerFileCompletions(): void {138const uri = this._editor.getModel()?.uri;139if (!uri) {140return;141}142143this._register(this.languageFeaturesService.completionProvider.register({ scheme: uri.scheme, hasAccessToAllModels: true }, {144_debugDisplayName: 'sessionsVariableFileAndFolder',145triggerCharacters: [VARIABLE_LEADER],146provideCompletionItems: async (model: ITextModel, position: Position, _context: CompletionContext, token: CancellationToken) => {147const workspaceUri = this._getWorkspaceUri();148if (!workspaceUri) {149return null;150}151152const range = computeRange(model, position, VariableCompletionHandler._wordPattern);153if (!range) {154return null;155}156157const result: CompletionList = { suggestions: [], incomplete: true };158await this._addFileAndFolderEntries(workspaceUri, result, range, token);159return result;160}161}));162}163164private async _addFileAndFolderEntries(workspaceUri: URI, result: CompletionList, info: ICompletionRangeResult, token: CancellationToken): Promise<void> {165const makeItem = (resource: URI, kind: FileKind, description?: string, boostPriority?: boolean): CompletionItem => {166const nameLabel = this.labelService.getUriBasenameLabel(resource);167const text = `${VARIABLE_LEADER}file:${nameLabel}`;168const uriLabel = this.labelService.getUriLabel(resource, { relative: true });169const labelDescription = description170? localize('fileEntryDescription', '{0} ({1})', uriLabel, description)171: uriLabel;172const sortText = boostPriority ? ' ' : '!';173174return {175label: { label: nameLabel, description: labelDescription },176filterText: `${nameLabel} ${VARIABLE_LEADER}${nameLabel} ${uriLabel}`,177insertText: info.varWord?.endColumn === info.replace.endColumn ? `${text} ` : text,178range: info,179kind: kind === FileKind.FILE ? CompletionItemKind.File : CompletionItemKind.Folder,180sortText,181command: {182id: ADD_REFERENCE_COMMAND,183title: '',184arguments: [{185attachments: this._contextAttachments,186entry: {187id: resource.toString(),188name: nameLabel,189value: resource,190kind: kind === FileKind.FILE ? 'file' : 'directory',191},192} satisfies IReferenceArg],193}194};195};196197let pattern: string | undefined;198if (info.varWord?.word && info.varWord.word.startsWith(VARIABLE_LEADER)) {199pattern = info.varWord.word.toLowerCase().slice(1); // remove leading #200}201202const seen = new ResourceSet();203204// HISTORY — always show recent files from editor history that are within the workspace205let historyCount = 0;206for (const [i, item] of this.historyService.getHistory().entries()) {207const resource = isDiffEditorInput(item) ? item.modified.resource : item.resource;208if (!resource || seen.has(resource) || !this.instantiationService.invokeFunction(accessor => isSupportedChatFileScheme(accessor, resource.scheme))) {209continue;210}211212// Only include files within the selected workspace213if (!isEqualOrParent(resource, workspaceUri)) {214continue;215}216217if (pattern) {218const uriLabel = this.labelService.getUriLabel(resource, { relative: true }).toLowerCase();219const baseName = this.labelService.getUriBasenameLabel(resource).toLowerCase();220const combined = `${baseName} ${uriLabel}`;221if (!isPatternInWord(pattern, 0, pattern.length, combined, 0, combined.length)) {222continue;223}224}225226seen.add(resource);227result.suggestions.push(makeItem(resource, FileKind.FILE, i === 0 ? localize('activeFile', 'Active file') : undefined, i === 0));228if (++historyCount >= 5) {229break;230}231}232233// SEARCH — always run to populate initial results (empty pattern returns scored files)234if (workspaceUri.scheme === Schemas.file || workspaceUri.scheme === Schemas.vscodeRemote) {235await this._addEntriesViaSearch(workspaceUri, pattern, seen, makeItem, result, token);236} else {237await this._addEntriesViaFileService(workspaceUri, pattern, seen, makeItem, result, token);238}239}240241/**242* Uses the search service to find files/folders — works for `file://` and `vscodeRemote` schemes.243*/244private async _addEntriesViaSearch(245workspaceUri: URI,246pattern: string | undefined,247seen: ResourceSet,248makeItem: (resource: URI, kind: FileKind, description?: string, boostPriority?: boolean) => CompletionItem,249result: CompletionList,250token: CancellationToken,251): Promise<void> {252try {253const { files, folders } = await searchFilesAndFolders(workspaceUri, pattern || '', true, token, undefined, this.configurationService, this.searchService);254255for (const file of files) {256if (!seen.has(file)) {257seen.add(file);258result.suggestions.push(makeItem(file, FileKind.FILE));259}260}261for (const folder of folders) {262if (!seen.has(folder)) {263seen.add(folder);264result.suggestions.push(makeItem(folder, FileKind.FOLDER));265}266}267} catch {268// search may fail or be cancelled269}270}271272/**273* Walks the file tree via IFileService — used for virtual filesystems274* (e.g. `github-remote-file://`) that don't support the search service.275*/276private async _addEntriesViaFileService(277workspaceUri: URI,278pattern: string | undefined,279seen: ResourceSet,280makeItem: (resource: URI, kind: FileKind, description?: string, boostPriority?: boolean) => CompletionItem,281result: CompletionList,282token: CancellationToken,283): Promise<void> {284const maxResults = 100;285const maxDepth = 10;286const patternLower = pattern?.toLowerCase();287288const collect = async (uri: URI, depth: number): Promise<void> => {289if (result.suggestions.length >= maxResults || depth > maxDepth || token.isCancellationRequested) {290return;291}292293try {294const stat = await this.fileService.resolve(uri);295if (!stat.children) {296return;297}298299for (const child of stat.children) {300if (result.suggestions.length >= maxResults || token.isCancellationRequested) {301break;302}303if (child.isDirectory) {304// Include matching folders as completions305if (!seen.has(child.resource)) {306const folderName = basename(child.resource).toLowerCase();307if (!patternLower || folderName.includes(patternLower)) {308seen.add(child.resource);309result.suggestions.push(makeItem(child.resource, FileKind.FOLDER));310}311}312await collect(child.resource, depth + 1);313} else {314if (!seen.has(child.resource)) {315const fileName = child.name.toLowerCase();316if (!patternLower || fileName.includes(patternLower)) {317seen.add(child.resource);318result.suggestions.push(makeItem(child.resource, FileKind.FILE));319}320}321}322}323} catch {324// ignore errors for individual directories325}326};327328await collect(workspaceUri, 0);329}330331// --- Decorations ---332333private _registerDecorations(): void {334if (!VariableCompletionHandler._decosRegistered) {335VariableCompletionHandler._decosRegistered = true;336this.codeEditorService.registerDecorationType('sessions-chat', VariableCompletionHandler._decoType, {337color: themeColorFromId(chatSlashCommandForeground),338backgroundColor: themeColorFromId(chatSlashCommandBackground),339borderRadius: '3px',340});341}342343this._register(this._editor.onDidChangeModelContent(() => this._updateDecorations()));344this._updateDecorations();345}346347private _updateDecorations(): void {348const model = this._editor.getModel();349const value = model?.getValue() ?? '';350351const decos: IDecorationOptions[] = [];352const regex = /#file:\S+/g;353let match: RegExpExecArray | null;354355while ((match = regex.exec(value)) !== null) {356// Convert string offset to line/column position357const startOffset = match.index;358const endOffset = startOffset + match[0].length;359const startPos = model!.getPositionAt(startOffset);360const endPos = model!.getPositionAt(endOffset);361362decos.push({363range: {364startLineNumber: startPos.lineNumber,365startColumn: startPos.column,366endLineNumber: endPos.lineNumber,367endColumn: endPos.column,368},369});370}371372this._editor.setDecorationsByType('sessions-chat', VariableCompletionHandler._decoType, decos);373}374375}376377378379