Book a Demo!
CoCalc Logo Icon
StoreFeaturesDocsShareSupportNewsAboutPoliciesSign UpSign In
microsoft
GitHub Repository: microsoft/vscode
Path: blob/main/src/vs/workbench/contrib/chat/browser/chatContentParts/chatChangesSummaryPart.ts
3296 views
1
/*---------------------------------------------------------------------------------------------
2
* Copyright (c) Microsoft Corporation. All rights reserved.
3
* Licensed under the MIT License. See License.txt in the project root for license information.
4
*--------------------------------------------------------------------------------------------*/
5
6
import * as dom from '../../../../../base/browser/dom.js';
7
import { $ } from '../../../../../base/browser/dom.js';
8
import { Disposable, DisposableStore, IDisposable, toDisposable } from '../../../../../base/common/lifecycle.js';
9
import { IChatContentPart, IChatContentPartRenderContext } from './chatContentParts.js';
10
import { IChatChangesSummaryPart as IChatFileChangesSummaryPart, IChatRendererContent } from '../../common/chatViewModel.js';
11
import { ChatTreeItem } from '../chat.js';
12
import { IInstantiationService } from '../../../../../platform/instantiation/common/instantiation.js';
13
import { IChatChangesSummary as IChatFileChangesSummary, IChatService } from '../../common/chatService.js';
14
import { IEditorService } from '../../../../services/editor/common/editorService.js';
15
import { IChatEditingSession, IEditSessionEntryDiff } from '../../common/chatEditingService.js';
16
import { WorkbenchList } from '../../../../../platform/list/browser/listService.js';
17
import { ButtonWithIcon } from '../../../../../base/browser/ui/button/button.js';
18
import { Codicon } from '../../../../../base/common/codicons.js';
19
import { URI } from '../../../../../base/common/uri.js';
20
import { ThemeIcon } from '../../../../../base/common/themables.js';
21
import { ResourcePool } from './chatCollections.js';
22
import { IResourceLabel, ResourceLabels } from '../../../../browser/labels.js';
23
import { IListRenderer, IListVirtualDelegate } from '../../../../../base/browser/ui/list/list.js';
24
import { FileKind } from '../../../../../platform/files/common/files.js';
25
import { createFileIconThemableTreeContainerScope } from '../../../files/browser/views/explorerView.js';
26
import { IThemeService } from '../../../../../platform/theme/common/themeService.js';
27
import { autorun, derived, IObservable, IObservableWithChange } from '../../../../../base/common/observable.js';
28
import { MultiDiffEditorInput } from '../../../multiDiffEditor/browser/multiDiffEditorInput.js';
29
import { MultiDiffEditorItem } from '../../../multiDiffEditor/browser/multiDiffSourceResolverService.js';
30
import { IEditorGroupsService } from '../../../../services/editor/common/editorGroupsService.js';
31
import { Emitter } from '../../../../../base/common/event.js';
32
import { IHoverService } from '../../../../../platform/hover/browser/hover.js';
33
import { localize2 } from '../../../../../nls.js';
34
35
export class ChatCheckpointFileChangesSummaryContentPart extends Disposable implements IChatContentPart {
36
37
public readonly domNode: HTMLElement;
38
39
public readonly ELEMENT_HEIGHT = 22;
40
public readonly MAX_ITEMS_SHOWN = 6;
41
42
private readonly _onDidChangeHeight = this._register(new Emitter<void>());
43
public readonly onDidChangeHeight = this._onDidChangeHeight.event;
44
45
private readonly diffsBetweenRequests = new Map<string, IObservable<IEditSessionEntryDiff | undefined>>();
46
47
private fileChanges: readonly IChatFileChangesSummary[];
48
private fileChangesDiffsObservable: IObservableWithChange<Map<string, IEditSessionEntryDiff>, void>;
49
50
private list!: WorkbenchList<IChatFileChangesSummaryItem>;
51
private isCollapsed: boolean = true;
52
53
constructor(
54
content: IChatFileChangesSummaryPart,
55
context: IChatContentPartRenderContext,
56
@IHoverService private readonly hoverService: IHoverService,
57
@IChatService private readonly chatService: IChatService,
58
@IEditorService private readonly editorService: IEditorService,
59
@IEditorGroupsService private readonly editorGroupsService: IEditorGroupsService,
60
@IInstantiationService private readonly instantiationService: IInstantiationService,
61
) {
62
super();
63
64
this.fileChanges = content.fileChanges;
65
this.fileChangesDiffsObservable = this.computeFileChangesDiffs(context, content.fileChanges);
66
67
const headerDomNode = $('.checkpoint-file-changes-summary-header');
68
this.domNode = $('.checkpoint-file-changes-summary', undefined, headerDomNode);
69
this.domNode.tabIndex = 0;
70
71
this._register(this.renderHeader(headerDomNode));
72
this._register(this.renderFilesList(this.domNode));
73
}
74
75
private changeID(change: IChatFileChangesSummary): string {
76
return `${change.sessionId}-${change.requestId}-${change.reference.path}`;
77
}
78
79
private computeFileChangesDiffs(context: IChatContentPartRenderContext, changes: readonly IChatFileChangesSummary[]): IObservableWithChange<Map<string, IEditSessionEntryDiff>, void> {
80
return derived((r) => {
81
const fileChangesDiffs = new Map<string, IEditSessionEntryDiff>();
82
const firstRequestId = changes[0].requestId;
83
const lastRequestId = changes[changes.length - 1].requestId;
84
for (const change of changes) {
85
const sessionId = change.sessionId;
86
const session = this.chatService.getSession(sessionId);
87
if (!session || !session.editingSessionObs) {
88
continue;
89
}
90
const editSession = session.editingSessionObs.promiseResult.read(r)?.data;
91
if (!editSession) {
92
continue;
93
}
94
const diff = this.getCachedEntryDiffBetweenRequests(editSession, change.reference, firstRequestId, lastRequestId)?.read(r);
95
if (!diff) {
96
continue;
97
}
98
fileChangesDiffs.set(this.changeID(change), diff);
99
}
100
return fileChangesDiffs;
101
});
102
}
103
104
public getCachedEntryDiffBetweenRequests(editSession: IChatEditingSession, uri: URI, startRequestId: string, stopRequestId: string): IObservable<IEditSessionEntryDiff | undefined> | undefined {
105
const key = `${uri}\0${startRequestId}\0${stopRequestId}`;
106
let observable = this.diffsBetweenRequests.get(key);
107
if (!observable) {
108
observable = editSession.getEntryDiffBetweenRequests(uri, startRequestId, stopRequestId);
109
this.diffsBetweenRequests.set(key, observable);
110
}
111
return observable;
112
}
113
114
private renderHeader(container: HTMLElement): IDisposable {
115
const viewListButtonContainer = container.appendChild($('.chat-file-changes-label'));
116
const viewListButton = new ButtonWithIcon(viewListButtonContainer, {});
117
viewListButton.label = this.fileChanges.length === 1 ? `Changed 1 file` : `Changed ${this.fileChanges.length} files`;
118
119
const setExpansionState = () => {
120
viewListButton.icon = this.isCollapsed ? Codicon.chevronRight : Codicon.chevronDown;
121
this.domNode.classList.toggle('chat-file-changes-collapsed', this.isCollapsed);
122
this._onDidChangeHeight.fire();
123
};
124
setExpansionState();
125
126
const disposables = new DisposableStore();
127
disposables.add(viewListButton);
128
disposables.add(viewListButton.onDidClick(() => {
129
this.isCollapsed = !this.isCollapsed;
130
setExpansionState();
131
}));
132
disposables.add(this.renderViewAllFileChangesButton(viewListButton.element));
133
return toDisposable(() => disposables.dispose());
134
}
135
136
private renderViewAllFileChangesButton(container: HTMLElement): IDisposable {
137
const button = container.appendChild($('.chat-view-changes-icon'));
138
this.hoverService.setupDelayedHover(button, () => ({
139
content: localize2('chat.viewFileChangesSummary', 'View All File Changes')
140
}));
141
button.classList.add(...ThemeIcon.asClassNameArray(Codicon.diffMultiple));
142
button.setAttribute('role', 'button');
143
button.tabIndex = 0;
144
145
return dom.addDisposableListener(button, 'click', (e) => {
146
const resources: { originalUri: URI; modifiedUri?: URI }[] = [];
147
for (const fileChange of this.fileChanges) {
148
const diffEntry = this.fileChangesDiffsObservable.get().get(this.changeID(fileChange));
149
if (diffEntry) {
150
resources.push({
151
originalUri: diffEntry.originalURI,
152
modifiedUri: diffEntry.modifiedURI
153
});
154
} else {
155
resources.push({
156
originalUri: fileChange.reference
157
});
158
}
159
}
160
const source = URI.parse(`multi-diff-editor:${new Date().getMilliseconds().toString() + Math.random().toString()}`);
161
const input = this.instantiationService.createInstance(
162
MultiDiffEditorInput,
163
source,
164
'Checkpoint File Changes',
165
resources.map(resource => {
166
return new MultiDiffEditorItem(
167
resource.originalUri,
168
resource.modifiedUri,
169
undefined,
170
);
171
}),
172
false
173
);
174
this.editorGroupsService.activeGroup.openEditor(input);
175
dom.EventHelper.stop(e, true);
176
});
177
}
178
179
private renderFilesList(container: HTMLElement): IDisposable {
180
const store = new DisposableStore();
181
this.list = store.add(this.instantiationService.createInstance(CollapsibleChangesSummaryListPool)).get();
182
const listNode = this.list.getHTMLElement();
183
const itemsShown = Math.min(this.fileChanges.length, this.MAX_ITEMS_SHOWN);
184
const height = itemsShown * this.ELEMENT_HEIGHT;
185
this.list.layout(height);
186
listNode.style.height = height + 'px';
187
this.updateList(this.fileChanges, this.fileChangesDiffsObservable.get());
188
container.appendChild(listNode.parentElement!);
189
190
store.add(this.list.onDidOpen((item) => {
191
const element = item.element;
192
if (!element) {
193
return;
194
}
195
const diff = this.fileChangesDiffsObservable.get().get(this.changeID(element));
196
if (diff) {
197
const input = {
198
original: { resource: diff.originalURI },
199
modified: { resource: diff.modifiedURI },
200
options: { preserveFocus: true }
201
};
202
this.editorService.openEditor(input);
203
} else {
204
this.editorService.openEditor({ resource: element.reference, options: { preserveFocus: true } });
205
}
206
}));
207
store.add(this.list.onContextMenu(e => {
208
dom.EventHelper.stop(e.browserEvent, true);
209
}));
210
store.add(autorun((r) => {
211
this.updateList(this.fileChanges, this.fileChangesDiffsObservable.read(r));
212
}));
213
return store;
214
}
215
216
private updateList(fileChanges: readonly IChatFileChangesSummary[], fileChangesDiffs: Map<string, IEditSessionEntryDiff>): void {
217
this.list.splice(0, this.list.length, this.computeFileChangeSummaryItems(fileChanges, fileChangesDiffs));
218
}
219
220
private computeFileChangeSummaryItems(fileChanges: readonly IChatFileChangesSummary[], fileChangesDiffs: Map<string, IEditSessionEntryDiff>): IChatFileChangesSummaryItem[] {
221
const items: IChatFileChangesSummaryItem[] = [];
222
for (const fileChange of fileChanges) {
223
const diffEntry = fileChangesDiffs.get(this.changeID(fileChange));
224
if (diffEntry) {
225
const additionalLabels: { description: string; className: string }[] = [];
226
if (diffEntry) {
227
additionalLabels.push({
228
description: ` +${diffEntry.added} `,
229
className: 'insertions',
230
});
231
additionalLabels.push({
232
description: ` -${diffEntry.removed} `,
233
className: 'deletions',
234
});
235
}
236
const item: IChatFileChangesSummaryItem = {
237
...fileChange,
238
additionalLabels
239
};
240
items.push(item);
241
} else {
242
items.push(fileChange);
243
}
244
}
245
return items;
246
}
247
248
hasSameContent(other: IChatRendererContent, followingContent: IChatRendererContent[], element: ChatTreeItem): boolean {
249
return other.kind === 'changesSummary' && other.fileChanges.length === this.fileChanges.length;
250
}
251
252
addDisposable(disposable: IDisposable): void {
253
this._register(disposable);
254
}
255
}
256
257
interface IChatFileChangesSummaryItem extends IChatFileChangesSummary {
258
additionalLabels?: { description: string; className: string }[];
259
}
260
261
interface IChatFileChangesSummaryListWrapper extends IDisposable {
262
list: WorkbenchList<IChatFileChangesSummaryItem>;
263
}
264
265
class CollapsibleChangesSummaryListPool extends Disposable {
266
267
private _resourcePool: ResourcePool<IChatFileChangesSummaryListWrapper>;
268
269
constructor(
270
@IInstantiationService private readonly instantiationService: IInstantiationService,
271
@IThemeService private readonly themeService: IThemeService
272
) {
273
super();
274
this._resourcePool = this._register(new ResourcePool(() => this.listFactory()));
275
}
276
277
private listFactory(): IChatFileChangesSummaryListWrapper {
278
const container = $('.chat-summary-list');
279
const store = new DisposableStore();
280
store.add(createFileIconThemableTreeContainerScope(container, this.themeService));
281
const resourceLabels = store.add(this.instantiationService.createInstance(ResourceLabels, { onDidChangeVisibility: () => Disposable.None }));
282
const list = store.add(this.instantiationService.createInstance(
283
WorkbenchList<IChatFileChangesSummaryItem>,
284
'ChatListRenderer',
285
container,
286
new CollapsibleChangesSummaryListDelegate(),
287
[this.instantiationService.createInstance(CollapsibleChangesSummaryListRenderer, resourceLabels)],
288
{
289
alwaysConsumeMouseWheel: false
290
}
291
));
292
return {
293
list: list,
294
dispose: () => {
295
store.dispose();
296
}
297
};
298
}
299
300
get(): WorkbenchList<IChatFileChangesSummaryItem> {
301
return this._resourcePool.get().list;
302
}
303
}
304
305
interface ICollapsibleChangesSummaryListTemplate extends IDisposable {
306
readonly label: IResourceLabel;
307
}
308
309
class CollapsibleChangesSummaryListDelegate implements IListVirtualDelegate<IChatFileChangesSummaryItem> {
310
311
getHeight(element: IChatFileChangesSummaryItem): number {
312
return 22;
313
}
314
315
getTemplateId(element: IChatFileChangesSummaryItem): string {
316
return CollapsibleChangesSummaryListRenderer.TEMPLATE_ID;
317
}
318
}
319
320
class CollapsibleChangesSummaryListRenderer implements IListRenderer<IChatFileChangesSummaryItem, ICollapsibleChangesSummaryListTemplate> {
321
322
static TEMPLATE_ID = 'collapsibleChangesSummaryListRenderer';
323
static CHANGES_SUMMARY_CLASS_NAME = 'insertions-and-deletions';
324
325
readonly templateId: string = CollapsibleChangesSummaryListRenderer.TEMPLATE_ID;
326
327
constructor(private labels: ResourceLabels) { }
328
329
renderTemplate(container: HTMLElement): ICollapsibleChangesSummaryListTemplate {
330
const label = this.labels.create(container, { supportHighlights: true, supportIcons: true });
331
return { label, dispose: () => label.dispose() };
332
}
333
334
renderElement(data: IChatFileChangesSummaryItem, index: number, templateData: ICollapsibleChangesSummaryListTemplate): void {
335
const label = templateData.label;
336
label.setFile(data.reference, {
337
fileKind: FileKind.FILE,
338
title: data.reference.path
339
});
340
const labelElement = label.element;
341
labelElement.querySelector(`.${CollapsibleChangesSummaryListRenderer.CHANGES_SUMMARY_CLASS_NAME}`)?.remove();
342
if (!data.additionalLabels) {
343
return;
344
}
345
const changesSummary = labelElement.appendChild($(`.${CollapsibleChangesSummaryListRenderer.CHANGES_SUMMARY_CLASS_NAME}`));
346
for (const additionalLabel of data.additionalLabels) {
347
const element = changesSummary.appendChild($(`.${additionalLabel.className}`));
348
element.textContent = additionalLabel.description;
349
}
350
}
351
352
disposeTemplate(templateData: ICollapsibleChangesSummaryListTemplate): void {
353
templateData.dispose();
354
}
355
}
356
357