Book a Demo!
CoCalc Logo Icon
StoreFeaturesDocsShareSupportNewsAboutPoliciesSign UpSign In
microsoft
GitHub Repository: microsoft/vscode
Path: blob/main/src/vs/sessions/contrib/changes/browser/changesViewRenderer.ts
13401 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 { ICompressedTreeElement, ICompressedTreeNode } from '../../../../base/browser/ui/tree/compressedObjectTreeModel.js';
8
import { ICompressibleTreeRenderer } from '../../../../base/browser/ui/tree/objectTree.js';
9
import { ITreeNode } from '../../../../base/browser/ui/tree/tree.js';
10
import { ActionRunner } from '../../../../base/common/actions.js';
11
import { DisposableStore } from '../../../../base/common/lifecycle.js';
12
import { autorun } from '../../../../base/common/observable.js';
13
import { basename, dirname, extUriBiasedIgnorePathCase, relativePath } from '../../../../base/common/resources.js';
14
import { IResourceNode, ResourceTree } from '../../../../base/common/resourceTree.js';
15
import { URI } from '../../../../base/common/uri.js';
16
import { MenuWorkbenchToolBar } from '../../../../platform/actions/browser/toolbar.js';
17
import { MenuId } from '../../../../platform/actions/common/actions.js';
18
import { IContextKey, IContextKeyService } from '../../../../platform/contextkey/common/contextkey.js';
19
import { FileKind } from '../../../../platform/files/common/files.js';
20
import { IInstantiationService } from '../../../../platform/instantiation/common/instantiation.js';
21
import { ServiceCollection } from '../../../../platform/instantiation/common/serviceCollection.js';
22
import { ILabelService } from '../../../../platform/label/common/label.js';
23
import { bindContextKey } from '../../../../platform/observable/common/platformObservableUtils.js';
24
import { IResourceLabel, ResourceLabels } from '../../../../workbench/browser/labels.js';
25
import { ChatContextKeys } from '../../../../workbench/contrib/chat/common/actions/chatContextKeys.js';
26
import { isIChatSessionFileChange2 } from '../../../../workbench/contrib/chat/common/chatSessionsService.js';
27
import { ModifiedFileEntryState } from '../../../../workbench/contrib/chat/common/editing/chatEditingService.js';
28
import { ISessionsManagementService } from '../../../services/sessions/common/sessionsManagement.js';
29
import { GITHUB_REMOTE_FILE_SCHEME, ISessionFileChange } from '../../../services/sessions/common/session.js';
30
import { ActiveSessionContextKeys, ChangesContextKeys, ChangesViewMode } from '../common/changes.js';
31
import { ChangesViewModel } from './changesViewModel.js';
32
33
const $ = dom.$;
34
35
export function toIChangesFileItem(changes: readonly ISessionFileChange[]): IChangesFileItem[] {
36
return changes.map(change => {
37
const isAddition = change.originalUri === undefined;
38
const isDeletion = change.modifiedUri === undefined;
39
const uri = isIChatSessionFileChange2(change)
40
? change.uri
41
: change.modifiedUri;
42
43
return {
44
type: 'file',
45
uri,
46
originalUri: change.originalUri,
47
isDeletion,
48
state: ModifiedFileEntryState.Accepted,
49
changeType: isAddition
50
? 'added'
51
: isDeletion
52
? 'deleted'
53
: 'modified',
54
linesAdded: change.insertions,
55
linesRemoved: change.deletions
56
} satisfies IChangesFileItem;
57
});
58
}
59
60
type ChangeType = 'added' | 'modified' | 'deleted' | 'none';
61
62
export interface IChangesFileItem {
63
readonly type: 'file';
64
readonly uri: URI;
65
readonly originalUri?: URI;
66
readonly state: ModifiedFileEntryState;
67
readonly isDeletion: boolean;
68
readonly changeType: ChangeType;
69
readonly linesAdded: number;
70
readonly linesRemoved: number;
71
}
72
73
export interface IChangesRootItem {
74
readonly type: 'root';
75
readonly uri: URI;
76
readonly name: string;
77
}
78
79
export interface IChangesTreeRootInfo {
80
readonly root: IChangesRootItem;
81
readonly resourceTreeRootUri: URI;
82
}
83
84
export type ChangesTreeElement = IChangesRootItem | IChangesFileItem | IResourceNode<IChangesFileItem, undefined>;
85
86
export function isChangesFileItem(element: ChangesTreeElement): element is IChangesFileItem {
87
return !ResourceTree.isResourceNode(element) && element.type === 'file';
88
}
89
90
export function isChangesRootItem(element: ChangesTreeElement): element is IChangesRootItem {
91
return !ResourceTree.isResourceNode(element) && element.type === 'root';
92
}
93
94
export function buildTreeChildren(items: IChangesFileItem[], treeRootInfo?: IChangesTreeRootInfo): ICompressedTreeElement<ChangesTreeElement>[] {
95
if (items.length === 0) {
96
return [];
97
}
98
99
let rootUri = treeRootInfo?.resourceTreeRootUri ?? URI.file('/');
100
101
// For github-remote-file URIs, set the root to /{owner}/{repo}/{ref}
102
// so the tree shows repo-relative paths instead of internal URI segments.
103
if (!treeRootInfo && items[0].uri.scheme === GITHUB_REMOTE_FILE_SCHEME) {
104
const parts = items[0].uri.path.split('/').filter(Boolean);
105
if (parts.length >= 3) {
106
rootUri = items[0].uri.with({ path: '/' + parts.slice(0, 3).join('/') });
107
}
108
}
109
110
const resourceTree = new ResourceTree<IChangesFileItem, undefined>(undefined, rootUri, extUriBiasedIgnorePathCase);
111
for (const item of items) {
112
resourceTree.add(item.uri, item);
113
}
114
115
function convertChildren(parent: IResourceNode<IChangesFileItem, undefined>): ICompressedTreeElement<ChangesTreeElement>[] {
116
const result: ICompressedTreeElement<ChangesTreeElement>[] = [];
117
for (const child of parent.children) {
118
if (child.element && child.childrenCount === 0) {
119
// Leaf node — just the file item
120
result.push({
121
element: child.element,
122
collapsible: false,
123
incompressible: true,
124
});
125
} else {
126
// Folder node. Ensure that the first level of folders under
127
// the root folder are not being collapsed with the root folder
128
// as that is a special node showing the workspace folder and
129
// branch information.
130
result.push({
131
element: child,
132
children: convertChildren(child),
133
incompressible: parent === resourceTree.root,
134
collapsible: true,
135
collapsed: false,
136
});
137
}
138
}
139
return result;
140
}
141
142
const children = convertChildren(resourceTree.root);
143
if (!treeRootInfo) {
144
return children;
145
}
146
147
return [{
148
element: treeRootInfo.root,
149
children,
150
collapsible: true,
151
collapsed: false,
152
incompressible: true,
153
}];
154
}
155
156
interface IChangesTreeTemplate {
157
readonly label: IResourceLabel;
158
readonly toolbar: MenuWorkbenchToolBar | undefined;
159
readonly changeKindContextKey: IContextKey<'root' | 'folder' | 'file'>;
160
readonly reviewCommentsBadge: HTMLElement;
161
readonly agentFeedbackBadge: HTMLElement;
162
readonly decorationBadge: HTMLElement;
163
readonly addedSpan: HTMLElement;
164
readonly removedSpan: HTMLElement;
165
readonly lineCountsContainer: HTMLElement;
166
readonly elementDisposables: DisposableStore;
167
readonly templateDisposables: DisposableStore;
168
}
169
170
export class ChangesTreeRenderer implements ICompressibleTreeRenderer<ChangesTreeElement, void, IChangesTreeTemplate> {
171
static TEMPLATE_ID = 'changesTreeRenderer';
172
readonly templateId: string = ChangesTreeRenderer.TEMPLATE_ID;
173
174
constructor(
175
private viewModel: ChangesViewModel,
176
private labels: ResourceLabels,
177
private actionRunner: ActionRunner | undefined,
178
private getRootUri: () => URI | undefined,
179
@IInstantiationService private readonly instantiationService: IInstantiationService,
180
@IContextKeyService private readonly contextKeyService: IContextKeyService,
181
@ILabelService private readonly labelService: ILabelService,
182
@ISessionsManagementService private readonly sessionManagementService: ISessionsManagementService,
183
) { }
184
185
renderTemplate(container: HTMLElement): IChangesTreeTemplate {
186
const templateDisposables = new DisposableStore();
187
const label = templateDisposables.add(this.labels.create(container, { supportHighlights: true, supportIcons: true }));
188
189
const reviewCommentsBadge = dom.$('.changes-review-comments-badge');
190
label.element.appendChild(reviewCommentsBadge);
191
192
const agentFeedbackBadge = dom.$('.changes-agent-feedback-badge');
193
label.element.appendChild(agentFeedbackBadge);
194
195
const lineCountsContainer = $('.working-set-line-counts');
196
const addedSpan = dom.$('.working-set-lines-added');
197
const removedSpan = dom.$('.working-set-lines-removed');
198
lineCountsContainer.appendChild(addedSpan);
199
lineCountsContainer.appendChild(removedSpan);
200
label.element.appendChild(lineCountsContainer);
201
202
const actionBarContainer = $('.chat-collapsible-list-action-bar');
203
const contextKeyService = templateDisposables.add(this.contextKeyService.createScoped(actionBarContainer));
204
const scopedInstantiationService = templateDisposables.add(this.instantiationService.createChild(new ServiceCollection([IContextKeyService, contextKeyService])));
205
const toolbar = templateDisposables.add(scopedInstantiationService.createInstance(MenuWorkbenchToolBar, actionBarContainer, MenuId.ChatEditingSessionChangeToolbar, { menuOptions: { shouldForwardArgs: true, arg: undefined }, actionRunner: this.actionRunner }));
206
label.element.appendChild(actionBarContainer);
207
208
templateDisposables.add(bindContextKey(ChatContextKeys.agentSessionType, contextKeyService, reader => {
209
const activeSession = this.sessionManagementService.activeSession.read(reader);
210
return activeSession?.sessionType ?? '';
211
}));
212
213
templateDisposables.add(bindContextKey(ActiveSessionContextKeys.HasGitRepository, contextKeyService, reader => {
214
return this.viewModel.activeSessionHasGitRepositoryObs.read(reader);
215
}));
216
217
templateDisposables.add(bindContextKey(ChangesContextKeys.VersionMode, contextKeyService, reader => {
218
return this.viewModel.versionModeObs.read(reader);
219
}));
220
221
const changeKindContextKey = ChangesContextKeys.ChangeKind.bindTo(contextKeyService);
222
223
const decorationBadge = dom.$('.changes-decoration-badge');
224
label.element.appendChild(decorationBadge);
225
226
return { label, toolbar, changeKindContextKey, reviewCommentsBadge, agentFeedbackBadge, decorationBadge, addedSpan, removedSpan, lineCountsContainer, elementDisposables: new DisposableStore(), templateDisposables };
227
}
228
229
renderElement(node: ITreeNode<ChangesTreeElement, void>, _index: number, templateData: IChangesTreeTemplate): void {
230
const element = node.element;
231
templateData.label.element.style.display = 'flex';
232
233
if (isChangesRootItem(element)) {
234
// Root element
235
this.renderRootElement(element, templateData);
236
} else if (ResourceTree.isResourceNode(element)) {
237
// Folder element
238
this.renderFolderElement(element, templateData);
239
} else {
240
// File element
241
this.renderFileElement(element, templateData);
242
}
243
}
244
245
renderCompressedElements(node: ITreeNode<ICompressedTreeNode<ChangesTreeElement>, void>, _index: number, templateData: IChangesTreeTemplate): void {
246
const compressed = node.element as ICompressedTreeNode<IResourceNode<IChangesFileItem, undefined>>;
247
const folder = compressed.elements[compressed.elements.length - 1];
248
249
templateData.label.element.style.display = 'flex';
250
251
const label = compressed.elements.map(e => e.name);
252
templateData.label.setResource({ resource: folder.uri, name: label }, {
253
fileKind: FileKind.FOLDER,
254
separator: this.labelService.getSeparator(folder.uri.scheme),
255
});
256
257
// Hide file-specific decorations for folders
258
templateData.reviewCommentsBadge.style.display = 'none';
259
templateData.agentFeedbackBadge.style.display = 'none';
260
templateData.decorationBadge.style.display = 'none';
261
templateData.lineCountsContainer.style.display = 'none';
262
263
if (templateData.toolbar) {
264
templateData.toolbar.context = folder;
265
}
266
267
templateData.changeKindContextKey.set('folder');
268
}
269
270
private renderFileElement(data: IChangesFileItem, templateData: IChangesTreeTemplate): void {
271
const root = this.getRootUri();
272
const viewMode = this.viewModel.viewModeObs.get();
273
274
templateData.label.setResource({
275
resource: data.uri,
276
name: basename(data.uri),
277
description: viewMode === ChangesViewMode.List
278
? root
279
? relativePath(root, dirname(data.uri))
280
: undefined
281
: undefined,
282
}, {
283
fileKind: FileKind.FILE,
284
fileDecorations: undefined,
285
strikethrough: data.changeType === 'deleted'
286
});
287
288
const showChangeDecorations = data.changeType !== 'none';
289
290
// Show file-specific decorations for changed files only
291
templateData.lineCountsContainer.style.display = showChangeDecorations ? '' : 'none';
292
templateData.decorationBadge.style.display = showChangeDecorations ? '' : 'none';
293
294
// Review comments
295
templateData.elementDisposables.add(autorun(reader => {
296
const reviewCommentByFile = this.viewModel.activeSessionReviewCommentCountByFileObs.read(reader);
297
const reviewCommentCount = reviewCommentByFile?.get(data.uri.fsPath) ?? 0;
298
299
if (reviewCommentCount > 0) {
300
templateData.reviewCommentsBadge.style.display = '';
301
templateData.reviewCommentsBadge.className = 'changes-review-comments-badge';
302
templateData.reviewCommentsBadge.replaceChildren(
303
dom.$('.codicon.codicon-comment-unresolved'),
304
dom.$('span', undefined, `${reviewCommentCount}`)
305
);
306
} else {
307
templateData.reviewCommentsBadge.style.display = 'none';
308
templateData.reviewCommentsBadge.replaceChildren();
309
}
310
}));
311
312
// Agent feedback
313
templateData.elementDisposables.add(autorun(reader => {
314
const agentFeedbackByFile = this.viewModel.activeSessionAgentFeedbackCountByFileObs.read(reader);
315
const agentFeedbackCount = agentFeedbackByFile?.get(data.uri.fsPath) ?? 0;
316
317
if (agentFeedbackCount > 0) {
318
templateData.agentFeedbackBadge.style.display = '';
319
templateData.agentFeedbackBadge.className = 'changes-agent-feedback-badge';
320
templateData.agentFeedbackBadge.replaceChildren(
321
dom.$('.codicon.codicon-comment'),
322
dom.$('span', undefined, `${agentFeedbackCount}`)
323
);
324
} else {
325
templateData.agentFeedbackBadge.style.display = 'none';
326
templateData.agentFeedbackBadge.replaceChildren();
327
}
328
}));
329
330
const badge = templateData.decorationBadge;
331
badge.className = 'changes-decoration-badge';
332
if (showChangeDecorations) {
333
// Update decoration badge (A/M/D)
334
switch (data.changeType) {
335
case 'added':
336
badge.textContent = 'A';
337
badge.classList.add('added');
338
break;
339
case 'deleted':
340
badge.textContent = 'D';
341
badge.classList.add('deleted');
342
break;
343
case 'modified':
344
default:
345
badge.textContent = 'M';
346
badge.classList.add('modified');
347
break;
348
}
349
350
templateData.addedSpan.textContent = `+${data.linesAdded}`;
351
templateData.removedSpan.textContent = `-${data.linesRemoved}`;
352
353
// eslint-disable-next-line no-restricted-syntax
354
templateData.label.element.querySelector('.monaco-icon-name-container')?.classList.add('modified');
355
} else {
356
badge.textContent = '';
357
// eslint-disable-next-line no-restricted-syntax
358
templateData.label.element.querySelector('.monaco-icon-name-container')?.classList.remove('modified');
359
}
360
361
if (templateData.toolbar) {
362
templateData.toolbar.context = data;
363
}
364
365
templateData.changeKindContextKey.set('file');
366
}
367
368
private renderRootElement(data: IChangesRootItem, templateData: IChangesTreeTemplate): void {
369
templateData.label.setResource({
370
resource: data.uri,
371
name: data.name,
372
}, {
373
fileKind: FileKind.ROOT_FOLDER,
374
separator: this.labelService.getSeparator(data.uri.scheme, data.uri.authority),
375
});
376
377
templateData.reviewCommentsBadge.style.display = 'none';
378
templateData.agentFeedbackBadge.style.display = 'none';
379
templateData.decorationBadge.style.display = 'none';
380
templateData.lineCountsContainer.style.display = 'none';
381
382
if (templateData.toolbar) {
383
templateData.toolbar.context = data.uri;
384
}
385
386
templateData.changeKindContextKey.set('root');
387
}
388
389
private renderFolderElement(node: IResourceNode<IChangesFileItem, undefined>, templateData: IChangesTreeTemplate): void {
390
templateData.label.setFile(node.uri, {
391
fileKind: FileKind.FOLDER,
392
hidePath: true,
393
});
394
395
// Hide file-specific decorations for folders
396
templateData.reviewCommentsBadge.style.display = 'none';
397
templateData.agentFeedbackBadge.style.display = 'none';
398
templateData.decorationBadge.style.display = 'none';
399
templateData.lineCountsContainer.style.display = 'none';
400
401
if (templateData.toolbar) {
402
templateData.toolbar.context = node;
403
}
404
405
templateData.changeKindContextKey.set('folder');
406
}
407
408
disposeElement(_element: ITreeNode<ChangesTreeElement, void>, _index: number, templateData: IChangesTreeTemplate): void {
409
templateData.elementDisposables.clear();
410
}
411
412
disposeCompressedElements(_element: ITreeNode<ICompressedTreeNode<ChangesTreeElement>, void>, _index: number, templateData: IChangesTreeTemplate): void {
413
templateData.elementDisposables.clear();
414
}
415
416
disposeTemplate(templateData: IChangesTreeTemplate): void {
417
templateData.elementDisposables.dispose();
418
templateData.templateDisposables.dispose();
419
}
420
}
421
422