Book a Demo!
CoCalc Logo Icon
StoreFeaturesDocsShareSupportNewsAboutPoliciesSign UpSign In
microsoft
GitHub Repository: microsoft/vscode
Path: blob/main/src/vs/sessions/contrib/chat/test/browser/sessionWorkspacePicker.test.ts
13405 views
1
/*---------------------------------------------------------------------------------------------
2
* Copyright (c) Microsoft Corporation. All rights reserved.
3
* Licensed under the MIT License. See License.txt in the project root for license information.
4
*--------------------------------------------------------------------------------------------*/
5
6
import assert from 'assert';
7
import { timeout } from '../../../../../base/common/async.js';
8
import { Codicon } from '../../../../../base/common/codicons.js';
9
import { Emitter, Event } from '../../../../../base/common/event.js';
10
import { Disposable, DisposableStore } from '../../../../../base/common/lifecycle.js';
11
import { ISettableObservable, observableValue } from '../../../../../base/common/observable.js';
12
import { URI } from '../../../../../base/common/uri.js';
13
import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../base/test/common/utils.js';
14
import { runWithFakedTimers } from '../../../../../base/test/common/timeTravelScheduler.js';
15
import { IActionWidgetService } from '../../../../../platform/actionWidget/browser/actionWidget.js';
16
import { RemoteAgentHostConnectionStatus, IRemoteAgentHostService } from '../../../../../platform/agentHost/common/remoteAgentHostService.js';
17
import { IClipboardService } from '../../../../../platform/clipboard/common/clipboardService.js';
18
import { TestInstantiationService } from '../../../../../platform/instantiation/test/common/instantiationServiceMock.js';
19
import { IQuickInputService } from '../../../../../platform/quickinput/common/quickInput.js';
20
import { IStorageService, StorageScope, StorageTarget } from '../../../../../platform/storage/common/storage.js';
21
import { TestStorageService } from '../../../../../workbench/test/common/workbenchTestServices.js';
22
import { IPreferencesService } from '../../../../../workbench/services/preferences/common/preferences.js';
23
import { IOutputService } from '../../../../../workbench/services/output/common/output.js';
24
import { IUriIdentityService } from '../../../../../platform/uriIdentity/common/uriIdentity.js';
25
import { extUri } from '../../../../../base/common/resources.js';
26
import { ISessionsProvidersChangeEvent, ISessionsProvidersService } from '../../../../services/sessions/browser/sessionsProvidersService.js';
27
import { ISessionsProvider } from '../../../../services/sessions/common/sessionsProvider.js';
28
import { IAgentHostSessionsProvider } from '../../../../common/agentHostSessionsProvider.js';
29
import { ISessionWorkspace, ISessionWorkspaceBrowseAction, SESSION_WORKSPACE_GROUP_LOCAL, SESSION_WORKSPACE_GROUP_REMOTE } from '../../../../services/sessions/common/session.js';
30
import { WorkspacePicker, IWorkspaceSelection } from '../../browser/sessionWorkspacePicker.js';
31
import { IWorkspacesService } from '../../../../../platform/workspaces/common/workspaces.js';
32
import { IConfigurationService } from '../../../../../platform/configuration/common/configuration.js';
33
import { ICommandService } from '../../../../../platform/commands/common/commands.js';
34
import { IContextViewService } from '../../../../../platform/contextview/browser/contextView.js';
35
import { IFileDialogService } from '../../../../../platform/dialogs/common/dialogs.js';
36
import { IContextKeyService } from '../../../../../platform/contextkey/common/contextkey.js';
37
import { MockContextKeyService } from '../../../../../platform/keybinding/test/common/mockKeybindingService.js';
38
import { IMenuService } from '../../../../../platform/actions/common/actions.js';
39
40
// ---- Storage key (must match the one in sessionWorkspacePicker.ts) ----------
41
const STORAGE_KEY_RECENT_WORKSPACES = 'sessions.recentlyPickedWorkspaces';
42
43
// ---- Mock providers ---------------------------------------------------------
44
45
function createMockProvider(id: string, opts?: {
46
connectionStatus?: ISettableObservable<RemoteAgentHostConnectionStatus>;
47
browseActions?: readonly ISessionWorkspaceBrowseAction[];
48
}): ISessionsProvider {
49
const base = {
50
id,
51
label: `Provider ${id}`,
52
icon: Codicon.remote,
53
sessionTypes: [],
54
onDidChangeSessionTypes: Event.None,
55
browseActions: opts?.browseActions ?? [],
56
resolveWorkspace: (uri: URI): ISessionWorkspace => ({
57
label: uri.path.substring(1) || uri.path,
58
icon: Codicon.folder,
59
repositories: [{ uri, workingDirectory: undefined, detail: undefined, baseBranchName: undefined }],
60
requiresWorkspaceTrust: false,
61
}),
62
onDidChangeSessions: Event.None,
63
getSessions: () => [],
64
createNewSession: () => { throw new Error('Not implemented'); },
65
getSessionTypes: () => [],
66
renameChat: async () => { },
67
setModel: () => { },
68
archiveSession: async () => { },
69
unarchiveSession: async () => { },
70
deleteSession: async () => { },
71
deleteChat: async () => { },
72
sendAndCreateChat: async () => { throw new Error('Not implemented'); },
73
addChat: () => { throw new Error('Not implemented'); },
74
sendRequest: async () => { throw new Error('Not implemented'); },
75
};
76
if (opts?.connectionStatus) {
77
return {
78
...base,
79
connectionStatus: opts.connectionStatus,
80
onDidChangeSessionConfig: Event.None,
81
getSessionConfig: () => undefined,
82
setSessionConfigValue: async () => { },
83
replaceSessionConfig: async () => { },
84
getSessionConfigCompletions: async () => [],
85
getCreateSessionConfig: () => undefined,
86
clearSessionConfig: () => { },
87
onDidChangeRootConfig: Event.None,
88
getRootConfig: () => undefined,
89
setRootConfigValue: async () => { },
90
replaceRootConfig: async () => { },
91
} as unknown as IAgentHostSessionsProvider;
92
}
93
return base;
94
}
95
96
class MockSessionsProvidersService extends Disposable {
97
declare readonly _serviceBrand: undefined;
98
99
private readonly _onDidChangeProviders = this._register(new Emitter<ISessionsProvidersChangeEvent>());
100
readonly onDidChangeProviders: Event<ISessionsProvidersChangeEvent> = this._onDidChangeProviders.event;
101
102
private _providers: ISessionsProvider[] = [];
103
104
setProviders(providers: ISessionsProvider[]): void {
105
const oldProviders = this._providers;
106
this._providers = providers;
107
const oldIds = new Set(oldProviders.map(p => p.id));
108
const newIds = new Set(providers.map(p => p.id));
109
this._onDidChangeProviders.fire({
110
added: providers.filter(p => !oldIds.has(p.id)),
111
removed: oldProviders.filter(p => !newIds.has(p.id)),
112
});
113
}
114
115
getProviders(): ISessionsProvider[] {
116
return this._providers;
117
}
118
119
getProvider<T extends ISessionsProvider>(providerId: string): T | undefined {
120
return this._providers.find(p => p.id === providerId) as T | undefined;
121
}
122
}
123
124
// ---- Test helpers -----------------------------------------------------------
125
126
function seedStorage(storageService: IStorageService, entries: { uri: URI; providerId: string; checked: boolean }[]): void {
127
const stored = entries.map(e => ({
128
uri: e.uri.toJSON(),
129
providerId: e.providerId,
130
checked: e.checked,
131
}));
132
storageService.store(STORAGE_KEY_RECENT_WORKSPACES, JSON.stringify(stored), StorageScope.PROFILE, StorageTarget.MACHINE);
133
}
134
135
function createTestPicker(
136
disposables: DisposableStore,
137
providersService: MockSessionsProvidersService,
138
storageService?: IStorageService,
139
): WorkspacePicker {
140
const instantiationService = disposables.add(new TestInstantiationService());
141
const storage = storageService ?? disposables.add(new TestStorageService());
142
143
instantiationService.stub(IActionWidgetService, { isVisible: false, hide: () => { }, show: () => { } });
144
instantiationService.stub(IContextViewService, { showContextView: () => ({ close: () => { } }), hideContextView: () => { }, layout: () => { } });
145
instantiationService.stub(IStorageService, storage);
146
instantiationService.stub(IUriIdentityService, { extUri });
147
instantiationService.stub(ISessionsProvidersService, providersService);
148
instantiationService.stub(IRemoteAgentHostService, {});
149
instantiationService.stub(IQuickInputService, {});
150
instantiationService.stub(IClipboardService, {});
151
instantiationService.stub(IPreferencesService, {});
152
instantiationService.stub(IOutputService, {});
153
instantiationService.stub(IConfigurationService, { getValue: () => undefined });
154
instantiationService.stub(ICommandService, { executeCommand: async () => { } });
155
instantiationService.stub(IFileDialogService, {});
156
instantiationService.stub(IContextKeyService, new MockContextKeyService());
157
instantiationService.stub(IMenuService, { createMenu: () => ({ onDidChange: Event.None, getActions: () => [], dispose: () => { } }) });
158
instantiationService.stub(IWorkspacesService, {
159
getRecentlyOpened: async () => ({ workspaces: [], files: [] }),
160
onDidChangeRecentlyOpened: Event.None,
161
});
162
163
return disposables.add(instantiationService.createInstance(WorkspacePicker));
164
}
165
166
// ---- Assertion helpers ------------------------------------------------------
167
168
function assertSelectedProvider(picker: WorkspacePicker, expectedProviderId: string | undefined, message?: string): void {
169
assert.strictEqual(picker.selectedProject?.providerId, expectedProviderId, message);
170
}
171
172
// ---- Tests ------------------------------------------------------------------
173
174
suite('WorkspacePicker - Connection Status', () => {
175
176
const disposables = new DisposableStore();
177
let providersService: MockSessionsProvidersService;
178
179
setup(() => {
180
providersService = new MockSessionsProvidersService();
181
disposables.add(providersService);
182
});
183
184
teardown(() => {
185
disposables.clear();
186
});
187
188
ensureNoDisposablesAreLeakedInTestSuite();
189
190
test('restore picks checked entry even when remote is disconnected (before grace period)', () => {
191
// Restore is honored synchronously: the picker shows the checked entry
192
// while we wait to see if the connection comes up. The grace-period
193
// fallback (covered in a separate test) only fires later.
194
const remoteStatus = observableValue<RemoteAgentHostConnectionStatus>('status', RemoteAgentHostConnectionStatus.Disconnected);
195
const remoteProvider = createMockProvider('agenthost-remote-1', { connectionStatus: remoteStatus });
196
const localProvider = createMockProvider('local-1');
197
198
const storage = disposables.add(new TestStorageService());
199
seedStorage(storage, [
200
{ uri: URI.file('/remote/project'), providerId: 'agenthost-remote-1', checked: true },
201
{ uri: URI.file('/local/project'), providerId: 'local-1', checked: false },
202
]);
203
204
providersService.setProviders([remoteProvider, localProvider]);
205
const picker = createTestPicker(disposables, providersService, storage);
206
207
assertSelectedProvider(picker, 'agenthost-remote-1');
208
});
209
210
test('restored remote that never connects falls back after grace period', () => runWithFakedTimers<void>({ useFakeTimers: true }, async () => {
211
// The provider is registered as Disconnected and never transitions —
212
// e.g. SSH host is unreachable and the status was set before the picker
213
// could subscribe. The picker should fall back to no selection after
214
// the grace period so the view pane drops the stale session.
215
const remoteStatus = observableValue<RemoteAgentHostConnectionStatus>('status', RemoteAgentHostConnectionStatus.Disconnected);
216
const remoteProvider = createMockProvider('agenthost-remote-1', { connectionStatus: remoteStatus });
217
218
const storage = disposables.add(new TestStorageService());
219
seedStorage(storage, [
220
{ uri: URI.file('/remote/project'), providerId: 'agenthost-remote-1', checked: true },
221
]);
222
223
providersService.setProviders([remoteProvider]);
224
const picker = createTestPicker(disposables, providersService, storage);
225
226
assertSelectedProvider(picker, 'agenthost-remote-1', 'Selection is restored synchronously');
227
228
const events: Array<IWorkspaceSelection | undefined> = [];
229
disposables.add(picker.onDidSelectWorkspace(e => events.push(e)));
230
231
// Advance past the grace period.
232
await timeout(10_000);
233
234
assertSelectedProvider(picker, undefined, 'Selection cleared after grace period');
235
assert.deepStrictEqual(events, [undefined], 'onDidSelectWorkspace fired with undefined');
236
}));
237
238
test('restored remote that connects within grace period keeps selection', () => runWithFakedTimers<void>({ useFakeTimers: true }, async () => {
239
const remoteStatus = observableValue<RemoteAgentHostConnectionStatus>('status', RemoteAgentHostConnectionStatus.Disconnected);
240
const remoteProvider = createMockProvider('agenthost-remote-1', { connectionStatus: remoteStatus });
241
242
const storage = disposables.add(new TestStorageService());
243
seedStorage(storage, [
244
{ uri: URI.file('/remote/project'), providerId: 'agenthost-remote-1', checked: true },
245
]);
246
247
providersService.setProviders([remoteProvider]);
248
const picker = createTestPicker(disposables, providersService, storage);
249
250
// Connection succeeds quickly.
251
await timeout(100);
252
remoteStatus.set(RemoteAgentHostConnectionStatus.Connecting, undefined);
253
await timeout(500);
254
remoteStatus.set(RemoteAgentHostConnectionStatus.Connected, undefined);
255
256
// Advance past the grace period — should not fall back since we connected.
257
await timeout(10_000);
258
259
assertSelectedProvider(picker, 'agenthost-remote-1', 'Selection preserved after successful connect');
260
}));
261
262
test('user pick during connect cancels the fallback', () => runWithFakedTimers<void>({ useFakeTimers: true }, async () => {
263
// If the user picks a different workspace while the restore-grace-period
264
// timer is running, the timer must not later clear the user's selection.
265
const remoteStatus = observableValue<RemoteAgentHostConnectionStatus>('status', RemoteAgentHostConnectionStatus.Disconnected);
266
const remoteProvider = createMockProvider('agenthost-remote-1', { connectionStatus: remoteStatus });
267
const localProvider = createMockProvider('local-1');
268
269
const storage = disposables.add(new TestStorageService());
270
seedStorage(storage, [
271
{ uri: URI.file('/remote/project'), providerId: 'agenthost-remote-1', checked: true },
272
]);
273
274
providersService.setProviders([remoteProvider, localProvider]);
275
const picker = createTestPicker(disposables, providersService, storage);
276
277
// User picks a local workspace while the remote is still trying to connect.
278
const localPick: IWorkspaceSelection = {
279
providerId: 'local-1',
280
workspace: localProvider.resolveWorkspace(URI.file('/local/picked'))!,
281
};
282
picker.setSelectedWorkspace(localPick, false);
283
284
// Grace period elapses; remote still disconnected — must not affect user pick.
285
await timeout(10_000);
286
287
assertSelectedProvider(picker, 'local-1', 'User pick preserved across grace-period elapse');
288
}));
289
290
test('restore picks checked entry while remote is connecting (no fallback flicker)', () => {
291
// SSH remote: provider registers in Disconnected state and immediately
292
// starts connecting. We restore the checked entry immediately rather than
293
// falling back to a different workspace and swapping later.
294
const remoteStatus = observableValue<RemoteAgentHostConnectionStatus>('status', RemoteAgentHostConnectionStatus.Disconnected);
295
const remoteProvider = createMockProvider('agenthost-remote-1', { connectionStatus: remoteStatus });
296
const localProvider = createMockProvider('local-1');
297
298
const storage = disposables.add(new TestStorageService());
299
seedStorage(storage, [
300
{ uri: URI.file('/remote/project'), providerId: 'agenthost-remote-1', checked: true },
301
{ uri: URI.file('/local/project'), providerId: 'local-1', checked: false },
302
]);
303
304
providersService.setProviders([remoteProvider, localProvider]);
305
const picker = createTestPicker(disposables, providersService, storage);
306
307
assertSelectedProvider(picker, 'agenthost-remote-1');
308
309
// Connection attempt starts (no fallback while connecting).
310
remoteStatus.set(RemoteAgentHostConnectionStatus.Connecting, undefined);
311
assertSelectedProvider(picker, 'agenthost-remote-1');
312
313
// After connection completes, selection is unchanged.
314
remoteStatus.set(RemoteAgentHostConnectionStatus.Connected, undefined);
315
assertSelectedProvider(picker, 'agenthost-remote-1');
316
});
317
318
test('connecting provider that fails falls back to no selection', () => {
319
// Real SSH remote lifecycle: starts Disconnected, transitions Connecting,
320
// then fails back to Disconnected. The picker must clear the selection
321
// and fire onDidSelectWorkspace(undefined) so the view pane calls unsetNewSession().
322
const remoteStatus = observableValue<RemoteAgentHostConnectionStatus>('status', RemoteAgentHostConnectionStatus.Disconnected);
323
const remoteProvider = createMockProvider('agenthost-remote-1', { connectionStatus: remoteStatus });
324
325
const storage = disposables.add(new TestStorageService());
326
seedStorage(storage, [
327
{ uri: URI.file('/remote/project'), providerId: 'agenthost-remote-1', checked: true },
328
]);
329
330
providersService.setProviders([remoteProvider]);
331
const picker = createTestPicker(disposables, providersService, storage);
332
333
assertSelectedProvider(picker, 'agenthost-remote-1', 'Selection is restored while connecting');
334
335
const events: Array<IWorkspaceSelection | undefined> = [];
336
disposables.add(picker.onDidSelectWorkspace(e => events.push(e)));
337
338
// SSH tunnel begins.
339
remoteStatus.set(RemoteAgentHostConnectionStatus.Connecting, undefined);
340
assertSelectedProvider(picker, 'agenthost-remote-1', 'Selection preserved while connecting');
341
342
// SSH tunnel fails.
343
remoteStatus.set(RemoteAgentHostConnectionStatus.Disconnected, undefined);
344
345
assertSelectedProvider(picker, undefined, 'Selection cleared after connection failure');
346
assert.deepStrictEqual(events, [undefined], 'onDidSelectWorkspace fired with undefined');
347
});
348
349
test('restore picks connected remote provider', () => {
350
const remoteStatus = observableValue<RemoteAgentHostConnectionStatus>('status', RemoteAgentHostConnectionStatus.Connected);
351
const remoteProvider = createMockProvider('agenthost-remote-1', { connectionStatus: remoteStatus });
352
353
const storage = disposables.add(new TestStorageService());
354
seedStorage(storage, [
355
{ uri: URI.file('/remote/project'), providerId: 'agenthost-remote-1', checked: true },
356
]);
357
358
providersService.setProviders([remoteProvider]);
359
const picker = createTestPicker(disposables, providersService, storage);
360
361
assertSelectedProvider(picker, 'agenthost-remote-1');
362
});
363
364
test('disconnect preserves selection (renders grayed; no auto-clear)', () => {
365
const remoteStatus = observableValue<RemoteAgentHostConnectionStatus>('status', RemoteAgentHostConnectionStatus.Connected);
366
const remoteProvider = createMockProvider('agenthost-remote-1', { connectionStatus: remoteStatus });
367
368
const storage = disposables.add(new TestStorageService());
369
seedStorage(storage, [
370
{ uri: URI.file('/remote/project'), providerId: 'agenthost-remote-1', checked: true },
371
]);
372
373
providersService.setProviders([remoteProvider]);
374
const picker = createTestPicker(disposables, providersService, storage);
375
assertSelectedProvider(picker, 'agenthost-remote-1');
376
377
// Disconnect — selection is preserved (the user picked it; we keep honoring it).
378
remoteStatus.set(RemoteAgentHostConnectionStatus.Disconnected, undefined);
379
assertSelectedProvider(picker, 'agenthost-remote-1', 'Selection should be preserved on disconnect');
380
});
381
382
test('reconnect keeps the selection (no extra event fires)', () => {
383
const remoteStatus = observableValue<RemoteAgentHostConnectionStatus>('status', RemoteAgentHostConnectionStatus.Connected);
384
const remoteProvider = createMockProvider('agenthost-remote-1', { connectionStatus: remoteStatus });
385
386
const storage = disposables.add(new TestStorageService());
387
seedStorage(storage, [
388
{ uri: URI.file('/remote/project'), providerId: 'agenthost-remote-1', checked: true },
389
]);
390
391
providersService.setProviders([remoteProvider]);
392
const picker = createTestPicker(disposables, providersService, storage);
393
assertSelectedProvider(picker, 'agenthost-remote-1');
394
395
// Disconnect / reconnect cycle — selection preserved throughout.
396
remoteStatus.set(RemoteAgentHostConnectionStatus.Disconnected, undefined);
397
remoteStatus.set(RemoteAgentHostConnectionStatus.Connected, undefined);
398
assertSelectedProvider(picker, 'agenthost-remote-1');
399
assert.strictEqual(
400
picker.selectedProject?.workspace.repositories[0]?.uri.path,
401
'/remote/project',
402
);
403
});
404
405
test('checked is globally unique after persist', () => {
406
const localProvider = createMockProvider('local-1');
407
const remoteStatus = observableValue<RemoteAgentHostConnectionStatus>('status', RemoteAgentHostConnectionStatus.Connected);
408
const remoteProvider = createMockProvider('agenthost-remote-1', { connectionStatus: remoteStatus });
409
410
const storage = disposables.add(new TestStorageService());
411
seedStorage(storage, [
412
{ uri: URI.file('/remote/project'), providerId: 'agenthost-remote-1', checked: true },
413
{ uri: URI.file('/local/project'), providerId: 'local-1', checked: false },
414
]);
415
416
providersService.setProviders([remoteProvider, localProvider]);
417
const picker = createTestPicker(disposables, providersService, storage);
418
419
// Select the local workspace
420
const resolvedWorkspace = localProvider.resolveWorkspace(URI.file('/local/project'));
421
assert.ok(resolvedWorkspace, 'resolveWorkspace should resolve file:// URIs');
422
const localWorkspace: IWorkspaceSelection = {
423
providerId: 'local-1',
424
workspace: resolvedWorkspace,
425
};
426
picker.setSelectedWorkspace(localWorkspace, false);
427
428
// Verify storage: only the local entry should be checked
429
const raw = storage.get(STORAGE_KEY_RECENT_WORKSPACES, StorageScope.PROFILE);
430
assert.ok(raw, 'Storage should have recent workspaces');
431
const stored = JSON.parse(raw!) as { providerId: string; checked: boolean }[];
432
const checkedEntries = stored.filter(e => e.checked);
433
assert.strictEqual(checkedEntries.length, 1, 'Only one entry should be checked');
434
assert.strictEqual(checkedEntries[0].providerId, 'local-1', 'The local entry should be checked');
435
});
436
437
test('local provider is never treated as unavailable', () => {
438
const localProvider = createMockProvider('local-1');
439
440
const storage = disposables.add(new TestStorageService());
441
seedStorage(storage, [
442
{ uri: URI.file('/local/project'), providerId: 'local-1', checked: true },
443
]);
444
445
providersService.setProviders([localProvider]);
446
const picker = createTestPicker(disposables, providersService, storage);
447
448
assertSelectedProvider(picker, 'local-1', 'Local provider workspace should always be selectable');
449
});
450
451
test('restore picks the stored workspace when its provider registers after another provider', () => {
452
// Regression: previously the picker filtered restore through `activeProviderId`,
453
// which auto-locked to whichever provider registered first. If the stored
454
// workspace belonged to a provider that registered later than another available
455
// provider (for example, local-agent-host registering after default-copilot),
456
// the stored entry was filtered out and never restored.
457
//
458
// Realistic shape: storage holds BOTH a (non-checked) recent for the
459
// early-registering provider and a (checked) recent for the late-registering
460
// provider. The picker may briefly show the early recent as a fallback, but
461
// once the checked entry's provider registers, the picker must upgrade to it.
462
const copilotProvider = createMockProvider('default-copilot');
463
464
const storage = disposables.add(new TestStorageService());
465
seedStorage(storage, [
466
{ uri: URI.file('/copilot/old-project'), providerId: 'default-copilot', checked: false },
467
{ uri: URI.file('/agent-host/project'), providerId: 'local-agent-host', checked: true },
468
]);
469
470
// Construct picker with only the early-registering provider available.
471
providersService.setProviders([copilotProvider]);
472
const picker = createTestPicker(disposables, providersService, storage);
473
474
// The fallback may be selected initially (early provider's recent),
475
// since the user's checked entry's provider isn't ready yet.
476
// Now the late provider arrives.
477
const agentHostProvider = createMockProvider('local-agent-host');
478
providersService.setProviders([copilotProvider, agentHostProvider]);
479
480
assertSelectedProvider(picker, 'local-agent-host', 'Stored workspace should be restored once its provider registers');
481
});
482
483
test('late-registering provider does not move selection out from under user', () => {
484
// After the user has explicitly picked a workspace, a provider
485
// registering later in the session must not switch the selection to its
486
// stored "checked" entry. We only do that auto-upgrade during initial
487
// startup before the user has acted.
488
const copilotProvider = createMockProvider('default-copilot');
489
490
const storage = disposables.add(new TestStorageService());
491
seedStorage(storage, [
492
{ uri: URI.file('/agent-host/project'), providerId: 'local-agent-host', checked: true },
493
]);
494
495
providersService.setProviders([copilotProvider]);
496
const picker = createTestPicker(disposables, providersService, storage);
497
498
// Suppression kicked in: no fallback selection while checked entry is pending.
499
assertSelectedProvider(picker, undefined, 'No fallback while checked entry pending');
500
501
// User explicitly picks a Copilot workspace.
502
const copilotPick: IWorkspaceSelection = {
503
providerId: 'default-copilot',
504
workspace: copilotProvider.resolveWorkspace(URI.file('/copilot/picked'))!,
505
};
506
picker.setSelectedWorkspace(copilotPick, false);
507
assertSelectedProvider(picker, 'default-copilot', 'User pick is honored');
508
509
// Now the late provider for the (still-stored) checked entry arrives.
510
const agentHostProvider = createMockProvider('local-agent-host');
511
providersService.setProviders([copilotProvider, agentHostProvider]);
512
513
assertSelectedProvider(picker, 'default-copilot', 'User selection is preserved across late provider registration');
514
});
515
});
516
517
// ---- Tab discovery ----------------------------------------------------------
518
519
/** Minimal subclass that exposes the protected `_getAvailableTabs` for testing. */
520
class TestablePicker extends WorkspacePicker {
521
getAvailableTabs(): string[] {
522
return this._getAvailableGroups();
523
}
524
}
525
526
function makeBrowseAction(providerId: string, group: string | undefined, label = 'browse'): ISessionWorkspaceBrowseAction {
527
return {
528
label,
529
group,
530
icon: Codicon.folder,
531
providerId,
532
run: async () => undefined,
533
};
534
}
535
536
function createTestablePicker(disposables: DisposableStore, providersService: MockSessionsProvidersService): TestablePicker {
537
const instantiationService = disposables.add(new TestInstantiationService());
538
instantiationService.stub(IActionWidgetService, { isVisible: false, hide: () => { }, show: () => { } });
539
instantiationService.stub(IContextViewService, { showContextView: () => ({ close: () => { } }), hideContextView: () => { }, layout: () => { } });
540
instantiationService.stub(IStorageService, disposables.add(new TestStorageService()));
541
instantiationService.stub(IUriIdentityService, { extUri });
542
instantiationService.stub(ISessionsProvidersService, providersService);
543
instantiationService.stub(IRemoteAgentHostService, {});
544
instantiationService.stub(IQuickInputService, {});
545
instantiationService.stub(IClipboardService, {});
546
instantiationService.stub(IPreferencesService, {});
547
instantiationService.stub(IOutputService, {});
548
instantiationService.stub(IConfigurationService, { getValue: () => undefined });
549
instantiationService.stub(ICommandService, { executeCommand: async () => { } });
550
instantiationService.stub(IFileDialogService, {});
551
instantiationService.stub(IContextKeyService, new MockContextKeyService());
552
instantiationService.stub(IMenuService, { createMenu: () => ({ onDidChange: Event.None, getActions: () => [], dispose: () => { } }) });
553
instantiationService.stub(IWorkspacesService, {
554
getRecentlyOpened: async () => ({ workspaces: [], files: [] }),
555
onDidChangeRecentlyOpened: Event.None,
556
});
557
return disposables.add(instantiationService.createInstance(TestablePicker));
558
}
559
560
suite('WorkspacePicker - Tab discovery', () => {
561
562
const disposables = new DisposableStore();
563
let providersService: MockSessionsProvidersService;
564
565
setup(() => {
566
providersService = new MockSessionsProvidersService();
567
disposables.add(providersService);
568
});
569
570
teardown(() => disposables.clear());
571
572
ensureNoDisposablesAreLeakedInTestSuite();
573
574
test('returns Remote group even when no providers contribute groups', () => {
575
providersService.setProviders([createMockProvider('p1')]);
576
const picker = createTestablePicker(disposables, providersService);
577
assert.deepStrictEqual(picker.getAvailableTabs(), [SESSION_WORKSPACE_GROUP_REMOTE]);
578
});
579
580
test('orders well-known groups Local first, then alphabetical', () => {
581
providersService.setProviders([
582
createMockProvider('remote', { browseActions: [makeBrowseAction('remote', SESSION_WORKSPACE_GROUP_REMOTE)] }),
583
createMockProvider('cloud', { browseActions: [makeBrowseAction('cloud', 'Cloud')] }),
584
createMockProvider('local', { browseActions: [makeBrowseAction('local', SESSION_WORKSPACE_GROUP_LOCAL)] }),
585
]);
586
const picker = createTestablePicker(disposables, providersService);
587
assert.deepStrictEqual(picker.getAvailableTabs(), [SESSION_WORKSPACE_GROUP_LOCAL, 'Cloud', SESSION_WORKSPACE_GROUP_REMOTE]);
588
});
589
590
test('deduplicates groups contributed by multiple providers / actions', () => {
591
providersService.setProviders([
592
createMockProvider('p1', { browseActions: [makeBrowseAction('p1', SESSION_WORKSPACE_GROUP_LOCAL)] }),
593
createMockProvider('p2', { browseActions: [makeBrowseAction('p2', SESSION_WORKSPACE_GROUP_LOCAL), makeBrowseAction('p2', SESSION_WORKSPACE_GROUP_LOCAL)] }),
594
]);
595
const picker = createTestablePicker(disposables, providersService);
596
assert.deepStrictEqual(picker.getAvailableTabs(), [SESSION_WORKSPACE_GROUP_LOCAL, SESSION_WORKSPACE_GROUP_REMOTE]);
597
});
598
599
test('appends custom group labels after Local', () => {
600
providersService.setProviders([
601
createMockProvider('p1', { browseActions: [makeBrowseAction('p1', 'Custom A'), makeBrowseAction('p1', SESSION_WORKSPACE_GROUP_LOCAL)] }),
602
createMockProvider('p2', { browseActions: [makeBrowseAction('p2', 'Custom B'), makeBrowseAction('p2', SESSION_WORKSPACE_GROUP_REMOTE)] }),
603
]);
604
const picker = createTestablePicker(disposables, providersService);
605
const tabs = picker.getAvailableTabs();
606
assert.strictEqual(tabs[0], SESSION_WORKSPACE_GROUP_LOCAL);
607
assert.deepStrictEqual(tabs.slice(1).sort(), ['Custom A', 'Custom B', SESSION_WORKSPACE_GROUP_REMOTE]);
608
});
609
610
test('ignores browse actions without a group', () => {
611
providersService.setProviders([
612
createMockProvider('p1', { browseActions: [makeBrowseAction('p1', undefined), makeBrowseAction('p1', SESSION_WORKSPACE_GROUP_LOCAL)] }),
613
]);
614
const picker = createTestablePicker(disposables, providersService);
615
assert.deepStrictEqual(picker.getAvailableTabs(), [SESSION_WORKSPACE_GROUP_LOCAL, SESSION_WORKSPACE_GROUP_REMOTE]);
616
});
617
618
test('discovers groups from recent workspaces does not add extra tabs', () => {
619
const provider: ISessionsProvider = {
620
...createMockProvider('p1'),
621
resolveWorkspace: (uri: URI): ISessionWorkspace => ({
622
label: uri.path,
623
icon: Codicon.folder,
624
group: 'Cloud',
625
repositories: [{ uri, workingDirectory: undefined, detail: undefined, baseBranchName: undefined }],
626
requiresWorkspaceTrust: false,
627
}),
628
};
629
const storage = disposables.add(new TestStorageService());
630
seedStorage(storage, [{ uri: URI.file('/repo'), providerId: 'p1', checked: false }]);
631
providersService.setProviders([provider]);
632
633
const instantiationService = disposables.add(new TestInstantiationService());
634
instantiationService.stub(IActionWidgetService, { isVisible: false, hide: () => { }, show: () => { } });
635
instantiationService.stub(IContextViewService, { showContextView: () => ({ close: () => { } }), hideContextView: () => { }, layout: () => { } });
636
instantiationService.stub(IStorageService, storage);
637
instantiationService.stub(IUriIdentityService, { extUri });
638
instantiationService.stub(ISessionsProvidersService, providersService);
639
instantiationService.stub(IRemoteAgentHostService, {});
640
instantiationService.stub(IQuickInputService, {});
641
instantiationService.stub(IClipboardService, {});
642
instantiationService.stub(IPreferencesService, {});
643
instantiationService.stub(IOutputService, {});
644
instantiationService.stub(IConfigurationService, { getValue: () => undefined });
645
instantiationService.stub(ICommandService, { executeCommand: async () => { } });
646
instantiationService.stub(IFileDialogService, {});
647
instantiationService.stub(IContextKeyService, new MockContextKeyService());
648
instantiationService.stub(IMenuService, { createMenu: () => ({ onDidChange: Event.None, getActions: () => [], dispose: () => { } }) });
649
instantiationService.stub(IWorkspacesService, {
650
getRecentlyOpened: async () => ({ workspaces: [], files: [] }),
651
onDidChangeRecentlyOpened: Event.None,
652
});
653
const picker = disposables.add(instantiationService.createInstance(TestablePicker));
654
// Recent workspace group ('Cloud') is not added as a tab — only
655
// browse actions and the always-present Remote group contribute tabs.
656
assert.deepStrictEqual(picker.getAvailableTabs(), [SESSION_WORKSPACE_GROUP_REMOTE]);
657
});
658
});
659
660