diff --git a/src/vs/platform/actions/common/actions.ts b/src/vs/platform/actions/common/actions.ts index 497ec1a43ab..5c1cf404a4c 100644 --- a/src/vs/platform/actions/common/actions.ts +++ b/src/vs/platform/actions/common/actions.ts @@ -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'); diff --git a/src/vs/sessions/contrib/changes/browser/changesView.ts b/src/vs/sessions/contrib/changes/browser/changesView.ts index 9cf004af32a..efd27ab3a77 100644 --- a/src/vs/sessions/contrib/changes/browser/changesView.ts +++ b/src/vs/sessions/contrib/changes/browser/changesView.ts @@ -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.List); +// --- Versions Mode + +const enum ChangesVersionMode { + AllChanges = 'allChanges', + LastTurn = 'lastTurn', + Uncommitted = 'uncommitted' +} + +const changesVersionModeContextKey = new RawContextKey('changesVersionMode', ChangesVersionMode.AllChanges); +const hasUncommittedChangesContextKey = new RawContextKey('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(this, ChangesVersionMode.AllChanges); + private readonly versionModeContextKey: IContextKey; + + 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; private readonly activeSessionFileCountObs: IObservableWithChange; private readonly activeSessionHasChangesObs: IObservableWithChange; private readonly activeSessionRepositoryChangesObs: IObservableWithChange; + private readonly activeSessionRepositoryObs: IObservableWithChange | undefined>; get activeSessionHasChanges(): IObservable { 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({ 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('sessions.hasOpenPullRequest', false); this.renderDisposables.add(autorun(reader => { @@ -1163,3 +1259,84 @@ class SetChangesTreeViewModeAction extends ViewAction { 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 { + const viewsService = accessor.get(IViewsService); + const view = viewsService.getActiveViewWithId(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 { + const viewsService = accessor.get(IViewsService); + const view = viewsService.getActiveViewWithId(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 { + const viewsService = accessor.get(IViewsService); + const view = viewsService.getActiveViewWithId(CHANGES_VIEW_ID); + view?.setVersionMode(ChangesVersionMode.Uncommitted); + } +} +registerAction2(UncommittedChangesAction); diff --git a/src/vs/workbench/api/browser/mainThreadGitExtensionService.ts b/src/vs/workbench/api/browser/mainThreadGitExtensionService.ts index ad414190cd6..40005d672df 100644 --- a/src/vs/workbench/api/browser/mainThreadGitExtensionService.ts +++ b/src/vs/workbench/api/browser/mainThreadGitExtensionService.ts @@ -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 { + 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 { const repository = this._repositories.get(handle); if (!repository) { diff --git a/src/vs/workbench/api/common/extHost.protocol.ts b/src/vs/workbench/api/common/extHost.protocol.ts index 1f3304a5d75..eb65735e58a 100644 --- a/src/vs/workbench/api/common/extHost.protocol.ts +++ b/src/vs/workbench/api/common/extHost.protocol.ts @@ -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; $getRepositoryState(handle: number): Promise; + $diffBetweenWithStats(handle: number, ref1: string, ref2: string, path?: string): Promise; } // --- proxy identifiers diff --git a/src/vs/workbench/api/common/extHostGitExtensionService.ts b/src/vs/workbench/api/common/extHostGitExtensionService.ts index 6c2960f0383..93f8c7ee7da 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, 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; getRefs(query: GitRefQuery, token?: vscode.CancellationToken): Promise; + diffBetweenWithStats(ref1: string, ref2: string, path?: string): Promise; } 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 { + 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 { if (this._gitApi) { return this._gitApi; diff --git a/src/vs/workbench/contrib/git/browser/gitService.ts b/src/vs/workbench/contrib/git/browser/gitService.ts index 2406d972a3b..ca34f506015 100644 --- a/src/vs/workbench/contrib/git/browser/gitService.ts +++ b/src/vs/workbench/contrib/git/browser/gitService.ts @@ -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 { return this.delegate.getRefs(this.rootUri, query, token); } + + async diffBetweenWithStats(ref1: string, ref2: string, path?: string): Promise { + return this.delegate.diffBetweenWithStats(this.rootUri, ref1, ref2, path); + } } diff --git a/src/vs/workbench/contrib/git/common/gitService.ts b/src/vs/workbench/contrib/git/common/gitService.ts index 2217f2200db..5f1b6284838 100644 --- a/src/vs/workbench/contrib/git/common/gitService.ts +++ b/src/vs/workbench/contrib/git/common/gitService.ts @@ -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; + diffBetweenWithStats(ref1: string, ref2: string, path?: string): Promise; } export interface IGitExtensionDelegate { @@ -69,6 +75,7 @@ export interface IGitExtensionDelegate { openRepository(uri: URI): Promise; getRefs(root: URI, query?: GitRefQuery, token?: CancellationToken): Promise; + diffBetweenWithStats(root: URI, ref1: string, ref2: string, path?: string): Promise; } export const IGitService = createDecorator('gitService');