Path: blob/main/src/vs/workbench/contrib/chat/browser/chatInlineAnchorWidget.ts
3296 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 dom from '../../../../base/browser/dom.js';6import { StandardMouseEvent } from '../../../../base/browser/mouseEvent.js';7import { getDefaultHoverDelegate } from '../../../../base/browser/ui/hover/hoverDelegateFactory.js';8import { KeyCode, KeyMod } from '../../../../base/common/keyCodes.js';9import { Disposable, DisposableStore } from '../../../../base/common/lifecycle.js';10import { URI } from '../../../../base/common/uri.js';11import { ICodeEditorService } from '../../../../editor/browser/services/codeEditorService.js';12import { IRange } from '../../../../editor/common/core/range.js';13import { EditorContextKeys } from '../../../../editor/common/editorContextKeys.js';14import { Location, SymbolKinds } from '../../../../editor/common/languages.js';15import { ILanguageService } from '../../../../editor/common/languages/language.js';16import { getIconClasses } from '../../../../editor/common/services/getIconClasses.js';17import { IModelService } from '../../../../editor/common/services/model.js';18import { DefinitionAction } from '../../../../editor/contrib/gotoSymbol/browser/goToCommands.js';19import * as nls from '../../../../nls.js';20import { getFlatContextMenuActions } from '../../../../platform/actions/browser/menuEntryActionViewItem.js';21import { Action2, IMenuService, MenuId, registerAction2 } from '../../../../platform/actions/common/actions.js';22import { IClipboardService } from '../../../../platform/clipboard/common/clipboardService.js';23import { ICommandService } from '../../../../platform/commands/common/commands.js';24import { IContextKey, IContextKeyService } from '../../../../platform/contextkey/common/contextkey.js';25import { IContextMenuService } from '../../../../platform/contextview/browser/contextView.js';26import { IResourceStat } from '../../../../platform/dnd/browser/dnd.js';27import { ITextResourceEditorInput } from '../../../../platform/editor/common/editor.js';28import { FileKind, IFileService } from '../../../../platform/files/common/files.js';29import { IHoverService } from '../../../../platform/hover/browser/hover.js';30import { IInstantiationService, ServicesAccessor } from '../../../../platform/instantiation/common/instantiation.js';31import { KeybindingWeight } from '../../../../platform/keybinding/common/keybindingsRegistry.js';32import { ILabelService } from '../../../../platform/label/common/label.js';33import { ITelemetryService } from '../../../../platform/telemetry/common/telemetry.js';34import { FolderThemeIcon, IThemeService } from '../../../../platform/theme/common/themeService.js';35import { fillEditorsDragData } from '../../../browser/dnd.js';36import { ResourceContextKey } from '../../../common/contextkeys.js';37import { IEditorService, SIDE_GROUP } from '../../../services/editor/common/editorService.js';38import { INotebookDocumentService } from '../../../services/notebook/common/notebookDocumentService.js';39import { ExplorerFolderContext } from '../../files/common/files.js';40import { IWorkspaceSymbol } from '../../search/common/search.js';41import { IChatContentInlineReference } from '../common/chatService.js';42import { IChatWidgetService } from './chat.js';43import { chatAttachmentResourceContextKey, hookUpSymbolAttachmentDragAndContextMenu } from './chatAttachmentWidgets.js';44import { IChatMarkdownAnchorService } from './chatContentParts/chatMarkdownAnchorService.js';4546type ContentRefData =47| { readonly kind: 'symbol'; readonly symbol: IWorkspaceSymbol }48| {49readonly kind?: undefined;50readonly uri: URI;51readonly range?: IRange;52};5354export function renderFileWidgets(element: HTMLElement, instantiationService: IInstantiationService, chatMarkdownAnchorService: IChatMarkdownAnchorService, disposables: DisposableStore) {55const links = element.querySelectorAll('a');56links.forEach(a => {57// Empty link text -> render file widget58if (!a.textContent?.trim()) {59const href = a.getAttribute('data-href');60const uri = href ? URI.parse(href) : undefined;61if (uri?.scheme) {62const widget = instantiationService.createInstance(InlineAnchorWidget, a, { kind: 'inlineReference', inlineReference: uri });63disposables.add(chatMarkdownAnchorService.register(widget));64disposables.add(widget);65}66}67});68}6970export class InlineAnchorWidget extends Disposable {7172public static readonly className = 'chat-inline-anchor-widget';7374private readonly _chatResourceContext: IContextKey<string>;7576readonly data: ContentRefData;7778private _isDisposed = false;7980constructor(81private readonly element: HTMLAnchorElement | HTMLElement,82public readonly inlineReference: IChatContentInlineReference,83@IContextKeyService originalContextKeyService: IContextKeyService,84@IContextMenuService contextMenuService: IContextMenuService,85@IFileService fileService: IFileService,86@IHoverService hoverService: IHoverService,87@IInstantiationService instantiationService: IInstantiationService,88@ILabelService labelService: ILabelService,89@ILanguageService languageService: ILanguageService,90@IMenuService menuService: IMenuService,91@IModelService modelService: IModelService,92@ITelemetryService telemetryService: ITelemetryService,93@IThemeService themeService: IThemeService,94@INotebookDocumentService private readonly notebookDocumentService: INotebookDocumentService,95) {96super();9798// TODO: Make sure we handle updates from an inlineReference being `resolved` late99100this.data = 'uri' in inlineReference.inlineReference101? inlineReference.inlineReference102: 'name' in inlineReference.inlineReference103? { kind: 'symbol', symbol: inlineReference.inlineReference }104: { uri: inlineReference.inlineReference };105106const contextKeyService = this._register(originalContextKeyService.createScoped(element));107this._chatResourceContext = chatAttachmentResourceContextKey.bindTo(contextKeyService);108109element.classList.add(InlineAnchorWidget.className, 'show-file-icons');110111let iconText: string;112let iconClasses: string[];113114let location: { readonly uri: URI; readonly range?: IRange };115116let updateContextKeys: (() => Promise<void>) | undefined;117if (this.data.kind === 'symbol') {118const symbol = this.data.symbol;119120location = this.data.symbol.location;121iconText = this.data.symbol.name;122iconClasses = ['codicon', ...getIconClasses(modelService, languageService, undefined, undefined, SymbolKinds.toIcon(symbol.kind))];123124this._store.add(instantiationService.invokeFunction(accessor => hookUpSymbolAttachmentDragAndContextMenu(accessor, element, contextKeyService, { value: symbol.location, name: symbol.name, kind: symbol.kind }, MenuId.ChatInlineSymbolAnchorContext)));125} else {126location = this.data;127128const label = labelService.getUriBasenameLabel(location.uri);129iconText = location.range && this.data.kind !== 'symbol' ?130`${label}#${location.range.startLineNumber}-${location.range.endLineNumber}` :131location.uri.scheme === 'vscode-notebook-cell' && this.data.kind !== 'symbol' ?132`${label} • cell${this.getCellIndex(location.uri)}` :133label;134135let fileKind = location.uri.path.endsWith('/') ? FileKind.FOLDER : FileKind.FILE;136const recomputeIconClasses = () => getIconClasses(modelService, languageService, location.uri, fileKind, fileKind === FileKind.FOLDER && !themeService.getFileIconTheme().hasFolderIcons ? FolderThemeIcon : undefined);137138iconClasses = recomputeIconClasses();139140const refreshIconClasses = () => {141iconEl.classList.remove(...iconClasses);142iconClasses = recomputeIconClasses();143iconEl.classList.add(...iconClasses);144};145146this._register(themeService.onDidFileIconThemeChange(() => {147refreshIconClasses();148}));149150const isFolderContext = ExplorerFolderContext.bindTo(contextKeyService);151fileService.stat(location.uri)152.then(stat => {153isFolderContext.set(stat.isDirectory);154if (stat.isDirectory) {155fileKind = FileKind.FOLDER;156refreshIconClasses();157}158})159.catch(() => { });160161// Context menu162this._register(dom.addDisposableListener(element, dom.EventType.CONTEXT_MENU, async domEvent => {163const event = new StandardMouseEvent(dom.getWindow(domEvent), domEvent);164dom.EventHelper.stop(domEvent, true);165166try {167await updateContextKeys?.();168} catch (e) {169console.error(e);170}171172if (this._isDisposed) {173return;174}175176contextMenuService.showContextMenu({177contextKeyService,178getAnchor: () => event,179getActions: () => {180const menu = menuService.getMenuActions(MenuId.ChatInlineResourceAnchorContext, contextKeyService, { arg: location.uri });181return getFlatContextMenuActions(menu);182},183});184}));185}186187const resourceContextKey = this._register(new ResourceContextKey(contextKeyService, fileService, languageService, modelService));188resourceContextKey.set(location.uri);189this._chatResourceContext.set(location.uri.toString());190191const iconEl = dom.$('span.icon');192iconEl.classList.add(...iconClasses);193element.replaceChildren(iconEl, dom.$('span.icon-label', {}, iconText));194195const fragment = location.range ? `${location.range.startLineNumber},${location.range.startColumn}` : '';196element.setAttribute('data-href', (fragment ? location.uri.with({ fragment }) : location.uri).toString());197198// Hover199const relativeLabel = labelService.getUriLabel(location.uri, { relative: true });200this._register(hoverService.setupManagedHover(getDefaultHoverDelegate('element'), element, relativeLabel));201202// Drag and drop203if (this.data.kind !== 'symbol') {204element.draggable = true;205this._register(dom.addDisposableListener(element, 'dragstart', e => {206const stat: IResourceStat = {207resource: location.uri,208selection: location.range,209};210instantiationService.invokeFunction(accessor => fillEditorsDragData(accessor, [stat], e));211212213e.dataTransfer?.setDragImage(element, 0, 0);214}));215}216}217218override dispose(): void {219this._isDisposed = true;220super.dispose();221}222223getHTMLElement(): HTMLElement {224return this.element;225}226227private getCellIndex(location: URI) {228const notebook = this.notebookDocumentService.getNotebook(location);229const index = notebook?.getCellIndex(location) ?? -1;230return index >= 0 ? ` ${index + 1}` : '';231}232}233234//#region Resource context menu235236registerAction2(class AddFileToChatAction extends Action2 {237238static readonly id = 'chat.inlineResourceAnchor.addFileToChat';239240constructor() {241super({242id: AddFileToChatAction.id,243title: nls.localize2('actions.attach.label', "Add File to Chat"),244menu: [{245id: MenuId.ChatInlineResourceAnchorContext,246group: 'chat',247order: 1,248when: ExplorerFolderContext.negate(),249}]250});251}252253override async run(accessor: ServicesAccessor, resource: URI): Promise<void> {254const chatWidgetService = accessor.get(IChatWidgetService);255256const widget = chatWidgetService.lastFocusedWidget;257if (widget) {258widget.attachmentModel.addFile(resource);259260}261}262});263264//#endregion265266//#region Resource keybindings267268registerAction2(class CopyResourceAction extends Action2 {269270static readonly id = 'chat.inlineResourceAnchor.copyResource';271272constructor() {273super({274id: CopyResourceAction.id,275title: nls.localize2('actions.copy.label', "Copy"),276f1: false,277precondition: chatAttachmentResourceContextKey,278keybinding: {279weight: KeybindingWeight.WorkbenchContrib,280primary: KeyMod.CtrlCmd | KeyCode.KeyC,281}282});283}284285override async run(accessor: ServicesAccessor): Promise<void> {286const chatWidgetService = accessor.get(IChatMarkdownAnchorService);287const clipboardService = accessor.get(IClipboardService);288289const anchor = chatWidgetService.lastFocusedAnchor;290if (!anchor) {291return;292}293294// TODO: we should also write out the standard mime types so that external programs can use them295// like how `fillEditorsDragData` works but without having an event to work with.296const resource = anchor.data.kind === 'symbol' ? anchor.data.symbol.location.uri : anchor.data.uri;297clipboardService.writeResources([resource]);298}299});300301registerAction2(class OpenToSideResourceAction extends Action2 {302303static readonly id = 'chat.inlineResourceAnchor.openToSide';304305constructor() {306super({307id: OpenToSideResourceAction.id,308title: nls.localize2('actions.openToSide.label', "Open to the Side"),309f1: false,310precondition: chatAttachmentResourceContextKey,311keybinding: {312weight: KeybindingWeight.ExternalExtension + 2,313primary: KeyMod.CtrlCmd | KeyCode.Enter,314mac: {315primary: KeyMod.WinCtrl | KeyCode.Enter316},317},318menu: [MenuId.ChatInlineSymbolAnchorContext, MenuId.ChatInputSymbolAttachmentContext].map(id => ({319id: id,320group: 'navigation',321order: 1322}))323});324}325326override async run(accessor: ServicesAccessor, arg?: Location | URI): Promise<void> {327const editorService = accessor.get(IEditorService);328329const target = this.getTarget(accessor, arg);330if (!target) {331return;332}333334const input: ITextResourceEditorInput = URI.isUri(target)335? { resource: target }336: {337resource: target.uri, options: {338selection: {339startColumn: target.range.startColumn,340startLineNumber: target.range.startLineNumber,341}342}343};344345await editorService.openEditors([input], SIDE_GROUP);346}347348private getTarget(accessor: ServicesAccessor, arg: URI | Location | undefined): Location | URI | undefined {349const chatWidgetService = accessor.get(IChatMarkdownAnchorService);350351if (arg) {352return arg;353}354355const anchor = chatWidgetService.lastFocusedAnchor;356if (!anchor) {357return undefined;358}359360return anchor.data.kind === 'symbol' ? anchor.data.symbol.location : anchor.data.uri;361}362});363364//#endregion365366//#region Symbol context menu367368registerAction2(class GoToDefinitionAction extends Action2 {369370static readonly id = 'chat.inlineSymbolAnchor.goToDefinition';371372constructor() {373super({374id: GoToDefinitionAction.id,375title: {376...nls.localize2('actions.goToDecl.label', "Go to Definition"),377mnemonicTitle: nls.localize({ key: 'miGotoDefinition', comment: ['&& denotes a mnemonic'] }, "Go to &&Definition"),378},379menu: [MenuId.ChatInlineSymbolAnchorContext, MenuId.ChatInputSymbolAttachmentContext].map(id => ({380id,381group: '4_symbol_nav',382order: 1.1,383when: EditorContextKeys.hasDefinitionProvider,384}))385});386}387388override async run(accessor: ServicesAccessor, location: Location): Promise<void> {389const editorService = accessor.get(ICodeEditorService);390const instantiationService = accessor.get(IInstantiationService);391392await openEditorWithSelection(editorService, location);393394const action = new DefinitionAction({ openToSide: false, openInPeek: false, muteMessage: true }, { title: { value: '', original: '' }, id: '', precondition: undefined });395return instantiationService.invokeFunction(accessor => action.run(accessor));396}397});398399async function openEditorWithSelection(editorService: ICodeEditorService, location: Location) {400await editorService.openCodeEditor({401resource: location.uri, options: {402selection: {403startColumn: location.range.startColumn,404startLineNumber: location.range.startLineNumber,405}406}407}, null);408}409410async function runGoToCommand(accessor: ServicesAccessor, command: string, location: Location) {411const editorService = accessor.get(ICodeEditorService);412const commandService = accessor.get(ICommandService);413414await openEditorWithSelection(editorService, location);415416return commandService.executeCommand(command);417}418419registerAction2(class GoToTypeDefinitionsAction extends Action2 {420421static readonly id = 'chat.inlineSymbolAnchor.goToTypeDefinitions';422423constructor() {424super({425id: GoToTypeDefinitionsAction.id,426title: {427...nls.localize2('goToTypeDefinitions.label', "Go to Type Definitions"),428mnemonicTitle: nls.localize({ key: 'miGotoTypeDefinition', comment: ['&& denotes a mnemonic'] }, "Go to &&Type Definitions"),429},430menu: [MenuId.ChatInlineSymbolAnchorContext, MenuId.ChatInputSymbolAttachmentContext].map(id => ({431id,432group: '4_symbol_nav',433order: 1.1,434when: EditorContextKeys.hasTypeDefinitionProvider,435})),436});437}438439override async run(accessor: ServicesAccessor, location: Location): Promise<void> {440return runGoToCommand(accessor, 'editor.action.goToTypeDefinition', location);441}442});443444registerAction2(class GoToImplementations extends Action2 {445446static readonly id = 'chat.inlineSymbolAnchor.goToImplementations';447448constructor() {449super({450id: GoToImplementations.id,451title: {452...nls.localize2('goToImplementations.label', "Go to Implementations"),453mnemonicTitle: nls.localize({ key: 'miGotoImplementations', comment: ['&& denotes a mnemonic'] }, "Go to &&Implementations"),454},455menu: [MenuId.ChatInlineSymbolAnchorContext, MenuId.ChatInputSymbolAttachmentContext].map(id => ({456id,457group: '4_symbol_nav',458order: 1.2,459when: EditorContextKeys.hasImplementationProvider,460})),461});462}463464override async run(accessor: ServicesAccessor, location: Location): Promise<void> {465return runGoToCommand(accessor, 'editor.action.goToImplementation', location);466}467});468469registerAction2(class GoToReferencesAction extends Action2 {470471static readonly id = 'chat.inlineSymbolAnchor.goToReferences';472473constructor() {474super({475id: GoToReferencesAction.id,476title: {477...nls.localize2('goToReferences.label', "Go to References"),478mnemonicTitle: nls.localize({ key: 'miGotoReference', comment: ['&& denotes a mnemonic'] }, "Go to &&References"),479},480menu: [MenuId.ChatInlineSymbolAnchorContext, MenuId.ChatInputSymbolAttachmentContext].map(id => ({481id,482group: '4_symbol_nav',483order: 1.3,484when: EditorContextKeys.hasReferenceProvider,485})),486});487}488489override async run(accessor: ServicesAccessor, location: Location): Promise<void> {490return runGoToCommand(accessor, 'editor.action.goToReferences', location);491}492});493494//#endregion495496497