mirror of
https://github.com/microsoft/vscode.git
synced 2026-04-02 00:09:30 +01:00
* agentHost: Disable folders from disconnected remotes * Address comments * add tests Co-authored-by: Copilot <copilot@github.com> --------- Co-authored-by: Copilot <copilot@github.com>
337 lines
15 KiB
TypeScript
337 lines
15 KiB
TypeScript
/*---------------------------------------------------------------------------------------------
|
|
* Copyright (c) Microsoft Corporation. All rights reserved.
|
|
* Licensed under the MIT License. See License.txt in the project root for license information.
|
|
*--------------------------------------------------------------------------------------------*/
|
|
|
|
import assert from 'assert';
|
|
import { Codicon } from '../../../../../base/common/codicons.js';
|
|
import { Emitter, Event } from '../../../../../base/common/event.js';
|
|
import { Disposable, DisposableStore } from '../../../../../base/common/lifecycle.js';
|
|
import { ISettableObservable, observableValue } from '../../../../../base/common/observable.js';
|
|
import { URI } from '../../../../../base/common/uri.js';
|
|
import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../base/test/common/utils.js';
|
|
import { IActionWidgetService } from '../../../../../platform/actionWidget/browser/actionWidget.js';
|
|
import { RemoteAgentHostConnectionStatus, IRemoteAgentHostService } from '../../../../../platform/agentHost/common/remoteAgentHostService.js';
|
|
import { IClipboardService } from '../../../../../platform/clipboard/common/clipboardService.js';
|
|
import { TestInstantiationService } from '../../../../../platform/instantiation/test/common/instantiationServiceMock.js';
|
|
import { IQuickInputService } from '../../../../../platform/quickinput/common/quickInput.js';
|
|
import { IStorageService, StorageScope, StorageTarget } from '../../../../../platform/storage/common/storage.js';
|
|
import { TestStorageService } from '../../../../../workbench/test/common/workbenchTestServices.js';
|
|
import { IPreferencesService } from '../../../../../workbench/services/preferences/common/preferences.js';
|
|
import { IOutputService } from '../../../../../workbench/services/output/common/output.js';
|
|
import { IUriIdentityService } from '../../../../../platform/uriIdentity/common/uriIdentity.js';
|
|
import { extUri } from '../../../../../base/common/resources.js';
|
|
import { ISessionsProvidersService } from '../../../sessions/browser/sessionsProvidersService.js';
|
|
import { ISessionsManagementService } from '../../../sessions/browser/sessionsManagementService.js';
|
|
import { ISessionChangeEvent, ISessionsProvider } from '../../../sessions/browser/sessionsProvider.js';
|
|
import { ISessionWorkspace } from '../../../sessions/common/sessionData.js';
|
|
import { WorkspacePicker, IWorkspaceSelection } from '../../browser/sessionWorkspacePicker.js';
|
|
|
|
// ---- Storage key (must match the one in sessionWorkspacePicker.ts) ----------
|
|
const STORAGE_KEY_RECENT_WORKSPACES = 'sessions.recentlyPickedWorkspaces';
|
|
|
|
// ---- Mock providers ---------------------------------------------------------
|
|
|
|
function createMockProvider(id: string, opts?: {
|
|
connectionStatus?: ISettableObservable<RemoteAgentHostConnectionStatus>;
|
|
}): ISessionsProvider {
|
|
return {
|
|
id,
|
|
label: `Provider ${id}`,
|
|
icon: Codicon.remote,
|
|
sessionTypes: [],
|
|
connectionStatus: opts?.connectionStatus,
|
|
browseActions: [],
|
|
resolveWorkspace: (uri: URI): ISessionWorkspace => ({
|
|
label: uri.path.substring(1) || uri.path,
|
|
icon: Codicon.folder,
|
|
repositories: [{ uri, workingDirectory: undefined, detail: undefined, baseBranchName: undefined, baseBranchProtected: undefined }],
|
|
requiresWorkspaceTrust: false,
|
|
}),
|
|
onDidChangeSessions: Event.None,
|
|
getSessions: () => [],
|
|
createNewSession: () => { throw new Error('Not implemented'); },
|
|
createNewSessionFrom: () => { throw new Error('Not implemented'); },
|
|
setSessionType: () => { throw new Error('Not implemented'); },
|
|
getSessionTypes: () => [],
|
|
renameSession: async () => { },
|
|
setModel: () => { },
|
|
archiveSession: async () => { },
|
|
unarchiveSession: async () => { },
|
|
deleteSession: async () => { },
|
|
setRead: () => { },
|
|
getUntitledSession: () => undefined,
|
|
sendRequest: async () => { throw new Error('Not implemented'); },
|
|
};
|
|
}
|
|
|
|
class MockSessionsProvidersService extends Disposable {
|
|
declare readonly _serviceBrand: undefined;
|
|
|
|
private readonly _onDidChangeProviders = this._register(new Emitter<void>());
|
|
readonly onDidChangeProviders: Event<void> = this._onDidChangeProviders.event;
|
|
readonly onDidChangeSessions: Event<ISessionChangeEvent> = Event.None;
|
|
readonly onDidReplaceSession = Event.None;
|
|
|
|
private _providers: ISessionsProvider[] = [];
|
|
|
|
setProviders(providers: ISessionsProvider[]): void {
|
|
this._providers = providers;
|
|
this._onDidChangeProviders.fire();
|
|
}
|
|
|
|
getProviders(): ISessionsProvider[] {
|
|
return this._providers;
|
|
}
|
|
|
|
resolveWorkspace(providerId: string, repositoryUri: URI): ISessionWorkspace | undefined {
|
|
const provider = this._providers.find(p => p.id === providerId);
|
|
return provider?.resolveWorkspace(repositoryUri);
|
|
}
|
|
}
|
|
|
|
// ---- Test helpers -----------------------------------------------------------
|
|
|
|
function seedStorage(storageService: IStorageService, entries: { uri: URI; providerId: string; checked: boolean }[]): void {
|
|
const stored = entries.map(e => ({
|
|
uri: e.uri.toJSON(),
|
|
providerId: e.providerId,
|
|
checked: e.checked,
|
|
}));
|
|
storageService.store(STORAGE_KEY_RECENT_WORKSPACES, JSON.stringify(stored), StorageScope.PROFILE, StorageTarget.MACHINE);
|
|
}
|
|
|
|
function createTestPicker(
|
|
disposables: DisposableStore,
|
|
providersService: MockSessionsProvidersService,
|
|
storageService?: IStorageService,
|
|
): WorkspacePicker {
|
|
const instantiationService = disposables.add(new TestInstantiationService());
|
|
const storage = storageService ?? disposables.add(new TestStorageService());
|
|
|
|
instantiationService.stub(IActionWidgetService, { isVisible: false, hide: () => { }, show: () => { } });
|
|
instantiationService.stub(IStorageService, storage);
|
|
instantiationService.stub(IUriIdentityService, { extUri });
|
|
instantiationService.stub(ISessionsProvidersService, providersService);
|
|
instantiationService.stub(ISessionsManagementService, {
|
|
activeProviderId: observableValue('activeProviderId', undefined),
|
|
});
|
|
instantiationService.stub(IRemoteAgentHostService, {});
|
|
instantiationService.stub(IQuickInputService, {});
|
|
instantiationService.stub(IClipboardService, {});
|
|
instantiationService.stub(IPreferencesService, {});
|
|
instantiationService.stub(IOutputService, {});
|
|
|
|
return disposables.add(instantiationService.createInstance(WorkspacePicker));
|
|
}
|
|
|
|
// ---- Assertion helpers ------------------------------------------------------
|
|
|
|
function assertSelectedProvider(picker: WorkspacePicker, expectedProviderId: string | undefined, message?: string): void {
|
|
assert.strictEqual(picker.selectedProject?.providerId, expectedProviderId, message);
|
|
}
|
|
|
|
// ---- Tests ------------------------------------------------------------------
|
|
|
|
suite('WorkspacePicker - Connection Status', () => {
|
|
|
|
const disposables = new DisposableStore();
|
|
let providersService: MockSessionsProvidersService;
|
|
|
|
setup(() => {
|
|
providersService = new MockSessionsProvidersService();
|
|
disposables.add(providersService);
|
|
});
|
|
|
|
teardown(() => {
|
|
disposables.clear();
|
|
});
|
|
|
|
ensureNoDisposablesAreLeakedInTestSuite();
|
|
|
|
test('restore skips unavailable (disconnected) provider', () => {
|
|
const remoteStatus = observableValue<RemoteAgentHostConnectionStatus>('status', RemoteAgentHostConnectionStatus.Disconnected);
|
|
const remoteProvider = createMockProvider('remote-1', { connectionStatus: remoteStatus });
|
|
const localProvider = createMockProvider('local-1');
|
|
|
|
const storage = disposables.add(new TestStorageService());
|
|
seedStorage(storage, [
|
|
{ uri: URI.file('/remote/project'), providerId: 'remote-1', checked: true },
|
|
{ uri: URI.file('/local/project'), providerId: 'local-1', checked: false },
|
|
]);
|
|
|
|
providersService.setProviders([remoteProvider, localProvider]);
|
|
const picker = createTestPicker(disposables, providersService, storage);
|
|
|
|
// The checked entry is from a disconnected provider — should fall back to local
|
|
assertSelectedProvider(picker, 'local-1');
|
|
});
|
|
|
|
test('restore skips connecting provider', () => {
|
|
const remoteStatus = observableValue<RemoteAgentHostConnectionStatus>('status', RemoteAgentHostConnectionStatus.Connecting);
|
|
const remoteProvider = createMockProvider('remote-1', { connectionStatus: remoteStatus });
|
|
const localProvider = createMockProvider('local-1');
|
|
|
|
const storage = disposables.add(new TestStorageService());
|
|
seedStorage(storage, [
|
|
{ uri: URI.file('/remote/project'), providerId: 'remote-1', checked: true },
|
|
{ uri: URI.file('/local/project'), providerId: 'local-1', checked: false },
|
|
]);
|
|
|
|
providersService.setProviders([remoteProvider, localProvider]);
|
|
const picker = createTestPicker(disposables, providersService, storage);
|
|
|
|
assertSelectedProvider(picker, 'local-1');
|
|
});
|
|
|
|
test('restore picks connected remote provider', () => {
|
|
const remoteStatus = observableValue<RemoteAgentHostConnectionStatus>('status', RemoteAgentHostConnectionStatus.Connected);
|
|
const remoteProvider = createMockProvider('remote-1', { connectionStatus: remoteStatus });
|
|
|
|
const storage = disposables.add(new TestStorageService());
|
|
seedStorage(storage, [
|
|
{ uri: URI.file('/remote/project'), providerId: 'remote-1', checked: true },
|
|
]);
|
|
|
|
providersService.setProviders([remoteProvider]);
|
|
const picker = createTestPicker(disposables, providersService, storage);
|
|
|
|
assertSelectedProvider(picker, 'remote-1');
|
|
});
|
|
|
|
test('disconnect clears selection from that provider', () => {
|
|
const remoteStatus = observableValue<RemoteAgentHostConnectionStatus>('status', RemoteAgentHostConnectionStatus.Connected);
|
|
const remoteProvider = createMockProvider('remote-1', { connectionStatus: remoteStatus });
|
|
|
|
const storage = disposables.add(new TestStorageService());
|
|
seedStorage(storage, [
|
|
{ uri: URI.file('/remote/project'), providerId: 'remote-1', checked: true },
|
|
]);
|
|
|
|
providersService.setProviders([remoteProvider]);
|
|
const picker = createTestPicker(disposables, providersService, storage);
|
|
assertSelectedProvider(picker, 'remote-1');
|
|
|
|
// Disconnect
|
|
remoteStatus.set(RemoteAgentHostConnectionStatus.Disconnected, undefined);
|
|
assertSelectedProvider(picker, undefined, 'Selection should be cleared after disconnect');
|
|
});
|
|
|
|
test('reconnect restores the same workspace', () => {
|
|
const remoteStatus = observableValue<RemoteAgentHostConnectionStatus>('status', RemoteAgentHostConnectionStatus.Connected);
|
|
const remoteProvider = createMockProvider('remote-1', { connectionStatus: remoteStatus });
|
|
|
|
const storage = disposables.add(new TestStorageService());
|
|
seedStorage(storage, [
|
|
{ uri: URI.file('/remote/project'), providerId: 'remote-1', checked: true },
|
|
]);
|
|
|
|
providersService.setProviders([remoteProvider]);
|
|
const picker = createTestPicker(disposables, providersService, storage);
|
|
assertSelectedProvider(picker, 'remote-1');
|
|
|
|
// Disconnect — clears selection
|
|
remoteStatus.set(RemoteAgentHostConnectionStatus.Disconnected, undefined);
|
|
assertSelectedProvider(picker, undefined, 'Should clear on disconnect');
|
|
|
|
// Reconnect — should restore
|
|
remoteStatus.set(RemoteAgentHostConnectionStatus.Connected, undefined);
|
|
assertSelectedProvider(picker, 'remote-1', 'Should restore after reconnect');
|
|
assert.strictEqual(
|
|
picker.selectedProject?.workspace.repositories[0]?.uri.path,
|
|
'/remote/project',
|
|
'Should restore the same workspace URI',
|
|
);
|
|
});
|
|
|
|
test('disconnect does not auto-select another provider workspace', () => {
|
|
const remoteStatus = observableValue<RemoteAgentHostConnectionStatus>('status', RemoteAgentHostConnectionStatus.Connected);
|
|
const remoteProvider = createMockProvider('remote-1', { connectionStatus: remoteStatus });
|
|
const localProvider = createMockProvider('local-1');
|
|
|
|
const storage = disposables.add(new TestStorageService());
|
|
seedStorage(storage, [
|
|
{ uri: URI.file('/remote/project'), providerId: 'remote-1', checked: true },
|
|
{ uri: URI.file('/local/project'), providerId: 'local-1', checked: false },
|
|
]);
|
|
|
|
providersService.setProviders([remoteProvider, localProvider]);
|
|
const picker = createTestPicker(disposables, providersService, storage);
|
|
assertSelectedProvider(picker, 'remote-1');
|
|
|
|
// Disconnect remote
|
|
remoteStatus.set(RemoteAgentHostConnectionStatus.Disconnected, undefined);
|
|
|
|
// Should NOT auto-select local workspace — should remain empty
|
|
assertSelectedProvider(picker, undefined, 'Should not auto-select another provider on disconnect');
|
|
});
|
|
|
|
test('checked is globally unique after persist', () => {
|
|
const localProvider = createMockProvider('local-1');
|
|
const remoteStatus = observableValue<RemoteAgentHostConnectionStatus>('status', RemoteAgentHostConnectionStatus.Connected);
|
|
const remoteProvider = createMockProvider('remote-1', { connectionStatus: remoteStatus });
|
|
|
|
const storage = disposables.add(new TestStorageService());
|
|
seedStorage(storage, [
|
|
{ uri: URI.file('/remote/project'), providerId: 'remote-1', checked: true },
|
|
{ uri: URI.file('/local/project'), providerId: 'local-1', checked: false },
|
|
]);
|
|
|
|
providersService.setProviders([remoteProvider, localProvider]);
|
|
const picker = createTestPicker(disposables, providersService, storage);
|
|
|
|
// Select the local workspace
|
|
const localWorkspace: IWorkspaceSelection = {
|
|
providerId: 'local-1',
|
|
workspace: localProvider.resolveWorkspace(URI.file('/local/project')),
|
|
};
|
|
picker.setSelectedWorkspace(localWorkspace, false);
|
|
|
|
// Verify storage: only the local entry should be checked
|
|
const raw = storage.get(STORAGE_KEY_RECENT_WORKSPACES, StorageScope.PROFILE);
|
|
assert.ok(raw, 'Storage should have recent workspaces');
|
|
const stored = JSON.parse(raw!) as { providerId: string; checked: boolean }[];
|
|
const checkedEntries = stored.filter(e => e.checked);
|
|
assert.strictEqual(checkedEntries.length, 1, 'Only one entry should be checked');
|
|
assert.strictEqual(checkedEntries[0].providerId, 'local-1', 'The local entry should be checked');
|
|
});
|
|
|
|
test('onDidSelectWorkspace fires on reconnect restore', () => {
|
|
const remoteStatus = observableValue<RemoteAgentHostConnectionStatus>('status', RemoteAgentHostConnectionStatus.Connected);
|
|
const remoteProvider = createMockProvider('remote-1', { connectionStatus: remoteStatus });
|
|
|
|
const storage = disposables.add(new TestStorageService());
|
|
seedStorage(storage, [
|
|
{ uri: URI.file('/remote/project'), providerId: 'remote-1', checked: true },
|
|
]);
|
|
|
|
providersService.setProviders([remoteProvider]);
|
|
const picker = createTestPicker(disposables, providersService, storage);
|
|
|
|
const selected: IWorkspaceSelection[] = [];
|
|
disposables.add(picker.onDidSelectWorkspace(w => selected.push(w)));
|
|
|
|
// Disconnect then reconnect
|
|
remoteStatus.set(RemoteAgentHostConnectionStatus.Disconnected, undefined);
|
|
remoteStatus.set(RemoteAgentHostConnectionStatus.Connected, undefined);
|
|
|
|
assert.strictEqual(selected.length, 1, 'onDidSelectWorkspace should fire once on reconnect');
|
|
assert.strictEqual(selected[0].providerId, 'remote-1');
|
|
assert.strictEqual(selected[0].workspace.repositories[0]?.uri.path, '/remote/project', 'Event should carry the correct workspace URI');
|
|
});
|
|
|
|
test('local provider is never treated as unavailable', () => {
|
|
const localProvider = createMockProvider('local-1');
|
|
|
|
const storage = disposables.add(new TestStorageService());
|
|
seedStorage(storage, [
|
|
{ uri: URI.file('/local/project'), providerId: 'local-1', checked: true },
|
|
]);
|
|
|
|
providersService.setProviders([localProvider]);
|
|
const picker = createTestPicker(disposables, providersService, storage);
|
|
|
|
assertSelectedProvider(picker, 'local-1', 'Local provider workspace should always be selectable');
|
|
});
|
|
});
|