Session - add actions to filter changes (#300000)

* Initial implementation

* Get changes from last turn working

* Update src/vs/workbench/api/common/extHostGitExtensionService.ts

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>

* Apply suggestion from @Copilot

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>

* Apply suggestion from @Copilot

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>

* Fixup the PR

* Another PR fix

---------

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
This commit is contained in:
Ladislau Szomoru
2026-03-08 00:03:02 +01:00
committed by GitHub
parent 010781f467
commit 7bd2f8d4d6
7 changed files with 251 additions and 12 deletions

View File

@@ -262,6 +262,7 @@ export class MenuId {
static readonly ChatEditingWidgetToolbar = new MenuId('ChatEditingWidgetToolbar');
static readonly ChatEditingSessionChangesToolbar = new MenuId('ChatEditingSessionChangesToolbar');
static readonly ChatEditingSessionApplySubmenu = new MenuId('ChatEditingSessionApplySubmenu');
static readonly ChatEditingSessionChangesVersionsSubmenu = new MenuId('ChatEditingSessionChangesVersionsSubmenu');
static readonly ChatEditingEditorContent = new MenuId('ChatEditingEditorContent');
static readonly ChatEditingEditorHunk = new MenuId('ChatEditingEditorHunk');
static readonly ChatEditingDeletedNotebookCell = new MenuId('ChatEditingDeletedNotebookCell');

View File

@@ -18,10 +18,10 @@ import { basename, dirname } from '../../../../base/common/path.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';
import { localize, localize2 } from '../../../../nls.js';
import { MenuWorkbenchButtonBar } from '../../../../platform/actions/browser/buttonbar.js';
import { MenuWorkbenchToolBar } from '../../../../platform/actions/browser/toolbar.js';
import { MenuId, registerAction2 } from '../../../../platform/actions/common/actions.js';
import { MenuId, Action2, MenuRegistry, registerAction2 } from '../../../../platform/actions/common/actions.js';
import { IConfigurationService } from '../../../../platform/configuration/common/configuration.js';
import { ContextKeyExpr, IContextKey, IContextKeyService, RawContextKey } from '../../../../platform/contextkey/common/contextkey.js';
import { IContextMenuService } from '../../../../platform/contextview/browser/contextView.js';
@@ -44,6 +44,9 @@ import { IResourceLabel, ResourceLabels } from '../../../../workbench/browser/la
import { ViewPane, IViewPaneOptions, ViewAction } from '../../../../workbench/browser/parts/views/viewPane.js';
import { ViewPaneContainer } from '../../../../workbench/browser/parts/views/viewPaneContainer.js';
import { IViewDescriptorService } from '../../../../workbench/common/views.js';
import { IViewsService } from '../../../../workbench/services/views/common/viewsService.js';
import { IsSessionsWindowContext } from '../../../../workbench/common/contextkeys.js';
import { CHAT_CATEGORY } from '../../../../workbench/contrib/chat/browser/actions/chatActions.js';
import { IAgentSessionsService } from '../../../../workbench/contrib/chat/browser/agentSessions/agentSessionsService.js';
import { AgentSessionProviders } from '../../../../workbench/contrib/chat/browser/agentSessions/agentSessions.js';
import { ChatContextKeys } from '../../../../workbench/contrib/chat/common/actions/chatContextKeys.js';
@@ -58,7 +61,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';
import { IGitRepository, IGitService } from '../../../../workbench/contrib/git/common/gitService.js';
const $ = dom.$;
@@ -77,6 +80,17 @@ export const enum ChangesViewMode {
const changesViewModeContextKey = new RawContextKey<ChangesViewMode>('changesViewMode', ChangesViewMode.List);
// --- Versions Mode
const enum ChangesVersionMode {
AllChanges = 'allChanges',
LastTurn = 'lastTurn',
Uncommitted = 'uncommitted'
}
const changesVersionModeContextKey = new RawContextKey<ChangesVersionMode>('changesVersionMode', ChangesVersionMode.AllChanges);
const hasUncommittedChangesContextKey = new RawContextKey<boolean>('hasUncommittedChanges', false);
// --- List Item
type ChangeType = 'added' | 'modified' | 'deleted';
@@ -229,11 +243,24 @@ export class ChangesViewPane extends ViewPane {
this.storageService.store('changesView.viewMode', mode, StorageScope.WORKSPACE, StorageTarget.USER);
}
// Version mode (all changes, last turn, uncommitted)
private readonly versionModeObs = observableValue<ChangesVersionMode>(this, ChangesVersionMode.AllChanges);
private readonly versionModeContextKey: IContextKey<ChangesVersionMode>;
setVersionMode(mode: ChangesVersionMode): void {
if (this.versionModeObs.get() === mode) {
return;
}
this.versionModeObs.set(mode, undefined);
this.versionModeContextKey.set(mode);
}
// Track the active session used by this view
private readonly activeSession: IObservableWithChange<IActiveSession | undefined>;
private readonly activeSessionFileCountObs: IObservableWithChange<number>;
private readonly activeSessionHasChangesObs: IObservableWithChange<boolean>;
private readonly activeSessionRepositoryChangesObs: IObservableWithChange<IChangesFileItem[] | undefined>;
private readonly activeSessionRepositoryObs: IObservableWithChange<IObservable<{ value?: IGitRepository }> | undefined>;
get activeSessionHasChanges(): IObservable<boolean> {
return this.activeSessionHasChangesObs;
@@ -272,6 +299,10 @@ export class ChangesViewPane extends ViewPane {
this.viewModeContextKey = changesViewModeContextKey.bindTo(contextKeyService);
this.viewModeContextKey.set(initialMode);
// Version mode
this.versionModeContextKey = changesVersionModeContextKey.bindTo(contextKeyService);
this.versionModeContextKey.set(ChangesVersionMode.AllChanges);
// Track active session from sessions management service
this.activeSession = derivedOpts<IActiveSession | undefined>({
equalsFn: (a, b) => isEqual(a?.resource, b?.resource),
@@ -290,7 +321,7 @@ export class ChangesViewPane extends ViewPane {
}).recomputeInitiallyAndOnChange(this._store);
// Track active session repository changes
const repositoryObs = derived(reader => {
this.activeSessionRepositoryObs = derived(reader => {
const activeSessionWorktree = this.activeSession.read(reader)?.worktree;
if (!activeSessionWorktree) {
return undefined;
@@ -300,19 +331,22 @@ export class ChangesViewPane extends ViewPane {
});
this.activeSessionRepositoryChangesObs = derived(reader => {
const repository = repositoryObs.read(reader)?.read(reader);
const repository = this.activeSessionRepositoryObs.read(reader)?.read(reader);
if (!repository) {
return undefined;
}
const state = repository.value?.state.read(reader);
const headCommit = state?.HEAD?.commit;
return (state?.workingTreeChanges ?? []).map(change => {
const isDeletion = change.modifiedUri === undefined;
const isAddition = change.originalUri === undefined;
const fileUri = change.modifiedUri ?? change.uri;
return {
type: 'file',
uri: change.modifiedUri ?? change.uri,
originalUri: change.originalUri,
uri: fileUri,
originalUri: isDeletion || !headCommit ? change.originalUri
: fileUri.with({ scheme: 'git', query: JSON.stringify({ path: fileUri.fsPath, ref: headCommit }) }),
state: ModifiedFileEntryState.Accepted,
isDeletion,
changeType: isDeletion ? 'deleted' : isAddition ? 'added' : 'modified',
@@ -558,15 +592,65 @@ export class ChangesViewPane extends ViewPane {
});
});
// Create observable for last turn changes using diffBetweenWithStats
// Reactively computes the diff between HEAD^ and HEAD. Memoize the diff observable so
// that we only recompute it when the HEAD commit id actually changes.
const headCommitObs = derived(reader => {
const repository = this.activeSessionRepositoryObs.read(reader)?.read(reader)?.value;
return repository?.state.read(reader)?.HEAD?.commit;
});
const lastTurnChangesObs = derived(reader => {
const repository = this.activeSessionRepositoryObs.read(reader)?.read(reader)?.value;
const headCommit = headCommitObs.read(reader);
if (!repository || !headCommit) {
return undefined;
}
return observableFromPromise(repository.diffBetweenWithStats(`${headCommit}^`, headCommit));
});
// Combine both entry sources for display
const combinedEntriesObs = derived(reader => {
const headCommit = headCommitObs.read(reader);
const editEntries = editSessionEntriesObs.read(reader);
const sessionFiles = sessionFilesObs.read(reader);
const repositoryFiles = this.activeSessionRepositoryChangesObs.read(reader) ?? [];
const versionMode = this.versionModeObs.read(reader);
let sourceEntries: IChangesFileItem[];
if (versionMode === ChangesVersionMode.Uncommitted) {
sourceEntries = repositoryFiles;
} else if (versionMode === ChangesVersionMode.LastTurn) {
const lastTurn = lastTurnChangesObs.read(reader);
const diffChanges = lastTurn?.read(reader).value ?? [];
const parentRef = headCommit ? `${headCommit}^` : '';
sourceEntries = diffChanges.map(change => {
const isDeletion = change.modifiedUri === undefined;
const isAddition = change.originalUri === undefined;
const fileUri = change.modifiedUri ?? change.uri;
const originalUri = isAddition ? change.originalUri
: headCommit ? fileUri.with({ scheme: 'git', query: JSON.stringify({ path: fileUri.fsPath, ref: parentRef }) })
: change.originalUri;
return {
type: 'file',
uri: fileUri,
originalUri,
state: ModifiedFileEntryState.Accepted,
isDeletion,
changeType: isDeletion ? 'deleted' : isAddition ? 'added' : 'modified',
linesAdded: change.insertions,
linesRemoved: change.deletions,
reviewCommentCount: 0,
} satisfies IChangesFileItem;
});
} else {
sourceEntries = [...editEntries, ...sessionFiles, ...repositoryFiles];
}
const resources = new Set();
const entries: IChangesFileItem[] = [];
for (const item of [...editEntries, ...sessionFiles, ...repositoryFiles]) {
for (const item of sourceEntries) {
if (!resources.has(item.uri.fsPath)) {
resources.add(item.uri.fsPath);
entries.push(item);
@@ -634,6 +718,18 @@ export class ChangesViewPane extends ViewPane {
return files > 0;
}));
// Also bind to the ViewPane's scoped context key service so the ViewTitle menu can evaluate it
this.renderDisposables.add(bindContextKey(ChatContextKeys.hasAgentSessionChanges, this.scopedContextKeyService, r => {
const { files } = topLevelStats.read(r);
return files > 0;
}));
// Track whether there are uncommitted (working tree) changes
this.renderDisposables.add(bindContextKey(hasUncommittedChangesContextKey, this.scopedContextKeyService, r => {
const repositoryFiles = this.activeSessionRepositoryChangesObs.read(r);
return (repositoryFiles?.length ?? 0) > 0;
}));
// Set context key for PR state from session metadata
const hasOpenPullRequestKey = scopedContextKeyService.createKey<boolean>('sessions.hasOpenPullRequest', false);
this.renderDisposables.add(autorun(reader => {
@@ -1163,3 +1259,84 @@ class SetChangesTreeViewModeAction extends ViewAction<ChangesViewPane> {
registerAction2(SetChangesListViewModeAction);
registerAction2(SetChangesTreeViewModeAction);
// --- Versions Submenu
MenuRegistry.appendMenuItem(MenuId.ViewTitle, {
submenu: MenuId.ChatEditingSessionChangesVersionsSubmenu,
title: localize2('versionsActions', 'Versions'),
icon: Codicon.versions,
group: 'navigation',
order: 9,
when: ContextKeyExpr.and(ContextKeyExpr.equals('view', CHANGES_VIEW_ID), IsSessionsWindowContext, ChatContextKeys.hasAgentSessionChanges),
});
class AllChangesAction extends Action2 {
constructor() {
super({
id: 'chatEditing.versionsAllChanges',
title: localize2('chatEditing.versionsAllChanges', 'All Changes'),
category: CHAT_CATEGORY,
toggled: changesVersionModeContextKey.isEqualTo(ChangesVersionMode.AllChanges),
menu: [{
id: MenuId.ChatEditingSessionChangesVersionsSubmenu,
group: '1_changes',
order: 1,
}],
});
}
override async run(accessor: ServicesAccessor): Promise<void> {
const viewsService = accessor.get(IViewsService);
const view = viewsService.getActiveViewWithId<ChangesViewPane>(CHANGES_VIEW_ID);
view?.setVersionMode(ChangesVersionMode.AllChanges);
}
}
registerAction2(AllChangesAction);
class LastTurnChangesAction extends Action2 {
constructor() {
super({
id: 'chatEditing.versionsLastTurnChanges',
title: localize2('chatEditing.versionsLastTurnChanges', "Last Turn's Changes"),
category: CHAT_CATEGORY,
toggled: changesVersionModeContextKey.isEqualTo(ChangesVersionMode.LastTurn),
menu: [{
id: MenuId.ChatEditingSessionChangesVersionsSubmenu,
group: '1_changes',
order: 2,
}],
});
}
override async run(accessor: ServicesAccessor): Promise<void> {
const viewsService = accessor.get(IViewsService);
const view = viewsService.getActiveViewWithId<ChangesViewPane>(CHANGES_VIEW_ID);
view?.setVersionMode(ChangesVersionMode.LastTurn);
}
}
registerAction2(LastTurnChangesAction);
class UncommittedChangesAction extends Action2 {
constructor() {
super({
id: 'chatEditing.versionsUncommittedChanges',
title: localize2('chatEditing.versionsUncommittedChanges', 'Uncommitted Changes'),
category: CHAT_CATEGORY,
toggled: changesVersionModeContextKey.isEqualTo(ChangesVersionMode.Uncommitted),
precondition: hasUncommittedChangesContextKey,
menu: [{
id: MenuId.ChatEditingSessionChangesVersionsSubmenu,
group: '2_uncommitted',
order: 1,
}],
});
}
override async run(accessor: ServicesAccessor): Promise<void> {
const viewsService = accessor.get(IViewsService);
const view = viewsService.getActiveViewWithId<ChangesViewPane>(CHANGES_VIEW_ID);
view?.setVersionMode(ChangesVersionMode.Uncommitted);
}
}
registerAction2(UncommittedChangesAction);

View File

@@ -8,9 +8,9 @@ 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, GitChange, IGitRepository } from '../../contrib/git/common/gitService.js';
import { IGitExtensionDelegate, IGitService, GitRef, GitRefQuery, GitRefType, GitRepositoryState, GitBranch, GitChange, GitDiffChange, 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';
import { ExtHostContext, ExtHostGitExtensionShape, GitDiffChangeDto, GitRefTypeDto, GitRepositoryStateDto, MainContext, MainThreadGitExtensionShape } from '../common/extHost.protocol.js';
function toGitRefType(type: GitRefTypeDto): GitRefType {
switch (type) {
@@ -21,6 +21,16 @@ function toGitRefType(type: GitRefTypeDto): GitRefType {
}
}
function toGitDiffChange(dto: GitDiffChangeDto): GitDiffChange {
return {
uri: URI.revive(dto.uri),
originalUri: dto.originalUri ? URI.revive(dto.originalUri) : undefined,
modifiedUri: dto.modifiedUri ? URI.revive(dto.modifiedUri) : undefined,
insertions: dto.insertions,
deletions: dto.deletions,
};
}
function toGitRepositoryState(dto: GitRepositoryStateDto | undefined): GitRepositoryState {
return {
HEAD: dto?.HEAD ? {
@@ -144,6 +154,16 @@ export class MainThreadGitExtensionService extends Disposable implements MainThr
} satisfies GitRef));
}
async diffBetweenWithStats(root: URI, ref1: string, ref2: string, path?: string): Promise<GitDiffChange[]> {
const handle = this._repositoryHandles.get(root);
if (handle === undefined) {
return [];
}
const result = await this._proxy.$diffBetweenWithStats(handle, ref1, ref2, path);
return result.map(toGitDiffChange);
}
async $onDidChangeRepository(handle: number): Promise<void> {
const repository = this._repositories.get(handle);
if (!repository) {

View File

@@ -3634,6 +3634,11 @@ export interface GitChangeDto {
readonly modifiedUri: UriComponents | undefined;
}
export interface GitDiffChangeDto extends GitChangeDto {
readonly insertions: number;
readonly deletions: number;
}
export interface GitRepositoryStateDto {
readonly HEAD?: GitBranchDto;
readonly mergeChanges: readonly GitChangeDto[];
@@ -3663,6 +3668,7 @@ export interface ExtHostGitExtensionShape {
$openRepository(root: UriComponents): Promise<{ handle: number; rootUri: UriComponents; state: GitRepositoryStateDto } | undefined>;
$getRefs(handle: number, query: GitRefQueryDto, token?: CancellationToken): Promise<GitRefDto[]>;
$getRepositoryState(handle: number): Promise<GitRepositoryStateDto | undefined>;
$diffBetweenWithStats(handle: number, ref1: string, ref2: string, path?: string): Promise<GitDiffChangeDto[]>;
}
// --- proxy identifiers

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, GitChangeDto, GitRefDto, GitRefQueryDto, GitRefTypeDto, GitRepositoryStateDto, GitUpstreamRefDto, MainContext, MainThreadGitExtensionShape } from './extHost.protocol.js';
import { ExtHostGitExtensionShape, GitBranchDto, GitChangeDto, GitDiffChangeDto, GitRefDto, GitRefQueryDto, GitRefTypeDto, GitRepositoryStateDto, GitUpstreamRefDto, MainContext, MainThreadGitExtensionShape } from './extHost.protocol.js';
import { ResourceMap } from '../../../base/common/map.js';
const GIT_EXTENSION_ID = 'vscode.git';
@@ -91,12 +91,18 @@ function toGitRepositoryStateDto(state: RepositoryState): GitRepositoryStateDto
};
}
interface DiffChange extends Change {
readonly insertions: number;
readonly deletions: number;
}
interface Repository {
readonly rootUri: vscode.Uri;
readonly state: RepositoryState;
status(): Promise<void>;
getRefs(query: GitRefQuery, token?: vscode.CancellationToken): Promise<GitRef[]>;
diffBetweenWithStats(ref1: string, ref2: string, path?: string): Promise<DiffChange[]>;
}
interface Change {
@@ -279,6 +285,24 @@ export class ExtHostGitExtensionService extends Disposable implements IExtHostGi
return toGitRepositoryStateDto(repository.state);
}
async $diffBetweenWithStats(handle: number, ref1: string, ref2: string, path?: string): Promise<GitDiffChangeDto[]> {
const repository = this._repositories.get(handle);
if (!repository) {
return [];
}
try {
const changes = await repository.diffBetweenWithStats(ref1, ref2, path);
return changes.map(c => ({
...toGitChangeDto(c),
insertions: c.insertions,
deletions: c.deletions,
}));
} catch {
return [];
}
}
private async _ensureGitApi(): Promise<GitExtensionAPI | undefined> {
if (this._gitApi) {
return this._gitApi;

View File

@@ -7,7 +7,7 @@ import { CancellationToken } from '../../../../base/common/cancellation.js';
import { BugIndicatingError } from '../../../../base/common/errors.js';
import { Disposable, IDisposable, toDisposable } from '../../../../base/common/lifecycle.js';
import { URI } from '../../../../base/common/uri.js';
import { IGitService, IGitExtensionDelegate, GitRef, GitRefQuery, IGitRepository, GitRepositoryState } from '../common/gitService.js';
import { IGitService, IGitExtensionDelegate, GitRef, GitRefQuery, IGitRepository, GitRepositoryState, GitDiffChange } from '../common/gitService.js';
import { ISettableObservable, observableValueOpts } from '../../../../base/common/observable.js';
import { structuralEquals } from '../../../../base/common/equals.js';
import { AutoOpenBarrier } from '../../../../base/common/async.js';
@@ -81,4 +81,8 @@ export class GitRepository extends Disposable implements IGitRepository {
async getRefs(query: GitRefQuery, token?: CancellationToken): Promise<GitRef[]> {
return this.delegate.getRefs(this.rootUri, query, token);
}
async diffBetweenWithStats(ref1: string, ref2: string, path?: string): Promise<GitDiffChange[]> {
return this.delegate.diffBetweenWithStats(this.rootUri, ref1, ref2, path);
}
}

View File

@@ -35,6 +35,11 @@ export interface GitChange {
readonly modifiedUri: URI | undefined;
}
export interface GitDiffChange extends GitChange {
readonly insertions: number;
readonly deletions: number;
}
export interface GitRepositoryState {
readonly HEAD?: GitBranch;
readonly mergeChanges: readonly GitChange[];
@@ -62,6 +67,7 @@ export interface IGitRepository {
updateState(state: GitRepositoryState): void;
getRefs(query: GitRefQuery, token?: CancellationToken): Promise<GitRef[]>;
diffBetweenWithStats(ref1: string, ref2: string, path?: string): Promise<GitDiffChange[]>;
}
export interface IGitExtensionDelegate {
@@ -69,6 +75,7 @@ export interface IGitExtensionDelegate {
openRepository(uri: URI): Promise<IGitRepository | undefined>;
getRefs(root: URI, query?: GitRefQuery, token?: CancellationToken): Promise<GitRef[]>;
diffBetweenWithStats(root: URI, ref1: string, ref2: string, path?: string): Promise<GitDiffChange[]>;
}
export const IGitService = createDecorator<IGitService>('gitService');