From 7bb6755241e2001b133e3c429ab320afcc8b7645 Mon Sep 17 00:00:00 2001 From: Joyce Er Date: Fri, 24 May 2024 01:24:21 -0700 Subject: [PATCH] feat: enable attaching symbols to chat via `@` (#213347) * feat: enable attaching symbols to chat via `@` * Oop --- .../browser/editorNavigationQuickAccess.ts | 12 ++--- .../browser/gotoSymbolQuickAccess.ts | 24 +++++++-- .../browser/actions/chatContextActions.ts | 50 ++++++++++++++----- .../contrib/chat/browser/chatInputPart.ts | 2 +- .../contrib/chat/common/chatModel.ts | 1 + .../quickaccess/gotoSymbolQuickAccess.ts | 2 +- 6 files changed, 66 insertions(+), 25 deletions(-) diff --git a/src/vs/editor/contrib/quickAccess/browser/editorNavigationQuickAccess.ts b/src/vs/editor/contrib/quickAccess/browser/editorNavigationQuickAccess.ts index de4198e7446c4..05c099c63c454 100644 --- a/src/vs/editor/contrib/quickAccess/browser/editorNavigationQuickAccess.ts +++ b/src/vs/editor/contrib/quickAccess/browser/editorNavigationQuickAccess.ts @@ -12,7 +12,7 @@ import { IRange } from 'vs/editor/common/core/range'; import { IDiffEditor, IEditor, ScrollType } from 'vs/editor/common/editorCommon'; import { IModelDeltaDecoration, ITextModel, OverviewRulerLane } from 'vs/editor/common/model'; import { overviewRulerRangeHighlight } from 'vs/editor/common/core/editorColorRegistry'; -import { IQuickAccessProvider } from 'vs/platform/quickinput/common/quickAccess'; +import { IQuickAccessProvider, IQuickAccessProviderRunOptions } from 'vs/platform/quickinput/common/quickAccess'; import { IKeyMods, IQuickPick, IQuickPickItem } from 'vs/platform/quickinput/common/quickInput'; import { themeColorFromId } from 'vs/platform/theme/common/themeService'; import { status } from 'vs/base/browser/ui/aria/aria'; @@ -52,7 +52,7 @@ export abstract class AbstractEditorNavigationQuickAccessProvider implements IQu //#region Provider methods - provide(picker: IQuickPick, token: CancellationToken): IDisposable { + provide(picker: IQuickPick, token: CancellationToken, runOptions?: IQuickAccessProviderRunOptions): IDisposable { const disposables = new DisposableStore(); // Apply options if any @@ -63,7 +63,7 @@ export abstract class AbstractEditorNavigationQuickAccessProvider implements IQu // Provide based on current active editor const pickerDisposable = disposables.add(new MutableDisposable()); - pickerDisposable.value = this.doProvide(picker, token); + pickerDisposable.value = this.doProvide(picker, token, runOptions); // Re-create whenever the active editor changes disposables.add(this.onDidActiveTextEditorControlChange(() => { @@ -78,7 +78,7 @@ export abstract class AbstractEditorNavigationQuickAccessProvider implements IQu return disposables; } - private doProvide(picker: IQuickPick, token: CancellationToken): IDisposable { + private doProvide(picker: IQuickPick, token: CancellationToken, runOptions?: IQuickAccessProviderRunOptions): IDisposable { const disposables = new DisposableStore(); // With text control @@ -113,7 +113,7 @@ export abstract class AbstractEditorNavigationQuickAccessProvider implements IQu disposables.add(toDisposable(() => this.clearDecorations(editor))); // Ask subclass for entries - disposables.add(this.provideWithTextEditor(context, picker, token)); + disposables.add(this.provideWithTextEditor(context, picker, token, runOptions)); } // Without text control @@ -134,7 +134,7 @@ export abstract class AbstractEditorNavigationQuickAccessProvider implements IQu /** * Subclasses to implement to provide picks for the picker when an editor is active. */ - protected abstract provideWithTextEditor(context: IQuickAccessTextEditorContext, picker: IQuickPick, token: CancellationToken): IDisposable; + protected abstract provideWithTextEditor(context: IQuickAccessTextEditorContext, picker: IQuickPick, token: CancellationToken, runOptions?: IQuickAccessProviderRunOptions): IDisposable; /** * Subclasses to implement to provide picks for the picker when no editor is active. diff --git a/src/vs/editor/contrib/quickAccess/browser/gotoSymbolQuickAccess.ts b/src/vs/editor/contrib/quickAccess/browser/gotoSymbolQuickAccess.ts index 01092241b7ac6..46aeeda1706c1 100644 --- a/src/vs/editor/contrib/quickAccess/browser/gotoSymbolQuickAccess.ts +++ b/src/vs/editor/contrib/quickAccess/browser/gotoSymbolQuickAccess.ts @@ -22,16 +22,26 @@ import { IQuickInputButton, IQuickPick, IQuickPickItem, IQuickPickSeparator } fr import { ILanguageFeaturesService } from 'vs/editor/common/services/languageFeatures'; import { Position } from 'vs/editor/common/core/position'; import { findLast } from 'vs/base/common/arraysFind'; +import { IQuickAccessProviderRunOptions } from 'vs/platform/quickinput/common/quickAccess'; +import { URI } from 'vs/base/common/uri'; export interface IGotoSymbolQuickPickItem extends IQuickPickItem { kind: SymbolKind; index: number; score?: number; + uri?: URI; + symbolName?: string; range?: { decoration: IRange; selection: IRange }; } export interface IGotoSymbolQuickAccessProviderOptions extends IEditorNavigationQuickAccessOptions { openSideBySideDirection?: () => undefined | 'right' | 'down'; + /** + * A handler to invoke when an item is accepted for + * this particular showing of the quick access. + * @param item The item that was accepted. + */ + readonly handleAccept?: (item: IQuickPickItem) => void; } export abstract class AbstractGotoSymbolQuickAccessProvider extends AbstractEditorNavigationQuickAccessProvider { @@ -59,7 +69,7 @@ export abstract class AbstractGotoSymbolQuickAccessProvider extends AbstractEdit return Disposable.None; } - protected provideWithTextEditor(context: IQuickAccessTextEditorContext, picker: IQuickPick, token: CancellationToken): IDisposable { + protected provideWithTextEditor(context: IQuickAccessTextEditorContext, picker: IQuickPick, token: CancellationToken, runOptions?: IQuickAccessProviderRunOptions): IDisposable { const editor = context.editor; const model = this.getModel(editor); if (!model) { @@ -68,7 +78,7 @@ export abstract class AbstractGotoSymbolQuickAccessProvider extends AbstractEdit // Provide symbols from model if available in registry if (this._languageFeaturesService.documentSymbolProvider.has(model)) { - return this.doProvideWithEditorSymbols(context, model, picker, token); + return this.doProvideWithEditorSymbols(context, model, picker, token, runOptions); } // Otherwise show an entry for a model without registry @@ -127,7 +137,7 @@ export abstract class AbstractGotoSymbolQuickAccessProvider extends AbstractEdit return symbolProviderRegistryPromise.p; } - private doProvideWithEditorSymbols(context: IQuickAccessTextEditorContext, model: ITextModel, picker: IQuickPick, token: CancellationToken): IDisposable { + private doProvideWithEditorSymbols(context: IQuickAccessTextEditorContext, model: ITextModel, picker: IQuickPick, token: CancellationToken, runOptions?: IQuickAccessProviderRunOptions): IDisposable { const editor = context.editor; const disposables = new DisposableStore(); @@ -137,6 +147,8 @@ export abstract class AbstractGotoSymbolQuickAccessProvider extends AbstractEdit if (item && item.range) { this.gotoLocation(context, { range: item.range.selection, keyMods: picker.keyMods, preserveFocus: event.inBackground }); + runOptions?.handleAccept?.(item); + if (!event.inBackground) { picker.hide(); } @@ -171,7 +183,7 @@ export abstract class AbstractGotoSymbolQuickAccessProvider extends AbstractEdit picker.busy = true; try { const query = prepareQuery(picker.value.substr(AbstractGotoSymbolQuickAccessProvider.PREFIX.length).trim()); - const items = await this.doGetSymbolPicks(symbolsPromise, query, undefined, picksCts.token); + const items = await this.doGetSymbolPicks(symbolsPromise, query, undefined, picksCts.token, model); if (token.isCancellationRequested) { return; } @@ -218,7 +230,7 @@ export abstract class AbstractGotoSymbolQuickAccessProvider extends AbstractEdit return disposables; } - protected async doGetSymbolPicks(symbolsPromise: Promise, query: IPreparedQuery, options: { extraContainerLabel?: string } | undefined, token: CancellationToken): Promise> { + protected async doGetSymbolPicks(symbolsPromise: Promise, query: IPreparedQuery, options: { extraContainerLabel?: string } | undefined, token: CancellationToken, model: ITextModel): Promise> { const symbols = await symbolsPromise; if (token.isCancellationRequested) { return []; @@ -326,6 +338,8 @@ export abstract class AbstractGotoSymbolQuickAccessProvider extends AbstractEdit selection: Range.collapseToStart(symbol.selectionRange), decoration: symbol.range }, + uri: model.uri, + symbolName: symbolLabel, strikethrough: deprecated, buttons }); diff --git a/src/vs/workbench/contrib/chat/browser/actions/chatContextActions.ts b/src/vs/workbench/contrib/chat/browser/actions/chatContextActions.ts index d63dd9fe8b784..70c5651e73ad1 100644 --- a/src/vs/workbench/contrib/chat/browser/actions/chatContextActions.ts +++ b/src/vs/workbench/contrib/chat/browser/actions/chatContextActions.ts @@ -7,10 +7,12 @@ import { CancellationToken } from 'vs/base/common/cancellation'; import { Codicon } from 'vs/base/common/codicons'; import { KeyCode, KeyMod } from 'vs/base/common/keyCodes'; import { Schemas } from 'vs/base/common/network'; +import { IRange } from 'vs/editor/common/core/range'; import { ThemeIcon } from 'vs/base/common/themables'; import { URI } from 'vs/base/common/uri'; import { ServicesAccessor } from 'vs/editor/browser/editorExtensions'; import { Command } from 'vs/editor/common/languages'; +import { AbstractGotoSymbolQuickAccessProvider, IGotoSymbolQuickPickItem } from 'vs/editor/contrib/quickAccess/browser/gotoSymbolQuickAccess'; import { localize, localize2 } from 'vs/nls'; import { Action2, MenuId, registerAction2 } from 'vs/platform/actions/common/actions'; import { ICommandService } from 'vs/platform/commands/common/commands'; @@ -20,7 +22,6 @@ import { IQuickInputService, IQuickPickItem, QuickPickItem } from 'vs/platform/q import { CHAT_CATEGORY } from 'vs/workbench/contrib/chat/browser/actions/chatActions'; import { IChatWidget, IChatWidgetService } from 'vs/workbench/contrib/chat/browser/chat'; import { ChatContextAttachments } from 'vs/workbench/contrib/chat/browser/contrib/chatContextAttachments'; -import { SelectAndInsertFileAction } from 'vs/workbench/contrib/chat/browser/contrib/chatDynamicVariables'; import { ChatAgentLocation, IChatAgentService } from 'vs/workbench/contrib/chat/common/chatAgents'; import { CONTEXT_CHAT_LOCATION, CONTEXT_IN_CHAT_INPUT } from 'vs/workbench/contrib/chat/common/chatContextKeys'; import { IChatRequestVariableEntry } from 'vs/workbench/contrib/chat/common/chatModel'; @@ -32,9 +33,10 @@ export function registerChatContextActions() { registerAction2(AttachContextAction); } -export type IChatContextQuickPickItem = IFileQuickPickItem | IDynamicVariableQuickPickItem | IStaticVariableQuickPickItem; +export type IChatContextQuickPickItem = IFileQuickPickItem | IDynamicVariableQuickPickItem | IStaticVariableQuickPickItem | IGotoSymbolQuickPickItem; export interface IFileQuickPickItem extends IQuickPickItem { + kind: 'file'; id: string; name: string; value: URI; @@ -44,6 +46,7 @@ export interface IFileQuickPickItem extends IQuickPickItem { } export interface IDynamicVariableQuickPickItem extends IQuickPickItem { + kind: 'dynamic'; id: string; name?: string; value: unknown; @@ -54,6 +57,7 @@ export interface IDynamicVariableQuickPickItem extends IQuickPickItem { } export interface IStaticVariableQuickPickItem extends IQuickPickItem { + kind: 'static'; id: string; name: string; value: unknown; @@ -92,8 +96,14 @@ class AttachContextAction extends Action2 { }); } - private _getFileContextId(item: { resource: URI }) { - return item.resource.toString(); + private _getFileContextId(item: { resource: URI } | { uri: URI; range: IRange }) { + if ('resource' in item) { + return item.resource.toString(); + } + + return item.uri.toString() + (item.range.startLineNumber !== item.range.endLineNumber ? + `:${item.range.startLineNumber}-${item.range.endLineNumber}` : + `:${item.range.startLineNumber}`); } private async _attachContext(widget: IChatWidget, commandService: ICommandService, ...picks: IChatContextQuickPickItem[]) { @@ -121,14 +131,27 @@ class AttachContextAction extends Action2 { id: this._getFileContextId(pick), value: pick.resource, name: pick.label, + isFile: true, + isDynamic: true + }); + } else if ('symbolName' in pick && pick.uri && pick.range) { + // Symbol + toAttach.push({ + ...pick, + range: undefined, + id: this._getFileContextId({ uri: pick.uri, range: pick.range.decoration }), + value: { uri: pick.uri, range: pick.range.decoration }, + fullName: pick.label, + name: pick.symbolName!, isDynamic: true }); } else { // All other dynamic variables and static variables toAttach.push({ ...pick, - id: pick.id, - value: pick.value, + range: undefined, + id: pick.id ?? '', + value: 'value' in pick ? pick.value : undefined, fullName: pick.label, name: 'name' in pick && typeof pick.name === 'string' ? pick.name : pick.label, icon: 'icon' in pick && ThemeIcon.isThemeIcon(pick.icon) ? pick.icon : undefined @@ -186,12 +209,11 @@ class AttachContextAction extends Action2 { } - if (chatVariablesService.hasVariable(SelectAndInsertFileAction.Name)) { - quickPickItems.push(SelectAndInsertFileAction.Item, { type: 'separator' }); - } - quickInputService.quickAccess.show('', { - enabledProviderPrefixes: [AnythingQuickAccessProvider.PREFIX], + enabledProviderPrefixes: [ + AnythingQuickAccessProvider.PREFIX, + AbstractGotoSymbolQuickAccessProvider.PREFIX + ], placeholder: localize('chatContext.attach.placeholder', 'Search attachments'), providerOptions: { handleAccept: (item: IChatContextQuickPickItem) => { @@ -208,7 +230,11 @@ class AttachContextAction extends Action2 { && !attachedContext.has(this._getFileContextId({ resource: item.resource })); // Hack because Typescript doesn't narrow this type correctly } - if (!('command' in item)) { + if (item && typeof item === 'object' && 'uri' in item && item.uri && item.range) { + return !attachedContext.has(this._getFileContextId({ uri: item.uri, range: item.range.decoration })); + } + + if (!('command' in item) && item.id) { return !attachedContext.has(item.id); } diff --git a/src/vs/workbench/contrib/chat/browser/chatInputPart.ts b/src/vs/workbench/contrib/chat/browser/chatInputPart.ts index dc56a5c97aa5c..879cc287c879b 100644 --- a/src/vs/workbench/contrib/chat/browser/chatInputPart.ts +++ b/src/vs/workbench/contrib/chat/browser/chatInputPart.ts @@ -442,7 +442,7 @@ export class ChatInputPart extends Disposable implements IHistoryNavigationWidge const widget = dom.append(container, $('.chat-attached-context-attachment.show-file-icons')); const label = this._contextResourceLabels.create(widget, { supportIcons: true }); const file = URI.isUri(attachment.value) ? attachment.value : attachment.value && typeof attachment.value === 'object' && 'uri' in attachment.value && URI.isUri(attachment.value.uri) ? attachment.value.uri : undefined; - if (file) { + if (file && attachment.isFile) { label.setFile(file, { fileKind: FileKind.FILE, hidePath: true, diff --git a/src/vs/workbench/contrib/chat/common/chatModel.ts b/src/vs/workbench/contrib/chat/common/chatModel.ts index 7338d036535cb..dbb3c3aef8be3 100644 --- a/src/vs/workbench/contrib/chat/common/chatModel.ts +++ b/src/vs/workbench/contrib/chat/common/chatModel.ts @@ -33,6 +33,7 @@ export interface IChatRequestVariableEntry { value: IChatRequestVariableValue; references?: IChatContentReference[]; isDynamic?: boolean; + isFile?: boolean; } export interface IChatRequestVariableData { diff --git a/src/vs/workbench/contrib/codeEditor/browser/quickaccess/gotoSymbolQuickAccess.ts b/src/vs/workbench/contrib/codeEditor/browser/quickaccess/gotoSymbolQuickAccess.ts index 71a1b5936988a..2a40f9d941311 100644 --- a/src/vs/workbench/contrib/codeEditor/browser/quickaccess/gotoSymbolQuickAccess.ts +++ b/src/vs/workbench/contrib/codeEditor/browser/quickaccess/gotoSymbolQuickAccess.ts @@ -118,7 +118,7 @@ export class GotoSymbolQuickAccessProvider extends AbstractGotoSymbolQuickAccess return []; } - return this.doGetSymbolPicks(this.getDocumentSymbols(model, token), prepareQuery(filter), options, token); + return this.doGetSymbolPicks(this.getDocumentSymbols(model, token), prepareQuery(filter), options, token, model); } //#endregion