Path: blob/main/extensions/copilot/src/extension/chatSessions/vscode-node/sessionOptionGroupBuilder.ts
13399 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*--------------------------------------------------------------------------------------------*/45/**6* ## Dropdown Business Rules7*8* ### Feature Flags9* - `CLIBranchSupport` — gates the Branch dropdown entirely.10* - `CLIIsolationOption` — gates the Isolation dropdown entirely.11*12* ### Trust13* - Git repository lookups are only performed on **trusted** folders14* (via {@link getTrustedRepository}). Untrusted folders are treated15* as non-git: isolation locks to Workspace and branch is hidden.16*17* ---18* ### NEW Sessions19*20* #### Isolation dropdown21* | Scenario | Shown? | Editable? | Selected |22* |-----------------------------------------------|--------|-----------|-------------------------------------------------|23* | Feature disabled | No | — | — |24* | Enabled, folder is a trusted git repo | Yes | Yes | Last-used value (defaults to Workspace) |25* | Enabled, folder is NOT a git repo / untrusted | Yes | Locked | Forced to Workspace |26* | Re-evaluated after git init (rebuildInputState) | Yes | Unlocked | Preserves current selection |27*28* #### Folder / Repository dropdown29* | Workspace type | Shown? | Editable? | Items |30* |---------------------------------------|--------|-----------|--------------------------------------------------|31* | Welcome view (no workspace folders) | Yes | Yes | MRU list (max 10) + "Browse folders…" command |32* | Single workspace folder, 1 repo item | No | — | Implicit (used as default) |33* | Single workspace folder, 0 repos | No | — | Implicit (workspace folder used as default) |34* | Multi-root / multiple repo items | Yes | Yes | All repos + non-git workspace folders, sorted A-Z |35*36* #### Branch dropdown37* | Scenario | Shown? | Editable? | Selected |38* |--------------------------------------------|--------|-----------|-------------|39* | `CLIBranchSupport` disabled | No | — | — |40* | Folder is NOT a git repo / untrusted | No | — | — |41* | Git repo, isolation disabled | Yes | Locked | HEAD branch |42* | Git repo, isolation enabled + Workspace | Yes | Locked | HEAD branch |43* | Git repo, isolation enabled + Worktree | Yes | Editable | HEAD branch |44*45* #### Branch item ordering46* 1. HEAD branch (first)47* 2. `main` / `master` (second, if it exists and isn't HEAD)48* 3. Other local branches (by committer date)49* 4. `copilot-worktree-*` branches excluded50* 5. Remote refs excluded51*52* #### Selection persistence53* - **Isolation** — persisted to global state on every change.54* - **Folder** — previous selection restored if still in list → first item.55* - **Branch** — previous selection if still in list → HEAD → stale previous preserved.56*57* ---58* ### EXISTING Sessions59*60* Everything is **locked** — no dropdowns are editable.61*62* | Dropdown | Shown? | Locked? | Value |63* |-----------|-----------------------------|---------|-------------------------------------------------|64* | Isolation | Yes (if feature enabled) | Yes | Worktree if session has worktree props, else Workspace |65* | Folder | Always | Yes | The session's folder / repo |66* | Branch | Only if branch name exists | Yes | Session's worktree branch or repo branch |67*68* ---69* ### State Transitions70*71* **handleInputStateChange** (user dropdown interaction):72* Partial refresh — rebuilds branch and isolation only.73* Cannot add/remove the folder dropdown group.74*75* **rebuildInputState** (external state changes):76* Full rebuild of all groups77* Used when git repos are discovered/closed or workspace folders78* change, since these can add/remove entire dropdown groups.79*80* **updateInputStateAfterFolderSelection** (Browse folders… flow):81* Same pattern as handleInputStateChange — updates folder selection,82* then locks/unlocks isolation and rebuilds branch based on git status.83*84* **provideChatSessionProviderOptionGroups** (initial build):85* Builds all groups, checks git status, forces workspace86* isolation if folder is non-git / untrusted.87*/8889import * as l10n from '@vscode/l10n';90import * as vscode from 'vscode';91import { ChatSessionProviderOptionItem, Uri } from 'vscode';92import { ConfigKey, IConfigurationService } from '../../../platform/configuration/common/configurationService';93import { IVSCodeExtensionContext } from '../../../platform/extContext/common/extensionContext';94import { IGitService, RepoContext } from '../../../platform/git/common/gitService';95import { IWorkspaceService } from '../../../platform/workspace/common/workspaceService';96import { createServiceIdentifier } from '../../../util/common/services';97import { isUri } from '../../../util/common/types';98import { SequencerByKey } from '../../../util/vs/base/common/async';99import { CancellationToken } from '../../../util/vs/base/common/cancellation';100import { basename } from '../../../util/vs/base/common/resources';101import { URI } from '../../../util/vs/base/common/uri';102import { IAgentSessionsWorkspace } from '../common/agentSessionsWorkspace';103import { IChatSessionWorktreeService } from '../common/chatSessionWorktreeService';104import { FolderRepositoryMRUEntry, IChatFolderMruService, IFolderRepositoryManager, IsolationMode } from '../common/folderRepositoryManager';105import { SessionIdForCLI } from '../copilotcli/common/utils';106import { isWelcomeView } from '../copilotcli/node/copilotCli';107export const REPOSITORY_OPTION_ID = 'repository';108export const BRANCH_OPTION_ID = 'branch';109export const ISOLATION_OPTION_ID = 'isolation';110export const OPEN_REPOSITORY_COMMAND_ID = 'github.copilot.cli.sessions.openRepository';111112/**113* Resolve which branch should be selected.114*115* Priority: previous selection (if still in the branch list) → active (HEAD)116* branch → previous selection as-is (stale but preserved so it's not lost).117*/118export function resolveBranchSelection<T extends { id: string }>(119branches: readonly T[],120activeBranchId: string | undefined,121previousSelection: T | undefined,122): T | undefined {123if (previousSelection) {124const inList = branches.find(b => b.id === previousSelection.id);125if (inList) {126return inList;127}128}129const activeBranch = activeBranchId130? branches.find(b => b.id === activeBranchId)131: undefined;132return activeBranch ?? previousSelection;133}134135/**136* Determine branch dropdown locked state.137*138* - Isolation enabled + Workspace selected → locked139* - Isolation enabled + Worktree selected → editable140* - Isolation disabled → locked (always workspace mode)141*/142export function resolveBranchLockState(143isolationEnabled: boolean,144currentIsolation: IsolationMode | undefined,145): { locked: boolean } {146if (!isolationEnabled) {147return { locked: true };148}149150const isWorktree = currentIsolation === IsolationMode.Worktree;151return {152locked: !isWorktree,153};154}155156/**157* Resolve which isolation item should be selected for a new session.158* Uses the previous selection if valid, otherwise falls back to the last-used value.159*/160export function resolveIsolationSelection(161lastUsed: IsolationMode,162previousSelectionId: string | undefined,163): IsolationMode {164if (previousSelectionId === IsolationMode.Workspace || previousSelectionId === IsolationMode.Worktree) {165return previousSelectionId;166}167return lastUsed;168}169170const LAST_USED_ISOLATION_OPTION_KEY = 'github.copilot.cli.lastUsedIsolationOption';171const MAX_MRU_ENTRIES = 10;172const COPILOT_WORKTREE_PATTERN = 'copilot-worktree-';173174function optionItemsEqual(a: vscode.ChatSessionProviderOptionItem | undefined, b: vscode.ChatSessionProviderOptionItem | undefined): boolean {175if (a === b) {176return true;177}178if (!a || !b) {179return false;180}181return a.id === b.id && a.locked === b.locked;182}183184function optionGroupsEqual(185oldGroups: readonly vscode.ChatSessionProviderOptionGroup[],186newGroups: readonly vscode.ChatSessionProviderOptionGroup[],187): boolean {188if (oldGroups.length !== newGroups.length) {189return false;190}191for (let i = 0; i < oldGroups.length; i++) {192const oldGroup = oldGroups[i];193const newGroup = newGroups[i];194if (oldGroup.id !== newGroup.id) {195return false;196}197if (!optionItemsEqual(oldGroup.selected, newGroup.selected)) {198return false;199}200if (oldGroup.items.length !== newGroup.items.length) {201return false;202}203for (let j = 0; j < oldGroup.items.length; j++) {204if (!optionItemsEqual(oldGroup.items[j], newGroup.items[j])) {205return false;206}207}208}209return true;210}211212export function getSelectedOption(groups: readonly vscode.ChatSessionProviderOptionGroup[], groupId: string): vscode.ChatSessionProviderOptionItem | undefined {213return groups.find(g => g.id === groupId)?.selected;214}215216/**217* Extract the selected repository, branch, and isolation values from an input state.218*/219export function getSelectedSessionOptions(inputState: vscode.ChatSessionInputState): { folder?: vscode.Uri; branch?: string; isolation?: IsolationMode } {220const repoId = getSelectedOption(inputState.groups, REPOSITORY_OPTION_ID)?.id;221const branch = getSelectedOption(inputState.groups, BRANCH_OPTION_ID)?.id;222const isolationId = getSelectedOption(inputState.groups, ISOLATION_OPTION_ID)?.id;223return {224folder: repoId ? vscode.Uri.file(repoId) : undefined,225branch: branch || undefined,226isolation: (isolationId === IsolationMode.Workspace || isolationId === IsolationMode.Worktree) ? isolationId : undefined,227};228}229230export function isBranchOptionFeatureEnabled(configurationService: IConfigurationService): boolean {231return configurationService.getConfig(ConfigKey.Advanced.CLIBranchSupport);232}233234/**235* Force the isolation option group to workspace and lock it when the236* selected folder is not a git repository (worktree isolation is a237* no-op without git). Use {@link resetIsolationLock} to unlock when238* the folder becomes a git repo (e.g. after git init).239*/240function forceWorkspaceIsolation(groups: vscode.ChatSessionProviderOptionGroup[]): void {241const isolationIdx = groups.findIndex(g => g.id === ISOLATION_OPTION_ID);242if (isolationIdx !== -1) {243const isolationGroup = groups[isolationIdx];244const workspaceItem = isolationGroup.items.find(i => i.id === IsolationMode.Workspace);245if (workspaceItem) {246groups[isolationIdx] = {247...isolationGroup,248items: isolationGroup.items.map(i => ({ ...i, locked: true })),249selected: { ...workspaceItem, locked: true },250};251}252}253}254255/**256* Remove the locked flag from all isolation items.257* Called when the selected folder turns out to be (or becomes) a git258* repository, so the worktree option is valid again.259*/260function resetIsolationLock(groups: vscode.ChatSessionProviderOptionGroup[]): void {261const isolationIdx = groups.findIndex(g => g.id === ISOLATION_OPTION_ID);262if (isolationIdx !== -1) {263const isolationGroup = groups[isolationIdx];264const unlock = (item: vscode.ChatSessionProviderOptionItem): vscode.ChatSessionProviderOptionItem => {265const { locked: _, ...rest } = item;266return rest;267};268groups[isolationIdx] = {269...isolationGroup,270items: isolationGroup.items.map(unlock),271selected: isolationGroup.selected ? unlock(isolationGroup.selected) : undefined,272};273}274}275276export function isIsolationOptionFeatureEnabled(configurationService: IConfigurationService): boolean {277return configurationService.getConfig(ConfigKey.Advanced.CLIIsolationOption);278}279280export function toRepositoryOptionItem(repository: RepoContext | Uri, isDefault: boolean = false): ChatSessionProviderOptionItem {281const repositoryUri = isUri(repository) ? repository : repository.rootUri;282const repositoryIcon = isUri(repository) ? 'repo' : repository.kind === 'repository' ? 'repo' : 'archive';283const repositoryName = repositoryUri.path.split('/').pop() ?? repositoryUri.toString();284285return {286id: repositoryUri.fsPath,287name: repositoryName,288icon: new vscode.ThemeIcon(repositoryIcon),289default: isDefault290} satisfies vscode.ChatSessionProviderOptionItem;291}292293export function toWorkspaceFolderOptionItem(workspaceFolderUri: URI, name: string): ChatSessionProviderOptionItem {294return {295id: workspaceFolderUri.fsPath,296name: name,297icon: new vscode.ThemeIcon('folder'),298} satisfies vscode.ChatSessionProviderOptionItem;299}300301export function folderMRUToChatProviderOptions(mruItems: FolderRepositoryMRUEntry[]): ChatSessionProviderOptionItem[] {302return mruItems.map((item) => {303if (item.repository) {304return toRepositoryOptionItem(item.folder);305} else {306return toWorkspaceFolderOptionItem(item.folder, basename(item.folder));307}308});309}310311/**312* Builds and manages the dropdown option groups (repository, branch, isolation)313* for new and existing CLI chat sessions.314*/315export interface ISessionOptionGroupBuilder {316readonly _serviceBrand: undefined;317provideChatSessionProviderOptionGroups(previousInputState: vscode.ChatSessionInputState | undefined): Promise<vscode.ChatSessionProviderOptionGroup[]>;318buildBranchOptionGroup(branches: vscode.ChatSessionProviderOptionItem[], headBranchName: string | undefined, isolationEnabled: boolean, currentIsolation: IsolationMode | undefined, previousSelection: vscode.ChatSessionProviderOptionItem | undefined): vscode.ChatSessionProviderOptionGroup | undefined;319handleInputStateChange(state: vscode.ChatSessionInputState): Promise<void>;320rebuildInputState(state: vscode.ChatSessionInputState, selectedFolderUri?: vscode.Uri): Promise<void>;321buildExistingSessionInputStateGroups(resource: vscode.Uri, token: vscode.CancellationToken): Promise<vscode.ChatSessionProviderOptionGroup[]>;322getBranchOptionItemsForRepository(repoUri: Uri, headBranchName: string | undefined): Promise<vscode.ChatSessionProviderOptionItem[]>;323getRepositoryOptionItems(): vscode.ChatSessionProviderOptionItem[];324/**325* Lock all dropdown groups (make them readonly).326* Used when a new session is being created.327*/328lockInputStateGroups(state: vscode.ChatSessionInputState): void;329/**330* Update the branch dropdown to display a specific branch name (locked).331* Used after a worktree is created to show the new branch.332*/333updateBranchInInputState(state: vscode.ChatSessionInputState, branchName: string): void;334}335export const ISessionOptionGroupBuilder = createServiceIdentifier<ISessionOptionGroupBuilder>('ISessionOptionGroupBuilder');336337export class SessionOptionGroupBuilder implements ISessionOptionGroupBuilder {338declare readonly _serviceBrand: undefined;339private readonly _getBranchOptionItemsForRepositorySequencer = new SequencerByKey<string>();340private readonly _pendingBuildGroups = new WeakMap<vscode.ChatSessionInputState, Promise<vscode.ChatSessionProviderOptionGroup[]>>();341// Keeps track of the new folders selected by user342private readonly _inputStateNewFolders = new WeakMap<vscode.ChatSessionInputState, vscode.Uri>();343constructor(344@IGitService private readonly gitService: IGitService,345@IConfigurationService private readonly configurationService: IConfigurationService,346@IVSCodeExtensionContext private readonly context: IVSCodeExtensionContext,347@IWorkspaceService private readonly workspaceService: IWorkspaceService,348@IChatFolderMruService private readonly copilotCLIFolderMruService: IChatFolderMruService,349@IAgentSessionsWorkspace private readonly agentSessionsWorkspace: IAgentSessionsWorkspace,350@IChatSessionWorktreeService private readonly chatSessionWorktreeService: IChatSessionWorktreeService,351@IFolderRepositoryManager private readonly folderRepositoryManager: IFolderRepositoryManager,352) { }353354355/**356* Return the git repository for a URI only if the folder is trusted.357* Untrusted folders are treated as non-git.358*/359private async getTrustedRepository(uri: vscode.Uri | undefined, discover?: boolean): Promise<RepoContext | undefined> {360if (!uri) {361return undefined;362}363const isTrusted = await this.workspaceService.isResourceTrusted(uri);364if (!isTrusted) {365return undefined;366}367return this.gitService.getRepository(uri, discover);368}369370async provideChatSessionProviderOptionGroups(previousInputState: vscode.ChatSessionInputState | undefined, selectedFolderUri?: vscode.Uri): Promise<vscode.ChatSessionProviderOptionGroup[]> {371const optionGroups: vscode.ChatSessionProviderOptionGroup[] = [];372const isolationEnabled = isIsolationOptionFeatureEnabled(this.configurationService);373const previouslySelectedIsolationOption = previousInputState ? getSelectedOption(previousInputState.groups, ISOLATION_OPTION_ID) : undefined;374let currentIsolation: IsolationMode | undefined;375if (isolationEnabled) {376const lastUsed = this.context.globalState.get<IsolationMode>(LAST_USED_ISOLATION_OPTION_KEY, IsolationMode.Workspace);377currentIsolation = resolveIsolationSelection(lastUsed, previouslySelectedIsolationOption?.id);378const items = [379{ id: IsolationMode.Workspace, name: l10n.t('Workspace'), icon: new vscode.ThemeIcon('folder') },380{ id: IsolationMode.Worktree, name: l10n.t('Worktree'), icon: new vscode.ThemeIcon('worktree') },381];382// Use the previous selection's ID to find the matching fresh item383// (without stale flags like `locked`), falling back to the default.384const selectedId = previouslySelectedIsolationOption?.id ?? currentIsolation;385optionGroups.push({386id: ISOLATION_OPTION_ID,387name: l10n.t('Isolation'),388description: l10n.t('Pick Isolation Mode'),389items,390selected: items.find(i => i.id === selectedId)!391});392}393394// Handle repository options based on workspace type395const folders = this.workspaceService.getWorkspaceFolders();396const isSingleFolderWorkspace = !isWelcomeView(this.workspaceService)397&& !this.agentSessionsWorkspace.isAgentSessionsWorkspace398&& folders?.length === 1;399let defaultRepoUri = selectedFolderUri ?? (isSingleFolderWorkspace ? folders![0] : undefined);400if (isWelcomeView(this.workspaceService)) {401const commands: vscode.Command[] = [];402const previouslySelected = previousInputState ? getSelectedOption(previousInputState.groups, REPOSITORY_OPTION_ID) : undefined;403let items: vscode.ChatSessionProviderOptionItem[] = [];404405// For untitled workspaces, show last used repositories and "Open Repository..." command406const repositories = await this.copilotCLIFolderMruService.getRecentlyUsedFolders(CancellationToken.None);407items = folderMRUToChatProviderOptions(repositories);408const addFolderToList = async (uri: Uri) => {409const newFolderRepo = await this.getTrustedRepository(uri, true);410const newFolderItem = newFolderRepo411? toRepositoryOptionItem(newFolderRepo.rootUri)412: toWorkspaceFolderOptionItem(uri, uri.path.split('/').pop() ?? uri.fsPath);413// Remove duplicate if already in the list, then add to top414items = items.filter(item => item.id !== newFolderItem.id);415items.unshift(newFolderItem);416};417if (selectedFolderUri) {418await addFolderToList(selectedFolderUri);419}420const previouslySelectedUri = previouslySelected ? vscode.Uri.file(previouslySelected.id) : undefined;421if (previouslySelectedUri) {422await addFolderToList(previouslySelectedUri);423}424// Ensure previously selected folder is added back into the list of folders.425const newFolder = previousInputState ? this._inputStateNewFolders.get(previousInputState) : undefined;426if (newFolder) {427await addFolderToList(newFolder);428}429const selectedFolderItem = selectedFolderUri ? items.find(i => i.id === selectedFolderUri.fsPath) : undefined;430const previouslySelectedItem = previouslySelected ? items.find(i => i.id === previouslySelected.id) : undefined;431const selectedItem = selectedFolderItem432?? previouslySelectedItem ?? items[0];433if (selectedItem) {434defaultRepoUri = vscode.Uri.file(selectedItem.id);435}436437items.splice(MAX_MRU_ENTRIES); // Limit to max entries438// If user selected something from the list but it's not there anymore (perhaps its an item at the end of MRU).439if (selectedItem && !items.some(item => item.id === selectedItem.id)) {440items.push(selectedItem);441}442443commands.push({444command: OPEN_REPOSITORY_COMMAND_ID,445title: l10n.t('Browse folders...')446});447448optionGroups.push({449id: REPOSITORY_OPTION_ID,450name: l10n.t('Folder'),451description: l10n.t('Pick Folder'),452items,453selected: selectedItem,454commands455});456} else {457const repositories = this.getRepositoryOptionItems();458if (repositories.length > 1) {459const previouslySelected = previousInputState ? getSelectedOption(previousInputState.groups, REPOSITORY_OPTION_ID) : undefined;460const selectedFolderRepo = selectedFolderUri ? repositories.find(repository => repository.id === selectedFolderUri.fsPath) : undefined;461const selectedRepository = selectedFolderRepo ?? (previouslySelected ? repositories.find(repository => repository.id === previouslySelected.id) ?? repositories[0] : repositories[0]);462defaultRepoUri = selectedRepository.id ? vscode.Uri.file(selectedRepository.id) : defaultRepoUri;463optionGroups.push({464id: REPOSITORY_OPTION_ID,465name: l10n.t('Folder'),466description: l10n.t('Pick Folder'),467items: repositories,468selected: selectedRepository469});470} else if (repositories.length === 1) {471defaultRepoUri = vscode.Uri.file(repositories[0].id);472}473}474475const repo = await this.getTrustedRepository(defaultRepoUri);476477// When the selected folder is not a git repo (or untrusted), force isolation to workspace478if (defaultRepoUri && !repo && isolationEnabled) {479forceWorkspaceIsolation(optionGroups);480}481482if (repo && isBranchOptionFeatureEnabled(this.configurationService)) {483const branches = await this.getBranchOptionItemsForRepository(repo.rootUri, repo.headBranchName);484const previouslySelectedBranchItem = previousInputState ? getSelectedOption(previousInputState.groups, BRANCH_OPTION_ID) : undefined;485const branchGroup = this.buildBranchOptionGroup(branches, repo.headBranchName, isolationEnabled, currentIsolation, previouslySelectedBranchItem);486if (branchGroup) {487optionGroups.push(branchGroup);488}489}490491return optionGroups;492}493494/**495* Build a branch option group from pre-fetched branch items.496* Returns undefined if there are no branches.497*/498buildBranchOptionGroup(499branches: vscode.ChatSessionProviderOptionItem[],500headBranchName: string | undefined,501isolationEnabled: boolean,502currentIsolation: IsolationMode | undefined,503previousSelection: vscode.ChatSessionProviderOptionItem | undefined,504): vscode.ChatSessionProviderOptionGroup | undefined {505if (branches.length === 0) {506return undefined;507}508// BUG: Work around for https://github.com/microsoft/vscode/issues/288457#issuecomment-4157935788509// Locked doesn't work, once locked, we cannot unlock.510const { locked } = resolveBranchLockState(isolationEnabled, currentIsolation);511// const locked = false;512// When locked (workspace isolation), ignore the previous selection so we513// always snap back to the active branch instead of keeping a stale pick.514const selectedItem = resolveBranchSelection(branches, headBranchName, locked ? undefined : previousSelection);515const lockedSelected = selectedItem && locked ? { ...selectedItem, locked } : undefined;516return {517id: BRANCH_OPTION_ID,518name: l10n.t('Branch'),519description: l10n.t('Pick Branch'),520items: lockedSelected ? [lockedSelected] : locked ? branches.map(b => ({ ...b, locked })) : branches,521selected: lockedSelected ?? selectedItem,522};523}524525/**526* Rebuild dependent option groups based on current selections.527* Called when any dropdown changes — inspects each group's `selected`528* property to determine the current state and update accordingly.529*/530async handleInputStateChange(state: vscode.ChatSessionInputState): Promise<void> {531// Persist the user's isolation choice so it's remembered across sessions532const currentIsolation = getSelectedOption(state.groups, ISOLATION_OPTION_ID)?.id as IsolationMode | undefined;533if (currentIsolation) {534void this.context.globalState.update(LAST_USED_ISOLATION_OPTION_KEY, currentIsolation);535}536537const newGroups = await this._buildGroupsOnce(state);538if (!optionGroupsEqual(state.groups, newGroups)) {539state.groups = newGroups;540}541}542543/**544* Full rebuild of all option groups (isolation, folder, branch).545* Called when external state changes (workspace folders added/removed,546* git repos discovered/closed) that may require adding or removing547* entire dropdown groups — not just updating branch/isolation.548*/549async rebuildInputState(state: vscode.ChatSessionInputState, selectedFolderUri?: vscode.Uri): Promise<void> {550const newGroups = await this._buildGroupsOnce(state, selectedFolderUri);551if (!optionGroupsEqual(state.groups, newGroups) || selectedFolderUri) {552state.groups = newGroups;553}554if (selectedFolderUri) {555this._inputStateNewFolders.set(state, selectedFolderUri);556}557}558559/**560* Deduplicate concurrent builds for the same state object.561* If a build is already in-flight for this state, return the same promise.562*/563private _buildGroupsOnce(state: vscode.ChatSessionInputState, selectedFolderUri?: vscode.Uri): Promise<vscode.ChatSessionProviderOptionGroup[]> {564const pending = this._pendingBuildGroups.get(state);565if (pending) {566return pending;567}568const promise = this.provideChatSessionProviderOptionGroups(state, selectedFolderUri).finally(() => {569this._pendingBuildGroups.delete(state);570});571this._pendingBuildGroups.set(state, promise);572return promise;573}574575async buildExistingSessionInputStateGroups(resource: vscode.Uri, token: vscode.CancellationToken): Promise<vscode.ChatSessionProviderOptionGroup[]> {576const copilotcliSessionId = SessionIdForCLI.parse(resource);577const optionGroups: vscode.ChatSessionProviderOptionGroup[] = [];578const folderInfo = await this.folderRepositoryManager.getFolderRepository(copilotcliSessionId, undefined, token);579const repositories = isWelcomeView(this.workspaceService) ? folderMRUToChatProviderOptions(await this.copilotCLIFolderMruService.getRecentlyUsedFolders(token)) : this.getRepositoryOptionItems();580const folderOrRepoId = folderInfo.repository?.fsPath ?? folderInfo.folder?.fsPath;581const existingItem = folderOrRepoId ? repositories.find(repo => repo.id === folderOrRepoId) : undefined;582const worktreeProperties = await this.chatSessionWorktreeService.getWorktreeProperties(copilotcliSessionId);583584let repoSelected: vscode.ChatSessionProviderOptionItem;585if (existingItem) {586repoSelected = { ...existingItem, locked: true };587} else if (folderInfo.repository) {588repoSelected = { ...toRepositoryOptionItem(folderInfo.repository), locked: true };589} else if (folderInfo.folder) {590const folderName = this.workspaceService.getWorkspaceFolderName(folderInfo.folder) || basename(folderInfo.folder);591repoSelected = { ...toWorkspaceFolderOptionItem(folderInfo.folder, folderName), locked: true };592} else {593let folderName = l10n.t('Unknown');594if (this.workspaceService.getWorkspaceFolders().length === 1) {595folderName = this.workspaceService.getWorkspaceFolderName(this.workspaceService.getWorkspaceFolders()[0]) || folderName;596}597repoSelected = { id: '', name: folderName, icon: new vscode.ThemeIcon('folder'), locked: true };598}599600if (isIsolationOptionFeatureEnabled(this.configurationService)) {601const isWorktree = !!worktreeProperties;602const isolationSelected = {603id: isWorktree ? IsolationMode.Worktree : IsolationMode.Workspace,604name: isWorktree ? l10n.t('Worktree') : l10n.t('Workspace'),605icon: new vscode.ThemeIcon(isWorktree ? 'worktree' : 'folder'),606locked: true607};608optionGroups.push({609id: ISOLATION_OPTION_ID,610name: l10n.t('Isolation'),611description: l10n.t('Pick Isolation Mode'),612items: [613{ id: IsolationMode.Workspace, name: l10n.t('Workspace'), icon: new vscode.ThemeIcon('folder') },614{ id: IsolationMode.Worktree, name: l10n.t('Worktree'), icon: new vscode.ThemeIcon('worktree') },615],616selected: isolationSelected617});618}619620optionGroups.push({621id: REPOSITORY_OPTION_ID,622name: l10n.t('Folder'),623description: l10n.t('Pick Folder'),624items: [repoSelected],625selected: repoSelected,626commands: []627});628629const branchName = worktreeProperties?.branchName ?? folderInfo.repositoryProperties?.branchName;630if (branchName) {631const branchSelected = { id: branchName, name: branchName, icon: new vscode.ThemeIcon('git-branch'), locked: true };632optionGroups.push({633id: BRANCH_OPTION_ID,634name: l10n.t('Branch'),635description: l10n.t('Pick Branch'),636items: [branchSelected],637selected: branchSelected,638});639}640641return optionGroups;642}643644async getBranchOptionItemsForRepository(repoUri: Uri, headBranchName: string | undefined): Promise<vscode.ChatSessionProviderOptionItem[]> {645const key = `${repoUri.toString()}|${headBranchName ?? ''}`;646return this._getBranchOptionItemsForRepositorySequencer.queue(key, async () => {647648const refs = await this.gitService.getRefs(repoUri, { sort: 'committerdate' });649650// Filter to local branches only (RefType.Head === 0)651const localBranches = refs.filter(ref => ref.type === 0 /* RefType.Head */ && ref.name);652653// Build items with HEAD branch first654const items: vscode.ChatSessionProviderOptionItem[] = [];655let headItem: vscode.ChatSessionProviderOptionItem | undefined;656let mainOrheadBranch: vscode.ChatSessionProviderOptionItem | undefined;657for (const ref of localBranches) {658if (!ref.name) {659continue;660}661if (ref.name.includes(COPILOT_WORKTREE_PATTERN)) {662continue;663}664const isHead = ref.name === headBranchName;665const item: vscode.ChatSessionProviderOptionItem = {666id: ref.name!,667name: ref.name!,668icon: new vscode.ThemeIcon('git-branch'),669// default: isHead670};671if (isHead) {672headItem = item;673} else if (ref.name === 'main' || ref.name === 'master') {674mainOrheadBranch = item;675} else {676items.push(item);677}678}679680if (mainOrheadBranch) {681items.unshift(mainOrheadBranch);682}683if (headItem) {684items.unshift(headItem);685}686687return items;688});689}690691getRepositoryOptionItems() {692// Exclude worktrees from the repository list693const repositories = this.gitService.repositories694.filter(repository => repository.kind !== 'worktree')695.filter(repository => {696if (isWelcomeView(this.workspaceService)) {697// In the welcome view, include all repositories from the MRU list698return true;699}700// Only include repositories that belong to one of the workspace folders701return this.workspaceService.getWorkspaceFolder(repository.rootUri) !== undefined;702});703704const repoItems = repositories705.map(repository => toRepositoryOptionItem(repository));706707// In multi-root workspaces, also include workspace folders that don't have any git repos708const workspaceFolders = this.workspaceService.getWorkspaceFolders();709if (workspaceFolders.length) {710// Find workspace folders that contain git repos711const foldersWithRepos = new Set<string>();712for (const repo of repositories) {713const folder = this.workspaceService.getWorkspaceFolder(repo.rootUri);714if (folder) {715foldersWithRepos.add(folder.fsPath);716}717}718719// Add workspace folders that don't have any git repos720for (const folder of workspaceFolders) {721if (!foldersWithRepos.has(folder.fsPath)) {722const folderName = this.workspaceService.getWorkspaceFolderName(folder);723repoItems.push(toWorkspaceFolderOptionItem(folder, folderName));724}725}726}727728return repoItems.sort((a, b) => a.name.localeCompare(b.name));729}730731lockInputStateGroups(state: vscode.ChatSessionInputState): void {732lockInputStateGroups(state);733}734735updateBranchInInputState(state: vscode.ChatSessionInputState, branchName: string): void {736const existingIdx = state.groups.findIndex(g => g.id === BRANCH_OPTION_ID);737if (existingIdx === -1) {738return;739}740const branchSelected: vscode.ChatSessionProviderOptionItem = {741id: branchName,742name: branchName,743icon: new vscode.ThemeIcon('git-branch'),744locked: true,745};746const branchGroup: vscode.ChatSessionProviderOptionGroup = {747id: BRANCH_OPTION_ID,748name: l10n.t('Branch'),749description: l10n.t('Pick Branch'),750items: [branchSelected],751selected: branchSelected,752};753const updatedGroups = [...state.groups];754updatedGroups[existingIdx] = branchGroup;755state.groups = updatedGroups;756}757}758759export function lockInputStateGroups(state: vscode.ChatSessionInputState): void {760state.groups = state.groups.map(group => ({761...group,762items: group.items.map(item => ({ ...item, locked: true })),763selected: group.selected ? { ...group.selected, locked: true } : undefined,764}));765}766767768