Path: blob/main/src/vs/workbench/contrib/chat/browser/chatContentParts/chatReferencesContentPart.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 { IListRenderer, IListVirtualDelegate } from '../../../../../base/browser/ui/list/list.js';7import { IListOptions } from '../../../../../base/browser/ui/list/listWidget.js';8import { coalesce } from '../../../../../base/common/arrays.js';9import { Codicon } from '../../../../../base/common/codicons.js';10import { Event } from '../../../../../base/common/event.js';11import { IMarkdownString } from '../../../../../base/common/htmlContent.js';12import { Disposable, DisposableStore } from '../../../../../base/common/lifecycle.js';13import { matchesSomeScheme, Schemas } from '../../../../../base/common/network.js';14import { basename } from '../../../../../base/common/path.js';15import { basenameOrAuthority, isEqualAuthority } from '../../../../../base/common/resources.js';16import { ThemeIcon } from '../../../../../base/common/themables.js';17import { URI } from '../../../../../base/common/uri.js';18import { IRange } from '../../../../../editor/common/core/range.js';19import { localize, localize2 } from '../../../../../nls.js';20import { getFlatContextMenuActions } from '../../../../../platform/actions/browser/menuEntryActionViewItem.js';21import { MenuWorkbenchToolBar } from '../../../../../platform/actions/browser/toolbar.js';22import { Action2, IMenuService, MenuId, registerAction2 } from '../../../../../platform/actions/common/actions.js';23import { IClipboardService } from '../../../../../platform/clipboard/common/clipboardService.js';24import { ContextKeyExpr, IContextKeyService } from '../../../../../platform/contextkey/common/contextkey.js';25import { IContextMenuService } from '../../../../../platform/contextview/browser/contextView.js';26import { FileKind } from '../../../../../platform/files/common/files.js';27import { IInstantiationService, ServicesAccessor } from '../../../../../platform/instantiation/common/instantiation.js';28import { ServiceCollection } from '../../../../../platform/instantiation/common/serviceCollection.js';29import { ILabelService } from '../../../../../platform/label/common/label.js';30import { WorkbenchList } from '../../../../../platform/list/browser/listService.js';31import { IOpenerService } from '../../../../../platform/opener/common/opener.js';32import { IProductService } from '../../../../../platform/product/common/productService.js';33import { IThemeService } from '../../../../../platform/theme/common/themeService.js';34import { fillEditorsDragData } from '../../../../browser/dnd.js';35import { IResourceLabel, IResourceLabelProps, ResourceLabels } from '../../../../browser/labels.js';36import { ColorScheme } from '../../../../browser/web.api.js';37import { ResourceContextKey } from '../../../../common/contextkeys.js';38import { SETTINGS_AUTHORITY } from '../../../../services/preferences/common/preferences.js';39import { createFileIconThemableTreeContainerScope } from '../../../files/browser/views/explorerView.js';40import { ExplorerFolderContext } from '../../../files/common/files.js';41import { chatEditingWidgetFileStateContextKey, ModifiedFileEntryState } from '../../common/chatEditingService.js';42import { ChatResponseReferencePartStatusKind, IChatContentReference, IChatWarningMessage } from '../../common/chatService.js';43import { IChatRendererContent, IChatResponseViewModel } from '../../common/chatViewModel.js';44import { ChatTreeItem, IChatWidgetService } from '../chat.js';45import { ChatCollapsibleContentPart } from './chatCollapsibleContentPart.js';46import { IDisposableReference, ResourcePool } from './chatCollections.js';47import { IChatContentPartRenderContext } from './chatContentParts.js';4849const $ = dom.$;5051export interface IChatReferenceListItem extends IChatContentReference {52title?: string;53description?: string;54state?: ModifiedFileEntryState;55excluded?: boolean;56}5758export type IChatCollapsibleListItem = IChatReferenceListItem | IChatWarningMessage;5960export class ChatCollapsibleListContentPart extends ChatCollapsibleContentPart {6162constructor(63private readonly data: ReadonlyArray<IChatCollapsibleListItem>,64labelOverride: IMarkdownString | string | undefined,65context: IChatContentPartRenderContext,66private readonly contentReferencesListPool: CollapsibleListPool,67@IOpenerService private readonly openerService: IOpenerService,68@IMenuService private readonly menuService: IMenuService,69@IInstantiationService private readonly instantiationService: IInstantiationService,70@IContextMenuService private readonly contextMenuService: IContextMenuService,71) {72super(labelOverride ?? (data.length > 1 ?73localize('usedReferencesPlural', "Used {0} references", data.length) :74localize('usedReferencesSingular', "Used {0} reference", 1)), context);75}7677protected override initContent(): HTMLElement {78const ref = this._register(this.contentReferencesListPool.get());79const list = ref.object;8081this._register(list.onDidOpen((e) => {82if (e.element && 'reference' in e.element && typeof e.element.reference === 'object') {83const uriOrLocation = 'variableName' in e.element.reference ? e.element.reference.value : e.element.reference;84const uri = URI.isUri(uriOrLocation) ? uriOrLocation :85uriOrLocation?.uri;86if (uri) {87this.openerService.open(88uri,89{90fromUserGesture: true,91editorOptions: {92...e.editorOptions,93...{94selection: uriOrLocation && 'range' in uriOrLocation ? uriOrLocation.range : undefined95}96}97});98}99}100}));101102this._register(list.onContextMenu(e => {103dom.EventHelper.stop(e.browserEvent, true);104105const uri = e.element && getResourceForElement(e.element);106if (!uri) {107return;108}109110this.contextMenuService.showContextMenu({111getAnchor: () => e.anchor,112getActions: () => {113const menu = this.menuService.getMenuActions(MenuId.ChatAttachmentsContext, list.contextKeyService, { shouldForwardArgs: true, arg: uri });114return getFlatContextMenuActions(menu);115}116});117}));118119const resourceContextKey = this._register(this.instantiationService.createInstance(ResourceContextKey));120this._register(list.onDidChangeFocus(e => {121resourceContextKey.reset();122const element = e.elements.length ? e.elements[0] : undefined;123const uri = element && getResourceForElement(element);124resourceContextKey.set(uri ?? null);125}));126127const maxItemsShown = 6;128const itemsShown = Math.min(this.data.length, maxItemsShown);129const height = itemsShown * 22;130list.layout(height);131list.getHTMLElement().style.height = `${height}px`;132list.splice(0, list.length, this.data);133134return list.getHTMLElement().parentElement!;135}136137hasSameContent(other: IChatRendererContent, followingContent: IChatRendererContent[], element: ChatTreeItem): boolean {138return other.kind === 'references' && other.references.length === this.data.length && (!!followingContent.length === this.hasFollowingContent);139}140}141142export interface IChatUsedReferencesListOptions {143expandedWhenEmptyResponse?: boolean;144}145146export class ChatUsedReferencesListContentPart extends ChatCollapsibleListContentPart {147constructor(148data: ReadonlyArray<IChatCollapsibleListItem>,149labelOverride: IMarkdownString | string | undefined,150context: IChatContentPartRenderContext,151contentReferencesListPool: CollapsibleListPool,152private readonly options: IChatUsedReferencesListOptions,153@IOpenerService openerService: IOpenerService,154@IMenuService menuService: IMenuService,155@IInstantiationService instantiationService: IInstantiationService,156@IContextMenuService contextMenuService: IContextMenuService,157) {158super(data, labelOverride, context, contentReferencesListPool, openerService, menuService, instantiationService, contextMenuService);159if (data.length === 0) {160dom.hide(this.domNode);161}162}163164protected override isExpanded(): boolean {165const element = this.context.element as IChatResponseViewModel;166return element.usedReferencesExpanded ?? !!(167this.options.expandedWhenEmptyResponse && element.response.value.length === 0168);169}170171protected override setExpanded(value: boolean): void {172const element = this.context.element as IChatResponseViewModel;173element.usedReferencesExpanded = !this.isExpanded();174}175}176177export class CollapsibleListPool extends Disposable {178private _pool: ResourcePool<WorkbenchList<IChatCollapsibleListItem>>;179180public get inUse(): ReadonlySet<WorkbenchList<IChatCollapsibleListItem>> {181return this._pool.inUse;182}183184constructor(185private _onDidChangeVisibility: Event<boolean>,186private readonly menuId: MenuId | undefined,187private readonly listOptions: IListOptions<IChatCollapsibleListItem> | undefined,188@IInstantiationService private readonly instantiationService: IInstantiationService,189@IThemeService private readonly themeService: IThemeService,190@ILabelService private readonly labelService: ILabelService,191) {192super();193this._pool = this._register(new ResourcePool(() => this.listFactory()));194}195196private listFactory(): WorkbenchList<IChatCollapsibleListItem> {197const resourceLabels = this._register(this.instantiationService.createInstance(ResourceLabels, { onDidChangeVisibility: this._onDidChangeVisibility }));198199const container = $('.chat-used-context-list');200this._register(createFileIconThemableTreeContainerScope(container, this.themeService));201202const list = this.instantiationService.createInstance(203WorkbenchList<IChatCollapsibleListItem>,204'ChatListRenderer',205container,206new CollapsibleListDelegate(),207[this.instantiationService.createInstance(CollapsibleListRenderer, resourceLabels, this.menuId)],208{209...this.listOptions,210alwaysConsumeMouseWheel: false,211accessibilityProvider: {212getAriaLabel: (element: IChatCollapsibleListItem) => {213if (element.kind === 'warning') {214return element.content.value;215}216const reference = element.reference;217if (typeof reference === 'string') {218return reference;219} else if ('variableName' in reference) {220return reference.variableName;221} else if (URI.isUri(reference)) {222return basename(reference.path);223} else {224return basename(reference.uri.path);225}226},227228getWidgetAriaLabel: () => localize('chatCollapsibleList', "Collapsible Chat References List")229},230dnd: {231getDragURI: (element: IChatCollapsibleListItem) => getResourceForElement(element)?.toString() ?? null,232getDragLabel: (elements, originalEvent) => {233const uris: URI[] = coalesce(elements.map(getResourceForElement));234if (!uris.length) {235return undefined;236} else if (uris.length === 1) {237return this.labelService.getUriLabel(uris[0], { relative: true });238} else {239return `${uris.length}`;240}241},242dispose: () => { },243onDragOver: () => false,244drop: () => { },245onDragStart: (data, originalEvent) => {246try {247const elements = data.getData() as IChatCollapsibleListItem[];248const uris: URI[] = coalesce(elements.map(getResourceForElement));249this.instantiationService.invokeFunction(accessor => fillEditorsDragData(accessor, uris, originalEvent));250} catch {251// noop252}253},254},255});256257return list;258}259260get(): IDisposableReference<WorkbenchList<IChatCollapsibleListItem>> {261const object = this._pool.get();262let stale = false;263return {264object,265isStale: () => stale,266dispose: () => {267stale = true;268this._pool.release(object);269}270};271}272}273274class CollapsibleListDelegate implements IListVirtualDelegate<IChatCollapsibleListItem> {275getHeight(element: IChatCollapsibleListItem): number {276return 22;277}278279getTemplateId(element: IChatCollapsibleListItem): string {280return CollapsibleListRenderer.TEMPLATE_ID;281}282}283284interface ICollapsibleListTemplate {285readonly contextKeyService?: IContextKeyService;286readonly label: IResourceLabel;287readonly templateDisposables: DisposableStore;288toolbar: MenuWorkbenchToolBar | undefined;289actionBarContainer?: HTMLElement;290fileDiffsContainer?: HTMLElement;291addedSpan?: HTMLElement;292removedSpan?: HTMLElement;293}294295class CollapsibleListRenderer implements IListRenderer<IChatCollapsibleListItem, ICollapsibleListTemplate> {296static TEMPLATE_ID = 'chatCollapsibleListRenderer';297readonly templateId: string = CollapsibleListRenderer.TEMPLATE_ID;298299constructor(300private labels: ResourceLabels,301private menuId: MenuId | undefined,302@IThemeService private readonly themeService: IThemeService,303@IProductService private readonly productService: IProductService,304@IInstantiationService private readonly instantiationService: IInstantiationService,305@IContextKeyService private readonly contextKeyService: IContextKeyService,306) { }307308renderTemplate(container: HTMLElement): ICollapsibleListTemplate {309const templateDisposables = new DisposableStore();310const label = templateDisposables.add(this.labels.create(container, { supportHighlights: true, supportIcons: true }));311312const fileDiffsContainer = $('.working-set-line-counts');313const addedSpan = dom.$('.working-set-lines-added');314const removedSpan = dom.$('.working-set-lines-removed');315fileDiffsContainer.appendChild(addedSpan);316fileDiffsContainer.appendChild(removedSpan);317label.element.appendChild(fileDiffsContainer);318319let toolbar;320let actionBarContainer;321let contextKeyService;322if (this.menuId) {323actionBarContainer = $('.chat-collapsible-list-action-bar');324contextKeyService = templateDisposables.add(this.contextKeyService.createScoped(actionBarContainer));325const scopedInstantiationService = templateDisposables.add(this.instantiationService.createChild(new ServiceCollection([IContextKeyService, contextKeyService])));326toolbar = templateDisposables.add(scopedInstantiationService.createInstance(MenuWorkbenchToolBar, actionBarContainer, this.menuId, { menuOptions: { shouldForwardArgs: true, arg: undefined } }));327label.element.appendChild(actionBarContainer);328}329330return { templateDisposables, label, toolbar, actionBarContainer, contextKeyService, fileDiffsContainer, addedSpan, removedSpan };331}332333334private getReferenceIcon(data: IChatContentReference): URI | ThemeIcon | undefined {335if (ThemeIcon.isThemeIcon(data.iconPath)) {336return data.iconPath;337} else {338return this.themeService.getColorTheme().type === ColorScheme.DARK && data.iconPath?.dark339? data.iconPath?.dark340: data.iconPath?.light;341}342}343344renderElement(data: IChatCollapsibleListItem, index: number, templateData: ICollapsibleListTemplate): void {345if (data.kind === 'warning') {346templateData.label.setResource({ name: data.content.value }, { icon: Codicon.warning });347return;348}349350const reference = data.reference;351const icon = this.getReferenceIcon(data);352templateData.label.element.style.display = 'flex';353let arg: URI | undefined;354if (typeof reference === 'object' && 'variableName' in reference) {355if (reference.value) {356const uri = URI.isUri(reference.value) ? reference.value : reference.value.uri;357templateData.label.setResource(358{359resource: uri,360name: basenameOrAuthority(uri),361description: `#${reference.variableName}`,362range: 'range' in reference.value ? reference.value.range : undefined,363}, { icon, title: data.options?.status?.description ?? data.title });364} else if (reference.variableName.startsWith('kernelVariable')) {365const variable = reference.variableName.split(':')[1];366const asVariableName = `${variable}`;367const label = `Kernel variable`;368templateData.label.setLabel(label, asVariableName, { title: data.options?.status?.description });369} else {370// Nothing else is expected to fall into here371templateData.label.setLabel('Unknown variable type');372}373} else if (typeof reference === 'string') {374templateData.label.setLabel(reference, undefined, { iconPath: URI.isUri(icon) ? icon : undefined, title: data.options?.status?.description ?? data.title });375376} else {377const uri = 'uri' in reference ? reference.uri : reference;378arg = uri;379const extraClasses = data.excluded ? ['excluded'] : [];380if (uri.scheme === 'https' && isEqualAuthority(uri.authority, 'github.com') && uri.path.includes('/tree/')) {381// Parse a nicer label for GitHub URIs that point at a particular commit + file382templateData.label.setResource(getResourceLabelForGithubUri(uri), { icon: Codicon.github, title: data.title, strikethrough: data.excluded, extraClasses });383} else if (uri.scheme === this.productService.urlProtocol && isEqualAuthority(uri.authority, SETTINGS_AUTHORITY)) {384// a nicer label for settings URIs385const settingId = uri.path.substring(1);386templateData.label.setResource({ resource: uri, name: settingId }, { icon: Codicon.settingsGear, title: localize('setting.hover', "Open setting '{0}'", settingId), strikethrough: data.excluded, extraClasses });387} else if (matchesSomeScheme(uri, Schemas.mailto, Schemas.http, Schemas.https)) {388templateData.label.setResource({ resource: uri, name: uri.toString() }, { icon: icon ?? Codicon.globe, title: data.options?.status?.description ?? data.title ?? uri.toString(), strikethrough: data.excluded, extraClasses });389} else {390templateData.label.setFile(uri, {391fileKind: FileKind.FILE,392// Should not have this live-updating data on a historical reference393fileDecorations: undefined,394range: 'range' in reference ? reference.range : undefined,395title: data.options?.status?.description ?? data.title,396strikethrough: data.excluded,397extraClasses398});399}400}401402for (const selector of ['.monaco-icon-suffix-container', '.monaco-icon-name-container']) {403const element = templateData.label.element.querySelector(selector);404if (element) {405if (data.options?.status?.kind === ChatResponseReferencePartStatusKind.Omitted || data.options?.status?.kind === ChatResponseReferencePartStatusKind.Partial) {406element.classList.add('warning');407} else {408element.classList.remove('warning');409}410}411}412413if (data.state !== undefined) {414if (templateData.actionBarContainer) {415if (data.state === ModifiedFileEntryState.Modified && !templateData.actionBarContainer.classList.contains('modified')) {416const diffMeta = data?.options?.diffMeta;417if (diffMeta) {418if (!templateData.fileDiffsContainer || !templateData.addedSpan || !templateData.removedSpan) {419return;420}421templateData.addedSpan.textContent = `+${diffMeta.added}`;422templateData.removedSpan.textContent = `-${diffMeta.removed}`;423templateData.fileDiffsContainer.setAttribute('aria-label', localize('chatEditingSession.fileCounts', '{0} lines added, {1} lines removed', diffMeta.added, diffMeta.removed));424}425templateData.label.element.querySelector('.monaco-icon-name-container')?.classList.add('modified');426} else if (data.state !== ModifiedFileEntryState.Modified) {427templateData.actionBarContainer.classList.remove('modified');428templateData.label.element.querySelector('.monaco-icon-name-container')?.classList.remove('modified');429}430}431if (templateData.toolbar) {432templateData.toolbar.context = arg;433}434if (templateData.contextKeyService) {435if (data.state !== undefined) {436chatEditingWidgetFileStateContextKey.bindTo(templateData.contextKeyService).set(data.state);437}438}439}440}441442disposeTemplate(templateData: ICollapsibleListTemplate): void {443templateData.templateDisposables.dispose();444}445}446447function getResourceLabelForGithubUri(uri: URI): IResourceLabelProps {448const repoPath = uri.path.split('/').slice(1, 3).join('/');449const filePath = uri.path.split('/').slice(5);450const fileName = filePath.at(-1);451const range = getLineRangeFromGithubUri(uri);452return {453resource: uri,454name: fileName ?? filePath.join('/'),455description: [repoPath, ...filePath.slice(0, -1)].join('/'),456range457};458}459460function getLineRangeFromGithubUri(uri: URI): IRange | undefined {461if (!uri.fragment) {462return undefined;463}464465// Extract the line range from the fragment466// Github line ranges are 1-based467const match = uri.fragment.match(/\bL(\d+)(?:-L(\d+))?/);468if (!match) {469return undefined;470}471472const startLine = parseInt(match[1]);473if (isNaN(startLine)) {474return undefined;475}476477const endLine = match[2] ? parseInt(match[2]) : startLine;478if (isNaN(endLine)) {479return undefined;480}481482return {483startLineNumber: startLine,484startColumn: 1,485endLineNumber: endLine,486endColumn: 1487};488}489490function getResourceForElement(element: IChatCollapsibleListItem): URI | null {491if (element.kind === 'warning') {492return null;493}494const { reference } = element;495if (typeof reference === 'string' || 'variableName' in reference) {496return null;497} else if (URI.isUri(reference)) {498return reference;499} else {500return reference.uri;501}502}503504//#region Resource context menu505506registerAction2(class AddToChatAction extends Action2 {507508static readonly id = 'workbench.action.chat.addToChatAction';509510constructor() {511super({512id: AddToChatAction.id,513title: {514...localize2('addToChat', "Add File to Chat"),515},516f1: false,517menu: [{518id: MenuId.ChatAttachmentsContext,519group: 'chat',520order: 1,521when: ContextKeyExpr.and(ResourceContextKey.IsFileSystemResource, ExplorerFolderContext.negate()),522}]523});524}525526override async run(accessor: ServicesAccessor, resource: URI): Promise<void> {527const chatWidgetService = accessor.get(IChatWidgetService);528if (!resource) {529return;530}531532const widget = chatWidgetService.lastFocusedWidget;533if (widget) {534widget.attachmentModel.addFile(resource);535}536}537});538539registerAction2(class OpenChatReferenceLinkAction extends Action2 {540541static readonly id = 'workbench.action.chat.copyLink';542543constructor() {544super({545id: OpenChatReferenceLinkAction.id,546title: {547...localize2('copyLink', "Copy Link"),548},549f1: false,550menu: [{551id: MenuId.ChatAttachmentsContext,552group: 'chat',553order: 0,554when: ContextKeyExpr.or(ResourceContextKey.Scheme.isEqualTo(Schemas.http), ResourceContextKey.Scheme.isEqualTo(Schemas.https)),555}]556});557}558559override async run(accessor: ServicesAccessor, resource: URI): Promise<void> {560await accessor.get(IClipboardService).writeResources([resource]);561}562});563564//#endregion565566567