From c30864b3d0fa2049360c59c98b9d0c4fe8ac1de3 Mon Sep 17 00:00:00 2001 From: Ladislau Szomoru <3372902+lszomoru@users.noreply.github.com> Date: Fri, 6 Mar 2026 21:08:17 +0100 Subject: [PATCH] Sessions - initial implementation for git changes (#299855) * Sessions - initial implementation of repository changes * Deduplicate resources and fix badge --- .../changesView/browser/changesView.ts | 76 +++++++++++++++---- .../browser/mainThreadGitExtensionService.ts | 22 +++++- .../workbench/api/common/extHost.protocol.ts | 10 +++ .../api/common/extHostGitExtensionService.ts | 72 +++++++++++++++--- .../contrib/git/common/gitService.ts | 10 +++ 5 files changed, 163 insertions(+), 27 deletions(-) diff --git a/src/vs/sessions/contrib/changesView/browser/changesView.ts b/src/vs/sessions/contrib/changesView/browser/changesView.ts index da1e460a297..9cf004af32a 100644 --- a/src/vs/sessions/contrib/changesView/browser/changesView.ts +++ b/src/vs/sessions/contrib/changesView/browser/changesView.ts @@ -13,9 +13,9 @@ import { Codicon } from '../../../../base/common/codicons.js'; import { MarkdownString } from '../../../../base/common/htmlContent.js'; import { Iterable } from '../../../../base/common/iterator.js'; import { DisposableStore, MutableDisposable } from '../../../../base/common/lifecycle.js'; -import { autorun, derived, derivedOpts, IObservable, IObservableWithChange, observableFromEvent, observableValue } from '../../../../base/common/observable.js'; +import { autorun, derived, derivedOpts, IObservable, IObservableWithChange, observableFromEvent, observableFromPromise, observableValue } from '../../../../base/common/observable.js'; import { basename, dirname } from '../../../../base/common/path.js'; -import { isEqual } from '../../../../base/common/resources.js'; +import { extUriBiasedIgnorePathCase, isEqual } from '../../../../base/common/resources.js'; import { ThemeIcon } from '../../../../base/common/themables.js'; import { URI } from '../../../../base/common/uri.js'; import { localize } from '../../../../nls.js'; @@ -58,6 +58,7 @@ import { IWorkbenchLayoutService } from '../../../../workbench/services/layout/b import { ISessionsManagementService } from '../../sessions/browser/sessionsManagementService.js'; import { GITHUB_REMOTE_FILE_SCHEME } from '../../fileTreeView/browser/githubFileSystemProvider.js'; import { CodeReviewStateKind, getCodeReviewFilesFromSessionChanges, getCodeReviewVersion, ICodeReviewService } from '../../codeReview/browser/codeReviewService.js'; +import { IGitService } from '../../../../workbench/contrib/git/common/gitService.js'; const $ = dom.$; @@ -101,6 +102,8 @@ interface IChangesFolderItem { interface IActiveSession { readonly resource: URI; readonly sessionType: string; + readonly repository: URI | undefined; + readonly worktree: URI | undefined; } type ChangesTreeElement = IChangesFileItem | IChangesFolderItem; @@ -230,6 +233,7 @@ export class ChangesViewPane extends ViewPane { private readonly activeSession: IObservableWithChange; private readonly activeSessionFileCountObs: IObservableWithChange; private readonly activeSessionHasChangesObs: IObservableWithChange; + private readonly activeSessionRepositoryChangesObs: IObservableWithChange; get activeSessionHasChanges(): IObservable { return this.activeSessionHasChangesObs; @@ -257,6 +261,7 @@ export class ChangesViewPane extends ViewPane { @ILabelService private readonly labelService: ILabelService, @IStorageService private readonly storageService: IStorageService, @ICodeReviewService private readonly codeReviewService: ICodeReviewService, + @IGitService private readonly gitService: IGitService, ) { super(options, keybindingService, contextMenuService, configurationService, contextKeyService, viewDescriptorService, instantiationService, openerService, themeService, hoverService); @@ -278,16 +283,49 @@ export class ChangesViewPane extends ViewPane { return { resource: activeSession.resource, + repository: activeSession.repository, + worktree: activeSession.worktree, sessionType: getChatSessionType(activeSession.resource), }; }).recomputeInitiallyAndOnChange(this._store); + // Track active session repository changes + const repositoryObs = derived(reader => { + const activeSessionWorktree = this.activeSession.read(reader)?.worktree; + if (!activeSessionWorktree) { + return undefined; + } + + return observableFromPromise(this.gitService.openRepository(activeSessionWorktree)); + }); + + this.activeSessionRepositoryChangesObs = derived(reader => { + const repository = repositoryObs.read(reader)?.read(reader); + if (!repository) { + return undefined; + } + + const state = repository.value?.state.read(reader); + return (state?.workingTreeChanges ?? []).map(change => { + const isDeletion = change.modifiedUri === undefined; + const isAddition = change.originalUri === undefined; + return { + type: 'file', + uri: change.modifiedUri ?? change.uri, + originalUri: change.originalUri, + state: ModifiedFileEntryState.Accepted, + isDeletion, + changeType: isDeletion ? 'deleted' : isAddition ? 'added' : 'modified', + reviewCommentCount: 0, + linesAdded: 0, + linesRemoved: 0, + } satisfies IChangesFileItem; + }); + }); + this.activeSessionFileCountObs = this.createActiveSessionFileCountObservable(); this.activeSessionHasChangesObs = this.activeSessionFileCountObs.map(fileCount => fileCount > 0).recomputeInitiallyAndOnChange(this._store); - // Setup badge tracking - this.registerBadgeTracking(); - // Set chatSessionType on the view's context key service so ViewTitle // menu items can use it in their `when` clauses. Update reactively // when the active session changes. @@ -298,14 +336,6 @@ export class ChangesViewPane extends ViewPane { })); } - private registerBadgeTracking(): void { - // Update badge when file count changes - this._register(autorun(reader => { - const fileCount = this.activeSessionFileCountObs.read(reader); - this.updateBadge(fileCount); - })); - } - private createActiveSessionFileCountObservable(): IObservableWithChange { const activeSessionResource = this.activeSession.map(a => a?.resource); @@ -532,13 +562,24 @@ export class ChangesViewPane extends ViewPane { const combinedEntriesObs = derived(reader => { const editEntries = editSessionEntriesObs.read(reader); const sessionFiles = sessionFilesObs.read(reader); - return [...editEntries, ...sessionFiles]; + const repositoryFiles = this.activeSessionRepositoryChangesObs.read(reader) ?? []; + + const resources = new Set(); + const entries: IChangesFileItem[] = []; + for (const item of [...editEntries, ...sessionFiles, ...repositoryFiles]) { + if (!resources.has(item.uri.fsPath)) { + resources.add(item.uri.fsPath); + entries.push(item); + } + } + return entries.sort((a, b) => extUriBiasedIgnorePathCase.compare(a.uri, b.uri)); }); // Calculate stats from combined entries const topLevelStats = derived(reader => { const editEntries = editSessionEntriesObs.read(reader); const sessionFiles = sessionFilesObs.read(reader); + const repositoryFiles = this.activeSessionRepositoryChangesObs.read(reader) ?? []; const entries = combinedEntriesObs.read(reader); let added = 0, removed = 0; @@ -549,7 +590,7 @@ export class ChangesViewPane extends ViewPane { } const files = entries.length; - const isSessionMenu = editEntries.length === 0 && sessionFiles.length > 0; + const isSessionMenu = editEntries.length === 0 && (sessionFiles.length > 0 || repositoryFiles.length > 0); return { files, added, removed, isSessionMenu }; }); @@ -653,6 +694,11 @@ export class ChangesViewPane extends ViewPane { dom.setVisibility(!hasEntries, this.welcomeContainer!); })); + // Update badge when file count changes + this.renderDisposables.add(autorun(reader => { + this.updateBadge(topLevelStats.read(reader).files); + })); + // Update summary text (line counts only, file count is shown in badge) if (this.summaryContainer) { dom.clearNode(this.summaryContainer); diff --git a/src/vs/workbench/api/browser/mainThreadGitExtensionService.ts b/src/vs/workbench/api/browser/mainThreadGitExtensionService.ts index 6ed0a6d0acd..ad414190cd6 100644 --- a/src/vs/workbench/api/browser/mainThreadGitExtensionService.ts +++ b/src/vs/workbench/api/browser/mainThreadGitExtensionService.ts @@ -8,7 +8,7 @@ import { Disposable } from '../../../base/common/lifecycle.js'; import { ResourceMap } from '../../../base/common/map.js'; import { URI } from '../../../base/common/uri.js'; import { GitRepository } from '../../contrib/git/browser/gitService.js'; -import { IGitExtensionDelegate, IGitService, GitRef, GitRefQuery, GitRefType, GitRepositoryState, GitBranch, IGitRepository } from '../../contrib/git/common/gitService.js'; +import { IGitExtensionDelegate, IGitService, GitRef, GitRefQuery, GitRefType, GitRepositoryState, GitBranch, GitChange, IGitRepository } from '../../contrib/git/common/gitService.js'; import { extHostNamedCustomer, IExtHostContext } from '../../services/extensions/common/extHostCustomers.js'; import { ExtHostContext, ExtHostGitExtensionShape, GitRefTypeDto, GitRepositoryStateDto, MainContext, MainThreadGitExtensionShape } from '../common/extHost.protocol.js'; @@ -32,6 +32,26 @@ function toGitRepositoryState(dto: GitRepositoryStateDto | undefined): GitReposi ahead: dto.HEAD.ahead, behind: dto.HEAD.behind, } satisfies GitBranch : undefined, + mergeChanges: dto?.mergeChanges?.map(c => ({ + uri: URI.revive(c.uri), + originalUri: c.originalUri ? URI.revive(c.originalUri) : undefined, + modifiedUri: c.modifiedUri ? URI.revive(c.modifiedUri) : undefined, + } satisfies GitChange)) ?? [], + indexChanges: dto?.indexChanges?.map(c => ({ + uri: URI.revive(c.uri), + originalUri: c.originalUri ? URI.revive(c.originalUri) : undefined, + modifiedUri: c.modifiedUri ? URI.revive(c.modifiedUri) : undefined, + } satisfies GitChange)) ?? [], + workingTreeChanges: dto?.workingTreeChanges?.map(c => ({ + uri: URI.revive(c.uri), + originalUri: c.originalUri ? URI.revive(c.originalUri) : undefined, + modifiedUri: c.modifiedUri ? URI.revive(c.modifiedUri) : undefined, + } satisfies GitChange)) ?? [], + untrackedChanges: dto?.untrackedChanges?.map(c => ({ + uri: URI.revive(c.uri), + originalUri: c.originalUri ? URI.revive(c.originalUri) : undefined, + modifiedUri: c.modifiedUri ? URI.revive(c.modifiedUri) : undefined, + } satisfies GitChange)) ?? [], }; } diff --git a/src/vs/workbench/api/common/extHost.protocol.ts b/src/vs/workbench/api/common/extHost.protocol.ts index 0bd60212242..be954021047 100644 --- a/src/vs/workbench/api/common/extHost.protocol.ts +++ b/src/vs/workbench/api/common/extHost.protocol.ts @@ -3627,8 +3627,18 @@ export interface GitRefDto { readonly revision: string; } +export interface GitChangeDto { + readonly uri: UriComponents; + readonly originalUri: UriComponents | undefined; + readonly modifiedUri: UriComponents | undefined; +} + export interface GitRepositoryStateDto { readonly HEAD?: GitBranchDto; + readonly mergeChanges: readonly GitChangeDto[]; + readonly indexChanges: readonly GitChangeDto[]; + readonly workingTreeChanges: readonly GitChangeDto[]; + readonly untrackedChanges: readonly GitChangeDto[]; } export interface GitBranchDto { diff --git a/src/vs/workbench/api/common/extHostGitExtensionService.ts b/src/vs/workbench/api/common/extHostGitExtensionService.ts index 61a64e83bf3..6c2960f0383 100644 --- a/src/vs/workbench/api/common/extHostGitExtensionService.ts +++ b/src/vs/workbench/api/common/extHostGitExtensionService.ts @@ -11,7 +11,7 @@ import { ExtensionIdentifier } from '../../../platform/extensions/common/extensi import { createDecorator } from '../../../platform/instantiation/common/instantiation.js'; import { IExtHostExtensionService } from './extHostExtensionService.js'; import { IExtHostRpcService } from './extHostRpcService.js'; -import { ExtHostGitExtensionShape, GitBranchDto, GitRefDto, GitRefQueryDto, GitRefTypeDto, GitRepositoryStateDto, GitUpstreamRefDto, MainContext, MainThreadGitExtensionShape } from './extHost.protocol.js'; +import { ExtHostGitExtensionShape, GitBranchDto, GitChangeDto, GitRefDto, GitRefQueryDto, GitRefTypeDto, GitRepositoryStateDto, GitUpstreamRefDto, MainContext, MainThreadGitExtensionShape } from './extHost.protocol.js'; import { ResourceMap } from '../../../base/common/map.js'; const GIT_EXTENSION_ID = 'vscode.git'; @@ -45,6 +45,52 @@ function toGitUpstreamRefDto(upstream: UpstreamRef): GitUpstreamRefDto { }; } +// Status values from the git extension's const enum Status +const enum GitStatus { + INDEX_ADDED = 1, + INDEX_DELETED = 2, + INDEX_RENAMED = 3, + MODIFIED = 5, + DELETED = 6, + UNTRACKED = 7, + INTENT_TO_ADD = 9, + INTENT_TO_RENAME = 10, +} + +function toGitChangeDto(change: Change): GitChangeDto { + switch (change.status) { + // Added: no original + case GitStatus.INDEX_ADDED: + case GitStatus.UNTRACKED: + case GitStatus.INTENT_TO_ADD: + return { uri: change.uri, originalUri: undefined, modifiedUri: change.uri }; + + // Deleted: no modified + case GitStatus.INDEX_DELETED: + case GitStatus.DELETED: + return { uri: change.uri, originalUri: change.uri, modifiedUri: undefined }; + + // Renamed: original is old name, modified is new name + case GitStatus.INDEX_RENAMED: + case GitStatus.INTENT_TO_RENAME: + return { uri: change.uri, originalUri: change.originalUri, modifiedUri: change.renameUri }; + + // Modified and everything else: both original and modified + default: + return { uri: change.uri, originalUri: change.originalUri, modifiedUri: change.uri }; + } +} + +function toGitRepositoryStateDto(state: RepositoryState): GitRepositoryStateDto { + return { + HEAD: state.HEAD ? toGitBranchDto(state.HEAD) : undefined, + mergeChanges: state.mergeChanges.map(toGitChangeDto), + indexChanges: state.indexChanges.map(toGitChangeDto), + workingTreeChanges: state.workingTreeChanges.map(toGitChangeDto), + untrackedChanges: state.untrackedChanges.map(toGitChangeDto), + }; +} + interface Repository { readonly rootUri: vscode.Uri; readonly state: RepositoryState; @@ -53,8 +99,19 @@ interface Repository { getRefs(query: GitRefQuery, token?: vscode.CancellationToken): Promise; } +interface Change { + readonly uri: vscode.Uri; + readonly originalUri: vscode.Uri; + readonly renameUri: vscode.Uri | undefined; + readonly status: number; +} + interface RepositoryState { readonly HEAD: Branch | undefined; + readonly mergeChanges: Change[]; + readonly indexChanges: Change[]; + readonly workingTreeChanges: Change[]; + readonly untrackedChanges: Change[]; readonly onDidChange: Event; } @@ -148,9 +205,7 @@ export class ExtHostGitExtensionService extends Disposable implements IExtHostGi return { handle: existingHandle, rootUri: repository.rootUri, - state: { - HEAD: repository.state.HEAD ? toGitBranchDto(repository.state.HEAD) : undefined - } + state: toGitRepositoryStateDto(repository.state), }; } @@ -178,11 +233,7 @@ export class ExtHostGitExtensionService extends Disposable implements IExtHostGi return { handle, rootUri: repository.rootUri, - state: { - HEAD: repository.state.HEAD - ? toGitBranchDto(repository.state.HEAD) - : undefined - } + state: toGitRepositoryStateDto(repository.state), }; } @@ -225,8 +276,7 @@ export class ExtHostGitExtensionService extends Disposable implements IExtHostGi return undefined; } - const state = repository.state; - return { HEAD: state.HEAD ? toGitBranchDto(state.HEAD) : undefined }; + return toGitRepositoryStateDto(repository.state); } private async _ensureGitApi(): Promise { diff --git a/src/vs/workbench/contrib/git/common/gitService.ts b/src/vs/workbench/contrib/git/common/gitService.ts index 353686d452a..2217f2200db 100644 --- a/src/vs/workbench/contrib/git/common/gitService.ts +++ b/src/vs/workbench/contrib/git/common/gitService.ts @@ -29,8 +29,18 @@ export interface GitRefQuery { readonly sort?: 'alphabetically' | 'committerdate' | 'creatordate'; } +export interface GitChange { + readonly uri: URI; + readonly originalUri: URI | undefined; + readonly modifiedUri: URI | undefined; +} + export interface GitRepositoryState { readonly HEAD?: GitBranch; + readonly mergeChanges: readonly GitChange[]; + readonly indexChanges: readonly GitChange[]; + readonly workingTreeChanges: readonly GitChange[]; + readonly untrackedChanges: readonly GitChange[]; } export interface GitBranch extends GitRef {