豆豆友情提示:这是一个非官方 GitHub 代理镜像,主要用于网络测试或访问加速。请勿在此进行登录、注册或处理任何敏感信息。进行这些操作请务必访问官方网站 github.com。 Raw 内容也通过此代理提供。
Skip to content

Commit afdf1da

Browse files
authored
Update background change migration UI (#4288)
* New UI for workspace change confirmation UI in Copilot CLI * Fixes * Updates * Fix tests
1 parent 2749723 commit afdf1da

File tree

4 files changed

+212
-55
lines changed

4 files changed

+212
-55
lines changed

src/extension/chatSessions/copilotcli/node/test/copilotcliSession.spec.ts

Lines changed: 4 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -30,13 +30,10 @@ import { PermissionRequest } from '../permissionHelpers';
3030
import { IUserQuestionHandler, UserInputRequest, UserInputResponse } from '../userInputHelpers';
3131
import { NullICopilotCLIImageSupport } from './copilotCliSessionService.spec';
3232

33-
vi.mock('../cliHelpers', async importOriginal => {
34-
const actual = await importOriginal<typeof import('../cliHelpers')>();
35-
return {
36-
...actual,
37-
getCopilotCLISessionStateDir: () => '/mock-session-state',
38-
};
39-
});
33+
vi.mock('../cliHelpers', async (importOriginal) => ({
34+
...(await importOriginal<typeof import('../cliHelpers')>()),
35+
getCopilotCLISessionStateDir: () => '/mock-session-state',
36+
}));
4037

4138
// Minimal shapes for types coming from the Copilot SDK we interact with
4239
interface MockSdkEventHandler { (payload: unknown): void }

src/extension/chatSessions/vscode-node/folderRepositoryManagerImpl.ts

Lines changed: 140 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -14,12 +14,10 @@ import { raceCancellation } from '../../../util/vs/base/common/async';
1414
import { Disposable, DisposableStore } from '../../../util/vs/base/common/lifecycle';
1515
import { ResourceSet } from '../../../util/vs/base/common/map';
1616
import { isEqual } from '../../../util/vs/base/common/resources';
17-
import { isWelcomeView } from '../copilotcli/node/copilotCli';
18-
import { ICopilotCLISessionService } from '../copilotcli/node/copilotcliSessionService';
1917
import { createTimeout } from '../../inlineEdits/common/common';
2018
import { IToolsService } from '../../tools/common/toolsService';
2119
import { IChatSessionWorkspaceFolderService } from '../common/chatSessionWorkspaceFolderService';
22-
import { ChatSessionWorktreeProperties, IChatSessionWorktreeService } from '../common/chatSessionWorktreeService';
20+
import { ChatSessionWorktreeFile, ChatSessionWorktreeProperties, IChatSessionWorktreeService } from '../common/chatSessionWorktreeService';
2321
import {
2422
FolderRepositoryInfo,
2523
FolderRepositoryMRUEntry,
@@ -28,6 +26,8 @@ import {
2826
InitializeFolderRepositoryOptions
2927
} from '../common/folderRepositoryManager';
3028
import { isUntitledSessionId } from '../common/utils';
29+
import { isWelcomeView } from '../copilotcli/node/copilotCli';
30+
import { ICopilotCLISessionService } from '../copilotcli/node/copilotcliSessionService';
3131

3232
/**
3333
* Message shown when user needs to trust a folder to continue.
@@ -274,11 +274,9 @@ export abstract class FolderRepositoryManager extends Disposable implements IFol
274274
// Check for uncommitted changes and prompt user before creating worktree
275275
let uncommittedChangesAction: 'move' | 'copy' | 'skip' | 'cancel' | undefined = undefined;
276276
if ((!sessionId || isUntitledSessionId(sessionId)) && !worktreeProperties) {
277-
if (await this.checkIfRepoHasUncommittedChanges(sessionId, token)) {
278-
uncommittedChangesAction = await this.promptForUncommittedChangesAction(sessionId, toolInvocationToken, token);
279-
if (uncommittedChangesAction === 'cancel') {
280-
return { folder, repository, worktree, worktreeProperties, trusted: true, cancelled: true };
281-
}
277+
uncommittedChangesAction = await this.promptForUncommittedChangesAction(sessionId, toolInvocationToken, token);
278+
if (uncommittedChangesAction === 'cancel') {
279+
return { folder, repository, worktree, worktreeProperties, trusted: true, cancelled: true };
282280
}
283281
}
284282

@@ -406,11 +404,56 @@ export abstract class FolderRepositoryManager extends Disposable implements IFol
406404
toolInvocationToken: vscode.ChatParticipantToolToken,
407405
token: vscode.CancellationToken
408406
): Promise<'move' | 'copy' | 'skip' | 'cancel' | undefined> {
409-
const hasUncommittedChanges = await this.checkIfRepoHasUncommittedChanges(sessionId, token);
410-
if (!hasUncommittedChanges) {
407+
const uncommittedChanges = await this.getUncommittedChangesPromptData(sessionId, token);
408+
if (!uncommittedChanges) {
411409
return undefined;
412410
}
413411

412+
if (!this.toolsService.getTool('vscode_get_modified_files_confirmation')) {
413+
return this.promptForUncommittedChangesActionOld(sessionId, toolInvocationToken, token);
414+
}
415+
416+
const isDelegation = !sessionId;
417+
const title = isDelegation
418+
? l10n.t('Delegate to Background Agent')
419+
: l10n.t('Uncommitted Changes');
420+
const message = isDelegation
421+
? l10n.t('Background Agent will work in an isolated worktree to implement your requested changes.')
422+
+ '\n\n'
423+
+ l10n.t('The selected repository has uncommitted changes. Should these changes be included in the new worktree?')
424+
: l10n.t('The selected repository has uncommitted changes. Should these changes be included in the new worktree?');
425+
426+
const copyChanges = l10n.t('Copy Changes');
427+
const moveChanges = l10n.t('Move Changes');
428+
const skipChanges = l10n.t('Skip Changes');
429+
const options = [copyChanges, moveChanges, skipChanges];
430+
const input = {
431+
title,
432+
message,
433+
options,
434+
modifiedFiles: uncommittedChanges.modifiedFiles
435+
};
436+
const result = await this.toolsService.invokeTool('vscode_get_modified_files_confirmation', { input, toolInvocationToken }, token);
437+
438+
const selection = this.getSelectedUncommittedChangesAction(result, options);
439+
440+
switch (selection?.toUpperCase()) {
441+
case moveChanges.toUpperCase():
442+
return 'move';
443+
case copyChanges.toUpperCase():
444+
return 'copy';
445+
case skipChanges.toUpperCase():
446+
return 'skip';
447+
default:
448+
return 'cancel';
449+
}
450+
}
451+
452+
private async promptForUncommittedChangesActionOld(
453+
sessionId: string | undefined,
454+
toolInvocationToken: vscode.ChatParticipantToolToken,
455+
token: vscode.CancellationToken
456+
): Promise<'move' | 'copy' | 'skip' | 'cancel' | undefined> {
414457
const isDelegation = !sessionId;
415458
const title = isDelegation
416459
? l10n.t('Delegate to Background Agent')
@@ -448,35 +491,103 @@ export abstract class FolderRepositoryManager extends Disposable implements IFol
448491
}
449492
}
450493

451-
/**
452-
* Check if the repository associated with a session has uncommitted changes.
453-
*/
454-
private async checkIfRepoHasUncommittedChanges(sessionId: string | undefined, _token: vscode.CancellationToken): Promise<boolean> {
494+
private getSelectedUncommittedChangesAction(
495+
result: vscode.LanguageModelToolResult,
496+
options: readonly string[]
497+
): string | undefined {
498+
for (const part of result.content) {
499+
if (!(part instanceof LanguageModelTextPart)) {
500+
continue;
501+
}
502+
503+
const matchedOption = options.find(option => option.toUpperCase() === part.value.toUpperCase());
504+
if (matchedOption) {
505+
return matchedOption;
506+
}
507+
}
508+
509+
return undefined;
510+
}
511+
512+
private async getUncommittedChangesPromptData(
513+
sessionId: string | undefined,
514+
token: vscode.CancellationToken
515+
): Promise<{ repository: vscode.Uri; modifiedFiles: Array<{ uri: vscode.Uri; originalUri?: vscode.Uri; insertions?: number; deletions?: number }> } | undefined> {
516+
const repository = await this.getRepositoryForUncommittedChanges(sessionId);
517+
if (!repository) {
518+
return undefined;
519+
}
520+
521+
const modifiedFiles = await this.getModifiedFilesForConfirmation(repository.rootUri, repository, token);
522+
if (modifiedFiles.length === 0) {
523+
return undefined;
524+
}
525+
526+
return {
527+
repository: repository.rootUri,
528+
modifiedFiles
529+
};
530+
}
531+
532+
private async getRepositoryForUncommittedChanges(sessionId: string | undefined): Promise<ReturnType<IGitService['activeRepository']['get']> | undefined> {
455533
if (sessionId && isUntitledSessionId(sessionId)) {
456534
const folder = this._untitledSessionFolders.get(sessionId)?.uri
457535
?? await this.workspaceFolderService.getSessionWorkspaceFolder(sessionId);
458536
if (folder) {
459-
const repo = await this.gitService.getRepository(folder, false);
460-
return repo?.changes
461-
? (repo.changes.indexChanges.length > 0 || repo.changes.workingTree.length > 0)
462-
: false;
537+
return await this.gitService.getRepository(folder, false);
463538
}
464-
// No folder selected, fall through to active repo check
465-
} else if (sessionId) {
466-
// Non-untitled session, no need to check
467-
return false;
539+
// No folder selected, fall through to the active repository check.
540+
}
541+
542+
if (sessionId && !isUntitledSessionId(sessionId)) {
543+
return undefined;
468544
}
469545

470-
// For delegation (no session) or untitled session without explicit folder selection,
471-
// check active repository if there's a single workspace folder
472546
if (!isWelcomeView(this.workspaceService) && this.workspaceService.getWorkspaceFolders().length === 1) {
473-
const repo = this.gitService.activeRepository.get();
474-
return repo?.changes
475-
? (repo.changes.indexChanges.length > 0 || repo.changes.workingTree.length > 0)
476-
: false;
547+
return this.gitService.activeRepository.get();
477548
}
478549

479-
return false;
550+
return undefined;
551+
}
552+
553+
private async getModifiedFilesForConfirmation(
554+
repositoryUri: vscode.Uri,
555+
repository: NonNullable<ReturnType<IGitService['activeRepository']['get']>>,
556+
token: vscode.CancellationToken
557+
): Promise<Array<{ uri: vscode.Uri; originalUri?: vscode.Uri; insertions?: number; deletions?: number }>> {
558+
const workspaceChanges = await this.workspaceFolderService.getWorkspaceChanges(repositoryUri) ?? [];
559+
if (workspaceChanges.length > 0) {
560+
return workspaceChanges.map(change => this.toModifiedFileConfirmationEntry(change));
561+
}
562+
563+
if (token.isCancellationRequested || !repository.changes) {
564+
return [];
565+
}
566+
567+
const modifiedFiles = new Map<string, { uri: vscode.Uri; originalUri?: vscode.Uri; insertions?: number; deletions?: number }>();
568+
for (const change of [...repository.changes.indexChanges, ...repository.changes.workingTree]) {
569+
const changePath = (change as { path?: string }).path;
570+
const fileUri = change.uri ?? (changePath ? vscode.Uri.joinPath(repositoryUri, changePath) : undefined);
571+
if (!fileUri) {
572+
continue;
573+
}
574+
modifiedFiles.set(fileUri.toString(), {
575+
uri: fileUri,
576+
originalUri: change.originalUri
577+
});
578+
}
579+
580+
return [...modifiedFiles.values()];
581+
}
582+
583+
private toModifiedFileConfirmationEntry(change: ChatSessionWorktreeFile): { uri: vscode.Uri; originalUri?: vscode.Uri; insertions?: number; deletions?: number } {
584+
const uri = vscode.Uri.file(change.modifiedFilePath ?? change.filePath);
585+
return {
586+
uri: uri,
587+
originalUri: change.originalFilePath ? vscode.Uri.file(change.originalFilePath) : undefined,
588+
insertions: change.statistics.additions,
589+
deletions: change.statistics.deletions
590+
};
480591
}
481592

482593
/**

src/extension/chatSessions/vscode-node/test/copilotCLIChatSessionParticipant.spec.ts

Lines changed: 27 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -34,7 +34,7 @@ import { MockChatResponseStream, TestChatRequest } from '../../../test/node/test
3434
import { type IToolsService } from '../../../tools/common/toolsService';
3535
import { mockLanguageModelChat } from '../../../tools/node/test/searchToolTestUtils';
3636
import { IChatSessionWorkspaceFolderService } from '../../common/chatSessionWorkspaceFolderService';
37-
import { IChatSessionWorktreeService, type ChatSessionWorktreeProperties } from '../../common/chatSessionWorktreeService';
37+
import { IChatSessionWorktreeService, type ChatSessionWorktreeFile, type ChatSessionWorktreeProperties } from '../../common/chatSessionWorktreeService';
3838
import { getWorkingDirectory, IWorkspaceInfo } from '../../common/workspaceInfo';
3939
import { IChatDelegationSummaryService } from '../../copilotcli/common/delegationSummaryService';
4040
import { type CopilotCLIModelInfo, type ICopilotCLIModels, type ICopilotCLISDK } from '../../copilotcli/node/copilotCli';
@@ -87,8 +87,14 @@ vi.mock('vscode', async (importOriginal) => {
8787

8888
class FakeToolsService extends mock<IToolsService>() {
8989
nextConfirmationButton: string | undefined = undefined;
90+
override getTool(name: string) {
91+
if (name === 'vscode_get_modified_files_confirmation') {
92+
return { name } as any;
93+
}
94+
return undefined;
95+
}
9096
override invokeTool = vi.fn(async (name: string, _options: unknown, _token: unknown) => {
91-
if (name === 'vscode_get_confirmation_with_options') {
97+
if (name === 'vscode_get_modified_files_confirmation') {
9298
const button = this.nextConfirmationButton;
9399
if (button !== undefined) {
94100
return new LanguageModelToolResult2([new LanguageModelTextPart(button)]);
@@ -102,6 +108,7 @@ class FakeToolsService extends mock<IToolsService>() {
102108
class FakeChatSessionWorkspaceFolderService extends mock<IChatSessionWorkspaceFolderService>() {
103109
private _sessionWorkspaceFolders = new Map<string, vscode.Uri>();
104110
private _recentFolders: { folder: vscode.Uri; lastAccessTime: number }[] = [];
111+
private _workspaceChanges = new Map<string, readonly ChatSessionWorktreeFile[] | undefined>();
105112
override trackSessionWorkspaceFolder = vi.fn(async (sessionId: string, workspaceFolderUri: string) => {
106113
this._sessionWorkspaceFolders.set(sessionId, vscode.Uri.file(workspaceFolderUri));
107114
});
@@ -114,12 +121,18 @@ class FakeChatSessionWorkspaceFolderService extends mock<IChatSessionWorkspaceFo
114121
override getRecentFolders = vi.fn((): Promise<{ folder: vscode.Uri; lastAccessTime: number }[]> => {
115122
return Promise.resolve(this._recentFolders);
116123
});
124+
override getWorkspaceChanges = vi.fn(async (workspaceFolderUri: vscode.Uri): Promise<readonly ChatSessionWorktreeFile[] | undefined> => {
125+
return this._workspaceChanges.get(workspaceFolderUri.toString());
126+
});
117127
setTestRecentFolders(folders: { folder: vscode.Uri; lastAccessTime: number }[]): void {
118128
this._recentFolders = folders;
119129
}
120130
setTestSessionWorkspaceFolder(sessionId: string, folder: vscode.Uri): void {
121131
this._sessionWorkspaceFolders.set(sessionId, folder);
122132
}
133+
setTestWorkspaceChanges(folder: vscode.Uri, changes: readonly ChatSessionWorktreeFile[] | undefined): void {
134+
this._workspaceChanges.set(folder.toString(), changes);
135+
}
123136
}
124137

125138
class FakeChatSessionWorktreeService extends mock<IChatSessionWorktreeService>() {
@@ -566,11 +579,12 @@ describe('CopilotCLIChatSessionParticipant.handleRequest', () => {
566579

567580
// With the awaitable confirmation, the session should be created in a single request
568581
expect(manager.sessions.size).toBe(1);
569-
expect(tools.invokeTool).toHaveBeenCalledWith(
570-
'vscode_get_confirmation_with_options',
571-
expect.objectContaining({ input: expect.objectContaining({ title: 'Delegate to Background Agent' }) }),
572-
token
573-
);
582+
const delegateCallArgs = (tools.invokeTool as unknown as ReturnType<typeof vi.fn>).mock.calls[0];
583+
expect(delegateCallArgs[0]).toBe('vscode_get_modified_files_confirmation');
584+
expect(delegateCallArgs[1].input.title).toBe('Delegate to Background Agent');
585+
expect(delegateCallArgs[1].input.modifiedFiles).toHaveLength(1);
586+
expect(delegateCallArgs[1].input.modifiedFiles[0].uri.toString()).toBe(Uri.file(`${sep}workspace${sep}file.ts`).toString());
587+
expect(delegateCallArgs[2]).toBe(token);
574588
});
575589

576590
it('handles /delegate command from another chat without active repository', async () => {
@@ -701,11 +715,12 @@ describe('CopilotCLIChatSessionParticipant.handleRequest', () => {
701715
expect(cliSessions[0].requests.length).toBe(1);
702716
expect(cliSessions[0].requests[0].input).toEqual({ prompt: 'Fix the bug', plan: false });
703717
// Verify confirmation tool was invoked with the right title
704-
expect(tools.invokeTool).toHaveBeenCalledWith(
705-
'vscode_get_confirmation_with_options',
706-
expect.objectContaining({ input: expect.objectContaining({ title: 'Uncommitted Changes' }) }),
707-
token
708-
);
718+
const confirmCallArgs = (tools.invokeTool as unknown as ReturnType<typeof vi.fn>).mock.calls[0];
719+
expect(confirmCallArgs[0]).toBe('vscode_get_modified_files_confirmation');
720+
expect(confirmCallArgs[1].input.title).toBe('Uncommitted Changes');
721+
expect(confirmCallArgs[1].input.modifiedFiles).toHaveLength(1);
722+
expect(confirmCallArgs[1].input.modifiedFiles[0].uri.toString()).toBe(Uri.file(`${sep}repo${sep}file.ts`).toString());
723+
expect(confirmCallArgs[2]).toBe(token);
709724
});
710725

711726
it('uses request prompt directly when user accepts uncommitted changes confirmation', async () => {

0 commit comments

Comments
 (0)