Sessions - initial implementation for git changes (#299855)

* Sessions - initial implementation of repository changes

* Deduplicate resources and fix badge
This commit is contained in:
Ladislau Szomoru
2026-03-06 21:08:17 +01:00
committed by GitHub
parent abe7ae5449
commit c30864b3d0
5 changed files with 163 additions and 27 deletions

View File

@@ -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<IActiveSession | undefined>;
private readonly activeSessionFileCountObs: IObservableWithChange<number>;
private readonly activeSessionHasChangesObs: IObservableWithChange<boolean>;
private readonly activeSessionRepositoryChangesObs: IObservableWithChange<IChangesFileItem[] | undefined>;
get activeSessionHasChanges(): IObservable<boolean> {
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<number> {
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);

View File

@@ -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)) ?? [],
};
}

View File

@@ -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 {

View File

@@ -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<GitRef[]>;
}
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<void>;
}
@@ -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<GitExtensionAPI | undefined> {

View File

@@ -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 {