Merge pull request #299090 from microsoft/osortega/interior-flea

Session window: merge to local
This commit is contained in:
Osvaldo Ortega
2026-03-03 18:42:59 -08:00
committed by GitHub
9 changed files with 127 additions and 103 deletions

View File

@@ -1060,6 +1060,22 @@ export class CommandCenter {
await repo.pull();
}
@command('_git.revParseAbbrevRef')
async revParseAbbrevRef(repositoryPath: string): Promise<string> {
const dotGit = await this.git.getRepositoryDotGit(repositoryPath);
const repo = new GitRepository(this.git, repositoryPath, undefined, dotGit, this.logger);
const result = await repo.exec(['rev-parse', '--abbrev-ref', 'HEAD']);
return result.stdout.trim();
}
@command('_git.mergeBranch')
async mergeBranch(repositoryPath: string, branch: string): Promise<string> {
const dotGit = await this.git.getRepositoryDotGit(repositoryPath);
const repo = new GitRepository(this.git, repositoryPath, undefined, dotGit, this.logger);
const result = await repo.exec(['merge', branch, '--no-edit']);
return result.stdout.trim();
}
@command('git.init')
async init(skipFolderPrompt = false): Promise<void> {
let repositoryPath: string | undefined = undefined;
@@ -5595,15 +5611,14 @@ export class CommandCenter {
options.modal = false;
break;
default: {
const hint = (err.stderr || err.message || String(err))
const hintLines = (err.stderr || err.stdout || err.message || String(err))
.replace(/^error: /mi, '')
.replace(/^> husky.*$/mi, '')
.split(/[\r\n]/)
.filter((line: string) => !!line)
[0];
.filter((line: string) => !!line);
message = hint
? l10n.t('Git: {0}', hint)
message = hintLines.length > 0
? l10n.t('Git: {0}', err.stdout ? hintLines[hintLines.length - 1] : hintLines[0])
: l10n.t('Git error');
break;

View File

@@ -183,7 +183,7 @@
"when": "github.hasGitHubRepo && timelineItem =~ /git:file:commit\\b/"
}
],
"chat/input/editing/sessionToolbar": [
"chat/input/editing/sessionApplyActions": [
{
"command": "github.createPullRequest",
"group": "navigation",

View File

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

View File

@@ -9,40 +9,29 @@ import { Schemas } from '../../../../base/common/network.js';
import { autorun } from '../../../../base/common/observable.js';
import { Codicon } from '../../../../base/common/codicons.js';
import { localize, localize2 } from '../../../../nls.js';
import { Action2, MenuId, registerAction2 } from '../../../../platform/actions/common/actions.js';
import { Action2, MenuId, MenuRegistry, registerAction2 } from '../../../../platform/actions/common/actions.js';
import { ICommandService } from '../../../../platform/commands/common/commands.js';
import { ContextKeyExpr, IContextKeyService, RawContextKey } from '../../../../platform/contextkey/common/contextkey.js';
import { ServicesAccessor } from '../../../../platform/instantiation/common/instantiation.js';
import { IFileService } from '../../../../platform/files/common/files.js';
import { INotificationService, Severity } from '../../../../platform/notification/common/notification.js';
import { IOpenerService } from '../../../../platform/opener/common/opener.js';
import { IProductService } from '../../../../platform/product/common/productService.js';
import { IWorkbenchContribution, registerWorkbenchContribution2, WorkbenchPhase } from '../../../../workbench/common/contributions.js';
import { IsSessionsWindowContext } from '../../../../workbench/common/contextkeys.js';
import { CHAT_CATEGORY } from '../../../../workbench/contrib/chat/browser/actions/chatActions.js';
import { ChatContextKeys } from '../../../../workbench/contrib/chat/common/actions/chatContextKeys.js';
import { IAgentSessionsService } from '../../../../workbench/contrib/chat/browser/agentSessions/agentSessionsService.js';
import { isIChatSessionFileChange2 } from '../../../../workbench/contrib/chat/common/chatSessionsService.js';
import { ISessionsManagementService } from '../../sessions/browser/sessionsManagementService.js';
import { IsSessionsWindowContext } from '../../../../workbench/common/contextkeys.js';
import { isEqualOrParent, joinPath, relativePath } from '../../../../base/common/resources.js';
import { ILogService } from '../../../../platform/log/common/log.js';
import { URI } from '../../../../base/common/uri.js';
/**
* Normalizes a URI to the `file` scheme so that path comparisons work
* even when the source URI uses a different scheme (e.g. `github-remote-file`).
*/
function toFileUri(uri: URI): URI {
return uri.scheme === 'file' ? uri : URI.file(uri.path);
}
const hasWorktreeAndRepositoryContextKey = new RawContextKey<boolean>('agentSessionHasWorktreeAndRepository', false, {
type: 'boolean',
description: localize('agentSessionHasWorktreeAndRepository', "True when the active agent session has both a worktree and a parent repository.")
});
class ApplyToParentRepoContribution extends Disposable implements IWorkbenchContribution {
class ApplyChangesToParentRepoContribution extends Disposable implements IWorkbenchContribution {
static readonly ID = 'sessions.contrib.applyToParentRepo';
static readonly ID = 'sessions.contrib.applyChangesToParentRepo';
constructor(
@IContextKeyService contextKeyService: IContextKeyService,
@@ -50,32 +39,38 @@ class ApplyToParentRepoContribution extends Disposable implements IWorkbenchCont
) {
super();
const contextKey = hasWorktreeAndRepositoryContextKey.bindTo(contextKeyService);
const worktreeAndRepoKey = hasWorktreeAndRepositoryContextKey.bindTo(contextKeyService);
this._register(autorun(reader => {
const activeSession = sessionManagementService.activeSession.read(reader);
const hasWorktreeAndRepo = !!activeSession?.worktree && !!activeSession?.repository;
contextKey.set(hasWorktreeAndRepo);
worktreeAndRepoKey.set(hasWorktreeAndRepo);
}));
}
}
class ApplyToParentRepoAction extends Action2 {
static readonly ID = 'chatEditing.applyToParentRepo';
class ApplyChangesToParentRepoAction extends Action2 {
static readonly ID = 'chatEditing.applyChangesToParentRepo';
constructor() {
super({
id: ApplyToParentRepoAction.ID,
title: localize2('applyToParentRepo', 'Apply to Parent Repo'),
id: ApplyChangesToParentRepoAction.ID,
title: localize2('applyChangesToParentRepo', 'Apply Changes to Parent Repository'),
icon: Codicon.desktopDownload,
category: CHAT_CATEGORY,
precondition: ContextKeyExpr.and(IsSessionsWindowContext, hasWorktreeAndRepositoryContextKey, ChatContextKeys.hasAgentSessionChanges),
precondition: ContextKeyExpr.and(
IsSessionsWindowContext,
hasWorktreeAndRepositoryContextKey,
),
menu: [
{
id: MenuId.ChatEditingSessionChangesToolbar,
id: MenuId.ChatEditingSessionApplySubmenu,
group: 'navigation',
order: 4,
when: ContextKeyExpr.and(IsSessionsWindowContext, hasWorktreeAndRepositoryContextKey, ChatContextKeys.hasAgentSessionChanges),
order: 2,
when: ContextKeyExpr.and(
IsSessionsWindowContext,
hasWorktreeAndRepositoryContextKey,
),
},
],
});
@@ -83,8 +78,7 @@ class ApplyToParentRepoAction extends Action2 {
override async run(accessor: ServicesAccessor): Promise<void> {
const sessionManagementService = accessor.get(ISessionsManagementService);
const agentSessionsService = accessor.get(IAgentSessionsService);
const fileService = accessor.get(IFileService);
const commandService = accessor.get(ICommandService);
const notificationService = accessor.get(INotificationService);
const logService = accessor.get(ILogService);
const openerService = accessor.get(IOpenerService);
@@ -98,55 +92,8 @@ class ApplyToParentRepoAction extends Action2 {
const worktreeRoot = activeSession.worktree;
const repoRoot = activeSession.repository;
const agentSession = agentSessionsService.getSession(activeSession.resource);
const changes = agentSession?.changes;
if (!changes || !(changes instanceof Array)) {
return;
}
let copiedCount = 0;
let deletedCount = 0;
let errorCount = 0;
for (const change of changes) {
try {
const modifiedUri = isIChatSessionFileChange2(change)
? change.modifiedUri ?? change.uri
: change.modifiedUri;
const isDeletion = isIChatSessionFileChange2(change)
? change.modifiedUri === undefined
: false;
if (isDeletion) {
const originalUri = change.originalUri;
if (originalUri && isEqualOrParent(toFileUri(originalUri), worktreeRoot)) {
const relPath = relativePath(worktreeRoot, toFileUri(originalUri));
if (relPath) {
const targetUri = joinPath(repoRoot, relPath);
if (await fileService.exists(targetUri)) {
await fileService.del(targetUri);
deletedCount++;
}
}
}
} else {
if (isEqualOrParent(toFileUri(modifiedUri), worktreeRoot)) {
const relPath = relativePath(worktreeRoot, toFileUri(modifiedUri));
if (relPath) {
const targetUri = joinPath(repoRoot, relPath);
await fileService.copy(modifiedUri, targetUri, true);
copiedCount++;
}
}
}
} catch (err) {
logService.error('[ApplyToParentRepo] Failed to apply change', err);
errorCount++;
}
}
const openFolderAction = toAction({
id: 'applyToParentRepo.openFolder',
id: 'applyChangesToParentRepo.openFolder',
label: localize('openInVSCode', "Open in VS Code"),
run: () => {
const scheme = productService.quality === 'stable'
@@ -168,26 +115,57 @@ class ApplyToParentRepoAction extends Action2 {
}
});
const totalApplied = copiedCount + deletedCount;
if (errorCount > 0) {
try {
// Get the worktree branch name. Since the worktree and parent repo
// share the same git object store, the parent can directly reference
// this branch for a merge.
const worktreeBranch = await commandService.executeCommand<string>(
'_git.revParseAbbrevRef',
worktreeRoot.fsPath
);
if (!worktreeBranch) {
notificationService.notify({
severity: Severity.Warning,
message: localize('applyChangesNoBranch', "Could not determine worktree branch name."),
});
return;
}
// Merge the worktree branch into the parent repo.
// This is idempotent: if already merged, git says "Already up to date."
// If new commits exist, they're brought in. Handles partial applies naturally.
const result = await commandService.executeCommand('_git.mergeBranch', repoRoot.fsPath, worktreeBranch);
if (!result) {
logService.warn('[ApplyChangesToParentRepo] No result from merge command');
} else {
notificationService.notify({
severity: Severity.Info,
message: typeof result === 'string' && result.startsWith('Already up to date')
? localize('alreadyUpToDate', 'Parent repository is up to date with worktree.')
: localize('applyChangesSuccess', 'Applied changes to parent repository.'),
actions: { primary: [openFolderAction] }
});
}
} catch (err) {
logService.error('[ApplyChangesToParentRepo] Failed to apply changes', err);
notificationService.notify({
severity: Severity.Warning,
message: totalApplied === 1
? localize('applyToParentRepoPartial1', "Applied 1 file to parent repo with {0} error(s).", errorCount)
: localize('applyToParentRepoPartialN', "Applied {0} files to parent repo with {1} error(s).", totalApplied, errorCount),
actions: { primary: [openFolderAction] }
});
} else if (totalApplied > 0) {
notificationService.notify({
severity: Severity.Info,
message: totalApplied === 1
? localize('applyToParentRepoSuccess1', "Applied 1 file to parent repo.")
: localize('applyToParentRepoSuccessN', "Applied {0} files to parent repo.", totalApplied),
message: localize('applyChangesConflict', "Failed to apply changes to parent repo. The parent repo may have diverged — resolve conflicts manually."),
actions: { primary: [openFolderAction] }
});
}
}
}
registerAction2(ApplyToParentRepoAction);
registerWorkbenchContribution2(ApplyToParentRepoContribution.ID, ApplyToParentRepoContribution, WorkbenchPhase.AfterRestored);
registerAction2(ApplyChangesToParentRepoAction);
registerWorkbenchContribution2(ApplyChangesToParentRepoContribution.ID, ApplyChangesToParentRepoContribution, WorkbenchPhase.AfterRestored);
// Register the apply submenu in the session changes toolbar
MenuRegistry.appendMenuItem(MenuId.ChatEditingSessionChangesToolbar, {
submenu: MenuId.ChatEditingSessionApplySubmenu,
title: localize2('applyActions', 'Apply Actions'),
group: 'navigation',
order: 1,
when: ContextKeyExpr.and(IsSessionsWindowContext, ChatContextKeys.hasAgentSessionChanges),
});

View File

@@ -593,10 +593,7 @@ export class ChangesViewPane extends ViewPane {
return { showIcon: true, showLabel: true, isSecondary: true, customClass: 'working-set-diff-stats', customLabel: diffStatsLabel };
}
if (action.id === 'github.createPullRequest' || action.id === 'github.openPullRequest') {
return { showIcon: true, showLabel: true, isSecondary: true, customClass: 'flex-grow' };
}
if (action.id === 'chatEditing.applyToParentRepo') {
return { showIcon: true, showLabel: false, isSecondary: true };
return { showIcon: true, showLabel: true, isSecondary: true };
}
if (action.id === 'chatEditing.synchronizeChanges') {
return { showIcon: true, showLabel: true, isSecondary: true };

View File

@@ -114,6 +114,29 @@
flex: 1;
}
/* ButtonWithDropdown container grows to fill available space */
.changes-view-body .chat-editing-session-actions.outside-card .monaco-button-dropdown {
flex: 1;
display: flex;
}
.changes-view-body .chat-editing-session-actions.outside-card .monaco-button-dropdown > .monaco-button {
flex: 1;
box-sizing: border-box;
}
.changes-view-body .chat-editing-session-actions.outside-card .monaco-button-dropdown > .monaco-button-dropdown-separator {
flex: 0;
}
.changes-view-body .chat-editing-session-actions.outside-card .monaco-button-dropdown > .monaco-button.monaco-dropdown-button {
flex: 0 0 auto;
padding: 4px;
width: auto;
min-width: 0;
border-radius: 0px 4px 4px 0px;
}
.changes-view-body .chat-editing-session-actions.outside-card .monaco-button.secondary.monaco-text-button.codicon {
padding: 4px 8px;
font-size: 16px !important;
@@ -136,6 +159,10 @@
color: var(--vscode-button-secondaryForeground);
}
.changes-view-body .chat-editing-session-actions.outside-card .monaco-button-dropdown > .monaco-button.secondary.monaco-text-button {
border-radius: 4px 0px 0px 4px;
}
.changes-view-body .chat-editing-session-actions .monaco-button.secondary:hover {
background-color: var(--vscode-button-secondaryHoverBackground);
color: var(--vscode-button-secondaryForeground);

View File

@@ -209,7 +209,7 @@ import './contrib/sessions/browser/customizationsToolbar.contribution.js';
import './contrib/changesView/browser/changesView.contribution.js';
import './contrib/files/browser/files.contribution.js';
import './contrib/gitSync/browser/gitSync.contribution.js';
import './contrib/applyToParentRepo/browser/applyToParentRepo.contribution.js';
import './contrib/applyCommitsToParentRepo/browser/applyChangesToParentRepo.js';
import './contrib/fileTreeView/browser/fileTreeView.contribution.js'; // view registration disabled; filesystem provider still needed
import './contrib/configuration/browser/configuration.contribution.js';

View File

@@ -109,7 +109,7 @@ function determineChangeType(resource: ISCMResource, groupId: string): 'added' |
* files is the presence/absence of a trailing newline (content otherwise identical),
* no diff will be generated because VS Code's diff algorithm treats the lines as equal.
*/
async function generateUnifiedDiff(
export async function generateUnifiedDiff(
fileService: IFileService,
relPath: string,
originalUri: URI | undefined,

View File

@@ -479,6 +479,12 @@ const apiMenus: IAPIMenu[] = [
description: localize('menus.chatEditingSessionChangesToolbar', "The Chat Editing widget toolbar menu for session changes."),
proposed: 'chatSessionsProvider'
},
{
key: 'chat/input/editing/sessionApplyActions',
id: MenuId.ChatEditingSessionApplySubmenu,
description: localize('menus.chatEditingSessionApplySubmenu', "Submenu for apply actions in the Chat Editing session changes toolbar."),
proposed: 'chatSessionsProvider'
},
{
// TODO: rename this to something like: `chatSessions/item/inline`
key: 'chat/chatSessions',