Path: blob/main/src/vs/sessions/contrib/chat/test/browser/sessionWorkspacePicker.test.ts
13405 views
/*---------------------------------------------------------------------------------------------1* Copyright (c) Microsoft Corporation. All rights reserved.2* Licensed under the MIT License. See License.txt in the project root for license information.3*--------------------------------------------------------------------------------------------*/45import assert from 'assert';6import { timeout } from '../../../../../base/common/async.js';7import { Codicon } from '../../../../../base/common/codicons.js';8import { Emitter, Event } from '../../../../../base/common/event.js';9import { Disposable, DisposableStore } from '../../../../../base/common/lifecycle.js';10import { ISettableObservable, observableValue } from '../../../../../base/common/observable.js';11import { URI } from '../../../../../base/common/uri.js';12import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../base/test/common/utils.js';13import { runWithFakedTimers } from '../../../../../base/test/common/timeTravelScheduler.js';14import { IActionWidgetService } from '../../../../../platform/actionWidget/browser/actionWidget.js';15import { RemoteAgentHostConnectionStatus, IRemoteAgentHostService } from '../../../../../platform/agentHost/common/remoteAgentHostService.js';16import { IClipboardService } from '../../../../../platform/clipboard/common/clipboardService.js';17import { TestInstantiationService } from '../../../../../platform/instantiation/test/common/instantiationServiceMock.js';18import { IQuickInputService } from '../../../../../platform/quickinput/common/quickInput.js';19import { IStorageService, StorageScope, StorageTarget } from '../../../../../platform/storage/common/storage.js';20import { TestStorageService } from '../../../../../workbench/test/common/workbenchTestServices.js';21import { IPreferencesService } from '../../../../../workbench/services/preferences/common/preferences.js';22import { IOutputService } from '../../../../../workbench/services/output/common/output.js';23import { IUriIdentityService } from '../../../../../platform/uriIdentity/common/uriIdentity.js';24import { extUri } from '../../../../../base/common/resources.js';25import { ISessionsProvidersChangeEvent, ISessionsProvidersService } from '../../../../services/sessions/browser/sessionsProvidersService.js';26import { ISessionsProvider } from '../../../../services/sessions/common/sessionsProvider.js';27import { IAgentHostSessionsProvider } from '../../../../common/agentHostSessionsProvider.js';28import { ISessionWorkspace, ISessionWorkspaceBrowseAction, SESSION_WORKSPACE_GROUP_LOCAL, SESSION_WORKSPACE_GROUP_REMOTE } from '../../../../services/sessions/common/session.js';29import { WorkspacePicker, IWorkspaceSelection } from '../../browser/sessionWorkspacePicker.js';30import { IWorkspacesService } from '../../../../../platform/workspaces/common/workspaces.js';31import { IConfigurationService } from '../../../../../platform/configuration/common/configuration.js';32import { ICommandService } from '../../../../../platform/commands/common/commands.js';33import { IContextViewService } from '../../../../../platform/contextview/browser/contextView.js';34import { IFileDialogService } from '../../../../../platform/dialogs/common/dialogs.js';35import { IContextKeyService } from '../../../../../platform/contextkey/common/contextkey.js';36import { MockContextKeyService } from '../../../../../platform/keybinding/test/common/mockKeybindingService.js';37import { IMenuService } from '../../../../../platform/actions/common/actions.js';3839// ---- Storage key (must match the one in sessionWorkspacePicker.ts) ----------40const STORAGE_KEY_RECENT_WORKSPACES = 'sessions.recentlyPickedWorkspaces';4142// ---- Mock providers ---------------------------------------------------------4344function createMockProvider(id: string, opts?: {45connectionStatus?: ISettableObservable<RemoteAgentHostConnectionStatus>;46browseActions?: readonly ISessionWorkspaceBrowseAction[];47}): ISessionsProvider {48const base = {49id,50label: `Provider ${id}`,51icon: Codicon.remote,52sessionTypes: [],53onDidChangeSessionTypes: Event.None,54browseActions: opts?.browseActions ?? [],55resolveWorkspace: (uri: URI): ISessionWorkspace => ({56label: uri.path.substring(1) || uri.path,57icon: Codicon.folder,58repositories: [{ uri, workingDirectory: undefined, detail: undefined, baseBranchName: undefined }],59requiresWorkspaceTrust: false,60}),61onDidChangeSessions: Event.None,62getSessions: () => [],63createNewSession: () => { throw new Error('Not implemented'); },64getSessionTypes: () => [],65renameChat: async () => { },66setModel: () => { },67archiveSession: async () => { },68unarchiveSession: async () => { },69deleteSession: async () => { },70deleteChat: async () => { },71sendAndCreateChat: async () => { throw new Error('Not implemented'); },72addChat: () => { throw new Error('Not implemented'); },73sendRequest: async () => { throw new Error('Not implemented'); },74};75if (opts?.connectionStatus) {76return {77...base,78connectionStatus: opts.connectionStatus,79onDidChangeSessionConfig: Event.None,80getSessionConfig: () => undefined,81setSessionConfigValue: async () => { },82replaceSessionConfig: async () => { },83getSessionConfigCompletions: async () => [],84getCreateSessionConfig: () => undefined,85clearSessionConfig: () => { },86onDidChangeRootConfig: Event.None,87getRootConfig: () => undefined,88setRootConfigValue: async () => { },89replaceRootConfig: async () => { },90} as unknown as IAgentHostSessionsProvider;91}92return base;93}9495class MockSessionsProvidersService extends Disposable {96declare readonly _serviceBrand: undefined;9798private readonly _onDidChangeProviders = this._register(new Emitter<ISessionsProvidersChangeEvent>());99readonly onDidChangeProviders: Event<ISessionsProvidersChangeEvent> = this._onDidChangeProviders.event;100101private _providers: ISessionsProvider[] = [];102103setProviders(providers: ISessionsProvider[]): void {104const oldProviders = this._providers;105this._providers = providers;106const oldIds = new Set(oldProviders.map(p => p.id));107const newIds = new Set(providers.map(p => p.id));108this._onDidChangeProviders.fire({109added: providers.filter(p => !oldIds.has(p.id)),110removed: oldProviders.filter(p => !newIds.has(p.id)),111});112}113114getProviders(): ISessionsProvider[] {115return this._providers;116}117118getProvider<T extends ISessionsProvider>(providerId: string): T | undefined {119return this._providers.find(p => p.id === providerId) as T | undefined;120}121}122123// ---- Test helpers -----------------------------------------------------------124125function seedStorage(storageService: IStorageService, entries: { uri: URI; providerId: string; checked: boolean }[]): void {126const stored = entries.map(e => ({127uri: e.uri.toJSON(),128providerId: e.providerId,129checked: e.checked,130}));131storageService.store(STORAGE_KEY_RECENT_WORKSPACES, JSON.stringify(stored), StorageScope.PROFILE, StorageTarget.MACHINE);132}133134function createTestPicker(135disposables: DisposableStore,136providersService: MockSessionsProvidersService,137storageService?: IStorageService,138): WorkspacePicker {139const instantiationService = disposables.add(new TestInstantiationService());140const storage = storageService ?? disposables.add(new TestStorageService());141142instantiationService.stub(IActionWidgetService, { isVisible: false, hide: () => { }, show: () => { } });143instantiationService.stub(IContextViewService, { showContextView: () => ({ close: () => { } }), hideContextView: () => { }, layout: () => { } });144instantiationService.stub(IStorageService, storage);145instantiationService.stub(IUriIdentityService, { extUri });146instantiationService.stub(ISessionsProvidersService, providersService);147instantiationService.stub(IRemoteAgentHostService, {});148instantiationService.stub(IQuickInputService, {});149instantiationService.stub(IClipboardService, {});150instantiationService.stub(IPreferencesService, {});151instantiationService.stub(IOutputService, {});152instantiationService.stub(IConfigurationService, { getValue: () => undefined });153instantiationService.stub(ICommandService, { executeCommand: async () => { } });154instantiationService.stub(IFileDialogService, {});155instantiationService.stub(IContextKeyService, new MockContextKeyService());156instantiationService.stub(IMenuService, { createMenu: () => ({ onDidChange: Event.None, getActions: () => [], dispose: () => { } }) });157instantiationService.stub(IWorkspacesService, {158getRecentlyOpened: async () => ({ workspaces: [], files: [] }),159onDidChangeRecentlyOpened: Event.None,160});161162return disposables.add(instantiationService.createInstance(WorkspacePicker));163}164165// ---- Assertion helpers ------------------------------------------------------166167function assertSelectedProvider(picker: WorkspacePicker, expectedProviderId: string | undefined, message?: string): void {168assert.strictEqual(picker.selectedProject?.providerId, expectedProviderId, message);169}170171// ---- Tests ------------------------------------------------------------------172173suite('WorkspacePicker - Connection Status', () => {174175const disposables = new DisposableStore();176let providersService: MockSessionsProvidersService;177178setup(() => {179providersService = new MockSessionsProvidersService();180disposables.add(providersService);181});182183teardown(() => {184disposables.clear();185});186187ensureNoDisposablesAreLeakedInTestSuite();188189test('restore picks checked entry even when remote is disconnected (before grace period)', () => {190// Restore is honored synchronously: the picker shows the checked entry191// while we wait to see if the connection comes up. The grace-period192// fallback (covered in a separate test) only fires later.193const remoteStatus = observableValue<RemoteAgentHostConnectionStatus>('status', RemoteAgentHostConnectionStatus.Disconnected);194const remoteProvider = createMockProvider('agenthost-remote-1', { connectionStatus: remoteStatus });195const localProvider = createMockProvider('local-1');196197const storage = disposables.add(new TestStorageService());198seedStorage(storage, [199{ uri: URI.file('/remote/project'), providerId: 'agenthost-remote-1', checked: true },200{ uri: URI.file('/local/project'), providerId: 'local-1', checked: false },201]);202203providersService.setProviders([remoteProvider, localProvider]);204const picker = createTestPicker(disposables, providersService, storage);205206assertSelectedProvider(picker, 'agenthost-remote-1');207});208209test('restored remote that never connects falls back after grace period', () => runWithFakedTimers<void>({ useFakeTimers: true }, async () => {210// The provider is registered as Disconnected and never transitions —211// e.g. SSH host is unreachable and the status was set before the picker212// could subscribe. The picker should fall back to no selection after213// the grace period so the view pane drops the stale session.214const remoteStatus = observableValue<RemoteAgentHostConnectionStatus>('status', RemoteAgentHostConnectionStatus.Disconnected);215const remoteProvider = createMockProvider('agenthost-remote-1', { connectionStatus: remoteStatus });216217const storage = disposables.add(new TestStorageService());218seedStorage(storage, [219{ uri: URI.file('/remote/project'), providerId: 'agenthost-remote-1', checked: true },220]);221222providersService.setProviders([remoteProvider]);223const picker = createTestPicker(disposables, providersService, storage);224225assertSelectedProvider(picker, 'agenthost-remote-1', 'Selection is restored synchronously');226227const events: Array<IWorkspaceSelection | undefined> = [];228disposables.add(picker.onDidSelectWorkspace(e => events.push(e)));229230// Advance past the grace period.231await timeout(10_000);232233assertSelectedProvider(picker, undefined, 'Selection cleared after grace period');234assert.deepStrictEqual(events, [undefined], 'onDidSelectWorkspace fired with undefined');235}));236237test('restored remote that connects within grace period keeps selection', () => runWithFakedTimers<void>({ useFakeTimers: true }, async () => {238const remoteStatus = observableValue<RemoteAgentHostConnectionStatus>('status', RemoteAgentHostConnectionStatus.Disconnected);239const remoteProvider = createMockProvider('agenthost-remote-1', { connectionStatus: remoteStatus });240241const storage = disposables.add(new TestStorageService());242seedStorage(storage, [243{ uri: URI.file('/remote/project'), providerId: 'agenthost-remote-1', checked: true },244]);245246providersService.setProviders([remoteProvider]);247const picker = createTestPicker(disposables, providersService, storage);248249// Connection succeeds quickly.250await timeout(100);251remoteStatus.set(RemoteAgentHostConnectionStatus.Connecting, undefined);252await timeout(500);253remoteStatus.set(RemoteAgentHostConnectionStatus.Connected, undefined);254255// Advance past the grace period — should not fall back since we connected.256await timeout(10_000);257258assertSelectedProvider(picker, 'agenthost-remote-1', 'Selection preserved after successful connect');259}));260261test('user pick during connect cancels the fallback', () => runWithFakedTimers<void>({ useFakeTimers: true }, async () => {262// If the user picks a different workspace while the restore-grace-period263// timer is running, the timer must not later clear the user's selection.264const remoteStatus = observableValue<RemoteAgentHostConnectionStatus>('status', RemoteAgentHostConnectionStatus.Disconnected);265const remoteProvider = createMockProvider('agenthost-remote-1', { connectionStatus: remoteStatus });266const localProvider = createMockProvider('local-1');267268const storage = disposables.add(new TestStorageService());269seedStorage(storage, [270{ uri: URI.file('/remote/project'), providerId: 'agenthost-remote-1', checked: true },271]);272273providersService.setProviders([remoteProvider, localProvider]);274const picker = createTestPicker(disposables, providersService, storage);275276// User picks a local workspace while the remote is still trying to connect.277const localPick: IWorkspaceSelection = {278providerId: 'local-1',279workspace: localProvider.resolveWorkspace(URI.file('/local/picked'))!,280};281picker.setSelectedWorkspace(localPick, false);282283// Grace period elapses; remote still disconnected — must not affect user pick.284await timeout(10_000);285286assertSelectedProvider(picker, 'local-1', 'User pick preserved across grace-period elapse');287}));288289test('restore picks checked entry while remote is connecting (no fallback flicker)', () => {290// SSH remote: provider registers in Disconnected state and immediately291// starts connecting. We restore the checked entry immediately rather than292// falling back to a different workspace and swapping later.293const remoteStatus = observableValue<RemoteAgentHostConnectionStatus>('status', RemoteAgentHostConnectionStatus.Disconnected);294const remoteProvider = createMockProvider('agenthost-remote-1', { connectionStatus: remoteStatus });295const localProvider = createMockProvider('local-1');296297const storage = disposables.add(new TestStorageService());298seedStorage(storage, [299{ uri: URI.file('/remote/project'), providerId: 'agenthost-remote-1', checked: true },300{ uri: URI.file('/local/project'), providerId: 'local-1', checked: false },301]);302303providersService.setProviders([remoteProvider, localProvider]);304const picker = createTestPicker(disposables, providersService, storage);305306assertSelectedProvider(picker, 'agenthost-remote-1');307308// Connection attempt starts (no fallback while connecting).309remoteStatus.set(RemoteAgentHostConnectionStatus.Connecting, undefined);310assertSelectedProvider(picker, 'agenthost-remote-1');311312// After connection completes, selection is unchanged.313remoteStatus.set(RemoteAgentHostConnectionStatus.Connected, undefined);314assertSelectedProvider(picker, 'agenthost-remote-1');315});316317test('connecting provider that fails falls back to no selection', () => {318// Real SSH remote lifecycle: starts Disconnected, transitions Connecting,319// then fails back to Disconnected. The picker must clear the selection320// and fire onDidSelectWorkspace(undefined) so the view pane calls unsetNewSession().321const remoteStatus = observableValue<RemoteAgentHostConnectionStatus>('status', RemoteAgentHostConnectionStatus.Disconnected);322const remoteProvider = createMockProvider('agenthost-remote-1', { connectionStatus: remoteStatus });323324const storage = disposables.add(new TestStorageService());325seedStorage(storage, [326{ uri: URI.file('/remote/project'), providerId: 'agenthost-remote-1', checked: true },327]);328329providersService.setProviders([remoteProvider]);330const picker = createTestPicker(disposables, providersService, storage);331332assertSelectedProvider(picker, 'agenthost-remote-1', 'Selection is restored while connecting');333334const events: Array<IWorkspaceSelection | undefined> = [];335disposables.add(picker.onDidSelectWorkspace(e => events.push(e)));336337// SSH tunnel begins.338remoteStatus.set(RemoteAgentHostConnectionStatus.Connecting, undefined);339assertSelectedProvider(picker, 'agenthost-remote-1', 'Selection preserved while connecting');340341// SSH tunnel fails.342remoteStatus.set(RemoteAgentHostConnectionStatus.Disconnected, undefined);343344assertSelectedProvider(picker, undefined, 'Selection cleared after connection failure');345assert.deepStrictEqual(events, [undefined], 'onDidSelectWorkspace fired with undefined');346});347348test('restore picks connected remote provider', () => {349const remoteStatus = observableValue<RemoteAgentHostConnectionStatus>('status', RemoteAgentHostConnectionStatus.Connected);350const remoteProvider = createMockProvider('agenthost-remote-1', { connectionStatus: remoteStatus });351352const storage = disposables.add(new TestStorageService());353seedStorage(storage, [354{ uri: URI.file('/remote/project'), providerId: 'agenthost-remote-1', checked: true },355]);356357providersService.setProviders([remoteProvider]);358const picker = createTestPicker(disposables, providersService, storage);359360assertSelectedProvider(picker, 'agenthost-remote-1');361});362363test('disconnect preserves selection (renders grayed; no auto-clear)', () => {364const remoteStatus = observableValue<RemoteAgentHostConnectionStatus>('status', RemoteAgentHostConnectionStatus.Connected);365const remoteProvider = createMockProvider('agenthost-remote-1', { connectionStatus: remoteStatus });366367const storage = disposables.add(new TestStorageService());368seedStorage(storage, [369{ uri: URI.file('/remote/project'), providerId: 'agenthost-remote-1', checked: true },370]);371372providersService.setProviders([remoteProvider]);373const picker = createTestPicker(disposables, providersService, storage);374assertSelectedProvider(picker, 'agenthost-remote-1');375376// Disconnect — selection is preserved (the user picked it; we keep honoring it).377remoteStatus.set(RemoteAgentHostConnectionStatus.Disconnected, undefined);378assertSelectedProvider(picker, 'agenthost-remote-1', 'Selection should be preserved on disconnect');379});380381test('reconnect keeps the selection (no extra event fires)', () => {382const remoteStatus = observableValue<RemoteAgentHostConnectionStatus>('status', RemoteAgentHostConnectionStatus.Connected);383const remoteProvider = createMockProvider('agenthost-remote-1', { connectionStatus: remoteStatus });384385const storage = disposables.add(new TestStorageService());386seedStorage(storage, [387{ uri: URI.file('/remote/project'), providerId: 'agenthost-remote-1', checked: true },388]);389390providersService.setProviders([remoteProvider]);391const picker = createTestPicker(disposables, providersService, storage);392assertSelectedProvider(picker, 'agenthost-remote-1');393394// Disconnect / reconnect cycle — selection preserved throughout.395remoteStatus.set(RemoteAgentHostConnectionStatus.Disconnected, undefined);396remoteStatus.set(RemoteAgentHostConnectionStatus.Connected, undefined);397assertSelectedProvider(picker, 'agenthost-remote-1');398assert.strictEqual(399picker.selectedProject?.workspace.repositories[0]?.uri.path,400'/remote/project',401);402});403404test('checked is globally unique after persist', () => {405const localProvider = createMockProvider('local-1');406const remoteStatus = observableValue<RemoteAgentHostConnectionStatus>('status', RemoteAgentHostConnectionStatus.Connected);407const remoteProvider = createMockProvider('agenthost-remote-1', { connectionStatus: remoteStatus });408409const storage = disposables.add(new TestStorageService());410seedStorage(storage, [411{ uri: URI.file('/remote/project'), providerId: 'agenthost-remote-1', checked: true },412{ uri: URI.file('/local/project'), providerId: 'local-1', checked: false },413]);414415providersService.setProviders([remoteProvider, localProvider]);416const picker = createTestPicker(disposables, providersService, storage);417418// Select the local workspace419const resolvedWorkspace = localProvider.resolveWorkspace(URI.file('/local/project'));420assert.ok(resolvedWorkspace, 'resolveWorkspace should resolve file:// URIs');421const localWorkspace: IWorkspaceSelection = {422providerId: 'local-1',423workspace: resolvedWorkspace,424};425picker.setSelectedWorkspace(localWorkspace, false);426427// Verify storage: only the local entry should be checked428const raw = storage.get(STORAGE_KEY_RECENT_WORKSPACES, StorageScope.PROFILE);429assert.ok(raw, 'Storage should have recent workspaces');430const stored = JSON.parse(raw!) as { providerId: string; checked: boolean }[];431const checkedEntries = stored.filter(e => e.checked);432assert.strictEqual(checkedEntries.length, 1, 'Only one entry should be checked');433assert.strictEqual(checkedEntries[0].providerId, 'local-1', 'The local entry should be checked');434});435436test('local provider is never treated as unavailable', () => {437const localProvider = createMockProvider('local-1');438439const storage = disposables.add(new TestStorageService());440seedStorage(storage, [441{ uri: URI.file('/local/project'), providerId: 'local-1', checked: true },442]);443444providersService.setProviders([localProvider]);445const picker = createTestPicker(disposables, providersService, storage);446447assertSelectedProvider(picker, 'local-1', 'Local provider workspace should always be selectable');448});449450test('restore picks the stored workspace when its provider registers after another provider', () => {451// Regression: previously the picker filtered restore through `activeProviderId`,452// which auto-locked to whichever provider registered first. If the stored453// workspace belonged to a provider that registered later than another available454// provider (for example, local-agent-host registering after default-copilot),455// the stored entry was filtered out and never restored.456//457// Realistic shape: storage holds BOTH a (non-checked) recent for the458// early-registering provider and a (checked) recent for the late-registering459// provider. The picker may briefly show the early recent as a fallback, but460// once the checked entry's provider registers, the picker must upgrade to it.461const copilotProvider = createMockProvider('default-copilot');462463const storage = disposables.add(new TestStorageService());464seedStorage(storage, [465{ uri: URI.file('/copilot/old-project'), providerId: 'default-copilot', checked: false },466{ uri: URI.file('/agent-host/project'), providerId: 'local-agent-host', checked: true },467]);468469// Construct picker with only the early-registering provider available.470providersService.setProviders([copilotProvider]);471const picker = createTestPicker(disposables, providersService, storage);472473// The fallback may be selected initially (early provider's recent),474// since the user's checked entry's provider isn't ready yet.475// Now the late provider arrives.476const agentHostProvider = createMockProvider('local-agent-host');477providersService.setProviders([copilotProvider, agentHostProvider]);478479assertSelectedProvider(picker, 'local-agent-host', 'Stored workspace should be restored once its provider registers');480});481482test('late-registering provider does not move selection out from under user', () => {483// After the user has explicitly picked a workspace, a provider484// registering later in the session must not switch the selection to its485// stored "checked" entry. We only do that auto-upgrade during initial486// startup before the user has acted.487const copilotProvider = createMockProvider('default-copilot');488489const storage = disposables.add(new TestStorageService());490seedStorage(storage, [491{ uri: URI.file('/agent-host/project'), providerId: 'local-agent-host', checked: true },492]);493494providersService.setProviders([copilotProvider]);495const picker = createTestPicker(disposables, providersService, storage);496497// Suppression kicked in: no fallback selection while checked entry is pending.498assertSelectedProvider(picker, undefined, 'No fallback while checked entry pending');499500// User explicitly picks a Copilot workspace.501const copilotPick: IWorkspaceSelection = {502providerId: 'default-copilot',503workspace: copilotProvider.resolveWorkspace(URI.file('/copilot/picked'))!,504};505picker.setSelectedWorkspace(copilotPick, false);506assertSelectedProvider(picker, 'default-copilot', 'User pick is honored');507508// Now the late provider for the (still-stored) checked entry arrives.509const agentHostProvider = createMockProvider('local-agent-host');510providersService.setProviders([copilotProvider, agentHostProvider]);511512assertSelectedProvider(picker, 'default-copilot', 'User selection is preserved across late provider registration');513});514});515516// ---- Tab discovery ----------------------------------------------------------517518/** Minimal subclass that exposes the protected `_getAvailableTabs` for testing. */519class TestablePicker extends WorkspacePicker {520getAvailableTabs(): string[] {521return this._getAvailableGroups();522}523}524525function makeBrowseAction(providerId: string, group: string | undefined, label = 'browse'): ISessionWorkspaceBrowseAction {526return {527label,528group,529icon: Codicon.folder,530providerId,531run: async () => undefined,532};533}534535function createTestablePicker(disposables: DisposableStore, providersService: MockSessionsProvidersService): TestablePicker {536const instantiationService = disposables.add(new TestInstantiationService());537instantiationService.stub(IActionWidgetService, { isVisible: false, hide: () => { }, show: () => { } });538instantiationService.stub(IContextViewService, { showContextView: () => ({ close: () => { } }), hideContextView: () => { }, layout: () => { } });539instantiationService.stub(IStorageService, disposables.add(new TestStorageService()));540instantiationService.stub(IUriIdentityService, { extUri });541instantiationService.stub(ISessionsProvidersService, providersService);542instantiationService.stub(IRemoteAgentHostService, {});543instantiationService.stub(IQuickInputService, {});544instantiationService.stub(IClipboardService, {});545instantiationService.stub(IPreferencesService, {});546instantiationService.stub(IOutputService, {});547instantiationService.stub(IConfigurationService, { getValue: () => undefined });548instantiationService.stub(ICommandService, { executeCommand: async () => { } });549instantiationService.stub(IFileDialogService, {});550instantiationService.stub(IContextKeyService, new MockContextKeyService());551instantiationService.stub(IMenuService, { createMenu: () => ({ onDidChange: Event.None, getActions: () => [], dispose: () => { } }) });552instantiationService.stub(IWorkspacesService, {553getRecentlyOpened: async () => ({ workspaces: [], files: [] }),554onDidChangeRecentlyOpened: Event.None,555});556return disposables.add(instantiationService.createInstance(TestablePicker));557}558559suite('WorkspacePicker - Tab discovery', () => {560561const disposables = new DisposableStore();562let providersService: MockSessionsProvidersService;563564setup(() => {565providersService = new MockSessionsProvidersService();566disposables.add(providersService);567});568569teardown(() => disposables.clear());570571ensureNoDisposablesAreLeakedInTestSuite();572573test('returns Remote group even when no providers contribute groups', () => {574providersService.setProviders([createMockProvider('p1')]);575const picker = createTestablePicker(disposables, providersService);576assert.deepStrictEqual(picker.getAvailableTabs(), [SESSION_WORKSPACE_GROUP_REMOTE]);577});578579test('orders well-known groups Local first, then alphabetical', () => {580providersService.setProviders([581createMockProvider('remote', { browseActions: [makeBrowseAction('remote', SESSION_WORKSPACE_GROUP_REMOTE)] }),582createMockProvider('cloud', { browseActions: [makeBrowseAction('cloud', 'Cloud')] }),583createMockProvider('local', { browseActions: [makeBrowseAction('local', SESSION_WORKSPACE_GROUP_LOCAL)] }),584]);585const picker = createTestablePicker(disposables, providersService);586assert.deepStrictEqual(picker.getAvailableTabs(), [SESSION_WORKSPACE_GROUP_LOCAL, 'Cloud', SESSION_WORKSPACE_GROUP_REMOTE]);587});588589test('deduplicates groups contributed by multiple providers / actions', () => {590providersService.setProviders([591createMockProvider('p1', { browseActions: [makeBrowseAction('p1', SESSION_WORKSPACE_GROUP_LOCAL)] }),592createMockProvider('p2', { browseActions: [makeBrowseAction('p2', SESSION_WORKSPACE_GROUP_LOCAL), makeBrowseAction('p2', SESSION_WORKSPACE_GROUP_LOCAL)] }),593]);594const picker = createTestablePicker(disposables, providersService);595assert.deepStrictEqual(picker.getAvailableTabs(), [SESSION_WORKSPACE_GROUP_LOCAL, SESSION_WORKSPACE_GROUP_REMOTE]);596});597598test('appends custom group labels after Local', () => {599providersService.setProviders([600createMockProvider('p1', { browseActions: [makeBrowseAction('p1', 'Custom A'), makeBrowseAction('p1', SESSION_WORKSPACE_GROUP_LOCAL)] }),601createMockProvider('p2', { browseActions: [makeBrowseAction('p2', 'Custom B'), makeBrowseAction('p2', SESSION_WORKSPACE_GROUP_REMOTE)] }),602]);603const picker = createTestablePicker(disposables, providersService);604const tabs = picker.getAvailableTabs();605assert.strictEqual(tabs[0], SESSION_WORKSPACE_GROUP_LOCAL);606assert.deepStrictEqual(tabs.slice(1).sort(), ['Custom A', 'Custom B', SESSION_WORKSPACE_GROUP_REMOTE]);607});608609test('ignores browse actions without a group', () => {610providersService.setProviders([611createMockProvider('p1', { browseActions: [makeBrowseAction('p1', undefined), makeBrowseAction('p1', SESSION_WORKSPACE_GROUP_LOCAL)] }),612]);613const picker = createTestablePicker(disposables, providersService);614assert.deepStrictEqual(picker.getAvailableTabs(), [SESSION_WORKSPACE_GROUP_LOCAL, SESSION_WORKSPACE_GROUP_REMOTE]);615});616617test('discovers groups from recent workspaces does not add extra tabs', () => {618const provider: ISessionsProvider = {619...createMockProvider('p1'),620resolveWorkspace: (uri: URI): ISessionWorkspace => ({621label: uri.path,622icon: Codicon.folder,623group: 'Cloud',624repositories: [{ uri, workingDirectory: undefined, detail: undefined, baseBranchName: undefined }],625requiresWorkspaceTrust: false,626}),627};628const storage = disposables.add(new TestStorageService());629seedStorage(storage, [{ uri: URI.file('/repo'), providerId: 'p1', checked: false }]);630providersService.setProviders([provider]);631632const instantiationService = disposables.add(new TestInstantiationService());633instantiationService.stub(IActionWidgetService, { isVisible: false, hide: () => { }, show: () => { } });634instantiationService.stub(IContextViewService, { showContextView: () => ({ close: () => { } }), hideContextView: () => { }, layout: () => { } });635instantiationService.stub(IStorageService, storage);636instantiationService.stub(IUriIdentityService, { extUri });637instantiationService.stub(ISessionsProvidersService, providersService);638instantiationService.stub(IRemoteAgentHostService, {});639instantiationService.stub(IQuickInputService, {});640instantiationService.stub(IClipboardService, {});641instantiationService.stub(IPreferencesService, {});642instantiationService.stub(IOutputService, {});643instantiationService.stub(IConfigurationService, { getValue: () => undefined });644instantiationService.stub(ICommandService, { executeCommand: async () => { } });645instantiationService.stub(IFileDialogService, {});646instantiationService.stub(IContextKeyService, new MockContextKeyService());647instantiationService.stub(IMenuService, { createMenu: () => ({ onDidChange: Event.None, getActions: () => [], dispose: () => { } }) });648instantiationService.stub(IWorkspacesService, {649getRecentlyOpened: async () => ({ workspaces: [], files: [] }),650onDidChangeRecentlyOpened: Event.None,651});652const picker = disposables.add(instantiationService.createInstance(TestablePicker));653// Recent workspace group ('Cloud') is not added as a tab — only654// browse actions and the always-present Remote group contribute tabs.655assert.deepStrictEqual(picker.getAvailableTabs(), [SESSION_WORKSPACE_GROUP_REMOTE]);656});657});658659660