Path: blob/main/src/vs/sessions/contrib/chat/browser/sessionWorkspacePicker.ts
13401 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 * as dom from '../../../../base/browser/dom.js';6import * as touch from '../../../../base/browser/touch.js';7import { IAction, toAction } from '../../../../base/common/actions.js';8import { Codicon } from '../../../../base/common/codicons.js';9import { Emitter, Event } from '../../../../base/common/event.js';10import { disposableTimeout } from '../../../../base/common/async.js';11import { Disposable, DisposableStore, MutableDisposable } from '../../../../base/common/lifecycle.js';12import { URI, UriComponents } from '../../../../base/common/uri.js';13import { basename } from '../../../../base/common/resources.js';14import { autorun } from '../../../../base/common/observable.js';15import { localize } from '../../../../nls.js';16import { IActionWidgetService } from '../../../../platform/actionWidget/browser/actionWidget.js';17import { ActionListItemKind, IActionListDelegate, IActionListItem, IActionListOptions } from '../../../../platform/actionWidget/browser/actionList.js';18import { TabbedActionListWidget } from '../../../../platform/actionWidget/browser/tabbedActionListWidget.js';19import { IMenuService, MenuItemAction } from '../../../../platform/actions/common/actions.js';20import { IRemoteAgentHostService, RemoteAgentHostConnectionStatus } from '../../../../platform/agentHost/common/remoteAgentHostService.js';21import { TUNNEL_ADDRESS_PREFIX } from '../../../../platform/agentHost/common/tunnelAgentHost.js';22import { ICommandService } from '../../../../platform/commands/common/commands.js';23import { IConfigurationService } from '../../../../platform/configuration/common/configuration.js';24import { IContextKeyService, IContextKey } from '../../../../platform/contextkey/common/contextkey.js';25import { IFileDialogService } from '../../../../platform/dialogs/common/dialogs.js';26import { IQuickInputService } from '../../../../platform/quickinput/common/quickInput.js';27import { IStorageService, StorageScope, StorageTarget } from '../../../../platform/storage/common/storage.js';28import { IUriIdentityService } from '../../../../platform/uriIdentity/common/uriIdentity.js';29import { renderIcon } from '../../../../base/browser/ui/iconLabel/iconLabels.js';30import { ThemeIcon } from '../../../../base/common/themables.js';31import { ISessionWorkspace, ISessionWorkspaceBrowseAction, SESSION_WORKSPACE_GROUP_LOCAL, SESSION_WORKSPACE_GROUP_REMOTE } from '../../../services/sessions/common/session.js';32import { ISessionsProvidersService } from '../../../services/sessions/browser/sessionsProvidersService.js';33import { IAgentHostSessionsProvider, isAgentHostProvider } from '../../../common/agentHostSessionsProvider.js';34import { SessionWorkspacePickerGroupContext } from '../../../common/contextkeys.js';35import { getStatusHover, getStatusLabel, showRemoteHostOptions } from '../../remoteAgentHost/browser/remoteHostOptions.js';36import { IInstantiationService } from '../../../../platform/instantiation/common/instantiation.js';37import { COPILOT_PROVIDER_ID } from '../../copilotChatSessions/browser/copilotChatSessionsProvider.js';38import { IWorkspacesService, isRecentFolder } from '../../../../platform/workspaces/common/workspaces.js';39import { Menus } from '../../../browser/menus.js';4041const LEGACY_STORAGE_KEY_RECENT_PROJECTS = 'sessions.recentlyPickedProjects';42const STORAGE_KEY_RECENT_WORKSPACES = 'sessions.recentlyPickedWorkspaces';43const FILTER_THRESHOLD = 10;44const MAX_RECENT_WORKSPACES = 10;4546/**47* Fixed picker width when the categorical tab bar is shown. Keeps the tab48* row and the list aligned and prevents horizontal jitter when switching49* tabs.50*/51const TABBED_PICKER_WIDTH = 360;5253/**54* Grace period for a restored remote workspace's provider to reach Connected55* before we fall back to no selection. SSH tunnels typically connect within56* a couple seconds; if it hasn't connected by then, we'd rather show no57* selection than leave the user staring at an unreachable workspace.58*/59const RESTORE_CONNECT_GRACE_MS = 5000;6061/**62* A workspace selection from the picker, pairing the workspace with its owning provider.63*/64export interface IWorkspaceSelection {65readonly providerId: string;66readonly workspace: ISessionWorkspace;67}6869/**70* Stored recent workspace entry. The `checked` flag marks the currently71* selected workspace so we only need a single storage key.72*/73interface IStoredRecentWorkspace {74readonly uri: UriComponents;75readonly providerId: string;76readonly checked: boolean;77}7879/**80* Item type used in the action list.81*/82export interface IWorkspacePickerItem {83readonly selection?: IWorkspaceSelection;84readonly browseActionIndex?: number;85readonly checked?: boolean;86/** Command to execute when this item is selected. */87readonly commandId?: string;88/** Inline action to run when this item is selected. */89readonly run?: () => void;90}9192/**93* A unified workspace picker that shows workspaces from all registered session94* providers in a single dropdown.95*96* Browse actions from providers are appended at the bottom of the list.97*/98export class WorkspacePicker extends Disposable {99100protected readonly _onDidSelectWorkspace = this._register(new Emitter<IWorkspaceSelection | undefined>());101readonly onDidSelectWorkspace: Event<IWorkspaceSelection | undefined> = this._onDidSelectWorkspace.event;102protected readonly _onDidChangeSelection = this._register(new Emitter<void>());103readonly onDidChangeSelection: Event<void> = this._onDidChangeSelection.event;104105private _selectedWorkspace: IWorkspaceSelection | undefined;106107/**108* Set to `true` once the user has explicitly picked or cleared a workspace.109* Until then, late-arriving provider registrations are allowed to upgrade110* the current (auto-restored) selection to the user's stored "checked"111* entry. After the user has acted, providers coming and going never move112* the selection out from under them.113*/114private _userHasPicked = false;115116/**117* Watches the connection status of a restored remote workspace. Cleared when118* the user explicitly picks, when the connection succeeds, or when it fails119* and we fall back.120*/121private readonly _connectionStatusWatch = this._register(new MutableDisposable());122123/** Provider ID chosen during the last local folder browse. */124private _selectedLocalProviderId: string | undefined;125126private _triggerElement: HTMLElement | undefined;127private readonly _renderDisposables = this._register(new DisposableStore());128private readonly _tabbedWidget: TabbedActionListWidget;129private readonly _pickerGroupContext: IContextKey<string>;130131/**132* Currently active workspace tab (a group label contributed by a133* provider, e.g. `"Local"` / `"Cloud"` / `"Remote"`).134*/135private _activeTab: string | undefined;136137/**138* Whether the user explicitly clicked a tab while the picker was open.139* Reset on each fresh open so the picker re-defaults to the selected140* workspace's group between opens.141*/142private _userPickedTab = false;143144/** Cached VS Code recent folder URIs, resolved lazily. */145private _vsCodeRecentFolderUris: URI[] = [];146147get selectedProject(): IWorkspaceSelection | undefined {148return this._selectedWorkspace;149}150151constructor(152@IActionWidgetService protected readonly actionWidgetService: IActionWidgetService,153@IStorageService private readonly storageService: IStorageService,154@IUriIdentityService private readonly uriIdentityService: IUriIdentityService,155@ISessionsProvidersService protected readonly sessionsProvidersService: ISessionsProvidersService,156@IRemoteAgentHostService private readonly remoteAgentHostService: IRemoteAgentHostService,157@IConfigurationService _configurationService: IConfigurationService,158@ICommandService private readonly commandService: ICommandService,159@IWorkspacesService private readonly workspacesService: IWorkspacesService,160@IMenuService private readonly menuService: IMenuService,161@IContextKeyService private readonly contextKeyService: IContextKeyService,162@IInstantiationService private readonly instantiationService: IInstantiationService,163@IFileDialogService private readonly fileDialogService: IFileDialogService,164@IQuickInputService private readonly quickInputService: IQuickInputService,165) {166super();167168this._tabbedWidget = this._register(this.instantiationService.createInstance(TabbedActionListWidget));169this._pickerGroupContext = SessionWorkspacePickerGroupContext.bindTo(this.contextKeyService);170this._register(this._tabbedWidget.onDidChangeTab(tab => {171this._activeTab = tab;172this._userPickedTab = true;173this._pickerGroupContext.set(tab);174}));175this._register(this._tabbedWidget.onDidHide(() => {176this._pickerGroupContext.reset();177}));178179// Migrate legacy storage to new key180this._migrateLegacyStorage();181182// Restore selected workspace from storage183this._selectedWorkspace = this._restoreSelectedWorkspace();184if (this._selectedWorkspace) {185this._watchForConnectionFailure(this._selectedWorkspace);186}187188// React to provider registrations/removals: re-validate the current189// selection, and if the user hasn't explicitly picked yet, re-restore190// from storage so we upgrade from any fallback to the user's actual191// stored selection once its provider arrives.192this._register(this.sessionsProvidersService.onDidChangeProviders(() => {193if (this._selectedWorkspace) {194const providers = this.sessionsProvidersService.getProviders();195if (!providers.some(p => p.id === this._selectedWorkspace!.providerId)) {196this._selectedWorkspace = undefined;197this._connectionStatusWatch.clear();198this._updateTriggerLabel();199this._onDidChangeSelection.fire();200this._onDidSelectWorkspace.fire(undefined);201}202}203if (!this._userHasPicked) {204const restored = this._restoreSelectedWorkspace();205if (restored && !this._isSelectedWorkspace(restored)) {206this._selectedWorkspace = restored;207this._updateTriggerLabel();208this._onDidChangeSelection.fire();209this._onDidSelectWorkspace.fire(restored);210this._watchForConnectionFailure(restored);211}212}213}));214215// Load VS Code recent folders eagerly and refresh on changes216this._loadVSCodeRecentFolders();217this._register(this.workspacesService.onDidChangeRecentlyOpened(() => this._loadVSCodeRecentFolders()));218219// Re-arm auto-tab whenever the workspace selection changes to a new220// value, but only while the picker is closed. This way picking a tab221// and then a workspace within the same open keeps that tab active for222// the current session, while the next fresh open follows the latest223// selection's category. Clears (`undefined`) are ignored so the224// previously-active tab is preserved.225this._register(this.onDidSelectWorkspace(selection => {226if (selection && !this.actionWidgetService.isVisible && !this._tabbedWidget.isVisible) {227this._userPickedTab = false;228}229}));230}231232/**233* Renders the project picker trigger button into the given container.234* Returns the container element.235*/236render(container: HTMLElement): HTMLElement {237this._renderDisposables.clear();238239const slot = dom.append(container, dom.$('.sessions-chat-picker-slot.sessions-chat-workspace-picker'));240this._renderDisposables.add({ dispose: () => slot.remove() });241242const trigger = dom.append(slot, dom.$('a.action-label'));243trigger.tabIndex = 0;244trigger.role = 'button';245trigger.setAttribute('aria-haspopup', 'listbox');246trigger.setAttribute('aria-expanded', 'false');247this._triggerElement = trigger;248249this._updateTriggerLabel();250251this._renderDisposables.add(touch.Gesture.addTarget(trigger));252[dom.EventType.CLICK, touch.EventType.Tap].forEach(eventType => {253this._renderDisposables.add(dom.addDisposableListener(trigger, eventType, (e) => {254dom.EventHelper.stop(e, true);255this.showPicker();256}));257});258259this._renderDisposables.add(dom.addDisposableListener(trigger, dom.EventType.KEY_DOWN, (e) => {260if (e.key === 'Enter' || e.key === ' ') {261dom.EventHelper.stop(e, true);262this.showPicker();263}264}));265266return slot;267}268269/**270* Shows the workspace picker dropdown anchored to the trigger element.271*272* @param force When true, re-show even if the picker is already visible.273* Used internally when swapping items in place after a tab274* change.275*/276showPicker(force = false): void {277if (!this._triggerElement) {278return;279}280const alreadyVisible = this.actionWidgetService.isVisible || this._tabbedWidget.isVisible;281if (!force && alreadyVisible) {282return;283}284285const tabs = this._showTabs() ? this._getAvailableGroups() : [];286287// Default the active tab to the group of the currently selected288// workspace. The user-pick latch is reset on every selection change,289// so picking a tab during one open of the picker doesn't permanently290// override auto-tab.291if (tabs.length > 0) {292const selectedGroup = this._selectedWorkspace?.workspace.group;293if (!this._userPickedTab && selectedGroup && tabs.includes(selectedGroup)) {294this._activeTab = selectedGroup;295}296if (!this._activeTab || !tabs.includes(this._activeTab)) {297this._activeTab = tabs[0];298}299}300301const tabbed = tabs.length > 1;302if (tabbed) {303this._showTabbedPicker(tabs);304} else {305this._activeTab = undefined;306this._showFlatPicker();307}308}309310/**311* Subclasses may opt out of the categorical tab bar (e.g. when scoped to312* a single host).313*/314protected _showTabs(): boolean {315return true;316}317318protected _getAvailableGroups(): string[] {319const groups = new Set<string>();320groups.add(SESSION_WORKSPACE_GROUP_REMOTE);321for (const provider of this.sessionsProvidersService.getProviders()) {322if (provider.supportsLocalWorkspaces) {323groups.add(SESSION_WORKSPACE_GROUP_LOCAL);324}325for (const action of provider.browseActions) {326if (action.group) {327groups.add(action.group);328}329}330}331return Array.from(groups).sort((a, b) =>332a === SESSION_WORKSPACE_GROUP_LOCAL ? -1333: b === SESSION_WORKSPACE_GROUP_LOCAL ? 1334: a.localeCompare(b));335}336337/**338* Builds the shared `IActionListDelegate` used by both the flat and339* tabbed presentations.340*/341private _buildDelegate(triggerElement: HTMLElement, hide: () => void): IActionListDelegate<IWorkspacePickerItem> {342return {343onSelect: (item) => {344hide();345if (item.run) {346item.run();347} else if (item.commandId) {348this.commandService.executeCommand(item.commandId);349} else if (item.selection && this._isProviderUnavailable(item.selection.providerId)) {350// Workspace belongs to an unavailable remote — ignore selection351return;352}353if (item.browseActionIndex !== undefined) {354this._executeBrowseAction(item.browseActionIndex);355} else if (item.selection) {356this._selectProject(item.selection);357}358},359onHide: () => {360triggerElement.setAttribute('aria-expanded', 'false');361triggerElement.focus();362},363};364}365366private _buildListOptions(items: readonly IActionListItem<IWorkspacePickerItem>[], pickerWidth: number | undefined): IActionListOptions {367const showFilter = items.filter(i => i.kind === ActionListItemKind.Action).length > FILTER_THRESHOLD;368return showFilter369? { showFilter: true, filterPlaceholder: localize('workspacePicker.filter', "Search Workspaces..."), reserveSubmenuSpace: false, inlineDescription: true, showGroupTitleOnFirstItem: true, minWidth: pickerWidth, maxWidth: pickerWidth }370: { reserveSubmenuSpace: false, inlineDescription: true, showGroupTitleOnFirstItem: true, minWidth: pickerWidth, maxWidth: pickerWidth };371}372373/**374* Flat (no-tabs) presentation. Delegates rendering to the shared375* `IActionWidgetService` so we benefit from its keybindings, focus376* tracking and submenu chrome.377*/378private _showFlatPicker(): void {379// Tear down any previous tabbed popup before delegating to the380// shared service — the two presentations don't co-exist.381this._tabbedWidget.hide();382const triggerElement = this._triggerElement!;383const items = this._buildItems();384const delegate = this._buildDelegate(triggerElement, () => this._hidePicker());385triggerElement.setAttribute('aria-expanded', 'true');386387this.actionWidgetService.show<IWorkspacePickerItem>(388'workspacePicker',389false,390items,391delegate,392triggerElement,393undefined,394[],395{396getAriaLabel: (item) => item.label ?? '',397getWidgetAriaLabel: () => localize('workspacePicker.ariaLabel', "Workspace Picker"),398},399this._buildListOptions(items, undefined),400);401}402403/**404* Tabbed presentation. Delegates rendering and lifecycle to the405* platform `TabbedActionListWidget`; this picker only owns the data406* and selection logic.407*/408private _showTabbedPicker(tabs: readonly string[]): void {409const triggerElement = this._triggerElement!;410// Hide the flat picker if it's visible — the two presentations411// don't co-exist.412if (this.actionWidgetService.isVisible) {413this.actionWidgetService.hide();414}415416const delegate = this._buildDelegate(triggerElement, () => this._hidePicker());417const accessibilityProvider = {418getAriaLabel: (item: IActionListItem<IWorkspacePickerItem>) => item.label ?? '',419getWidgetAriaLabel: () => localize('workspacePicker.ariaLabel', "Workspace Picker"),420};421422triggerElement.setAttribute('aria-expanded', 'true');423this._pickerGroupContext.set(this._activeTab ?? tabs[0]);424this._tabbedWidget.show<IWorkspacePickerItem>({425user: 'workspacePicker',426anchor: triggerElement,427tabs,428initialTab: this._activeTab ?? tabs[0],429createActionList: (tab) => {430this._activeTab = tab;431const items = this._buildItems();432return { items, listOptions: { inlineDescription: true, showGroupTitleOnFirstItem: true } };433},434delegate,435accessibilityProvider,436width: TABBED_PICKER_WIDTH,437tabBarClassName: 'sessions-workspace-picker-tabbar',438});439}440441/**442* Programmatically set the selected project.443* @param fireEvent Whether to fire the onDidSelectWorkspace event. Defaults to true.444*/445setSelectedWorkspace(project: IWorkspaceSelection, fireEvent = true): void {446this._selectProject(project, fireEvent);447}448449/**450* Hides whichever popup variant is currently visible — the shared451* action-widget-service flat picker or our own context-view-driven452* tabbed picker.453*/454private _hidePicker(): void {455this._tabbedWidget.hide();456if (this.actionWidgetService.isVisible) {457this.actionWidgetService.hide();458}459}460461/**462* Clears the selected project.463*/464clearSelection(): void {465this._hidePicker();466this._userHasPicked = true;467this._connectionStatusWatch.clear();468this._selectedWorkspace = undefined;469// Clear checked state from all recents470const recents = this._getStoredRecentWorkspaces();471const updated = recents.map(p => ({ ...p, checked: false }));472this.storageService.store(STORAGE_KEY_RECENT_WORKSPACES, JSON.stringify(updated), StorageScope.PROFILE, StorageTarget.MACHINE);473this._updateTriggerLabel();474this._onDidChangeSelection.fire();475}476477/**478* Clears the selection if it matches the given URI.479*/480removeFromRecents(uri: URI): void {481if (this._selectedWorkspace && this.uriIdentityService.extUri.isEqual(this._selectedWorkspace.workspace.repositories[0]?.uri, uri)) {482this.clearSelection();483}484}485486private _selectProject(selection: IWorkspaceSelection, fireEvent = true): void {487this._userHasPicked = true;488this._connectionStatusWatch.clear();489this._selectedWorkspace = selection;490this._persistSelectedWorkspace(selection);491this._updateTriggerLabel();492this._onDidChangeSelection.fire();493if (fireEvent) {494this._onDidSelectWorkspace.fire(selection);495}496}497498/**499* Executes a browse action from a provider, identified by index.500*/501protected async _executeBrowseAction(actionIndex: number): Promise<void> {502const allActions = this._getAllBrowseActions();503const action = allActions[actionIndex];504if (!action) {505return;506}507508try {509const workspace = await action.run();510if (workspace) {511let providerId = action.providerId;512if (!providerId) {513// Picker-owned local action — use the provider chosen during browse514providerId = this._selectedLocalProviderId ?? '';515this._selectedLocalProviderId = undefined;516}517if (providerId) {518this._selectProject({ providerId, workspace });519}520}521} catch {522// browse action was cancelled or failed523}524}525526/**527* Collects browse actions from all registered providers, scoped to the528* currently active tab when tabs are shown.529*/530protected _getAllBrowseActions(): ISessionWorkspaceBrowseAction[] {531const all = this.sessionsProvidersService.getProviders().flatMap(p => p.browseActions);532const hasLocalSupport = this.sessionsProvidersService.getProviders().some(p => p.supportsLocalWorkspaces);533if (hasLocalSupport) {534const localAction: ISessionWorkspaceBrowseAction = {535label: localize('workspacePicker.browseSelectLocal', "Select..."),536group: SESSION_WORKSPACE_GROUP_LOCAL,537icon: Codicon.folderOpened,538providerId: '',539run: () => this._browseForLocalFolder(),540};541all.unshift(localAction);542}543if (!this._isTabFiltered()) {544return all;545}546return all.filter(a => a.group === this._activeTab);547}548549/**550* Opens a folder picker dialog and resolves the selected folder through551* a provider that supports local workspaces. When multiple providers552* support local workspaces, shows a quick pick to choose the provider first.553*/554private async _browseForLocalFolder(): Promise<ISessionWorkspace | undefined> {555const localProviders = this.sessionsProvidersService.getProviders().filter(p => p.supportsLocalWorkspaces);556if (localProviders.length === 0) {557return undefined;558}559560let provider = localProviders[0];561if (localProviders.length > 1) {562const picked = await this.quickInputService.pick(563localProviders.map(p => ({ label: p.label, provider: p })),564{ placeHolder: localize('pickLocalProvider', "Select a provider") },565);566if (!picked) {567return undefined;568}569provider = picked.provider;570}571572const result = await this.fileDialogService.showOpenDialog({573canSelectFolders: true,574canSelectFiles: false,575canSelectMany: false,576});577if (!result?.length) {578return undefined;579}580581this._selectedLocalProviderId = provider.id;582return provider.resolveWorkspace(result[0]);583}584585/** True when the picker is currently scoped to a single tab. */586protected _isTabFiltered(): boolean {587return this._showTabs() && !!this._activeTab && this._getAvailableGroups().length > 1;588}589590/**591* Builds the picker items list from recent workspaces.592*593* Items are shown in a flat recency-sorted list (most recently used first)594* without source grouping. Own recents come first, followed by VS Code595* recent folders.596*/597protected _buildItems(): IActionListItem<IWorkspacePickerItem>[] {598const items: IActionListItem<IWorkspacePickerItem>[] = [];599600// Collect recent workspaces from picker storage across all providers601const allProviders = this.sessionsProvidersService.getProviders();602const providerIds = new Set(allProviders.map(p => p.id));603const tabFilter = this._isTabFiltered()604? (w: IWorkspaceSelection) => w.workspace.group === this._activeTab605: undefined;606const ownRecentWorkspaces = this._getRecentWorkspaces()607.filter(w => providerIds.has(w.providerId))608.filter(w => !tabFilter || tabFilter({ providerId: w.providerId, workspace: w.workspace }));609610// Merge VS Code recent folders (resolved through providers, deduplicated)611const vsCodeRecents = this._getVSCodeRecentWorkspaces()612.filter(w => providerIds.has(w.providerId))613.filter(w => !tabFilter || tabFilter({ providerId: w.providerId, workspace: w.workspace }));614const ownRecentCount = ownRecentWorkspaces.length;615const recentWorkspaces = [...ownRecentWorkspaces, ...vsCodeRecents];616617// Build flat list in recency order (no source grouping)618for (let i = 0; i < recentWorkspaces.length; i++) {619const { workspace, providerId } = recentWorkspaces[i];620const isOwnRecent = i < ownRecentCount;621const provider = allProviders.find(p => p.id === providerId);622const connectionStatus = provider && isAgentHostProvider(provider) ? provider.connectionStatus?.get() : undefined;623const isDisconnected = connectionStatus === RemoteAgentHostConnectionStatus.Disconnected;624const selection: IWorkspaceSelection = { providerId, workspace };625const selected = this._isSelectedWorkspace(selection);626items.push({627kind: ActionListItemKind.Action,628label: workspace.label,629description: workspace.description,630group: { title: '', icon: workspace.icon },631disabled: isDisconnected,632item: { selection, checked: selected || undefined },633onRemove: isOwnRecent ? () => this._removeRecentWorkspace(selection) : () => this._removeVSCodeRecentWorkspace(selection),634});635}636637// Browse actions from all providers (filtered to the active tab)638const allBrowseActions = this._getAllBrowseActions();639// Remote providers with connection status — shown as dynamic rows640// in the Manage submenu on the Remote tab.641const remoteProviders = allProviders.filter(isAgentHostProvider).filter(p => p.connectionStatus !== undefined);642const includeRemoteProviders = this._activeTab === SESSION_WORKSPACE_GROUP_REMOTE;643644if (items.length > 0 && (allBrowseActions.length > 0)) {645items.push({ kind: ActionListItemKind.Separator, label: '' });646}647648// Render each browse action individually. Within a tab, actions are649// already constrained to a single category, so cross-provider650// merging is no longer meaningful.651allBrowseActions.forEach((action, index) => {652const provider = allProviders.find(p => p.id === action.providerId);653const connectionStatus = provider && isAgentHostProvider(provider) ? provider.connectionStatus?.get() : undefined;654const isUnavailable = connectionStatus === RemoteAgentHostConnectionStatus.Disconnected || connectionStatus === RemoteAgentHostConnectionStatus.Connecting;655items.push({656kind: ActionListItemKind.Action,657label: localize('workspacePicker.browseSelectAction', "Select..."),658description: action.description,659group: { title: '', icon: action.icon },660disabled: isUnavailable,661item: { browseActionIndex: index },662});663});664665// Inline "Manage" entries: dynamic remote provider rows (scoped to666// the Remote tab) + menu-contributed actions (filtered by the667// `sessionWorkspacePickerGroup` context key).668const manageActions: IAction[] = [];669if (includeRemoteProviders) {670for (const provider of remoteProviders) {671const status = provider.connectionStatus!.get();672const isTunnel = provider.remoteAddress?.startsWith(TUNNEL_ADDRESS_PREFIX);673const action = toAction({674id: `workspacePicker.remote.${provider.id}`,675label: provider.label,676tooltip: getStatusLabel(status),677enabled: true,678run: () => {679this._hidePicker();680this._showRemoteHostOptionsDelayed(provider);681},682});683const extended = action as IAction & { icon?: ThemeIcon; hoverContent?: string; onRemove?: () => void };684extended.icon = isTunnel ? Codicon.cloud : Codicon.remote;685extended.hoverContent = getStatusHover(status, provider.remoteAddress);686if (!isTunnel && provider.remoteAddress) {687const address = provider.remoteAddress;688extended.onRemove = async () => {689await this.remoteAgentHostService.removeRemoteAgentHost(address);690};691}692manageActions.push(action);693}694}695696const menuActions = this.menuService.getMenuActions(Menus.SessionWorkspaceManage, this.contextKeyService, { renderShortTitle: true });697for (const [, actions] of menuActions) {698for (const menuAction of actions) {699if (menuAction instanceof MenuItemAction) {700const icon = ThemeIcon.isThemeIcon(menuAction.item.icon) ? menuAction.item.icon : undefined;701manageActions.push(Object.assign(menuAction, { icon }));702}703}704}705706if (manageActions.length > 0) {707if (items.length > 0 && items[items.length - 1].kind !== ActionListItemKind.Separator) {708items.push({ kind: ActionListItemKind.Separator, label: '' });709}710for (const action of manageActions) {711const icon = (action as IAction & { icon?: ThemeIcon }).icon;712items.push({713kind: ActionListItemKind.Action,714label: action.label,715group: { title: '', icon: icon ?? Codicon.settingsGear },716item: { run: () => action.run(), commandId: action.id },717});718}719}720721return items;722}723724private _showRemoteHostOptionsDelayed(provider: IAgentHostSessionsProvider): void {725// Defer one tick so the action widget fully tears down (focus/DOM cleanup)726// before the QuickPick opens and claims focus.727const timeout = setTimeout(() => {728this.instantiationService.invokeFunction(accessor => showRemoteHostOptions(accessor, provider));729}, 1);730this._renderDisposables.add({ dispose: () => clearTimeout(timeout) });731}732733private _updateTriggerLabel(): void {734if (!this._triggerElement) {735return;736}737738dom.clearNode(this._triggerElement);739const workspace = this._selectedWorkspace?.workspace;740const label = workspace ? workspace.label : localize('pickWorkspace', "workspace");741const icon = workspace ? workspace.icon : Codicon.project;742743this._triggerElement.setAttribute('aria-label', workspace744? localize('workspacePicker.selectedAriaLabel', "New session in {0}", label)745: localize('workspacePicker.pickAriaLabel', "Start by picking a workspace"));746747dom.append(this._triggerElement, renderIcon(icon));748const labelSpan = dom.append(this._triggerElement, dom.$('span.sessions-chat-dropdown-label'));749labelSpan.textContent = label;750dom.append(this._triggerElement, renderIcon(Codicon.chevronDown)).classList.add('sessions-chat-dropdown-chevron');751}752753/**754* Returns whether the given provider is a remote that is currently unavailable755* (disconnected or still connecting).756* Returns false for providers without connection status (e.g. local providers).757*/758protected _isProviderUnavailable(providerId: string): boolean {759const provider = this.sessionsProvidersService.getProvider(providerId);760if (!provider || !isAgentHostProvider(provider) || !provider.connectionStatus) {761return false;762}763return provider.connectionStatus.get() !== RemoteAgentHostConnectionStatus.Connected;764}765766protected _isSelectedWorkspace(selection: IWorkspaceSelection): boolean {767if (!this._selectedWorkspace) {768return false;769}770if (this._selectedWorkspace.providerId !== selection.providerId) {771return false;772}773const selectedUri = this._selectedWorkspace.workspace.repositories[0]?.uri;774const candidateUri = selection.workspace.repositories[0]?.uri;775return this.uriIdentityService.extUri.isEqual(selectedUri, candidateUri);776}777778private _persistSelectedWorkspace(selection: IWorkspaceSelection): void {779const uri = selection.workspace.repositories[0]?.uri;780if (!uri) {781return;782}783this._addRecentWorkspace(selection.providerId, selection.workspace, true);784}785786private _restoreSelectedWorkspace(): IWorkspaceSelection | undefined {787// Try the checked entry first788const checked = this._restoreCheckedWorkspace();789if (checked) {790return checked;791}792793// Fall back to the first resolvable recent workspace from a connected provider.794// Fallbacks (vs. the user's explicit checked pick) require the provider795// to be ready: we don't want to silently land on, e.g., a disconnected796// remote workspace that the user never picked.797try {798const providers = this.sessionsProvidersService.getProviders();799const providerIds = new Set(providers.map(p => p.id));800const storedRecents = this._getStoredRecentWorkspaces();801802for (const stored of storedRecents) {803if (!providerIds.has(stored.providerId)) {804continue;805}806if (this._isProviderUnavailable(stored.providerId)) {807continue;808}809const uri = URI.revive(stored.uri);810const workspace = this.sessionsProvidersService.getProvider(stored.providerId)?.resolveWorkspace(uri);811if (workspace) {812return { providerId: stored.providerId, workspace };813}814}815return undefined;816} catch {817return undefined;818}819}820821/**822* Restore only the checked (previously selected) workspace if its provider823* is registered. The provider's connection status is intentionally NOT824* checked — we honor the user's explicit pick even if the remote is still825* connecting or currently disconnected. The trigger label reflects the826* connection state separately (spinner / grayed).827*/828private _restoreCheckedWorkspace(): IWorkspaceSelection | undefined {829try {830const providers = this.sessionsProvidersService.getProviders();831const providerIds = new Set(providers.map(p => p.id));832const storedRecents = this._getStoredRecentWorkspaces();833834for (const stored of storedRecents) {835if (!stored.checked || !providerIds.has(stored.providerId)) {836continue;837}838const uri = URI.revive(stored.uri);839const workspace = this.sessionsProvidersService.getProvider(stored.providerId)?.resolveWorkspace(uri);840if (workspace) {841return { providerId: stored.providerId, workspace };842}843}844return undefined;845} catch {846return undefined;847}848}849850/**851* When restoring a workspace whose provider isn't currently Connected,852* watch the connection status. Fires `onDidSelectWorkspace(undefined)`853* (which the view pane converts to `unsetNewSession()`) if:854* - the status transitions to Disconnected after we start watching, or855* - the status is still not Connected after a short grace period.856*857* The grace period covers a race: provider state can transition synchronously858* inside provider registration before our autorun's first read, so we may859* never observe an explicit Disconnected transition. The timer ensures we860* eventually fall back instead of leaving the picker showing an unreachable861* remote with no session.862*863* Has no effect once the user makes an explicit pick (`_userHasPicked`).864*/865private _watchForConnectionFailure(selection: IWorkspaceSelection): void {866const provider = this.sessionsProvidersService.getProvider(selection.providerId);867if (!provider || !isAgentHostProvider(provider) || !provider.connectionStatus) {868return;869}870const connStatus = provider.connectionStatus;871if (connStatus.get() === RemoteAgentHostConnectionStatus.Connected) {872return;873}874875const store = new DisposableStore();876this._connectionStatusWatch.value = store;877878const fallback = () => {879this._connectionStatusWatch.clear();880if (!this._userHasPicked && this._isSelectedWorkspace(selection)) {881this._selectedWorkspace = undefined;882this._updateTriggerLabel();883this._onDidChangeSelection.fire();884this._onDidSelectWorkspace.fire(undefined);885}886};887888let isFirstRun = true;889store.add(autorun(reader => {890const status = connStatus.read(reader);891if (status === RemoteAgentHostConnectionStatus.Connected) {892this._connectionStatusWatch.clear();893} else if (status === RemoteAgentHostConnectionStatus.Disconnected && !isFirstRun) {894fallback();895}896isFirstRun = false;897}));898899// Safety net: if the connection hasn't succeeded by the grace period,900// fall back. Catches the case where the provider's status flips before901// our autorun subscribes (so we never observe a transition).902disposableTimeout(() => {903if (connStatus.get() !== RemoteAgentHostConnectionStatus.Connected) {904fallback();905}906}, RESTORE_CONNECT_GRACE_MS, store);907}908909/**910* Migrate legacy `sessions.recentlyPickedProjects` storage to the new911* `sessions.recentlyPickedWorkspaces` key, adding `providerId` (defaulting912* to Copilot) and ensuring at least one entry is checked.913*/914private _migrateLegacyStorage(): void {915// Already migrated916if (this.storageService.get(STORAGE_KEY_RECENT_WORKSPACES, StorageScope.PROFILE)) {917return;918}919920const raw = this.storageService.get(LEGACY_STORAGE_KEY_RECENT_PROJECTS, StorageScope.PROFILE);921if (!raw) {922return;923}924925try {926const parsed = JSON.parse(raw) as { uri: UriComponents; checked?: boolean }[];927const hasAnyChecked = parsed.some(e => e.checked);928const migrated: IStoredRecentWorkspace[] = parsed.map((entry, index) => ({929uri: entry.uri,930providerId: COPILOT_PROVIDER_ID,931checked: hasAnyChecked ? !!entry.checked : index === 0,932}));933this.storageService.store(STORAGE_KEY_RECENT_WORKSPACES, JSON.stringify(migrated), StorageScope.PROFILE, StorageTarget.MACHINE);934} catch { /* ignore */ }935936this.storageService.remove(LEGACY_STORAGE_KEY_RECENT_PROJECTS, StorageScope.PROFILE);937}938939// -- Recent workspaces storage --940941private _addRecentWorkspace(providerId: string, workspace: ISessionWorkspace, checked: boolean): void {942const uri = workspace.repositories[0]?.uri;943if (!uri) {944return;945}946const recents = this._getStoredRecentWorkspaces();947const filtered = recents.map(p => {948// Remove the entry being re-added (it will go to the front)949if (p.providerId === providerId && this.uriIdentityService.extUri.isEqual(URI.revive(p.uri), uri)) {950return undefined;951}952// Clear checked from all other entries when marking checked953if (checked && p.checked) {954return { ...p, checked: false };955}956return p;957}).filter((p): p is IStoredRecentWorkspace => p !== undefined);958959const entry: IStoredRecentWorkspace = { uri: uri.toJSON(), providerId, checked };960const updated = [entry, ...filtered].slice(0, MAX_RECENT_WORKSPACES);961this.storageService.store(STORAGE_KEY_RECENT_WORKSPACES, JSON.stringify(updated), StorageScope.PROFILE, StorageTarget.MACHINE);962}963964protected _getRecentWorkspaces(): { providerId: string; workspace: ISessionWorkspace }[] {965return this._getStoredRecentWorkspaces()966.map(stored => {967const uri = URI.revive(stored.uri);968const workspace = this.sessionsProvidersService.getProvider(stored.providerId)?.resolveWorkspace(uri);969if (!workspace) {970return undefined;971}972return { providerId: stored.providerId, workspace };973})974.filter((w): w is { providerId: string; workspace: ISessionWorkspace } => w !== undefined);975}976977protected _removeRecentWorkspace(selection: IWorkspaceSelection): void {978const uri = selection.workspace.repositories[0]?.uri;979if (!uri) {980return;981}982const recents = this._getStoredRecentWorkspaces();983const updated = recents.filter(p =>984!(p.providerId === selection.providerId && this.uriIdentityService.extUri.isEqual(URI.revive(p.uri), uri))985);986this.storageService.store(STORAGE_KEY_RECENT_WORKSPACES, JSON.stringify(updated), StorageScope.PROFILE, StorageTarget.MACHINE);987988// Clear current selection if it was the removed workspace989if (this._isSelectedWorkspace(selection)) {990this._hidePicker();991this._selectedWorkspace = undefined;992this._updateTriggerLabel();993this._onDidSelectWorkspace.fire(undefined);994}995}996997protected _removeVSCodeRecentWorkspace(selection: IWorkspaceSelection): void {998const uri = selection.workspace.repositories[0]?.uri;999if (!uri) {1000return;1001}1002this.workspacesService.removeRecentlyOpened([uri]);10031004// Clear current selection if it was the removed workspace1005if (this._isSelectedWorkspace(selection)) {1006this._hidePicker();1007this._selectedWorkspace = undefined;1008this._updateTriggerLabel();1009this._onDidSelectWorkspace.fire(undefined);1010}1011}10121013private _getStoredRecentWorkspaces(): IStoredRecentWorkspace[] {1014const raw = this.storageService.get(STORAGE_KEY_RECENT_WORKSPACES, StorageScope.PROFILE);1015if (!raw) {1016return [];1017}1018try {1019return JSON.parse(raw) as IStoredRecentWorkspace[];1020} catch {1021return [];1022}1023}10241025// -- VS Code recent folders -----------------------------------------------10261027private async _loadVSCodeRecentFolders(): Promise<void> {1028const recentlyOpened = await this.workspacesService.getRecentlyOpened();1029this._vsCodeRecentFolderUris = recentlyOpened.workspaces1030.filter(isRecentFolder)1031.map(f => f.folderUri)1032.filter(uri => !this._isCopilotWorktree(uri))1033.slice(0, 10);1034}10351036/**1037* Returns whether the given URI points to a copilot-managed folder1038* (a folder whose name starts with `copilot-`).1039*/1040private _isCopilotWorktree(uri: URI): boolean {1041return basename(uri).startsWith('copilot-');1042}10431044/**1045* Returns VS Code recent folders resolved through registered session1046* providers, excluding any URIs already present in the sessions' own1047* recent workspace history.1048*/1049protected _getVSCodeRecentWorkspaces(): { providerId: string; workspace: ISessionWorkspace }[] {1050if (this._vsCodeRecentFolderUris.length === 0) {1051return [];1052}10531054// Collect URIs already in sessions history to avoid duplicates1055const ownRecents = this._getStoredRecentWorkspaces();1056const ownUris = new Set(ownRecents.map(r => URI.revive(r.uri).toString()));10571058const providers = this.sessionsProvidersService.getProviders();1059const result: { providerId: string; workspace: ISessionWorkspace }[] = [];10601061for (const folderUri of this._vsCodeRecentFolderUris) {1062if (ownUris.has(folderUri.toString())) {1063continue;1064}1065for (const provider of providers) {1066if (this._isProviderUnavailable(provider.id)) {1067continue;1068}1069const workspace = provider.resolveWorkspace(folderUri);1070if (workspace) {1071result.push({ providerId: provider.id, workspace });1072}1073}1074if (result.length >= 10) {1075break;1076}1077}10781079return result;1080}10811082}108310841085