Path: blob/main/extensions/copilot/src/extension/chatSessions/vscode-node/test/sessionOptionGroupBuilder.spec.ts
13405 views
/*---------------------------------------------------------------------------------------------1* Copyright (c) Microsoft Corporation. All rights reserved.2* Licensed under the MIT License. See License.txt in the project root for license information.3*--------------------------------------------------------------------------------------------*/45import { beforeAll, beforeEach, describe, expect, it, vi } from 'vitest';6import type * as vscode from 'vscode';7// eslint-disable-next-line no-duplicate-imports8import * as vscodeShim from 'vscode';9import { ConfigKey } from '../../../../platform/configuration/common/configurationService';10import { DefaultsOnlyConfigurationService } from '../../../../platform/configuration/common/defaultsOnlyConfigurationService';11import { InMemoryConfigurationService } from '../../../../platform/configuration/test/common/inMemoryConfigurationService';12import { IVSCodeExtensionContext } from '../../../../platform/extContext/common/extensionContext';13import { IGitService, RepoContext } from '../../../../platform/git/common/gitService';14import { NullWorkspaceService } from '../../../../platform/workspace/common/workspaceService';15import { mock } from '../../../../util/common/test/simpleMock';16import { CancellationToken } from '../../../../util/vs/base/common/cancellation';17import { Event } from '../../../../util/vs/base/common/event';18import { URI } from '../../../../util/vs/base/common/uri';19import { IAgentSessionsWorkspace } from '../../common/agentSessionsWorkspace';20import { ChatSessionWorktreeProperties, IChatSessionWorktreeService } from '../../common/chatSessionWorktreeService';21import { FolderRepositoryMRUEntry, IChatFolderMruService, IFolderRepositoryManager, IsolationMode } from '../../common/folderRepositoryManager';22import {23BRANCH_OPTION_ID,24ISOLATION_OPTION_ID,25REPOSITORY_OPTION_ID,26SessionOptionGroupBuilder,27folderMRUToChatProviderOptions,28getSelectedOption,29getSelectedSessionOptions,30isBranchOptionFeatureEnabled,31isIsolationOptionFeatureEnabled,32resolveBranchLockState,33resolveBranchSelection,34resolveIsolationSelection,35toRepositoryOptionItem,36toWorkspaceFolderOptionItem,37} from '../sessionOptionGroupBuilder';3839beforeAll(() => {40(vscodeShim as Record<string, unknown>).workspace = {41...((vscodeShim as Record<string, unknown>).workspace as object),42isResourceTrusted: async () => true,43};44});4546// ─── Test Helpers ────────────────────────────────────────────────4748class TestGitService extends mock<IGitService>() {49declare readonly _serviceBrand: undefined;50override onDidOpenRepository = Event.None;51override onDidCloseRepository = Event.None;52override onDidFinishInitialization = Event.None;53override activeRepository = { get: () => undefined } as IGitService['activeRepository'];54override repositories: RepoContext[] = [];55override getRepository = vi.fn(async (_uri: URI): Promise<RepoContext | undefined> => this.repositories[0]);56override getRefs = vi.fn(async () => [] as { name: string | undefined; type: number }[]);57}5859class TestFolderMruService extends mock<IChatFolderMruService>() {60declare readonly _serviceBrand: undefined;61override getRecentlyUsedFolders = vi.fn(async () => [] as FolderRepositoryMRUEntry[]);62override deleteRecentlyUsedFolder = vi.fn(async () => { });63}6465class TestWorktreeService extends mock<IChatSessionWorktreeService>() {66declare readonly _serviceBrand: undefined;67override getWorktreeProperties = vi.fn(async (): Promise<ChatSessionWorktreeProperties | undefined> => undefined);68}6970class TestFolderRepositoryManager extends mock<IFolderRepositoryManager>() {71declare readonly _serviceBrand: undefined;72override getFolderRepository = vi.fn(async () => ({73folder: undefined,74repository: undefined,75worktree: undefined,76worktreeProperties: undefined,77trusted: undefined,78}));79}8081function createInMemoryContext(): IVSCodeExtensionContext {82const state = new Map<string, unknown>();83return {84globalState: {85get: (key: string, defaultValue?: unknown) => state.get(key) ?? defaultValue,86keys: () => [...state.keys()],87update: (key: string, value: unknown) => { state.set(key, value); return Promise.resolve(); },88},89} as unknown as IVSCodeExtensionContext;90}9192function makeRepo(path: string, kind: 'repository' | 'worktree' = 'repository'): RepoContext {93return {94rootUri: URI.file(path),95kind,96headBranchName: 'main',97remotes: ['origin'],98remoteFetchUrls: ['https://github.com/owner/repo.git'],99} as unknown as RepoContext;100}101102function makeRef(name: string, type: number = 0 /* Head */): { name: string; type: number } {103return { name, type };104}105106function createMockChatSessionInputState(groups: readonly vscode.ChatSessionProviderOptionGroup[]): vscode.ChatSessionInputState {107return {108onDidDispose: Event.None,109onDidChange: Event.None,110groups,111sessionResource: undefined112};113}114115// ─── Pure function tests ─────────────────────────────────────────116describe('SessionOptionGroupBuilder', () => {117118describe('getSelectedOption', () => {119it('returns selected from matching group', () => {120const selected = { id: 'main', name: 'main' };121const groups: vscode.ChatSessionProviderOptionGroup[] = [122{ id: 'branch', name: 'Branch', description: '', items: [selected], selected },123];124expect(getSelectedOption(groups, 'branch')).toBe(selected);125});126127it('returns undefined when group not found', () => {128expect(getSelectedOption([], 'branch')).toBeUndefined();129});130131it('returns undefined when group has no selection', () => {132const groups: vscode.ChatSessionProviderOptionGroup[] = [133{ id: 'branch', name: 'Branch', description: '', items: [] },134];135expect(getSelectedOption(groups, 'branch')).toBeUndefined();136});137});138139describe('getSelectedSessionOptions', () => {140it('extracts folder, branch, and isolation from input state groups', () => {141const inputState = createMockChatSessionInputState([142{ id: REPOSITORY_OPTION_ID, name: 'Folder', items: [{ id: '/my-repo', name: 'my-repo' }], selected: { id: '/my-repo', name: 'my-repo' } },143{ id: BRANCH_OPTION_ID, name: 'Branch', items: [{ id: 'main', name: 'main' }], selected: { id: 'main', name: 'main' } },144{ id: ISOLATION_OPTION_ID, name: 'Isolation', items: [{ id: IsolationMode.Worktree, name: 'Worktree' }], selected: { id: IsolationMode.Worktree, name: 'Worktree' } },145]);146const result = getSelectedSessionOptions(inputState);147expect(result.folder?.fsPath).toBe(URI.file('/my-repo').fsPath);148expect(result.branch).toBe('main');149expect(result.isolation).toBe(IsolationMode.Worktree);150});151152it('returns undefined values when no groups are present', () => {153const inputState = createMockChatSessionInputState([]);154const result = getSelectedSessionOptions(inputState);155expect(result.folder).toBeUndefined();156expect(result.branch).toBeUndefined();157expect(result.isolation).toBeUndefined();158});159160it('returns undefined values when groups have no selection', () => {161const inputState = createMockChatSessionInputState([162{ id: REPOSITORY_OPTION_ID, name: 'Folder', items: [] },163{ id: BRANCH_OPTION_ID, name: 'Branch', items: [] },164{ id: ISOLATION_OPTION_ID, name: 'Isolation', items: [] },165]);166const result = getSelectedSessionOptions(inputState);167expect(result.folder).toBeUndefined();168expect(result.branch).toBeUndefined();169expect(result.isolation).toBeUndefined();170});171});172173describe('isBranchOptionFeatureEnabled / isIsolationOptionFeatureEnabled', () => {174it('reads CLIBranchSupport config key', () => {175const configService = new InMemoryConfigurationService(new DefaultsOnlyConfigurationService());176// Default value should be whatever the config default is177const result = isBranchOptionFeatureEnabled(configService);178expect(typeof result).toBe('boolean');179});180181it('reads CLIIsolationOption config key', () => {182const configService = new InMemoryConfigurationService(new DefaultsOnlyConfigurationService());183const result = isIsolationOptionFeatureEnabled(configService);184expect(typeof result).toBe('boolean');185});186});187188describe('toRepositoryOptionItem', () => {189it('creates option item from RepoContext', () => {190const repo = makeRepo('/workspace/my-project');191const item = toRepositoryOptionItem(repo);192expect(item.id).toBe(URI.file('/workspace/my-project').fsPath);193expect(item.name).toBe('my-project');194});195196it('uses repo icon for repository kind', () => {197const repo = makeRepo('/repo', 'repository');198const item = toRepositoryOptionItem(repo);199expect(item.icon).toBeDefined();200});201202it('creates option item from Uri', () => {203const uri = URI.file('/some/folder');204const item = toRepositoryOptionItem(uri as any);205expect(item.id).toBe(uri.fsPath);206expect(item.name).toBe('folder');207});208209it('marks item as default when isDefault is true', () => {210const repo = makeRepo('/repo');211const item = toRepositoryOptionItem(repo, true);212expect(item.default).toBe(true);213});214});215216describe('toWorkspaceFolderOptionItem', () => {217it('creates option item with folder icon', () => {218const uri = URI.file('/workspace/my-folder');219const item = toWorkspaceFolderOptionItem(uri, 'My Folder');220expect(item.id).toBe(uri.fsPath);221expect(item.name).toBe('My Folder');222expect(item.icon).toBeDefined();223});224});225226describe('folderMRUToChatProviderOptions', () => {227it('converts MRU entries with repositories to repo option items', () => {228const uri = URI.file('/my-repo');229const entries: FolderRepositoryMRUEntry[] = [230{ folder: uri, repository: uri, lastAccessed: 100 },231];232const items = folderMRUToChatProviderOptions(entries);233expect(items).toHaveLength(1);234expect(items[0].id).toBe(uri.fsPath);235});236237it('converts MRU entries without repositories to folder option items', () => {238const uri = URI.file('/my-folder');239const entries: FolderRepositoryMRUEntry[] = [240{ folder: uri, repository: undefined, lastAccessed: 100 },241];242const items = folderMRUToChatProviderOptions(entries);243expect(items).toHaveLength(1);244expect(items[0].id).toBe(uri.fsPath);245});246247it('returns empty array for empty input', () => {248expect(folderMRUToChatProviderOptions([])).toEqual([]);249});250});251252describe('resolveBranchSelection', () => {253const main = { id: 'main', name: 'main' };254const dev = { id: 'dev', name: 'dev' };255const featureX = { id: 'feature-x', name: 'feature-x' };256const branches = [main, dev, featureX];257258it('returns previous selection if it still exists in the branch list', () => {259expect(resolveBranchSelection(branches, 'main', dev)?.id).toBe('dev');260});261262it('falls back to active (HEAD) branch when previous selection is no longer in list', () => {263const stale = { id: 'deleted-branch', name: 'deleted-branch' };264expect(resolveBranchSelection(branches, 'main', stale)?.id).toBe('main');265});266267it('preserves stale previous selection when no active branch matches either', () => {268const stale = { id: 'deleted-branch', name: 'deleted-branch' };269expect(resolveBranchSelection(branches, undefined, stale)?.id).toBe('deleted-branch');270});271272it('returns active branch when there is no previous selection', () => {273expect(resolveBranchSelection(branches, 'dev', undefined)?.id).toBe('dev');274});275276it('returns undefined when no branches, no active, no previous', () => {277expect(resolveBranchSelection([], undefined, undefined)).toBeUndefined();278});279280it('returns undefined when branches exist but no active and no previous', () => {281expect(resolveBranchSelection(branches, undefined, undefined)).toBeUndefined();282});283});284285describe('resolveBranchLockState', () => {286it('locked when isolation is enabled and Workspace is selected', () => {287const result = resolveBranchLockState(true, IsolationMode.Workspace);288expect(result.locked).toBe(true);289});290291it('editable when isolation is enabled and Worktree is selected', () => {292const result = resolveBranchLockState(true, IsolationMode.Worktree);293expect(result.locked).toBe(false);294});295296it('locked when isolation feature is disabled', () => {297const result = resolveBranchLockState(false, undefined);298expect(result.locked).toBe(true);299});300301it('locked when isolation is disabled even if isolation value is worktree', () => {302const result = resolveBranchLockState(false, IsolationMode.Worktree);303expect(result.locked).toBe(true);304});305});306307describe('resolveIsolationSelection', () => {308it('uses previous selection when it is a valid isolation mode', () => {309expect(resolveIsolationSelection(IsolationMode.Worktree, IsolationMode.Workspace)).toBe(IsolationMode.Workspace);310expect(resolveIsolationSelection(IsolationMode.Workspace, IsolationMode.Worktree)).toBe(IsolationMode.Worktree);311});312313it('falls back to lastUsed when there is no previous selection', () => {314expect(resolveIsolationSelection(IsolationMode.Worktree, undefined)).toBe(IsolationMode.Worktree);315});316317it('falls back to lastUsed when previous selection is not a valid isolation mode', () => {318expect(resolveIsolationSelection(IsolationMode.Workspace, 'invalid-value')).toBe(IsolationMode.Workspace);319});320});321322// ─── SessionOptionGroupBuilder class tests ───────────────────────323324describe('SessionOptionGroupBuilder Class', () => {325let gitService: TestGitService;326let configurationService: InMemoryConfigurationService;327let context: IVSCodeExtensionContext;328let workspaceService: NullWorkspaceService;329let folderMruService: TestFolderMruService;330let agentSessionsWorkspace: IAgentSessionsWorkspace;331let worktreeService: TestWorktreeService;332let folderRepositoryManager: TestFolderRepositoryManager;333let builder: SessionOptionGroupBuilder;334335beforeEach(async () => {336vi.restoreAllMocks();337gitService = new TestGitService();338configurationService = new InMemoryConfigurationService(new DefaultsOnlyConfigurationService());339context = createInMemoryContext();340workspaceService = new NullWorkspaceService([URI.file('/workspace')]);341folderMruService = new TestFolderMruService();342agentSessionsWorkspace = { _serviceBrand: undefined, isAgentSessionsWorkspace: false };343worktreeService = new TestWorktreeService();344folderRepositoryManager = new TestFolderRepositoryManager();345346builder = new SessionOptionGroupBuilder(347gitService,348configurationService,349context,350workspaceService,351folderMruService,352agentSessionsWorkspace,353worktreeService,354folderRepositoryManager,355);356});357358describe('getRepositoryOptionItems', () => {359it('returns empty array when no repositories', () => {360gitService.repositories = [];361const items = builder.getRepositoryOptionItems();362// Should still return workspace folder as non-git folder363expect(items.length).toBeGreaterThanOrEqual(0);364});365366it('excludes worktree repositories', () => {367gitService.repositories = [368makeRepo('/repo', 'repository'),369makeRepo('/worktree', 'worktree'),370];371const items = builder.getRepositoryOptionItems();372expect(items.find(i => i.id === URI.file('/worktree').fsPath)).toBeUndefined();373});374375it('includes repositories that belong to workspace folders', () => {376const repoUri = URI.file('/workspace');377gitService.repositories = [makeRepo('/workspace')];378const items = builder.getRepositoryOptionItems();379expect(items.find(i => i.id === repoUri.fsPath)).toBeDefined();380});381382it('includes workspace folders without git repos in multi-root', () => {383workspaceService = new NullWorkspaceService([URI.file('/workspace'), URI.file('/other-folder')]);384builder = new SessionOptionGroupBuilder(385gitService, configurationService, context, workspaceService,386folderMruService, agentSessionsWorkspace, worktreeService, folderRepositoryManager,387);388// Only one repo under /workspace389gitService.repositories = [makeRepo('/workspace')];390const items = builder.getRepositoryOptionItems();391// Should include the repo and the non-git folder392expect(items.length).toBe(2);393expect(items.find(i => i.id === URI.file('/other-folder').fsPath)).toBeDefined();394});395396it('sorts items alphabetically by name', () => {397// NullWorkspaceService.getWorkspaceFolderName returns 'default', so we use git repos398// which derive their name from the URI path399workspaceService = new NullWorkspaceService([URI.file('/z-repo'), URI.file('/a-repo')]);400builder = new SessionOptionGroupBuilder(401gitService, configurationService, context, workspaceService,402folderMruService, agentSessionsWorkspace, worktreeService, folderRepositoryManager,403);404gitService.repositories = [makeRepo('/z-repo'), makeRepo('/a-repo')];405const items = builder.getRepositoryOptionItems();406expect(items.length).toBe(2);407expect(items[0].name).toBe('a-repo');408expect(items[1].name).toBe('z-repo');409});410});411412describe('buildBranchOptionGroup', () => {413it('returns undefined when no branches', () => {414const result = builder.buildBranchOptionGroup([], 'main', false, undefined, undefined);415expect(result).toBeUndefined();416});417418it('returns branch group with items', () => {419const branches = [420{ id: 'main', name: 'main', icon: {} as any },421{ id: 'dev', name: 'dev', icon: {} as any },422];423const result = builder.buildBranchOptionGroup(branches, 'main', false, undefined, undefined);424expect(result).toBeDefined();425expect(result!.id).toBe(BRANCH_OPTION_ID);426expect(result!.items).toHaveLength(1);427});428429it('selects HEAD branch when no previous selection', () => {430const branches = [431{ id: 'main', name: 'main', icon: {} as any },432{ id: 'dev', name: 'dev', icon: {} as any },433];434const result = builder.buildBranchOptionGroup(branches, 'main', false, undefined, undefined);435expect(result!.selected?.id).toBe('main');436});437438it('locks items when isolation is disabled', () => {439const branches = [{ id: 'main', name: 'main', icon: {} as any }];440const result = builder.buildBranchOptionGroup(branches, 'main', false, undefined, undefined);441expect(result!.items[0].locked).toBe(true);442});443444it('locks items when isolation is enabled but Workspace is selected', () => {445const branches = [{ id: 'main', name: 'main', icon: {} as any }];446const result = builder.buildBranchOptionGroup(branches, 'main', true, IsolationMode.Workspace, undefined);447expect(result!.items[0].locked).toBe(true);448});449450it('does not lock items when isolation is enabled and Worktree is selected', () => {451const branches = [{ id: 'main', name: 'main', icon: {} as any }];452const result = builder.buildBranchOptionGroup(branches, 'main', true, IsolationMode.Worktree, undefined);453expect(result!.items[0].locked).toBeUndefined();454});455456it('resets to HEAD branch when locked with workspace isolation even if previous selection was different', () => {457const branches = [458{ id: 'main', name: 'main', icon: {} as any },459{ id: 'hello', name: 'hello', icon: {} as any },460];461const previousSelection = { id: 'hello', name: 'hello', icon: {} as any };462const result = builder.buildBranchOptionGroup(branches, 'main', true, IsolationMode.Workspace, previousSelection);463expect(result!.selected?.id).toBe('main');464expect(result!.selected?.locked).toBe(true);465});466});467468describe('getBranchOptionItemsForRepository', () => {469it('returns branch items sorted with HEAD first', async () => {470const repoUri = URI.file('/repo');471gitService.getRefs.mockResolvedValue([472makeRef('feature'),473makeRef('main'),474makeRef('dev'),475]);476const items = await builder.getBranchOptionItemsForRepository(repoUri, 'main');477expect(items[0].id).toBe('main');478});479480it('puts main/master branch second after HEAD', async () => {481const repoUri = URI.file('/repo');482gitService.getRefs.mockResolvedValue([483makeRef('feature'),484makeRef('main'),485makeRef('dev'),486]);487// HEAD is 'dev'488const items = await builder.getBranchOptionItemsForRepository(repoUri, 'dev');489expect(items[0].id).toBe('dev'); // HEAD first490expect(items[1].id).toBe('main'); // main/master second491});492493it('filters out copilot-worktree branches', async () => {494const repoUri = URI.file('/repo');495gitService.getRefs.mockResolvedValue([496makeRef('main'),497makeRef('copilot-worktree-abc123'),498]);499const items = await builder.getBranchOptionItemsForRepository(repoUri, 'main');500expect(items).toHaveLength(1);501expect(items[0].id).toBe('main');502});503504it('filters out non-local branches (remote refs)', async () => {505const repoUri = URI.file('/repo');506gitService.getRefs.mockResolvedValue([507makeRef('main'),508{ name: 'origin/main', type: 1 }, // RefType.Remote509]);510const items = await builder.getBranchOptionItemsForRepository(repoUri, 'main');511expect(items).toHaveLength(1);512});513514it('returns empty array when no refs', async () => {515const repoUri = URI.file('/repo');516gitService.getRefs.mockResolvedValue([]);517const items = await builder.getBranchOptionItemsForRepository(repoUri, 'main');518expect(items).toHaveLength(0);519});520521it('skips refs with no name', async () => {522const repoUri = URI.file('/repo');523gitService.getRefs.mockResolvedValue([524{ name: undefined, type: 0 },525makeRef('main'),526]);527const items = await builder.getBranchOptionItemsForRepository(repoUri, 'main');528expect(items).toHaveLength(1);529});530});531532describe('provideChatSessionProviderOptionGroups', () => {533it('returns repository group for multi-repo workspaces', async () => {534workspaceService = new NullWorkspaceService([URI.file('/repo1'), URI.file('/repo2')]);535builder = new SessionOptionGroupBuilder(536gitService, configurationService, context, workspaceService,537folderMruService, agentSessionsWorkspace, worktreeService, folderRepositoryManager,538);539gitService.repositories = [makeRepo('/repo1'), makeRepo('/repo2')];540541const groups = await builder.provideChatSessionProviderOptionGroups(undefined);542const repoGroup = groups.find(g => g.id === REPOSITORY_OPTION_ID);543expect(repoGroup).toBeDefined();544expect(repoGroup!.items.length).toBe(2);545});546547it('pre-selects selectedFolderUri in multi-repo workspace', async () => {548workspaceService = new NullWorkspaceService([URI.file('/repo1'), URI.file('/repo2')]);549builder = new SessionOptionGroupBuilder(550gitService, configurationService, context, workspaceService,551folderMruService, agentSessionsWorkspace, worktreeService, folderRepositoryManager,552);553gitService.repositories = [makeRepo('/repo1'), makeRepo('/repo2')];554gitService.getRepository.mockResolvedValue(makeRepo('/repo2'));555await configurationService.setConfig(ConfigKey.Advanced.CLIBranchSupport, false);556557const groups = await builder.provideChatSessionProviderOptionGroups(undefined, URI.file('/repo2') as any);558const repoGroup = groups.find(g => g.id === REPOSITORY_OPTION_ID);559expect(repoGroup).toBeDefined();560expect(repoGroup!.selected?.id).toBe(URI.file('/repo2').fsPath);561});562563it('pre-selects selectedFolderUri over previous selection in multi-repo workspace', async () => {564workspaceService = new NullWorkspaceService([URI.file('/repo1'), URI.file('/repo2')]);565builder = new SessionOptionGroupBuilder(566gitService, configurationService, context, workspaceService,567folderMruService, agentSessionsWorkspace, worktreeService, folderRepositoryManager,568);569gitService.repositories = [makeRepo('/repo1'), makeRepo('/repo2')];570gitService.getRepository.mockResolvedValue(makeRepo('/repo2'));571await configurationService.setConfig(ConfigKey.Advanced.CLIBranchSupport, false);572573const previousState = createMockChatSessionInputState([{574id: REPOSITORY_OPTION_ID,575name: 'Folder',576description: '',577items: [],578selected: { id: URI.file('/repo1').fsPath, name: 'repo1' },579}]);580581const groups = await builder.provideChatSessionProviderOptionGroups(previousState, URI.file('/repo2') as any);582const repoGroup = groups.find(g => g.id === REPOSITORY_OPTION_ID);583expect(repoGroup!.selected?.id).toBe(URI.file('/repo2').fsPath);584});585586it('does not include repository group for single-repo workspace', async () => {587gitService.repositories = [makeRepo('/workspace')];588await configurationService.setConfig(ConfigKey.Advanced.CLIBranchSupport, false);589await configurationService.setConfig(ConfigKey.Advanced.CLIIsolationOption, false);590591const groups = await builder.provideChatSessionProviderOptionGroups(undefined);592const repoGroup = groups.find(g => g.id === REPOSITORY_OPTION_ID);593expect(repoGroup).toBeUndefined();594});595596it('does not include repository group for single folder with no git repos', async () => {597gitService.repositories = [];598gitService.getRepository.mockResolvedValue(undefined);599await configurationService.setConfig(ConfigKey.Advanced.CLIBranchSupport, false);600await configurationService.setConfig(ConfigKey.Advanced.CLIIsolationOption, false);601602const groups = await builder.provideChatSessionProviderOptionGroups(undefined);603expect(groups.find(g => g.id === REPOSITORY_OPTION_ID)).toBeUndefined();604});605606it('includes isolation group when feature is enabled', async () => {607await configurationService.setConfig(ConfigKey.Advanced.CLIIsolationOption, true);608const groups = await builder.provideChatSessionProviderOptionGroups(undefined);609const isolationGroup = groups.find(g => g.id === ISOLATION_OPTION_ID);610expect(isolationGroup).toBeDefined();611expect(isolationGroup!.items).toHaveLength(2);612});613614it('does not include isolation group when feature is disabled', async () => {615await configurationService.setConfig(ConfigKey.Advanced.CLIIsolationOption, false);616const groups = await builder.provideChatSessionProviderOptionGroups(undefined);617const isolationGroup = groups.find(g => g.id === ISOLATION_OPTION_ID);618expect(isolationGroup).toBeUndefined();619});620621it('includes branch group when feature is enabled and repo exists', async () => {622await configurationService.setConfig(ConfigKey.Advanced.CLIBranchSupport, true);623const repo = makeRepo('/workspace');624gitService.repositories = [repo];625gitService.getRepository.mockResolvedValue(repo);626gitService.getRefs.mockResolvedValue([makeRef('main')]);627628const groups = await builder.provideChatSessionProviderOptionGroups(undefined);629const branchGroup = groups.find(g => g.id === BRANCH_OPTION_ID);630expect(branchGroup).toBeDefined();631});632633it('does not include branch group when feature is disabled', async () => {634await configurationService.setConfig(ConfigKey.Advanced.CLIBranchSupport, false);635const groups = await builder.provideChatSessionProviderOptionGroups(undefined);636const branchGroup = groups.find(g => g.id === BRANCH_OPTION_ID);637expect(branchGroup).toBeUndefined();638});639640it('preserves previous isolation selection', async () => {641await configurationService.setConfig(ConfigKey.Advanced.CLIIsolationOption, true);642const repo = makeRepo('/workspace');643gitService.repositories = [repo];644gitService.getRepository.mockResolvedValue(repo);645646const previousState = createMockChatSessionInputState([{647id: ISOLATION_OPTION_ID,648name: 'Isolation',649description: '',650items: [],651selected: { id: IsolationMode.Worktree, name: 'Worktree' },652}]);653654const groups = await builder.provideChatSessionProviderOptionGroups(previousState);655const isolationGroup = groups.find(g => g.id === ISOLATION_OPTION_ID);656expect(isolationGroup!.selected?.id).toBe(IsolationMode.Worktree);657});658659it('shows MRU items for welcome view (empty workspace)', async () => {660workspaceService = new NullWorkspaceService([]);661builder = new SessionOptionGroupBuilder(662gitService, configurationService, context, workspaceService,663folderMruService, agentSessionsWorkspace, worktreeService, folderRepositoryManager,664);665const mruUri = URI.file('/recent-repo');666folderMruService.getRecentlyUsedFolders.mockResolvedValue([667{ folder: mruUri, repository: mruUri, lastAccessed: Date.now() },668]);669670const groups = await builder.provideChatSessionProviderOptionGroups(undefined);671const repoGroup = groups.find(g => g.id === REPOSITORY_OPTION_ID);672expect(repoGroup).toBeDefined();673expect(repoGroup!.items).toHaveLength(1);674expect(repoGroup!.items[0].id).toBe(mruUri.fsPath);675// First item should be auto-selected when no previous selection676expect(repoGroup!.selected?.id).toBe(mruUri.fsPath);677// Should have a command for browsing folders678expect(repoGroup!.commands).toBeDefined();679expect(repoGroup!.commands!.length).toBeGreaterThan(0);680});681682it('caps MRU items at 10 entries in welcome view', async () => {683workspaceService = new NullWorkspaceService([]);684builder = new SessionOptionGroupBuilder(685gitService, configurationService, context, workspaceService,686folderMruService, agentSessionsWorkspace, worktreeService, folderRepositoryManager,687);688const entries = Array.from({ length: 15 }, (_, i) => {689const uri = URI.file(`/repo-${i}`);690return { folder: uri, repository: uri, lastAccessed: i } as FolderRepositoryMRUEntry;691});692folderMruService.getRecentlyUsedFolders.mockResolvedValue(entries);693694const groups = await builder.provideChatSessionProviderOptionGroups(undefined);695const repoGroup = groups.find(g => g.id === REPOSITORY_OPTION_ID);696expect(repoGroup!.items).toHaveLength(10);697});698699it('pre-selects selectedFolderUri in welcome view', async () => {700workspaceService = new NullWorkspaceService([]);701builder = new SessionOptionGroupBuilder(702gitService, configurationService, context, workspaceService,703folderMruService, agentSessionsWorkspace, worktreeService, folderRepositoryManager,704);705const mruUri1 = URI.file('/repo-a');706const mruUri2 = URI.file('/repo-b');707folderMruService.getRecentlyUsedFolders.mockResolvedValue([708{ folder: mruUri1, repository: mruUri1, lastAccessed: Date.now() },709{ folder: mruUri2, repository: mruUri2, lastAccessed: Date.now() - 1000 },710]);711712const groups = await builder.provideChatSessionProviderOptionGroups(undefined, mruUri2 as any);713const repoGroup = groups.find(g => g.id === REPOSITORY_OPTION_ID);714expect(repoGroup!.selected?.id).toBe(mruUri2.fsPath);715});716717it('pre-selects selectedFolderUri over previous selection in welcome view', async () => {718workspaceService = new NullWorkspaceService([]);719builder = new SessionOptionGroupBuilder(720gitService, configurationService, context, workspaceService,721folderMruService, agentSessionsWorkspace, worktreeService, folderRepositoryManager,722);723const mruUri1 = URI.file('/repo-a');724const mruUri2 = URI.file('/repo-b');725folderMruService.getRecentlyUsedFolders.mockResolvedValue([726{ folder: mruUri1, repository: mruUri1, lastAccessed: Date.now() },727{ folder: mruUri2, repository: mruUri2, lastAccessed: Date.now() - 1000 },728]);729730const previousState = createMockChatSessionInputState([{731id: REPOSITORY_OPTION_ID,732name: 'Folder',733description: '',734items: [],735selected: { id: mruUri1.fsPath, name: 'repo-a' },736}]);737738const groups = await builder.provideChatSessionProviderOptionGroups(previousState, mruUri2 as any);739const repoGroup = groups.find(g => g.id === REPOSITORY_OPTION_ID);740expect(repoGroup!.selected?.id).toBe(mruUri2.fsPath);741});742743it('shows branch dropdown in welcome view when first MRU item is a git repo', async () => {744workspaceService = new NullWorkspaceService([]);745await configurationService.setConfig(ConfigKey.Advanced.CLIBranchSupport, true);746await configurationService.setConfig(ConfigKey.Advanced.CLIIsolationOption, true);747await context.globalState.update('github.copilot.cli.lastUsedIsolationOption', IsolationMode.Worktree);748builder = new SessionOptionGroupBuilder(749gitService, configurationService, context, workspaceService,750folderMruService, agentSessionsWorkspace, worktreeService, folderRepositoryManager,751);752const mruUri = URI.file('/recent-repo');753folderMruService.getRecentlyUsedFolders.mockResolvedValue([754{ folder: mruUri, repository: mruUri, lastAccessed: Date.now() },755]);756const repo = makeRepo(mruUri.fsPath);757gitService.getRepository.mockResolvedValue(repo);758gitService.getRefs.mockResolvedValue([makeRef('main'), makeRef('develop')]);759760const groups = await builder.provideChatSessionProviderOptionGroups(undefined);761const branchGroup = groups.find(g => g.id === BRANCH_OPTION_ID);762expect(branchGroup).toBeDefined();763expect(branchGroup!.items.length).toBe(2);764});765766it('selects no repo in welcome view when MRU is empty', async () => {767workspaceService = new NullWorkspaceService([]);768builder = new SessionOptionGroupBuilder(769gitService, configurationService, context, workspaceService,770folderMruService, agentSessionsWorkspace, worktreeService, folderRepositoryManager,771);772folderMruService.getRecentlyUsedFolders.mockResolvedValue([]);773774const groups = await builder.provideChatSessionProviderOptionGroups(undefined);775const repoGroup = groups.find(g => g.id === REPOSITORY_OPTION_ID);776expect(repoGroup).toBeDefined();777expect(repoGroup!.items).toHaveLength(0);778expect(repoGroup!.selected).toBeUndefined();779});780781it('preserves previous selection even when no longer in welcome view MRU', async () => {782workspaceService = new NullWorkspaceService([]);783builder = new SessionOptionGroupBuilder(784gitService, configurationService, context, workspaceService,785folderMruService, agentSessionsWorkspace, worktreeService, folderRepositoryManager,786);787const currentUri = URI.file('/current-repo');788const removedUri = URI.file('/removed-repo');789folderMruService.getRecentlyUsedFolders.mockResolvedValue([790{ folder: currentUri, repository: currentUri, lastAccessed: Date.now() },791]);792gitService.getRepository.mockResolvedValue(undefined);793794const previousState = createMockChatSessionInputState([{795id: REPOSITORY_OPTION_ID,796name: 'Folder',797description: '',798items: [],799selected: { id: removedUri.fsPath, name: 'removed-repo' },800}]);801802const groups = await builder.provideChatSessionProviderOptionGroups(previousState);803const repoGroup = groups.find(g => g.id === REPOSITORY_OPTION_ID);804// Previous selection is re-resolved and added to the top805expect(repoGroup!.selected?.id).toBe(removedUri.fsPath);806expect(repoGroup!.items[0].id).toBe(removedUri.fsPath);807});808809it('adds new folder (git repo) to top of items in welcome view', async () => {810workspaceService = new NullWorkspaceService([]);811builder = new SessionOptionGroupBuilder(812gitService, configurationService, context, workspaceService,813folderMruService, agentSessionsWorkspace, worktreeService, folderRepositoryManager,814);815const mruUri = URI.file('/existing-repo');816folderMruService.getRecentlyUsedFolders.mockResolvedValue([817{ folder: mruUri, repository: mruUri, lastAccessed: Date.now() },818]);819const newFolderUri = URI.file('/new-git-folder');820const newRepo = makeRepo(newFolderUri.fsPath);821gitService.getRepository.mockResolvedValue(newRepo);822823const groups = await builder.provideChatSessionProviderOptionGroups(undefined, newFolderUri as any);824const repoGroup = groups.find(g => g.id === REPOSITORY_OPTION_ID);825expect(repoGroup).toBeDefined();826expect(repoGroup!.items[0].id).toBe(newFolderUri.fsPath);827});828829it('adds new folder (non-git) to top of items in welcome view', async () => {830workspaceService = new NullWorkspaceService([]);831builder = new SessionOptionGroupBuilder(832gitService, configurationService, context, workspaceService,833folderMruService, agentSessionsWorkspace, worktreeService, folderRepositoryManager,834);835const mruUri = URI.file('/existing-repo');836folderMruService.getRecentlyUsedFolders.mockResolvedValue([837{ folder: mruUri, repository: mruUri, lastAccessed: Date.now() },838]);839const newFolderUri = URI.file('/new-plain-folder');840gitService.getRepository.mockResolvedValue(undefined);841842const groups = await builder.provideChatSessionProviderOptionGroups(undefined, newFolderUri as any);843const repoGroup = groups.find(g => g.id === REPOSITORY_OPTION_ID);844expect(repoGroup).toBeDefined();845expect(repoGroup!.items[0].id).toBe(newFolderUri.fsPath);846});847848it('deduplicates new folder if already in MRU list', async () => {849workspaceService = new NullWorkspaceService([]);850builder = new SessionOptionGroupBuilder(851gitService, configurationService, context, workspaceService,852folderMruService, agentSessionsWorkspace, worktreeService, folderRepositoryManager,853);854const sharedUri = URI.file('/shared-repo');855folderMruService.getRecentlyUsedFolders.mockResolvedValue([856{ folder: sharedUri, repository: sharedUri, lastAccessed: Date.now() },857]);858const newRepo = makeRepo(sharedUri.fsPath);859gitService.getRepository.mockResolvedValue(newRepo);860861const groups = await builder.provideChatSessionProviderOptionGroups(undefined, sharedUri as any);862const repoGroup = groups.find(g => g.id === REPOSITORY_OPTION_ID);863expect(repoGroup).toBeDefined();864// Should not have duplicates865const matchingItems = repoGroup!.items.filter(i => i.id === sharedUri.fsPath);866expect(matchingItems).toHaveLength(1);867// And it should be at the top868expect(repoGroup!.items[0].id).toBe(sharedUri.fsPath);869});870871it('does not duplicate selected item when new folder replaces its MRU entry', async () => {872// Regression: the selected item was resolved from MRU before873// deduplication replaced it with a fresh object. Using reference874// equality (Array.includes) caused the stale reference to be875// re-appended, creating a duplicate.876workspaceService = new NullWorkspaceService([]);877builder = new SessionOptionGroupBuilder(878gitService, configurationService, context, workspaceService,879folderMruService, agentSessionsWorkspace, worktreeService, folderRepositoryManager,880);881const repoUri = URI.file('/my-repo');882folderMruService.getRecentlyUsedFolders.mockResolvedValue([883{ folder: repoUri, repository: repoUri, lastAccessed: Date.now() },884]);885gitService.getRepository.mockResolvedValue(makeRepo(repoUri.fsPath));886887const groups = await builder.provideChatSessionProviderOptionGroups(undefined, repoUri as any);888const repoGroup = groups.find(g => g.id === REPOSITORY_OPTION_ID)!;889// Selected item must reference an object that is in the items list890expect(repoGroup.items.some(i => i.id === repoGroup.selected?.id)).toBe(true);891// And there must be exactly one item with that id892expect(repoGroup.items.filter(i => i.id === repoUri.fsPath)).toHaveLength(1);893});894895it('does not add new folder when no previousInputState', async () => {896workspaceService = new NullWorkspaceService([]);897builder = new SessionOptionGroupBuilder(898gitService, configurationService, context, workspaceService,899folderMruService, agentSessionsWorkspace, worktreeService, folderRepositoryManager,900);901folderMruService.getRecentlyUsedFolders.mockResolvedValue([]);902903const groups = await builder.provideChatSessionProviderOptionGroups(undefined);904const repoGroup = groups.find(g => g.id === REPOSITORY_OPTION_ID);905expect(repoGroup!.items).toHaveLength(0);906});907908it('re-resolves previously selected folder as git repo when not in MRU', async () => {909// When the previous selection is not in the MRU list, the builder should910// look it up via getTrustedRepository and add it with the correct icon.911workspaceService = new NullWorkspaceService([]);912builder = new SessionOptionGroupBuilder(913gitService, configurationService, context, workspaceService,914folderMruService, agentSessionsWorkspace, worktreeService, folderRepositoryManager,915);916const mruUri = URI.file('/current-repo');917const prevUri = URI.file('/prev-repo');918folderMruService.getRecentlyUsedFolders.mockResolvedValue([919{ folder: mruUri, repository: mruUri, lastAccessed: Date.now() },920]);921const prevRepo = makeRepo(prevUri.fsPath);922gitService.getRepository.mockResolvedValue(prevRepo);923924const previousState = createMockChatSessionInputState([{925id: REPOSITORY_OPTION_ID,926name: 'Folder',927description: '',928items: [],929selected: { id: prevUri.fsPath, name: 'prev-repo' },930}]);931932const groups = await builder.provideChatSessionProviderOptionGroups(previousState);933const repoGroup = groups.find(g => g.id === REPOSITORY_OPTION_ID);934expect(repoGroup!.selected?.id).toBe(prevUri.fsPath);935// The previously selected item should be at the top936expect(repoGroup!.items[0].id).toBe(prevUri.fsPath);937});938});939940describe('handleInputStateChange', () => {941it('rebuilds branch group when repo changes', async () => {942await configurationService.setConfig(ConfigKey.Advanced.CLIBranchSupport, true);943await configurationService.setConfig(ConfigKey.Advanced.CLIIsolationOption, true);944const repo = makeRepo('/new-repo');945gitService.getRepository.mockResolvedValue(repo);946gitService.getRefs.mockResolvedValue([makeRef('main'), makeRef('develop')]);947948const state = createMockChatSessionInputState([949{950id: ISOLATION_OPTION_ID,951name: 'Isolation',952description: '',953items: [],954selected: { id: IsolationMode.Worktree, name: 'Worktree' },955},956{957id: REPOSITORY_OPTION_ID,958name: 'Folder',959description: '',960items: [],961selected: { id: URI.file('/new-repo').fsPath, name: 'new-repo' },962},963{964id: BRANCH_OPTION_ID,965name: 'Branch',966description: '',967items: [{ id: 'old-branch', name: 'old-branch' }],968selected: { id: 'old-branch', name: 'old-branch' },969},970]);971972await builder.handleInputStateChange(state);973const branchGroup = state.groups.find(g => g.id === BRANCH_OPTION_ID);974expect(branchGroup).toBeDefined();975expect(branchGroup!.items.length).toBe(2);976});977978it('removes branch group when repo has no branches', async () => {979await configurationService.setConfig(ConfigKey.Advanced.CLIBranchSupport, true);980gitService.getRepository.mockResolvedValue(makeRepo('/repo'));981gitService.getRefs.mockResolvedValue([]);982983const state = createMockChatSessionInputState([984{985id: REPOSITORY_OPTION_ID,986name: 'Folder',987description: '',988items: [],989selected: { id: URI.file('/repo').fsPath, name: 'repo' },990},991{992id: BRANCH_OPTION_ID,993name: 'Branch',994description: '',995items: [{ id: 'old', name: 'old' }],996},997]);998999await builder.handleInputStateChange(state);1000const branchGroup = state.groups.find(g => g.id === BRANCH_OPTION_ID);1001expect(branchGroup).toBeUndefined();1002});10031004it('does not add branch group when branch feature is disabled', async () => {1005await configurationService.setConfig(ConfigKey.Advanced.CLIBranchSupport, false);10061007const state = createMockChatSessionInputState([{1008id: REPOSITORY_OPTION_ID,1009name: 'Folder',1010description: '',1011items: [],1012selected: { id: URI.file('/repo').fsPath, name: 'repo' },1013}]);10141015await builder.handleInputStateChange(state);1016expect(state.groups.find(g => g.id === BRANCH_OPTION_ID)).toBeUndefined();1017});10181019it('persists isolation selection to global state', async () => {1020await configurationService.setConfig(ConfigKey.Advanced.CLIIsolationOption, true);1021gitService.getRepository.mockResolvedValue(makeRepo('/workspace'));10221023const state = createMockChatSessionInputState([{1024id: ISOLATION_OPTION_ID,1025name: 'Isolation',1026description: '',1027items: [],1028selected: { id: IsolationMode.Worktree, name: 'Worktree' },1029}]);10301031await builder.handleInputStateChange(state);1032expect(context.globalState.get('github.copilot.cli.lastUsedIsolationOption')).toBe(IsolationMode.Worktree);1033});10341035it('forces workspace isolation when selected folder is not a git repo', async () => {1036await configurationService.setConfig(ConfigKey.Advanced.CLIIsolationOption, true);1037gitService.getRepository.mockResolvedValue(undefined);10381039const state = createMockChatSessionInputState([1040{1041id: ISOLATION_OPTION_ID,1042name: 'Isolation',1043description: '',1044items: [1045{ id: IsolationMode.Workspace, name: 'Workspace' },1046{ id: IsolationMode.Worktree, name: 'Worktree' },1047],1048selected: { id: IsolationMode.Worktree, name: 'Worktree' },1049},1050{1051id: REPOSITORY_OPTION_ID,1052name: 'Folder',1053description: '',1054items: [],1055selected: { id: URI.file('/non-git').fsPath, name: 'non-git' },1056},1057]);10581059await builder.handleInputStateChange(state);10601061const isolationGroup = state.groups.find(g => g.id === ISOLATION_OPTION_ID);1062expect(isolationGroup!.selected?.id).toBe(IsolationMode.Workspace);1063expect(isolationGroup!.selected?.locked).toBe(true);1064});10651066it('unlocks isolation when selected folder is a git repo', async () => {1067await configurationService.setConfig(ConfigKey.Advanced.CLIIsolationOption, true);1068gitService.getRepository.mockResolvedValue(makeRepo('/workspace'));10691070const state = createMockChatSessionInputState([1071{1072id: ISOLATION_OPTION_ID,1073name: 'Isolation',1074description: '',1075items: [1076{ id: IsolationMode.Workspace, name: 'Workspace', locked: true },1077{ id: IsolationMode.Worktree, name: 'Worktree', locked: true },1078],1079selected: { id: IsolationMode.Workspace, name: 'Workspace', locked: true },1080},1081{1082id: REPOSITORY_OPTION_ID,1083name: 'Folder',1084description: '',1085items: [],1086selected: { id: URI.file('/workspace').fsPath, name: 'workspace' },1087},1088]);10891090await builder.handleInputStateChange(state);10911092const isolationGroup = state.groups.find(g => g.id === ISOLATION_OPTION_ID);1093expect(isolationGroup!.selected?.locked).toBeUndefined();1094expect(isolationGroup!.items.every(i => !('locked' in i))).toBe(true);1095});1096});10971098describe('buildExistingSessionInputStateGroups', () => {1099it('returns locked groups for existing session', async () => {1100folderRepositoryManager.getFolderRepository.mockResolvedValue({1101folder: URI.file('/workspace'),1102repository: URI.file('/workspace'),1103worktree: undefined,1104worktreeProperties: undefined,1105trusted: true,1106} as any);1107worktreeService.getWorktreeProperties.mockResolvedValue(undefined);11081109const resource = URI.from({ scheme: 'copilotcli', path: '/session-1' });1110const groups = await builder.buildExistingSessionInputStateGroups(resource, CancellationToken.None);11111112const repoGroup = groups.find(g => g.id === REPOSITORY_OPTION_ID);1113expect(repoGroup).toBeDefined();1114expect(repoGroup!.selected?.locked).toBe(true);1115});11161117it('includes worktree branch for worktree sessions', async () => {1118const worktreeProps: ChatSessionWorktreeProperties = {1119version: 2,1120baseCommit: 'abc',1121baseBranchName: 'main',1122branchName: 'copilot/feature',1123repositoryPath: '/repo',1124worktreePath: '/wt',1125};1126folderRepositoryManager.getFolderRepository.mockResolvedValue({1127folder: URI.file('/repo'),1128repository: URI.file('/repo'),1129worktree: undefined,1130worktreeProperties: worktreeProps,1131trusted: true,1132} as any);1133worktreeService.getWorktreeProperties.mockResolvedValue(worktreeProps);11341135const resource = URI.from({ scheme: 'copilotcli', path: '/session-1' });1136const groups = await builder.buildExistingSessionInputStateGroups(resource, CancellationToken.None);11371138const branchGroup = groups.find(g => g.id === BRANCH_OPTION_ID);1139expect(branchGroup).toBeDefined();1140expect(branchGroup!.selected?.id).toBe('copilot/feature');1141expect(branchGroup!.selected?.locked).toBe(true);1142});11431144it('includes repository branch for non-worktree sessions', async () => {1145folderRepositoryManager.getFolderRepository.mockResolvedValue({1146folder: URI.file('/workspace'),1147repository: URI.file('/workspace'),1148repositoryProperties: {1149repositoryPath: '/workspace',1150branchName: 'main',1151baseBranchName: 'origin/main',1152},1153trusted: true,1154} as any);1155worktreeService.getWorktreeProperties.mockResolvedValue(undefined);11561157const resource = URI.from({ scheme: 'copilotcli', path: '/session-1' });1158const groups = await builder.buildExistingSessionInputStateGroups(resource, CancellationToken.None);11591160const branchGroup = groups.find(g => g.id === BRANCH_OPTION_ID);1161expect(branchGroup).toBeDefined();1162expect(branchGroup!.selected?.id).toBe('main');1163expect(branchGroup!.selected?.locked).toBe(true);1164expect(branchGroup!.when).toBeUndefined();1165});11661167it('includes isolation group when feature is enabled and session is worktree', async () => {1168await configurationService.setConfig(ConfigKey.Advanced.CLIIsolationOption, true);1169const worktreeProps: ChatSessionWorktreeProperties = {1170version: 2,1171baseCommit: 'abc',1172baseBranchName: 'main',1173branchName: 'copilot/feature',1174repositoryPath: '/repo',1175worktreePath: '/wt',1176};1177folderRepositoryManager.getFolderRepository.mockResolvedValue({1178folder: URI.file('/repo'),1179repository: URI.file('/repo'),1180trusted: true,1181} as any);1182worktreeService.getWorktreeProperties.mockResolvedValue(worktreeProps);11831184const resource = URI.from({ scheme: 'copilotcli', path: '/session-1' });1185const groups = await builder.buildExistingSessionInputStateGroups(resource, CancellationToken.None);11861187const isolationGroup = groups.find(g => g.id === ISOLATION_OPTION_ID);1188expect(isolationGroup).toBeDefined();1189expect(isolationGroup!.selected?.id).toBe(IsolationMode.Worktree);1190expect(isolationGroup!.selected?.locked).toBe(true);1191});11921193it('shows Workspace isolation for non-worktree sessions', async () => {1194await configurationService.setConfig(ConfigKey.Advanced.CLIIsolationOption, true);1195folderRepositoryManager.getFolderRepository.mockResolvedValue({1196folder: URI.file('/workspace'),1197repository: URI.file('/workspace'),1198trusted: true,1199} as any);1200worktreeService.getWorktreeProperties.mockResolvedValue(undefined);12011202const resource = URI.from({ scheme: 'copilotcli', path: '/session-1' });1203const groups = await builder.buildExistingSessionInputStateGroups(resource, CancellationToken.None);12041205const isolationGroup = groups.find(g => g.id === ISOLATION_OPTION_ID);1206expect(isolationGroup!.selected?.id).toBe(IsolationMode.Workspace);1207});12081209it('omits isolation group when feature is disabled for existing session', async () => {1210await configurationService.setConfig(ConfigKey.Advanced.CLIIsolationOption, false);1211folderRepositoryManager.getFolderRepository.mockResolvedValue({1212folder: URI.file('/workspace'),1213repository: URI.file('/workspace'),1214trusted: true,1215} as any);1216worktreeService.getWorktreeProperties.mockResolvedValue(undefined);12171218const resource = URI.from({ scheme: 'copilotcli', path: '/session-1' });1219const groups = await builder.buildExistingSessionInputStateGroups(resource, CancellationToken.None);12201221expect(groups.find(g => g.id === ISOLATION_OPTION_ID)).toBeUndefined();1222});12231224it('omits branch group when session has no branch name', async () => {1225folderRepositoryManager.getFolderRepository.mockResolvedValue({1226folder: URI.file('/workspace'),1227repository: undefined,1228repositoryProperties: undefined,1229trusted: true,1230} as any);1231worktreeService.getWorktreeProperties.mockResolvedValue(undefined);12321233const resource = URI.from({ scheme: 'copilotcli', path: '/session-1' });1234const groups = await builder.buildExistingSessionInputStateGroups(resource, CancellationToken.None);12351236expect(groups.find(g => g.id === BRANCH_OPTION_ID)).toBeUndefined();1237});1238});12391240describe('rebuildInputState', () => {1241it('adds folder dropdown when a second workspace folder appears', async () => {1242// Start with single workspace folder — no folder dropdown1243gitService.repositories = [makeRepo('/workspace')];1244gitService.getRepository.mockResolvedValue(makeRepo('/workspace'));1245await configurationService.setConfig(ConfigKey.Advanced.CLIBranchSupport, false);1246await configurationService.setConfig(ConfigKey.Advanced.CLIIsolationOption, false);12471248const initialGroups = await builder.provideChatSessionProviderOptionGroups(undefined);1249expect(initialGroups.find(g => g.id === REPOSITORY_OPTION_ID)).toBeUndefined();12501251const state = createMockChatSessionInputState(initialGroups);12521253// Simulate adding a second workspace folder1254workspaceService = new NullWorkspaceService([URI.file('/workspace'), URI.file('/workspace2')]);1255builder = new SessionOptionGroupBuilder(1256gitService, configurationService, context, workspaceService,1257folderMruService, agentSessionsWorkspace, worktreeService, folderRepositoryManager,1258);1259gitService.repositories = [makeRepo('/workspace'), makeRepo('/workspace2')];12601261await builder.rebuildInputState(state);12621263const repoGroup = state.groups.find(g => g.id === REPOSITORY_OPTION_ID);1264expect(repoGroup).toBeDefined();1265expect(repoGroup!.items.length).toBe(2);1266});12671268it('removes folder dropdown when going from two workspace folders to one', async () => {1269// Start with two workspace folders — folder dropdown shown1270workspaceService = new NullWorkspaceService([URI.file('/repo1'), URI.file('/repo2')]);1271builder = new SessionOptionGroupBuilder(1272gitService, configurationService, context, workspaceService,1273folderMruService, agentSessionsWorkspace, worktreeService, folderRepositoryManager,1274);1275gitService.repositories = [makeRepo('/repo1'), makeRepo('/repo2')];1276gitService.getRepository.mockResolvedValue(makeRepo('/repo1'));1277await configurationService.setConfig(ConfigKey.Advanced.CLIBranchSupport, false);1278await configurationService.setConfig(ConfigKey.Advanced.CLIIsolationOption, false);12791280const initialGroups = await builder.provideChatSessionProviderOptionGroups(undefined);1281expect(initialGroups.find(g => g.id === REPOSITORY_OPTION_ID)).toBeDefined();12821283const state = createMockChatSessionInputState(initialGroups);12841285// Simulate removing a workspace folder1286workspaceService = new NullWorkspaceService([URI.file('/repo1')]);1287builder = new SessionOptionGroupBuilder(1288gitService, configurationService, context, workspaceService,1289folderMruService, agentSessionsWorkspace, worktreeService, folderRepositoryManager,1290);1291gitService.repositories = [makeRepo('/repo1')];12921293await builder.rebuildInputState(state);12941295expect(state.groups.find(g => g.id === REPOSITORY_OPTION_ID)).toBeUndefined();1296});12971298it('adds branch dropdown after git init in single folder workspace', async () => {1299// Start with non-git folder — no branch dropdown1300gitService.repositories = [];1301gitService.getRepository.mockResolvedValue(undefined);1302await configurationService.setConfig(ConfigKey.Advanced.CLIBranchSupport, true);1303await configurationService.setConfig(ConfigKey.Advanced.CLIIsolationOption, false);13041305const initialGroups = await builder.provideChatSessionProviderOptionGroups(undefined);1306expect(initialGroups.find(g => g.id === BRANCH_OPTION_ID)).toBeUndefined();13071308const state = createMockChatSessionInputState(initialGroups);13091310// Simulate git init — repo now discovered1311const repo = makeRepo('/workspace');1312gitService.repositories = [repo];1313gitService.getRepository.mockResolvedValue(repo);1314gitService.getRefs.mockResolvedValue([makeRef('main')]);13151316await builder.rebuildInputState(state);13171318const branchGroup = state.groups.find(g => g.id === BRANCH_OPTION_ID);1319expect(branchGroup).toBeDefined();1320expect(branchGroup!.items.length).toBe(1);1321expect(branchGroup!.items[0].id).toBe('main');1322});13231324it('preserves selected folder across rebuild', async () => {1325workspaceService = new NullWorkspaceService([URI.file('/repo1'), URI.file('/repo2')]);1326builder = new SessionOptionGroupBuilder(1327gitService, configurationService, context, workspaceService,1328folderMruService, agentSessionsWorkspace, worktreeService, folderRepositoryManager,1329);1330gitService.repositories = [makeRepo('/repo1'), makeRepo('/repo2')];1331gitService.getRepository.mockResolvedValue(makeRepo('/repo2'));1332await configurationService.setConfig(ConfigKey.Advanced.CLIBranchSupport, false);1333await configurationService.setConfig(ConfigKey.Advanced.CLIIsolationOption, false);13341335// User selects /repo21336const initialGroups = await builder.provideChatSessionProviderOptionGroups(undefined);1337const repoGroupIndex = initialGroups.findIndex(g => g.id === REPOSITORY_OPTION_ID);1338const repoGroup = initialGroups[repoGroupIndex];1339initialGroups[repoGroupIndex] = { ...repoGroup, selected: repoGroup.items.find(i => i.id === URI.file('/repo2').fsPath) };13401341const state = createMockChatSessionInputState(initialGroups);13421343// Add a third folder1344workspaceService = new NullWorkspaceService([URI.file('/repo1'), URI.file('/repo2'), URI.file('/repo3')]);1345builder = new SessionOptionGroupBuilder(1346gitService, configurationService, context, workspaceService,1347folderMruService, agentSessionsWorkspace, worktreeService, folderRepositoryManager,1348);1349gitService.repositories = [makeRepo('/repo1'), makeRepo('/repo2'), makeRepo('/repo3')];13501351await builder.rebuildInputState(state);13521353const newRepoGroup = state.groups.find(g => g.id === REPOSITORY_OPTION_ID)!;1354expect(newRepoGroup.items.length).toBe(3);1355// Previous selection preserved1356expect(newRepoGroup.selected?.id).toBe(URI.file('/repo2').fsPath);1357});13581359it('unlocks isolation after git init for non-git folder', async () => {1360// Start with non-git folder — isolation locked1361gitService.repositories = [];1362gitService.getRepository.mockResolvedValue(undefined);1363await configurationService.setConfig(ConfigKey.Advanced.CLIBranchSupport, false);1364await configurationService.setConfig(ConfigKey.Advanced.CLIIsolationOption, true);13651366const initialGroups = await builder.provideChatSessionProviderOptionGroups(undefined);1367const isolationGroup = initialGroups.find(g => g.id === ISOLATION_OPTION_ID);1368expect(isolationGroup).toBeDefined();1369// Should be locked to workspace for non-git1370expect(isolationGroup!.selected?.locked).toBe(true);13711372const state = createMockChatSessionInputState(initialGroups);13731374// Simulate git init1375const repo = makeRepo('/workspace');1376gitService.repositories = [repo];1377gitService.getRepository.mockResolvedValue(repo);13781379await builder.rebuildInputState(state);13801381const newIsolationGroup = state.groups.find(g => g.id === ISOLATION_OPTION_ID);1382expect(newIsolationGroup).toBeDefined();1383// Should be unlocked after git init1384expect(newIsolationGroup!.selected?.locked).toBeUndefined();1385});13861387it('rebuildInputState after lockInputStateGroups restores correct editable state', async () => {1388// Scenario: user starts a session, dropdowns are locked, then trust1389// fails and rebuildInputState is called to unlock them.1390const repo = makeRepo('/workspace');1391gitService.repositories = [repo];1392gitService.getRepository.mockResolvedValue(repo);1393gitService.getRefs.mockResolvedValue([makeRef('main'), makeRef('dev')]);1394await configurationService.setConfig(ConfigKey.Advanced.CLIBranchSupport, true);1395await configurationService.setConfig(ConfigKey.Advanced.CLIIsolationOption, true);13961397// Build initial groups (worktree isolation → branch editable)1398const initialGroups = await builder.provideChatSessionProviderOptionGroups(undefined);1399const state = createMockChatSessionInputState(initialGroups);14001401// Simulate selecting worktree isolation1402const isolationIdx = state.groups.findIndex(g => g.id === ISOLATION_OPTION_ID);1403const worktreeItem = state.groups[isolationIdx].items.find(i => i.id === IsolationMode.Worktree)!;1404const mutableGroups = [...state.groups];1405mutableGroups[isolationIdx] = { ...state.groups[isolationIdx], selected: worktreeItem };1406state.groups = mutableGroups;14071408// Lock all groups (simulating session start)1409builder.lockInputStateGroups(state);14101411// Verify everything is locked1412for (const group of state.groups) {1413expect(group.selected?.locked).toBe(true);1414for (const item of group.items) {1415expect(item.locked).toBe(true);1416}1417}14181419// Rebuild (simulating trust failure unlock)1420await builder.rebuildInputState(state);14211422// Branch should be editable (worktree isolation selected)1423const branchGroup = state.groups.find(g => g.id === BRANCH_OPTION_ID);1424expect(branchGroup).toBeDefined();1425expect(branchGroup!.selected?.locked).toBeUndefined();14261427// Isolation items should be editable1428const isolationGroup = state.groups.find(g => g.id === ISOLATION_OPTION_ID);1429expect(isolationGroup).toBeDefined();1430expect(isolationGroup!.selected?.locked).toBeUndefined();1431for (const item of isolationGroup!.items) {1432expect(item.locked).toBeUndefined();1433}1434});14351436it('rebuildInputState after lock re-applies branch lock when workspace isolation is selected', async () => {1437const repo = makeRepo('/workspace');1438gitService.repositories = [repo];1439gitService.getRepository.mockResolvedValue(repo);1440gitService.getRefs.mockResolvedValue([makeRef('main')]);1441await configurationService.setConfig(ConfigKey.Advanced.CLIBranchSupport, true);1442await configurationService.setConfig(ConfigKey.Advanced.CLIIsolationOption, true);14431444const initialGroups = await builder.provideChatSessionProviderOptionGroups(undefined);1445const state = createMockChatSessionInputState(initialGroups);14461447// Default isolation is workspace → branch should be locked1448builder.lockInputStateGroups(state);1449await builder.rebuildInputState(state);14501451const branchGroup = state.groups.find(g => g.id === BRANCH_OPTION_ID);1452expect(branchGroup).toBeDefined();1453// Branch must remain locked because workspace isolation is selected1454expect(branchGroup!.selected?.locked).toBe(true);1455});14561457it('rebuildInputState after lock re-applies isolation lock for non-git folder', async () => {1458gitService.repositories = [];1459gitService.getRepository.mockResolvedValue(undefined);1460await configurationService.setConfig(ConfigKey.Advanced.CLIBranchSupport, true);1461await configurationService.setConfig(ConfigKey.Advanced.CLIIsolationOption, true);14621463const initialGroups = await builder.provideChatSessionProviderOptionGroups(undefined);1464const state = createMockChatSessionInputState(initialGroups);14651466builder.lockInputStateGroups(state);1467await builder.rebuildInputState(state);14681469// Isolation should be forced to workspace and locked for non-git folder1470const isolationGroup = state.groups.find(g => g.id === ISOLATION_OPTION_ID);1471expect(isolationGroup).toBeDefined();1472expect(isolationGroup!.selected?.id).toBe(IsolationMode.Workspace);1473expect(isolationGroup!.selected?.locked).toBe(true);14741475// Branch should not be shown for non-git folder1476expect(state.groups.find(g => g.id === BRANCH_OPTION_ID)).toBeUndefined();1477});14781479it('stores selectedFolderUri so it persists in subsequent rebuilds (welcome view)', async () => {1480// In the welcome view, rebuildInputState with a selectedFolderUri should1481// remember it so the next rebuild keeps the folder in the list.1482workspaceService = new NullWorkspaceService([]);1483builder = new SessionOptionGroupBuilder(1484gitService, configurationService, context, workspaceService,1485folderMruService, agentSessionsWorkspace, worktreeService, folderRepositoryManager,1486);1487await configurationService.setConfig(ConfigKey.Advanced.CLIBranchSupport, false);1488await configurationService.setConfig(ConfigKey.Advanced.CLIIsolationOption, false);14891490const browsedUri = URI.file('/browsed-folder');1491folderMruService.getRecentlyUsedFolders.mockResolvedValue([]);1492gitService.getRepository.mockResolvedValue(undefined);14931494// Initial build — empty1495const initialGroups = await builder.provideChatSessionProviderOptionGroups(undefined);1496const state = createMockChatSessionInputState(initialGroups);14971498// Simulate "Browse folders…" — rebuild with the browsed folder1499await builder.rebuildInputState(state, browsedUri as any);1500const repoGroup1 = state.groups.find(g => g.id === REPOSITORY_OPTION_ID);1501expect(repoGroup1!.items.some(i => i.id === browsedUri.fsPath)).toBe(true);15021503// Second rebuild without selectedFolderUri — the browsed folder should persist1504folderMruService.getRecentlyUsedFolders.mockResolvedValue([]);1505await builder.rebuildInputState(state);1506const repoGroup2 = state.groups.find(g => g.id === REPOSITORY_OPTION_ID);1507expect(repoGroup2!.items.some(i => i.id === browsedUri.fsPath)).toBe(true);1508});1509});15101511describe('lockInputStateGroups', () => {1512it('locks all items and selections in every group', async () => {1513const repo = makeRepo('/workspace');1514gitService.repositories = [repo];1515gitService.getRepository.mockResolvedValue(repo);1516gitService.getRefs.mockResolvedValue([makeRef('main'), makeRef('dev')]);1517await configurationService.setConfig(ConfigKey.Advanced.CLIBranchSupport, true);1518await configurationService.setConfig(ConfigKey.Advanced.CLIIsolationOption, true);15191520const initialGroups = await builder.provideChatSessionProviderOptionGroups(undefined);1521const state = createMockChatSessionInputState(initialGroups);15221523// Verify some items are unlocked before locking1524const isolationBefore = state.groups.find(g => g.id === ISOLATION_OPTION_ID);1525expect(isolationBefore!.items.some(i => !i.locked)).toBe(true);15261527builder.lockInputStateGroups(state);15281529for (const group of state.groups) {1530if (group.selected) {1531expect(group.selected.locked).toBe(true);1532}1533for (const item of group.items) {1534expect(item.locked).toBe(true);1535}1536}1537});15381539it('preserves group ids and selected ids after locking', async () => {1540const repo = makeRepo('/workspace');1541gitService.repositories = [repo];1542gitService.getRepository.mockResolvedValue(repo);1543gitService.getRefs.mockResolvedValue([makeRef('main')]);1544await configurationService.setConfig(ConfigKey.Advanced.CLIBranchSupport, true);1545await configurationService.setConfig(ConfigKey.Advanced.CLIIsolationOption, true);15461547const initialGroups = await builder.provideChatSessionProviderOptionGroups(undefined);1548const groupIds = initialGroups.map(g => g.id);1549const selectedIds = initialGroups.map(g => g.selected?.id);1550const state = createMockChatSessionInputState(initialGroups);15511552builder.lockInputStateGroups(state);15531554expect(state.groups.map(g => g.id)).toEqual(groupIds);1555expect(state.groups.map(g => g.selected?.id)).toEqual(selectedIds);1556});15571558it('handles groups with no selected item', () => {1559const state = createMockChatSessionInputState([1560{ id: 'test', name: 'Test', items: [{ id: 'a', name: 'A' }] },1561]);15621563builder.lockInputStateGroups(state);15641565expect(state.groups[0].selected).toBeUndefined();1566expect(state.groups[0].items[0].locked).toBe(true);1567});15681569it('handles empty groups array', () => {1570const state = createMockChatSessionInputState([]);15711572builder.lockInputStateGroups(state);15731574expect(state.groups).toEqual([]);1575});1576});15771578describe('updateBranchInInputState', () => {1579it('replaces existing branch group with new locked branch', async () => {1580const repo = makeRepo('/workspace');1581gitService.repositories = [repo];1582gitService.getRepository.mockResolvedValue(repo);1583gitService.getRefs.mockResolvedValue([makeRef('main'), makeRef('dev')]);1584await configurationService.setConfig(ConfigKey.Advanced.CLIBranchSupport, true);1585await configurationService.setConfig(ConfigKey.Advanced.CLIIsolationOption, true);15861587// Select worktree isolation so branch dropdown has multiple editable items1588await context.globalState.update('github.copilot.cli.lastUsedIsolationOption', IsolationMode.Worktree);15891590const initialGroups = await builder.provideChatSessionProviderOptionGroups(undefined);1591const state = createMockChatSessionInputState(initialGroups);15921593// Verify branch group exists with multiple items (worktree → editable)1594const branchBefore = state.groups.find(g => g.id === BRANCH_OPTION_ID);1595expect(branchBefore).toBeDefined();1596expect(branchBefore!.items.length).toBeGreaterThan(1);15971598builder.updateBranchInInputState(state, 'copilot/my-feature');15991600const branchAfter = state.groups.find(g => g.id === BRANCH_OPTION_ID);1601expect(branchAfter).toBeDefined();1602expect(branchAfter!.items).toHaveLength(1);1603expect(branchAfter!.items[0].id).toBe('copilot/my-feature');1604expect(branchAfter!.items[0].locked).toBe(true);1605expect(branchAfter!.selected?.id).toBe('copilot/my-feature');1606expect(branchAfter!.selected?.locked).toBe(true);1607});16081609it('does not add branch group when none exists', () => {1610const state = createMockChatSessionInputState([1611{1612id: ISOLATION_OPTION_ID,1613name: 'Isolation',1614items: [{ id: IsolationMode.Workspace, name: 'Workspace' }],1615selected: { id: IsolationMode.Workspace, name: 'Workspace' },1616},1617]);16181619builder.updateBranchInInputState(state, 'copilot/my-feature');16201621// Should not add a branch group1622expect(state.groups.find(g => g.id === BRANCH_OPTION_ID)).toBeUndefined();1623expect(state.groups).toHaveLength(1);1624});16251626it('preserves other groups when updating branch', async () => {1627const repo = makeRepo('/workspace');1628gitService.repositories = [repo];1629gitService.getRepository.mockResolvedValue(repo);1630gitService.getRefs.mockResolvedValue([makeRef('main')]);1631await configurationService.setConfig(ConfigKey.Advanced.CLIBranchSupport, true);1632await configurationService.setConfig(ConfigKey.Advanced.CLIIsolationOption, true);16331634const initialGroups = await builder.provideChatSessionProviderOptionGroups(undefined);1635const state = createMockChatSessionInputState(initialGroups);16361637const isolationBefore = state.groups.find(g => g.id === ISOLATION_OPTION_ID);16381639builder.updateBranchInInputState(state, 'copilot/new-branch');16401641// Isolation group should be unchanged1642const isolationAfter = state.groups.find(g => g.id === ISOLATION_OPTION_ID);1643expect(isolationAfter).toEqual(isolationBefore);16441645// Branch group should be updated1646const branchAfter = state.groups.find(g => g.id === BRANCH_OPTION_ID);1647expect(branchAfter!.selected?.id).toBe('copilot/new-branch');1648});1649});1650});1651});165216531654