Path: blob/main/src/vs/sessions/contrib/changes/browser/changesView.ts
13401 views
/*---------------------------------------------------------------------------------------------1* Copyright (c) Microsoft Corporation. All rights reserved.2* Licensed under the MIT License. See License.txt in the project root for license information.3*--------------------------------------------------------------------------------------------*/45import './media/changesView.css';6import * as dom from '../../../../base/browser/dom.js';7import { ActionViewItem, IActionViewItemOptions } from '../../../../base/browser/ui/actionbar/actionViewItems.js';8import { Schemas } from '../../../../base/common/network.js';9import { isWeb } from '../../../../base/common/platform.js';10import { renderLabelWithIcons } from '../../../../base/browser/ui/iconLabel/iconLabels.js';11import { IListVirtualDelegate } from '../../../../base/browser/ui/list/list.js';12import { IObjectTreeElement, ITreeSorter } from '../../../../base/browser/ui/tree/tree.js';13import { ActionRunner, IAction } from '../../../../base/common/actions.js';14import { Codicon } from '../../../../base/common/codicons.js';15import { Disposable, DisposableStore, IDisposable } from '../../../../base/common/lifecycle.js';16import { Event } from '../../../../base/common/event.js';17import { autorun, derived, derivedObservableWithCache, derivedOpts, IObservable } from '../../../../base/common/observable.js';18import { CountBadge } from '../../../../base/browser/ui/countBadge/countBadge.js';19import { ProgressBar } from '../../../../base/browser/ui/progressbar/progressbar.js';20import { basename, isEqual } from '../../../../base/common/resources.js';21import { ThemeIcon } from '../../../../base/common/themables.js';22import { URI } from '../../../../base/common/uri.js';23import { localize, localize2 } from '../../../../nls.js';24import { MenuWorkbenchButtonBar } from '../../../../platform/actions/browser/buttonbar.js';25import { MenuWorkbenchToolBar } from '../../../../platform/actions/browser/toolbar.js';26import { ActionWidgetDropdownActionViewItem } from '../../../../platform/actions/browser/actionWidgetDropdownActionViewItem.js';27import { MenuId, Action2, MenuItemAction, registerAction2, IMenuService } from '../../../../platform/actions/common/actions.js';28import { IActionWidgetService } from '../../../../platform/actionWidget/browser/actionWidget.js';29import { IActionWidgetDropdownActionProvider } from '../../../../platform/actionWidget/browser/actionWidgetDropdown.js';30import { IConfigurationService } from '../../../../platform/configuration/common/configuration.js';31import { IContextKey, IContextKeyService } from '../../../../platform/contextkey/common/contextkey.js';32import { IContextMenuService } from '../../../../platform/contextview/browser/contextView.js';33import { IHoverService } from '../../../../platform/hover/browser/hover.js';34import { IInstantiationService, ServicesAccessor } from '../../../../platform/instantiation/common/instantiation.js';35import { IKeybindingService } from '../../../../platform/keybinding/common/keybinding.js';36import { ILabelService } from '../../../../platform/label/common/label.js';37import { WorkbenchCompressibleObjectTree } from '../../../../platform/list/browser/listService.js';38import { ILogService } from '../../../../platform/log/common/log.js';39import { bindContextKey } from '../../../../platform/observable/common/platformObservableUtils.js';40import { IOpenerService } from '../../../../platform/opener/common/opener.js';41import { ServiceCollection } from '../../../../platform/instantiation/common/serviceCollection.js';42import { IStorageService } from '../../../../platform/storage/common/storage.js';43import { ITelemetryService } from '../../../../platform/telemetry/common/telemetry.js';44import { IThemeService } from '../../../../platform/theme/common/themeService.js';45import { defaultCountBadgeStyles, defaultProgressBarStyles } from '../../../../platform/theme/browser/defaultStyles.js';46import { IWorkspaceContextService } from '../../../../platform/workspace/common/workspace.js';47import { fillEditorsDragData } from '../../../../workbench/browser/dnd.js';48import { ResourceLabels } from '../../../../workbench/browser/labels.js';49import { ViewPane, IViewPaneOptions, ViewAction } from '../../../../workbench/browser/parts/views/viewPane.js';50import { ViewPaneContainer } from '../../../../workbench/browser/parts/views/viewPaneContainer.js';51import { IViewDescriptorService } from '../../../../workbench/common/views.js';52import { CHAT_CATEGORY } from '../../../../workbench/contrib/chat/browser/actions/chatActions.js';53import { IAgentSessionsService } from '../../../../workbench/contrib/chat/browser/agentSessions/agentSessionsService.js';54import { ChatContextKeys } from '../../../../workbench/contrib/chat/common/actions/chatContextKeys.js';55import { createFileIconThemableTreeContainerScope } from '../../../../workbench/contrib/files/browser/views/explorerView.js';56import { ACTIVE_GROUP, IEditorService, SIDE_GROUP } from '../../../../workbench/services/editor/common/editorService.js';57import { IExtensionService } from '../../../../workbench/services/extensions/common/extensions.js';58import { IWorkbenchLayoutService } from '../../../../workbench/services/layout/browser/layoutService.js';59import { IMultiDiffEditorOptions } from '../../../../editor/browser/widget/multiDiffEditor/multiDiffEditorWidgetImpl.js';60import { ChangesMultiDiffSourceResolver, getChangesMultiDiffSourceUri } from './changesMultiDiffSourceResolver.js';61import { ISessionsManagementService } from '../../../services/sessions/common/sessionsManagement.js';62import { CodeReviewStateKind, getCodeReviewFilesFromSessionChanges, getCodeReviewVersion, ICodeReviewService, PRReviewStateKind } from '../../codeReview/browser/codeReviewService.js';63import { CIStatusWidget } from './checksWidget.js';64import { COPILOT_CLOUD_SESSION_TYPE, GITHUB_REMOTE_FILE_SCHEME, SessionStatus } from '../../../services/sessions/common/session.js';65import { Orientation } from '../../../../base/browser/ui/sash/sash.js';66import { IView, Sizing, SplitView } from '../../../../base/browser/ui/splitview/splitview.js';67import { Color } from '../../../../base/common/color.js';68import { PANEL_SECTION_BORDER } from '../../../../workbench/common/theme.js';69import { EditorResourceAccessor, SideBySideEditor } from '../../../../workbench/common/editor.js';70import { logChangesViewFileSelect, logChangesViewVersionModeChange, logChangesViewViewModeChange } from '../../../common/sessionsTelemetry.js';71import { ChecksViewModel } from './checksViewModel.js';72import { AGENT_HOST_SKILL_BUTTON_UPDATE_PR_ID, isAgentHostSkillButtonId } from '../../agentHost/browser/agentHostSkillButtons.js';73import { ActiveSessionContextKeys, CHANGES_VIEW_CONTAINER_ID, CHANGES_VIEW_ID, ChangesContextKeys, ChangesVersionMode, ChangesViewMode, IsolationMode } from '../common/changes.js';74import { buildTreeChildren, ChangesTreeElement, ChangesTreeRenderer, IChangesFileItem, IChangesTreeRootInfo, isChangesFileItem, toIChangesFileItem } from './changesViewRenderer.js';75import { ChangesViewModel } from './changesViewModel.js';76import { ResourceTree } from '../../../../base/common/resourceTree.js';77import { structuralEquals } from '../../../../base/common/equals.js';78import { compareFileNames, comparePaths } from '../../../../base/common/comparers.js';79import { IViewsService } from '../../../../workbench/services/views/common/viewsService.js';8081const $ = dom.$;8283// --- Constants8485const RUN_SESSION_CODE_REVIEW_ACTION_ID = 'sessions.codeReview.run';8687// --- ButtonBar widget8889class ChangesButtonBarWidget extends Disposable {90constructor(91container: HTMLElement,92viewModel: ChangesViewModel,93@IAgentSessionsService agentSessionsService: IAgentSessionsService,94@IMenuService menuService: IMenuService,95@ICodeReviewService codeReviewService: ICodeReviewService,96@IContextKeyService contextKeyService: IContextKeyService,97@IContextMenuService contextMenuService: IContextMenuService,98@IKeybindingService keybindingService: IKeybindingService,99@ITelemetryService telemetryService: ITelemetryService,100@IHoverService hoverService: IHoverService101) {102super();103104const outgoingChangesObs = derived(reader => {105const activeSessionState = viewModel.activeSessionStateObs.read(reader);106return activeSessionState?.outgoingChanges ?? 0;107});108109const reviewStateObs = derivedOpts<{ isLoading: boolean; commentCount: number | undefined }>({ equalsFn: structuralEquals }, reader => {110const sessionResource = viewModel.activeSessionResourceObs.read(reader);111if (!sessionResource) {112return { isLoading: false, commentCount: undefined };113}114115const sessionChanges = viewModel.activeSessionChangesObs.read(reader);116const prReviewState = codeReviewService.getPRReviewState(sessionResource).read(reader);117const prReviewCommentCount = prReviewState.kind === PRReviewStateKind.Loaded118? prReviewState.comments.length119: 0;120121let isLoading = false;122let commentCount: number | undefined;123if (sessionChanges && sessionChanges.length > 0) {124const reviewFiles = getCodeReviewFilesFromSessionChanges(sessionChanges);125const reviewVersion = getCodeReviewVersion(reviewFiles);126const reviewState = codeReviewService.getReviewState(sessionResource).read(reader);127128if (reviewState.kind === CodeReviewStateKind.Loading && reviewState.version === reviewVersion) {129isLoading = true;130} else {131const codeReviewCommentCount = reviewState.kind === CodeReviewStateKind.Result && reviewState.version === reviewVersion132? reviewState.comments.length133: 0;134const totalReviewCommentCount = codeReviewCommentCount + prReviewCommentCount;135if (totalReviewCommentCount > 0) {136commentCount = totalReviewCommentCount;137}138}139} else if (prReviewCommentCount > 0) {140commentCount = prReviewCommentCount;141}142143return { isLoading, commentCount };144});145146this._register(autorun(reader => {147const sessionResource = viewModel.activeSessionResourceObs.read(reader);148const outgoingChanges = outgoingChangesObs.read(reader);149const reviewState = reviewStateObs.read(reader);150151reader.store.add(new MenuWorkbenchButtonBar(152container,153MenuId.ChatEditingSessionChangesToolbar,154{155telemetrySource: 'changesView',156disableWhileRunning: true,157menuOptions: sessionResource158? { args: [sessionResource, agentSessionsService.getSession(sessionResource)?.metadata] }159: { shouldForwardArgs: true },160buttonConfigProvider: (action) => this._getButtonConfiguration(action, outgoingChanges, reviewState)161},162menuService, contextKeyService, contextMenuService, keybindingService, telemetryService, hoverService163));164}));165}166167private _getButtonConfiguration(action: IAction, outgoingChanges: number, reviewState: { isLoading: boolean; commentCount: number | undefined }): { showIcon: boolean; showLabel: boolean; isSecondary?: boolean; customLabel?: string; customClass?: string } | undefined {168if (169action.id === 'github.copilot.sessions.sync' ||170action.id === 'github.copilot.claude.sessions.sync' ||171action.id === 'github.copilot.chat.createPullRequestCopilotCLIAgentSession.updatePR' ||172action.id === AGENT_HOST_SKILL_BUTTON_UPDATE_PR_ID173) {174const customLabel = outgoingChanges > 0175? `${action.label} ${outgoingChanges}↑`176: action.label;177return { customLabel, showIcon: true, showLabel: true, isSecondary: false };178}179if (action.id === RUN_SESSION_CODE_REVIEW_ACTION_ID) {180if (reviewState.isLoading) {181return { showIcon: true, showLabel: true, isSecondary: true, customLabel: '$(loading~spin)', customClass: 'code-review-loading' };182}183if (reviewState.commentCount !== undefined) {184return { showIcon: true, showLabel: true, isSecondary: true, customLabel: String(reviewState.commentCount), customClass: 'code-review-comments' };185}186return { showIcon: true, showLabel: false, isSecondary: true };187}188if (189action.id === 'chatEditing.viewAllSessionChanges' ||190action.id === 'github.copilot.chat.openPullRequestCopilotCLIAgentSession.openPR'191) {192return { showIcon: true, showLabel: false, isSecondary: true };193}194if (action.id === 'agentFeedbackEditor.action.submitActiveSession') {195return { showIcon: false, showLabel: true, isSecondary: false };196}197if (198action.id === 'github.copilot.chat.createPullRequestCopilotCLIAgentSession.createPR' ||199action.id === 'github.copilot.chat.mergeCopilotCLIAgentSessionChanges.merge' ||200action.id === 'github.copilot.chat.checkoutPullRequestReroute' ||201action.id === 'pr.checkoutFromChat' ||202action.id === 'github.copilot.sessions.initializeRepository' ||203action.id === 'github.copilot.sessions.commit' ||204action.id === 'github.copilot.claude.sessions.initializeRepository' ||205action.id === 'github.copilot.claude.sessions.commit' ||206action.id === 'github.copilot.claude.sessions.commitAndSync' ||207action.id === 'agentSession.markAsDone' ||208isAgentHostSkillButtonId(action.id)209) {210return { showIcon: true, showLabel: true, isSecondary: false };211}212213// Unknown actions (e.g. extension-contributed): only hide the label when an icon is present.214if (action instanceof MenuItemAction) {215const icon = action.item.icon;216if (icon) {217// Icon-only button (no forced secondary state so primary/secondary can be inferred).218return { showIcon: true, showLabel: false };219}220}221222// Fall back to default button behavior for actions without an icon.223return undefined;224}225}226227// --- View Pane228229export class ChangesViewPane extends ViewPane {230231private bodyContainer: HTMLElement | undefined;232private welcomeContainer: HTMLElement | undefined;233private filesHeaderNode: HTMLElement | undefined;234private fileHeaderToolbarContainer: HTMLElement | undefined;235private contentContainer: HTMLElement | undefined;236private overviewContainer: HTMLElement | undefined;237private summaryContainer: HTMLElement | undefined;238private listContainer: HTMLElement | undefined;239// Actions container is positioned outside the card for this layout experiment240private actionsContainer: HTMLElement | undefined;241242private changesProgressBar!: ProgressBar;243private tree: WorkbenchCompressibleObjectTree<ChangesTreeElement> | undefined;244private ciStatusWidget: CIStatusWidget | undefined;245private splitView: SplitView | undefined;246private splitViewContainer: HTMLElement | undefined;247248private readonly isMergeBaseBranchProtectedContextKey: IContextKey<boolean>;249private readonly isolationModeContextKey: IContextKey<IsolationMode>;250private readonly hasGitRepositoryContextKey: IContextKey<boolean>;251private readonly hasUpstreamContextKey: IContextKey<boolean>;252private readonly hasIncomingChangesContextKey: IContextKey<boolean>;253private readonly hasOpenPullRequestContextKey: IContextKey<boolean>;254private readonly hasOutgoingChangesContextKey: IContextKey<boolean>;255private readonly hasPullRequestContextKey: IContextKey<boolean>;256private readonly hasGitHubRemoteContextKey: IContextKey<boolean>;257private readonly hasUncommittedChangesContextKey: IContextKey<boolean>;258259private readonly scopedInstantiationService: IInstantiationService;260261private readonly renderDisposables = this._register(new DisposableStore());262263// Track current body dimensions for list layout264private currentBodyHeight = 0;265private currentBodyWidth = 0;266267readonly viewModel: ChangesViewModel;268269constructor(270options: IViewPaneOptions,271@IKeybindingService keybindingService: IKeybindingService,272@IContextMenuService contextMenuService: IContextMenuService,273@IConfigurationService configurationService: IConfigurationService,274@IContextKeyService contextKeyService: IContextKeyService,275@IViewDescriptorService viewDescriptorService: IViewDescriptorService,276@IInstantiationService instantiationService: IInstantiationService,277@IOpenerService openerService: IOpenerService,278@IThemeService themeService: IThemeService,279@IHoverService hoverService: IHoverService,280@IEditorService private readonly editorService: IEditorService,281@ISessionsManagementService private readonly sessionManagementService: ISessionsManagementService,282@ILabelService private readonly labelService: ILabelService,283@ILogService private readonly logService: ILogService,284@ITelemetryService private readonly telemetryService: ITelemetryService,285) {286super({ ...options, titleMenuId: MenuId.ChatEditingSessionTitleToolbar }, keybindingService, contextMenuService, configurationService, contextKeyService, viewDescriptorService, instantiationService, openerService, themeService, hoverService);287288this.viewModel = this.instantiationService.createInstance(ChangesViewModel);289this._register(this.viewModel);290291// Multi-diff editor source resolver292const changesMultiDiffSourceResolver = this.instantiationService.createInstance(ChangesMultiDiffSourceResolver, this.viewModel);293this._register(changesMultiDiffSourceResolver);294295// Context keys296this.isMergeBaseBranchProtectedContextKey = ActiveSessionContextKeys.IsMergeBaseBranchProtected.bindTo(this.scopedContextKeyService);297this.isolationModeContextKey = ActiveSessionContextKeys.IsolationMode.bindTo(this.scopedContextKeyService);298this.hasGitRepositoryContextKey = ActiveSessionContextKeys.HasGitRepository.bindTo(this.scopedContextKeyService);299this.hasUpstreamContextKey = ActiveSessionContextKeys.HasUpstream.bindTo(this.scopedContextKeyService);300this.hasIncomingChangesContextKey = ActiveSessionContextKeys.HasIncomingChanges.bindTo(this.scopedContextKeyService);301this.hasOutgoingChangesContextKey = ActiveSessionContextKeys.HasOutgoingChanges.bindTo(this.scopedContextKeyService);302this.hasUncommittedChangesContextKey = ActiveSessionContextKeys.HasUncommittedChanges.bindTo(this.scopedContextKeyService);303this.hasGitHubRemoteContextKey = ActiveSessionContextKeys.HasGitHubRemote.bindTo(this.scopedContextKeyService);304this.hasPullRequestContextKey = ActiveSessionContextKeys.HasPullRequest.bindTo(this.scopedContextKeyService);305this.hasOpenPullRequestContextKey = ActiveSessionContextKeys.HasOpenPullRequest.bindTo(this.scopedContextKeyService);306307// Version mode308this._register(bindContextKey(ChangesContextKeys.VersionMode, this.scopedContextKeyService, reader => {309return this.viewModel.versionModeObs.read(reader);310}));311312// View mode313this._register(bindContextKey(ChangesContextKeys.ViewMode, this.scopedContextKeyService, reader => {314return this.viewModel.viewModeObs.read(reader);315}));316317// Set chatSessionType on the view's context key service so ViewTitle menu items318// can use it in their `when` clauses. Update reactively when the active session319// changes.320this._register(bindContextKey(ChatContextKeys.agentSessionType, this.scopedContextKeyService, reader => {321return this.viewModel.activeSessionTypeObs.read(reader) ?? '';322}));323324const scopedServiceCollection = new ServiceCollection([IContextKeyService, this.scopedContextKeyService]);325this.scopedInstantiationService = this.instantiationService.createChild(scopedServiceCollection);326this._register(this.scopedInstantiationService);327}328329protected override renderBody(container: HTMLElement): void {330super.renderBody(container);331332this.bodyContainer = dom.append(container, $('.changes-view-body'));333334// Actions container - positioned outside and above the card335this.actionsContainer = dom.append(this.bodyContainer, $('.chat-editing-session-actions.outside-card'));336337// SplitView container for resizable file tree / CI checks split338this.splitViewContainer = dom.append(this.bodyContainer, $('.changes-splitview-container'));339340// Main container with file icons support (the "card") — top pane341this.contentContainer = dom.append(this.splitViewContainer, $('.chat-editing-session-container.show-file-icons'));342this._register(createFileIconThemableTreeContainerScope(this.contentContainer, this.themeService));343344// Toggle class based on whether the file icon theme has file icons345const updateHasFileIcons = () => {346this.contentContainer!.classList.toggle('has-file-icons', this.themeService.getFileIconTheme().hasFileIcons);347};348updateHasFileIcons();349this._register(this.themeService.onDidFileIconThemeChange(updateHasFileIcons));350351// Files header352this.filesHeaderNode = dom.append(this.contentContainer, $('.changes-files-header'));353354// Changesets toolbar355const filesHeaderToolbarContainer = dom.append(this.filesHeaderNode, $('.changes-files-header-toolbar'));356this._register(this.scopedInstantiationService.createInstance(MenuWorkbenchToolBar, filesHeaderToolbarContainer, MenuId.ChatEditingSessionChangesFileHeaderToolbar, {357menuOptions: { shouldForwardArgs: true },358actionViewItemProvider: (action) => {359if (action.id === 'chatEditing.versionsPicker' && action instanceof MenuItemAction) {360return this.scopedInstantiationService.createInstance(ChangesPickerActionItem, action, this.viewModel);361}362return undefined;363},364}));365366// File header right-aligned toolbar367this.fileHeaderToolbarContainer = dom.append(this.filesHeaderNode, $('.changes-files-header-right-toolbar'));368this._register(this.scopedInstantiationService.createInstance(MenuWorkbenchToolBar, this.fileHeaderToolbarContainer, MenuId.ChatEditingSessionChangesFileHeaderRightToolbar, {369menuOptions: { shouldForwardArgs: true },370actionViewItemProvider: (action, options) => {371if (action.id === ChangesDiffStatsAction.ID && action instanceof MenuItemAction) {372return this.scopedInstantiationService.createInstance(ChangesDiffStatsActionItem, action, this.viewModel, options);373}374return undefined;375},376}));377378// Overview section (header with summary only - actions moved outside card)379this.overviewContainer = dom.append(this.contentContainer, $('.chat-editing-session-overview'));380this.summaryContainer = dom.append(this.overviewContainer, $('.changes-summary'));381382// Changes card progress bar383const progressContainer = dom.append(this.contentContainer, $('.changes-progress'));384this.changesProgressBar = this._register(new ProgressBar(progressContainer, defaultProgressBarStyles));385this.changesProgressBar.stop().hide();386387// List container388this.listContainer = dom.append(this.contentContainer, $('.changes-file-list'));389390// Welcome message for empty state (hidden by default, shown when no changes)391this.welcomeContainer = dom.append(this.contentContainer, $('.changes-welcome'));392this.welcomeContainer.style.display = 'none';393394const welcomeIcon = dom.append(this.welcomeContainer, $('.changes-welcome-icon'));395welcomeIcon.classList.add(...ThemeIcon.asClassNameArray(Codicon.diffMultiple));396const welcomeMessage = dom.append(this.welcomeContainer, $('.changes-welcome-message'));397welcomeMessage.textContent = localize('changesView.noChanges', "Changed files and other session artifacts will appear here.");398399// CI Status widget — bottom pane400this.ciStatusWidget = this._register(this.instantiationService.createInstance(CIStatusWidget, this.splitViewContainer));401402// Create SplitView403this.splitView = this._register(new SplitView(this.splitViewContainer, {404orientation: Orientation.VERTICAL,405proportionalLayout: false,406}));407408// Shared constants for pane sizing409const ciMinHeight = CIStatusWidget.HEADER_HEIGHT + CIStatusWidget.MIN_BODY_HEIGHT;410const treeMinHeight = 3 * ChangesTreeDelegate.ROW_HEIGHT;411412// Top pane: file tree413const treePane: IView = {414element: this.contentContainer,415minimumSize: treeMinHeight,416maximumSize: Number.POSITIVE_INFINITY,417onDidChange: Event.None,418layout: (height) => {419this.contentContainer!.style.height = `${height}px`;420this._layoutTreeInPane(height);421},422};423424// Bottom pane: CI checks425const ciElement = this.ciStatusWidget.element;426const ciWidget = this.ciStatusWidget;427const ciPane: IView = {428element: ciElement,429get minimumSize() { return ciWidget.collapsed ? CIStatusWidget.HEADER_HEIGHT : ciMinHeight; },430get maximumSize() { return ciWidget.collapsed ? CIStatusWidget.HEADER_HEIGHT : Number.POSITIVE_INFINITY; },431onDidChange: Event.map(this.ciStatusWidget.onDidChangeHeight, () => undefined),432layout: (height) => {433ciElement.style.height = `${height}px`;434const bodyHeight = Math.max(0, height - CIStatusWidget.HEADER_HEIGHT);435ciWidget.layout(bodyHeight);436},437};438439this.splitView.addView(treePane, Sizing.Distribute, 0, true);440this.splitView.addView(ciPane, CIStatusWidget.HEADER_HEIGHT + CIStatusWidget.PREFERRED_BODY_HEIGHT, 1, true);441442// Style the sash as a visible separator between sections443const updateSplitViewStyles = () => {444const borderColor = this.themeService.getColorTheme().getColor(PANEL_SECTION_BORDER);445this.splitView!.style({ separatorBorder: borderColor ?? Color.transparent });446};447updateSplitViewStyles();448this._register(this.themeService.onDidColorThemeChange(updateSplitViewStyles));449450// Initially hide CI pane until checks arrive451this.splitView.setViewVisible(1, false);452453let savedCIPaneHeight = CIStatusWidget.HEADER_HEIGHT + CIStatusWidget.PREFERRED_BODY_HEIGHT;454this._register(this.ciStatusWidget.onDidToggleCollapsed(collapsed => {455if (!this.splitView || !this.ciStatusWidget) {456return;457}458if (collapsed) {459// Save current size before collapsing460const currentSize = this.splitView.getViewSize(1);461if (currentSize > CIStatusWidget.HEADER_HEIGHT) {462savedCIPaneHeight = currentSize;463}464this.splitView.resizeView(1, CIStatusWidget.HEADER_HEIGHT);465} else {466// Restore saved size on expand467this.splitView.resizeView(1, savedCIPaneHeight);468}469this.layoutSplitView();470}));471472this._register(this.ciStatusWidget.onDidChangeHeight(() => {473if (!this.splitView || !this.ciStatusWidget) {474return;475}476const visible = this.ciStatusWidget.visible;477const isCurrentlyVisible = this.splitView.isViewVisible(1);478if (visible !== isCurrentlyVisible) {479this.splitView.setViewVisible(1, visible);480}481this.layoutSplitView();482}));483484this._register(this.onDidChangeBodyVisibility(visible => {485if (visible) {486this.onVisible();487} else {488this.renderDisposables.clear();489}490}));491492// Trigger initial render if already visible493if (this.isBodyVisible()) {494this.onVisible();495}496}497498override getActionsContext(): URI | undefined {499return this.viewModel.activeSessionResourceObs.get();500}501502private onVisible(): void {503this.renderDisposables.clear();504505// Title actions506this.renderDisposables.add(autorun(reader => {507this.viewModel.activeSessionResourceObs.read(reader);508this.updateActions();509}));510511// Loading512this.renderDisposables.add(autorun(reader => {513const isLoading = this.viewModel.activeSessionIsLoadingObs.read(reader);514if (isLoading) {515this.changesProgressBar.infinite().show(200);516} else {517this.changesProgressBar.stop().hide();518}519}));520521// Changes522const changesObs = derived(reader => {523const changes = this.viewModel.activeSessionChangesObs.read(reader);524return toIChangesFileItem(changes);525});526527// Changes statistics528const topLevelStats = derived(reader => {529const entries = changesObs.read(reader);530531let added = 0, removed = 0;532533for (const entry of entries) {534added += entry.linesAdded;535removed += entry.linesRemoved;536}537538return { files: entries.length, added, removed };539});540541// Setup context keys and actions toolbar542if (this.actionsContainer) {543dom.clearNode(this.actionsContainer);544545// Bind context keys546this._bindContextKeys(topLevelStats);547548this.renderDisposables.add(this.scopedInstantiationService.createInstance(549ChangesButtonBarWidget, this.actionsContainer, this.viewModel));550}551552const activeSessionStatusObs = derived(reader => {553const activeSession = this.sessionManagementService.activeSession.read(reader);554return activeSession?.status.read(reader);555});556557// Update visibility based on entries558this.renderDisposables.add(autorun(reader => {559if (this.viewModel.activeSessionIsLoadingObs.read(reader)) {560return;561}562563// Hide the actions toolbar for untitled sessions.564const activeSessionStatus = activeSessionStatusObs.read(reader);565if (this.actionsContainer) {566dom.setVisibility(activeSessionStatus !== undefined && activeSessionStatus !== SessionStatus.Untitled, this.actionsContainer);567}568569const hasGitRepository = this.viewModel.activeSessionHasGitRepositoryObs.read(reader);570571const { files } = topLevelStats.read(reader);572const hasEntries = files > 0;573574// Show the files header whenever the session is git-backed (so users575// can switch version modes) or there are session-provided entries to576// count (for non-git sessions like the local agent host).577dom.setVisibility(hasGitRepository || hasEntries, this.filesHeaderNode!);578579if (this.fileHeaderToolbarContainer) {580dom.setVisibility(hasEntries, this.fileHeaderToolbarContainer);581}582583dom.setVisibility(hasEntries, this.listContainer!);584dom.setVisibility(!hasEntries, this.welcomeContainer!);585586this.layoutSplitView();587}));588589// Update summary text (line counts only, file count is shown in badge)590if (this.summaryContainer) {591dom.clearNode(this.summaryContainer);592593const linesAddedSpan = dom.$('.working-set-lines-added');594const linesRemovedSpan = dom.$('.working-set-lines-removed');595596this.summaryContainer.appendChild(linesAddedSpan);597this.summaryContainer.appendChild(linesRemovedSpan);598599this.renderDisposables.add(autorun(reader => {600if (this.viewModel.activeSessionIsLoadingObs.read(reader)) {601return;602}603604const { added, removed } = topLevelStats.read(reader);605606linesAddedSpan.textContent = `+${added}`;607linesRemovedSpan.textContent = `-${removed}`;608}));609}610611// Create the tree612if (!this.tree && this.listContainer) {613this.tree = this.createChangesTree(this.listContainer, this.onDidChangeBodyVisibility, this._store);614}615616// Register tree event handlers617if (this.tree) {618const tree = this.tree;619620// Re-layout when collapse state changes so the card height adjusts621this.renderDisposables.add(tree.onDidChangeContentHeight(() => this.layoutSplitView()));622623this.renderDisposables.add(tree.onDidOpen((e) => {624if (!e.element || !isChangesFileItem(e.element)) {625return;626}627628logChangesViewFileSelect(this.telemetryService, e.element.changeType);629630const modalEditorMode = this.configurationService.getValue<string>('workbench.editor.useModal');631if (modalEditorMode === 'all') {632const items = changesObs.get();633this._openFileItem(e.element, items, e.sideBySide, !!e.editorOptions?.preserveFocus, !!e.editorOptions?.pinned, items.length > 1);634return;635}636637// Open multi-file diff editor638void this._openMultiFileDiffEditor(e.element.uri);639}));640}641642// Checks643if (this.ciStatusWidget) {644const checksViewModel = this.instantiationService.createInstance(ChecksViewModel);645this.renderDisposables.add(checksViewModel);646647this.renderDisposables.add(this.ciStatusWidget.setInput(checksViewModel));648}649650// Update tree data with combined entries651this.renderDisposables.add(autorun(reader => {652const changes = changesObs.read(reader);653const viewMode = this.viewModel.viewModeObs.read(reader);654const isLoading = this.viewModel.activeSessionIsLoadingObs.read(reader);655// Read session state so this autorun re-runs when git state (e.g. branch name)656// arrives asynchronously, since the tree root label depends on it.657this.viewModel.activeSessionStateObs.read(reader);658659if (!this.tree || isLoading) {660return;661}662663// Toggle list-mode class to remove tree indentation in list mode664this.listContainer?.classList.toggle('list-mode', viewMode === ChangesViewMode.List);665666if (viewMode === ChangesViewMode.Tree) {667// Tree mode: build hierarchical tree from file entries668const treeRootInfo = this.getTreeRootInfo(changes);669const treeChildren = buildTreeChildren(changes, treeRootInfo);670this.tree.setChildren(null, treeChildren);671} else {672// List mode: flat list of file items673const listChildren = changes.map(item => ({674element: item,675collapsible: false,676} satisfies IObjectTreeElement<ChangesTreeElement>));677this.tree.setChildren(null, listChildren);678}679680this.layoutSplitView();681}));682}683684private _bindContextKeys(topLevelStats: IObservable<{ files: number }>): void {685// Request in progress (can be updated independently since it only affects action enablement, and not visibility)686this.renderDisposables.add(bindContextKey(ChatContextKeys.requestInProgress, this.scopedContextKeyService, reader => {687const activeSessionStatus = this.sessionManagementService.activeSession.read(reader)?.status.read(reader);688return activeSessionStatus !== SessionStatus.Completed && activeSessionStatus !== SessionStatus.Error;689}));690691// Has changes (can be updated independently since it only affects action enablement, and not visibility)692this.renderDisposables.add(bindContextKey(ChatContextKeys.hasAgentSessionChanges, this.scopedContextKeyService, reader => {693const { files } = topLevelStats.read(reader);694return files > 0;695}));696697// Bulk update the context keys698this.renderDisposables.add(autorun(reader => {699const state = this.viewModel.activeSessionStateObs.read(reader);700if (!state) {701return;702}703704this.logService.info(`[ChangesViewPane][_bindContextKeys] Context keys: ${JSON.stringify(state)}`);705706this.scopedContextKeyService.bufferChangeEvents(() => {707this.isolationModeContextKey.set(state.isolationMode);708this.hasGitRepositoryContextKey.set(state.hasGitRepository);709this.isMergeBaseBranchProtectedContextKey.set(state.isMergeBaseBranchProtected === true);710this.hasGitHubRemoteContextKey.set(state.hasGitHubRemote === true);711this.hasPullRequestContextKey.set(state.hasPullRequest === true);712this.hasOpenPullRequestContextKey.set(state.hasOpenPullRequest === true);713this.hasUpstreamContextKey.set(state.upstreamBranchName !== undefined);714this.hasIncomingChangesContextKey.set(state.incomingChanges !== undefined && state.incomingChanges > 0);715this.hasOutgoingChangesContextKey.set(state.outgoingChanges !== undefined && state.outgoingChanges > 0);716this.hasUncommittedChangesContextKey.set(state.uncommittedChanges !== undefined && state.uncommittedChanges > 0);717});718}));719}720721/** Layout the tree within its SplitView pane. */722private _layoutTreeInPane(paneHeight: number): void {723if (!this.tree) {724return;725}726// Subtract overview/padding within the content container727const overviewHeight = this.overviewContainer?.offsetHeight ?? 0;728const filesHeaderHeight = this.filesHeaderNode?.offsetHeight ?? 0;729const treeHeight = Math.max(0, paneHeight - filesHeaderHeight - overviewHeight);730this.tree.layout(treeHeight, this.currentBodyWidth);731this.tree.getHTMLElement().style.height = `${treeHeight}px`;732}733734/** Layout the SplitView to fill available body space. */735private layoutSplitView(): void {736if (!this.splitView || !this.splitViewContainer) {737return;738}739const bodyHeight = this.currentBodyHeight;740if (bodyHeight <= 0) {741return;742}743const bodyPadding = 16; // 8px top + 8px bottom from .changes-view-body744const actionsHeight = this.actionsContainer?.offsetHeight ?? 0;745const actionsMargin = actionsHeight > 0 ? 8 : 0;746const availableHeight = Math.max(0, bodyHeight - bodyPadding - actionsHeight - actionsMargin);747this.splitViewContainer.style.height = `${availableHeight}px`;748this.splitView.layout(availableHeight);749}750751private getTreeSelection(): IChangesFileItem[] {752const selection = this.tree?.getSelection() ?? [];753return selection.filter(item => !!item && isChangesFileItem(item));754}755756private getTreeRootInfo(items: readonly IChangesFileItem[]): IChangesTreeRootInfo | undefined {757if (items.length === 0) {758return undefined;759}760761// Get the repository details for the session762// - uri: location of the repository763// - workingDirectory (optional): location of the worktree764const activeSession = this.sessionManagementService.activeSession.get();765const repository = activeSession?.workspace.get()?.repositories[0];766const workspaceFolderUri = repository?.workingDirectory ?? repository?.uri;767if (!repository?.uri || !workspaceFolderUri) {768return undefined;769}770771let name: string = '';772let resourceTreeRootUri = workspaceFolderUri;773774if (workspaceFolderUri.scheme === GITHUB_REMOTE_FILE_SCHEME) {775// Cloud session776resourceTreeRootUri = URI.from({ scheme: Schemas.copilotPr, path: '/' });777const segments = workspaceFolderUri.path.split('/').filter(Boolean);778name = `${segments.slice(0, 2).join('/')} (${decodeURIComponent(segments[2])})`;779} else {780// Local session781const branchName = this.viewModel.activeSessionStateObs.get()?.branchName;782name = repository.workingDirectory783? `${basename(repository.uri)} (${branchName})`784: basename(repository.uri);785}786787return {788root: {789type: 'root',790uri: workspaceFolderUri,791name792},793resourceTreeRootUri794};795}796797private getSessionDiscardRef(): string {798const versionMode = this.viewModel.versionModeObs.get();799const firstCheckpointRef = this.viewModel.activeSessionFirstCheckpointRefObs.get();800const lastCheckpointRef = this.viewModel.activeSessionLastCheckpointRefObs.get();801802if (versionMode === ChangesVersionMode.UncommittedChanges) {803return 'HEAD';804}805806return versionMode === ChangesVersionMode.LastTurn807? lastCheckpointRef808? `${lastCheckpointRef}^`809: ''810: firstCheckpointRef ?? '';811}812813protected override layoutBody(height: number, width: number): void {814super.layoutBody(height, width);815this.currentBodyHeight = height;816this.currentBodyWidth = width;817this.layoutSplitView();818}819820override focus(): void {821super.focus();822823if (this.tree && this.tree.getNode(null).visibleChildrenCount > 0) {824this.tree.domFocus();825}826}827828private renderSidebarList(829container: HTMLElement,830onDidLayout: Event<{ readonly height: number; readonly width: number }>,831items: IChangesFileItem[],832openFileItem: (item: IChangesFileItem, items: IChangesFileItem[], sideBySide: boolean, preserveFocus: boolean, pinned: boolean, includeSidebar: boolean) => void,833): IDisposable {834const disposables = new DisposableStore();835836container.classList.add('changes-file-list');837838const viewMode = this.viewModel.viewModeObs.get();839container.classList.toggle('list-mode', viewMode === ChangesViewMode.List);840841// "Changes" header842const headerNode = dom.append(container, $('.changes-sidebar-header'));843const headerLabel = dom.append(headerNode, $('span'));844headerLabel.textContent = localize('changes', "Changes");845const countBadge = disposables.add(new CountBadge(headerNode, { count: items.length }, defaultCountBadgeStyles));846countBadge.setCount(items.length);847848const tree = this.createChangesTree(container, Event.None, disposables, () => tree.getSelection().filter(item => !!item && isChangesFileItem(item)));849850if (viewMode === ChangesViewMode.Tree) {851tree.setChildren(null, buildTreeChildren(items, this.getTreeRootInfo(items)));852} else {853tree.setChildren(null, items.map(item => ({ element: item as ChangesTreeElement, collapsible: false })));854}855856// Open file on selection. The `updatingSelection` guard relies on857// `tree.setFocus`/`setSelection` firing events synchronously.858let updatingSelection = false;859disposables.add(tree.onDidOpen(e => {860if (e.element && isChangesFileItem(e.element) && !updatingSelection) {861openFileItem(e.element, items, e.sideBySide, !!e.editorOptions.preserveFocus, !!e.editorOptions.pinned, false /* preserve existing sidebar */);862}863}));864865// Track active editor and highlight in sidebar866disposables.add(Event.runAndSubscribe(this.editorService.onDidActiveEditorChange, () => {867const activeEditor = this.editorService.activeEditor;868if (!activeEditor) {869return;870}871872const primaryResource = EditorResourceAccessor.getCanonicalUri(activeEditor, { supportSideBySide: SideBySideEditor.PRIMARY });873const secondaryResource = EditorResourceAccessor.getCanonicalUri(activeEditor, { supportSideBySide: SideBySideEditor.SECONDARY });874875const index = items.findIndex(i =>876(primaryResource !== undefined && isEqual(i.uri, primaryResource)) ||877(secondaryResource !== undefined && i.originalUri !== undefined && isEqual(i.originalUri, secondaryResource))878);879if (index >= 0) {880updatingSelection = true;881try {882tree.setFocus([items[index]]);883tree.setSelection([items[index]]);884tree.reveal(items[index]);885} finally {886updatingSelection = false;887}888}889}));890891// Layout on resize, accounting for the header height892disposables.add(onDidLayout(e => {893const headerHeight = headerNode.offsetHeight;894tree.layout(Math.max(0, e.height - headerHeight), e.width);895}));896897return disposables;898}899900private createChangesTree(901container: HTMLElement,902onDidChangeVisibility: Event<boolean>,903disposables: DisposableStore,904getSelection?: () => IChangesFileItem[],905): WorkbenchCompressibleObjectTree<ChangesTreeElement> {906const resourceLabels = disposables.add(this.instantiationService.createInstance(ResourceLabels, { onDidChangeVisibility }));907const actionRunner = disposables.add(new ChangesViewActionRunner(908() => this.viewModel.activeSessionResourceObs.get(),909() => this.getSessionDiscardRef(),910getSelection ?? (() => this.getTreeSelection()),911));912return disposables.add(this.instantiationService.createInstance(913WorkbenchCompressibleObjectTree<ChangesTreeElement>,914'ChangesViewTree',915container,916new ChangesTreeDelegate(),917[this.instantiationService.createInstance(ChangesTreeRenderer, this.viewModel, resourceLabels, actionRunner,918() => {919// Pass in the tree root to be used to compute the label description920const activeSession = this.sessionManagementService.activeSession.get();921const repository = activeSession?.workspace.get()?.repositories[0];922return repository?.uri.scheme === GITHUB_REMOTE_FILE_SCHEME923? URI.from({ scheme: Schemas.copilotPr, path: '/' })924: repository?.workingDirectory ?? repository?.uri;925})],926{927alwaysConsumeMouseWheel: false,928accessibilityProvider: {929getAriaLabel: (element: ChangesTreeElement) => isChangesFileItem(element) ? basename(element.uri) : element.name,930getWidgetAriaLabel: () => localize('changesViewTree', "Changes Tree")931},932dnd: {933getDragURI: (element: ChangesTreeElement) => element.uri.toString(),934getDragLabel: (elements) => {935const uris = elements.map(e => e.uri);936if (uris.length === 1) {937return this.labelService.getUriLabel(uris[0], { relative: true });938}939return `${uris.length}`;940},941dispose: () => { },942onDragOver: () => false,943drop: () => { },944onDragStart: (data, originalEvent) => {945try {946const elements = data.getData() as ChangesTreeElement[];947const uris = elements.filter(isChangesFileItem).map(e => e.uri);948this.instantiationService.invokeFunction(accessor => fillEditorsDragData(accessor, uris, originalEvent));949} catch {950// noop951}952},953},954identityProvider: {955getId: (element: ChangesTreeElement) => element.uri.toString()956},957indent: this.viewModel.viewModeObs.get() === ChangesViewMode.List ? 0 : 8,958compressionEnabled: true,959sorter: new ChangesTreeSorter(() => this.viewModel.viewModeObs.get()),960twistieAdditionalCssClass: (e: unknown) => {961return this.viewModel.viewModeObs.get() === ChangesViewMode.List962? 'force-no-twistie'963: undefined;964},965}966));967}968969async openChanges(resource?: URI): Promise<void> {970const items = this.viewModel.activeSessionChangesObs.get();971if (items.length === 0) {972return;973}974975const modalEditorMode = this.configurationService.getValue<string>('workbench.editor.useModal');976if (modalEditorMode === 'all') {977const changes = toIChangesFileItem(items);978const changeToOpen = resource ? changes.find(c => isEqual(c.uri, resource)) : undefined;979await this._openFileItem(changeToOpen ?? changes[0], changes, false, false, false, changes.length > 1);980return;981}982983// Open multi-file diff editor984await this._openMultiFileDiffEditor(resource);985}986987private async _openFileItem(item: IChangesFileItem, items: IChangesFileItem[], sideBySide: boolean, preserveFocus: boolean, pinned: boolean, includeSidebar: boolean): Promise<void> {988const { uri: modifiedFileUri, originalUri, isDeletion } = item;989const currentIndex = items.indexOf(item);990991const sidebar = includeSidebar ? {992render: (container: unknown, onDidLayout: Event<{ readonly height: number; readonly width: number }>) => {993return this.renderSidebarList(container as HTMLElement, onDidLayout, items, this._openFileItem.bind(this));994}995} : undefined;996997const navigation = {998total: items.length,999current: currentIndex,1000navigate: (index: number) => {1001const target = items[index];1002if (target) {1003this._openFileItem(target, items, false, false, false, includeSidebar);1004}1005}1006};10071008const group = sideBySide ? SIDE_GROUP : ACTIVE_GROUP;10091010if (isDeletion && originalUri) {1011this.editorService.openEditor({1012resource: originalUri,1013options: { preserveFocus, pinned, modal: { sidebar, navigation } }1014}, group);1015return;1016}10171018if (originalUri) {1019this.editorService.openEditor({1020original: { resource: originalUri },1021modified: { resource: modifiedFileUri },1022options: { preserveFocus, pinned, modal: { sidebar, navigation } }1023}, group);1024return;1025}10261027this.editorService.openEditor({1028resource: modifiedFileUri,1029options: { preserveFocus, pinned, modal: { sidebar, navigation } }1030}, group);1031}10321033private async _openMultiFileDiffEditor(reveal?: URI): Promise<void> {1034const sessionResource = this.viewModel.activeSessionResourceObs.get();1035const changes = this.viewModel.activeSessionChangesObs.get();10361037if (!sessionResource || changes.length === 0) {1038return;1039}10401041// Determine the reveal target (original/modified URI pair) from the1042// current change list, so the multi-diff editor can navigate to it.1043let options: IMultiDiffEditorOptions | undefined;1044if (reveal) {1045const target = changes.find(c => isEqual(c.modifiedUri, reveal));1046if (target) {1047options = {1048viewState: {1049revealData: {1050resource: {1051original: target.originalUri,1052modified: target.modifiedUri,1053},1054},1055},1056} satisfies IMultiDiffEditorOptions;1057}1058}10591060// Open the multi-diff editor using the sessions source URI. The resource1061// list is resolved via `SessionsMultiDiffSourceResolver` and updates1062// reactively as `activeSessionChangesObs` changes.1063await this.editorService.openEditor({1064multiDiffSource: getChangesMultiDiffSourceUri(sessionResource),1065label: localize('sessions.changes.title', 'Session Changes'),1066options,1067});1068}10691070override dispose(): void {1071this.tree = undefined;1072super.dispose();1073}1074}10751076export class ChangesViewPaneContainer extends ViewPaneContainer {1077constructor(1078@IWorkbenchLayoutService layoutService: IWorkbenchLayoutService,1079@ITelemetryService telemetryService: ITelemetryService,1080@IInstantiationService instantiationService: IInstantiationService,1081@IContextMenuService contextMenuService: IContextMenuService,1082@IThemeService themeService: IThemeService,1083@IStorageService storageService: IStorageService,1084@IConfigurationService configurationService: IConfigurationService,1085@IExtensionService extensionService: IExtensionService,1086@IWorkspaceContextService contextService: IWorkspaceContextService,1087@IViewDescriptorService viewDescriptorService: IViewDescriptorService,1088@ILogService logService: ILogService,1089) {1090super(CHANGES_VIEW_CONTAINER_ID, { mergeViewWithContainerWhenSingleView: true }, instantiationService, configurationService, layoutService, contextMenuService, telemetryService, extensionService, themeService, storageService, contextService, viewDescriptorService, logService);1091}10921093override create(parent: HTMLElement): void {1094super.create(parent);1095parent.classList.add('changes-viewlet');1096}1097}10981099// --- Action Runner11001101class ChangesViewActionRunner extends ActionRunner {11021103constructor(1104private readonly getSessionResource: () => URI | undefined,1105private readonly getSessionDiscardRef: () => string,1106private readonly getSelectedFileItems: () => IChangesFileItem[]1107) {1108super();1109}11101111protected override async runAction(action: IAction, context: ChangesTreeElement): Promise<void> {1112if (!(action instanceof MenuItemAction)) {1113return super.runAction(action, context);1114}11151116const sessionResource = this.getSessionResource();1117const discardRef = this.getSessionDiscardRef();1118const selection = this.getSelectedFileItems();11191120const contextIsSelected = selection.some(s => s === context);1121const actualContext = contextIsSelected ? selection : [context];1122const args = actualContext.map(e => {1123if (ResourceTree.isResourceNode(e)) {1124return ResourceTree.collect(e);1125}11261127return isChangesFileItem(e) ? [e] : [];1128}).flat();1129await action.run(sessionResource, discardRef, ...args.map(item => item.uri));1130}1131}11321133// --- Tree Delegate and Sorter11341135class ChangesTreeDelegate implements IListVirtualDelegate<ChangesTreeElement> {1136static readonly ROW_HEIGHT = 22;11371138getHeight(_element: ChangesTreeElement): number {1139return ChangesTreeDelegate.ROW_HEIGHT;1140}11411142getTemplateId(_element: ChangesTreeElement): string {1143return ChangesTreeRenderer.TEMPLATE_ID;1144}1145}11461147class ChangesTreeSorter implements ITreeSorter<ChangesTreeElement> {1148constructor(private readonly viewMode: () => ChangesViewMode) { }11491150compare(a: ChangesTreeElement, b: ChangesTreeElement): number {1151if (this.viewMode() === ChangesViewMode.List) {1152// List1153const aPath = (a as IChangesFileItem).uri.fsPath;1154const bPath = (b as IChangesFileItem).uri.fsPath;11551156return comparePaths(aPath, bPath);1157}11581159// Tree1160const aIsDirectory = ResourceTree.isResourceNode(a);1161const bIsDirectory = ResourceTree.isResourceNode(b);11621163if (aIsDirectory !== bIsDirectory) {1164return aIsDirectory ? -1 : 1;1165}11661167const aName = ResourceTree.isResourceNode(a)1168? a.name1169: basename((a as IChangesFileItem).uri);1170const bName = ResourceTree.isResourceNode(b)1171? b.name1172: basename((b as IChangesFileItem).uri);11731174return compareFileNames(aName, bName);1175}1176}11771178// --- View Mode Actions11791180class SetChangesListViewModeAction extends ViewAction<ChangesViewPane> {1181constructor() {1182super({1183id: 'workbench.changesView.action.setListViewMode',1184title: localize('setListViewMode', "View as List"),1185viewId: CHANGES_VIEW_ID,1186f1: false,1187icon: Codicon.listTree,1188toggled: ChangesContextKeys.ViewMode.isEqualTo(ChangesViewMode.List),1189menu: {1190id: MenuId.ChatEditingSessionTitleToolbar,1191group: '1_viewmode',1192order: 11193}1194});1195}11961197async runInView(accessor: ServicesAccessor, view: ChangesViewPane): Promise<void> {1198logChangesViewViewModeChange(accessor.get(ITelemetryService), ChangesViewMode.List);1199view.viewModel.setViewMode(ChangesViewMode.List);1200}1201}12021203class SetChangesTreeViewModeAction extends ViewAction<ChangesViewPane> {1204constructor() {1205super({1206id: 'workbench.changesView.action.setTreeViewMode',1207title: localize('setTreeViewMode', "View as Tree"),1208viewId: CHANGES_VIEW_ID,1209f1: false,1210icon: Codicon.listFlat,1211toggled: ChangesContextKeys.ViewMode.isEqualTo(ChangesViewMode.Tree),1212menu: {1213id: MenuId.ChatEditingSessionTitleToolbar,1214group: '1_viewmode',1215order: 21216}1217});1218}12191220async runInView(accessor: ServicesAccessor, view: ChangesViewPane): Promise<void> {1221logChangesViewViewModeChange(accessor.get(ITelemetryService), ChangesViewMode.Tree);1222view.viewModel.setViewMode(ChangesViewMode.Tree);1223}1224}12251226registerAction2(SetChangesListViewModeAction);1227registerAction2(SetChangesTreeViewModeAction);12281229// --- Versions Picker Action12301231class VersionsPickerAction extends Action2 {1232static readonly ID = 'chatEditing.versionsPicker';12331234constructor() {1235super({1236id: VersionsPickerAction.ID,1237title: localize2('chatEditing.versionsPicker', 'Versions'),1238category: CHAT_CATEGORY,1239icon: Codicon.listFilter,1240f1: false,1241menu: [{1242id: MenuId.ChatEditingSessionChangesFileHeaderToolbar,1243group: 'navigation',1244order: 9,1245when: ActiveSessionContextKeys.HasGitRepository,1246}],1247});1248}12491250override async run(): Promise<void> { }1251}1252registerAction2(VersionsPickerAction);12531254class ChangesPickerActionItem extends ActionWidgetDropdownActionViewItem {1255constructor(1256action: MenuItemAction,1257private readonly viewModel: ChangesViewModel,1258@IActionWidgetService actionWidgetService: IActionWidgetService,1259@IKeybindingService keybindingService: IKeybindingService,1260@IContextKeyService contextKeyService: IContextKeyService,1261@ISessionsManagementService sessionManagementService: ISessionsManagementService,1262@ITelemetryService private readonly telemetryService: ITelemetryService,1263) {1264const actionProvider: IActionWidgetDropdownActionProvider = {1265getActions: () => {1266const state = viewModel.activeSessionStateObs.get();1267const branchName = state?.branchName;1268const baseBranchName = state?.baseBranchName;12691270const actions = [1271{1272...action,1273id: 'chatEditing.versionsBranchChanges',1274label: localize('chatEditing.versionsBranchChanges', 'Branch Changes'),1275detail: branchName && baseBranchName1276? `${branchName} → ${baseBranchName}`1277: branchName,1278checked: viewModel.versionModeObs.get() === ChangesVersionMode.BranchChanges,1279category: { label: 'changes', order: 1, showHeader: false },1280run: async () => {1281viewModel.setVersionMode(ChangesVersionMode.BranchChanges);1282logChangesViewVersionModeChange(this.telemetryService, ChangesVersionMode.BranchChanges);1283if (this.element) {1284this.renderLabel(this.element);1285}1286},1287},1288];12891290if (!isWeb) {1291actions.push({1292...action,1293id: 'chatEditing.versionsUncommittedChanges',1294label: localize('chatEditing.versionsUncommittedChanges', 'Uncommitted Changes'),1295detail: localize('chatEditing.versionsUncommittedChanges.description', 'Show uncommitted changes in this session'),1296checked: viewModel.versionModeObs.get() === ChangesVersionMode.UncommittedChanges,1297category: { label: 'changes', order: 2, showHeader: false },1298enabled: viewModel.activeSessionTypeObs.get() !== COPILOT_CLOUD_SESSION_TYPE,1299run: async () => {1300viewModel.setVersionMode(ChangesVersionMode.UncommittedChanges);1301logChangesViewVersionModeChange(this.telemetryService, ChangesVersionMode.UncommittedChanges);1302if (this.element) {1303this.renderLabel(this.element);1304}1305},1306});1307actions.push({1308...action,1309id: 'chatEditing.versionsAllChanges',1310label: localize('chatEditing.versionsAllChanges', 'All Changes'),1311detail: localize('chatEditing.versionsAllChanges.description', 'Show all changes made in this session'),1312checked: viewModel.versionModeObs.get() === ChangesVersionMode.AllChanges,1313category: { label: 'checkpoints', order: 3, showHeader: false },1314enabled: viewModel.activeSessionTypeObs.get() === COPILOT_CLOUD_SESSION_TYPE ||1315(viewModel.activeSessionFirstCheckpointRefObs.get() !== undefined &&1316viewModel.activeSessionLastCheckpointRefObs.get() !== undefined),1317run: async () => {1318viewModel.setVersionMode(ChangesVersionMode.AllChanges);1319logChangesViewVersionModeChange(this.telemetryService, ChangesVersionMode.AllChanges);1320if (this.element) {1321this.renderLabel(this.element);1322}1323},1324});1325actions.push({1326...action,1327id: 'chatEditing.versionsLastTurnChanges',1328label: localize('chatEditing.versionsLastTurnChanges', "Last Turn's Changes"),1329detail: localize('chatEditing.versionsLastTurnChanges.description', 'Show only changes from the last turn'),1330checked: viewModel.versionModeObs.get() === ChangesVersionMode.LastTurn,1331category: { label: 'checkpoints', order: 4, showHeader: false },1332enabled: viewModel.activeSessionTypeObs.get() === COPILOT_CLOUD_SESSION_TYPE ||1333(viewModel.activeSessionFirstCheckpointRefObs.get() !== undefined &&1334viewModel.activeSessionLastCheckpointRefObs.get() !== undefined),1335run: async () => {1336viewModel.setVersionMode(ChangesVersionMode.LastTurn);1337logChangesViewVersionModeChange(this.telemetryService, ChangesVersionMode.LastTurn);1338if (this.element) {1339this.renderLabel(this.element);1340}1341},1342});1343}13441345return actions;1346},1347};13481349super(action, { actionProvider, listOptions: {} }, actionWidgetService, keybindingService, contextKeyService, telemetryService);13501351this._register(autorun(reader => {1352viewModel.versionModeObs.read(reader);13531354if (this.element) {1355this.renderLabel(this.element);1356}1357}));1358}13591360protected override renderLabel(element: HTMLElement): IDisposable | null {1361const mode = this.viewModel.versionModeObs.get();1362const label = mode === ChangesVersionMode.BranchChanges1363? localize('sessionsChanges.versionsBranchChanges', "Branch Changes")1364: mode === ChangesVersionMode.UncommittedChanges1365? localize('sessionsChanges.versionsUncommittedChanges', 'Uncommitted Changes')1366: mode === ChangesVersionMode.AllChanges1367? localize('sessionsChanges.versionsAllChanges', "All Changes")1368: localize('sessionsChanges.versionsLastTurn', "Last Turn's Changes");13691370dom.reset(element, dom.$('span', undefined, label), ...renderLabelWithIcons('$(chevron-down)'));1371this.updateAriaLabel();1372return null;1373}1374}13751376// --- Diff Stats Action13771378class ChangesDiffStatsAction extends Action2 {1379static readonly ID = 'workbench.changesView.action.viewChanges';13801381constructor() {1382super({1383id: ChangesDiffStatsAction.ID,1384title: localize2('changesView.viewChanges', 'View All Changes'),1385f1: false,1386menu: {1387id: MenuId.ChatEditingSessionChangesFileHeaderRightToolbar,1388group: 'navigation',1389order: 1,1390when: ChatContextKeys.hasAgentSessionChanges1391},1392});1393}13941395override async run(accessor: ServicesAccessor): Promise<void> {1396const viewsService = accessor.get(IViewsService);1397const view = viewsService.getViewWithId<ChangesViewPane>(CHANGES_VIEW_ID);1398await view?.openChanges();1399}1400}1401registerAction2(ChangesDiffStatsAction);14021403class ChangesDiffStatsActionItem extends ActionViewItem {1404private readonly diffStatsObs: IObservable<{ files: number; insertions: number; deletions: number } | undefined>;14051406constructor(1407action: MenuItemAction,1408viewModel: ChangesViewModel,1409options: IActionViewItemOptions,1410) {1411super(null, action, { ...options, icon: false, label: true });14121413const diffStatsRawObs = derivedObservableWithCache<{ files: number; insertions: number; deletions: number } | undefined>(this,1414(reader, lastValue) => {1415const entries = viewModel.activeSessionChangesObs.read(reader);1416const isLoading = viewModel.activeSessionIsLoadingObs.read(reader);14171418if (isLoading) {1419return lastValue;1420}14211422let insertions = 0, deletions = 0;1423for (const entry of entries) {1424insertions += entry.insertions;1425deletions += entry.deletions;1426}14271428return { files: entries.length, insertions, deletions };1429});14301431this.diffStatsObs = derivedOpts<{ files: number; insertions: number; deletions: number } | undefined>({1432equalsFn: structuralEquals1433}, reader => diffStatsRawObs.read(reader));14341435this._register(autorun(reader => {1436const diffStats = this.diffStatsObs.read(reader);1437if (diffStats === undefined) {1438return;1439}14401441this.updateLabel();1442this.updateTooltip();1443}));1444}14451446override render(container: HTMLElement): void {1447super.render(container);1448container.classList.add('changes-diff-stats-action');1449}14501451protected override updateLabel(): void {1452if (!this.label) {1453return;1454}14551456const diffStats = this.diffStatsObs.get();1457if (diffStats === undefined) {1458return;1459}14601461const { insertions, deletions } = diffStats;14621463dom.reset(1464this.label,1465dom.$('span.working-set-lines-added', undefined, `+${insertions}`),1466dom.$('span.working-set-lines-removed', undefined, `-${deletions}`)1467);1468}14691470protected override getTooltip(): string | undefined {1471const diffStats = this.diffStatsObs.get();1472if (diffStats === undefined) {1473return undefined;1474}14751476const { files, insertions, deletions } = diffStats;1477return localize('changesView.diffStats.label', '{0} files, {1} additions, {2} deletions', files, insertions, deletions);1478}1479}148014811482