Book a Demo!
CoCalc Logo Icon
StoreFeaturesDocsShareSupportNewsAboutPoliciesSign UpSign In
microsoft
GitHub Repository: microsoft/vscode
Path: blob/main/extensions/copilot/src/extension/chatSessions/vscode-node/test/sessionOptionGroupBuilder.spec.ts
13405 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
import { beforeAll, beforeEach, describe, expect, it, vi } from 'vitest';
7
import type * as vscode from 'vscode';
8
// eslint-disable-next-line no-duplicate-imports
9
import * as vscodeShim from 'vscode';
10
import { ConfigKey } from '../../../../platform/configuration/common/configurationService';
11
import { DefaultsOnlyConfigurationService } from '../../../../platform/configuration/common/defaultsOnlyConfigurationService';
12
import { InMemoryConfigurationService } from '../../../../platform/configuration/test/common/inMemoryConfigurationService';
13
import { IVSCodeExtensionContext } from '../../../../platform/extContext/common/extensionContext';
14
import { IGitService, RepoContext } from '../../../../platform/git/common/gitService';
15
import { NullWorkspaceService } from '../../../../platform/workspace/common/workspaceService';
16
import { mock } from '../../../../util/common/test/simpleMock';
17
import { CancellationToken } from '../../../../util/vs/base/common/cancellation';
18
import { Event } from '../../../../util/vs/base/common/event';
19
import { URI } from '../../../../util/vs/base/common/uri';
20
import { IAgentSessionsWorkspace } from '../../common/agentSessionsWorkspace';
21
import { ChatSessionWorktreeProperties, IChatSessionWorktreeService } from '../../common/chatSessionWorktreeService';
22
import { FolderRepositoryMRUEntry, IChatFolderMruService, IFolderRepositoryManager, IsolationMode } from '../../common/folderRepositoryManager';
23
import {
24
BRANCH_OPTION_ID,
25
ISOLATION_OPTION_ID,
26
REPOSITORY_OPTION_ID,
27
SessionOptionGroupBuilder,
28
folderMRUToChatProviderOptions,
29
getSelectedOption,
30
getSelectedSessionOptions,
31
isBranchOptionFeatureEnabled,
32
isIsolationOptionFeatureEnabled,
33
resolveBranchLockState,
34
resolveBranchSelection,
35
resolveIsolationSelection,
36
toRepositoryOptionItem,
37
toWorkspaceFolderOptionItem,
38
} from '../sessionOptionGroupBuilder';
39
40
beforeAll(() => {
41
(vscodeShim as Record<string, unknown>).workspace = {
42
...((vscodeShim as Record<string, unknown>).workspace as object),
43
isResourceTrusted: async () => true,
44
};
45
});
46
47
// ─── Test Helpers ────────────────────────────────────────────────
48
49
class TestGitService extends mock<IGitService>() {
50
declare readonly _serviceBrand: undefined;
51
override onDidOpenRepository = Event.None;
52
override onDidCloseRepository = Event.None;
53
override onDidFinishInitialization = Event.None;
54
override activeRepository = { get: () => undefined } as IGitService['activeRepository'];
55
override repositories: RepoContext[] = [];
56
override getRepository = vi.fn(async (_uri: URI): Promise<RepoContext | undefined> => this.repositories[0]);
57
override getRefs = vi.fn(async () => [] as { name: string | undefined; type: number }[]);
58
}
59
60
class TestFolderMruService extends mock<IChatFolderMruService>() {
61
declare readonly _serviceBrand: undefined;
62
override getRecentlyUsedFolders = vi.fn(async () => [] as FolderRepositoryMRUEntry[]);
63
override deleteRecentlyUsedFolder = vi.fn(async () => { });
64
}
65
66
class TestWorktreeService extends mock<IChatSessionWorktreeService>() {
67
declare readonly _serviceBrand: undefined;
68
override getWorktreeProperties = vi.fn(async (): Promise<ChatSessionWorktreeProperties | undefined> => undefined);
69
}
70
71
class TestFolderRepositoryManager extends mock<IFolderRepositoryManager>() {
72
declare readonly _serviceBrand: undefined;
73
override getFolderRepository = vi.fn(async () => ({
74
folder: undefined,
75
repository: undefined,
76
worktree: undefined,
77
worktreeProperties: undefined,
78
trusted: undefined,
79
}));
80
}
81
82
function createInMemoryContext(): IVSCodeExtensionContext {
83
const state = new Map<string, unknown>();
84
return {
85
globalState: {
86
get: (key: string, defaultValue?: unknown) => state.get(key) ?? defaultValue,
87
keys: () => [...state.keys()],
88
update: (key: string, value: unknown) => { state.set(key, value); return Promise.resolve(); },
89
},
90
} as unknown as IVSCodeExtensionContext;
91
}
92
93
function makeRepo(path: string, kind: 'repository' | 'worktree' = 'repository'): RepoContext {
94
return {
95
rootUri: URI.file(path),
96
kind,
97
headBranchName: 'main',
98
remotes: ['origin'],
99
remoteFetchUrls: ['https://github.com/owner/repo.git'],
100
} as unknown as RepoContext;
101
}
102
103
function makeRef(name: string, type: number = 0 /* Head */): { name: string; type: number } {
104
return { name, type };
105
}
106
107
function createMockChatSessionInputState(groups: readonly vscode.ChatSessionProviderOptionGroup[]): vscode.ChatSessionInputState {
108
return {
109
onDidDispose: Event.None,
110
onDidChange: Event.None,
111
groups,
112
sessionResource: undefined
113
};
114
}
115
116
// ─── Pure function tests ─────────────────────────────────────────
117
describe('SessionOptionGroupBuilder', () => {
118
119
describe('getSelectedOption', () => {
120
it('returns selected from matching group', () => {
121
const selected = { id: 'main', name: 'main' };
122
const groups: vscode.ChatSessionProviderOptionGroup[] = [
123
{ id: 'branch', name: 'Branch', description: '', items: [selected], selected },
124
];
125
expect(getSelectedOption(groups, 'branch')).toBe(selected);
126
});
127
128
it('returns undefined when group not found', () => {
129
expect(getSelectedOption([], 'branch')).toBeUndefined();
130
});
131
132
it('returns undefined when group has no selection', () => {
133
const groups: vscode.ChatSessionProviderOptionGroup[] = [
134
{ id: 'branch', name: 'Branch', description: '', items: [] },
135
];
136
expect(getSelectedOption(groups, 'branch')).toBeUndefined();
137
});
138
});
139
140
describe('getSelectedSessionOptions', () => {
141
it('extracts folder, branch, and isolation from input state groups', () => {
142
const inputState = createMockChatSessionInputState([
143
{ id: REPOSITORY_OPTION_ID, name: 'Folder', items: [{ id: '/my-repo', name: 'my-repo' }], selected: { id: '/my-repo', name: 'my-repo' } },
144
{ id: BRANCH_OPTION_ID, name: 'Branch', items: [{ id: 'main', name: 'main' }], selected: { id: 'main', name: 'main' } },
145
{ id: ISOLATION_OPTION_ID, name: 'Isolation', items: [{ id: IsolationMode.Worktree, name: 'Worktree' }], selected: { id: IsolationMode.Worktree, name: 'Worktree' } },
146
]);
147
const result = getSelectedSessionOptions(inputState);
148
expect(result.folder?.fsPath).toBe(URI.file('/my-repo').fsPath);
149
expect(result.branch).toBe('main');
150
expect(result.isolation).toBe(IsolationMode.Worktree);
151
});
152
153
it('returns undefined values when no groups are present', () => {
154
const inputState = createMockChatSessionInputState([]);
155
const result = getSelectedSessionOptions(inputState);
156
expect(result.folder).toBeUndefined();
157
expect(result.branch).toBeUndefined();
158
expect(result.isolation).toBeUndefined();
159
});
160
161
it('returns undefined values when groups have no selection', () => {
162
const inputState = createMockChatSessionInputState([
163
{ id: REPOSITORY_OPTION_ID, name: 'Folder', items: [] },
164
{ id: BRANCH_OPTION_ID, name: 'Branch', items: [] },
165
{ id: ISOLATION_OPTION_ID, name: 'Isolation', items: [] },
166
]);
167
const result = getSelectedSessionOptions(inputState);
168
expect(result.folder).toBeUndefined();
169
expect(result.branch).toBeUndefined();
170
expect(result.isolation).toBeUndefined();
171
});
172
});
173
174
describe('isBranchOptionFeatureEnabled / isIsolationOptionFeatureEnabled', () => {
175
it('reads CLIBranchSupport config key', () => {
176
const configService = new InMemoryConfigurationService(new DefaultsOnlyConfigurationService());
177
// Default value should be whatever the config default is
178
const result = isBranchOptionFeatureEnabled(configService);
179
expect(typeof result).toBe('boolean');
180
});
181
182
it('reads CLIIsolationOption config key', () => {
183
const configService = new InMemoryConfigurationService(new DefaultsOnlyConfigurationService());
184
const result = isIsolationOptionFeatureEnabled(configService);
185
expect(typeof result).toBe('boolean');
186
});
187
});
188
189
describe('toRepositoryOptionItem', () => {
190
it('creates option item from RepoContext', () => {
191
const repo = makeRepo('/workspace/my-project');
192
const item = toRepositoryOptionItem(repo);
193
expect(item.id).toBe(URI.file('/workspace/my-project').fsPath);
194
expect(item.name).toBe('my-project');
195
});
196
197
it('uses repo icon for repository kind', () => {
198
const repo = makeRepo('/repo', 'repository');
199
const item = toRepositoryOptionItem(repo);
200
expect(item.icon).toBeDefined();
201
});
202
203
it('creates option item from Uri', () => {
204
const uri = URI.file('/some/folder');
205
const item = toRepositoryOptionItem(uri as any);
206
expect(item.id).toBe(uri.fsPath);
207
expect(item.name).toBe('folder');
208
});
209
210
it('marks item as default when isDefault is true', () => {
211
const repo = makeRepo('/repo');
212
const item = toRepositoryOptionItem(repo, true);
213
expect(item.default).toBe(true);
214
});
215
});
216
217
describe('toWorkspaceFolderOptionItem', () => {
218
it('creates option item with folder icon', () => {
219
const uri = URI.file('/workspace/my-folder');
220
const item = toWorkspaceFolderOptionItem(uri, 'My Folder');
221
expect(item.id).toBe(uri.fsPath);
222
expect(item.name).toBe('My Folder');
223
expect(item.icon).toBeDefined();
224
});
225
});
226
227
describe('folderMRUToChatProviderOptions', () => {
228
it('converts MRU entries with repositories to repo option items', () => {
229
const uri = URI.file('/my-repo');
230
const entries: FolderRepositoryMRUEntry[] = [
231
{ folder: uri, repository: uri, lastAccessed: 100 },
232
];
233
const items = folderMRUToChatProviderOptions(entries);
234
expect(items).toHaveLength(1);
235
expect(items[0].id).toBe(uri.fsPath);
236
});
237
238
it('converts MRU entries without repositories to folder option items', () => {
239
const uri = URI.file('/my-folder');
240
const entries: FolderRepositoryMRUEntry[] = [
241
{ folder: uri, repository: undefined, lastAccessed: 100 },
242
];
243
const items = folderMRUToChatProviderOptions(entries);
244
expect(items).toHaveLength(1);
245
expect(items[0].id).toBe(uri.fsPath);
246
});
247
248
it('returns empty array for empty input', () => {
249
expect(folderMRUToChatProviderOptions([])).toEqual([]);
250
});
251
});
252
253
describe('resolveBranchSelection', () => {
254
const main = { id: 'main', name: 'main' };
255
const dev = { id: 'dev', name: 'dev' };
256
const featureX = { id: 'feature-x', name: 'feature-x' };
257
const branches = [main, dev, featureX];
258
259
it('returns previous selection if it still exists in the branch list', () => {
260
expect(resolveBranchSelection(branches, 'main', dev)?.id).toBe('dev');
261
});
262
263
it('falls back to active (HEAD) branch when previous selection is no longer in list', () => {
264
const stale = { id: 'deleted-branch', name: 'deleted-branch' };
265
expect(resolveBranchSelection(branches, 'main', stale)?.id).toBe('main');
266
});
267
268
it('preserves stale previous selection when no active branch matches either', () => {
269
const stale = { id: 'deleted-branch', name: 'deleted-branch' };
270
expect(resolveBranchSelection(branches, undefined, stale)?.id).toBe('deleted-branch');
271
});
272
273
it('returns active branch when there is no previous selection', () => {
274
expect(resolveBranchSelection(branches, 'dev', undefined)?.id).toBe('dev');
275
});
276
277
it('returns undefined when no branches, no active, no previous', () => {
278
expect(resolveBranchSelection([], undefined, undefined)).toBeUndefined();
279
});
280
281
it('returns undefined when branches exist but no active and no previous', () => {
282
expect(resolveBranchSelection(branches, undefined, undefined)).toBeUndefined();
283
});
284
});
285
286
describe('resolveBranchLockState', () => {
287
it('locked when isolation is enabled and Workspace is selected', () => {
288
const result = resolveBranchLockState(true, IsolationMode.Workspace);
289
expect(result.locked).toBe(true);
290
});
291
292
it('editable when isolation is enabled and Worktree is selected', () => {
293
const result = resolveBranchLockState(true, IsolationMode.Worktree);
294
expect(result.locked).toBe(false);
295
});
296
297
it('locked when isolation feature is disabled', () => {
298
const result = resolveBranchLockState(false, undefined);
299
expect(result.locked).toBe(true);
300
});
301
302
it('locked when isolation is disabled even if isolation value is worktree', () => {
303
const result = resolveBranchLockState(false, IsolationMode.Worktree);
304
expect(result.locked).toBe(true);
305
});
306
});
307
308
describe('resolveIsolationSelection', () => {
309
it('uses previous selection when it is a valid isolation mode', () => {
310
expect(resolveIsolationSelection(IsolationMode.Worktree, IsolationMode.Workspace)).toBe(IsolationMode.Workspace);
311
expect(resolveIsolationSelection(IsolationMode.Workspace, IsolationMode.Worktree)).toBe(IsolationMode.Worktree);
312
});
313
314
it('falls back to lastUsed when there is no previous selection', () => {
315
expect(resolveIsolationSelection(IsolationMode.Worktree, undefined)).toBe(IsolationMode.Worktree);
316
});
317
318
it('falls back to lastUsed when previous selection is not a valid isolation mode', () => {
319
expect(resolveIsolationSelection(IsolationMode.Workspace, 'invalid-value')).toBe(IsolationMode.Workspace);
320
});
321
});
322
323
// ─── SessionOptionGroupBuilder class tests ───────────────────────
324
325
describe('SessionOptionGroupBuilder Class', () => {
326
let gitService: TestGitService;
327
let configurationService: InMemoryConfigurationService;
328
let context: IVSCodeExtensionContext;
329
let workspaceService: NullWorkspaceService;
330
let folderMruService: TestFolderMruService;
331
let agentSessionsWorkspace: IAgentSessionsWorkspace;
332
let worktreeService: TestWorktreeService;
333
let folderRepositoryManager: TestFolderRepositoryManager;
334
let builder: SessionOptionGroupBuilder;
335
336
beforeEach(async () => {
337
vi.restoreAllMocks();
338
gitService = new TestGitService();
339
configurationService = new InMemoryConfigurationService(new DefaultsOnlyConfigurationService());
340
context = createInMemoryContext();
341
workspaceService = new NullWorkspaceService([URI.file('/workspace')]);
342
folderMruService = new TestFolderMruService();
343
agentSessionsWorkspace = { _serviceBrand: undefined, isAgentSessionsWorkspace: false };
344
worktreeService = new TestWorktreeService();
345
folderRepositoryManager = new TestFolderRepositoryManager();
346
347
builder = new SessionOptionGroupBuilder(
348
gitService,
349
configurationService,
350
context,
351
workspaceService,
352
folderMruService,
353
agentSessionsWorkspace,
354
worktreeService,
355
folderRepositoryManager,
356
);
357
});
358
359
describe('getRepositoryOptionItems', () => {
360
it('returns empty array when no repositories', () => {
361
gitService.repositories = [];
362
const items = builder.getRepositoryOptionItems();
363
// Should still return workspace folder as non-git folder
364
expect(items.length).toBeGreaterThanOrEqual(0);
365
});
366
367
it('excludes worktree repositories', () => {
368
gitService.repositories = [
369
makeRepo('/repo', 'repository'),
370
makeRepo('/worktree', 'worktree'),
371
];
372
const items = builder.getRepositoryOptionItems();
373
expect(items.find(i => i.id === URI.file('/worktree').fsPath)).toBeUndefined();
374
});
375
376
it('includes repositories that belong to workspace folders', () => {
377
const repoUri = URI.file('/workspace');
378
gitService.repositories = [makeRepo('/workspace')];
379
const items = builder.getRepositoryOptionItems();
380
expect(items.find(i => i.id === repoUri.fsPath)).toBeDefined();
381
});
382
383
it('includes workspace folders without git repos in multi-root', () => {
384
workspaceService = new NullWorkspaceService([URI.file('/workspace'), URI.file('/other-folder')]);
385
builder = new SessionOptionGroupBuilder(
386
gitService, configurationService, context, workspaceService,
387
folderMruService, agentSessionsWorkspace, worktreeService, folderRepositoryManager,
388
);
389
// Only one repo under /workspace
390
gitService.repositories = [makeRepo('/workspace')];
391
const items = builder.getRepositoryOptionItems();
392
// Should include the repo and the non-git folder
393
expect(items.length).toBe(2);
394
expect(items.find(i => i.id === URI.file('/other-folder').fsPath)).toBeDefined();
395
});
396
397
it('sorts items alphabetically by name', () => {
398
// NullWorkspaceService.getWorkspaceFolderName returns 'default', so we use git repos
399
// which derive their name from the URI path
400
workspaceService = new NullWorkspaceService([URI.file('/z-repo'), URI.file('/a-repo')]);
401
builder = new SessionOptionGroupBuilder(
402
gitService, configurationService, context, workspaceService,
403
folderMruService, agentSessionsWorkspace, worktreeService, folderRepositoryManager,
404
);
405
gitService.repositories = [makeRepo('/z-repo'), makeRepo('/a-repo')];
406
const items = builder.getRepositoryOptionItems();
407
expect(items.length).toBe(2);
408
expect(items[0].name).toBe('a-repo');
409
expect(items[1].name).toBe('z-repo');
410
});
411
});
412
413
describe('buildBranchOptionGroup', () => {
414
it('returns undefined when no branches', () => {
415
const result = builder.buildBranchOptionGroup([], 'main', false, undefined, undefined);
416
expect(result).toBeUndefined();
417
});
418
419
it('returns branch group with items', () => {
420
const branches = [
421
{ id: 'main', name: 'main', icon: {} as any },
422
{ id: 'dev', name: 'dev', icon: {} as any },
423
];
424
const result = builder.buildBranchOptionGroup(branches, 'main', false, undefined, undefined);
425
expect(result).toBeDefined();
426
expect(result!.id).toBe(BRANCH_OPTION_ID);
427
expect(result!.items).toHaveLength(1);
428
});
429
430
it('selects HEAD branch when no previous selection', () => {
431
const branches = [
432
{ id: 'main', name: 'main', icon: {} as any },
433
{ id: 'dev', name: 'dev', icon: {} as any },
434
];
435
const result = builder.buildBranchOptionGroup(branches, 'main', false, undefined, undefined);
436
expect(result!.selected?.id).toBe('main');
437
});
438
439
it('locks items when isolation is disabled', () => {
440
const branches = [{ id: 'main', name: 'main', icon: {} as any }];
441
const result = builder.buildBranchOptionGroup(branches, 'main', false, undefined, undefined);
442
expect(result!.items[0].locked).toBe(true);
443
});
444
445
it('locks items when isolation is enabled but Workspace is selected', () => {
446
const branches = [{ id: 'main', name: 'main', icon: {} as any }];
447
const result = builder.buildBranchOptionGroup(branches, 'main', true, IsolationMode.Workspace, undefined);
448
expect(result!.items[0].locked).toBe(true);
449
});
450
451
it('does not lock items when isolation is enabled and Worktree is selected', () => {
452
const branches = [{ id: 'main', name: 'main', icon: {} as any }];
453
const result = builder.buildBranchOptionGroup(branches, 'main', true, IsolationMode.Worktree, undefined);
454
expect(result!.items[0].locked).toBeUndefined();
455
});
456
457
it('resets to HEAD branch when locked with workspace isolation even if previous selection was different', () => {
458
const branches = [
459
{ id: 'main', name: 'main', icon: {} as any },
460
{ id: 'hello', name: 'hello', icon: {} as any },
461
];
462
const previousSelection = { id: 'hello', name: 'hello', icon: {} as any };
463
const result = builder.buildBranchOptionGroup(branches, 'main', true, IsolationMode.Workspace, previousSelection);
464
expect(result!.selected?.id).toBe('main');
465
expect(result!.selected?.locked).toBe(true);
466
});
467
});
468
469
describe('getBranchOptionItemsForRepository', () => {
470
it('returns branch items sorted with HEAD first', async () => {
471
const repoUri = URI.file('/repo');
472
gitService.getRefs.mockResolvedValue([
473
makeRef('feature'),
474
makeRef('main'),
475
makeRef('dev'),
476
]);
477
const items = await builder.getBranchOptionItemsForRepository(repoUri, 'main');
478
expect(items[0].id).toBe('main');
479
});
480
481
it('puts main/master branch second after HEAD', async () => {
482
const repoUri = URI.file('/repo');
483
gitService.getRefs.mockResolvedValue([
484
makeRef('feature'),
485
makeRef('main'),
486
makeRef('dev'),
487
]);
488
// HEAD is 'dev'
489
const items = await builder.getBranchOptionItemsForRepository(repoUri, 'dev');
490
expect(items[0].id).toBe('dev'); // HEAD first
491
expect(items[1].id).toBe('main'); // main/master second
492
});
493
494
it('filters out copilot-worktree branches', async () => {
495
const repoUri = URI.file('/repo');
496
gitService.getRefs.mockResolvedValue([
497
makeRef('main'),
498
makeRef('copilot-worktree-abc123'),
499
]);
500
const items = await builder.getBranchOptionItemsForRepository(repoUri, 'main');
501
expect(items).toHaveLength(1);
502
expect(items[0].id).toBe('main');
503
});
504
505
it('filters out non-local branches (remote refs)', async () => {
506
const repoUri = URI.file('/repo');
507
gitService.getRefs.mockResolvedValue([
508
makeRef('main'),
509
{ name: 'origin/main', type: 1 }, // RefType.Remote
510
]);
511
const items = await builder.getBranchOptionItemsForRepository(repoUri, 'main');
512
expect(items).toHaveLength(1);
513
});
514
515
it('returns empty array when no refs', async () => {
516
const repoUri = URI.file('/repo');
517
gitService.getRefs.mockResolvedValue([]);
518
const items = await builder.getBranchOptionItemsForRepository(repoUri, 'main');
519
expect(items).toHaveLength(0);
520
});
521
522
it('skips refs with no name', async () => {
523
const repoUri = URI.file('/repo');
524
gitService.getRefs.mockResolvedValue([
525
{ name: undefined, type: 0 },
526
makeRef('main'),
527
]);
528
const items = await builder.getBranchOptionItemsForRepository(repoUri, 'main');
529
expect(items).toHaveLength(1);
530
});
531
});
532
533
describe('provideChatSessionProviderOptionGroups', () => {
534
it('returns repository group for multi-repo workspaces', async () => {
535
workspaceService = new NullWorkspaceService([URI.file('/repo1'), URI.file('/repo2')]);
536
builder = new SessionOptionGroupBuilder(
537
gitService, configurationService, context, workspaceService,
538
folderMruService, agentSessionsWorkspace, worktreeService, folderRepositoryManager,
539
);
540
gitService.repositories = [makeRepo('/repo1'), makeRepo('/repo2')];
541
542
const groups = await builder.provideChatSessionProviderOptionGroups(undefined);
543
const repoGroup = groups.find(g => g.id === REPOSITORY_OPTION_ID);
544
expect(repoGroup).toBeDefined();
545
expect(repoGroup!.items.length).toBe(2);
546
});
547
548
it('pre-selects selectedFolderUri in multi-repo workspace', async () => {
549
workspaceService = new NullWorkspaceService([URI.file('/repo1'), URI.file('/repo2')]);
550
builder = new SessionOptionGroupBuilder(
551
gitService, configurationService, context, workspaceService,
552
folderMruService, agentSessionsWorkspace, worktreeService, folderRepositoryManager,
553
);
554
gitService.repositories = [makeRepo('/repo1'), makeRepo('/repo2')];
555
gitService.getRepository.mockResolvedValue(makeRepo('/repo2'));
556
await configurationService.setConfig(ConfigKey.Advanced.CLIBranchSupport, false);
557
558
const groups = await builder.provideChatSessionProviderOptionGroups(undefined, URI.file('/repo2') as any);
559
const repoGroup = groups.find(g => g.id === REPOSITORY_OPTION_ID);
560
expect(repoGroup).toBeDefined();
561
expect(repoGroup!.selected?.id).toBe(URI.file('/repo2').fsPath);
562
});
563
564
it('pre-selects selectedFolderUri over previous selection in multi-repo workspace', async () => {
565
workspaceService = new NullWorkspaceService([URI.file('/repo1'), URI.file('/repo2')]);
566
builder = new SessionOptionGroupBuilder(
567
gitService, configurationService, context, workspaceService,
568
folderMruService, agentSessionsWorkspace, worktreeService, folderRepositoryManager,
569
);
570
gitService.repositories = [makeRepo('/repo1'), makeRepo('/repo2')];
571
gitService.getRepository.mockResolvedValue(makeRepo('/repo2'));
572
await configurationService.setConfig(ConfigKey.Advanced.CLIBranchSupport, false);
573
574
const previousState = createMockChatSessionInputState([{
575
id: REPOSITORY_OPTION_ID,
576
name: 'Folder',
577
description: '',
578
items: [],
579
selected: { id: URI.file('/repo1').fsPath, name: 'repo1' },
580
}]);
581
582
const groups = await builder.provideChatSessionProviderOptionGroups(previousState, URI.file('/repo2') as any);
583
const repoGroup = groups.find(g => g.id === REPOSITORY_OPTION_ID);
584
expect(repoGroup!.selected?.id).toBe(URI.file('/repo2').fsPath);
585
});
586
587
it('does not include repository group for single-repo workspace', async () => {
588
gitService.repositories = [makeRepo('/workspace')];
589
await configurationService.setConfig(ConfigKey.Advanced.CLIBranchSupport, false);
590
await configurationService.setConfig(ConfigKey.Advanced.CLIIsolationOption, false);
591
592
const groups = await builder.provideChatSessionProviderOptionGroups(undefined);
593
const repoGroup = groups.find(g => g.id === REPOSITORY_OPTION_ID);
594
expect(repoGroup).toBeUndefined();
595
});
596
597
it('does not include repository group for single folder with no git repos', async () => {
598
gitService.repositories = [];
599
gitService.getRepository.mockResolvedValue(undefined);
600
await configurationService.setConfig(ConfigKey.Advanced.CLIBranchSupport, false);
601
await configurationService.setConfig(ConfigKey.Advanced.CLIIsolationOption, false);
602
603
const groups = await builder.provideChatSessionProviderOptionGroups(undefined);
604
expect(groups.find(g => g.id === REPOSITORY_OPTION_ID)).toBeUndefined();
605
});
606
607
it('includes isolation group when feature is enabled', async () => {
608
await configurationService.setConfig(ConfigKey.Advanced.CLIIsolationOption, true);
609
const groups = await builder.provideChatSessionProviderOptionGroups(undefined);
610
const isolationGroup = groups.find(g => g.id === ISOLATION_OPTION_ID);
611
expect(isolationGroup).toBeDefined();
612
expect(isolationGroup!.items).toHaveLength(2);
613
});
614
615
it('does not include isolation group when feature is disabled', async () => {
616
await configurationService.setConfig(ConfigKey.Advanced.CLIIsolationOption, false);
617
const groups = await builder.provideChatSessionProviderOptionGroups(undefined);
618
const isolationGroup = groups.find(g => g.id === ISOLATION_OPTION_ID);
619
expect(isolationGroup).toBeUndefined();
620
});
621
622
it('includes branch group when feature is enabled and repo exists', async () => {
623
await configurationService.setConfig(ConfigKey.Advanced.CLIBranchSupport, true);
624
const repo = makeRepo('/workspace');
625
gitService.repositories = [repo];
626
gitService.getRepository.mockResolvedValue(repo);
627
gitService.getRefs.mockResolvedValue([makeRef('main')]);
628
629
const groups = await builder.provideChatSessionProviderOptionGroups(undefined);
630
const branchGroup = groups.find(g => g.id === BRANCH_OPTION_ID);
631
expect(branchGroup).toBeDefined();
632
});
633
634
it('does not include branch group when feature is disabled', async () => {
635
await configurationService.setConfig(ConfigKey.Advanced.CLIBranchSupport, false);
636
const groups = await builder.provideChatSessionProviderOptionGroups(undefined);
637
const branchGroup = groups.find(g => g.id === BRANCH_OPTION_ID);
638
expect(branchGroup).toBeUndefined();
639
});
640
641
it('preserves previous isolation selection', async () => {
642
await configurationService.setConfig(ConfigKey.Advanced.CLIIsolationOption, true);
643
const repo = makeRepo('/workspace');
644
gitService.repositories = [repo];
645
gitService.getRepository.mockResolvedValue(repo);
646
647
const previousState = createMockChatSessionInputState([{
648
id: ISOLATION_OPTION_ID,
649
name: 'Isolation',
650
description: '',
651
items: [],
652
selected: { id: IsolationMode.Worktree, name: 'Worktree' },
653
}]);
654
655
const groups = await builder.provideChatSessionProviderOptionGroups(previousState);
656
const isolationGroup = groups.find(g => g.id === ISOLATION_OPTION_ID);
657
expect(isolationGroup!.selected?.id).toBe(IsolationMode.Worktree);
658
});
659
660
it('shows MRU items for welcome view (empty workspace)', async () => {
661
workspaceService = new NullWorkspaceService([]);
662
builder = new SessionOptionGroupBuilder(
663
gitService, configurationService, context, workspaceService,
664
folderMruService, agentSessionsWorkspace, worktreeService, folderRepositoryManager,
665
);
666
const mruUri = URI.file('/recent-repo');
667
folderMruService.getRecentlyUsedFolders.mockResolvedValue([
668
{ folder: mruUri, repository: mruUri, lastAccessed: Date.now() },
669
]);
670
671
const groups = await builder.provideChatSessionProviderOptionGroups(undefined);
672
const repoGroup = groups.find(g => g.id === REPOSITORY_OPTION_ID);
673
expect(repoGroup).toBeDefined();
674
expect(repoGroup!.items).toHaveLength(1);
675
expect(repoGroup!.items[0].id).toBe(mruUri.fsPath);
676
// First item should be auto-selected when no previous selection
677
expect(repoGroup!.selected?.id).toBe(mruUri.fsPath);
678
// Should have a command for browsing folders
679
expect(repoGroup!.commands).toBeDefined();
680
expect(repoGroup!.commands!.length).toBeGreaterThan(0);
681
});
682
683
it('caps MRU items at 10 entries in welcome view', async () => {
684
workspaceService = new NullWorkspaceService([]);
685
builder = new SessionOptionGroupBuilder(
686
gitService, configurationService, context, workspaceService,
687
folderMruService, agentSessionsWorkspace, worktreeService, folderRepositoryManager,
688
);
689
const entries = Array.from({ length: 15 }, (_, i) => {
690
const uri = URI.file(`/repo-${i}`);
691
return { folder: uri, repository: uri, lastAccessed: i } as FolderRepositoryMRUEntry;
692
});
693
folderMruService.getRecentlyUsedFolders.mockResolvedValue(entries);
694
695
const groups = await builder.provideChatSessionProviderOptionGroups(undefined);
696
const repoGroup = groups.find(g => g.id === REPOSITORY_OPTION_ID);
697
expect(repoGroup!.items).toHaveLength(10);
698
});
699
700
it('pre-selects selectedFolderUri in welcome view', async () => {
701
workspaceService = new NullWorkspaceService([]);
702
builder = new SessionOptionGroupBuilder(
703
gitService, configurationService, context, workspaceService,
704
folderMruService, agentSessionsWorkspace, worktreeService, folderRepositoryManager,
705
);
706
const mruUri1 = URI.file('/repo-a');
707
const mruUri2 = URI.file('/repo-b');
708
folderMruService.getRecentlyUsedFolders.mockResolvedValue([
709
{ folder: mruUri1, repository: mruUri1, lastAccessed: Date.now() },
710
{ folder: mruUri2, repository: mruUri2, lastAccessed: Date.now() - 1000 },
711
]);
712
713
const groups = await builder.provideChatSessionProviderOptionGroups(undefined, mruUri2 as any);
714
const repoGroup = groups.find(g => g.id === REPOSITORY_OPTION_ID);
715
expect(repoGroup!.selected?.id).toBe(mruUri2.fsPath);
716
});
717
718
it('pre-selects selectedFolderUri over previous selection in welcome view', async () => {
719
workspaceService = new NullWorkspaceService([]);
720
builder = new SessionOptionGroupBuilder(
721
gitService, configurationService, context, workspaceService,
722
folderMruService, agentSessionsWorkspace, worktreeService, folderRepositoryManager,
723
);
724
const mruUri1 = URI.file('/repo-a');
725
const mruUri2 = URI.file('/repo-b');
726
folderMruService.getRecentlyUsedFolders.mockResolvedValue([
727
{ folder: mruUri1, repository: mruUri1, lastAccessed: Date.now() },
728
{ folder: mruUri2, repository: mruUri2, lastAccessed: Date.now() - 1000 },
729
]);
730
731
const previousState = createMockChatSessionInputState([{
732
id: REPOSITORY_OPTION_ID,
733
name: 'Folder',
734
description: '',
735
items: [],
736
selected: { id: mruUri1.fsPath, name: 'repo-a' },
737
}]);
738
739
const groups = await builder.provideChatSessionProviderOptionGroups(previousState, mruUri2 as any);
740
const repoGroup = groups.find(g => g.id === REPOSITORY_OPTION_ID);
741
expect(repoGroup!.selected?.id).toBe(mruUri2.fsPath);
742
});
743
744
it('shows branch dropdown in welcome view when first MRU item is a git repo', async () => {
745
workspaceService = new NullWorkspaceService([]);
746
await configurationService.setConfig(ConfigKey.Advanced.CLIBranchSupport, true);
747
await configurationService.setConfig(ConfigKey.Advanced.CLIIsolationOption, true);
748
await context.globalState.update('github.copilot.cli.lastUsedIsolationOption', IsolationMode.Worktree);
749
builder = new SessionOptionGroupBuilder(
750
gitService, configurationService, context, workspaceService,
751
folderMruService, agentSessionsWorkspace, worktreeService, folderRepositoryManager,
752
);
753
const mruUri = URI.file('/recent-repo');
754
folderMruService.getRecentlyUsedFolders.mockResolvedValue([
755
{ folder: mruUri, repository: mruUri, lastAccessed: Date.now() },
756
]);
757
const repo = makeRepo(mruUri.fsPath);
758
gitService.getRepository.mockResolvedValue(repo);
759
gitService.getRefs.mockResolvedValue([makeRef('main'), makeRef('develop')]);
760
761
const groups = await builder.provideChatSessionProviderOptionGroups(undefined);
762
const branchGroup = groups.find(g => g.id === BRANCH_OPTION_ID);
763
expect(branchGroup).toBeDefined();
764
expect(branchGroup!.items.length).toBe(2);
765
});
766
767
it('selects no repo in welcome view when MRU is empty', async () => {
768
workspaceService = new NullWorkspaceService([]);
769
builder = new SessionOptionGroupBuilder(
770
gitService, configurationService, context, workspaceService,
771
folderMruService, agentSessionsWorkspace, worktreeService, folderRepositoryManager,
772
);
773
folderMruService.getRecentlyUsedFolders.mockResolvedValue([]);
774
775
const groups = await builder.provideChatSessionProviderOptionGroups(undefined);
776
const repoGroup = groups.find(g => g.id === REPOSITORY_OPTION_ID);
777
expect(repoGroup).toBeDefined();
778
expect(repoGroup!.items).toHaveLength(0);
779
expect(repoGroup!.selected).toBeUndefined();
780
});
781
782
it('preserves previous selection even when no longer in welcome view MRU', async () => {
783
workspaceService = new NullWorkspaceService([]);
784
builder = new SessionOptionGroupBuilder(
785
gitService, configurationService, context, workspaceService,
786
folderMruService, agentSessionsWorkspace, worktreeService, folderRepositoryManager,
787
);
788
const currentUri = URI.file('/current-repo');
789
const removedUri = URI.file('/removed-repo');
790
folderMruService.getRecentlyUsedFolders.mockResolvedValue([
791
{ folder: currentUri, repository: currentUri, lastAccessed: Date.now() },
792
]);
793
gitService.getRepository.mockResolvedValue(undefined);
794
795
const previousState = createMockChatSessionInputState([{
796
id: REPOSITORY_OPTION_ID,
797
name: 'Folder',
798
description: '',
799
items: [],
800
selected: { id: removedUri.fsPath, name: 'removed-repo' },
801
}]);
802
803
const groups = await builder.provideChatSessionProviderOptionGroups(previousState);
804
const repoGroup = groups.find(g => g.id === REPOSITORY_OPTION_ID);
805
// Previous selection is re-resolved and added to the top
806
expect(repoGroup!.selected?.id).toBe(removedUri.fsPath);
807
expect(repoGroup!.items[0].id).toBe(removedUri.fsPath);
808
});
809
810
it('adds new folder (git repo) to top of items in welcome view', async () => {
811
workspaceService = new NullWorkspaceService([]);
812
builder = new SessionOptionGroupBuilder(
813
gitService, configurationService, context, workspaceService,
814
folderMruService, agentSessionsWorkspace, worktreeService, folderRepositoryManager,
815
);
816
const mruUri = URI.file('/existing-repo');
817
folderMruService.getRecentlyUsedFolders.mockResolvedValue([
818
{ folder: mruUri, repository: mruUri, lastAccessed: Date.now() },
819
]);
820
const newFolderUri = URI.file('/new-git-folder');
821
const newRepo = makeRepo(newFolderUri.fsPath);
822
gitService.getRepository.mockResolvedValue(newRepo);
823
824
const groups = await builder.provideChatSessionProviderOptionGroups(undefined, newFolderUri as any);
825
const repoGroup = groups.find(g => g.id === REPOSITORY_OPTION_ID);
826
expect(repoGroup).toBeDefined();
827
expect(repoGroup!.items[0].id).toBe(newFolderUri.fsPath);
828
});
829
830
it('adds new folder (non-git) to top of items in welcome view', async () => {
831
workspaceService = new NullWorkspaceService([]);
832
builder = new SessionOptionGroupBuilder(
833
gitService, configurationService, context, workspaceService,
834
folderMruService, agentSessionsWorkspace, worktreeService, folderRepositoryManager,
835
);
836
const mruUri = URI.file('/existing-repo');
837
folderMruService.getRecentlyUsedFolders.mockResolvedValue([
838
{ folder: mruUri, repository: mruUri, lastAccessed: Date.now() },
839
]);
840
const newFolderUri = URI.file('/new-plain-folder');
841
gitService.getRepository.mockResolvedValue(undefined);
842
843
const groups = await builder.provideChatSessionProviderOptionGroups(undefined, newFolderUri as any);
844
const repoGroup = groups.find(g => g.id === REPOSITORY_OPTION_ID);
845
expect(repoGroup).toBeDefined();
846
expect(repoGroup!.items[0].id).toBe(newFolderUri.fsPath);
847
});
848
849
it('deduplicates new folder if already in MRU list', async () => {
850
workspaceService = new NullWorkspaceService([]);
851
builder = new SessionOptionGroupBuilder(
852
gitService, configurationService, context, workspaceService,
853
folderMruService, agentSessionsWorkspace, worktreeService, folderRepositoryManager,
854
);
855
const sharedUri = URI.file('/shared-repo');
856
folderMruService.getRecentlyUsedFolders.mockResolvedValue([
857
{ folder: sharedUri, repository: sharedUri, lastAccessed: Date.now() },
858
]);
859
const newRepo = makeRepo(sharedUri.fsPath);
860
gitService.getRepository.mockResolvedValue(newRepo);
861
862
const groups = await builder.provideChatSessionProviderOptionGroups(undefined, sharedUri as any);
863
const repoGroup = groups.find(g => g.id === REPOSITORY_OPTION_ID);
864
expect(repoGroup).toBeDefined();
865
// Should not have duplicates
866
const matchingItems = repoGroup!.items.filter(i => i.id === sharedUri.fsPath);
867
expect(matchingItems).toHaveLength(1);
868
// And it should be at the top
869
expect(repoGroup!.items[0].id).toBe(sharedUri.fsPath);
870
});
871
872
it('does not duplicate selected item when new folder replaces its MRU entry', async () => {
873
// Regression: the selected item was resolved from MRU before
874
// deduplication replaced it with a fresh object. Using reference
875
// equality (Array.includes) caused the stale reference to be
876
// re-appended, creating a duplicate.
877
workspaceService = new NullWorkspaceService([]);
878
builder = new SessionOptionGroupBuilder(
879
gitService, configurationService, context, workspaceService,
880
folderMruService, agentSessionsWorkspace, worktreeService, folderRepositoryManager,
881
);
882
const repoUri = URI.file('/my-repo');
883
folderMruService.getRecentlyUsedFolders.mockResolvedValue([
884
{ folder: repoUri, repository: repoUri, lastAccessed: Date.now() },
885
]);
886
gitService.getRepository.mockResolvedValue(makeRepo(repoUri.fsPath));
887
888
const groups = await builder.provideChatSessionProviderOptionGroups(undefined, repoUri as any);
889
const repoGroup = groups.find(g => g.id === REPOSITORY_OPTION_ID)!;
890
// Selected item must reference an object that is in the items list
891
expect(repoGroup.items.some(i => i.id === repoGroup.selected?.id)).toBe(true);
892
// And there must be exactly one item with that id
893
expect(repoGroup.items.filter(i => i.id === repoUri.fsPath)).toHaveLength(1);
894
});
895
896
it('does not add new folder when no previousInputState', async () => {
897
workspaceService = new NullWorkspaceService([]);
898
builder = new SessionOptionGroupBuilder(
899
gitService, configurationService, context, workspaceService,
900
folderMruService, agentSessionsWorkspace, worktreeService, folderRepositoryManager,
901
);
902
folderMruService.getRecentlyUsedFolders.mockResolvedValue([]);
903
904
const groups = await builder.provideChatSessionProviderOptionGroups(undefined);
905
const repoGroup = groups.find(g => g.id === REPOSITORY_OPTION_ID);
906
expect(repoGroup!.items).toHaveLength(0);
907
});
908
909
it('re-resolves previously selected folder as git repo when not in MRU', async () => {
910
// When the previous selection is not in the MRU list, the builder should
911
// look it up via getTrustedRepository and add it with the correct icon.
912
workspaceService = new NullWorkspaceService([]);
913
builder = new SessionOptionGroupBuilder(
914
gitService, configurationService, context, workspaceService,
915
folderMruService, agentSessionsWorkspace, worktreeService, folderRepositoryManager,
916
);
917
const mruUri = URI.file('/current-repo');
918
const prevUri = URI.file('/prev-repo');
919
folderMruService.getRecentlyUsedFolders.mockResolvedValue([
920
{ folder: mruUri, repository: mruUri, lastAccessed: Date.now() },
921
]);
922
const prevRepo = makeRepo(prevUri.fsPath);
923
gitService.getRepository.mockResolvedValue(prevRepo);
924
925
const previousState = createMockChatSessionInputState([{
926
id: REPOSITORY_OPTION_ID,
927
name: 'Folder',
928
description: '',
929
items: [],
930
selected: { id: prevUri.fsPath, name: 'prev-repo' },
931
}]);
932
933
const groups = await builder.provideChatSessionProviderOptionGroups(previousState);
934
const repoGroup = groups.find(g => g.id === REPOSITORY_OPTION_ID);
935
expect(repoGroup!.selected?.id).toBe(prevUri.fsPath);
936
// The previously selected item should be at the top
937
expect(repoGroup!.items[0].id).toBe(prevUri.fsPath);
938
});
939
});
940
941
describe('handleInputStateChange', () => {
942
it('rebuilds branch group when repo changes', async () => {
943
await configurationService.setConfig(ConfigKey.Advanced.CLIBranchSupport, true);
944
await configurationService.setConfig(ConfigKey.Advanced.CLIIsolationOption, true);
945
const repo = makeRepo('/new-repo');
946
gitService.getRepository.mockResolvedValue(repo);
947
gitService.getRefs.mockResolvedValue([makeRef('main'), makeRef('develop')]);
948
949
const state = createMockChatSessionInputState([
950
{
951
id: ISOLATION_OPTION_ID,
952
name: 'Isolation',
953
description: '',
954
items: [],
955
selected: { id: IsolationMode.Worktree, name: 'Worktree' },
956
},
957
{
958
id: REPOSITORY_OPTION_ID,
959
name: 'Folder',
960
description: '',
961
items: [],
962
selected: { id: URI.file('/new-repo').fsPath, name: 'new-repo' },
963
},
964
{
965
id: BRANCH_OPTION_ID,
966
name: 'Branch',
967
description: '',
968
items: [{ id: 'old-branch', name: 'old-branch' }],
969
selected: { id: 'old-branch', name: 'old-branch' },
970
},
971
]);
972
973
await builder.handleInputStateChange(state);
974
const branchGroup = state.groups.find(g => g.id === BRANCH_OPTION_ID);
975
expect(branchGroup).toBeDefined();
976
expect(branchGroup!.items.length).toBe(2);
977
});
978
979
it('removes branch group when repo has no branches', async () => {
980
await configurationService.setConfig(ConfigKey.Advanced.CLIBranchSupport, true);
981
gitService.getRepository.mockResolvedValue(makeRepo('/repo'));
982
gitService.getRefs.mockResolvedValue([]);
983
984
const state = createMockChatSessionInputState([
985
{
986
id: REPOSITORY_OPTION_ID,
987
name: 'Folder',
988
description: '',
989
items: [],
990
selected: { id: URI.file('/repo').fsPath, name: 'repo' },
991
},
992
{
993
id: BRANCH_OPTION_ID,
994
name: 'Branch',
995
description: '',
996
items: [{ id: 'old', name: 'old' }],
997
},
998
]);
999
1000
await builder.handleInputStateChange(state);
1001
const branchGroup = state.groups.find(g => g.id === BRANCH_OPTION_ID);
1002
expect(branchGroup).toBeUndefined();
1003
});
1004
1005
it('does not add branch group when branch feature is disabled', async () => {
1006
await configurationService.setConfig(ConfigKey.Advanced.CLIBranchSupport, false);
1007
1008
const state = createMockChatSessionInputState([{
1009
id: REPOSITORY_OPTION_ID,
1010
name: 'Folder',
1011
description: '',
1012
items: [],
1013
selected: { id: URI.file('/repo').fsPath, name: 'repo' },
1014
}]);
1015
1016
await builder.handleInputStateChange(state);
1017
expect(state.groups.find(g => g.id === BRANCH_OPTION_ID)).toBeUndefined();
1018
});
1019
1020
it('persists isolation selection to global state', async () => {
1021
await configurationService.setConfig(ConfigKey.Advanced.CLIIsolationOption, true);
1022
gitService.getRepository.mockResolvedValue(makeRepo('/workspace'));
1023
1024
const state = createMockChatSessionInputState([{
1025
id: ISOLATION_OPTION_ID,
1026
name: 'Isolation',
1027
description: '',
1028
items: [],
1029
selected: { id: IsolationMode.Worktree, name: 'Worktree' },
1030
}]);
1031
1032
await builder.handleInputStateChange(state);
1033
expect(context.globalState.get('github.copilot.cli.lastUsedIsolationOption')).toBe(IsolationMode.Worktree);
1034
});
1035
1036
it('forces workspace isolation when selected folder is not a git repo', async () => {
1037
await configurationService.setConfig(ConfigKey.Advanced.CLIIsolationOption, true);
1038
gitService.getRepository.mockResolvedValue(undefined);
1039
1040
const state = createMockChatSessionInputState([
1041
{
1042
id: ISOLATION_OPTION_ID,
1043
name: 'Isolation',
1044
description: '',
1045
items: [
1046
{ id: IsolationMode.Workspace, name: 'Workspace' },
1047
{ id: IsolationMode.Worktree, name: 'Worktree' },
1048
],
1049
selected: { id: IsolationMode.Worktree, name: 'Worktree' },
1050
},
1051
{
1052
id: REPOSITORY_OPTION_ID,
1053
name: 'Folder',
1054
description: '',
1055
items: [],
1056
selected: { id: URI.file('/non-git').fsPath, name: 'non-git' },
1057
},
1058
]);
1059
1060
await builder.handleInputStateChange(state);
1061
1062
const isolationGroup = state.groups.find(g => g.id === ISOLATION_OPTION_ID);
1063
expect(isolationGroup!.selected?.id).toBe(IsolationMode.Workspace);
1064
expect(isolationGroup!.selected?.locked).toBe(true);
1065
});
1066
1067
it('unlocks isolation when selected folder is a git repo', async () => {
1068
await configurationService.setConfig(ConfigKey.Advanced.CLIIsolationOption, true);
1069
gitService.getRepository.mockResolvedValue(makeRepo('/workspace'));
1070
1071
const state = createMockChatSessionInputState([
1072
{
1073
id: ISOLATION_OPTION_ID,
1074
name: 'Isolation',
1075
description: '',
1076
items: [
1077
{ id: IsolationMode.Workspace, name: 'Workspace', locked: true },
1078
{ id: IsolationMode.Worktree, name: 'Worktree', locked: true },
1079
],
1080
selected: { id: IsolationMode.Workspace, name: 'Workspace', locked: true },
1081
},
1082
{
1083
id: REPOSITORY_OPTION_ID,
1084
name: 'Folder',
1085
description: '',
1086
items: [],
1087
selected: { id: URI.file('/workspace').fsPath, name: 'workspace' },
1088
},
1089
]);
1090
1091
await builder.handleInputStateChange(state);
1092
1093
const isolationGroup = state.groups.find(g => g.id === ISOLATION_OPTION_ID);
1094
expect(isolationGroup!.selected?.locked).toBeUndefined();
1095
expect(isolationGroup!.items.every(i => !('locked' in i))).toBe(true);
1096
});
1097
});
1098
1099
describe('buildExistingSessionInputStateGroups', () => {
1100
it('returns locked groups for existing session', async () => {
1101
folderRepositoryManager.getFolderRepository.mockResolvedValue({
1102
folder: URI.file('/workspace'),
1103
repository: URI.file('/workspace'),
1104
worktree: undefined,
1105
worktreeProperties: undefined,
1106
trusted: true,
1107
} as any);
1108
worktreeService.getWorktreeProperties.mockResolvedValue(undefined);
1109
1110
const resource = URI.from({ scheme: 'copilotcli', path: '/session-1' });
1111
const groups = await builder.buildExistingSessionInputStateGroups(resource, CancellationToken.None);
1112
1113
const repoGroup = groups.find(g => g.id === REPOSITORY_OPTION_ID);
1114
expect(repoGroup).toBeDefined();
1115
expect(repoGroup!.selected?.locked).toBe(true);
1116
});
1117
1118
it('includes worktree branch for worktree sessions', async () => {
1119
const worktreeProps: ChatSessionWorktreeProperties = {
1120
version: 2,
1121
baseCommit: 'abc',
1122
baseBranchName: 'main',
1123
branchName: 'copilot/feature',
1124
repositoryPath: '/repo',
1125
worktreePath: '/wt',
1126
};
1127
folderRepositoryManager.getFolderRepository.mockResolvedValue({
1128
folder: URI.file('/repo'),
1129
repository: URI.file('/repo'),
1130
worktree: undefined,
1131
worktreeProperties: worktreeProps,
1132
trusted: true,
1133
} as any);
1134
worktreeService.getWorktreeProperties.mockResolvedValue(worktreeProps);
1135
1136
const resource = URI.from({ scheme: 'copilotcli', path: '/session-1' });
1137
const groups = await builder.buildExistingSessionInputStateGroups(resource, CancellationToken.None);
1138
1139
const branchGroup = groups.find(g => g.id === BRANCH_OPTION_ID);
1140
expect(branchGroup).toBeDefined();
1141
expect(branchGroup!.selected?.id).toBe('copilot/feature');
1142
expect(branchGroup!.selected?.locked).toBe(true);
1143
});
1144
1145
it('includes repository branch for non-worktree sessions', async () => {
1146
folderRepositoryManager.getFolderRepository.mockResolvedValue({
1147
folder: URI.file('/workspace'),
1148
repository: URI.file('/workspace'),
1149
repositoryProperties: {
1150
repositoryPath: '/workspace',
1151
branchName: 'main',
1152
baseBranchName: 'origin/main',
1153
},
1154
trusted: true,
1155
} as any);
1156
worktreeService.getWorktreeProperties.mockResolvedValue(undefined);
1157
1158
const resource = URI.from({ scheme: 'copilotcli', path: '/session-1' });
1159
const groups = await builder.buildExistingSessionInputStateGroups(resource, CancellationToken.None);
1160
1161
const branchGroup = groups.find(g => g.id === BRANCH_OPTION_ID);
1162
expect(branchGroup).toBeDefined();
1163
expect(branchGroup!.selected?.id).toBe('main');
1164
expect(branchGroup!.selected?.locked).toBe(true);
1165
expect(branchGroup!.when).toBeUndefined();
1166
});
1167
1168
it('includes isolation group when feature is enabled and session is worktree', async () => {
1169
await configurationService.setConfig(ConfigKey.Advanced.CLIIsolationOption, true);
1170
const worktreeProps: ChatSessionWorktreeProperties = {
1171
version: 2,
1172
baseCommit: 'abc',
1173
baseBranchName: 'main',
1174
branchName: 'copilot/feature',
1175
repositoryPath: '/repo',
1176
worktreePath: '/wt',
1177
};
1178
folderRepositoryManager.getFolderRepository.mockResolvedValue({
1179
folder: URI.file('/repo'),
1180
repository: URI.file('/repo'),
1181
trusted: true,
1182
} as any);
1183
worktreeService.getWorktreeProperties.mockResolvedValue(worktreeProps);
1184
1185
const resource = URI.from({ scheme: 'copilotcli', path: '/session-1' });
1186
const groups = await builder.buildExistingSessionInputStateGroups(resource, CancellationToken.None);
1187
1188
const isolationGroup = groups.find(g => g.id === ISOLATION_OPTION_ID);
1189
expect(isolationGroup).toBeDefined();
1190
expect(isolationGroup!.selected?.id).toBe(IsolationMode.Worktree);
1191
expect(isolationGroup!.selected?.locked).toBe(true);
1192
});
1193
1194
it('shows Workspace isolation for non-worktree sessions', async () => {
1195
await configurationService.setConfig(ConfigKey.Advanced.CLIIsolationOption, true);
1196
folderRepositoryManager.getFolderRepository.mockResolvedValue({
1197
folder: URI.file('/workspace'),
1198
repository: URI.file('/workspace'),
1199
trusted: true,
1200
} as any);
1201
worktreeService.getWorktreeProperties.mockResolvedValue(undefined);
1202
1203
const resource = URI.from({ scheme: 'copilotcli', path: '/session-1' });
1204
const groups = await builder.buildExistingSessionInputStateGroups(resource, CancellationToken.None);
1205
1206
const isolationGroup = groups.find(g => g.id === ISOLATION_OPTION_ID);
1207
expect(isolationGroup!.selected?.id).toBe(IsolationMode.Workspace);
1208
});
1209
1210
it('omits isolation group when feature is disabled for existing session', async () => {
1211
await configurationService.setConfig(ConfigKey.Advanced.CLIIsolationOption, false);
1212
folderRepositoryManager.getFolderRepository.mockResolvedValue({
1213
folder: URI.file('/workspace'),
1214
repository: URI.file('/workspace'),
1215
trusted: true,
1216
} as any);
1217
worktreeService.getWorktreeProperties.mockResolvedValue(undefined);
1218
1219
const resource = URI.from({ scheme: 'copilotcli', path: '/session-1' });
1220
const groups = await builder.buildExistingSessionInputStateGroups(resource, CancellationToken.None);
1221
1222
expect(groups.find(g => g.id === ISOLATION_OPTION_ID)).toBeUndefined();
1223
});
1224
1225
it('omits branch group when session has no branch name', async () => {
1226
folderRepositoryManager.getFolderRepository.mockResolvedValue({
1227
folder: URI.file('/workspace'),
1228
repository: undefined,
1229
repositoryProperties: undefined,
1230
trusted: true,
1231
} as any);
1232
worktreeService.getWorktreeProperties.mockResolvedValue(undefined);
1233
1234
const resource = URI.from({ scheme: 'copilotcli', path: '/session-1' });
1235
const groups = await builder.buildExistingSessionInputStateGroups(resource, CancellationToken.None);
1236
1237
expect(groups.find(g => g.id === BRANCH_OPTION_ID)).toBeUndefined();
1238
});
1239
});
1240
1241
describe('rebuildInputState', () => {
1242
it('adds folder dropdown when a second workspace folder appears', async () => {
1243
// Start with single workspace folder — no folder dropdown
1244
gitService.repositories = [makeRepo('/workspace')];
1245
gitService.getRepository.mockResolvedValue(makeRepo('/workspace'));
1246
await configurationService.setConfig(ConfigKey.Advanced.CLIBranchSupport, false);
1247
await configurationService.setConfig(ConfigKey.Advanced.CLIIsolationOption, false);
1248
1249
const initialGroups = await builder.provideChatSessionProviderOptionGroups(undefined);
1250
expect(initialGroups.find(g => g.id === REPOSITORY_OPTION_ID)).toBeUndefined();
1251
1252
const state = createMockChatSessionInputState(initialGroups);
1253
1254
// Simulate adding a second workspace folder
1255
workspaceService = new NullWorkspaceService([URI.file('/workspace'), URI.file('/workspace2')]);
1256
builder = new SessionOptionGroupBuilder(
1257
gitService, configurationService, context, workspaceService,
1258
folderMruService, agentSessionsWorkspace, worktreeService, folderRepositoryManager,
1259
);
1260
gitService.repositories = [makeRepo('/workspace'), makeRepo('/workspace2')];
1261
1262
await builder.rebuildInputState(state);
1263
1264
const repoGroup = state.groups.find(g => g.id === REPOSITORY_OPTION_ID);
1265
expect(repoGroup).toBeDefined();
1266
expect(repoGroup!.items.length).toBe(2);
1267
});
1268
1269
it('removes folder dropdown when going from two workspace folders to one', async () => {
1270
// Start with two workspace folders — folder dropdown shown
1271
workspaceService = new NullWorkspaceService([URI.file('/repo1'), URI.file('/repo2')]);
1272
builder = new SessionOptionGroupBuilder(
1273
gitService, configurationService, context, workspaceService,
1274
folderMruService, agentSessionsWorkspace, worktreeService, folderRepositoryManager,
1275
);
1276
gitService.repositories = [makeRepo('/repo1'), makeRepo('/repo2')];
1277
gitService.getRepository.mockResolvedValue(makeRepo('/repo1'));
1278
await configurationService.setConfig(ConfigKey.Advanced.CLIBranchSupport, false);
1279
await configurationService.setConfig(ConfigKey.Advanced.CLIIsolationOption, false);
1280
1281
const initialGroups = await builder.provideChatSessionProviderOptionGroups(undefined);
1282
expect(initialGroups.find(g => g.id === REPOSITORY_OPTION_ID)).toBeDefined();
1283
1284
const state = createMockChatSessionInputState(initialGroups);
1285
1286
// Simulate removing a workspace folder
1287
workspaceService = new NullWorkspaceService([URI.file('/repo1')]);
1288
builder = new SessionOptionGroupBuilder(
1289
gitService, configurationService, context, workspaceService,
1290
folderMruService, agentSessionsWorkspace, worktreeService, folderRepositoryManager,
1291
);
1292
gitService.repositories = [makeRepo('/repo1')];
1293
1294
await builder.rebuildInputState(state);
1295
1296
expect(state.groups.find(g => g.id === REPOSITORY_OPTION_ID)).toBeUndefined();
1297
});
1298
1299
it('adds branch dropdown after git init in single folder workspace', async () => {
1300
// Start with non-git folder — no branch dropdown
1301
gitService.repositories = [];
1302
gitService.getRepository.mockResolvedValue(undefined);
1303
await configurationService.setConfig(ConfigKey.Advanced.CLIBranchSupport, true);
1304
await configurationService.setConfig(ConfigKey.Advanced.CLIIsolationOption, false);
1305
1306
const initialGroups = await builder.provideChatSessionProviderOptionGroups(undefined);
1307
expect(initialGroups.find(g => g.id === BRANCH_OPTION_ID)).toBeUndefined();
1308
1309
const state = createMockChatSessionInputState(initialGroups);
1310
1311
// Simulate git init — repo now discovered
1312
const repo = makeRepo('/workspace');
1313
gitService.repositories = [repo];
1314
gitService.getRepository.mockResolvedValue(repo);
1315
gitService.getRefs.mockResolvedValue([makeRef('main')]);
1316
1317
await builder.rebuildInputState(state);
1318
1319
const branchGroup = state.groups.find(g => g.id === BRANCH_OPTION_ID);
1320
expect(branchGroup).toBeDefined();
1321
expect(branchGroup!.items.length).toBe(1);
1322
expect(branchGroup!.items[0].id).toBe('main');
1323
});
1324
1325
it('preserves selected folder across rebuild', async () => {
1326
workspaceService = new NullWorkspaceService([URI.file('/repo1'), URI.file('/repo2')]);
1327
builder = new SessionOptionGroupBuilder(
1328
gitService, configurationService, context, workspaceService,
1329
folderMruService, agentSessionsWorkspace, worktreeService, folderRepositoryManager,
1330
);
1331
gitService.repositories = [makeRepo('/repo1'), makeRepo('/repo2')];
1332
gitService.getRepository.mockResolvedValue(makeRepo('/repo2'));
1333
await configurationService.setConfig(ConfigKey.Advanced.CLIBranchSupport, false);
1334
await configurationService.setConfig(ConfigKey.Advanced.CLIIsolationOption, false);
1335
1336
// User selects /repo2
1337
const initialGroups = await builder.provideChatSessionProviderOptionGroups(undefined);
1338
const repoGroupIndex = initialGroups.findIndex(g => g.id === REPOSITORY_OPTION_ID);
1339
const repoGroup = initialGroups[repoGroupIndex];
1340
initialGroups[repoGroupIndex] = { ...repoGroup, selected: repoGroup.items.find(i => i.id === URI.file('/repo2').fsPath) };
1341
1342
const state = createMockChatSessionInputState(initialGroups);
1343
1344
// Add a third folder
1345
workspaceService = new NullWorkspaceService([URI.file('/repo1'), URI.file('/repo2'), URI.file('/repo3')]);
1346
builder = new SessionOptionGroupBuilder(
1347
gitService, configurationService, context, workspaceService,
1348
folderMruService, agentSessionsWorkspace, worktreeService, folderRepositoryManager,
1349
);
1350
gitService.repositories = [makeRepo('/repo1'), makeRepo('/repo2'), makeRepo('/repo3')];
1351
1352
await builder.rebuildInputState(state);
1353
1354
const newRepoGroup = state.groups.find(g => g.id === REPOSITORY_OPTION_ID)!;
1355
expect(newRepoGroup.items.length).toBe(3);
1356
// Previous selection preserved
1357
expect(newRepoGroup.selected?.id).toBe(URI.file('/repo2').fsPath);
1358
});
1359
1360
it('unlocks isolation after git init for non-git folder', async () => {
1361
// Start with non-git folder — isolation locked
1362
gitService.repositories = [];
1363
gitService.getRepository.mockResolvedValue(undefined);
1364
await configurationService.setConfig(ConfigKey.Advanced.CLIBranchSupport, false);
1365
await configurationService.setConfig(ConfigKey.Advanced.CLIIsolationOption, true);
1366
1367
const initialGroups = await builder.provideChatSessionProviderOptionGroups(undefined);
1368
const isolationGroup = initialGroups.find(g => g.id === ISOLATION_OPTION_ID);
1369
expect(isolationGroup).toBeDefined();
1370
// Should be locked to workspace for non-git
1371
expect(isolationGroup!.selected?.locked).toBe(true);
1372
1373
const state = createMockChatSessionInputState(initialGroups);
1374
1375
// Simulate git init
1376
const repo = makeRepo('/workspace');
1377
gitService.repositories = [repo];
1378
gitService.getRepository.mockResolvedValue(repo);
1379
1380
await builder.rebuildInputState(state);
1381
1382
const newIsolationGroup = state.groups.find(g => g.id === ISOLATION_OPTION_ID);
1383
expect(newIsolationGroup).toBeDefined();
1384
// Should be unlocked after git init
1385
expect(newIsolationGroup!.selected?.locked).toBeUndefined();
1386
});
1387
1388
it('rebuildInputState after lockInputStateGroups restores correct editable state', async () => {
1389
// Scenario: user starts a session, dropdowns are locked, then trust
1390
// fails and rebuildInputState is called to unlock them.
1391
const repo = makeRepo('/workspace');
1392
gitService.repositories = [repo];
1393
gitService.getRepository.mockResolvedValue(repo);
1394
gitService.getRefs.mockResolvedValue([makeRef('main'), makeRef('dev')]);
1395
await configurationService.setConfig(ConfigKey.Advanced.CLIBranchSupport, true);
1396
await configurationService.setConfig(ConfigKey.Advanced.CLIIsolationOption, true);
1397
1398
// Build initial groups (worktree isolation → branch editable)
1399
const initialGroups = await builder.provideChatSessionProviderOptionGroups(undefined);
1400
const state = createMockChatSessionInputState(initialGroups);
1401
1402
// Simulate selecting worktree isolation
1403
const isolationIdx = state.groups.findIndex(g => g.id === ISOLATION_OPTION_ID);
1404
const worktreeItem = state.groups[isolationIdx].items.find(i => i.id === IsolationMode.Worktree)!;
1405
const mutableGroups = [...state.groups];
1406
mutableGroups[isolationIdx] = { ...state.groups[isolationIdx], selected: worktreeItem };
1407
state.groups = mutableGroups;
1408
1409
// Lock all groups (simulating session start)
1410
builder.lockInputStateGroups(state);
1411
1412
// Verify everything is locked
1413
for (const group of state.groups) {
1414
expect(group.selected?.locked).toBe(true);
1415
for (const item of group.items) {
1416
expect(item.locked).toBe(true);
1417
}
1418
}
1419
1420
// Rebuild (simulating trust failure unlock)
1421
await builder.rebuildInputState(state);
1422
1423
// Branch should be editable (worktree isolation selected)
1424
const branchGroup = state.groups.find(g => g.id === BRANCH_OPTION_ID);
1425
expect(branchGroup).toBeDefined();
1426
expect(branchGroup!.selected?.locked).toBeUndefined();
1427
1428
// Isolation items should be editable
1429
const isolationGroup = state.groups.find(g => g.id === ISOLATION_OPTION_ID);
1430
expect(isolationGroup).toBeDefined();
1431
expect(isolationGroup!.selected?.locked).toBeUndefined();
1432
for (const item of isolationGroup!.items) {
1433
expect(item.locked).toBeUndefined();
1434
}
1435
});
1436
1437
it('rebuildInputState after lock re-applies branch lock when workspace isolation is selected', async () => {
1438
const repo = makeRepo('/workspace');
1439
gitService.repositories = [repo];
1440
gitService.getRepository.mockResolvedValue(repo);
1441
gitService.getRefs.mockResolvedValue([makeRef('main')]);
1442
await configurationService.setConfig(ConfigKey.Advanced.CLIBranchSupport, true);
1443
await configurationService.setConfig(ConfigKey.Advanced.CLIIsolationOption, true);
1444
1445
const initialGroups = await builder.provideChatSessionProviderOptionGroups(undefined);
1446
const state = createMockChatSessionInputState(initialGroups);
1447
1448
// Default isolation is workspace → branch should be locked
1449
builder.lockInputStateGroups(state);
1450
await builder.rebuildInputState(state);
1451
1452
const branchGroup = state.groups.find(g => g.id === BRANCH_OPTION_ID);
1453
expect(branchGroup).toBeDefined();
1454
// Branch must remain locked because workspace isolation is selected
1455
expect(branchGroup!.selected?.locked).toBe(true);
1456
});
1457
1458
it('rebuildInputState after lock re-applies isolation lock for non-git folder', async () => {
1459
gitService.repositories = [];
1460
gitService.getRepository.mockResolvedValue(undefined);
1461
await configurationService.setConfig(ConfigKey.Advanced.CLIBranchSupport, true);
1462
await configurationService.setConfig(ConfigKey.Advanced.CLIIsolationOption, true);
1463
1464
const initialGroups = await builder.provideChatSessionProviderOptionGroups(undefined);
1465
const state = createMockChatSessionInputState(initialGroups);
1466
1467
builder.lockInputStateGroups(state);
1468
await builder.rebuildInputState(state);
1469
1470
// Isolation should be forced to workspace and locked for non-git folder
1471
const isolationGroup = state.groups.find(g => g.id === ISOLATION_OPTION_ID);
1472
expect(isolationGroup).toBeDefined();
1473
expect(isolationGroup!.selected?.id).toBe(IsolationMode.Workspace);
1474
expect(isolationGroup!.selected?.locked).toBe(true);
1475
1476
// Branch should not be shown for non-git folder
1477
expect(state.groups.find(g => g.id === BRANCH_OPTION_ID)).toBeUndefined();
1478
});
1479
1480
it('stores selectedFolderUri so it persists in subsequent rebuilds (welcome view)', async () => {
1481
// In the welcome view, rebuildInputState with a selectedFolderUri should
1482
// remember it so the next rebuild keeps the folder in the list.
1483
workspaceService = new NullWorkspaceService([]);
1484
builder = new SessionOptionGroupBuilder(
1485
gitService, configurationService, context, workspaceService,
1486
folderMruService, agentSessionsWorkspace, worktreeService, folderRepositoryManager,
1487
);
1488
await configurationService.setConfig(ConfigKey.Advanced.CLIBranchSupport, false);
1489
await configurationService.setConfig(ConfigKey.Advanced.CLIIsolationOption, false);
1490
1491
const browsedUri = URI.file('/browsed-folder');
1492
folderMruService.getRecentlyUsedFolders.mockResolvedValue([]);
1493
gitService.getRepository.mockResolvedValue(undefined);
1494
1495
// Initial build — empty
1496
const initialGroups = await builder.provideChatSessionProviderOptionGroups(undefined);
1497
const state = createMockChatSessionInputState(initialGroups);
1498
1499
// Simulate "Browse folders…" — rebuild with the browsed folder
1500
await builder.rebuildInputState(state, browsedUri as any);
1501
const repoGroup1 = state.groups.find(g => g.id === REPOSITORY_OPTION_ID);
1502
expect(repoGroup1!.items.some(i => i.id === browsedUri.fsPath)).toBe(true);
1503
1504
// Second rebuild without selectedFolderUri — the browsed folder should persist
1505
folderMruService.getRecentlyUsedFolders.mockResolvedValue([]);
1506
await builder.rebuildInputState(state);
1507
const repoGroup2 = state.groups.find(g => g.id === REPOSITORY_OPTION_ID);
1508
expect(repoGroup2!.items.some(i => i.id === browsedUri.fsPath)).toBe(true);
1509
});
1510
});
1511
1512
describe('lockInputStateGroups', () => {
1513
it('locks all items and selections in every group', async () => {
1514
const repo = makeRepo('/workspace');
1515
gitService.repositories = [repo];
1516
gitService.getRepository.mockResolvedValue(repo);
1517
gitService.getRefs.mockResolvedValue([makeRef('main'), makeRef('dev')]);
1518
await configurationService.setConfig(ConfigKey.Advanced.CLIBranchSupport, true);
1519
await configurationService.setConfig(ConfigKey.Advanced.CLIIsolationOption, true);
1520
1521
const initialGroups = await builder.provideChatSessionProviderOptionGroups(undefined);
1522
const state = createMockChatSessionInputState(initialGroups);
1523
1524
// Verify some items are unlocked before locking
1525
const isolationBefore = state.groups.find(g => g.id === ISOLATION_OPTION_ID);
1526
expect(isolationBefore!.items.some(i => !i.locked)).toBe(true);
1527
1528
builder.lockInputStateGroups(state);
1529
1530
for (const group of state.groups) {
1531
if (group.selected) {
1532
expect(group.selected.locked).toBe(true);
1533
}
1534
for (const item of group.items) {
1535
expect(item.locked).toBe(true);
1536
}
1537
}
1538
});
1539
1540
it('preserves group ids and selected ids after locking', async () => {
1541
const repo = makeRepo('/workspace');
1542
gitService.repositories = [repo];
1543
gitService.getRepository.mockResolvedValue(repo);
1544
gitService.getRefs.mockResolvedValue([makeRef('main')]);
1545
await configurationService.setConfig(ConfigKey.Advanced.CLIBranchSupport, true);
1546
await configurationService.setConfig(ConfigKey.Advanced.CLIIsolationOption, true);
1547
1548
const initialGroups = await builder.provideChatSessionProviderOptionGroups(undefined);
1549
const groupIds = initialGroups.map(g => g.id);
1550
const selectedIds = initialGroups.map(g => g.selected?.id);
1551
const state = createMockChatSessionInputState(initialGroups);
1552
1553
builder.lockInputStateGroups(state);
1554
1555
expect(state.groups.map(g => g.id)).toEqual(groupIds);
1556
expect(state.groups.map(g => g.selected?.id)).toEqual(selectedIds);
1557
});
1558
1559
it('handles groups with no selected item', () => {
1560
const state = createMockChatSessionInputState([
1561
{ id: 'test', name: 'Test', items: [{ id: 'a', name: 'A' }] },
1562
]);
1563
1564
builder.lockInputStateGroups(state);
1565
1566
expect(state.groups[0].selected).toBeUndefined();
1567
expect(state.groups[0].items[0].locked).toBe(true);
1568
});
1569
1570
it('handles empty groups array', () => {
1571
const state = createMockChatSessionInputState([]);
1572
1573
builder.lockInputStateGroups(state);
1574
1575
expect(state.groups).toEqual([]);
1576
});
1577
});
1578
1579
describe('updateBranchInInputState', () => {
1580
it('replaces existing branch group with new locked branch', async () => {
1581
const repo = makeRepo('/workspace');
1582
gitService.repositories = [repo];
1583
gitService.getRepository.mockResolvedValue(repo);
1584
gitService.getRefs.mockResolvedValue([makeRef('main'), makeRef('dev')]);
1585
await configurationService.setConfig(ConfigKey.Advanced.CLIBranchSupport, true);
1586
await configurationService.setConfig(ConfigKey.Advanced.CLIIsolationOption, true);
1587
1588
// Select worktree isolation so branch dropdown has multiple editable items
1589
await context.globalState.update('github.copilot.cli.lastUsedIsolationOption', IsolationMode.Worktree);
1590
1591
const initialGroups = await builder.provideChatSessionProviderOptionGroups(undefined);
1592
const state = createMockChatSessionInputState(initialGroups);
1593
1594
// Verify branch group exists with multiple items (worktree → editable)
1595
const branchBefore = state.groups.find(g => g.id === BRANCH_OPTION_ID);
1596
expect(branchBefore).toBeDefined();
1597
expect(branchBefore!.items.length).toBeGreaterThan(1);
1598
1599
builder.updateBranchInInputState(state, 'copilot/my-feature');
1600
1601
const branchAfter = state.groups.find(g => g.id === BRANCH_OPTION_ID);
1602
expect(branchAfter).toBeDefined();
1603
expect(branchAfter!.items).toHaveLength(1);
1604
expect(branchAfter!.items[0].id).toBe('copilot/my-feature');
1605
expect(branchAfter!.items[0].locked).toBe(true);
1606
expect(branchAfter!.selected?.id).toBe('copilot/my-feature');
1607
expect(branchAfter!.selected?.locked).toBe(true);
1608
});
1609
1610
it('does not add branch group when none exists', () => {
1611
const state = createMockChatSessionInputState([
1612
{
1613
id: ISOLATION_OPTION_ID,
1614
name: 'Isolation',
1615
items: [{ id: IsolationMode.Workspace, name: 'Workspace' }],
1616
selected: { id: IsolationMode.Workspace, name: 'Workspace' },
1617
},
1618
]);
1619
1620
builder.updateBranchInInputState(state, 'copilot/my-feature');
1621
1622
// Should not add a branch group
1623
expect(state.groups.find(g => g.id === BRANCH_OPTION_ID)).toBeUndefined();
1624
expect(state.groups).toHaveLength(1);
1625
});
1626
1627
it('preserves other groups when updating branch', async () => {
1628
const repo = makeRepo('/workspace');
1629
gitService.repositories = [repo];
1630
gitService.getRepository.mockResolvedValue(repo);
1631
gitService.getRefs.mockResolvedValue([makeRef('main')]);
1632
await configurationService.setConfig(ConfigKey.Advanced.CLIBranchSupport, true);
1633
await configurationService.setConfig(ConfigKey.Advanced.CLIIsolationOption, true);
1634
1635
const initialGroups = await builder.provideChatSessionProviderOptionGroups(undefined);
1636
const state = createMockChatSessionInputState(initialGroups);
1637
1638
const isolationBefore = state.groups.find(g => g.id === ISOLATION_OPTION_ID);
1639
1640
builder.updateBranchInInputState(state, 'copilot/new-branch');
1641
1642
// Isolation group should be unchanged
1643
const isolationAfter = state.groups.find(g => g.id === ISOLATION_OPTION_ID);
1644
expect(isolationAfter).toEqual(isolationBefore);
1645
1646
// Branch group should be updated
1647
const branchAfter = state.groups.find(g => g.id === BRANCH_OPTION_ID);
1648
expect(branchAfter!.selected?.id).toBe('copilot/new-branch');
1649
});
1650
});
1651
});
1652
});
1653
1654