Book a Demo!
CoCalc Logo Icon
StoreFeaturesDocsShareSupportNewsAboutPoliciesSign UpSign In
microsoft
GitHub Repository: microsoft/vscode
Path: blob/main/src/vs/sessions/contrib/changes/browser/changesView.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 './media/changesView.css';
7
import * as dom from '../../../../base/browser/dom.js';
8
import { ActionViewItem, IActionViewItemOptions } from '../../../../base/browser/ui/actionbar/actionViewItems.js';
9
import { Schemas } from '../../../../base/common/network.js';
10
import { isWeb } from '../../../../base/common/platform.js';
11
import { renderLabelWithIcons } from '../../../../base/browser/ui/iconLabel/iconLabels.js';
12
import { IListVirtualDelegate } from '../../../../base/browser/ui/list/list.js';
13
import { IObjectTreeElement, ITreeSorter } from '../../../../base/browser/ui/tree/tree.js';
14
import { ActionRunner, IAction } from '../../../../base/common/actions.js';
15
import { Codicon } from '../../../../base/common/codicons.js';
16
import { Disposable, DisposableStore, IDisposable } from '../../../../base/common/lifecycle.js';
17
import { Event } from '../../../../base/common/event.js';
18
import { autorun, derived, derivedObservableWithCache, derivedOpts, IObservable } from '../../../../base/common/observable.js';
19
import { CountBadge } from '../../../../base/browser/ui/countBadge/countBadge.js';
20
import { ProgressBar } from '../../../../base/browser/ui/progressbar/progressbar.js';
21
import { basename, isEqual } from '../../../../base/common/resources.js';
22
import { ThemeIcon } from '../../../../base/common/themables.js';
23
import { URI } from '../../../../base/common/uri.js';
24
import { localize, localize2 } from '../../../../nls.js';
25
import { MenuWorkbenchButtonBar } from '../../../../platform/actions/browser/buttonbar.js';
26
import { MenuWorkbenchToolBar } from '../../../../platform/actions/browser/toolbar.js';
27
import { ActionWidgetDropdownActionViewItem } from '../../../../platform/actions/browser/actionWidgetDropdownActionViewItem.js';
28
import { MenuId, Action2, MenuItemAction, registerAction2, IMenuService } from '../../../../platform/actions/common/actions.js';
29
import { IActionWidgetService } from '../../../../platform/actionWidget/browser/actionWidget.js';
30
import { IActionWidgetDropdownActionProvider } from '../../../../platform/actionWidget/browser/actionWidgetDropdown.js';
31
import { IConfigurationService } from '../../../../platform/configuration/common/configuration.js';
32
import { IContextKey, IContextKeyService } from '../../../../platform/contextkey/common/contextkey.js';
33
import { IContextMenuService } from '../../../../platform/contextview/browser/contextView.js';
34
import { IHoverService } from '../../../../platform/hover/browser/hover.js';
35
import { IInstantiationService, ServicesAccessor } from '../../../../platform/instantiation/common/instantiation.js';
36
import { IKeybindingService } from '../../../../platform/keybinding/common/keybinding.js';
37
import { ILabelService } from '../../../../platform/label/common/label.js';
38
import { WorkbenchCompressibleObjectTree } from '../../../../platform/list/browser/listService.js';
39
import { ILogService } from '../../../../platform/log/common/log.js';
40
import { bindContextKey } from '../../../../platform/observable/common/platformObservableUtils.js';
41
import { IOpenerService } from '../../../../platform/opener/common/opener.js';
42
import { ServiceCollection } from '../../../../platform/instantiation/common/serviceCollection.js';
43
import { IStorageService } from '../../../../platform/storage/common/storage.js';
44
import { ITelemetryService } from '../../../../platform/telemetry/common/telemetry.js';
45
import { IThemeService } from '../../../../platform/theme/common/themeService.js';
46
import { defaultCountBadgeStyles, defaultProgressBarStyles } from '../../../../platform/theme/browser/defaultStyles.js';
47
import { IWorkspaceContextService } from '../../../../platform/workspace/common/workspace.js';
48
import { fillEditorsDragData } from '../../../../workbench/browser/dnd.js';
49
import { ResourceLabels } from '../../../../workbench/browser/labels.js';
50
import { ViewPane, IViewPaneOptions, ViewAction } from '../../../../workbench/browser/parts/views/viewPane.js';
51
import { ViewPaneContainer } from '../../../../workbench/browser/parts/views/viewPaneContainer.js';
52
import { IViewDescriptorService } from '../../../../workbench/common/views.js';
53
import { CHAT_CATEGORY } from '../../../../workbench/contrib/chat/browser/actions/chatActions.js';
54
import { IAgentSessionsService } from '../../../../workbench/contrib/chat/browser/agentSessions/agentSessionsService.js';
55
import { ChatContextKeys } from '../../../../workbench/contrib/chat/common/actions/chatContextKeys.js';
56
import { createFileIconThemableTreeContainerScope } from '../../../../workbench/contrib/files/browser/views/explorerView.js';
57
import { ACTIVE_GROUP, IEditorService, SIDE_GROUP } from '../../../../workbench/services/editor/common/editorService.js';
58
import { IExtensionService } from '../../../../workbench/services/extensions/common/extensions.js';
59
import { IWorkbenchLayoutService } from '../../../../workbench/services/layout/browser/layoutService.js';
60
import { IMultiDiffEditorOptions } from '../../../../editor/browser/widget/multiDiffEditor/multiDiffEditorWidgetImpl.js';
61
import { ChangesMultiDiffSourceResolver, getChangesMultiDiffSourceUri } from './changesMultiDiffSourceResolver.js';
62
import { ISessionsManagementService } from '../../../services/sessions/common/sessionsManagement.js';
63
import { CodeReviewStateKind, getCodeReviewFilesFromSessionChanges, getCodeReviewVersion, ICodeReviewService, PRReviewStateKind } from '../../codeReview/browser/codeReviewService.js';
64
import { CIStatusWidget } from './checksWidget.js';
65
import { COPILOT_CLOUD_SESSION_TYPE, GITHUB_REMOTE_FILE_SCHEME, SessionStatus } from '../../../services/sessions/common/session.js';
66
import { Orientation } from '../../../../base/browser/ui/sash/sash.js';
67
import { IView, Sizing, SplitView } from '../../../../base/browser/ui/splitview/splitview.js';
68
import { Color } from '../../../../base/common/color.js';
69
import { PANEL_SECTION_BORDER } from '../../../../workbench/common/theme.js';
70
import { EditorResourceAccessor, SideBySideEditor } from '../../../../workbench/common/editor.js';
71
import { logChangesViewFileSelect, logChangesViewVersionModeChange, logChangesViewViewModeChange } from '../../../common/sessionsTelemetry.js';
72
import { ChecksViewModel } from './checksViewModel.js';
73
import { AGENT_HOST_SKILL_BUTTON_UPDATE_PR_ID, isAgentHostSkillButtonId } from '../../agentHost/browser/agentHostSkillButtons.js';
74
import { ActiveSessionContextKeys, CHANGES_VIEW_CONTAINER_ID, CHANGES_VIEW_ID, ChangesContextKeys, ChangesVersionMode, ChangesViewMode, IsolationMode } from '../common/changes.js';
75
import { buildTreeChildren, ChangesTreeElement, ChangesTreeRenderer, IChangesFileItem, IChangesTreeRootInfo, isChangesFileItem, toIChangesFileItem } from './changesViewRenderer.js';
76
import { ChangesViewModel } from './changesViewModel.js';
77
import { ResourceTree } from '../../../../base/common/resourceTree.js';
78
import { structuralEquals } from '../../../../base/common/equals.js';
79
import { compareFileNames, comparePaths } from '../../../../base/common/comparers.js';
80
import { IViewsService } from '../../../../workbench/services/views/common/viewsService.js';
81
82
const $ = dom.$;
83
84
// --- Constants
85
86
const RUN_SESSION_CODE_REVIEW_ACTION_ID = 'sessions.codeReview.run';
87
88
// --- ButtonBar widget
89
90
class ChangesButtonBarWidget extends Disposable {
91
constructor(
92
container: HTMLElement,
93
viewModel: ChangesViewModel,
94
@IAgentSessionsService agentSessionsService: IAgentSessionsService,
95
@IMenuService menuService: IMenuService,
96
@ICodeReviewService codeReviewService: ICodeReviewService,
97
@IContextKeyService contextKeyService: IContextKeyService,
98
@IContextMenuService contextMenuService: IContextMenuService,
99
@IKeybindingService keybindingService: IKeybindingService,
100
@ITelemetryService telemetryService: ITelemetryService,
101
@IHoverService hoverService: IHoverService
102
) {
103
super();
104
105
const outgoingChangesObs = derived(reader => {
106
const activeSessionState = viewModel.activeSessionStateObs.read(reader);
107
return activeSessionState?.outgoingChanges ?? 0;
108
});
109
110
const reviewStateObs = derivedOpts<{ isLoading: boolean; commentCount: number | undefined }>({ equalsFn: structuralEquals }, reader => {
111
const sessionResource = viewModel.activeSessionResourceObs.read(reader);
112
if (!sessionResource) {
113
return { isLoading: false, commentCount: undefined };
114
}
115
116
const sessionChanges = viewModel.activeSessionChangesObs.read(reader);
117
const prReviewState = codeReviewService.getPRReviewState(sessionResource).read(reader);
118
const prReviewCommentCount = prReviewState.kind === PRReviewStateKind.Loaded
119
? prReviewState.comments.length
120
: 0;
121
122
let isLoading = false;
123
let commentCount: number | undefined;
124
if (sessionChanges && sessionChanges.length > 0) {
125
const reviewFiles = getCodeReviewFilesFromSessionChanges(sessionChanges);
126
const reviewVersion = getCodeReviewVersion(reviewFiles);
127
const reviewState = codeReviewService.getReviewState(sessionResource).read(reader);
128
129
if (reviewState.kind === CodeReviewStateKind.Loading && reviewState.version === reviewVersion) {
130
isLoading = true;
131
} else {
132
const codeReviewCommentCount = reviewState.kind === CodeReviewStateKind.Result && reviewState.version === reviewVersion
133
? reviewState.comments.length
134
: 0;
135
const totalReviewCommentCount = codeReviewCommentCount + prReviewCommentCount;
136
if (totalReviewCommentCount > 0) {
137
commentCount = totalReviewCommentCount;
138
}
139
}
140
} else if (prReviewCommentCount > 0) {
141
commentCount = prReviewCommentCount;
142
}
143
144
return { isLoading, commentCount };
145
});
146
147
this._register(autorun(reader => {
148
const sessionResource = viewModel.activeSessionResourceObs.read(reader);
149
const outgoingChanges = outgoingChangesObs.read(reader);
150
const reviewState = reviewStateObs.read(reader);
151
152
reader.store.add(new MenuWorkbenchButtonBar(
153
container,
154
MenuId.ChatEditingSessionChangesToolbar,
155
{
156
telemetrySource: 'changesView',
157
disableWhileRunning: true,
158
menuOptions: sessionResource
159
? { args: [sessionResource, agentSessionsService.getSession(sessionResource)?.metadata] }
160
: { shouldForwardArgs: true },
161
buttonConfigProvider: (action) => this._getButtonConfiguration(action, outgoingChanges, reviewState)
162
},
163
menuService, contextKeyService, contextMenuService, keybindingService, telemetryService, hoverService
164
));
165
}));
166
}
167
168
private _getButtonConfiguration(action: IAction, outgoingChanges: number, reviewState: { isLoading: boolean; commentCount: number | undefined }): { showIcon: boolean; showLabel: boolean; isSecondary?: boolean; customLabel?: string; customClass?: string } | undefined {
169
if (
170
action.id === 'github.copilot.sessions.sync' ||
171
action.id === 'github.copilot.claude.sessions.sync' ||
172
action.id === 'github.copilot.chat.createPullRequestCopilotCLIAgentSession.updatePR' ||
173
action.id === AGENT_HOST_SKILL_BUTTON_UPDATE_PR_ID
174
) {
175
const customLabel = outgoingChanges > 0
176
? `${action.label} ${outgoingChanges}↑`
177
: action.label;
178
return { customLabel, showIcon: true, showLabel: true, isSecondary: false };
179
}
180
if (action.id === RUN_SESSION_CODE_REVIEW_ACTION_ID) {
181
if (reviewState.isLoading) {
182
return { showIcon: true, showLabel: true, isSecondary: true, customLabel: '$(loading~spin)', customClass: 'code-review-loading' };
183
}
184
if (reviewState.commentCount !== undefined) {
185
return { showIcon: true, showLabel: true, isSecondary: true, customLabel: String(reviewState.commentCount), customClass: 'code-review-comments' };
186
}
187
return { showIcon: true, showLabel: false, isSecondary: true };
188
}
189
if (
190
action.id === 'chatEditing.viewAllSessionChanges' ||
191
action.id === 'github.copilot.chat.openPullRequestCopilotCLIAgentSession.openPR'
192
) {
193
return { showIcon: true, showLabel: false, isSecondary: true };
194
}
195
if (action.id === 'agentFeedbackEditor.action.submitActiveSession') {
196
return { showIcon: false, showLabel: true, isSecondary: false };
197
}
198
if (
199
action.id === 'github.copilot.chat.createPullRequestCopilotCLIAgentSession.createPR' ||
200
action.id === 'github.copilot.chat.mergeCopilotCLIAgentSessionChanges.merge' ||
201
action.id === 'github.copilot.chat.checkoutPullRequestReroute' ||
202
action.id === 'pr.checkoutFromChat' ||
203
action.id === 'github.copilot.sessions.initializeRepository' ||
204
action.id === 'github.copilot.sessions.commit' ||
205
action.id === 'github.copilot.claude.sessions.initializeRepository' ||
206
action.id === 'github.copilot.claude.sessions.commit' ||
207
action.id === 'github.copilot.claude.sessions.commitAndSync' ||
208
action.id === 'agentSession.markAsDone' ||
209
isAgentHostSkillButtonId(action.id)
210
) {
211
return { showIcon: true, showLabel: true, isSecondary: false };
212
}
213
214
// Unknown actions (e.g. extension-contributed): only hide the label when an icon is present.
215
if (action instanceof MenuItemAction) {
216
const icon = action.item.icon;
217
if (icon) {
218
// Icon-only button (no forced secondary state so primary/secondary can be inferred).
219
return { showIcon: true, showLabel: false };
220
}
221
}
222
223
// Fall back to default button behavior for actions without an icon.
224
return undefined;
225
}
226
}
227
228
// --- View Pane
229
230
export class ChangesViewPane extends ViewPane {
231
232
private bodyContainer: HTMLElement | undefined;
233
private welcomeContainer: HTMLElement | undefined;
234
private filesHeaderNode: HTMLElement | undefined;
235
private fileHeaderToolbarContainer: HTMLElement | undefined;
236
private contentContainer: HTMLElement | undefined;
237
private overviewContainer: HTMLElement | undefined;
238
private summaryContainer: HTMLElement | undefined;
239
private listContainer: HTMLElement | undefined;
240
// Actions container is positioned outside the card for this layout experiment
241
private actionsContainer: HTMLElement | undefined;
242
243
private changesProgressBar!: ProgressBar;
244
private tree: WorkbenchCompressibleObjectTree<ChangesTreeElement> | undefined;
245
private ciStatusWidget: CIStatusWidget | undefined;
246
private splitView: SplitView | undefined;
247
private splitViewContainer: HTMLElement | undefined;
248
249
private readonly isMergeBaseBranchProtectedContextKey: IContextKey<boolean>;
250
private readonly isolationModeContextKey: IContextKey<IsolationMode>;
251
private readonly hasGitRepositoryContextKey: IContextKey<boolean>;
252
private readonly hasUpstreamContextKey: IContextKey<boolean>;
253
private readonly hasIncomingChangesContextKey: IContextKey<boolean>;
254
private readonly hasOpenPullRequestContextKey: IContextKey<boolean>;
255
private readonly hasOutgoingChangesContextKey: IContextKey<boolean>;
256
private readonly hasPullRequestContextKey: IContextKey<boolean>;
257
private readonly hasGitHubRemoteContextKey: IContextKey<boolean>;
258
private readonly hasUncommittedChangesContextKey: IContextKey<boolean>;
259
260
private readonly scopedInstantiationService: IInstantiationService;
261
262
private readonly renderDisposables = this._register(new DisposableStore());
263
264
// Track current body dimensions for list layout
265
private currentBodyHeight = 0;
266
private currentBodyWidth = 0;
267
268
readonly viewModel: ChangesViewModel;
269
270
constructor(
271
options: IViewPaneOptions,
272
@IKeybindingService keybindingService: IKeybindingService,
273
@IContextMenuService contextMenuService: IContextMenuService,
274
@IConfigurationService configurationService: IConfigurationService,
275
@IContextKeyService contextKeyService: IContextKeyService,
276
@IViewDescriptorService viewDescriptorService: IViewDescriptorService,
277
@IInstantiationService instantiationService: IInstantiationService,
278
@IOpenerService openerService: IOpenerService,
279
@IThemeService themeService: IThemeService,
280
@IHoverService hoverService: IHoverService,
281
@IEditorService private readonly editorService: IEditorService,
282
@ISessionsManagementService private readonly sessionManagementService: ISessionsManagementService,
283
@ILabelService private readonly labelService: ILabelService,
284
@ILogService private readonly logService: ILogService,
285
@ITelemetryService private readonly telemetryService: ITelemetryService,
286
) {
287
super({ ...options, titleMenuId: MenuId.ChatEditingSessionTitleToolbar }, keybindingService, contextMenuService, configurationService, contextKeyService, viewDescriptorService, instantiationService, openerService, themeService, hoverService);
288
289
this.viewModel = this.instantiationService.createInstance(ChangesViewModel);
290
this._register(this.viewModel);
291
292
// Multi-diff editor source resolver
293
const changesMultiDiffSourceResolver = this.instantiationService.createInstance(ChangesMultiDiffSourceResolver, this.viewModel);
294
this._register(changesMultiDiffSourceResolver);
295
296
// Context keys
297
this.isMergeBaseBranchProtectedContextKey = ActiveSessionContextKeys.IsMergeBaseBranchProtected.bindTo(this.scopedContextKeyService);
298
this.isolationModeContextKey = ActiveSessionContextKeys.IsolationMode.bindTo(this.scopedContextKeyService);
299
this.hasGitRepositoryContextKey = ActiveSessionContextKeys.HasGitRepository.bindTo(this.scopedContextKeyService);
300
this.hasUpstreamContextKey = ActiveSessionContextKeys.HasUpstream.bindTo(this.scopedContextKeyService);
301
this.hasIncomingChangesContextKey = ActiveSessionContextKeys.HasIncomingChanges.bindTo(this.scopedContextKeyService);
302
this.hasOutgoingChangesContextKey = ActiveSessionContextKeys.HasOutgoingChanges.bindTo(this.scopedContextKeyService);
303
this.hasUncommittedChangesContextKey = ActiveSessionContextKeys.HasUncommittedChanges.bindTo(this.scopedContextKeyService);
304
this.hasGitHubRemoteContextKey = ActiveSessionContextKeys.HasGitHubRemote.bindTo(this.scopedContextKeyService);
305
this.hasPullRequestContextKey = ActiveSessionContextKeys.HasPullRequest.bindTo(this.scopedContextKeyService);
306
this.hasOpenPullRequestContextKey = ActiveSessionContextKeys.HasOpenPullRequest.bindTo(this.scopedContextKeyService);
307
308
// Version mode
309
this._register(bindContextKey(ChangesContextKeys.VersionMode, this.scopedContextKeyService, reader => {
310
return this.viewModel.versionModeObs.read(reader);
311
}));
312
313
// View mode
314
this._register(bindContextKey(ChangesContextKeys.ViewMode, this.scopedContextKeyService, reader => {
315
return this.viewModel.viewModeObs.read(reader);
316
}));
317
318
// Set chatSessionType on the view's context key service so ViewTitle menu items
319
// can use it in their `when` clauses. Update reactively when the active session
320
// changes.
321
this._register(bindContextKey(ChatContextKeys.agentSessionType, this.scopedContextKeyService, reader => {
322
return this.viewModel.activeSessionTypeObs.read(reader) ?? '';
323
}));
324
325
const scopedServiceCollection = new ServiceCollection([IContextKeyService, this.scopedContextKeyService]);
326
this.scopedInstantiationService = this.instantiationService.createChild(scopedServiceCollection);
327
this._register(this.scopedInstantiationService);
328
}
329
330
protected override renderBody(container: HTMLElement): void {
331
super.renderBody(container);
332
333
this.bodyContainer = dom.append(container, $('.changes-view-body'));
334
335
// Actions container - positioned outside and above the card
336
this.actionsContainer = dom.append(this.bodyContainer, $('.chat-editing-session-actions.outside-card'));
337
338
// SplitView container for resizable file tree / CI checks split
339
this.splitViewContainer = dom.append(this.bodyContainer, $('.changes-splitview-container'));
340
341
// Main container with file icons support (the "card") — top pane
342
this.contentContainer = dom.append(this.splitViewContainer, $('.chat-editing-session-container.show-file-icons'));
343
this._register(createFileIconThemableTreeContainerScope(this.contentContainer, this.themeService));
344
345
// Toggle class based on whether the file icon theme has file icons
346
const updateHasFileIcons = () => {
347
this.contentContainer!.classList.toggle('has-file-icons', this.themeService.getFileIconTheme().hasFileIcons);
348
};
349
updateHasFileIcons();
350
this._register(this.themeService.onDidFileIconThemeChange(updateHasFileIcons));
351
352
// Files header
353
this.filesHeaderNode = dom.append(this.contentContainer, $('.changes-files-header'));
354
355
// Changesets toolbar
356
const filesHeaderToolbarContainer = dom.append(this.filesHeaderNode, $('.changes-files-header-toolbar'));
357
this._register(this.scopedInstantiationService.createInstance(MenuWorkbenchToolBar, filesHeaderToolbarContainer, MenuId.ChatEditingSessionChangesFileHeaderToolbar, {
358
menuOptions: { shouldForwardArgs: true },
359
actionViewItemProvider: (action) => {
360
if (action.id === 'chatEditing.versionsPicker' && action instanceof MenuItemAction) {
361
return this.scopedInstantiationService.createInstance(ChangesPickerActionItem, action, this.viewModel);
362
}
363
return undefined;
364
},
365
}));
366
367
// File header right-aligned toolbar
368
this.fileHeaderToolbarContainer = dom.append(this.filesHeaderNode, $('.changes-files-header-right-toolbar'));
369
this._register(this.scopedInstantiationService.createInstance(MenuWorkbenchToolBar, this.fileHeaderToolbarContainer, MenuId.ChatEditingSessionChangesFileHeaderRightToolbar, {
370
menuOptions: { shouldForwardArgs: true },
371
actionViewItemProvider: (action, options) => {
372
if (action.id === ChangesDiffStatsAction.ID && action instanceof MenuItemAction) {
373
return this.scopedInstantiationService.createInstance(ChangesDiffStatsActionItem, action, this.viewModel, options);
374
}
375
return undefined;
376
},
377
}));
378
379
// Overview section (header with summary only - actions moved outside card)
380
this.overviewContainer = dom.append(this.contentContainer, $('.chat-editing-session-overview'));
381
this.summaryContainer = dom.append(this.overviewContainer, $('.changes-summary'));
382
383
// Changes card progress bar
384
const progressContainer = dom.append(this.contentContainer, $('.changes-progress'));
385
this.changesProgressBar = this._register(new ProgressBar(progressContainer, defaultProgressBarStyles));
386
this.changesProgressBar.stop().hide();
387
388
// List container
389
this.listContainer = dom.append(this.contentContainer, $('.changes-file-list'));
390
391
// Welcome message for empty state (hidden by default, shown when no changes)
392
this.welcomeContainer = dom.append(this.contentContainer, $('.changes-welcome'));
393
this.welcomeContainer.style.display = 'none';
394
395
const welcomeIcon = dom.append(this.welcomeContainer, $('.changes-welcome-icon'));
396
welcomeIcon.classList.add(...ThemeIcon.asClassNameArray(Codicon.diffMultiple));
397
const welcomeMessage = dom.append(this.welcomeContainer, $('.changes-welcome-message'));
398
welcomeMessage.textContent = localize('changesView.noChanges', "Changed files and other session artifacts will appear here.");
399
400
// CI Status widget — bottom pane
401
this.ciStatusWidget = this._register(this.instantiationService.createInstance(CIStatusWidget, this.splitViewContainer));
402
403
// Create SplitView
404
this.splitView = this._register(new SplitView(this.splitViewContainer, {
405
orientation: Orientation.VERTICAL,
406
proportionalLayout: false,
407
}));
408
409
// Shared constants for pane sizing
410
const ciMinHeight = CIStatusWidget.HEADER_HEIGHT + CIStatusWidget.MIN_BODY_HEIGHT;
411
const treeMinHeight = 3 * ChangesTreeDelegate.ROW_HEIGHT;
412
413
// Top pane: file tree
414
const treePane: IView = {
415
element: this.contentContainer,
416
minimumSize: treeMinHeight,
417
maximumSize: Number.POSITIVE_INFINITY,
418
onDidChange: Event.None,
419
layout: (height) => {
420
this.contentContainer!.style.height = `${height}px`;
421
this._layoutTreeInPane(height);
422
},
423
};
424
425
// Bottom pane: CI checks
426
const ciElement = this.ciStatusWidget.element;
427
const ciWidget = this.ciStatusWidget;
428
const ciPane: IView = {
429
element: ciElement,
430
get minimumSize() { return ciWidget.collapsed ? CIStatusWidget.HEADER_HEIGHT : ciMinHeight; },
431
get maximumSize() { return ciWidget.collapsed ? CIStatusWidget.HEADER_HEIGHT : Number.POSITIVE_INFINITY; },
432
onDidChange: Event.map(this.ciStatusWidget.onDidChangeHeight, () => undefined),
433
layout: (height) => {
434
ciElement.style.height = `${height}px`;
435
const bodyHeight = Math.max(0, height - CIStatusWidget.HEADER_HEIGHT);
436
ciWidget.layout(bodyHeight);
437
},
438
};
439
440
this.splitView.addView(treePane, Sizing.Distribute, 0, true);
441
this.splitView.addView(ciPane, CIStatusWidget.HEADER_HEIGHT + CIStatusWidget.PREFERRED_BODY_HEIGHT, 1, true);
442
443
// Style the sash as a visible separator between sections
444
const updateSplitViewStyles = () => {
445
const borderColor = this.themeService.getColorTheme().getColor(PANEL_SECTION_BORDER);
446
this.splitView!.style({ separatorBorder: borderColor ?? Color.transparent });
447
};
448
updateSplitViewStyles();
449
this._register(this.themeService.onDidColorThemeChange(updateSplitViewStyles));
450
451
// Initially hide CI pane until checks arrive
452
this.splitView.setViewVisible(1, false);
453
454
let savedCIPaneHeight = CIStatusWidget.HEADER_HEIGHT + CIStatusWidget.PREFERRED_BODY_HEIGHT;
455
this._register(this.ciStatusWidget.onDidToggleCollapsed(collapsed => {
456
if (!this.splitView || !this.ciStatusWidget) {
457
return;
458
}
459
if (collapsed) {
460
// Save current size before collapsing
461
const currentSize = this.splitView.getViewSize(1);
462
if (currentSize > CIStatusWidget.HEADER_HEIGHT) {
463
savedCIPaneHeight = currentSize;
464
}
465
this.splitView.resizeView(1, CIStatusWidget.HEADER_HEIGHT);
466
} else {
467
// Restore saved size on expand
468
this.splitView.resizeView(1, savedCIPaneHeight);
469
}
470
this.layoutSplitView();
471
}));
472
473
this._register(this.ciStatusWidget.onDidChangeHeight(() => {
474
if (!this.splitView || !this.ciStatusWidget) {
475
return;
476
}
477
const visible = this.ciStatusWidget.visible;
478
const isCurrentlyVisible = this.splitView.isViewVisible(1);
479
if (visible !== isCurrentlyVisible) {
480
this.splitView.setViewVisible(1, visible);
481
}
482
this.layoutSplitView();
483
}));
484
485
this._register(this.onDidChangeBodyVisibility(visible => {
486
if (visible) {
487
this.onVisible();
488
} else {
489
this.renderDisposables.clear();
490
}
491
}));
492
493
// Trigger initial render if already visible
494
if (this.isBodyVisible()) {
495
this.onVisible();
496
}
497
}
498
499
override getActionsContext(): URI | undefined {
500
return this.viewModel.activeSessionResourceObs.get();
501
}
502
503
private onVisible(): void {
504
this.renderDisposables.clear();
505
506
// Title actions
507
this.renderDisposables.add(autorun(reader => {
508
this.viewModel.activeSessionResourceObs.read(reader);
509
this.updateActions();
510
}));
511
512
// Loading
513
this.renderDisposables.add(autorun(reader => {
514
const isLoading = this.viewModel.activeSessionIsLoadingObs.read(reader);
515
if (isLoading) {
516
this.changesProgressBar.infinite().show(200);
517
} else {
518
this.changesProgressBar.stop().hide();
519
}
520
}));
521
522
// Changes
523
const changesObs = derived(reader => {
524
const changes = this.viewModel.activeSessionChangesObs.read(reader);
525
return toIChangesFileItem(changes);
526
});
527
528
// Changes statistics
529
const topLevelStats = derived(reader => {
530
const entries = changesObs.read(reader);
531
532
let added = 0, removed = 0;
533
534
for (const entry of entries) {
535
added += entry.linesAdded;
536
removed += entry.linesRemoved;
537
}
538
539
return { files: entries.length, added, removed };
540
});
541
542
// Setup context keys and actions toolbar
543
if (this.actionsContainer) {
544
dom.clearNode(this.actionsContainer);
545
546
// Bind context keys
547
this._bindContextKeys(topLevelStats);
548
549
this.renderDisposables.add(this.scopedInstantiationService.createInstance(
550
ChangesButtonBarWidget, this.actionsContainer, this.viewModel));
551
}
552
553
const activeSessionStatusObs = derived(reader => {
554
const activeSession = this.sessionManagementService.activeSession.read(reader);
555
return activeSession?.status.read(reader);
556
});
557
558
// Update visibility based on entries
559
this.renderDisposables.add(autorun(reader => {
560
if (this.viewModel.activeSessionIsLoadingObs.read(reader)) {
561
return;
562
}
563
564
// Hide the actions toolbar for untitled sessions.
565
const activeSessionStatus = activeSessionStatusObs.read(reader);
566
if (this.actionsContainer) {
567
dom.setVisibility(activeSessionStatus !== undefined && activeSessionStatus !== SessionStatus.Untitled, this.actionsContainer);
568
}
569
570
const hasGitRepository = this.viewModel.activeSessionHasGitRepositoryObs.read(reader);
571
572
const { files } = topLevelStats.read(reader);
573
const hasEntries = files > 0;
574
575
// Show the files header whenever the session is git-backed (so users
576
// can switch version modes) or there are session-provided entries to
577
// count (for non-git sessions like the local agent host).
578
dom.setVisibility(hasGitRepository || hasEntries, this.filesHeaderNode!);
579
580
if (this.fileHeaderToolbarContainer) {
581
dom.setVisibility(hasEntries, this.fileHeaderToolbarContainer);
582
}
583
584
dom.setVisibility(hasEntries, this.listContainer!);
585
dom.setVisibility(!hasEntries, this.welcomeContainer!);
586
587
this.layoutSplitView();
588
}));
589
590
// Update summary text (line counts only, file count is shown in badge)
591
if (this.summaryContainer) {
592
dom.clearNode(this.summaryContainer);
593
594
const linesAddedSpan = dom.$('.working-set-lines-added');
595
const linesRemovedSpan = dom.$('.working-set-lines-removed');
596
597
this.summaryContainer.appendChild(linesAddedSpan);
598
this.summaryContainer.appendChild(linesRemovedSpan);
599
600
this.renderDisposables.add(autorun(reader => {
601
if (this.viewModel.activeSessionIsLoadingObs.read(reader)) {
602
return;
603
}
604
605
const { added, removed } = topLevelStats.read(reader);
606
607
linesAddedSpan.textContent = `+${added}`;
608
linesRemovedSpan.textContent = `-${removed}`;
609
}));
610
}
611
612
// Create the tree
613
if (!this.tree && this.listContainer) {
614
this.tree = this.createChangesTree(this.listContainer, this.onDidChangeBodyVisibility, this._store);
615
}
616
617
// Register tree event handlers
618
if (this.tree) {
619
const tree = this.tree;
620
621
// Re-layout when collapse state changes so the card height adjusts
622
this.renderDisposables.add(tree.onDidChangeContentHeight(() => this.layoutSplitView()));
623
624
this.renderDisposables.add(tree.onDidOpen((e) => {
625
if (!e.element || !isChangesFileItem(e.element)) {
626
return;
627
}
628
629
logChangesViewFileSelect(this.telemetryService, e.element.changeType);
630
631
const modalEditorMode = this.configurationService.getValue<string>('workbench.editor.useModal');
632
if (modalEditorMode === 'all') {
633
const items = changesObs.get();
634
this._openFileItem(e.element, items, e.sideBySide, !!e.editorOptions?.preserveFocus, !!e.editorOptions?.pinned, items.length > 1);
635
return;
636
}
637
638
// Open multi-file diff editor
639
void this._openMultiFileDiffEditor(e.element.uri);
640
}));
641
}
642
643
// Checks
644
if (this.ciStatusWidget) {
645
const checksViewModel = this.instantiationService.createInstance(ChecksViewModel);
646
this.renderDisposables.add(checksViewModel);
647
648
this.renderDisposables.add(this.ciStatusWidget.setInput(checksViewModel));
649
}
650
651
// Update tree data with combined entries
652
this.renderDisposables.add(autorun(reader => {
653
const changes = changesObs.read(reader);
654
const viewMode = this.viewModel.viewModeObs.read(reader);
655
const isLoading = this.viewModel.activeSessionIsLoadingObs.read(reader);
656
// Read session state so this autorun re-runs when git state (e.g. branch name)
657
// arrives asynchronously, since the tree root label depends on it.
658
this.viewModel.activeSessionStateObs.read(reader);
659
660
if (!this.tree || isLoading) {
661
return;
662
}
663
664
// Toggle list-mode class to remove tree indentation in list mode
665
this.listContainer?.classList.toggle('list-mode', viewMode === ChangesViewMode.List);
666
667
if (viewMode === ChangesViewMode.Tree) {
668
// Tree mode: build hierarchical tree from file entries
669
const treeRootInfo = this.getTreeRootInfo(changes);
670
const treeChildren = buildTreeChildren(changes, treeRootInfo);
671
this.tree.setChildren(null, treeChildren);
672
} else {
673
// List mode: flat list of file items
674
const listChildren = changes.map(item => ({
675
element: item,
676
collapsible: false,
677
} satisfies IObjectTreeElement<ChangesTreeElement>));
678
this.tree.setChildren(null, listChildren);
679
}
680
681
this.layoutSplitView();
682
}));
683
}
684
685
private _bindContextKeys(topLevelStats: IObservable<{ files: number }>): void {
686
// Request in progress (can be updated independently since it only affects action enablement, and not visibility)
687
this.renderDisposables.add(bindContextKey(ChatContextKeys.requestInProgress, this.scopedContextKeyService, reader => {
688
const activeSessionStatus = this.sessionManagementService.activeSession.read(reader)?.status.read(reader);
689
return activeSessionStatus !== SessionStatus.Completed && activeSessionStatus !== SessionStatus.Error;
690
}));
691
692
// Has changes (can be updated independently since it only affects action enablement, and not visibility)
693
this.renderDisposables.add(bindContextKey(ChatContextKeys.hasAgentSessionChanges, this.scopedContextKeyService, reader => {
694
const { files } = topLevelStats.read(reader);
695
return files > 0;
696
}));
697
698
// Bulk update the context keys
699
this.renderDisposables.add(autorun(reader => {
700
const state = this.viewModel.activeSessionStateObs.read(reader);
701
if (!state) {
702
return;
703
}
704
705
this.logService.info(`[ChangesViewPane][_bindContextKeys] Context keys: ${JSON.stringify(state)}`);
706
707
this.scopedContextKeyService.bufferChangeEvents(() => {
708
this.isolationModeContextKey.set(state.isolationMode);
709
this.hasGitRepositoryContextKey.set(state.hasGitRepository);
710
this.isMergeBaseBranchProtectedContextKey.set(state.isMergeBaseBranchProtected === true);
711
this.hasGitHubRemoteContextKey.set(state.hasGitHubRemote === true);
712
this.hasPullRequestContextKey.set(state.hasPullRequest === true);
713
this.hasOpenPullRequestContextKey.set(state.hasOpenPullRequest === true);
714
this.hasUpstreamContextKey.set(state.upstreamBranchName !== undefined);
715
this.hasIncomingChangesContextKey.set(state.incomingChanges !== undefined && state.incomingChanges > 0);
716
this.hasOutgoingChangesContextKey.set(state.outgoingChanges !== undefined && state.outgoingChanges > 0);
717
this.hasUncommittedChangesContextKey.set(state.uncommittedChanges !== undefined && state.uncommittedChanges > 0);
718
});
719
}));
720
}
721
722
/** Layout the tree within its SplitView pane. */
723
private _layoutTreeInPane(paneHeight: number): void {
724
if (!this.tree) {
725
return;
726
}
727
// Subtract overview/padding within the content container
728
const overviewHeight = this.overviewContainer?.offsetHeight ?? 0;
729
const filesHeaderHeight = this.filesHeaderNode?.offsetHeight ?? 0;
730
const treeHeight = Math.max(0, paneHeight - filesHeaderHeight - overviewHeight);
731
this.tree.layout(treeHeight, this.currentBodyWidth);
732
this.tree.getHTMLElement().style.height = `${treeHeight}px`;
733
}
734
735
/** Layout the SplitView to fill available body space. */
736
private layoutSplitView(): void {
737
if (!this.splitView || !this.splitViewContainer) {
738
return;
739
}
740
const bodyHeight = this.currentBodyHeight;
741
if (bodyHeight <= 0) {
742
return;
743
}
744
const bodyPadding = 16; // 8px top + 8px bottom from .changes-view-body
745
const actionsHeight = this.actionsContainer?.offsetHeight ?? 0;
746
const actionsMargin = actionsHeight > 0 ? 8 : 0;
747
const availableHeight = Math.max(0, bodyHeight - bodyPadding - actionsHeight - actionsMargin);
748
this.splitViewContainer.style.height = `${availableHeight}px`;
749
this.splitView.layout(availableHeight);
750
}
751
752
private getTreeSelection(): IChangesFileItem[] {
753
const selection = this.tree?.getSelection() ?? [];
754
return selection.filter(item => !!item && isChangesFileItem(item));
755
}
756
757
private getTreeRootInfo(items: readonly IChangesFileItem[]): IChangesTreeRootInfo | undefined {
758
if (items.length === 0) {
759
return undefined;
760
}
761
762
// Get the repository details for the session
763
// - uri: location of the repository
764
// - workingDirectory (optional): location of the worktree
765
const activeSession = this.sessionManagementService.activeSession.get();
766
const repository = activeSession?.workspace.get()?.repositories[0];
767
const workspaceFolderUri = repository?.workingDirectory ?? repository?.uri;
768
if (!repository?.uri || !workspaceFolderUri) {
769
return undefined;
770
}
771
772
let name: string = '';
773
let resourceTreeRootUri = workspaceFolderUri;
774
775
if (workspaceFolderUri.scheme === GITHUB_REMOTE_FILE_SCHEME) {
776
// Cloud session
777
resourceTreeRootUri = URI.from({ scheme: Schemas.copilotPr, path: '/' });
778
const segments = workspaceFolderUri.path.split('/').filter(Boolean);
779
name = `${segments.slice(0, 2).join('/')} (${decodeURIComponent(segments[2])})`;
780
} else {
781
// Local session
782
const branchName = this.viewModel.activeSessionStateObs.get()?.branchName;
783
name = repository.workingDirectory
784
? `${basename(repository.uri)} (${branchName})`
785
: basename(repository.uri);
786
}
787
788
return {
789
root: {
790
type: 'root',
791
uri: workspaceFolderUri,
792
name
793
},
794
resourceTreeRootUri
795
};
796
}
797
798
private getSessionDiscardRef(): string {
799
const versionMode = this.viewModel.versionModeObs.get();
800
const firstCheckpointRef = this.viewModel.activeSessionFirstCheckpointRefObs.get();
801
const lastCheckpointRef = this.viewModel.activeSessionLastCheckpointRefObs.get();
802
803
if (versionMode === ChangesVersionMode.UncommittedChanges) {
804
return 'HEAD';
805
}
806
807
return versionMode === ChangesVersionMode.LastTurn
808
? lastCheckpointRef
809
? `${lastCheckpointRef}^`
810
: ''
811
: firstCheckpointRef ?? '';
812
}
813
814
protected override layoutBody(height: number, width: number): void {
815
super.layoutBody(height, width);
816
this.currentBodyHeight = height;
817
this.currentBodyWidth = width;
818
this.layoutSplitView();
819
}
820
821
override focus(): void {
822
super.focus();
823
824
if (this.tree && this.tree.getNode(null).visibleChildrenCount > 0) {
825
this.tree.domFocus();
826
}
827
}
828
829
private renderSidebarList(
830
container: HTMLElement,
831
onDidLayout: Event<{ readonly height: number; readonly width: number }>,
832
items: IChangesFileItem[],
833
openFileItem: (item: IChangesFileItem, items: IChangesFileItem[], sideBySide: boolean, preserveFocus: boolean, pinned: boolean, includeSidebar: boolean) => void,
834
): IDisposable {
835
const disposables = new DisposableStore();
836
837
container.classList.add('changes-file-list');
838
839
const viewMode = this.viewModel.viewModeObs.get();
840
container.classList.toggle('list-mode', viewMode === ChangesViewMode.List);
841
842
// "Changes" header
843
const headerNode = dom.append(container, $('.changes-sidebar-header'));
844
const headerLabel = dom.append(headerNode, $('span'));
845
headerLabel.textContent = localize('changes', "Changes");
846
const countBadge = disposables.add(new CountBadge(headerNode, { count: items.length }, defaultCountBadgeStyles));
847
countBadge.setCount(items.length);
848
849
const tree = this.createChangesTree(container, Event.None, disposables, () => tree.getSelection().filter(item => !!item && isChangesFileItem(item)));
850
851
if (viewMode === ChangesViewMode.Tree) {
852
tree.setChildren(null, buildTreeChildren(items, this.getTreeRootInfo(items)));
853
} else {
854
tree.setChildren(null, items.map(item => ({ element: item as ChangesTreeElement, collapsible: false })));
855
}
856
857
// Open file on selection. The `updatingSelection` guard relies on
858
// `tree.setFocus`/`setSelection` firing events synchronously.
859
let updatingSelection = false;
860
disposables.add(tree.onDidOpen(e => {
861
if (e.element && isChangesFileItem(e.element) && !updatingSelection) {
862
openFileItem(e.element, items, e.sideBySide, !!e.editorOptions.preserveFocus, !!e.editorOptions.pinned, false /* preserve existing sidebar */);
863
}
864
}));
865
866
// Track active editor and highlight in sidebar
867
disposables.add(Event.runAndSubscribe(this.editorService.onDidActiveEditorChange, () => {
868
const activeEditor = this.editorService.activeEditor;
869
if (!activeEditor) {
870
return;
871
}
872
873
const primaryResource = EditorResourceAccessor.getCanonicalUri(activeEditor, { supportSideBySide: SideBySideEditor.PRIMARY });
874
const secondaryResource = EditorResourceAccessor.getCanonicalUri(activeEditor, { supportSideBySide: SideBySideEditor.SECONDARY });
875
876
const index = items.findIndex(i =>
877
(primaryResource !== undefined && isEqual(i.uri, primaryResource)) ||
878
(secondaryResource !== undefined && i.originalUri !== undefined && isEqual(i.originalUri, secondaryResource))
879
);
880
if (index >= 0) {
881
updatingSelection = true;
882
try {
883
tree.setFocus([items[index]]);
884
tree.setSelection([items[index]]);
885
tree.reveal(items[index]);
886
} finally {
887
updatingSelection = false;
888
}
889
}
890
}));
891
892
// Layout on resize, accounting for the header height
893
disposables.add(onDidLayout(e => {
894
const headerHeight = headerNode.offsetHeight;
895
tree.layout(Math.max(0, e.height - headerHeight), e.width);
896
}));
897
898
return disposables;
899
}
900
901
private createChangesTree(
902
container: HTMLElement,
903
onDidChangeVisibility: Event<boolean>,
904
disposables: DisposableStore,
905
getSelection?: () => IChangesFileItem[],
906
): WorkbenchCompressibleObjectTree<ChangesTreeElement> {
907
const resourceLabels = disposables.add(this.instantiationService.createInstance(ResourceLabels, { onDidChangeVisibility }));
908
const actionRunner = disposables.add(new ChangesViewActionRunner(
909
() => this.viewModel.activeSessionResourceObs.get(),
910
() => this.getSessionDiscardRef(),
911
getSelection ?? (() => this.getTreeSelection()),
912
));
913
return disposables.add(this.instantiationService.createInstance(
914
WorkbenchCompressibleObjectTree<ChangesTreeElement>,
915
'ChangesViewTree',
916
container,
917
new ChangesTreeDelegate(),
918
[this.instantiationService.createInstance(ChangesTreeRenderer, this.viewModel, resourceLabels, actionRunner,
919
() => {
920
// Pass in the tree root to be used to compute the label description
921
const activeSession = this.sessionManagementService.activeSession.get();
922
const repository = activeSession?.workspace.get()?.repositories[0];
923
return repository?.uri.scheme === GITHUB_REMOTE_FILE_SCHEME
924
? URI.from({ scheme: Schemas.copilotPr, path: '/' })
925
: repository?.workingDirectory ?? repository?.uri;
926
})],
927
{
928
alwaysConsumeMouseWheel: false,
929
accessibilityProvider: {
930
getAriaLabel: (element: ChangesTreeElement) => isChangesFileItem(element) ? basename(element.uri) : element.name,
931
getWidgetAriaLabel: () => localize('changesViewTree', "Changes Tree")
932
},
933
dnd: {
934
getDragURI: (element: ChangesTreeElement) => element.uri.toString(),
935
getDragLabel: (elements) => {
936
const uris = elements.map(e => e.uri);
937
if (uris.length === 1) {
938
return this.labelService.getUriLabel(uris[0], { relative: true });
939
}
940
return `${uris.length}`;
941
},
942
dispose: () => { },
943
onDragOver: () => false,
944
drop: () => { },
945
onDragStart: (data, originalEvent) => {
946
try {
947
const elements = data.getData() as ChangesTreeElement[];
948
const uris = elements.filter(isChangesFileItem).map(e => e.uri);
949
this.instantiationService.invokeFunction(accessor => fillEditorsDragData(accessor, uris, originalEvent));
950
} catch {
951
// noop
952
}
953
},
954
},
955
identityProvider: {
956
getId: (element: ChangesTreeElement) => element.uri.toString()
957
},
958
indent: this.viewModel.viewModeObs.get() === ChangesViewMode.List ? 0 : 8,
959
compressionEnabled: true,
960
sorter: new ChangesTreeSorter(() => this.viewModel.viewModeObs.get()),
961
twistieAdditionalCssClass: (e: unknown) => {
962
return this.viewModel.viewModeObs.get() === ChangesViewMode.List
963
? 'force-no-twistie'
964
: undefined;
965
},
966
}
967
));
968
}
969
970
async openChanges(resource?: URI): Promise<void> {
971
const items = this.viewModel.activeSessionChangesObs.get();
972
if (items.length === 0) {
973
return;
974
}
975
976
const modalEditorMode = this.configurationService.getValue<string>('workbench.editor.useModal');
977
if (modalEditorMode === 'all') {
978
const changes = toIChangesFileItem(items);
979
const changeToOpen = resource ? changes.find(c => isEqual(c.uri, resource)) : undefined;
980
await this._openFileItem(changeToOpen ?? changes[0], changes, false, false, false, changes.length > 1);
981
return;
982
}
983
984
// Open multi-file diff editor
985
await this._openMultiFileDiffEditor(resource);
986
}
987
988
private async _openFileItem(item: IChangesFileItem, items: IChangesFileItem[], sideBySide: boolean, preserveFocus: boolean, pinned: boolean, includeSidebar: boolean): Promise<void> {
989
const { uri: modifiedFileUri, originalUri, isDeletion } = item;
990
const currentIndex = items.indexOf(item);
991
992
const sidebar = includeSidebar ? {
993
render: (container: unknown, onDidLayout: Event<{ readonly height: number; readonly width: number }>) => {
994
return this.renderSidebarList(container as HTMLElement, onDidLayout, items, this._openFileItem.bind(this));
995
}
996
} : undefined;
997
998
const navigation = {
999
total: items.length,
1000
current: currentIndex,
1001
navigate: (index: number) => {
1002
const target = items[index];
1003
if (target) {
1004
this._openFileItem(target, items, false, false, false, includeSidebar);
1005
}
1006
}
1007
};
1008
1009
const group = sideBySide ? SIDE_GROUP : ACTIVE_GROUP;
1010
1011
if (isDeletion && originalUri) {
1012
this.editorService.openEditor({
1013
resource: originalUri,
1014
options: { preserveFocus, pinned, modal: { sidebar, navigation } }
1015
}, group);
1016
return;
1017
}
1018
1019
if (originalUri) {
1020
this.editorService.openEditor({
1021
original: { resource: originalUri },
1022
modified: { resource: modifiedFileUri },
1023
options: { preserveFocus, pinned, modal: { sidebar, navigation } }
1024
}, group);
1025
return;
1026
}
1027
1028
this.editorService.openEditor({
1029
resource: modifiedFileUri,
1030
options: { preserveFocus, pinned, modal: { sidebar, navigation } }
1031
}, group);
1032
}
1033
1034
private async _openMultiFileDiffEditor(reveal?: URI): Promise<void> {
1035
const sessionResource = this.viewModel.activeSessionResourceObs.get();
1036
const changes = this.viewModel.activeSessionChangesObs.get();
1037
1038
if (!sessionResource || changes.length === 0) {
1039
return;
1040
}
1041
1042
// Determine the reveal target (original/modified URI pair) from the
1043
// current change list, so the multi-diff editor can navigate to it.
1044
let options: IMultiDiffEditorOptions | undefined;
1045
if (reveal) {
1046
const target = changes.find(c => isEqual(c.modifiedUri, reveal));
1047
if (target) {
1048
options = {
1049
viewState: {
1050
revealData: {
1051
resource: {
1052
original: target.originalUri,
1053
modified: target.modifiedUri,
1054
},
1055
},
1056
},
1057
} satisfies IMultiDiffEditorOptions;
1058
}
1059
}
1060
1061
// Open the multi-diff editor using the sessions source URI. The resource
1062
// list is resolved via `SessionsMultiDiffSourceResolver` and updates
1063
// reactively as `activeSessionChangesObs` changes.
1064
await this.editorService.openEditor({
1065
multiDiffSource: getChangesMultiDiffSourceUri(sessionResource),
1066
label: localize('sessions.changes.title', 'Session Changes'),
1067
options,
1068
});
1069
}
1070
1071
override dispose(): void {
1072
this.tree = undefined;
1073
super.dispose();
1074
}
1075
}
1076
1077
export class ChangesViewPaneContainer extends ViewPaneContainer {
1078
constructor(
1079
@IWorkbenchLayoutService layoutService: IWorkbenchLayoutService,
1080
@ITelemetryService telemetryService: ITelemetryService,
1081
@IInstantiationService instantiationService: IInstantiationService,
1082
@IContextMenuService contextMenuService: IContextMenuService,
1083
@IThemeService themeService: IThemeService,
1084
@IStorageService storageService: IStorageService,
1085
@IConfigurationService configurationService: IConfigurationService,
1086
@IExtensionService extensionService: IExtensionService,
1087
@IWorkspaceContextService contextService: IWorkspaceContextService,
1088
@IViewDescriptorService viewDescriptorService: IViewDescriptorService,
1089
@ILogService logService: ILogService,
1090
) {
1091
super(CHANGES_VIEW_CONTAINER_ID, { mergeViewWithContainerWhenSingleView: true }, instantiationService, configurationService, layoutService, contextMenuService, telemetryService, extensionService, themeService, storageService, contextService, viewDescriptorService, logService);
1092
}
1093
1094
override create(parent: HTMLElement): void {
1095
super.create(parent);
1096
parent.classList.add('changes-viewlet');
1097
}
1098
}
1099
1100
// --- Action Runner
1101
1102
class ChangesViewActionRunner extends ActionRunner {
1103
1104
constructor(
1105
private readonly getSessionResource: () => URI | undefined,
1106
private readonly getSessionDiscardRef: () => string,
1107
private readonly getSelectedFileItems: () => IChangesFileItem[]
1108
) {
1109
super();
1110
}
1111
1112
protected override async runAction(action: IAction, context: ChangesTreeElement): Promise<void> {
1113
if (!(action instanceof MenuItemAction)) {
1114
return super.runAction(action, context);
1115
}
1116
1117
const sessionResource = this.getSessionResource();
1118
const discardRef = this.getSessionDiscardRef();
1119
const selection = this.getSelectedFileItems();
1120
1121
const contextIsSelected = selection.some(s => s === context);
1122
const actualContext = contextIsSelected ? selection : [context];
1123
const args = actualContext.map(e => {
1124
if (ResourceTree.isResourceNode(e)) {
1125
return ResourceTree.collect(e);
1126
}
1127
1128
return isChangesFileItem(e) ? [e] : [];
1129
}).flat();
1130
await action.run(sessionResource, discardRef, ...args.map(item => item.uri));
1131
}
1132
}
1133
1134
// --- Tree Delegate and Sorter
1135
1136
class ChangesTreeDelegate implements IListVirtualDelegate<ChangesTreeElement> {
1137
static readonly ROW_HEIGHT = 22;
1138
1139
getHeight(_element: ChangesTreeElement): number {
1140
return ChangesTreeDelegate.ROW_HEIGHT;
1141
}
1142
1143
getTemplateId(_element: ChangesTreeElement): string {
1144
return ChangesTreeRenderer.TEMPLATE_ID;
1145
}
1146
}
1147
1148
class ChangesTreeSorter implements ITreeSorter<ChangesTreeElement> {
1149
constructor(private readonly viewMode: () => ChangesViewMode) { }
1150
1151
compare(a: ChangesTreeElement, b: ChangesTreeElement): number {
1152
if (this.viewMode() === ChangesViewMode.List) {
1153
// List
1154
const aPath = (a as IChangesFileItem).uri.fsPath;
1155
const bPath = (b as IChangesFileItem).uri.fsPath;
1156
1157
return comparePaths(aPath, bPath);
1158
}
1159
1160
// Tree
1161
const aIsDirectory = ResourceTree.isResourceNode(a);
1162
const bIsDirectory = ResourceTree.isResourceNode(b);
1163
1164
if (aIsDirectory !== bIsDirectory) {
1165
return aIsDirectory ? -1 : 1;
1166
}
1167
1168
const aName = ResourceTree.isResourceNode(a)
1169
? a.name
1170
: basename((a as IChangesFileItem).uri);
1171
const bName = ResourceTree.isResourceNode(b)
1172
? b.name
1173
: basename((b as IChangesFileItem).uri);
1174
1175
return compareFileNames(aName, bName);
1176
}
1177
}
1178
1179
// --- View Mode Actions
1180
1181
class SetChangesListViewModeAction extends ViewAction<ChangesViewPane> {
1182
constructor() {
1183
super({
1184
id: 'workbench.changesView.action.setListViewMode',
1185
title: localize('setListViewMode', "View as List"),
1186
viewId: CHANGES_VIEW_ID,
1187
f1: false,
1188
icon: Codicon.listTree,
1189
toggled: ChangesContextKeys.ViewMode.isEqualTo(ChangesViewMode.List),
1190
menu: {
1191
id: MenuId.ChatEditingSessionTitleToolbar,
1192
group: '1_viewmode',
1193
order: 1
1194
}
1195
});
1196
}
1197
1198
async runInView(accessor: ServicesAccessor, view: ChangesViewPane): Promise<void> {
1199
logChangesViewViewModeChange(accessor.get(ITelemetryService), ChangesViewMode.List);
1200
view.viewModel.setViewMode(ChangesViewMode.List);
1201
}
1202
}
1203
1204
class SetChangesTreeViewModeAction extends ViewAction<ChangesViewPane> {
1205
constructor() {
1206
super({
1207
id: 'workbench.changesView.action.setTreeViewMode',
1208
title: localize('setTreeViewMode', "View as Tree"),
1209
viewId: CHANGES_VIEW_ID,
1210
f1: false,
1211
icon: Codicon.listFlat,
1212
toggled: ChangesContextKeys.ViewMode.isEqualTo(ChangesViewMode.Tree),
1213
menu: {
1214
id: MenuId.ChatEditingSessionTitleToolbar,
1215
group: '1_viewmode',
1216
order: 2
1217
}
1218
});
1219
}
1220
1221
async runInView(accessor: ServicesAccessor, view: ChangesViewPane): Promise<void> {
1222
logChangesViewViewModeChange(accessor.get(ITelemetryService), ChangesViewMode.Tree);
1223
view.viewModel.setViewMode(ChangesViewMode.Tree);
1224
}
1225
}
1226
1227
registerAction2(SetChangesListViewModeAction);
1228
registerAction2(SetChangesTreeViewModeAction);
1229
1230
// --- Versions Picker Action
1231
1232
class VersionsPickerAction extends Action2 {
1233
static readonly ID = 'chatEditing.versionsPicker';
1234
1235
constructor() {
1236
super({
1237
id: VersionsPickerAction.ID,
1238
title: localize2('chatEditing.versionsPicker', 'Versions'),
1239
category: CHAT_CATEGORY,
1240
icon: Codicon.listFilter,
1241
f1: false,
1242
menu: [{
1243
id: MenuId.ChatEditingSessionChangesFileHeaderToolbar,
1244
group: 'navigation',
1245
order: 9,
1246
when: ActiveSessionContextKeys.HasGitRepository,
1247
}],
1248
});
1249
}
1250
1251
override async run(): Promise<void> { }
1252
}
1253
registerAction2(VersionsPickerAction);
1254
1255
class ChangesPickerActionItem extends ActionWidgetDropdownActionViewItem {
1256
constructor(
1257
action: MenuItemAction,
1258
private readonly viewModel: ChangesViewModel,
1259
@IActionWidgetService actionWidgetService: IActionWidgetService,
1260
@IKeybindingService keybindingService: IKeybindingService,
1261
@IContextKeyService contextKeyService: IContextKeyService,
1262
@ISessionsManagementService sessionManagementService: ISessionsManagementService,
1263
@ITelemetryService private readonly telemetryService: ITelemetryService,
1264
) {
1265
const actionProvider: IActionWidgetDropdownActionProvider = {
1266
getActions: () => {
1267
const state = viewModel.activeSessionStateObs.get();
1268
const branchName = state?.branchName;
1269
const baseBranchName = state?.baseBranchName;
1270
1271
const actions = [
1272
{
1273
...action,
1274
id: 'chatEditing.versionsBranchChanges',
1275
label: localize('chatEditing.versionsBranchChanges', 'Branch Changes'),
1276
detail: branchName && baseBranchName
1277
? `${branchName} → ${baseBranchName}`
1278
: branchName,
1279
checked: viewModel.versionModeObs.get() === ChangesVersionMode.BranchChanges,
1280
category: { label: 'changes', order: 1, showHeader: false },
1281
run: async () => {
1282
viewModel.setVersionMode(ChangesVersionMode.BranchChanges);
1283
logChangesViewVersionModeChange(this.telemetryService, ChangesVersionMode.BranchChanges);
1284
if (this.element) {
1285
this.renderLabel(this.element);
1286
}
1287
},
1288
},
1289
];
1290
1291
if (!isWeb) {
1292
actions.push({
1293
...action,
1294
id: 'chatEditing.versionsUncommittedChanges',
1295
label: localize('chatEditing.versionsUncommittedChanges', 'Uncommitted Changes'),
1296
detail: localize('chatEditing.versionsUncommittedChanges.description', 'Show uncommitted changes in this session'),
1297
checked: viewModel.versionModeObs.get() === ChangesVersionMode.UncommittedChanges,
1298
category: { label: 'changes', order: 2, showHeader: false },
1299
enabled: viewModel.activeSessionTypeObs.get() !== COPILOT_CLOUD_SESSION_TYPE,
1300
run: async () => {
1301
viewModel.setVersionMode(ChangesVersionMode.UncommittedChanges);
1302
logChangesViewVersionModeChange(this.telemetryService, ChangesVersionMode.UncommittedChanges);
1303
if (this.element) {
1304
this.renderLabel(this.element);
1305
}
1306
},
1307
});
1308
actions.push({
1309
...action,
1310
id: 'chatEditing.versionsAllChanges',
1311
label: localize('chatEditing.versionsAllChanges', 'All Changes'),
1312
detail: localize('chatEditing.versionsAllChanges.description', 'Show all changes made in this session'),
1313
checked: viewModel.versionModeObs.get() === ChangesVersionMode.AllChanges,
1314
category: { label: 'checkpoints', order: 3, showHeader: false },
1315
enabled: viewModel.activeSessionTypeObs.get() === COPILOT_CLOUD_SESSION_TYPE ||
1316
(viewModel.activeSessionFirstCheckpointRefObs.get() !== undefined &&
1317
viewModel.activeSessionLastCheckpointRefObs.get() !== undefined),
1318
run: async () => {
1319
viewModel.setVersionMode(ChangesVersionMode.AllChanges);
1320
logChangesViewVersionModeChange(this.telemetryService, ChangesVersionMode.AllChanges);
1321
if (this.element) {
1322
this.renderLabel(this.element);
1323
}
1324
},
1325
});
1326
actions.push({
1327
...action,
1328
id: 'chatEditing.versionsLastTurnChanges',
1329
label: localize('chatEditing.versionsLastTurnChanges', "Last Turn's Changes"),
1330
detail: localize('chatEditing.versionsLastTurnChanges.description', 'Show only changes from the last turn'),
1331
checked: viewModel.versionModeObs.get() === ChangesVersionMode.LastTurn,
1332
category: { label: 'checkpoints', order: 4, showHeader: false },
1333
enabled: viewModel.activeSessionTypeObs.get() === COPILOT_CLOUD_SESSION_TYPE ||
1334
(viewModel.activeSessionFirstCheckpointRefObs.get() !== undefined &&
1335
viewModel.activeSessionLastCheckpointRefObs.get() !== undefined),
1336
run: async () => {
1337
viewModel.setVersionMode(ChangesVersionMode.LastTurn);
1338
logChangesViewVersionModeChange(this.telemetryService, ChangesVersionMode.LastTurn);
1339
if (this.element) {
1340
this.renderLabel(this.element);
1341
}
1342
},
1343
});
1344
}
1345
1346
return actions;
1347
},
1348
};
1349
1350
super(action, { actionProvider, listOptions: {} }, actionWidgetService, keybindingService, contextKeyService, telemetryService);
1351
1352
this._register(autorun(reader => {
1353
viewModel.versionModeObs.read(reader);
1354
1355
if (this.element) {
1356
this.renderLabel(this.element);
1357
}
1358
}));
1359
}
1360
1361
protected override renderLabel(element: HTMLElement): IDisposable | null {
1362
const mode = this.viewModel.versionModeObs.get();
1363
const label = mode === ChangesVersionMode.BranchChanges
1364
? localize('sessionsChanges.versionsBranchChanges', "Branch Changes")
1365
: mode === ChangesVersionMode.UncommittedChanges
1366
? localize('sessionsChanges.versionsUncommittedChanges', 'Uncommitted Changes')
1367
: mode === ChangesVersionMode.AllChanges
1368
? localize('sessionsChanges.versionsAllChanges', "All Changes")
1369
: localize('sessionsChanges.versionsLastTurn', "Last Turn's Changes");
1370
1371
dom.reset(element, dom.$('span', undefined, label), ...renderLabelWithIcons('$(chevron-down)'));
1372
this.updateAriaLabel();
1373
return null;
1374
}
1375
}
1376
1377
// --- Diff Stats Action
1378
1379
class ChangesDiffStatsAction extends Action2 {
1380
static readonly ID = 'workbench.changesView.action.viewChanges';
1381
1382
constructor() {
1383
super({
1384
id: ChangesDiffStatsAction.ID,
1385
title: localize2('changesView.viewChanges', 'View All Changes'),
1386
f1: false,
1387
menu: {
1388
id: MenuId.ChatEditingSessionChangesFileHeaderRightToolbar,
1389
group: 'navigation',
1390
order: 1,
1391
when: ChatContextKeys.hasAgentSessionChanges
1392
},
1393
});
1394
}
1395
1396
override async run(accessor: ServicesAccessor): Promise<void> {
1397
const viewsService = accessor.get(IViewsService);
1398
const view = viewsService.getViewWithId<ChangesViewPane>(CHANGES_VIEW_ID);
1399
await view?.openChanges();
1400
}
1401
}
1402
registerAction2(ChangesDiffStatsAction);
1403
1404
class ChangesDiffStatsActionItem extends ActionViewItem {
1405
private readonly diffStatsObs: IObservable<{ files: number; insertions: number; deletions: number } | undefined>;
1406
1407
constructor(
1408
action: MenuItemAction,
1409
viewModel: ChangesViewModel,
1410
options: IActionViewItemOptions,
1411
) {
1412
super(null, action, { ...options, icon: false, label: true });
1413
1414
const diffStatsRawObs = derivedObservableWithCache<{ files: number; insertions: number; deletions: number } | undefined>(this,
1415
(reader, lastValue) => {
1416
const entries = viewModel.activeSessionChangesObs.read(reader);
1417
const isLoading = viewModel.activeSessionIsLoadingObs.read(reader);
1418
1419
if (isLoading) {
1420
return lastValue;
1421
}
1422
1423
let insertions = 0, deletions = 0;
1424
for (const entry of entries) {
1425
insertions += entry.insertions;
1426
deletions += entry.deletions;
1427
}
1428
1429
return { files: entries.length, insertions, deletions };
1430
});
1431
1432
this.diffStatsObs = derivedOpts<{ files: number; insertions: number; deletions: number } | undefined>({
1433
equalsFn: structuralEquals
1434
}, reader => diffStatsRawObs.read(reader));
1435
1436
this._register(autorun(reader => {
1437
const diffStats = this.diffStatsObs.read(reader);
1438
if (diffStats === undefined) {
1439
return;
1440
}
1441
1442
this.updateLabel();
1443
this.updateTooltip();
1444
}));
1445
}
1446
1447
override render(container: HTMLElement): void {
1448
super.render(container);
1449
container.classList.add('changes-diff-stats-action');
1450
}
1451
1452
protected override updateLabel(): void {
1453
if (!this.label) {
1454
return;
1455
}
1456
1457
const diffStats = this.diffStatsObs.get();
1458
if (diffStats === undefined) {
1459
return;
1460
}
1461
1462
const { insertions, deletions } = diffStats;
1463
1464
dom.reset(
1465
this.label,
1466
dom.$('span.working-set-lines-added', undefined, `+${insertions}`),
1467
dom.$('span.working-set-lines-removed', undefined, `-${deletions}`)
1468
);
1469
}
1470
1471
protected override getTooltip(): string | undefined {
1472
const diffStats = this.diffStatsObs.get();
1473
if (diffStats === undefined) {
1474
return undefined;
1475
}
1476
1477
const { files, insertions, deletions } = diffStats;
1478
return localize('changesView.diffStats.label', '{0} files, {1} additions, {2} deletions', files, insertions, deletions);
1479
}
1480
}
1481
1482