Path: blob/main/src/vs/workbench/contrib/chat/browser/attachments/implicitContextAttachment.ts
5262 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 { StandardKeyboardEvent } from '../../../../../base/browser/keyboardEvent.js';7import { StandardMouseEvent } from '../../../../../base/browser/mouseEvent.js';8import { Button } from '../../../../../base/browser/ui/button/button.js';9import { Codicon } from '../../../../../base/common/codicons.js';10import { IMarkdownString } from '../../../../../base/common/htmlContent.js';11import { ThemeIcon } from '../../../../../base/common/themables.js';12import { KeyCode } from '../../../../../base/common/keyCodes.js';13import { Disposable, DisposableStore } from '../../../../../base/common/lifecycle.js';14import { Schemas } from '../../../../../base/common/network.js';15import { basename, dirname } from '../../../../../base/common/resources.js';16import { URI } from '../../../../../base/common/uri.js';17import { isLocation, Location } from '../../../../../editor/common/languages.js';18import { getIconClasses } from '../../../../../editor/common/services/getIconClasses.js';19import { ILanguageService } from '../../../../../editor/common/languages/language.js';20import { IModelService } from '../../../../../editor/common/services/model.js';21import { localize } from '../../../../../nls.js';22import { getFlatContextMenuActions } from '../../../../../platform/actions/browser/menuEntryActionViewItem.js';23import { IMenuService, MenuId } from '../../../../../platform/actions/common/actions.js';24import { IConfigurationService } from '../../../../../platform/configuration/common/configuration.js';25import { IContextKeyService } from '../../../../../platform/contextkey/common/contextkey.js';26import { IContextMenuService } from '../../../../../platform/contextview/browser/contextView.js';27import { FileKind, IFileService } from '../../../../../platform/files/common/files.js';28import { IHoverService } from '../../../../../platform/hover/browser/hover.js';29import { ILabelService } from '../../../../../platform/label/common/label.js';30import { IResourceLabel, ResourceLabels } from '../../../../browser/labels.js';31import { ResourceContextKey } from '../../../../common/contextkeys.js';32import { IChatRequestStringVariableEntry, isStringImplicitContextValue } from '../../common/attachments/chatVariableEntries.js';33import { IChatWidget } from '../chat.js';34import { ChatAttachmentModel } from './chatAttachmentModel.js';35import { IChatContextService } from '../contextContrib/chatContextService.js';36import { ChatImplicitContext, ChatImplicitContexts } from './chatImplicitContext.js';37import { IRange } from '../../../../../editor/common/core/range.js';3839export class ImplicitContextAttachmentWidget extends Disposable {4041private readonly renderDisposables = this._register(new DisposableStore());42private renderedCount = 0;4344constructor(45private readonly widgetRef: () => IChatWidget | undefined,46private readonly isAttachmentAlreadyAttached: (targetUri: URI | undefined, targetRange: IRange | undefined, targetHandle: number | undefined) => boolean,47private readonly attachment: ChatImplicitContexts,48private readonly resourceLabels: ResourceLabels,49private readonly attachmentModel: ChatAttachmentModel,50private readonly domNode: HTMLElement,51@IContextKeyService private readonly contextKeyService: IContextKeyService,52@IContextMenuService private readonly contextMenuService: IContextMenuService,53@ILabelService private readonly labelService: ILabelService,54@IMenuService private readonly menuService: IMenuService,55@IFileService private readonly fileService: IFileService,56@ILanguageService private readonly languageService: ILanguageService,57@IModelService private readonly modelService: IModelService,58@IHoverService private readonly hoverService: IHoverService,59@IConfigurationService private readonly configService: IConfigurationService,60@IChatContextService private readonly chatContextService: IChatContextService,61) {62super();6364this.render();65}6667private render() {68this.renderDisposables.clear();69this.renderedCount = 0;7071for (const context of this.attachment.values) {72const targetUri: URI | undefined = context.uri;73const targetRange = isLocation(context.value) ? context.value.range : undefined;74const targetHandle = isStringImplicitContextValue(context.value) ? context.value.handle : undefined;75const currentlyAttached = this.isAttachmentAlreadyAttached(targetUri, targetRange, targetHandle);76if (!currentlyAttached) {77this.renderMainContext(context, context.isSelection);78this.renderedCount++;79}80}81}8283get hasRenderedContexts(): boolean {84return this.renderedCount > 0;85}8687private renderMainContext(context: ChatImplicitContext, isSelection?: boolean) {88const contextNode = dom.$('.chat-attached-context-attachment.show-file-icons.implicit');89this.domNode.appendChild(contextNode);9091contextNode.classList.toggle('disabled', !context.enabled);92const file: URI | undefined = context.uri;93const attachmentTypeName = file?.scheme === Schemas.vscodeNotebookCell ? localize('cell.lowercase', "cell") : localize('file.lowercase', "file");9495const isSuggestedEnabled = this.configService.getValue('chat.implicitContext.suggestedContext');9697// Create toggle button BEFORE the label so it appears on the left98if (isSuggestedEnabled) {99if (!isSelection) {100const buttonMsg = context.enabled ? localize('disable', "Disable current {0} context", attachmentTypeName) : '';101const toggleButton = this.renderDisposables.add(new Button(contextNode, { supportIcons: true, title: buttonMsg }));102toggleButton.icon = context.enabled ? Codicon.x : Codicon.plus;103this.renderDisposables.add(toggleButton.onDidClick(async (e) => {104e.stopPropagation();105e.preventDefault();106if (!context.enabled) {107await this.convertToRegularAttachment(context);108}109context.enabled = false;110}));111} else {112const pinButtonMsg = localize('pinSelection', "Pin selection");113const pinButton = this.renderDisposables.add(new Button(contextNode, { supportIcons: true, title: pinButtonMsg }));114pinButton.icon = Codicon.pinned;115this.renderDisposables.add(pinButton.onDidClick(async (e) => {116e.stopPropagation();117e.preventDefault();118await this.pinSelection();119}));120}121122if (!context.enabled && isSelection) {123contextNode.classList.remove('disabled');124}125126this.renderDisposables.add(dom.addDisposableListener(contextNode, dom.EventType.CLICK, async (e) => {127if (!context.enabled && !isSelection) {128await this.convertToRegularAttachment(context);129}130}));131132this.renderDisposables.add(dom.addDisposableListener(contextNode, dom.EventType.KEY_DOWN, async (e) => {133const event = new StandardKeyboardEvent(e);134if (event.equals(KeyCode.Enter) || event.equals(KeyCode.Space)) {135if (!context.enabled && !isSelection) {136e.preventDefault();137e.stopPropagation();138await this.convertToRegularAttachment(context);139}140}141}));142} else {143const buttonMsg = context.enabled ? localize('disable', "Disable current {0} context", attachmentTypeName) : localize('enable', "Enable current {0} context", attachmentTypeName);144const toggleButton = this.renderDisposables.add(new Button(contextNode, { supportIcons: true, title: buttonMsg }));145toggleButton.icon = context.enabled ? Codicon.eye : Codicon.eyeClosed;146this.renderDisposables.add(toggleButton.onDidClick((e) => {147e.stopPropagation(); // prevent it from triggering the click handler on the parent immediately after rerendering148context.enabled = !context.enabled;149}));150}151152const label = this.resourceLabels.create(contextNode, { supportIcons: true });153154let title: string | undefined;155let markdownTooltip: IMarkdownString | undefined;156if (isStringImplicitContextValue(context.value)) {157markdownTooltip = context.value.tooltip;158title = this.renderString(label, context.name, context.icon, context.value.resourceUri, markdownTooltip, localize('openFile', "Current file context"));159} else {160title = this.renderResource(context.value, context.isSelection, context.enabled, label);161}162163if (markdownTooltip || title) {164this.renderDisposables.add(this.hoverService.setupDelayedHover(contextNode, {165content: markdownTooltip! ?? title!,166appearance: { showPointer: true },167}));168}169170// Context menu171const scopedContextKeyService = this.renderDisposables.add(this.contextKeyService.createScoped(contextNode));172173const resourceContextKey = this.renderDisposables.add(new ResourceContextKey(scopedContextKeyService, this.fileService, this.languageService, this.modelService));174resourceContextKey.set(file);175176this.renderDisposables.add(dom.addDisposableListener(contextNode, dom.EventType.CONTEXT_MENU, async domEvent => {177const event = new StandardMouseEvent(dom.getWindow(domEvent), domEvent);178dom.EventHelper.stop(domEvent, true);179180this.contextMenuService.showContextMenu({181contextKeyService: scopedContextKeyService,182getAnchor: () => event,183getActions: () => {184const menu = this.menuService.getMenuActions(MenuId.ChatInputResourceAttachmentContext, scopedContextKeyService, { arg: file });185return getFlatContextMenuActions(menu);186},187});188}));189}190191private renderString(resourceLabel: IResourceLabel, name: string, icon: ThemeIcon | undefined, resourceUri: URI | undefined, markdownTooltip: IMarkdownString | undefined, defaultTitle: string): string | undefined {192// Don't set title if we have a markdown tooltip - the hover service will handle it193const title = markdownTooltip ? undefined : defaultTitle;194195// Derive icon classes from resourceUri for file/folder icons196if (icon && (ThemeIcon.isFile(icon) || ThemeIcon.isFolder(icon)) && resourceUri) {197const fileKind = ThemeIcon.isFolder(icon) ? FileKind.FOLDER : FileKind.FILE;198const iconClasses = getIconClasses(this.modelService, this.languageService, resourceUri, fileKind);199resourceLabel.setLabel(name, undefined, { extraClasses: iconClasses, title });200} else {201resourceLabel.setLabel(name, undefined, { iconPath: icon, title });202}203return title;204}205206private renderResource(attachmentValue: Location | URI | undefined, isSelection: boolean, enabled: boolean, label: IResourceLabel): string {207const file = URI.isUri(attachmentValue) ? attachmentValue : attachmentValue!.uri;208const range = URI.isUri(attachmentValue) || !isSelection ? undefined : attachmentValue!.range;209210const attachmentTypeName = file.scheme === Schemas.vscodeNotebookCell ? localize('cell.lowercase', "cell") : localize('file.lowercase', "file");211212const fileBasename = basename(file);213const fileDirname = dirname(file);214const friendlyName = `${fileBasename} ${fileDirname}`;215const ariaLabel = range ? localize('chat.fileAttachmentWithRange', "Attached {0}, {1}, line {2} to line {3}", attachmentTypeName, friendlyName, range.startLineNumber, range.endLineNumber) : localize('chat.fileAttachment', "Attached {0}, {1}", attachmentTypeName, friendlyName);216217const uriLabel = this.labelService.getUriLabel(file, { relative: true });218const currentFile = localize('openEditor', "Current {0} context", attachmentTypeName);219const inactive = localize('enableHint', "Enable current {0} context", attachmentTypeName);220const currentFileHint = enabled || isSelection ? currentFile : inactive;221const title = `${currentFileHint}\n${uriLabel}`;222223label.setFile(file, {224fileKind: FileKind.FILE,225hidePath: true,226range,227title228});229this.domNode.ariaLabel = ariaLabel;230this.domNode.tabIndex = 0;231232return title;233}234235private async convertToRegularAttachment(attachment: ChatImplicitContext): Promise<void> {236if (!attachment.value) {237return;238}239if (isStringImplicitContextValue(attachment.value)) {240if (attachment.value.value === undefined) {241await this.chatContextService.resolveChatContext(attachment.value);242}243const context: IChatRequestStringVariableEntry = {244kind: 'string',245value: attachment.value.value,246id: attachment.id,247name: attachment.name,248icon: attachment.value.icon,249modelDescription: attachment.modelDescription,250uri: attachment.value.uri,251resourceUri: attachment.value.resourceUri,252tooltip: attachment.value.tooltip,253commandId: attachment.value.commandId,254handle: attachment.value.handle255};256this.attachmentModel.addContext(context);257} else {258const file = URI.isUri(attachment.value) ? attachment.value : attachment.value.uri;259if (file.scheme === Schemas.vscodeNotebookCell && isLocation(attachment.value)) {260this.attachmentModel.addFile(file, attachment.value.range);261} else {262this.attachmentModel.addFile(file);263}264}265this.widgetRef()?.focusInput();266}267268private async pinSelection(): Promise<void> {269for (const attachment of this.attachment.values) {270if (!attachment.value || !attachment.isSelection) {271continue;272}273274if (!URI.isUri(attachment.value) && !isStringImplicitContextValue(attachment.value)) {275const location = attachment.value;276this.attachmentModel.addFile(location.uri, location.range);277}278}279this.widgetRef()?.focusInput();280}281}282283284