Book a Demo!
CoCalc Logo Icon
StoreFeaturesDocsShareSupportNewsAboutPoliciesSign UpSign In
microsoft
GitHub Repository: microsoft/vscode
Path: blob/main/extensions/copilot/src/extension/chatSessions/vscode-node/sessionOptionGroupBuilder.ts
13399 views
1
/*---------------------------------------------------------------------------------------------
2
* Copyright (c) Microsoft Corporation. All rights reserved.
3
* Licensed under the MIT License. See License.txt in the project root for license information.
4
*--------------------------------------------------------------------------------------------*/
5
6
/**
7
* ## Dropdown Business Rules
8
*
9
* ### Feature Flags
10
* - `CLIBranchSupport` — gates the Branch dropdown entirely.
11
* - `CLIIsolationOption` — gates the Isolation dropdown entirely.
12
*
13
* ### Trust
14
* - Git repository lookups are only performed on **trusted** folders
15
* (via {@link getTrustedRepository}). Untrusted folders are treated
16
* as non-git: isolation locks to Workspace and branch is hidden.
17
*
18
* ---
19
* ### NEW Sessions
20
*
21
* #### Isolation dropdown
22
* | Scenario | Shown? | Editable? | Selected |
23
* |-----------------------------------------------|--------|-----------|-------------------------------------------------|
24
* | Feature disabled | No | — | — |
25
* | Enabled, folder is a trusted git repo | Yes | Yes | Last-used value (defaults to Workspace) |
26
* | Enabled, folder is NOT a git repo / untrusted | Yes | Locked | Forced to Workspace |
27
* | Re-evaluated after git init (rebuildInputState) | Yes | Unlocked | Preserves current selection |
28
*
29
* #### Folder / Repository dropdown
30
* | Workspace type | Shown? | Editable? | Items |
31
* |---------------------------------------|--------|-----------|--------------------------------------------------|
32
* | Welcome view (no workspace folders) | Yes | Yes | MRU list (max 10) + "Browse folders…" command |
33
* | Single workspace folder, 1 repo item | No | — | Implicit (used as default) |
34
* | Single workspace folder, 0 repos | No | — | Implicit (workspace folder used as default) |
35
* | Multi-root / multiple repo items | Yes | Yes | All repos + non-git workspace folders, sorted A-Z |
36
*
37
* #### Branch dropdown
38
* | Scenario | Shown? | Editable? | Selected |
39
* |--------------------------------------------|--------|-----------|-------------|
40
* | `CLIBranchSupport` disabled | No | — | — |
41
* | Folder is NOT a git repo / untrusted | No | — | — |
42
* | Git repo, isolation disabled | Yes | Locked | HEAD branch |
43
* | Git repo, isolation enabled + Workspace | Yes | Locked | HEAD branch |
44
* | Git repo, isolation enabled + Worktree | Yes | Editable | HEAD branch |
45
*
46
* #### Branch item ordering
47
* 1. HEAD branch (first)
48
* 2. `main` / `master` (second, if it exists and isn't HEAD)
49
* 3. Other local branches (by committer date)
50
* 4. `copilot-worktree-*` branches excluded
51
* 5. Remote refs excluded
52
*
53
* #### Selection persistence
54
* - **Isolation** — persisted to global state on every change.
55
* - **Folder** — previous selection restored if still in list → first item.
56
* - **Branch** — previous selection if still in list → HEAD → stale previous preserved.
57
*
58
* ---
59
* ### EXISTING Sessions
60
*
61
* Everything is **locked** — no dropdowns are editable.
62
*
63
* | Dropdown | Shown? | Locked? | Value |
64
* |-----------|-----------------------------|---------|-------------------------------------------------|
65
* | Isolation | Yes (if feature enabled) | Yes | Worktree if session has worktree props, else Workspace |
66
* | Folder | Always | Yes | The session's folder / repo |
67
* | Branch | Only if branch name exists | Yes | Session's worktree branch or repo branch |
68
*
69
* ---
70
* ### State Transitions
71
*
72
* **handleInputStateChange** (user dropdown interaction):
73
* Partial refresh — rebuilds branch and isolation only.
74
* Cannot add/remove the folder dropdown group.
75
*
76
* **rebuildInputState** (external state changes):
77
* Full rebuild of all groups
78
* Used when git repos are discovered/closed or workspace folders
79
* change, since these can add/remove entire dropdown groups.
80
*
81
* **updateInputStateAfterFolderSelection** (Browse folders… flow):
82
* Same pattern as handleInputStateChange — updates folder selection,
83
* then locks/unlocks isolation and rebuilds branch based on git status.
84
*
85
* **provideChatSessionProviderOptionGroups** (initial build):
86
* Builds all groups, checks git status, forces workspace
87
* isolation if folder is non-git / untrusted.
88
*/
89
90
import * as l10n from '@vscode/l10n';
91
import * as vscode from 'vscode';
92
import { ChatSessionProviderOptionItem, Uri } from 'vscode';
93
import { ConfigKey, IConfigurationService } from '../../../platform/configuration/common/configurationService';
94
import { IVSCodeExtensionContext } from '../../../platform/extContext/common/extensionContext';
95
import { IGitService, RepoContext } from '../../../platform/git/common/gitService';
96
import { IWorkspaceService } from '../../../platform/workspace/common/workspaceService';
97
import { createServiceIdentifier } from '../../../util/common/services';
98
import { isUri } from '../../../util/common/types';
99
import { SequencerByKey } from '../../../util/vs/base/common/async';
100
import { CancellationToken } from '../../../util/vs/base/common/cancellation';
101
import { basename } from '../../../util/vs/base/common/resources';
102
import { URI } from '../../../util/vs/base/common/uri';
103
import { IAgentSessionsWorkspace } from '../common/agentSessionsWorkspace';
104
import { IChatSessionWorktreeService } from '../common/chatSessionWorktreeService';
105
import { FolderRepositoryMRUEntry, IChatFolderMruService, IFolderRepositoryManager, IsolationMode } from '../common/folderRepositoryManager';
106
import { SessionIdForCLI } from '../copilotcli/common/utils';
107
import { isWelcomeView } from '../copilotcli/node/copilotCli';
108
export const REPOSITORY_OPTION_ID = 'repository';
109
export const BRANCH_OPTION_ID = 'branch';
110
export const ISOLATION_OPTION_ID = 'isolation';
111
export const OPEN_REPOSITORY_COMMAND_ID = 'github.copilot.cli.sessions.openRepository';
112
113
/**
114
* Resolve which branch should be selected.
115
*
116
* Priority: previous selection (if still in the branch list) → active (HEAD)
117
* branch → previous selection as-is (stale but preserved so it's not lost).
118
*/
119
export function resolveBranchSelection<T extends { id: string }>(
120
branches: readonly T[],
121
activeBranchId: string | undefined,
122
previousSelection: T | undefined,
123
): T | undefined {
124
if (previousSelection) {
125
const inList = branches.find(b => b.id === previousSelection.id);
126
if (inList) {
127
return inList;
128
}
129
}
130
const activeBranch = activeBranchId
131
? branches.find(b => b.id === activeBranchId)
132
: undefined;
133
return activeBranch ?? previousSelection;
134
}
135
136
/**
137
* Determine branch dropdown locked state.
138
*
139
* - Isolation enabled + Workspace selected → locked
140
* - Isolation enabled + Worktree selected → editable
141
* - Isolation disabled → locked (always workspace mode)
142
*/
143
export function resolveBranchLockState(
144
isolationEnabled: boolean,
145
currentIsolation: IsolationMode | undefined,
146
): { locked: boolean } {
147
if (!isolationEnabled) {
148
return { locked: true };
149
}
150
151
const isWorktree = currentIsolation === IsolationMode.Worktree;
152
return {
153
locked: !isWorktree,
154
};
155
}
156
157
/**
158
* Resolve which isolation item should be selected for a new session.
159
* Uses the previous selection if valid, otherwise falls back to the last-used value.
160
*/
161
export function resolveIsolationSelection(
162
lastUsed: IsolationMode,
163
previousSelectionId: string | undefined,
164
): IsolationMode {
165
if (previousSelectionId === IsolationMode.Workspace || previousSelectionId === IsolationMode.Worktree) {
166
return previousSelectionId;
167
}
168
return lastUsed;
169
}
170
171
const LAST_USED_ISOLATION_OPTION_KEY = 'github.copilot.cli.lastUsedIsolationOption';
172
const MAX_MRU_ENTRIES = 10;
173
const COPILOT_WORKTREE_PATTERN = 'copilot-worktree-';
174
175
function optionItemsEqual(a: vscode.ChatSessionProviderOptionItem | undefined, b: vscode.ChatSessionProviderOptionItem | undefined): boolean {
176
if (a === b) {
177
return true;
178
}
179
if (!a || !b) {
180
return false;
181
}
182
return a.id === b.id && a.locked === b.locked;
183
}
184
185
function optionGroupsEqual(
186
oldGroups: readonly vscode.ChatSessionProviderOptionGroup[],
187
newGroups: readonly vscode.ChatSessionProviderOptionGroup[],
188
): boolean {
189
if (oldGroups.length !== newGroups.length) {
190
return false;
191
}
192
for (let i = 0; i < oldGroups.length; i++) {
193
const oldGroup = oldGroups[i];
194
const newGroup = newGroups[i];
195
if (oldGroup.id !== newGroup.id) {
196
return false;
197
}
198
if (!optionItemsEqual(oldGroup.selected, newGroup.selected)) {
199
return false;
200
}
201
if (oldGroup.items.length !== newGroup.items.length) {
202
return false;
203
}
204
for (let j = 0; j < oldGroup.items.length; j++) {
205
if (!optionItemsEqual(oldGroup.items[j], newGroup.items[j])) {
206
return false;
207
}
208
}
209
}
210
return true;
211
}
212
213
export function getSelectedOption(groups: readonly vscode.ChatSessionProviderOptionGroup[], groupId: string): vscode.ChatSessionProviderOptionItem | undefined {
214
return groups.find(g => g.id === groupId)?.selected;
215
}
216
217
/**
218
* Extract the selected repository, branch, and isolation values from an input state.
219
*/
220
export function getSelectedSessionOptions(inputState: vscode.ChatSessionInputState): { folder?: vscode.Uri; branch?: string; isolation?: IsolationMode } {
221
const repoId = getSelectedOption(inputState.groups, REPOSITORY_OPTION_ID)?.id;
222
const branch = getSelectedOption(inputState.groups, BRANCH_OPTION_ID)?.id;
223
const isolationId = getSelectedOption(inputState.groups, ISOLATION_OPTION_ID)?.id;
224
return {
225
folder: repoId ? vscode.Uri.file(repoId) : undefined,
226
branch: branch || undefined,
227
isolation: (isolationId === IsolationMode.Workspace || isolationId === IsolationMode.Worktree) ? isolationId : undefined,
228
};
229
}
230
231
export function isBranchOptionFeatureEnabled(configurationService: IConfigurationService): boolean {
232
return configurationService.getConfig(ConfigKey.Advanced.CLIBranchSupport);
233
}
234
235
/**
236
* Force the isolation option group to workspace and lock it when the
237
* selected folder is not a git repository (worktree isolation is a
238
* no-op without git). Use {@link resetIsolationLock} to unlock when
239
* the folder becomes a git repo (e.g. after git init).
240
*/
241
function forceWorkspaceIsolation(groups: vscode.ChatSessionProviderOptionGroup[]): void {
242
const isolationIdx = groups.findIndex(g => g.id === ISOLATION_OPTION_ID);
243
if (isolationIdx !== -1) {
244
const isolationGroup = groups[isolationIdx];
245
const workspaceItem = isolationGroup.items.find(i => i.id === IsolationMode.Workspace);
246
if (workspaceItem) {
247
groups[isolationIdx] = {
248
...isolationGroup,
249
items: isolationGroup.items.map(i => ({ ...i, locked: true })),
250
selected: { ...workspaceItem, locked: true },
251
};
252
}
253
}
254
}
255
256
/**
257
* Remove the locked flag from all isolation items.
258
* Called when the selected folder turns out to be (or becomes) a git
259
* repository, so the worktree option is valid again.
260
*/
261
function resetIsolationLock(groups: vscode.ChatSessionProviderOptionGroup[]): void {
262
const isolationIdx = groups.findIndex(g => g.id === ISOLATION_OPTION_ID);
263
if (isolationIdx !== -1) {
264
const isolationGroup = groups[isolationIdx];
265
const unlock = (item: vscode.ChatSessionProviderOptionItem): vscode.ChatSessionProviderOptionItem => {
266
const { locked: _, ...rest } = item;
267
return rest;
268
};
269
groups[isolationIdx] = {
270
...isolationGroup,
271
items: isolationGroup.items.map(unlock),
272
selected: isolationGroup.selected ? unlock(isolationGroup.selected) : undefined,
273
};
274
}
275
}
276
277
export function isIsolationOptionFeatureEnabled(configurationService: IConfigurationService): boolean {
278
return configurationService.getConfig(ConfigKey.Advanced.CLIIsolationOption);
279
}
280
281
export function toRepositoryOptionItem(repository: RepoContext | Uri, isDefault: boolean = false): ChatSessionProviderOptionItem {
282
const repositoryUri = isUri(repository) ? repository : repository.rootUri;
283
const repositoryIcon = isUri(repository) ? 'repo' : repository.kind === 'repository' ? 'repo' : 'archive';
284
const repositoryName = repositoryUri.path.split('/').pop() ?? repositoryUri.toString();
285
286
return {
287
id: repositoryUri.fsPath,
288
name: repositoryName,
289
icon: new vscode.ThemeIcon(repositoryIcon),
290
default: isDefault
291
} satisfies vscode.ChatSessionProviderOptionItem;
292
}
293
294
export function toWorkspaceFolderOptionItem(workspaceFolderUri: URI, name: string): ChatSessionProviderOptionItem {
295
return {
296
id: workspaceFolderUri.fsPath,
297
name: name,
298
icon: new vscode.ThemeIcon('folder'),
299
} satisfies vscode.ChatSessionProviderOptionItem;
300
}
301
302
export function folderMRUToChatProviderOptions(mruItems: FolderRepositoryMRUEntry[]): ChatSessionProviderOptionItem[] {
303
return mruItems.map((item) => {
304
if (item.repository) {
305
return toRepositoryOptionItem(item.folder);
306
} else {
307
return toWorkspaceFolderOptionItem(item.folder, basename(item.folder));
308
}
309
});
310
}
311
312
/**
313
* Builds and manages the dropdown option groups (repository, branch, isolation)
314
* for new and existing CLI chat sessions.
315
*/
316
export interface ISessionOptionGroupBuilder {
317
readonly _serviceBrand: undefined;
318
provideChatSessionProviderOptionGroups(previousInputState: vscode.ChatSessionInputState | undefined): Promise<vscode.ChatSessionProviderOptionGroup[]>;
319
buildBranchOptionGroup(branches: vscode.ChatSessionProviderOptionItem[], headBranchName: string | undefined, isolationEnabled: boolean, currentIsolation: IsolationMode | undefined, previousSelection: vscode.ChatSessionProviderOptionItem | undefined): vscode.ChatSessionProviderOptionGroup | undefined;
320
handleInputStateChange(state: vscode.ChatSessionInputState): Promise<void>;
321
rebuildInputState(state: vscode.ChatSessionInputState, selectedFolderUri?: vscode.Uri): Promise<void>;
322
buildExistingSessionInputStateGroups(resource: vscode.Uri, token: vscode.CancellationToken): Promise<vscode.ChatSessionProviderOptionGroup[]>;
323
getBranchOptionItemsForRepository(repoUri: Uri, headBranchName: string | undefined): Promise<vscode.ChatSessionProviderOptionItem[]>;
324
getRepositoryOptionItems(): vscode.ChatSessionProviderOptionItem[];
325
/**
326
* Lock all dropdown groups (make them readonly).
327
* Used when a new session is being created.
328
*/
329
lockInputStateGroups(state: vscode.ChatSessionInputState): void;
330
/**
331
* Update the branch dropdown to display a specific branch name (locked).
332
* Used after a worktree is created to show the new branch.
333
*/
334
updateBranchInInputState(state: vscode.ChatSessionInputState, branchName: string): void;
335
}
336
export const ISessionOptionGroupBuilder = createServiceIdentifier<ISessionOptionGroupBuilder>('ISessionOptionGroupBuilder');
337
338
export class SessionOptionGroupBuilder implements ISessionOptionGroupBuilder {
339
declare readonly _serviceBrand: undefined;
340
private readonly _getBranchOptionItemsForRepositorySequencer = new SequencerByKey<string>();
341
private readonly _pendingBuildGroups = new WeakMap<vscode.ChatSessionInputState, Promise<vscode.ChatSessionProviderOptionGroup[]>>();
342
// Keeps track of the new folders selected by user
343
private readonly _inputStateNewFolders = new WeakMap<vscode.ChatSessionInputState, vscode.Uri>();
344
constructor(
345
@IGitService private readonly gitService: IGitService,
346
@IConfigurationService private readonly configurationService: IConfigurationService,
347
@IVSCodeExtensionContext private readonly context: IVSCodeExtensionContext,
348
@IWorkspaceService private readonly workspaceService: IWorkspaceService,
349
@IChatFolderMruService private readonly copilotCLIFolderMruService: IChatFolderMruService,
350
@IAgentSessionsWorkspace private readonly agentSessionsWorkspace: IAgentSessionsWorkspace,
351
@IChatSessionWorktreeService private readonly chatSessionWorktreeService: IChatSessionWorktreeService,
352
@IFolderRepositoryManager private readonly folderRepositoryManager: IFolderRepositoryManager,
353
) { }
354
355
356
/**
357
* Return the git repository for a URI only if the folder is trusted.
358
* Untrusted folders are treated as non-git.
359
*/
360
private async getTrustedRepository(uri: vscode.Uri | undefined, discover?: boolean): Promise<RepoContext | undefined> {
361
if (!uri) {
362
return undefined;
363
}
364
const isTrusted = await this.workspaceService.isResourceTrusted(uri);
365
if (!isTrusted) {
366
return undefined;
367
}
368
return this.gitService.getRepository(uri, discover);
369
}
370
371
async provideChatSessionProviderOptionGroups(previousInputState: vscode.ChatSessionInputState | undefined, selectedFolderUri?: vscode.Uri): Promise<vscode.ChatSessionProviderOptionGroup[]> {
372
const optionGroups: vscode.ChatSessionProviderOptionGroup[] = [];
373
const isolationEnabled = isIsolationOptionFeatureEnabled(this.configurationService);
374
const previouslySelectedIsolationOption = previousInputState ? getSelectedOption(previousInputState.groups, ISOLATION_OPTION_ID) : undefined;
375
let currentIsolation: IsolationMode | undefined;
376
if (isolationEnabled) {
377
const lastUsed = this.context.globalState.get<IsolationMode>(LAST_USED_ISOLATION_OPTION_KEY, IsolationMode.Workspace);
378
currentIsolation = resolveIsolationSelection(lastUsed, previouslySelectedIsolationOption?.id);
379
const items = [
380
{ id: IsolationMode.Workspace, name: l10n.t('Workspace'), icon: new vscode.ThemeIcon('folder') },
381
{ id: IsolationMode.Worktree, name: l10n.t('Worktree'), icon: new vscode.ThemeIcon('worktree') },
382
];
383
// Use the previous selection's ID to find the matching fresh item
384
// (without stale flags like `locked`), falling back to the default.
385
const selectedId = previouslySelectedIsolationOption?.id ?? currentIsolation;
386
optionGroups.push({
387
id: ISOLATION_OPTION_ID,
388
name: l10n.t('Isolation'),
389
description: l10n.t('Pick Isolation Mode'),
390
items,
391
selected: items.find(i => i.id === selectedId)!
392
});
393
}
394
395
// Handle repository options based on workspace type
396
const folders = this.workspaceService.getWorkspaceFolders();
397
const isSingleFolderWorkspace = !isWelcomeView(this.workspaceService)
398
&& !this.agentSessionsWorkspace.isAgentSessionsWorkspace
399
&& folders?.length === 1;
400
let defaultRepoUri = selectedFolderUri ?? (isSingleFolderWorkspace ? folders![0] : undefined);
401
if (isWelcomeView(this.workspaceService)) {
402
const commands: vscode.Command[] = [];
403
const previouslySelected = previousInputState ? getSelectedOption(previousInputState.groups, REPOSITORY_OPTION_ID) : undefined;
404
let items: vscode.ChatSessionProviderOptionItem[] = [];
405
406
// For untitled workspaces, show last used repositories and "Open Repository..." command
407
const repositories = await this.copilotCLIFolderMruService.getRecentlyUsedFolders(CancellationToken.None);
408
items = folderMRUToChatProviderOptions(repositories);
409
const addFolderToList = async (uri: Uri) => {
410
const newFolderRepo = await this.getTrustedRepository(uri, true);
411
const newFolderItem = newFolderRepo
412
? toRepositoryOptionItem(newFolderRepo.rootUri)
413
: toWorkspaceFolderOptionItem(uri, uri.path.split('/').pop() ?? uri.fsPath);
414
// Remove duplicate if already in the list, then add to top
415
items = items.filter(item => item.id !== newFolderItem.id);
416
items.unshift(newFolderItem);
417
};
418
if (selectedFolderUri) {
419
await addFolderToList(selectedFolderUri);
420
}
421
const previouslySelectedUri = previouslySelected ? vscode.Uri.file(previouslySelected.id) : undefined;
422
if (previouslySelectedUri) {
423
await addFolderToList(previouslySelectedUri);
424
}
425
// Ensure previously selected folder is added back into the list of folders.
426
const newFolder = previousInputState ? this._inputStateNewFolders.get(previousInputState) : undefined;
427
if (newFolder) {
428
await addFolderToList(newFolder);
429
}
430
const selectedFolderItem = selectedFolderUri ? items.find(i => i.id === selectedFolderUri.fsPath) : undefined;
431
const previouslySelectedItem = previouslySelected ? items.find(i => i.id === previouslySelected.id) : undefined;
432
const selectedItem = selectedFolderItem
433
?? previouslySelectedItem ?? items[0];
434
if (selectedItem) {
435
defaultRepoUri = vscode.Uri.file(selectedItem.id);
436
}
437
438
items.splice(MAX_MRU_ENTRIES); // Limit to max entries
439
// If user selected something from the list but it's not there anymore (perhaps its an item at the end of MRU).
440
if (selectedItem && !items.some(item => item.id === selectedItem.id)) {
441
items.push(selectedItem);
442
}
443
444
commands.push({
445
command: OPEN_REPOSITORY_COMMAND_ID,
446
title: l10n.t('Browse folders...')
447
});
448
449
optionGroups.push({
450
id: REPOSITORY_OPTION_ID,
451
name: l10n.t('Folder'),
452
description: l10n.t('Pick Folder'),
453
items,
454
selected: selectedItem,
455
commands
456
});
457
} else {
458
const repositories = this.getRepositoryOptionItems();
459
if (repositories.length > 1) {
460
const previouslySelected = previousInputState ? getSelectedOption(previousInputState.groups, REPOSITORY_OPTION_ID) : undefined;
461
const selectedFolderRepo = selectedFolderUri ? repositories.find(repository => repository.id === selectedFolderUri.fsPath) : undefined;
462
const selectedRepository = selectedFolderRepo ?? (previouslySelected ? repositories.find(repository => repository.id === previouslySelected.id) ?? repositories[0] : repositories[0]);
463
defaultRepoUri = selectedRepository.id ? vscode.Uri.file(selectedRepository.id) : defaultRepoUri;
464
optionGroups.push({
465
id: REPOSITORY_OPTION_ID,
466
name: l10n.t('Folder'),
467
description: l10n.t('Pick Folder'),
468
items: repositories,
469
selected: selectedRepository
470
});
471
} else if (repositories.length === 1) {
472
defaultRepoUri = vscode.Uri.file(repositories[0].id);
473
}
474
}
475
476
const repo = await this.getTrustedRepository(defaultRepoUri);
477
478
// When the selected folder is not a git repo (or untrusted), force isolation to workspace
479
if (defaultRepoUri && !repo && isolationEnabled) {
480
forceWorkspaceIsolation(optionGroups);
481
}
482
483
if (repo && isBranchOptionFeatureEnabled(this.configurationService)) {
484
const branches = await this.getBranchOptionItemsForRepository(repo.rootUri, repo.headBranchName);
485
const previouslySelectedBranchItem = previousInputState ? getSelectedOption(previousInputState.groups, BRANCH_OPTION_ID) : undefined;
486
const branchGroup = this.buildBranchOptionGroup(branches, repo.headBranchName, isolationEnabled, currentIsolation, previouslySelectedBranchItem);
487
if (branchGroup) {
488
optionGroups.push(branchGroup);
489
}
490
}
491
492
return optionGroups;
493
}
494
495
/**
496
* Build a branch option group from pre-fetched branch items.
497
* Returns undefined if there are no branches.
498
*/
499
buildBranchOptionGroup(
500
branches: vscode.ChatSessionProviderOptionItem[],
501
headBranchName: string | undefined,
502
isolationEnabled: boolean,
503
currentIsolation: IsolationMode | undefined,
504
previousSelection: vscode.ChatSessionProviderOptionItem | undefined,
505
): vscode.ChatSessionProviderOptionGroup | undefined {
506
if (branches.length === 0) {
507
return undefined;
508
}
509
// BUG: Work around for https://github.com/microsoft/vscode/issues/288457#issuecomment-4157935788
510
// Locked doesn't work, once locked, we cannot unlock.
511
const { locked } = resolveBranchLockState(isolationEnabled, currentIsolation);
512
// const locked = false;
513
// When locked (workspace isolation), ignore the previous selection so we
514
// always snap back to the active branch instead of keeping a stale pick.
515
const selectedItem = resolveBranchSelection(branches, headBranchName, locked ? undefined : previousSelection);
516
const lockedSelected = selectedItem && locked ? { ...selectedItem, locked } : undefined;
517
return {
518
id: BRANCH_OPTION_ID,
519
name: l10n.t('Branch'),
520
description: l10n.t('Pick Branch'),
521
items: lockedSelected ? [lockedSelected] : locked ? branches.map(b => ({ ...b, locked })) : branches,
522
selected: lockedSelected ?? selectedItem,
523
};
524
}
525
526
/**
527
* Rebuild dependent option groups based on current selections.
528
* Called when any dropdown changes — inspects each group's `selected`
529
* property to determine the current state and update accordingly.
530
*/
531
async handleInputStateChange(state: vscode.ChatSessionInputState): Promise<void> {
532
// Persist the user's isolation choice so it's remembered across sessions
533
const currentIsolation = getSelectedOption(state.groups, ISOLATION_OPTION_ID)?.id as IsolationMode | undefined;
534
if (currentIsolation) {
535
void this.context.globalState.update(LAST_USED_ISOLATION_OPTION_KEY, currentIsolation);
536
}
537
538
const newGroups = await this._buildGroupsOnce(state);
539
if (!optionGroupsEqual(state.groups, newGroups)) {
540
state.groups = newGroups;
541
}
542
}
543
544
/**
545
* Full rebuild of all option groups (isolation, folder, branch).
546
* Called when external state changes (workspace folders added/removed,
547
* git repos discovered/closed) that may require adding or removing
548
* entire dropdown groups — not just updating branch/isolation.
549
*/
550
async rebuildInputState(state: vscode.ChatSessionInputState, selectedFolderUri?: vscode.Uri): Promise<void> {
551
const newGroups = await this._buildGroupsOnce(state, selectedFolderUri);
552
if (!optionGroupsEqual(state.groups, newGroups) || selectedFolderUri) {
553
state.groups = newGroups;
554
}
555
if (selectedFolderUri) {
556
this._inputStateNewFolders.set(state, selectedFolderUri);
557
}
558
}
559
560
/**
561
* Deduplicate concurrent builds for the same state object.
562
* If a build is already in-flight for this state, return the same promise.
563
*/
564
private _buildGroupsOnce(state: vscode.ChatSessionInputState, selectedFolderUri?: vscode.Uri): Promise<vscode.ChatSessionProviderOptionGroup[]> {
565
const pending = this._pendingBuildGroups.get(state);
566
if (pending) {
567
return pending;
568
}
569
const promise = this.provideChatSessionProviderOptionGroups(state, selectedFolderUri).finally(() => {
570
this._pendingBuildGroups.delete(state);
571
});
572
this._pendingBuildGroups.set(state, promise);
573
return promise;
574
}
575
576
async buildExistingSessionInputStateGroups(resource: vscode.Uri, token: vscode.CancellationToken): Promise<vscode.ChatSessionProviderOptionGroup[]> {
577
const copilotcliSessionId = SessionIdForCLI.parse(resource);
578
const optionGroups: vscode.ChatSessionProviderOptionGroup[] = [];
579
const folderInfo = await this.folderRepositoryManager.getFolderRepository(copilotcliSessionId, undefined, token);
580
const repositories = isWelcomeView(this.workspaceService) ? folderMRUToChatProviderOptions(await this.copilotCLIFolderMruService.getRecentlyUsedFolders(token)) : this.getRepositoryOptionItems();
581
const folderOrRepoId = folderInfo.repository?.fsPath ?? folderInfo.folder?.fsPath;
582
const existingItem = folderOrRepoId ? repositories.find(repo => repo.id === folderOrRepoId) : undefined;
583
const worktreeProperties = await this.chatSessionWorktreeService.getWorktreeProperties(copilotcliSessionId);
584
585
let repoSelected: vscode.ChatSessionProviderOptionItem;
586
if (existingItem) {
587
repoSelected = { ...existingItem, locked: true };
588
} else if (folderInfo.repository) {
589
repoSelected = { ...toRepositoryOptionItem(folderInfo.repository), locked: true };
590
} else if (folderInfo.folder) {
591
const folderName = this.workspaceService.getWorkspaceFolderName(folderInfo.folder) || basename(folderInfo.folder);
592
repoSelected = { ...toWorkspaceFolderOptionItem(folderInfo.folder, folderName), locked: true };
593
} else {
594
let folderName = l10n.t('Unknown');
595
if (this.workspaceService.getWorkspaceFolders().length === 1) {
596
folderName = this.workspaceService.getWorkspaceFolderName(this.workspaceService.getWorkspaceFolders()[0]) || folderName;
597
}
598
repoSelected = { id: '', name: folderName, icon: new vscode.ThemeIcon('folder'), locked: true };
599
}
600
601
if (isIsolationOptionFeatureEnabled(this.configurationService)) {
602
const isWorktree = !!worktreeProperties;
603
const isolationSelected = {
604
id: isWorktree ? IsolationMode.Worktree : IsolationMode.Workspace,
605
name: isWorktree ? l10n.t('Worktree') : l10n.t('Workspace'),
606
icon: new vscode.ThemeIcon(isWorktree ? 'worktree' : 'folder'),
607
locked: true
608
};
609
optionGroups.push({
610
id: ISOLATION_OPTION_ID,
611
name: l10n.t('Isolation'),
612
description: l10n.t('Pick Isolation Mode'),
613
items: [
614
{ id: IsolationMode.Workspace, name: l10n.t('Workspace'), icon: new vscode.ThemeIcon('folder') },
615
{ id: IsolationMode.Worktree, name: l10n.t('Worktree'), icon: new vscode.ThemeIcon('worktree') },
616
],
617
selected: isolationSelected
618
});
619
}
620
621
optionGroups.push({
622
id: REPOSITORY_OPTION_ID,
623
name: l10n.t('Folder'),
624
description: l10n.t('Pick Folder'),
625
items: [repoSelected],
626
selected: repoSelected,
627
commands: []
628
});
629
630
const branchName = worktreeProperties?.branchName ?? folderInfo.repositoryProperties?.branchName;
631
if (branchName) {
632
const branchSelected = { id: branchName, name: branchName, icon: new vscode.ThemeIcon('git-branch'), locked: true };
633
optionGroups.push({
634
id: BRANCH_OPTION_ID,
635
name: l10n.t('Branch'),
636
description: l10n.t('Pick Branch'),
637
items: [branchSelected],
638
selected: branchSelected,
639
});
640
}
641
642
return optionGroups;
643
}
644
645
async getBranchOptionItemsForRepository(repoUri: Uri, headBranchName: string | undefined): Promise<vscode.ChatSessionProviderOptionItem[]> {
646
const key = `${repoUri.toString()}|${headBranchName ?? ''}`;
647
return this._getBranchOptionItemsForRepositorySequencer.queue(key, async () => {
648
649
const refs = await this.gitService.getRefs(repoUri, { sort: 'committerdate' });
650
651
// Filter to local branches only (RefType.Head === 0)
652
const localBranches = refs.filter(ref => ref.type === 0 /* RefType.Head */ && ref.name);
653
654
// Build items with HEAD branch first
655
const items: vscode.ChatSessionProviderOptionItem[] = [];
656
let headItem: vscode.ChatSessionProviderOptionItem | undefined;
657
let mainOrheadBranch: vscode.ChatSessionProviderOptionItem | undefined;
658
for (const ref of localBranches) {
659
if (!ref.name) {
660
continue;
661
}
662
if (ref.name.includes(COPILOT_WORKTREE_PATTERN)) {
663
continue;
664
}
665
const isHead = ref.name === headBranchName;
666
const item: vscode.ChatSessionProviderOptionItem = {
667
id: ref.name!,
668
name: ref.name!,
669
icon: new vscode.ThemeIcon('git-branch'),
670
// default: isHead
671
};
672
if (isHead) {
673
headItem = item;
674
} else if (ref.name === 'main' || ref.name === 'master') {
675
mainOrheadBranch = item;
676
} else {
677
items.push(item);
678
}
679
}
680
681
if (mainOrheadBranch) {
682
items.unshift(mainOrheadBranch);
683
}
684
if (headItem) {
685
items.unshift(headItem);
686
}
687
688
return items;
689
});
690
}
691
692
getRepositoryOptionItems() {
693
// Exclude worktrees from the repository list
694
const repositories = this.gitService.repositories
695
.filter(repository => repository.kind !== 'worktree')
696
.filter(repository => {
697
if (isWelcomeView(this.workspaceService)) {
698
// In the welcome view, include all repositories from the MRU list
699
return true;
700
}
701
// Only include repositories that belong to one of the workspace folders
702
return this.workspaceService.getWorkspaceFolder(repository.rootUri) !== undefined;
703
});
704
705
const repoItems = repositories
706
.map(repository => toRepositoryOptionItem(repository));
707
708
// In multi-root workspaces, also include workspace folders that don't have any git repos
709
const workspaceFolders = this.workspaceService.getWorkspaceFolders();
710
if (workspaceFolders.length) {
711
// Find workspace folders that contain git repos
712
const foldersWithRepos = new Set<string>();
713
for (const repo of repositories) {
714
const folder = this.workspaceService.getWorkspaceFolder(repo.rootUri);
715
if (folder) {
716
foldersWithRepos.add(folder.fsPath);
717
}
718
}
719
720
// Add workspace folders that don't have any git repos
721
for (const folder of workspaceFolders) {
722
if (!foldersWithRepos.has(folder.fsPath)) {
723
const folderName = this.workspaceService.getWorkspaceFolderName(folder);
724
repoItems.push(toWorkspaceFolderOptionItem(folder, folderName));
725
}
726
}
727
}
728
729
return repoItems.sort((a, b) => a.name.localeCompare(b.name));
730
}
731
732
lockInputStateGroups(state: vscode.ChatSessionInputState): void {
733
lockInputStateGroups(state);
734
}
735
736
updateBranchInInputState(state: vscode.ChatSessionInputState, branchName: string): void {
737
const existingIdx = state.groups.findIndex(g => g.id === BRANCH_OPTION_ID);
738
if (existingIdx === -1) {
739
return;
740
}
741
const branchSelected: vscode.ChatSessionProviderOptionItem = {
742
id: branchName,
743
name: branchName,
744
icon: new vscode.ThemeIcon('git-branch'),
745
locked: true,
746
};
747
const branchGroup: vscode.ChatSessionProviderOptionGroup = {
748
id: BRANCH_OPTION_ID,
749
name: l10n.t('Branch'),
750
description: l10n.t('Pick Branch'),
751
items: [branchSelected],
752
selected: branchSelected,
753
};
754
const updatedGroups = [...state.groups];
755
updatedGroups[existingIdx] = branchGroup;
756
state.groups = updatedGroups;
757
}
758
}
759
760
export function lockInputStateGroups(state: vscode.ChatSessionInputState): void {
761
state.groups = state.groups.map(group => ({
762
...group,
763
items: group.items.map(item => ({ ...item, locked: true })),
764
selected: group.selected ? { ...group.selected, locked: true } : undefined,
765
}));
766
}
767
768