Path: blob/main/src/vs/workbench/contrib/chat/browser/chatContentParts/chatChangesSummaryPart.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 { $ } from '../../../../../base/browser/dom.js';7import { Disposable, DisposableStore, IDisposable, toDisposable } from '../../../../../base/common/lifecycle.js';8import { IChatContentPart, IChatContentPartRenderContext } from './chatContentParts.js';9import { IChatChangesSummaryPart as IChatFileChangesSummaryPart, IChatRendererContent } from '../../common/chatViewModel.js';10import { ChatTreeItem } from '../chat.js';11import { IInstantiationService } from '../../../../../platform/instantiation/common/instantiation.js';12import { IChatChangesSummary as IChatFileChangesSummary, IChatService } from '../../common/chatService.js';13import { IEditorService } from '../../../../services/editor/common/editorService.js';14import { IChatEditingSession, IEditSessionEntryDiff } from '../../common/chatEditingService.js';15import { WorkbenchList } from '../../../../../platform/list/browser/listService.js';16import { ButtonWithIcon } from '../../../../../base/browser/ui/button/button.js';17import { Codicon } from '../../../../../base/common/codicons.js';18import { URI } from '../../../../../base/common/uri.js';19import { ThemeIcon } from '../../../../../base/common/themables.js';20import { ResourcePool } from './chatCollections.js';21import { IResourceLabel, ResourceLabels } from '../../../../browser/labels.js';22import { IListRenderer, IListVirtualDelegate } from '../../../../../base/browser/ui/list/list.js';23import { FileKind } from '../../../../../platform/files/common/files.js';24import { createFileIconThemableTreeContainerScope } from '../../../files/browser/views/explorerView.js';25import { IThemeService } from '../../../../../platform/theme/common/themeService.js';26import { autorun, derived, IObservable, IObservableWithChange } from '../../../../../base/common/observable.js';27import { MultiDiffEditorInput } from '../../../multiDiffEditor/browser/multiDiffEditorInput.js';28import { MultiDiffEditorItem } from '../../../multiDiffEditor/browser/multiDiffSourceResolverService.js';29import { IEditorGroupsService } from '../../../../services/editor/common/editorGroupsService.js';30import { Emitter } from '../../../../../base/common/event.js';31import { IHoverService } from '../../../../../platform/hover/browser/hover.js';32import { localize2 } from '../../../../../nls.js';3334export class ChatCheckpointFileChangesSummaryContentPart extends Disposable implements IChatContentPart {3536public readonly domNode: HTMLElement;3738public readonly ELEMENT_HEIGHT = 22;39public readonly MAX_ITEMS_SHOWN = 6;4041private readonly _onDidChangeHeight = this._register(new Emitter<void>());42public readonly onDidChangeHeight = this._onDidChangeHeight.event;4344private readonly diffsBetweenRequests = new Map<string, IObservable<IEditSessionEntryDiff | undefined>>();4546private fileChanges: readonly IChatFileChangesSummary[];47private fileChangesDiffsObservable: IObservableWithChange<Map<string, IEditSessionEntryDiff>, void>;4849private list!: WorkbenchList<IChatFileChangesSummaryItem>;50private isCollapsed: boolean = true;5152constructor(53content: IChatFileChangesSummaryPart,54context: IChatContentPartRenderContext,55@IHoverService private readonly hoverService: IHoverService,56@IChatService private readonly chatService: IChatService,57@IEditorService private readonly editorService: IEditorService,58@IEditorGroupsService private readonly editorGroupsService: IEditorGroupsService,59@IInstantiationService private readonly instantiationService: IInstantiationService,60) {61super();6263this.fileChanges = content.fileChanges;64this.fileChangesDiffsObservable = this.computeFileChangesDiffs(context, content.fileChanges);6566const headerDomNode = $('.checkpoint-file-changes-summary-header');67this.domNode = $('.checkpoint-file-changes-summary', undefined, headerDomNode);68this.domNode.tabIndex = 0;6970this._register(this.renderHeader(headerDomNode));71this._register(this.renderFilesList(this.domNode));72}7374private changeID(change: IChatFileChangesSummary): string {75return `${change.sessionId}-${change.requestId}-${change.reference.path}`;76}7778private computeFileChangesDiffs(context: IChatContentPartRenderContext, changes: readonly IChatFileChangesSummary[]): IObservableWithChange<Map<string, IEditSessionEntryDiff>, void> {79return derived((r) => {80const fileChangesDiffs = new Map<string, IEditSessionEntryDiff>();81const firstRequestId = changes[0].requestId;82const lastRequestId = changes[changes.length - 1].requestId;83for (const change of changes) {84const sessionId = change.sessionId;85const session = this.chatService.getSession(sessionId);86if (!session || !session.editingSessionObs) {87continue;88}89const editSession = session.editingSessionObs.promiseResult.read(r)?.data;90if (!editSession) {91continue;92}93const diff = this.getCachedEntryDiffBetweenRequests(editSession, change.reference, firstRequestId, lastRequestId)?.read(r);94if (!diff) {95continue;96}97fileChangesDiffs.set(this.changeID(change), diff);98}99return fileChangesDiffs;100});101}102103public getCachedEntryDiffBetweenRequests(editSession: IChatEditingSession, uri: URI, startRequestId: string, stopRequestId: string): IObservable<IEditSessionEntryDiff | undefined> | undefined {104const key = `${uri}\0${startRequestId}\0${stopRequestId}`;105let observable = this.diffsBetweenRequests.get(key);106if (!observable) {107observable = editSession.getEntryDiffBetweenRequests(uri, startRequestId, stopRequestId);108this.diffsBetweenRequests.set(key, observable);109}110return observable;111}112113private renderHeader(container: HTMLElement): IDisposable {114const viewListButtonContainer = container.appendChild($('.chat-file-changes-label'));115const viewListButton = new ButtonWithIcon(viewListButtonContainer, {});116viewListButton.label = this.fileChanges.length === 1 ? `Changed 1 file` : `Changed ${this.fileChanges.length} files`;117118const setExpansionState = () => {119viewListButton.icon = this.isCollapsed ? Codicon.chevronRight : Codicon.chevronDown;120this.domNode.classList.toggle('chat-file-changes-collapsed', this.isCollapsed);121this._onDidChangeHeight.fire();122};123setExpansionState();124125const disposables = new DisposableStore();126disposables.add(viewListButton);127disposables.add(viewListButton.onDidClick(() => {128this.isCollapsed = !this.isCollapsed;129setExpansionState();130}));131disposables.add(this.renderViewAllFileChangesButton(viewListButton.element));132return toDisposable(() => disposables.dispose());133}134135private renderViewAllFileChangesButton(container: HTMLElement): IDisposable {136const button = container.appendChild($('.chat-view-changes-icon'));137this.hoverService.setupDelayedHover(button, () => ({138content: localize2('chat.viewFileChangesSummary', 'View All File Changes')139}));140button.classList.add(...ThemeIcon.asClassNameArray(Codicon.diffMultiple));141button.setAttribute('role', 'button');142button.tabIndex = 0;143144return dom.addDisposableListener(button, 'click', (e) => {145const resources: { originalUri: URI; modifiedUri?: URI }[] = [];146for (const fileChange of this.fileChanges) {147const diffEntry = this.fileChangesDiffsObservable.get().get(this.changeID(fileChange));148if (diffEntry) {149resources.push({150originalUri: diffEntry.originalURI,151modifiedUri: diffEntry.modifiedURI152});153} else {154resources.push({155originalUri: fileChange.reference156});157}158}159const source = URI.parse(`multi-diff-editor:${new Date().getMilliseconds().toString() + Math.random().toString()}`);160const input = this.instantiationService.createInstance(161MultiDiffEditorInput,162source,163'Checkpoint File Changes',164resources.map(resource => {165return new MultiDiffEditorItem(166resource.originalUri,167resource.modifiedUri,168undefined,169);170}),171false172);173this.editorGroupsService.activeGroup.openEditor(input);174dom.EventHelper.stop(e, true);175});176}177178private renderFilesList(container: HTMLElement): IDisposable {179const store = new DisposableStore();180this.list = store.add(this.instantiationService.createInstance(CollapsibleChangesSummaryListPool)).get();181const listNode = this.list.getHTMLElement();182const itemsShown = Math.min(this.fileChanges.length, this.MAX_ITEMS_SHOWN);183const height = itemsShown * this.ELEMENT_HEIGHT;184this.list.layout(height);185listNode.style.height = height + 'px';186this.updateList(this.fileChanges, this.fileChangesDiffsObservable.get());187container.appendChild(listNode.parentElement!);188189store.add(this.list.onDidOpen((item) => {190const element = item.element;191if (!element) {192return;193}194const diff = this.fileChangesDiffsObservable.get().get(this.changeID(element));195if (diff) {196const input = {197original: { resource: diff.originalURI },198modified: { resource: diff.modifiedURI },199options: { preserveFocus: true }200};201this.editorService.openEditor(input);202} else {203this.editorService.openEditor({ resource: element.reference, options: { preserveFocus: true } });204}205}));206store.add(this.list.onContextMenu(e => {207dom.EventHelper.stop(e.browserEvent, true);208}));209store.add(autorun((r) => {210this.updateList(this.fileChanges, this.fileChangesDiffsObservable.read(r));211}));212return store;213}214215private updateList(fileChanges: readonly IChatFileChangesSummary[], fileChangesDiffs: Map<string, IEditSessionEntryDiff>): void {216this.list.splice(0, this.list.length, this.computeFileChangeSummaryItems(fileChanges, fileChangesDiffs));217}218219private computeFileChangeSummaryItems(fileChanges: readonly IChatFileChangesSummary[], fileChangesDiffs: Map<string, IEditSessionEntryDiff>): IChatFileChangesSummaryItem[] {220const items: IChatFileChangesSummaryItem[] = [];221for (const fileChange of fileChanges) {222const diffEntry = fileChangesDiffs.get(this.changeID(fileChange));223if (diffEntry) {224const additionalLabels: { description: string; className: string }[] = [];225if (diffEntry) {226additionalLabels.push({227description: ` +${diffEntry.added} `,228className: 'insertions',229});230additionalLabels.push({231description: ` -${diffEntry.removed} `,232className: 'deletions',233});234}235const item: IChatFileChangesSummaryItem = {236...fileChange,237additionalLabels238};239items.push(item);240} else {241items.push(fileChange);242}243}244return items;245}246247hasSameContent(other: IChatRendererContent, followingContent: IChatRendererContent[], element: ChatTreeItem): boolean {248return other.kind === 'changesSummary' && other.fileChanges.length === this.fileChanges.length;249}250251addDisposable(disposable: IDisposable): void {252this._register(disposable);253}254}255256interface IChatFileChangesSummaryItem extends IChatFileChangesSummary {257additionalLabels?: { description: string; className: string }[];258}259260interface IChatFileChangesSummaryListWrapper extends IDisposable {261list: WorkbenchList<IChatFileChangesSummaryItem>;262}263264class CollapsibleChangesSummaryListPool extends Disposable {265266private _resourcePool: ResourcePool<IChatFileChangesSummaryListWrapper>;267268constructor(269@IInstantiationService private readonly instantiationService: IInstantiationService,270@IThemeService private readonly themeService: IThemeService271) {272super();273this._resourcePool = this._register(new ResourcePool(() => this.listFactory()));274}275276private listFactory(): IChatFileChangesSummaryListWrapper {277const container = $('.chat-summary-list');278const store = new DisposableStore();279store.add(createFileIconThemableTreeContainerScope(container, this.themeService));280const resourceLabels = store.add(this.instantiationService.createInstance(ResourceLabels, { onDidChangeVisibility: () => Disposable.None }));281const list = store.add(this.instantiationService.createInstance(282WorkbenchList<IChatFileChangesSummaryItem>,283'ChatListRenderer',284container,285new CollapsibleChangesSummaryListDelegate(),286[this.instantiationService.createInstance(CollapsibleChangesSummaryListRenderer, resourceLabels)],287{288alwaysConsumeMouseWheel: false289}290));291return {292list: list,293dispose: () => {294store.dispose();295}296};297}298299get(): WorkbenchList<IChatFileChangesSummaryItem> {300return this._resourcePool.get().list;301}302}303304interface ICollapsibleChangesSummaryListTemplate extends IDisposable {305readonly label: IResourceLabel;306}307308class CollapsibleChangesSummaryListDelegate implements IListVirtualDelegate<IChatFileChangesSummaryItem> {309310getHeight(element: IChatFileChangesSummaryItem): number {311return 22;312}313314getTemplateId(element: IChatFileChangesSummaryItem): string {315return CollapsibleChangesSummaryListRenderer.TEMPLATE_ID;316}317}318319class CollapsibleChangesSummaryListRenderer implements IListRenderer<IChatFileChangesSummaryItem, ICollapsibleChangesSummaryListTemplate> {320321static TEMPLATE_ID = 'collapsibleChangesSummaryListRenderer';322static CHANGES_SUMMARY_CLASS_NAME = 'insertions-and-deletions';323324readonly templateId: string = CollapsibleChangesSummaryListRenderer.TEMPLATE_ID;325326constructor(private labels: ResourceLabels) { }327328renderTemplate(container: HTMLElement): ICollapsibleChangesSummaryListTemplate {329const label = this.labels.create(container, { supportHighlights: true, supportIcons: true });330return { label, dispose: () => label.dispose() };331}332333renderElement(data: IChatFileChangesSummaryItem, index: number, templateData: ICollapsibleChangesSummaryListTemplate): void {334const label = templateData.label;335label.setFile(data.reference, {336fileKind: FileKind.FILE,337title: data.reference.path338});339const labelElement = label.element;340labelElement.querySelector(`.${CollapsibleChangesSummaryListRenderer.CHANGES_SUMMARY_CLASS_NAME}`)?.remove();341if (!data.additionalLabels) {342return;343}344const changesSummary = labelElement.appendChild($(`.${CollapsibleChangesSummaryListRenderer.CHANGES_SUMMARY_CLASS_NAME}`));345for (const additionalLabel of data.additionalLabels) {346const element = changesSummary.appendChild($(`.${additionalLabel.className}`));347element.textContent = additionalLabel.description;348}349}350351disposeTemplate(templateData: ICollapsibleChangesSummaryListTemplate): void {352templateData.dispose();353}354}355356357