Book a Demo!
CoCalc Logo Icon
StoreFeaturesDocsShareSupportNewsAboutPoliciesSign UpSign In
microsoft
GitHub Repository: microsoft/vscode
Path: blob/main/src/vs/sessions/contrib/chat/browser/sessionWorkspacePicker.ts
13401 views
1
/*---------------------------------------------------------------------------------------------
2
* Copyright (c) Microsoft Corporation. All rights reserved.
3
* Licensed under the MIT License. See License.txt in the project root for license information.
4
*--------------------------------------------------------------------------------------------*/
5
6
import * as dom from '../../../../base/browser/dom.js';
7
import * as touch from '../../../../base/browser/touch.js';
8
import { IAction, toAction } from '../../../../base/common/actions.js';
9
import { Codicon } from '../../../../base/common/codicons.js';
10
import { Emitter, Event } from '../../../../base/common/event.js';
11
import { disposableTimeout } from '../../../../base/common/async.js';
12
import { Disposable, DisposableStore, MutableDisposable } from '../../../../base/common/lifecycle.js';
13
import { URI, UriComponents } from '../../../../base/common/uri.js';
14
import { basename } from '../../../../base/common/resources.js';
15
import { autorun } from '../../../../base/common/observable.js';
16
import { localize } from '../../../../nls.js';
17
import { IActionWidgetService } from '../../../../platform/actionWidget/browser/actionWidget.js';
18
import { ActionListItemKind, IActionListDelegate, IActionListItem, IActionListOptions } from '../../../../platform/actionWidget/browser/actionList.js';
19
import { TabbedActionListWidget } from '../../../../platform/actionWidget/browser/tabbedActionListWidget.js';
20
import { IMenuService, MenuItemAction } from '../../../../platform/actions/common/actions.js';
21
import { IRemoteAgentHostService, RemoteAgentHostConnectionStatus } from '../../../../platform/agentHost/common/remoteAgentHostService.js';
22
import { TUNNEL_ADDRESS_PREFIX } from '../../../../platform/agentHost/common/tunnelAgentHost.js';
23
import { ICommandService } from '../../../../platform/commands/common/commands.js';
24
import { IConfigurationService } from '../../../../platform/configuration/common/configuration.js';
25
import { IContextKeyService, IContextKey } from '../../../../platform/contextkey/common/contextkey.js';
26
import { IFileDialogService } from '../../../../platform/dialogs/common/dialogs.js';
27
import { IQuickInputService } from '../../../../platform/quickinput/common/quickInput.js';
28
import { IStorageService, StorageScope, StorageTarget } from '../../../../platform/storage/common/storage.js';
29
import { IUriIdentityService } from '../../../../platform/uriIdentity/common/uriIdentity.js';
30
import { renderIcon } from '../../../../base/browser/ui/iconLabel/iconLabels.js';
31
import { ThemeIcon } from '../../../../base/common/themables.js';
32
import { ISessionWorkspace, ISessionWorkspaceBrowseAction, SESSION_WORKSPACE_GROUP_LOCAL, SESSION_WORKSPACE_GROUP_REMOTE } from '../../../services/sessions/common/session.js';
33
import { ISessionsProvidersService } from '../../../services/sessions/browser/sessionsProvidersService.js';
34
import { IAgentHostSessionsProvider, isAgentHostProvider } from '../../../common/agentHostSessionsProvider.js';
35
import { SessionWorkspacePickerGroupContext } from '../../../common/contextkeys.js';
36
import { getStatusHover, getStatusLabel, showRemoteHostOptions } from '../../remoteAgentHost/browser/remoteHostOptions.js';
37
import { IInstantiationService } from '../../../../platform/instantiation/common/instantiation.js';
38
import { COPILOT_PROVIDER_ID } from '../../copilotChatSessions/browser/copilotChatSessionsProvider.js';
39
import { IWorkspacesService, isRecentFolder } from '../../../../platform/workspaces/common/workspaces.js';
40
import { Menus } from '../../../browser/menus.js';
41
42
const LEGACY_STORAGE_KEY_RECENT_PROJECTS = 'sessions.recentlyPickedProjects';
43
const STORAGE_KEY_RECENT_WORKSPACES = 'sessions.recentlyPickedWorkspaces';
44
const FILTER_THRESHOLD = 10;
45
const MAX_RECENT_WORKSPACES = 10;
46
47
/**
48
* Fixed picker width when the categorical tab bar is shown. Keeps the tab
49
* row and the list aligned and prevents horizontal jitter when switching
50
* tabs.
51
*/
52
const TABBED_PICKER_WIDTH = 360;
53
54
/**
55
* Grace period for a restored remote workspace's provider to reach Connected
56
* before we fall back to no selection. SSH tunnels typically connect within
57
* a couple seconds; if it hasn't connected by then, we'd rather show no
58
* selection than leave the user staring at an unreachable workspace.
59
*/
60
const RESTORE_CONNECT_GRACE_MS = 5000;
61
62
/**
63
* A workspace selection from the picker, pairing the workspace with its owning provider.
64
*/
65
export interface IWorkspaceSelection {
66
readonly providerId: string;
67
readonly workspace: ISessionWorkspace;
68
}
69
70
/**
71
* Stored recent workspace entry. The `checked` flag marks the currently
72
* selected workspace so we only need a single storage key.
73
*/
74
interface IStoredRecentWorkspace {
75
readonly uri: UriComponents;
76
readonly providerId: string;
77
readonly checked: boolean;
78
}
79
80
/**
81
* Item type used in the action list.
82
*/
83
export interface IWorkspacePickerItem {
84
readonly selection?: IWorkspaceSelection;
85
readonly browseActionIndex?: number;
86
readonly checked?: boolean;
87
/** Command to execute when this item is selected. */
88
readonly commandId?: string;
89
/** Inline action to run when this item is selected. */
90
readonly run?: () => void;
91
}
92
93
/**
94
* A unified workspace picker that shows workspaces from all registered session
95
* providers in a single dropdown.
96
*
97
* Browse actions from providers are appended at the bottom of the list.
98
*/
99
export class WorkspacePicker extends Disposable {
100
101
protected readonly _onDidSelectWorkspace = this._register(new Emitter<IWorkspaceSelection | undefined>());
102
readonly onDidSelectWorkspace: Event<IWorkspaceSelection | undefined> = this._onDidSelectWorkspace.event;
103
protected readonly _onDidChangeSelection = this._register(new Emitter<void>());
104
readonly onDidChangeSelection: Event<void> = this._onDidChangeSelection.event;
105
106
private _selectedWorkspace: IWorkspaceSelection | undefined;
107
108
/**
109
* Set to `true` once the user has explicitly picked or cleared a workspace.
110
* Until then, late-arriving provider registrations are allowed to upgrade
111
* the current (auto-restored) selection to the user's stored "checked"
112
* entry. After the user has acted, providers coming and going never move
113
* the selection out from under them.
114
*/
115
private _userHasPicked = false;
116
117
/**
118
* Watches the connection status of a restored remote workspace. Cleared when
119
* the user explicitly picks, when the connection succeeds, or when it fails
120
* and we fall back.
121
*/
122
private readonly _connectionStatusWatch = this._register(new MutableDisposable());
123
124
/** Provider ID chosen during the last local folder browse. */
125
private _selectedLocalProviderId: string | undefined;
126
127
private _triggerElement: HTMLElement | undefined;
128
private readonly _renderDisposables = this._register(new DisposableStore());
129
private readonly _tabbedWidget: TabbedActionListWidget;
130
private readonly _pickerGroupContext: IContextKey<string>;
131
132
/**
133
* Currently active workspace tab (a group label contributed by a
134
* provider, e.g. `"Local"` / `"Cloud"` / `"Remote"`).
135
*/
136
private _activeTab: string | undefined;
137
138
/**
139
* Whether the user explicitly clicked a tab while the picker was open.
140
* Reset on each fresh open so the picker re-defaults to the selected
141
* workspace's group between opens.
142
*/
143
private _userPickedTab = false;
144
145
/** Cached VS Code recent folder URIs, resolved lazily. */
146
private _vsCodeRecentFolderUris: URI[] = [];
147
148
get selectedProject(): IWorkspaceSelection | undefined {
149
return this._selectedWorkspace;
150
}
151
152
constructor(
153
@IActionWidgetService protected readonly actionWidgetService: IActionWidgetService,
154
@IStorageService private readonly storageService: IStorageService,
155
@IUriIdentityService private readonly uriIdentityService: IUriIdentityService,
156
@ISessionsProvidersService protected readonly sessionsProvidersService: ISessionsProvidersService,
157
@IRemoteAgentHostService private readonly remoteAgentHostService: IRemoteAgentHostService,
158
@IConfigurationService _configurationService: IConfigurationService,
159
@ICommandService private readonly commandService: ICommandService,
160
@IWorkspacesService private readonly workspacesService: IWorkspacesService,
161
@IMenuService private readonly menuService: IMenuService,
162
@IContextKeyService private readonly contextKeyService: IContextKeyService,
163
@IInstantiationService private readonly instantiationService: IInstantiationService,
164
@IFileDialogService private readonly fileDialogService: IFileDialogService,
165
@IQuickInputService private readonly quickInputService: IQuickInputService,
166
) {
167
super();
168
169
this._tabbedWidget = this._register(this.instantiationService.createInstance(TabbedActionListWidget));
170
this._pickerGroupContext = SessionWorkspacePickerGroupContext.bindTo(this.contextKeyService);
171
this._register(this._tabbedWidget.onDidChangeTab(tab => {
172
this._activeTab = tab;
173
this._userPickedTab = true;
174
this._pickerGroupContext.set(tab);
175
}));
176
this._register(this._tabbedWidget.onDidHide(() => {
177
this._pickerGroupContext.reset();
178
}));
179
180
// Migrate legacy storage to new key
181
this._migrateLegacyStorage();
182
183
// Restore selected workspace from storage
184
this._selectedWorkspace = this._restoreSelectedWorkspace();
185
if (this._selectedWorkspace) {
186
this._watchForConnectionFailure(this._selectedWorkspace);
187
}
188
189
// React to provider registrations/removals: re-validate the current
190
// selection, and if the user hasn't explicitly picked yet, re-restore
191
// from storage so we upgrade from any fallback to the user's actual
192
// stored selection once its provider arrives.
193
this._register(this.sessionsProvidersService.onDidChangeProviders(() => {
194
if (this._selectedWorkspace) {
195
const providers = this.sessionsProvidersService.getProviders();
196
if (!providers.some(p => p.id === this._selectedWorkspace!.providerId)) {
197
this._selectedWorkspace = undefined;
198
this._connectionStatusWatch.clear();
199
this._updateTriggerLabel();
200
this._onDidChangeSelection.fire();
201
this._onDidSelectWorkspace.fire(undefined);
202
}
203
}
204
if (!this._userHasPicked) {
205
const restored = this._restoreSelectedWorkspace();
206
if (restored && !this._isSelectedWorkspace(restored)) {
207
this._selectedWorkspace = restored;
208
this._updateTriggerLabel();
209
this._onDidChangeSelection.fire();
210
this._onDidSelectWorkspace.fire(restored);
211
this._watchForConnectionFailure(restored);
212
}
213
}
214
}));
215
216
// Load VS Code recent folders eagerly and refresh on changes
217
this._loadVSCodeRecentFolders();
218
this._register(this.workspacesService.onDidChangeRecentlyOpened(() => this._loadVSCodeRecentFolders()));
219
220
// Re-arm auto-tab whenever the workspace selection changes to a new
221
// value, but only while the picker is closed. This way picking a tab
222
// and then a workspace within the same open keeps that tab active for
223
// the current session, while the next fresh open follows the latest
224
// selection's category. Clears (`undefined`) are ignored so the
225
// previously-active tab is preserved.
226
this._register(this.onDidSelectWorkspace(selection => {
227
if (selection && !this.actionWidgetService.isVisible && !this._tabbedWidget.isVisible) {
228
this._userPickedTab = false;
229
}
230
}));
231
}
232
233
/**
234
* Renders the project picker trigger button into the given container.
235
* Returns the container element.
236
*/
237
render(container: HTMLElement): HTMLElement {
238
this._renderDisposables.clear();
239
240
const slot = dom.append(container, dom.$('.sessions-chat-picker-slot.sessions-chat-workspace-picker'));
241
this._renderDisposables.add({ dispose: () => slot.remove() });
242
243
const trigger = dom.append(slot, dom.$('a.action-label'));
244
trigger.tabIndex = 0;
245
trigger.role = 'button';
246
trigger.setAttribute('aria-haspopup', 'listbox');
247
trigger.setAttribute('aria-expanded', 'false');
248
this._triggerElement = trigger;
249
250
this._updateTriggerLabel();
251
252
this._renderDisposables.add(touch.Gesture.addTarget(trigger));
253
[dom.EventType.CLICK, touch.EventType.Tap].forEach(eventType => {
254
this._renderDisposables.add(dom.addDisposableListener(trigger, eventType, (e) => {
255
dom.EventHelper.stop(e, true);
256
this.showPicker();
257
}));
258
});
259
260
this._renderDisposables.add(dom.addDisposableListener(trigger, dom.EventType.KEY_DOWN, (e) => {
261
if (e.key === 'Enter' || e.key === ' ') {
262
dom.EventHelper.stop(e, true);
263
this.showPicker();
264
}
265
}));
266
267
return slot;
268
}
269
270
/**
271
* Shows the workspace picker dropdown anchored to the trigger element.
272
*
273
* @param force When true, re-show even if the picker is already visible.
274
* Used internally when swapping items in place after a tab
275
* change.
276
*/
277
showPicker(force = false): void {
278
if (!this._triggerElement) {
279
return;
280
}
281
const alreadyVisible = this.actionWidgetService.isVisible || this._tabbedWidget.isVisible;
282
if (!force && alreadyVisible) {
283
return;
284
}
285
286
const tabs = this._showTabs() ? this._getAvailableGroups() : [];
287
288
// Default the active tab to the group of the currently selected
289
// workspace. The user-pick latch is reset on every selection change,
290
// so picking a tab during one open of the picker doesn't permanently
291
// override auto-tab.
292
if (tabs.length > 0) {
293
const selectedGroup = this._selectedWorkspace?.workspace.group;
294
if (!this._userPickedTab && selectedGroup && tabs.includes(selectedGroup)) {
295
this._activeTab = selectedGroup;
296
}
297
if (!this._activeTab || !tabs.includes(this._activeTab)) {
298
this._activeTab = tabs[0];
299
}
300
}
301
302
const tabbed = tabs.length > 1;
303
if (tabbed) {
304
this._showTabbedPicker(tabs);
305
} else {
306
this._activeTab = undefined;
307
this._showFlatPicker();
308
}
309
}
310
311
/**
312
* Subclasses may opt out of the categorical tab bar (e.g. when scoped to
313
* a single host).
314
*/
315
protected _showTabs(): boolean {
316
return true;
317
}
318
319
protected _getAvailableGroups(): string[] {
320
const groups = new Set<string>();
321
groups.add(SESSION_WORKSPACE_GROUP_REMOTE);
322
for (const provider of this.sessionsProvidersService.getProviders()) {
323
if (provider.supportsLocalWorkspaces) {
324
groups.add(SESSION_WORKSPACE_GROUP_LOCAL);
325
}
326
for (const action of provider.browseActions) {
327
if (action.group) {
328
groups.add(action.group);
329
}
330
}
331
}
332
return Array.from(groups).sort((a, b) =>
333
a === SESSION_WORKSPACE_GROUP_LOCAL ? -1
334
: b === SESSION_WORKSPACE_GROUP_LOCAL ? 1
335
: a.localeCompare(b));
336
}
337
338
/**
339
* Builds the shared `IActionListDelegate` used by both the flat and
340
* tabbed presentations.
341
*/
342
private _buildDelegate(triggerElement: HTMLElement, hide: () => void): IActionListDelegate<IWorkspacePickerItem> {
343
return {
344
onSelect: (item) => {
345
hide();
346
if (item.run) {
347
item.run();
348
} else if (item.commandId) {
349
this.commandService.executeCommand(item.commandId);
350
} else if (item.selection && this._isProviderUnavailable(item.selection.providerId)) {
351
// Workspace belongs to an unavailable remote — ignore selection
352
return;
353
}
354
if (item.browseActionIndex !== undefined) {
355
this._executeBrowseAction(item.browseActionIndex);
356
} else if (item.selection) {
357
this._selectProject(item.selection);
358
}
359
},
360
onHide: () => {
361
triggerElement.setAttribute('aria-expanded', 'false');
362
triggerElement.focus();
363
},
364
};
365
}
366
367
private _buildListOptions(items: readonly IActionListItem<IWorkspacePickerItem>[], pickerWidth: number | undefined): IActionListOptions {
368
const showFilter = items.filter(i => i.kind === ActionListItemKind.Action).length > FILTER_THRESHOLD;
369
return showFilter
370
? { showFilter: true, filterPlaceholder: localize('workspacePicker.filter', "Search Workspaces..."), reserveSubmenuSpace: false, inlineDescription: true, showGroupTitleOnFirstItem: true, minWidth: pickerWidth, maxWidth: pickerWidth }
371
: { reserveSubmenuSpace: false, inlineDescription: true, showGroupTitleOnFirstItem: true, minWidth: pickerWidth, maxWidth: pickerWidth };
372
}
373
374
/**
375
* Flat (no-tabs) presentation. Delegates rendering to the shared
376
* `IActionWidgetService` so we benefit from its keybindings, focus
377
* tracking and submenu chrome.
378
*/
379
private _showFlatPicker(): void {
380
// Tear down any previous tabbed popup before delegating to the
381
// shared service — the two presentations don't co-exist.
382
this._tabbedWidget.hide();
383
const triggerElement = this._triggerElement!;
384
const items = this._buildItems();
385
const delegate = this._buildDelegate(triggerElement, () => this._hidePicker());
386
triggerElement.setAttribute('aria-expanded', 'true');
387
388
this.actionWidgetService.show<IWorkspacePickerItem>(
389
'workspacePicker',
390
false,
391
items,
392
delegate,
393
triggerElement,
394
undefined,
395
[],
396
{
397
getAriaLabel: (item) => item.label ?? '',
398
getWidgetAriaLabel: () => localize('workspacePicker.ariaLabel', "Workspace Picker"),
399
},
400
this._buildListOptions(items, undefined),
401
);
402
}
403
404
/**
405
* Tabbed presentation. Delegates rendering and lifecycle to the
406
* platform `TabbedActionListWidget`; this picker only owns the data
407
* and selection logic.
408
*/
409
private _showTabbedPicker(tabs: readonly string[]): void {
410
const triggerElement = this._triggerElement!;
411
// Hide the flat picker if it's visible — the two presentations
412
// don't co-exist.
413
if (this.actionWidgetService.isVisible) {
414
this.actionWidgetService.hide();
415
}
416
417
const delegate = this._buildDelegate(triggerElement, () => this._hidePicker());
418
const accessibilityProvider = {
419
getAriaLabel: (item: IActionListItem<IWorkspacePickerItem>) => item.label ?? '',
420
getWidgetAriaLabel: () => localize('workspacePicker.ariaLabel', "Workspace Picker"),
421
};
422
423
triggerElement.setAttribute('aria-expanded', 'true');
424
this._pickerGroupContext.set(this._activeTab ?? tabs[0]);
425
this._tabbedWidget.show<IWorkspacePickerItem>({
426
user: 'workspacePicker',
427
anchor: triggerElement,
428
tabs,
429
initialTab: this._activeTab ?? tabs[0],
430
createActionList: (tab) => {
431
this._activeTab = tab;
432
const items = this._buildItems();
433
return { items, listOptions: { inlineDescription: true, showGroupTitleOnFirstItem: true } };
434
},
435
delegate,
436
accessibilityProvider,
437
width: TABBED_PICKER_WIDTH,
438
tabBarClassName: 'sessions-workspace-picker-tabbar',
439
});
440
}
441
442
/**
443
* Programmatically set the selected project.
444
* @param fireEvent Whether to fire the onDidSelectWorkspace event. Defaults to true.
445
*/
446
setSelectedWorkspace(project: IWorkspaceSelection, fireEvent = true): void {
447
this._selectProject(project, fireEvent);
448
}
449
450
/**
451
* Hides whichever popup variant is currently visible — the shared
452
* action-widget-service flat picker or our own context-view-driven
453
* tabbed picker.
454
*/
455
private _hidePicker(): void {
456
this._tabbedWidget.hide();
457
if (this.actionWidgetService.isVisible) {
458
this.actionWidgetService.hide();
459
}
460
}
461
462
/**
463
* Clears the selected project.
464
*/
465
clearSelection(): void {
466
this._hidePicker();
467
this._userHasPicked = true;
468
this._connectionStatusWatch.clear();
469
this._selectedWorkspace = undefined;
470
// Clear checked state from all recents
471
const recents = this._getStoredRecentWorkspaces();
472
const updated = recents.map(p => ({ ...p, checked: false }));
473
this.storageService.store(STORAGE_KEY_RECENT_WORKSPACES, JSON.stringify(updated), StorageScope.PROFILE, StorageTarget.MACHINE);
474
this._updateTriggerLabel();
475
this._onDidChangeSelection.fire();
476
}
477
478
/**
479
* Clears the selection if it matches the given URI.
480
*/
481
removeFromRecents(uri: URI): void {
482
if (this._selectedWorkspace && this.uriIdentityService.extUri.isEqual(this._selectedWorkspace.workspace.repositories[0]?.uri, uri)) {
483
this.clearSelection();
484
}
485
}
486
487
private _selectProject(selection: IWorkspaceSelection, fireEvent = true): void {
488
this._userHasPicked = true;
489
this._connectionStatusWatch.clear();
490
this._selectedWorkspace = selection;
491
this._persistSelectedWorkspace(selection);
492
this._updateTriggerLabel();
493
this._onDidChangeSelection.fire();
494
if (fireEvent) {
495
this._onDidSelectWorkspace.fire(selection);
496
}
497
}
498
499
/**
500
* Executes a browse action from a provider, identified by index.
501
*/
502
protected async _executeBrowseAction(actionIndex: number): Promise<void> {
503
const allActions = this._getAllBrowseActions();
504
const action = allActions[actionIndex];
505
if (!action) {
506
return;
507
}
508
509
try {
510
const workspace = await action.run();
511
if (workspace) {
512
let providerId = action.providerId;
513
if (!providerId) {
514
// Picker-owned local action — use the provider chosen during browse
515
providerId = this._selectedLocalProviderId ?? '';
516
this._selectedLocalProviderId = undefined;
517
}
518
if (providerId) {
519
this._selectProject({ providerId, workspace });
520
}
521
}
522
} catch {
523
// browse action was cancelled or failed
524
}
525
}
526
527
/**
528
* Collects browse actions from all registered providers, scoped to the
529
* currently active tab when tabs are shown.
530
*/
531
protected _getAllBrowseActions(): ISessionWorkspaceBrowseAction[] {
532
const all = this.sessionsProvidersService.getProviders().flatMap(p => p.browseActions);
533
const hasLocalSupport = this.sessionsProvidersService.getProviders().some(p => p.supportsLocalWorkspaces);
534
if (hasLocalSupport) {
535
const localAction: ISessionWorkspaceBrowseAction = {
536
label: localize('workspacePicker.browseSelectLocal', "Select..."),
537
group: SESSION_WORKSPACE_GROUP_LOCAL,
538
icon: Codicon.folderOpened,
539
providerId: '',
540
run: () => this._browseForLocalFolder(),
541
};
542
all.unshift(localAction);
543
}
544
if (!this._isTabFiltered()) {
545
return all;
546
}
547
return all.filter(a => a.group === this._activeTab);
548
}
549
550
/**
551
* Opens a folder picker dialog and resolves the selected folder through
552
* a provider that supports local workspaces. When multiple providers
553
* support local workspaces, shows a quick pick to choose the provider first.
554
*/
555
private async _browseForLocalFolder(): Promise<ISessionWorkspace | undefined> {
556
const localProviders = this.sessionsProvidersService.getProviders().filter(p => p.supportsLocalWorkspaces);
557
if (localProviders.length === 0) {
558
return undefined;
559
}
560
561
let provider = localProviders[0];
562
if (localProviders.length > 1) {
563
const picked = await this.quickInputService.pick(
564
localProviders.map(p => ({ label: p.label, provider: p })),
565
{ placeHolder: localize('pickLocalProvider', "Select a provider") },
566
);
567
if (!picked) {
568
return undefined;
569
}
570
provider = picked.provider;
571
}
572
573
const result = await this.fileDialogService.showOpenDialog({
574
canSelectFolders: true,
575
canSelectFiles: false,
576
canSelectMany: false,
577
});
578
if (!result?.length) {
579
return undefined;
580
}
581
582
this._selectedLocalProviderId = provider.id;
583
return provider.resolveWorkspace(result[0]);
584
}
585
586
/** True when the picker is currently scoped to a single tab. */
587
protected _isTabFiltered(): boolean {
588
return this._showTabs() && !!this._activeTab && this._getAvailableGroups().length > 1;
589
}
590
591
/**
592
* Builds the picker items list from recent workspaces.
593
*
594
* Items are shown in a flat recency-sorted list (most recently used first)
595
* without source grouping. Own recents come first, followed by VS Code
596
* recent folders.
597
*/
598
protected _buildItems(): IActionListItem<IWorkspacePickerItem>[] {
599
const items: IActionListItem<IWorkspacePickerItem>[] = [];
600
601
// Collect recent workspaces from picker storage across all providers
602
const allProviders = this.sessionsProvidersService.getProviders();
603
const providerIds = new Set(allProviders.map(p => p.id));
604
const tabFilter = this._isTabFiltered()
605
? (w: IWorkspaceSelection) => w.workspace.group === this._activeTab
606
: undefined;
607
const ownRecentWorkspaces = this._getRecentWorkspaces()
608
.filter(w => providerIds.has(w.providerId))
609
.filter(w => !tabFilter || tabFilter({ providerId: w.providerId, workspace: w.workspace }));
610
611
// Merge VS Code recent folders (resolved through providers, deduplicated)
612
const vsCodeRecents = this._getVSCodeRecentWorkspaces()
613
.filter(w => providerIds.has(w.providerId))
614
.filter(w => !tabFilter || tabFilter({ providerId: w.providerId, workspace: w.workspace }));
615
const ownRecentCount = ownRecentWorkspaces.length;
616
const recentWorkspaces = [...ownRecentWorkspaces, ...vsCodeRecents];
617
618
// Build flat list in recency order (no source grouping)
619
for (let i = 0; i < recentWorkspaces.length; i++) {
620
const { workspace, providerId } = recentWorkspaces[i];
621
const isOwnRecent = i < ownRecentCount;
622
const provider = allProviders.find(p => p.id === providerId);
623
const connectionStatus = provider && isAgentHostProvider(provider) ? provider.connectionStatus?.get() : undefined;
624
const isDisconnected = connectionStatus === RemoteAgentHostConnectionStatus.Disconnected;
625
const selection: IWorkspaceSelection = { providerId, workspace };
626
const selected = this._isSelectedWorkspace(selection);
627
items.push({
628
kind: ActionListItemKind.Action,
629
label: workspace.label,
630
description: workspace.description,
631
group: { title: '', icon: workspace.icon },
632
disabled: isDisconnected,
633
item: { selection, checked: selected || undefined },
634
onRemove: isOwnRecent ? () => this._removeRecentWorkspace(selection) : () => this._removeVSCodeRecentWorkspace(selection),
635
});
636
}
637
638
// Browse actions from all providers (filtered to the active tab)
639
const allBrowseActions = this._getAllBrowseActions();
640
// Remote providers with connection status — shown as dynamic rows
641
// in the Manage submenu on the Remote tab.
642
const remoteProviders = allProviders.filter(isAgentHostProvider).filter(p => p.connectionStatus !== undefined);
643
const includeRemoteProviders = this._activeTab === SESSION_WORKSPACE_GROUP_REMOTE;
644
645
if (items.length > 0 && (allBrowseActions.length > 0)) {
646
items.push({ kind: ActionListItemKind.Separator, label: '' });
647
}
648
649
// Render each browse action individually. Within a tab, actions are
650
// already constrained to a single category, so cross-provider
651
// merging is no longer meaningful.
652
allBrowseActions.forEach((action, index) => {
653
const provider = allProviders.find(p => p.id === action.providerId);
654
const connectionStatus = provider && isAgentHostProvider(provider) ? provider.connectionStatus?.get() : undefined;
655
const isUnavailable = connectionStatus === RemoteAgentHostConnectionStatus.Disconnected || connectionStatus === RemoteAgentHostConnectionStatus.Connecting;
656
items.push({
657
kind: ActionListItemKind.Action,
658
label: localize('workspacePicker.browseSelectAction', "Select..."),
659
description: action.description,
660
group: { title: '', icon: action.icon },
661
disabled: isUnavailable,
662
item: { browseActionIndex: index },
663
});
664
});
665
666
// Inline "Manage" entries: dynamic remote provider rows (scoped to
667
// the Remote tab) + menu-contributed actions (filtered by the
668
// `sessionWorkspacePickerGroup` context key).
669
const manageActions: IAction[] = [];
670
if (includeRemoteProviders) {
671
for (const provider of remoteProviders) {
672
const status = provider.connectionStatus!.get();
673
const isTunnel = provider.remoteAddress?.startsWith(TUNNEL_ADDRESS_PREFIX);
674
const action = toAction({
675
id: `workspacePicker.remote.${provider.id}`,
676
label: provider.label,
677
tooltip: getStatusLabel(status),
678
enabled: true,
679
run: () => {
680
this._hidePicker();
681
this._showRemoteHostOptionsDelayed(provider);
682
},
683
});
684
const extended = action as IAction & { icon?: ThemeIcon; hoverContent?: string; onRemove?: () => void };
685
extended.icon = isTunnel ? Codicon.cloud : Codicon.remote;
686
extended.hoverContent = getStatusHover(status, provider.remoteAddress);
687
if (!isTunnel && provider.remoteAddress) {
688
const address = provider.remoteAddress;
689
extended.onRemove = async () => {
690
await this.remoteAgentHostService.removeRemoteAgentHost(address);
691
};
692
}
693
manageActions.push(action);
694
}
695
}
696
697
const menuActions = this.menuService.getMenuActions(Menus.SessionWorkspaceManage, this.contextKeyService, { renderShortTitle: true });
698
for (const [, actions] of menuActions) {
699
for (const menuAction of actions) {
700
if (menuAction instanceof MenuItemAction) {
701
const icon = ThemeIcon.isThemeIcon(menuAction.item.icon) ? menuAction.item.icon : undefined;
702
manageActions.push(Object.assign(menuAction, { icon }));
703
}
704
}
705
}
706
707
if (manageActions.length > 0) {
708
if (items.length > 0 && items[items.length - 1].kind !== ActionListItemKind.Separator) {
709
items.push({ kind: ActionListItemKind.Separator, label: '' });
710
}
711
for (const action of manageActions) {
712
const icon = (action as IAction & { icon?: ThemeIcon }).icon;
713
items.push({
714
kind: ActionListItemKind.Action,
715
label: action.label,
716
group: { title: '', icon: icon ?? Codicon.settingsGear },
717
item: { run: () => action.run(), commandId: action.id },
718
});
719
}
720
}
721
722
return items;
723
}
724
725
private _showRemoteHostOptionsDelayed(provider: IAgentHostSessionsProvider): void {
726
// Defer one tick so the action widget fully tears down (focus/DOM cleanup)
727
// before the QuickPick opens and claims focus.
728
const timeout = setTimeout(() => {
729
this.instantiationService.invokeFunction(accessor => showRemoteHostOptions(accessor, provider));
730
}, 1);
731
this._renderDisposables.add({ dispose: () => clearTimeout(timeout) });
732
}
733
734
private _updateTriggerLabel(): void {
735
if (!this._triggerElement) {
736
return;
737
}
738
739
dom.clearNode(this._triggerElement);
740
const workspace = this._selectedWorkspace?.workspace;
741
const label = workspace ? workspace.label : localize('pickWorkspace', "workspace");
742
const icon = workspace ? workspace.icon : Codicon.project;
743
744
this._triggerElement.setAttribute('aria-label', workspace
745
? localize('workspacePicker.selectedAriaLabel', "New session in {0}", label)
746
: localize('workspacePicker.pickAriaLabel', "Start by picking a workspace"));
747
748
dom.append(this._triggerElement, renderIcon(icon));
749
const labelSpan = dom.append(this._triggerElement, dom.$('span.sessions-chat-dropdown-label'));
750
labelSpan.textContent = label;
751
dom.append(this._triggerElement, renderIcon(Codicon.chevronDown)).classList.add('sessions-chat-dropdown-chevron');
752
}
753
754
/**
755
* Returns whether the given provider is a remote that is currently unavailable
756
* (disconnected or still connecting).
757
* Returns false for providers without connection status (e.g. local providers).
758
*/
759
protected _isProviderUnavailable(providerId: string): boolean {
760
const provider = this.sessionsProvidersService.getProvider(providerId);
761
if (!provider || !isAgentHostProvider(provider) || !provider.connectionStatus) {
762
return false;
763
}
764
return provider.connectionStatus.get() !== RemoteAgentHostConnectionStatus.Connected;
765
}
766
767
protected _isSelectedWorkspace(selection: IWorkspaceSelection): boolean {
768
if (!this._selectedWorkspace) {
769
return false;
770
}
771
if (this._selectedWorkspace.providerId !== selection.providerId) {
772
return false;
773
}
774
const selectedUri = this._selectedWorkspace.workspace.repositories[0]?.uri;
775
const candidateUri = selection.workspace.repositories[0]?.uri;
776
return this.uriIdentityService.extUri.isEqual(selectedUri, candidateUri);
777
}
778
779
private _persistSelectedWorkspace(selection: IWorkspaceSelection): void {
780
const uri = selection.workspace.repositories[0]?.uri;
781
if (!uri) {
782
return;
783
}
784
this._addRecentWorkspace(selection.providerId, selection.workspace, true);
785
}
786
787
private _restoreSelectedWorkspace(): IWorkspaceSelection | undefined {
788
// Try the checked entry first
789
const checked = this._restoreCheckedWorkspace();
790
if (checked) {
791
return checked;
792
}
793
794
// Fall back to the first resolvable recent workspace from a connected provider.
795
// Fallbacks (vs. the user's explicit checked pick) require the provider
796
// to be ready: we don't want to silently land on, e.g., a disconnected
797
// remote workspace that the user never picked.
798
try {
799
const providers = this.sessionsProvidersService.getProviders();
800
const providerIds = new Set(providers.map(p => p.id));
801
const storedRecents = this._getStoredRecentWorkspaces();
802
803
for (const stored of storedRecents) {
804
if (!providerIds.has(stored.providerId)) {
805
continue;
806
}
807
if (this._isProviderUnavailable(stored.providerId)) {
808
continue;
809
}
810
const uri = URI.revive(stored.uri);
811
const workspace = this.sessionsProvidersService.getProvider(stored.providerId)?.resolveWorkspace(uri);
812
if (workspace) {
813
return { providerId: stored.providerId, workspace };
814
}
815
}
816
return undefined;
817
} catch {
818
return undefined;
819
}
820
}
821
822
/**
823
* Restore only the checked (previously selected) workspace if its provider
824
* is registered. The provider's connection status is intentionally NOT
825
* checked — we honor the user's explicit pick even if the remote is still
826
* connecting or currently disconnected. The trigger label reflects the
827
* connection state separately (spinner / grayed).
828
*/
829
private _restoreCheckedWorkspace(): IWorkspaceSelection | undefined {
830
try {
831
const providers = this.sessionsProvidersService.getProviders();
832
const providerIds = new Set(providers.map(p => p.id));
833
const storedRecents = this._getStoredRecentWorkspaces();
834
835
for (const stored of storedRecents) {
836
if (!stored.checked || !providerIds.has(stored.providerId)) {
837
continue;
838
}
839
const uri = URI.revive(stored.uri);
840
const workspace = this.sessionsProvidersService.getProvider(stored.providerId)?.resolveWorkspace(uri);
841
if (workspace) {
842
return { providerId: stored.providerId, workspace };
843
}
844
}
845
return undefined;
846
} catch {
847
return undefined;
848
}
849
}
850
851
/**
852
* When restoring a workspace whose provider isn't currently Connected,
853
* watch the connection status. Fires `onDidSelectWorkspace(undefined)`
854
* (which the view pane converts to `unsetNewSession()`) if:
855
* - the status transitions to Disconnected after we start watching, or
856
* - the status is still not Connected after a short grace period.
857
*
858
* The grace period covers a race: provider state can transition synchronously
859
* inside provider registration before our autorun's first read, so we may
860
* never observe an explicit Disconnected transition. The timer ensures we
861
* eventually fall back instead of leaving the picker showing an unreachable
862
* remote with no session.
863
*
864
* Has no effect once the user makes an explicit pick (`_userHasPicked`).
865
*/
866
private _watchForConnectionFailure(selection: IWorkspaceSelection): void {
867
const provider = this.sessionsProvidersService.getProvider(selection.providerId);
868
if (!provider || !isAgentHostProvider(provider) || !provider.connectionStatus) {
869
return;
870
}
871
const connStatus = provider.connectionStatus;
872
if (connStatus.get() === RemoteAgentHostConnectionStatus.Connected) {
873
return;
874
}
875
876
const store = new DisposableStore();
877
this._connectionStatusWatch.value = store;
878
879
const fallback = () => {
880
this._connectionStatusWatch.clear();
881
if (!this._userHasPicked && this._isSelectedWorkspace(selection)) {
882
this._selectedWorkspace = undefined;
883
this._updateTriggerLabel();
884
this._onDidChangeSelection.fire();
885
this._onDidSelectWorkspace.fire(undefined);
886
}
887
};
888
889
let isFirstRun = true;
890
store.add(autorun(reader => {
891
const status = connStatus.read(reader);
892
if (status === RemoteAgentHostConnectionStatus.Connected) {
893
this._connectionStatusWatch.clear();
894
} else if (status === RemoteAgentHostConnectionStatus.Disconnected && !isFirstRun) {
895
fallback();
896
}
897
isFirstRun = false;
898
}));
899
900
// Safety net: if the connection hasn't succeeded by the grace period,
901
// fall back. Catches the case where the provider's status flips before
902
// our autorun subscribes (so we never observe a transition).
903
disposableTimeout(() => {
904
if (connStatus.get() !== RemoteAgentHostConnectionStatus.Connected) {
905
fallback();
906
}
907
}, RESTORE_CONNECT_GRACE_MS, store);
908
}
909
910
/**
911
* Migrate legacy `sessions.recentlyPickedProjects` storage to the new
912
* `sessions.recentlyPickedWorkspaces` key, adding `providerId` (defaulting
913
* to Copilot) and ensuring at least one entry is checked.
914
*/
915
private _migrateLegacyStorage(): void {
916
// Already migrated
917
if (this.storageService.get(STORAGE_KEY_RECENT_WORKSPACES, StorageScope.PROFILE)) {
918
return;
919
}
920
921
const raw = this.storageService.get(LEGACY_STORAGE_KEY_RECENT_PROJECTS, StorageScope.PROFILE);
922
if (!raw) {
923
return;
924
}
925
926
try {
927
const parsed = JSON.parse(raw) as { uri: UriComponents; checked?: boolean }[];
928
const hasAnyChecked = parsed.some(e => e.checked);
929
const migrated: IStoredRecentWorkspace[] = parsed.map((entry, index) => ({
930
uri: entry.uri,
931
providerId: COPILOT_PROVIDER_ID,
932
checked: hasAnyChecked ? !!entry.checked : index === 0,
933
}));
934
this.storageService.store(STORAGE_KEY_RECENT_WORKSPACES, JSON.stringify(migrated), StorageScope.PROFILE, StorageTarget.MACHINE);
935
} catch { /* ignore */ }
936
937
this.storageService.remove(LEGACY_STORAGE_KEY_RECENT_PROJECTS, StorageScope.PROFILE);
938
}
939
940
// -- Recent workspaces storage --
941
942
private _addRecentWorkspace(providerId: string, workspace: ISessionWorkspace, checked: boolean): void {
943
const uri = workspace.repositories[0]?.uri;
944
if (!uri) {
945
return;
946
}
947
const recents = this._getStoredRecentWorkspaces();
948
const filtered = recents.map(p => {
949
// Remove the entry being re-added (it will go to the front)
950
if (p.providerId === providerId && this.uriIdentityService.extUri.isEqual(URI.revive(p.uri), uri)) {
951
return undefined;
952
}
953
// Clear checked from all other entries when marking checked
954
if (checked && p.checked) {
955
return { ...p, checked: false };
956
}
957
return p;
958
}).filter((p): p is IStoredRecentWorkspace => p !== undefined);
959
960
const entry: IStoredRecentWorkspace = { uri: uri.toJSON(), providerId, checked };
961
const updated = [entry, ...filtered].slice(0, MAX_RECENT_WORKSPACES);
962
this.storageService.store(STORAGE_KEY_RECENT_WORKSPACES, JSON.stringify(updated), StorageScope.PROFILE, StorageTarget.MACHINE);
963
}
964
965
protected _getRecentWorkspaces(): { providerId: string; workspace: ISessionWorkspace }[] {
966
return this._getStoredRecentWorkspaces()
967
.map(stored => {
968
const uri = URI.revive(stored.uri);
969
const workspace = this.sessionsProvidersService.getProvider(stored.providerId)?.resolveWorkspace(uri);
970
if (!workspace) {
971
return undefined;
972
}
973
return { providerId: stored.providerId, workspace };
974
})
975
.filter((w): w is { providerId: string; workspace: ISessionWorkspace } => w !== undefined);
976
}
977
978
protected _removeRecentWorkspace(selection: IWorkspaceSelection): void {
979
const uri = selection.workspace.repositories[0]?.uri;
980
if (!uri) {
981
return;
982
}
983
const recents = this._getStoredRecentWorkspaces();
984
const updated = recents.filter(p =>
985
!(p.providerId === selection.providerId && this.uriIdentityService.extUri.isEqual(URI.revive(p.uri), uri))
986
);
987
this.storageService.store(STORAGE_KEY_RECENT_WORKSPACES, JSON.stringify(updated), StorageScope.PROFILE, StorageTarget.MACHINE);
988
989
// Clear current selection if it was the removed workspace
990
if (this._isSelectedWorkspace(selection)) {
991
this._hidePicker();
992
this._selectedWorkspace = undefined;
993
this._updateTriggerLabel();
994
this._onDidSelectWorkspace.fire(undefined);
995
}
996
}
997
998
protected _removeVSCodeRecentWorkspace(selection: IWorkspaceSelection): void {
999
const uri = selection.workspace.repositories[0]?.uri;
1000
if (!uri) {
1001
return;
1002
}
1003
this.workspacesService.removeRecentlyOpened([uri]);
1004
1005
// Clear current selection if it was the removed workspace
1006
if (this._isSelectedWorkspace(selection)) {
1007
this._hidePicker();
1008
this._selectedWorkspace = undefined;
1009
this._updateTriggerLabel();
1010
this._onDidSelectWorkspace.fire(undefined);
1011
}
1012
}
1013
1014
private _getStoredRecentWorkspaces(): IStoredRecentWorkspace[] {
1015
const raw = this.storageService.get(STORAGE_KEY_RECENT_WORKSPACES, StorageScope.PROFILE);
1016
if (!raw) {
1017
return [];
1018
}
1019
try {
1020
return JSON.parse(raw) as IStoredRecentWorkspace[];
1021
} catch {
1022
return [];
1023
}
1024
}
1025
1026
// -- VS Code recent folders -----------------------------------------------
1027
1028
private async _loadVSCodeRecentFolders(): Promise<void> {
1029
const recentlyOpened = await this.workspacesService.getRecentlyOpened();
1030
this._vsCodeRecentFolderUris = recentlyOpened.workspaces
1031
.filter(isRecentFolder)
1032
.map(f => f.folderUri)
1033
.filter(uri => !this._isCopilotWorktree(uri))
1034
.slice(0, 10);
1035
}
1036
1037
/**
1038
* Returns whether the given URI points to a copilot-managed folder
1039
* (a folder whose name starts with `copilot-`).
1040
*/
1041
private _isCopilotWorktree(uri: URI): boolean {
1042
return basename(uri).startsWith('copilot-');
1043
}
1044
1045
/**
1046
* Returns VS Code recent folders resolved through registered session
1047
* providers, excluding any URIs already present in the sessions' own
1048
* recent workspace history.
1049
*/
1050
protected _getVSCodeRecentWorkspaces(): { providerId: string; workspace: ISessionWorkspace }[] {
1051
if (this._vsCodeRecentFolderUris.length === 0) {
1052
return [];
1053
}
1054
1055
// Collect URIs already in sessions history to avoid duplicates
1056
const ownRecents = this._getStoredRecentWorkspaces();
1057
const ownUris = new Set(ownRecents.map(r => URI.revive(r.uri).toString()));
1058
1059
const providers = this.sessionsProvidersService.getProviders();
1060
const result: { providerId: string; workspace: ISessionWorkspace }[] = [];
1061
1062
for (const folderUri of this._vsCodeRecentFolderUris) {
1063
if (ownUris.has(folderUri.toString())) {
1064
continue;
1065
}
1066
for (const provider of providers) {
1067
if (this._isProviderUnavailable(provider.id)) {
1068
continue;
1069
}
1070
const workspace = provider.resolveWorkspace(folderUri);
1071
if (workspace) {
1072
result.push({ providerId: provider.id, workspace });
1073
}
1074
}
1075
if (result.length >= 10) {
1076
break;
1077
}
1078
}
1079
1080
return result;
1081
}
1082
1083
}
1084
1085