diff --git a/src/vs/sessions/contrib/changes/browser/changesView.ts b/src/vs/sessions/contrib/changes/browser/changesView.ts index a53c9e9077a..bf4a115d1ea 100644 --- a/src/vs/sessions/contrib/changes/browser/changesView.ts +++ b/src/vs/sessions/contrib/changes/browser/changesView.ts @@ -10,6 +10,7 @@ import { IListVirtualDelegate } from '../../../../base/browser/ui/list/list.js'; import { ICompressedTreeNode } from '../../../../base/browser/ui/tree/compressedObjectTreeModel.js'; import { ICompressibleTreeRenderer } from '../../../../base/browser/ui/tree/objectTree.js'; import { IObjectTreeElement, ITreeNode } from '../../../../base/browser/ui/tree/tree.js'; +import { ActionRunner, IAction } from '../../../../base/common/actions.js'; import { Codicon } from '../../../../base/common/codicons.js'; import { Iterable } from '../../../../base/common/iterator.js'; import { Disposable, DisposableStore } from '../../../../base/common/lifecycle.js'; @@ -222,6 +223,7 @@ function buildTreeChildren(items: IChangesFileItem[]): IObjectTreeElement; readonly activeSessionResourceObs: IObservable; + readonly activeSessionIsolationModeObs: IObservable; readonly activeSessionRepositoryObs: IObservableWithChange; readonly activeSessionChangesObs: IObservable; @@ -260,6 +262,14 @@ class ChangesViewModel extends Disposable { return activeSession?.resource; }); + // Active session isolation mode + this.activeSessionIsolationModeObs = derived(reader => { + const activeSession = this.sessionManagementService.activeSession.read(reader); + return activeSession?.workspace.read(reader)?.repositories[0]?.workingDirectory === undefined + ? IsolationMode.Workspace + : IsolationMode.Worktree; + }); + // Active session changes this.activeSessionChangesObs = derivedOpts({ equalsFn: arrayEqualsC() @@ -748,10 +758,7 @@ export class ChangesViewPane extends ViewPane { })); this.renderDisposables.add(bindContextKey(isolationModeContextKey, this.scopedContextKeyService, reader => { - const activeSession = this.sessionManagementService.activeSession.read(reader); - return activeSession?.workspace.read(reader)?.repositories[0].workingDirectory === undefined - ? IsolationMode.Workspace - : IsolationMode.Worktree; + return this.viewModel.activeSessionIsolationModeObs.read(reader); })); this.renderDisposables.add(bindContextKey(isMergeBaseBranchProtectedContextKey, this.scopedContextKeyService, reader => { @@ -918,12 +925,17 @@ export class ChangesViewPane extends ViewPane { // Create the tree if (!this.tree && this.listContainer) { const resourceLabels = this._register(this.instantiationService.createInstance(ResourceLabels, { onDidChangeVisibility: this.onDidChangeBodyVisibility })); + const actionRunner = this.renderDisposables.add(new ChangesViewActionRunner( + () => this.viewModel.activeSessionResourceObs.get(), + () => this.getSessionRefs(), + () => this.getTreeSelection(), + )); this.tree = this.instantiationService.createInstance( WorkbenchCompressibleObjectTree, 'ChangesViewTree', this.listContainer, new ChangesTreeDelegate(), - [this.instantiationService.createInstance(ChangesTreeRenderer, resourceLabels, MenuId.ChatEditingSessionChangesToolbar)], + [this.instantiationService.createInstance(ChangesTreeRenderer, resourceLabels, MenuId.ChatEditingSessionChangeToolbar, actionRunner)], { alwaysConsumeMouseWheel: false, accessibilityProvider: { @@ -1112,6 +1124,33 @@ export class ChangesViewPane extends ViewPane { this.splitView.layout(availableHeight); } + private getTreeSelection(): IChangesFileItem[] { + const selection = this.tree?.getSelection() ?? []; + return selection.filter(item => !!item && isChangesFileItem(item)); + } + + private getSessionRefs(): [string, string] { + const activeSession = this.sessionManagementService.activeSession.get(); + const activeSessionIsolationMode = this.viewModel.activeSessionIsolationModeObs.get(); + const activeSessionRepositoryState = this.viewModel.activeSessionRepositoryObs.get()?.state.get(); + + let originalRef: string, modifiedRef: string; + if (activeSessionIsolationMode === IsolationMode.Worktree) { + // Worktree + originalRef = activeSession?.workspace.get()?.repositories[0].baseBranchName ?? ''; + modifiedRef = activeSessionRepositoryState?.HEAD?.name ?? ''; + } else { + // Workspace + const upstream = activeSessionRepositoryState?.HEAD?.upstream; + originalRef = upstream + ? `${upstream.remote}/${upstream.name}` + : activeSessionRepositoryState?.HEAD?.name ?? ''; + modifiedRef = activeSessionRepositoryState?.HEAD?.name ?? ''; + } + + return [originalRef, modifiedRef]; + } + protected override layoutBody(height: number, width: number): void { super.layoutBody(height, width); this.currentBodyHeight = height; @@ -1154,6 +1193,32 @@ export class ChangesViewPaneContainer extends ViewPaneContainer { } } +// --- Action Runner + +class ChangesViewActionRunner extends ActionRunner { + + constructor( + private readonly getSessionResource: () => URI | undefined, + private readonly getSessionRefs: () => [originalRef: string, modifiedRef: string], + private readonly getSelectedFileItems: () => IChangesFileItem[] + ) { + super(); + } + + protected override async runAction(action: IAction, context: URI): Promise { + if (!(action instanceof MenuItemAction)) { + return super.runAction(action, context); + } + + const sessionResource = this.getSessionResource(); + const [originalRef, modifiedRef] = this.getSessionRefs(); + const selection = this.getSelectedFileItems(); + const contextIsSelected = selection.some(s => isEqual(s.uri, context)); + const actualContext = contextIsSelected ? selection.map(s => s.uri) : [context]; + await action.run(sessionResource, originalRef, modifiedRef, ...actualContext); + } +} + // --- Tree Delegate & Renderer class ChangesTreeDelegate implements IListVirtualDelegate { @@ -1187,6 +1252,7 @@ class ChangesTreeRenderer implements ICompressibleTreeRenderer { + const activeSession = sessionManagementService.activeSession.get(); + const activeSessionIsolationMode = this.viewModel.activeSessionIsolationModeObs.get(); + const activeSessionRepositoryState = this.viewModel.activeSessionRepositoryObs.get()?.state.get(); + const activeSessionRepository = activeSession?.workspace.get()?.repositories[0]; + + const baseBranchName = activeSessionIsolationMode === IsolationMode.Worktree + ? activeSessionRepository?.baseBranchName ?? '' + : activeSessionRepositoryState?.HEAD?.upstream + ? `${activeSessionRepositoryState.HEAD.upstream.remote}/${activeSessionRepositoryState.HEAD.upstream.name}` + : activeSessionRepositoryState?.HEAD?.name ?? ''; + + const branchName = activeSessionRepository?.detail + ?? activeSessionRepositoryState?.HEAD?.name ?? ''; + + const allChangesDescription = baseBranchName && branchName + ? `${branchName} → ${baseBranchName}` + : branchName ?? localize('chatEditing.versionsAllChanges.description', 'Show all changes made in this session'); + return [ { ...action, id: 'chatEditing.versionsAllChanges', label: localize('chatEditing.versionsAllChanges', 'All Changes'), - description: localize('chatEditing.versionsAllChanges.description', 'Show all changes made in this session'), + description: allChangesDescription, checked: viewModel.versionModeObs.get() === ChangesVersionMode.AllChanges, run: async () => { viewModel.setVersionMode(ChangesVersionMode.AllChanges); diff --git a/src/vs/sessions/contrib/changes/browser/media/changesView.css b/src/vs/sessions/contrib/changes/browser/media/changesView.css index 4eec1c1ce2b..2b22a4cc90c 100644 --- a/src/vs/sessions/contrib/changes/browser/media/changesView.css +++ b/src/vs/sessions/contrib/changes/browser/media/changesView.css @@ -250,6 +250,13 @@ display: inherit; } +/* Hide diff stats on hover/focus/select when toolbar is visible */ +.changes-view-body .monaco-list-row:hover .working-set-line-counts, +.changes-view-body .monaco-list-row.focused .working-set-line-counts, +.changes-view-body .monaco-list-row.selected .working-set-line-counts { + display: none; +} + /* Decoration badges (A/M/D) */ .changes-view-body .chat-editing-session-list .changes-decoration-badge { display: inline-flex; diff --git a/src/vs/sessions/contrib/chat/test/browser/sessionsConfigurationService.test.ts b/src/vs/sessions/contrib/chat/test/browser/sessionsConfigurationService.test.ts index 48add616d63..f771e533cf6 100644 --- a/src/vs/sessions/contrib/chat/test/browser/sessionsConfigurationService.test.ts +++ b/src/vs/sessions/contrib/chat/test/browser/sessionsConfigurationService.test.ts @@ -31,6 +31,7 @@ function makeSession(opts: { repository?: URI; worktree?: URI } = {}): ISessionD uri: opts.repository, workingDirectory: opts.worktree, detail: undefined, + baseBranchName: undefined, baseBranchProtected: undefined, }], requiresWorkspaceTrust: false, diff --git a/src/vs/sessions/contrib/copilotChatSessions/browser/copilotChatSessionsProvider.ts b/src/vs/sessions/contrib/copilotChatSessions/browser/copilotChatSessionsProvider.ts index 574ee1d8749..b0b6ab7ad21 100644 --- a/src/vs/sessions/contrib/copilotChatSessions/browser/copilotChatSessionsProvider.ts +++ b/src/vs/sessions/contrib/copilotChatSessions/browser/copilotChatSessionsProvider.ts @@ -740,12 +740,13 @@ class AgentSessionAdapter implements IChatData { } private _buildWorkspace(session: IAgentSession): ISessionWorkspace | undefined { - const [repoUri, worktreeUri, branchName, baseBranchProtected] = this._extractRepositoryFromMetadata(session); + const [repoUri, worktreeUri, branchName, baseBranchName, baseBranchProtected] = this._extractRepositoryFromMetadata(session); const repository: ISessionRepository = { uri: repoUri ?? URI.parse('unknown://'), workingDirectory: worktreeUri, detail: branchName, + baseBranchName, baseBranchProtected, }; @@ -761,10 +762,10 @@ class AgentSessionAdapter implements IChatData { * Extract repository/worktree information from session metadata. * Mirrors the logic in sessionsManagementService.getRepositoryFromMetadata(). */ - private _extractRepositoryFromMetadata(session: IAgentSession): [URI | undefined, URI | undefined, string | undefined, boolean | undefined] { + private _extractRepositoryFromMetadata(session: IAgentSession): [URI | undefined, URI | undefined, string | undefined, string | undefined, boolean | undefined] { const metadata = session.metadata; if (!metadata) { - return [undefined, undefined, undefined, undefined]; + return [undefined, undefined, undefined, undefined, undefined]; } if (session.providerType === AgentSessionProviders.Cloud) { @@ -774,13 +775,13 @@ class AgentSessionAdapter implements IChatData { authority: 'github', path: `/${metadata.owner}/${metadata.name}/${encodeURIComponent(branch)}` }); - return [repositoryUri, undefined, undefined, undefined]; + return [repositoryUri, undefined, undefined, undefined, undefined]; } // Background/CLI sessions: check workingDirectoryPath first const workingDirectoryPath = metadata?.workingDirectoryPath as string | undefined; if (workingDirectoryPath) { - return [URI.file(workingDirectoryPath), undefined, undefined, undefined]; + return [URI.file(workingDirectoryPath), undefined, undefined, undefined, undefined]; } // Fall back to repositoryPath + worktreePath @@ -791,12 +792,14 @@ class AgentSessionAdapter implements IChatData { const worktreePathUri = typeof worktreePath === 'string' ? URI.file(worktreePath) : undefined; const worktreeBranchName = metadata?.branchName as string | undefined; + const worktreeBaseBranchName = metadata?.baseBranchName as string | undefined; const worktreeBaseBranchProtected = metadata?.baseBranchProtected as boolean | undefined; return [ URI.isUri(repositoryPathUri) ? repositoryPathUri : undefined, URI.isUri(worktreePathUri) ? worktreePathUri : undefined, worktreeBranchName, + worktreeBaseBranchName, worktreeBaseBranchProtected, ]; } @@ -1102,7 +1105,7 @@ export class CopilotChatSessionsProvider extends Disposable implements ISessions return { label: this._labelFromUri(uri), icon: this._iconFromUri(uri), - repositories: [{ uri, workingDirectory: undefined, detail: undefined, baseBranchProtected: undefined }], + repositories: [{ uri, workingDirectory: undefined, detail: undefined, baseBranchName: undefined, baseBranchProtected: undefined }], requiresWorkspaceTrust: true }; } @@ -1116,7 +1119,7 @@ export class CopilotChatSessionsProvider extends Disposable implements ISessions return { label: this._labelFromUri(uri), icon: this._iconFromUri(uri), - repositories: [{ uri, workingDirectory: undefined, detail: undefined, baseBranchProtected: undefined }], + repositories: [{ uri, workingDirectory: undefined, detail: undefined, baseBranchName: undefined, baseBranchProtected: undefined }], requiresWorkspaceTrust: false, }; } @@ -1127,7 +1130,7 @@ export class CopilotChatSessionsProvider extends Disposable implements ISessions return { label: this._labelFromUri(repositoryUri), icon: this._iconFromUri(repositoryUri), - repositories: [{ uri: repositoryUri, workingDirectory: undefined, detail: undefined, baseBranchProtected: undefined }], + repositories: [{ uri: repositoryUri, workingDirectory: undefined, detail: undefined, baseBranchName: undefined, baseBranchProtected: undefined }], requiresWorkspaceTrust: repositoryUri.scheme !== GITHUB_REMOTE_FILE_SCHEME }; } diff --git a/src/vs/sessions/contrib/remoteAgentHost/browser/remoteAgentHostSessionsProvider.ts b/src/vs/sessions/contrib/remoteAgentHost/browser/remoteAgentHostSessionsProvider.ts index 6bd0250b478..0525bb3ae34 100644 --- a/src/vs/sessions/contrib/remoteAgentHost/browser/remoteAgentHostSessionsProvider.ts +++ b/src/vs/sessions/contrib/remoteAgentHost/browser/remoteAgentHostSessionsProvider.ts @@ -190,7 +190,7 @@ export class RemoteAgentHostSessionsProvider extends Disposable implements ISess return { label: folderName, icon: Codicon.remote, - repositories: [{ uri, workingDirectory: undefined, detail: providerLabel, baseBranchProtected: undefined }], + repositories: [{ uri, workingDirectory: undefined, detail: providerLabel, baseBranchName: undefined, baseBranchProtected: undefined }], requiresWorkspaceTrust: false, }; } @@ -200,7 +200,7 @@ export class RemoteAgentHostSessionsProvider extends Disposable implements ISess return { label: folderName, icon: Codicon.remote, - repositories: [{ uri, workingDirectory: undefined, detail: this.label, baseBranchProtected: undefined }], + repositories: [{ uri, workingDirectory: undefined, detail: this.label, baseBranchName: undefined, baseBranchProtected: undefined }], requiresWorkspaceTrust: true, }; } diff --git a/src/vs/sessions/contrib/remoteAgentHost/test/browser/remoteAgentHostSessionsProvider.test.ts b/src/vs/sessions/contrib/remoteAgentHost/test/browser/remoteAgentHostSessionsProvider.test.ts index 24258030422..45da7d4b967 100644 --- a/src/vs/sessions/contrib/remoteAgentHost/test/browser/remoteAgentHostSessionsProvider.test.ts +++ b/src/vs/sessions/contrib/remoteAgentHost/test/browser/remoteAgentHostSessionsProvider.test.ts @@ -289,7 +289,7 @@ suite('RemoteAgentHostSessionsProvider', () => { const workspace = { label: 'my-project', icon: { id: 'remote' }, - repositories: [{ uri: URI.parse('vscode-agent-host://auth/home/user/project'), workingDirectory: undefined, detail: undefined, baseBranchProtected: undefined }], + repositories: [{ uri: URI.parse('vscode-agent-host://auth/home/user/project'), workingDirectory: undefined, detail: undefined, baseBranchName: undefined, baseBranchProtected: undefined }], requiresWorkspaceTrust: false, }; @@ -443,7 +443,7 @@ suite('RemoteAgentHostSessionsProvider', () => { const workspace = { label: 'project', icon: { id: 'remote' }, - repositories: [{ uri: URI.parse('vscode-agent-host://auth/home/user/project'), workingDirectory: undefined, detail: undefined, baseBranchProtected: undefined }], + repositories: [{ uri: URI.parse('vscode-agent-host://auth/home/user/project'), workingDirectory: undefined, detail: undefined, baseBranchName: undefined, baseBranchProtected: undefined }], requiresWorkspaceTrust: false, }; const session = provider.createNewSession(workspace); diff --git a/src/vs/sessions/contrib/sessions/common/sessionData.ts b/src/vs/sessions/contrib/sessions/common/sessionData.ts index 81c15544542..0edd650d5b0 100644 --- a/src/vs/sessions/contrib/sessions/common/sessionData.ts +++ b/src/vs/sessions/contrib/sessions/common/sessionData.ts @@ -36,6 +36,8 @@ export interface ISessionRepository { readonly workingDirectory: URI | undefined; /** Provider-chosen display detail (e.g., branch name, host name). */ readonly detail: string | undefined; + /** Name of the base branch. */ + readonly baseBranchName: string | undefined; /** Whether the base branch is protected (drives PR vs merge workflow). */ readonly baseBranchProtected: boolean | undefined; } diff --git a/src/vs/sessions/contrib/terminal/test/browser/sessionsTerminalContribution.test.ts b/src/vs/sessions/contrib/terminal/test/browser/sessionsTerminalContribution.test.ts index 04d87184b26..1f77b5ce80a 100644 --- a/src/vs/sessions/contrib/terminal/test/browser/sessionsTerminalContribution.test.ts +++ b/src/vs/sessions/contrib/terminal/test/browser/sessionsTerminalContribution.test.ts @@ -51,6 +51,7 @@ function makeAgentSession(opts: { uri: opts.repository ?? opts.worktree!, workingDirectory: opts.worktree, detail: undefined, + baseBranchName: undefined, baseBranchProtected: undefined, } : undefined; const chat: IChatData = { @@ -83,6 +84,7 @@ function makeNonAgentSession(opts: { repository?: URI; worktree?: URI; providerT uri: opts.repository ?? opts.worktree!, workingDirectory: opts.worktree, detail: undefined, + baseBranchName: undefined, baseBranchProtected: undefined, } : undefined; const chat: IChatData = {