From debdf55cb7d311d7080f80dd12227184a9a84439 Mon Sep 17 00:00:00 2001 From: "vs-code-engineering[bot]" Date: Mon, 26 Oct 2026 12:54:41 +0000 Subject: [PATCH] [cherry-pick] sessions: Add inline remote management actions --- .../agentHost/common/wslRemoteAgentHost.ts | 2 +- .../browser/remoteAgentHostActions.ts | 10 +- .../browser/remoteHostOptions.ts | 24 +- .../remoteAgentHost/browser/remotesModel.ts | 158 ++++++- .../remoteAgentHost/browser/remotesTable.ts | 41 +- .../remoteAgentHost/browser/remotesWidget.ts | 47 +- .../electron-browser/remotes.contribution.ts | 2 +- .../test/browser/remotesModel.test.ts | 433 +++++++++++++++++- .../test/browser/remotesWidget.fixture.ts | 32 +- .../test/browser/remotesWidget.test.ts | 221 ++++++++- .../electron-browser/remotesEditor.test.ts | 4 + 11 files changed, 912 insertions(+), 62 deletions(-) diff --git a/src/vs/platform/agentHost/common/wslRemoteAgentHost.ts b/src/vs/platform/agentHost/common/wslRemoteAgentHost.ts index 745040fb58f603..21d1f5d776eb77 100644 --- a/src/vs/platform/agentHost/common/wslRemoteAgentHost.ts +++ b/src/vs/platform/agentHost/common/wslRemoteAgentHost.ts @@ -13,7 +13,7 @@ import type { IRelayMessage } from './relayTransport.js'; */ export const WSL_REMOTE_AGENT_HOST_CHANNEL = 'wslRemoteAgentHost'; -export const WSL_INSTALL_DOCS_URL = 'https://aka.ms/vscode-remote/wsl/install-wsl'; +export const WSL_INSTALL_DOCS_URL = 'https://aka.ms/vscode-agent-wsl'; /** * Prefix for WSL display addresses (`wsl:`). This is NOT a `ws://` diff --git a/src/vs/sessions/contrib/providers/remoteAgentHost/browser/remoteAgentHostActions.ts b/src/vs/sessions/contrib/providers/remoteAgentHost/browser/remoteAgentHostActions.ts index 9bddceca64f8a0..06739784d534f4 100644 --- a/src/vs/sessions/contrib/providers/remoteAgentHost/browser/remoteAgentHostActions.ts +++ b/src/vs/sessions/contrib/providers/remoteAgentHost/browser/remoteAgentHostActions.ts @@ -46,7 +46,7 @@ import { Menus } from '../../../../browser/menus.js'; import { ISessionsService } from '../../../../services/sessions/browser/sessionsService.js'; import { ISessionsProvidersService } from '../../../../services/sessions/browser/sessionsProvidersService.js'; import { IAgentHostSessionsProvider, isAgentHostProvider } from '../../../../common/agentHostSessionsProvider.js'; -import { runServerUpgrade } from './remoteHostOptions.js'; +import { confirmTunnelDeletion, runServerUpgrade } from './remoteHostOptions.js'; import { IConnectionDiagnosticsService } from './connectionDiagnostics.js'; import { SESSION_WORKSPACE_GROUP_REMOTE } from '../../../../services/sessions/common/session.js'; import { ISessionsPartService } from '../../../../services/sessions/browser/sessionsPartService.js'; @@ -1019,13 +1019,7 @@ async function promptToConnectViaTunnel( tunnelPicker.ignoreFocusOut = true; let keepOpen = true; try { - const confirmation = await dialogService.confirm({ - type: 'warning', - message: localize('tunnelDeleteConfirmation', "Are you sure you want to delete dev tunnel '{0}'?", event.item.tunnel.name), - detail: localize('tunnelDeleteDetail', "The tunnel may be recreated if a machine starts hosting it again."), - primaryButton: localize('tunnelDeleteButton', "&&Delete"), - }); - if (!confirmation.confirmed) { + if (!await confirmTunnelDeletion(dialogService, event.item.tunnel.name)) { return; } if (isHostedTunnel(event.item.tunnel)) { diff --git a/src/vs/sessions/contrib/providers/remoteAgentHost/browser/remoteHostOptions.ts b/src/vs/sessions/contrib/providers/remoteAgentHost/browser/remoteHostOptions.ts index 78fbff7f1b6a14..5989d66d5bef17 100644 --- a/src/vs/sessions/contrib/providers/remoteAgentHost/browser/remoteHostOptions.ts +++ b/src/vs/sessions/contrib/providers/remoteAgentHost/browser/remoteHostOptions.ts @@ -51,6 +51,16 @@ export async function removeRemoteHost(provider: IAgentHostSessionsProvider, rem } } +export async function confirmTunnelDeletion(dialogService: IDialogService, name: string): Promise { + const { confirmed } = await dialogService.confirm({ + type: 'warning', + message: localize('tunnelDeleteConfirmation', "Are you sure you want to delete dev tunnel '{0}'?", name), + detail: localize('tunnelDeleteDetail', "This deletes the tunnel registration and disconnects its clients. Files on the remote machine will not be deleted. The tunnel may be recreated if a machine starts hosting it again."), + primaryButton: localize('tunnelDeleteButton', "&&Delete"), + }); + return confirmed; +} + export async function renameRemoteHost( provider: Pick, quickInputService: IQuickInputService, @@ -101,19 +111,17 @@ export function hasUpgradeReconnectStarted(status: RemoteAgentHostConnectionStat * surfaced error). All user-facing errors are reported via the * notification service. */ -export async function runServerUpgrade( - accessor: ServicesAccessor, +export async function upgradeRemoteHost( provider: IAgentHostSessionsProvider, upgradeMethod: string, + remoteAgentHostService: IRemoteAgentHostService, + notificationService: INotificationService, + progressService: IProgressService, ): Promise { const address = provider.remoteAddress; if (!address) { return; } - const remoteAgentHostService = accessor.get(IRemoteAgentHostService); - const notificationService = accessor.get(INotificationService); - const progressService = accessor.get(IProgressService); - await progressService.withProgress( { location: ProgressLocation.Notification, @@ -194,6 +202,10 @@ export async function runServerUpgrade( ); } +export async function runServerUpgrade(accessor: ServicesAccessor, provider: IAgentHostSessionsProvider, upgradeMethod: string): Promise { + await upgradeRemoteHost(provider, upgradeMethod, accessor.get(IRemoteAgentHostService), accessor.get(INotificationService), accessor.get(IProgressService)); +} + /** * Surface a transient notification each time a provider transitions into * the `incompatible` state. Fires once per transition (not on every diff --git a/src/vs/sessions/contrib/providers/remoteAgentHost/browser/remotesModel.ts b/src/vs/sessions/contrib/providers/remoteAgentHost/browser/remotesModel.ts index 3b24ec1c1a1401..1692d47ee47b11 100644 --- a/src/vs/sessions/contrib/providers/remoteAgentHost/browser/remotesModel.ts +++ b/src/vs/sessions/contrib/providers/remoteAgentHost/browser/remotesModel.ts @@ -16,18 +16,23 @@ import { cloudSandboxAddress } from '../../../../../platform/agentHost/common/cl import { IMissionControlAvailabilityService, IMissionControlEnvironmentService, IMissionControlHost } from '../../../../../platform/agentHost/common/missionControlEnvironment.js'; import { addSSHRemoteAgentHostEntry, getEntryAddress, IRemoteAgentHostEntry, IRemoteAgentHostService, readSSHRemoteAgentHostEntries, RemoteAgentHostConnectionStatus, RemoteAgentHostEntryType, RemoteAgentHostsEnabledSettingId } from '../../../../../platform/agentHost/common/remoteAgentHostService.js'; import { computeSSHConnectionKey, ISSHRemoteAgentHostService } from '../../../../../platform/agentHost/common/sshRemoteAgentHost.js'; -import { ITunnelAgentHostService, ITunnelInfo, TUNNEL_ADDRESS_PREFIX, TUNNEL_MIN_PROTOCOL_VERSION, TunnelAuthenticationRequiredError } from '../../../../../platform/agentHost/common/tunnelAgentHost.js'; +import { isTunnelHosted, ITunnelAgentHostService, ITunnelInfo, TUNNEL_ADDRESS_PREFIX, TUNNEL_MIN_PROTOCOL_VERSION, TunnelAuthenticationRequiredError } from '../../../../../platform/agentHost/common/tunnelAgentHost.js'; import { IWSLRemoteAgentHostService, WSL_ADDRESS_PREFIX } from '../../../../../platform/agentHost/common/wslRemoteAgentHost.js'; +import { IRemoteAgentHostLocationPreferenceService } from '../../../../../platform/agentHost/common/remoteAgentHostLocationPreference.js'; +import { IClipboardService } from '../../../../../platform/clipboard/common/clipboardService.js'; import { IConfigurationService } from '../../../../../platform/configuration/common/configuration.js'; import { IDialogService } from '../../../../../platform/dialogs/common/dialogs.js'; import { ILogService } from '../../../../../platform/log/common/log.js'; +import { INotificationService } from '../../../../../platform/notification/common/notification.js'; +import { IProductService } from '../../../../../platform/product/common/productService.js'; import { IQuickInputService } from '../../../../../platform/quickinput/common/quickInput.js'; -import { IProgress } from '../../../../../platform/progress/common/progress.js'; +import { IProgress, IProgressService } from '../../../../../platform/progress/common/progress.js'; +import { IRemoteTunnelService, TunnelStatus } from '../../../../../platform/remoteTunnel/common/remoteTunnel.js'; import { IStorageService, StorageScope } from '../../../../../platform/storage/common/storage.js'; import { DevContainerAgentHostEnabledSettingId, IDevContainerAgentHostService } from '../../../../common/devContainerAgentHostService.js'; import { IAgentHostSessionsProvider, isAgentHostProvider, isRemoteSessionCreationEnvironment } from '../../../../common/agentHostSessionsProvider.js'; import { ISessionsProvidersService } from '../../../../services/sessions/browser/sessionsProvidersService.js'; -import { reconnectRemoteHost, removeRemoteHost, renameRemoteHost } from './remoteHostOptions.js'; +import { changeRemoteAgentHostLocationPreference, confirmTunnelDeletion, reconnectRemoteHost, removeRemoteHost, renameRemoteHost, upgradeRemoteHost } from './remoteHostOptions.js'; import { getRemoteAgentHostPickerKey, RemoteAgentHostPickerVisibility } from './remoteAgentHostPickerVisibility.js'; export const remoteKinds = ['ssh', 'wsl', 'tunnel', 'environment', 'devContainer'] as const; @@ -47,6 +52,7 @@ export interface ICustomizationRemote { readonly name: string; readonly detail: string; readonly canRemove: boolean; + readonly canDelete?: boolean; readonly connectionStatus: RemoteAgentHostConnectionStatus; readonly lastConnectionError?: string; readonly unavailableReason?: string; @@ -67,6 +73,10 @@ export interface IRemotesModel extends IDisposable { refresh(token: CancellationToken, progress?: IProgress): Promise; setConnected(id: string, connected: boolean): Promise; rename(id: string): Promise; + copyAddress(id: string): Promise; + changePreferredAgentLocation(id: string): Promise; + updateServer(id: string): Promise; + deleteTunnel(id: string): Promise; remove(id: string): Promise; setHidden(id: string, hidden: boolean): void; } @@ -83,7 +93,7 @@ export function getVisibleRemoteKinds(operatingSystem: OperatingSystem, environm type RemoteTarget = | { readonly kind: 'ssh'; readonly entry: IRemoteAgentHostEntry } | { readonly kind: 'wsl'; readonly distro: string; readonly name: string } - | { readonly kind: 'tunnel'; readonly tunnel: ITunnelInfo; readonly authProvider?: 'github' | 'microsoft' } + | { readonly kind: 'tunnel'; readonly tunnel: ITunnelInfo; readonly authProvider?: 'github' | 'microsoft'; readonly source: 'discovered' | 'cached' } | { readonly kind: 'environment'; readonly host: IMissionControlHost; readonly account: string } | { readonly kind: 'devContainer'; readonly container: IDiscoveredDevContainer; readonly workspaceUri?: URI }; @@ -97,6 +107,9 @@ export class RemotesModel extends Disposable implements IRemotesModel { private readonly _tunnelAuthenticationRequired = observableValue<'github' | 'microsoft' | undefined>(this, undefined); readonly tunnelAuthenticationRequired = this._tunnelAuthenticationRequired; private readonly pickerVisibility: RemoteAgentHostPickerVisibility; + private remoteTunnelStatus: TunnelStatus | undefined; + private receivedTunnelStatusEvent = false; + private tunnelDiscoveryVersion = 0; constructor( private readonly discoverDevContainers: (token: CancellationToken) => Promise, @@ -113,6 +126,12 @@ export class RemotesModel extends Disposable implements IRemotesModel { @IMissionControlEnvironmentService private readonly environmentService: IMissionControlEnvironmentService, @IMissionControlAvailabilityService environmentAvailabilityService: IMissionControlAvailabilityService, @IDialogService private readonly dialogService: IDialogService, + @IClipboardService private readonly clipboardService: IClipboardService, + @IRemoteAgentHostLocationPreferenceService private readonly locationPreferenceService: IRemoteAgentHostLocationPreferenceService, + @INotificationService private readonly notificationService: INotificationService, + @IProgressService private readonly progressService: IProgressService, + @IProductService private readonly productService: IProductService, + @IRemoteTunnelService remoteTunnelService: IRemoteTunnelService, ) { super(); this.pickerVisibility = this._register(new RemoteAgentHostPickerVisibility(storageService)); @@ -131,6 +150,24 @@ export class RemotesModel extends Disposable implements IRemotesModel { this._register(remoteAgentHostService.onDidChangeDisplayName(() => this.update())); this._register(storageService.onDidChangeValue(StorageScope.APPLICATION, undefined, this._store)(() => this.update())); this._register(configurationService.onDidChangeConfiguration(() => this.update())); + this._register(remoteTunnelService.onDidChangeTunnelStatus(status => { + this.receivedTunnelStatusEvent = true; + this.remoteTunnelStatus = status; + this.update(); + })); + void this.loadTunnelStatus(remoteTunnelService); + } + + private async loadTunnelStatus(remoteTunnelService: IRemoteTunnelService): Promise { + try { + const status = await remoteTunnelService.getTunnelStatus(); + if (!this._store.isDisposed && !this.receivedTunnelStatusEvent) { + this.remoteTunnelStatus = status; + this.update(); + } + } catch (error) { + this.logService.warn('[Remotes] Could not read hosted tunnel status', error); + } } private get enabled(): boolean { @@ -143,11 +180,13 @@ export class RemotesModel extends Disposable implements IRemotesModel { return []; } const discover = async (kind: RemoteKind, task: () => Promise): Promise => { + const tunnelDiscoveryVersion = this.tunnelDiscoveryVersion; progress?.report({ kind, refreshing: true }); let failed = false; try { const targets = await task(); - if (!token.isCancellationRequested && !this._store.isDisposed && this.enabled) { + if (!token.isCancellationRequested && !this._store.isDisposed && this.enabled + && (kind !== 'tunnel' || tunnelDiscoveryVersion === this.tunnelDiscoveryVersion)) { for (const [id, target] of this.targets) { if (target.kind === kind) { this.targets.delete(id); @@ -183,7 +222,7 @@ export class RemotesModel extends Disposable implements IRemotesModel { discover('wsl', async () => isWindows ? (await this.wslService.listDistros()).map(distro => ({ kind: 'wsl', distro: distro.name, name: distro.name })) : []), discover('tunnel', async () => { const authProvider = await this.tunnelService.getAuthProvider({ silent: true }); - return (await this.tunnelService.listTunnels({ silent: true, authProvider })).map(tunnel => ({ kind: 'tunnel', tunnel, authProvider })); + return (await this.tunnelService.listTunnels({ silent: true, authProvider })).map(tunnel => ({ kind: 'tunnel', tunnel, authProvider, source: 'discovered' })); }), discover('environment', async () => { if (this.environmentService.enabled) { @@ -258,6 +297,49 @@ export class RemotesModel extends Disposable implements IRemotesModel { this.update(); } + async copyAddress(id: string): Promise { + if (!this.enabled || this._store.isDisposed || !this.targets.has(id)) { + throw new Error(localize('remotes.unavailable', "This remote is no longer available.")); + } + await this.clipboardService.writeText(id); + } + + async changePreferredAgentLocation(id: string): Promise { + const remote = this._remotes.get().find(remote => remote.id === id); + if (!this.enabled || this._store.isDisposed || !remote) { + throw new Error(localize('remotes.unavailable', "This remote is no longer available.")); + } + const target = this.targets.get(id); + if (target?.kind !== 'tunnel' && target?.kind !== 'ssh') { + throw new Error(localize('remotes.locationUnavailable', "Preferred agent location can only be changed for SSH hosts and tunnels here.")); + } + const provider = this.providersService.getProviders().filter(isAgentHostProvider).find(provider => provider.remoteAddress === id); + await changeRemoteAgentHostLocationPreference({ + preferenceKey: provider?.remoteLocationPreferenceKey ?? this.getLocationPreferenceKey(target), + hostLabel: remote.name, + productName: this.productService.nameShort, + provider, + dialogService: this.dialogService, + locationPreferenceService: this.locationPreferenceService, + notificationService: this.notificationService, + remoteAgentHostService: this.remoteAgentHostService, + progressService: this.progressService, + }); + } + + async updateServer(id: string): Promise { + const remote = this._remotes.get().find(remote => remote.id === id); + const provider = this.providersService.getProviders().filter(isAgentHostProvider).find(provider => provider.remoteAddress === id); + if (!this.enabled || this._store.isDisposed || !remote || !provider) { + throw new Error(localize('remotes.unavailable', "This remote is no longer available.")); + } + const status = provider.connectionStatus?.get() ?? remote.connectionStatus; + if (!RemoteAgentHostConnectionStatus.isIncompatible(status) || !status.vscodeUpgradeMethod) { + throw new Error(localize('remotes.updateUnavailable', "This remote does not support updating its server.")); + } + await upgradeRemoteHost(provider, status.vscodeUpgradeMethod, this.remoteAgentHostService, this.notificationService, this.progressService); + } + async rename(id: string): Promise { const remote = this._remotes.get().find(remote => remote.id === id); if (!remote || !this.enabled) { @@ -271,7 +353,10 @@ export class RemotesModel extends Disposable implements IRemotesModel { this.checkEnvironment(target); this.environmentService.setDisplayName(target.host.id, name); }, - } : provider ?? { remoteAddress: id, label: remote.name }; + } : { + remoteAddress: id, label: remote.name, + setDisplayName: provider?.setDisplayName ? (name: string | undefined) => provider.setDisplayName?.(name) : undefined, + }; await renameRemoteHost(renameTarget, this.quickInputService, this.remoteAgentHostService); if (!this._store.isDisposed) { this.update(); @@ -319,6 +404,42 @@ export class RemotesModel extends Disposable implements IRemotesModel { } } + async deleteTunnel(id: string): Promise { + const target = this.targets.get(id); + const remote = this._remotes.get().find(remote => remote.id === id); + if (!this.enabled || this._store.isDisposed || !remote || target?.kind !== 'tunnel') { + throw new Error(localize('remotes.unavailable', "This remote is no longer available.")); + } + this.checkTunnelDeletion(target.tunnel); + if (!await confirmTunnelDeletion(this.dialogService, remote.name)) { + return; + } + const current = this.targets.get(id); + if (this._store.isDisposed || !this.enabled || current?.kind !== 'tunnel' + || current.tunnel.clusterId !== target.tunnel.clusterId || current.authProvider !== target.authProvider) { + throw new CancellationError(); + } + this.checkTunnelDeletion(current.tunnel); + await this.tunnelService.deleteTunnel(current.tunnel, current.authProvider); + this.tunnelDiscoveryVersion++; + this.targets.delete(id); + if (!this._store.isDisposed) { + this.update(); + } + } + + private checkTunnelDeletion(tunnel: ITunnelInfo): void { + if (!this.tunnelService.canDeleteTunnels) { + throw new Error(localize('remotes.tunnelDeleteUnsupported', "Deleting tunnels is not supported by this application.")); + } + if (!this.remoteTunnelStatus) { + throw new Error(localize('remotes.tunnelStatusUnavailable', "Tunnel hosting status is not available yet. Try again.")); + } + if (isTunnelHosted(this.remoteTunnelStatus.type === 'connected' ? this.remoteTunnelStatus.info : undefined, tunnel)) { + throw new Error(localize('remotes.tunnelDeleteHosted', "This application is currently hosting this tunnel. Stop hosting it before deleting it.")); + } + } + async setConnected(id: string, connected: boolean): Promise { const target = this.targets.get(id); if (!target || !this.enabled) { @@ -420,13 +541,17 @@ export class RemotesModel extends Disposable implements IRemotesModel { } private getPickerKey(target: RemoteTarget): string { + return getRemoteAgentHostPickerKey(this.getLocationPreferenceKey(target), target.kind === 'environment' ? target.account : undefined); + } + + private getLocationPreferenceKey(target: RemoteTarget): string { if (target.kind === 'ssh' && target.entry.connection.type === RemoteAgentHostEntryType.SSH) { const connection = target.entry.connection; - return getRemoteAgentHostPickerKey(computeSSHConnectionKey({ + return computeSSHConnectionKey({ sshConfigHost: connection.sshConfigHost, host: connection.hostName, username: connection.user, port: connection.port, - })); + }); } - return getRemoteAgentHostPickerKey(this.getId(target), target.kind === 'environment' ? target.account : undefined); + return this.getId(target); } private update(reader?: IReader): void { @@ -466,7 +591,7 @@ export class RemotesModel extends Disposable implements IRemotesModel { ...sshEntries.map((entry): RemoteTarget => ({ kind: 'ssh', entry })), ...distros.map((distro): RemoteTarget => ({ kind: 'wsl', ...distro })), ...tunnels.map((tunnel): RemoteTarget => ({ - kind: 'tunnel', authProvider: tunnel.authProvider, + kind: 'tunnel', authProvider: tunnel.authProvider, source: 'cached', tunnel: { ...tunnel, tags: [], protocolVersion: tunnel.protocolVersion ?? TUNNEL_MIN_PROTOCOL_VERSION, hostConnectionCount: 0 }, })), ...workspaces.map((workspace): RemoteTarget => ({ kind: 'devContainer', workspaceUri: workspace.workspaceUri, container: { id: getComparisonKey(workspace.workspaceUri), name: workspace.name } })), @@ -474,7 +599,10 @@ export class RemotesModel extends Disposable implements IRemotesModel { this.registered.clear(); for (const target of registered) { const id = this.getId(target); - this.targets.set(id, target); + const existing = this.targets.get(id); + if (existing?.kind !== 'tunnel' || existing.source !== 'discovered') { + this.targets.set(id, target); + } this.registered.add(id); } const remotes: ICustomizationRemote[] = []; @@ -511,12 +639,16 @@ export class RemotesModel extends Disposable implements IRemotesModel { } const provider = providersByAddress.get(id); if (target.kind !== 'environment') { - name = this.remoteAgentHostService.getDisplayNameOverride(id) ?? provider?.label ?? name; + name = this.remoteAgentHostService.getDisplayNameOverride(id) ?? (target.kind === 'tunnel' ? name : provider?.label ?? name); } const canRemove = target.kind === 'environment' || this.registered.has(id) && (target.kind === 'devContainer' || !!provider && provider.canRemove !== false); const hidden = this.pickerVisibility.isHidden(this.getPickerKey(target), reader); remotes.push({ id, kind: target.kind, name, detail, canRemove, unavailableReason, + ...(target.kind === 'tunnel' ? { + canDelete: this.tunnelService.canDeleteTunnels && !!this.remoteTunnelStatus + && !isTunnelHosted(this.remoteTunnelStatus.type === 'connected' ? this.remoteTunnelStatus.info : undefined, target.tunnel), + } : {}), hidden, pickerUnavailableReason: this.getPickerUnavailableReason(target, provider, hidden, reader), connectionStatus: connectionStatuses.get(id) ?? RemoteAgentHostConnectionStatus.disconnected, ...(connectionErrors.has(id) ? { lastConnectionError: connectionErrors.get(id) } : {}), diff --git a/src/vs/sessions/contrib/providers/remoteAgentHost/browser/remotesTable.ts b/src/vs/sessions/contrib/providers/remoteAgentHost/browser/remotesTable.ts index 00e03916a4251f..6b09e9cde193e1 100644 --- a/src/vs/sessions/contrib/providers/remoteAgentHost/browser/remotesTable.ts +++ b/src/vs/sessions/contrib/providers/remoteAgentHost/browser/remotesTable.ts @@ -61,7 +61,7 @@ export interface IRemoteTableRow { } type RemoteColumn = 'gutter' | 'name' | 'connection' | 'actions'; -export type RemoteRowAction = 'toggleGroup' | 'toggleVisibility' | 'configure' | 'documentation' | 'connect' | 'rename' | 'delete' | 'signIn'; +export type RemoteRowAction = 'toggleGroup' | 'toggleVisibility' | 'configure' | 'documentation' | 'connect' | 'rename' | 'copyAddress' | 'locationPreference' | 'updateServer' | 'delete' | 'signIn'; type RemoteCellTemplate = { readonly container: HTMLElement; @@ -72,7 +72,7 @@ type RemoteCellTemplate = { | { readonly column: 'name'; readonly label: HTMLElement; readonly count: HTMLElement; readonly progress: HTMLElement; readonly warning: HTMLElement; readonly description: HTMLElement; readonly links: HTMLElement; readonly actionContainer: HTMLElement; actionLink?: Link; actionProgress?: HTMLElement; link?: Link } | { readonly column: 'connection'; readonly label: HTMLElement; readonly warning: HTMLElement } | { readonly column: 'gutter'; readonly action: Action; readonly toolbar: WorkbenchToolBar } - | { readonly column: 'actions'; readonly action: Action; readonly rename: Action; readonly delete: Action; readonly documentation: Action; readonly toolbar: WorkbenchToolBar; actionLayout: string } + | { readonly column: 'actions'; readonly action: Action; readonly rename: Action; readonly copyAddress: Action; readonly locationPreference: Action; readonly updateServer: Action; readonly delete: Action; readonly documentation: Action; readonly toolbar: WorkbenchToolBar; actionLayout: string } ); export function getConnectionLabel(remote: ICustomizationRemote, connecting?: boolean): string { @@ -158,7 +158,22 @@ export class RemotesTableRenderer implements ITableRenderer { + if (template.row?.entry.type === 'remote') { + this.onAction(template.row.entry, 'copyAddress'); + } + })); + const locationPreference = disposables.add(new Action('remotes.locationPreference', '', ThemeIcon.asClassName(Codicon.serverProcess), true, async () => { + if (template.row?.entry.type === 'remote') { + this.onAction(template.row.entry, 'locationPreference'); + } + })); + const updateServer = disposables.add(new Action('remotes.updateServer', '', ThemeIcon.asClassName(Codicon.cloudDownload), true, async () => { + if (template.row?.entry.type === 'remote') { + this.onAction(template.row.entry, 'updateServer'); + } + })); + template = { ...base, column: this.column, action, rename, copyAddress, locationPreference, updateServer, delete: deleteAction, documentation, toolbar, actionLayout: '' }; } disposables.add(dom.addDisposableGenericMouseDownListener(container, event => dom.EventHelper.stop(event, true))); break; @@ -279,11 +294,18 @@ export class RemotesTableRenderer implements ITableRenderer new RemotesTableRenderer( column.id, (entry, action) => this.runRowAction(entry, action), actionHoverDelegate, @@ -268,9 +268,15 @@ export class RemotesWidget extends Disposable { } else if (action === 'connect') { void this.setConnected(entry.remote.id, !RemoteAgentHostConnectionStatus.isConnected(entry.remote.connectionStatus)); } else if (action === 'rename') { - void this.renameRemote(entry.remote.id); - } else if (action === 'delete' && entry.remote.kind === 'environment' && entry.remote.canRemove) { - void this.deleteEnvironment(entry.remote.id); + void this.manageRemote(entry.remote.id, 'rename'); + } else if (action === 'copyAddress') { + void this.copyAddress(entry.remote.id); + } else if (action === 'locationPreference' && (entry.remote.kind === 'tunnel' || entry.remote.kind === 'ssh')) { + void this.manageRemote(entry.remote.id, 'locationPreference'); + } else if (action === 'updateServer') { + void this.manageRemote(entry.remote.id, 'updateServer'); + } else if (action === 'delete' && (entry.remote.kind === 'environment' && entry.remote.canRemove || entry.remote.kind === 'tunnel' && entry.remote.canDelete)) { + void this.deleteRemote(entry.remote.id, entry.remote.kind); } } } @@ -474,14 +480,32 @@ export class RemotesWidget extends Disposable { this.layout(); } - private async renameRemote(id: string): Promise { + private async copyAddress(id: string): Promise { + try { + await this.model.copyAddress(id); + } catch (error) { + if (!isCancellationError(error)) { + this.notificationService.error(error); + } + } + } + + private async manageRemote(id: string, action: 'rename' | 'locationPreference' | 'updateServer'): Promise { if (this.pendingActions.has(id) || this.pendingConnections.has(id)) { return; } + const focusedAction = dom.getActiveElement(); + const restoreFocus = dom.isHTMLElement(focusedAction) && this.domNode.contains(focusedAction) && focusedAction.closest('[data-remote-id]')?.dataset.remoteId === id; this.pendingActions.add(id); this.render(); try { - await this.model.rename(id); + if (action === 'locationPreference') { + await this.model.changePreferredAgentLocation(id); + } else if (action === 'updateServer') { + await this.model.updateServer(id); + } else { + await this.model.rename(id); + } } catch (error) { if (!isCancellationError(error)) { this.notificationService.error(error); @@ -493,7 +517,8 @@ export class RemotesWidget extends Disposable { const active = dom.getActiveElement(); const activeRow = dom.isHTMLElement(active) && this.domNode.contains(active) ? active.closest('[data-remote-id]') : undefined; const index = this.rows.findIndex(row => row.id === id); - if (activeRow && activeRow.dataset.remoteId !== id && index >= 0) { + if (index >= 0 && (activeRow && activeRow.dataset.remoteId !== id + || restoreFocus && !focusedAction?.isConnected && active === this.domNode.ownerDocument.body)) { this.table.setFocus([index]); this.table.domFocus(); } @@ -501,7 +526,7 @@ export class RemotesWidget extends Disposable { } } - private async deleteEnvironment(id: string): Promise { + private async deleteRemote(id: string, kind: 'environment' | 'tunnel'): Promise { if (this.pendingActions.has(id) || this.pendingConnections.has(id)) { return; } @@ -510,7 +535,11 @@ export class RemotesWidget extends Disposable { this.pendingActions.add(id); this.render(); try { - await this.model.remove(id); + if (kind === 'tunnel') { + await this.model.deleteTunnel(id); + } else { + await this.model.remove(id); + } } catch (error) { if (!isCancellationError(error)) { this.notificationService.error(error); diff --git a/src/vs/sessions/contrib/providers/remoteAgentHost/electron-browser/remotes.contribution.ts b/src/vs/sessions/contrib/providers/remoteAgentHost/electron-browser/remotes.contribution.ts index 820899f296bfc7..7726b069760536 100644 --- a/src/vs/sessions/contrib/providers/remoteAgentHost/electron-browser/remotes.contribution.ts +++ b/src/vs/sessions/contrib/providers/remoteAgentHost/electron-browser/remotes.contribution.ts @@ -94,7 +94,7 @@ class RemotesAccessibleView implements IAccessibleViewImplementation { localize('remotes.help.navigation', "Type in Search to filter remotes by name, address, or connection type. Clear Search and Collapse All are at the right of the search field; use Tab to reach these actions and Left and Right Arrow to move between them. Clear Search returns focus to the input. Collapse All preserves the search text. Groups without matches are hidden while searching. Use Down Arrow from Search to enter the table. Use Up and Down Arrow to navigate rows. On a group, use Left and Right Arrow to collapse or expand it, or press Enter to toggle it. Visibility icons are always shown; other row actions appear on hover or keyboard focus. On a remote, press Tab to reach its actions and use Left and Right Arrow to move between Connect or Disconnect and Rename. Use Enter or Space to activate an action. Resize columns by dragging their boundaries. Narrow windows scroll horizontally."), localize('remotes.help.connection', "Connection labels include Connected, Connecting, and Disconnected. A warning indicates that a connection you requested failed. Hover the warning or open the Accessible View to read the error. Automatic connection failures do not create a warning, but automatic retries retain an existing warning until they finish. A failed retry updates the error; a successful retry clears it. Environments whose owning application is not online show Offline. Use Connect or Disconnect to manage the connection. Connection actions are unavailable while connecting or reconnecting. Disconnect follows the existing remote behavior and may remove it from the picker."), localize('remotes.help.visibility', "Use the eye action at the start of an eligible remote row to show or hide it in the Environment and workspace pickers. Focus the row and press Tab, then Enter or Space to toggle it. Hidden remotes remain in this editor with a dimmed name and a Show action. Remotes that cannot currently appear have a disabled unavailable indicator instead of an eye; hover it or open the Accessible View for the reason. Newly discovered remotes must first be connected, and offline shared environments must come online. Your hidden preference is retained while a remote is unavailable. Choosing a folder through a remote subpicker shows that remote again. Hiding a remote does not prevent sending an existing draft when the remote is available and connected. This preference is local to your profile on this machine and does not connect, disconnect, or remove sessions."), - localize('remotes.help.manage', "Rename changes the remote's display name in this client. Leave the name empty to restore its default. Environment rows also offer Delete after Rename. Use the arrow keys to reach it in the row actions. Other remote types do not offer Delete; manage their SSH configuration, WSL distribution, or tunnel at the source."), + localize('remotes.help.manage', "Rename changes the remote's display name in this client. Leave the name empty to restore its default. Copy Address copies a remote's connection address without connecting, including while it is offline or busy. SSH and tunnel rows offer Change Preferred Agent Location, which uses the existing choice of a dedicated process or remote editor window and reconnects after saving. Cancelling leaves the preference unchanged. Incompatible hosts offer Update Server when they advertise an update method. This reports progress and reconnects after the server restarts. Environment and supported tunnel rows also offer Delete with confirmation. Deleting a tunnel removes its service-side registration and disconnects its clients, without deleting remote files. A tunnel currently hosted by this application must be stopped before it can be deleted. A deleted tunnel may reappear if a machine starts hosting it again. Use the arrow keys to reach these row actions. Other remote types do not offer Delete; manage their SSH configuration or WSL distribution at the source."), localize('remotes.help.discovery', "Refresh looks for remotes without connecting. Its icon spins while discovery is running. Each group also shows a spinner after its count while that group's discovery is running. A failed group immediately shows a warning instead of its spinner, even if other groups are still loading. The tooltip and group label explain the problem; the Window log contains diagnostic details. If tunnel sign-in permissions are missing, a Sign In action appears instead of an error. Focus that row and press Tab, then Enter or Space to sign in and grant access to your tunnels. Sign In is disabled with a spinner while sign-in is in progress. This refreshes the list without connecting to a remote. Known locations remain available, and Refresh retries discovery."), localize('remotes.help.setup', "Empty groups show setup guidance and a documentation link. Focus the placeholder row and press Tab to reach its link. Groups with documentation also have an info action that opens the same guide, even when they contain remotes or are collapsed. Focus the group and use Tab and the arrow keys to reach its actions. The SSH settings action opens your user SSH config in a modal editor and creates it if necessary. The Tunnels group has an Open Tunnel Settings action. After configuring a host, return to Remotes and choose Refresh."), ].join('\n\n') : editor.getAccessibilityContent(), diff --git a/src/vs/sessions/contrib/providers/remoteAgentHost/test/browser/remotesModel.test.ts b/src/vs/sessions/contrib/providers/remoteAgentHost/test/browser/remotesModel.test.ts index 0416d9ce0cc302..3d2ecb56682bf1 100644 --- a/src/vs/sessions/contrib/providers/remoteAgentHost/test/browser/remotesModel.test.ts +++ b/src/vs/sessions/contrib/providers/remoteAgentHost/test/browser/remotesModel.test.ts @@ -13,18 +13,29 @@ import { isWindows } from '../../../../../../base/common/platform.js'; import { URI } from '../../../../../../base/common/uri.js'; import { mock } from '../../../../../../base/test/common/mock.js'; import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../../base/test/common/utils.js'; +import { runWithFakedTimers } from '../../../../../../base/test/common/virtualScheduling/index.js'; import { IDiscoveredDevContainer } from '../../../../../../platform/agentHost/common/devContainerAgentHost.js'; import { cloudSandboxAddress } from '../../../../../../platform/agentHost/common/cloudSandboxAgentHost.js'; import { GitHubEnvironmentsEnabledSettingId, IMissionControlAvailabilityService, IMissionControlEnvironmentService, IMissionControlHost } from '../../../../../../platform/agentHost/common/missionControlEnvironment.js'; import { addSSHRemoteAgentHostEntry, IRemoteAgentHostConnectionInfo, IRemoteAgentHostService, readSSHRemoteAgentHostEntries, removeSSHRemoteAgentHostEntry, RemoteAgentHostConnectionStatus, RemoteAgentHostEntryType, RemoteAgentHostsEnabledSettingId } from '../../../../../../platform/agentHost/common/remoteAgentHostService.js'; import { ISSHRemoteAgentHostService } from '../../../../../../platform/agentHost/common/sshRemoteAgentHost.js'; import { ICachedTunnel, ITunnelAgentHostService, ITunnelInfo, TunnelAuthenticationRequiredError } from '../../../../../../platform/agentHost/common/tunnelAgentHost.js'; +import { IRemoteAgentHostLocationPreferenceService, RemoteAgentHostLocationPreference } from '../../../../../../platform/agentHost/common/remoteAgentHostLocationPreference.js'; +import { IVscodeUpgradeResult } from '../../../../../../platform/agentHost/common/state/protocolUpgrade.js'; +import { IClipboardService } from '../../../../../../platform/clipboard/common/clipboardService.js'; import { IWSLCachedDistro, IWSLRemoteAgentHostService } from '../../../../../../platform/agentHost/common/wslRemoteAgentHost.js'; import { ConfigurationTarget, IConfigurationService } from '../../../../../../platform/configuration/common/configuration.js'; import { TestConfigurationService } from '../../../../../../platform/configuration/test/common/testConfigurationService.js'; import { IConfirmation, IConfirmationResult, IDialogService } from '../../../../../../platform/dialogs/common/dialogs.js'; +import { TestDialogService } from '../../../../../../platform/dialogs/test/common/testDialogService.js'; import { TestInstantiationService } from '../../../../../../platform/instantiation/test/common/instantiationServiceMock.js'; import { ILogService, NullLogService } from '../../../../../../platform/log/common/log.js'; +import { INotification, INotificationService } from '../../../../../../platform/notification/common/notification.js'; +import { TestNotificationService } from '../../../../../../platform/notification/test/common/testNotificationService.js'; +import { IProductService } from '../../../../../../platform/product/common/productService.js'; +import { IProgressService } from '../../../../../../platform/progress/common/progress.js'; +import { IRemoteTunnelService, TunnelStatus } from '../../../../../../platform/remoteTunnel/common/remoteTunnel.js'; +import { TestProgressService } from '../../../../../../workbench/test/browser/workbenchTestServices.js'; import { IInputOptions, IQuickInputService } from '../../../../../../platform/quickinput/common/quickInput.js'; import { InMemoryStorageService, IStorageService } from '../../../../../../platform/storage/common/storage.js'; import { IAgentHostSessionsProvider } from '../../../../../common/agentHostSessionsProvider.js'; @@ -38,7 +49,7 @@ suite('RemotesModel', () => { const store = ensureNoDisposablesAreLeakedInTestSuite(); const tunnel: ITunnelInfo = { tunnelId: 'laptop', clusterId: 'eu', name: 'Laptop', protocolVersion: 6, tags: [], hostConnectionCount: 1 }; - function setup(discover: (token: CancellationToken) => Promise = async () => [{ id: 'container', name: 'Project', workspaceFolder: '/project' }], restoreProvidersDuringInitialization = false, initialConnections: readonly IRemoteAgentHostConnectionInfo[] = []) { + function setup(discover: (token: CancellationToken) => Promise = async () => [{ id: 'container', name: 'Project', workspaceFolder: '/project' }], restoreProvidersDuringInitialization = false, initialConnections: readonly IRemoteAgentHostConnectionInfo[] = [], initialTunnelStatus?: Promise) { const instantiationService = store.add(new TestInstantiationService()); const storage = store.add(new InMemoryStorageService()); const configuration = new TestConfigurationService({ @@ -51,6 +62,10 @@ suite('RemotesModel', () => { let workspaces: { workspaceUri: URI; name: string }[] = []; const providersChanged = store.add(new Emitter<{ added: []; removed: [] }>()); const connectionsChanged = store.add(new Emitter()); + const tunnelsChanged = store.add(new Emitter()); + const tunnelStatusChanged = store.add(new Emitter()); + const tunnelState: { canDelete: boolean; pendingDeletion?: DeferredPromise; failure?: Error } = { canDelete: true }; + const deletedTunnels: { tunnel: ITunnelInfo; authProvider?: 'github' | 'microsoft' }[] = []; let connections = initialConnections; let workspacesInitialized = false; let initializingWorkspaces = false; @@ -59,6 +74,7 @@ suite('RemotesModel', () => { const removals: string[] = []; const connectionChanges: { id: string; connected: boolean }[] = []; const reconnects: string[] = []; + const tunnelConnections: { tunnel: ITunnelInfo; authProvider?: 'github' | 'microsoft' }[] = []; const signIns: string[] = []; const displayNames = new Map(); const displayNamesChanged = store.add(new Emitter()); @@ -74,6 +90,12 @@ suite('RemotesModel', () => { pendingConfirmation: undefined as DeferredPromise | undefined, }; const confirmations: IConfirmation[] = []; + const copiedAddresses: string[] = []; + const locationPromptResult: { result: RemoteAgentHostLocationPreference | undefined } = { result: undefined }; + const locationPreferences = new Map(); + const notifications: INotification[] = []; + const upgrades: { address: string; method: string }[] = []; + const upgrade: { result: IVscodeUpgradeResult; failure?: Error } = { result: { ok: true, upgradeNeeded: false } }; const environmentConnections: string[] = []; const environmentNames: { id: string; name: string | undefined }[] = []; const environmentDeletions: string[] = []; @@ -93,12 +115,29 @@ suite('RemotesModel', () => { let tunnelAuthentication: () => Promise<'github' | 'microsoft' | undefined> = async () => 'github'; instantiationService.stub(IStorageService, storage); instantiationService.stub(IConfigurationService, configuration); - instantiationService.stub(IDialogService, { - confirm: async confirmation => { + instantiationService.stub(IDialogService, new class extends TestDialogService { + override async confirm(confirmation: IConfirmation) { confirmations.push(confirmation); return environmentState.pendingConfirmation?.p ?? { confirmed: true }; - }, + } + }({ confirmed: true }, locationPromptResult)); + instantiationService.stub(IClipboardService, { writeText: async text => { copiedAddresses.push(text); } }); + instantiationService.stub(IRemoteAgentHostLocationPreferenceService, { + getPreference: key => locationPreferences.get(key), + setPreference: (key, preference) => { locationPreferences.set(key, preference); }, + }); + instantiationService.stub(IProductService, { nameShort: 'Code - OSS' }); + instantiationService.stub(IProgressService, new TestProgressService()); + instantiationService.stub(IRemoteTunnelService, { + onDidChangeTunnelStatus: tunnelStatusChanged.event, + getTunnelStatus: () => initialTunnelStatus ?? Promise.resolve({ type: 'disconnected' }), }); + instantiationService.stub(INotificationService, new class extends TestNotificationService { + override notify(notification: INotification) { + notifications.push(notification); + return super.notify(notification); + } + }()); instantiationService.stub(ILogService, new class extends NullLogService { override warn(message: string, ...args: unknown[]) { logEntries.push({ message, args }); } }()); @@ -109,6 +148,13 @@ suite('RemotesModel', () => { getDisplayNameOverride: address => displayNames.get(address), setDisplayName, reconnect: address => { reconnects.push(address); }, + triggerServerUpgrade: async (address, method) => { + upgrades.push({ address, method }); + if (upgrade.failure) { + throw upgrade.failure; + } + return upgrade.result; + }, waitForConnection: async address => { if (connectionFailure) { throw connectionFailure; @@ -161,14 +207,27 @@ suite('RemotesModel', () => { cacheDistro: distro => { distros = [distro]; }, }); instantiationService.stub(ITunnelAgentHostService, { - onDidChangeTunnels: Event.None, + get canDeleteTunnels() { return tunnelState.canDelete; }, + deleteTunnel: async (tunnel, authProvider) => { + deletedTunnels.push({ tunnel, authProvider }); + await tunnelState.pendingDeletion?.p; + if (tunnelState.failure) { + throw tunnelState.failure; + } + tunnels = tunnels.filter(cached => cached.tunnelId !== tunnel.tunnelId); + tunnelsChanged.fire(); + }, + onDidChangeTunnels: tunnelsChanged.event, getCachedTunnels: () => tunnels, getAuthProvider: () => tunnelAuthentication(), listTunnels: () => tunnelDiscovery(), isTunnelDismissed: id => dismissed.has(id), clearTunnelDismissal: id => { dismissed.delete(id); }, cacheTunnel: (value, authProvider) => { tunnels = [{ ...value, authProvider }]; }, - connect: async value => { reconnects.push(`tunnel:${value.tunnelId}`); }, + connect: async (value, authProvider) => { + reconnects.push(`tunnel:${value.tunnelId}`); + tunnelConnections.push({ tunnel: value, authProvider }); + }, signIn: async provider => { signIns.push(provider); }, }); instantiationService.stub(IDevContainerAgentHostService, { @@ -189,9 +248,10 @@ suite('RemotesModel', () => { removeWorkspace: async () => { workspaces = []; removals.push('container'); }, }); const pickerVisibility = store.add(new RemoteAgentHostPickerVisibility(storage)); - const createProvider = (address: string) => new class extends mock() { + const createProvider = (address: string, preferenceKey?: string) => new class extends mock() { override readonly id = `agenthost-${address}`; override readonly remoteAddress = address; + override readonly remoteLocationPreferenceKey = preferenceKey; override readonly environment = { id: 'remote', label: 'Remote' }; override supportsWorkspaceSelection = true; override hostGroup: IAgentHostSessionsProvider['hostGroup']; @@ -241,8 +301,13 @@ suite('RemotesModel', () => { const model = store.add(instantiationService.createInstance(RemotesModel, discover)); return { model, storage, configuration, removals, logEntries, initializationReentered, providers, providersChanged, connectionChanges, reconnects, signIns, + copiedAddresses, locationPromptResult, locationPreferences, notifications, upgrades, upgrade, setConnections: (value: readonly IRemoteAgentHostConnectionInfo[]) => { connections = value; connectionsChanged.fire(); }, createProvider, setCachedDistros: (cached: IWSLCachedDistro[]) => { distros = cached; }, + getCachedTunnels: () => tunnels, + setCachedTunnels: (cached: ICachedTunnel[]) => { tunnels = cached; tunnelsChanged.fire(); }, + tunnelConnections, tunnelState, deletedTunnels, + setTunnelStatus: (status: TunnelStatus) => tunnelStatusChanged.fire(status), failConnection: (error: Error) => { connectionFailure = error; }, renameInputs, setRenameInput: (value: string | undefined) => { renameInput = value; }, setDisplayName, getWSLDiscoveryCalls: () => wslDiscoveryCalls, setSSHDiscovery: (discovery: () => Promise) => { sshDiscovery = discovery; }, @@ -311,6 +376,221 @@ suite('RemotesModel', () => { }); }); + test('fresh tunnel discovery wins over cached and provider names without registering or connecting', async () => { + const { model, setCachedTunnels, getCachedTunnels, setTunnelDiscovery, providersChanged, connectionChanges, reconnects } = setup(); + const cached: ICachedTunnel[] = [ + { ...tunnel, name: 'Old cached name', authProvider: 'github' }, + { ...tunnel, tunnelId: 'offline', name: 'Cached offline', authProvider: 'github' }, + ]; + setCachedTunnels(cached); + setTunnelDiscovery(async () => [ + { ...tunnel, name: 'Renamed tunnel' }, + { ...tunnel, tunnelId: 'new', name: 'New tunnel' }, + ]); + await model.refresh(CancellationToken.None); + const snapshot = () => model.remotes.get().filter(remote => remote.kind === 'tunnel').map(({ id, name, canRemove }) => ({ id, name, canRemove })); + const refreshed = snapshot(); + providersChanged.fire({ added: [], removed: [] }); + setCachedTunnels([...cached]); + assert.deepStrictEqual({ refreshed, afterEvents: snapshot(), cached: getCachedTunnels(), connectionChanges, reconnects }, { + refreshed: [ + { id: 'tunnel:offline', name: 'Cached offline', canRemove: false }, + { id: 'tunnel:new', name: 'New tunnel', canRemove: false }, + { id: 'tunnel:laptop', name: 'Renamed tunnel', canRemove: true }, + ], + afterEvents: refreshed, cached, connectionChanges: [], reconnects: [], + }); + }); + + test('explicit tunnel rename overrides discovery and clearing it restores the latest discovered name', async () => { + const { model, setCachedTunnels, setTunnelDiscovery, setRenameInput, renameInputs } = setup(); + setCachedTunnels([{ ...tunnel, name: 'Old cached name' }]); + setTunnelDiscovery(async () => [{ ...tunnel, name: 'Discovered name' }]); + await model.refresh(CancellationToken.None); + setRenameInput('My tunnel'); + await model.rename('tunnel:laptop'); + setTunnelDiscovery(async () => [{ ...tunnel, name: 'Newest discovered name' }]); + await model.refresh(CancellationToken.None); + const renamed = model.remotes.get().find(remote => remote.id === 'tunnel:laptop')!.name; + setRenameInput(''); + await model.rename('tunnel:laptop'); + assert.deepStrictEqual({ renamed, cleared: model.remotes.get().find(remote => remote.id === 'tunnel:laptop')!.name, initialRenameValue: renameInputs[0].value }, { + renamed: 'My tunnel', cleared: 'Newest discovered name', initialRenameValue: 'Discovered name', + }); + }); + + test('connecting a cached tunnel uses fresh discovery metadata when the provider is absent', async () => { + const { model, providers, setCachedTunnels, setTunnelDiscovery, setTunnelAuthentication, tunnelConnections } = setup(); + providers.delete('tunnel:laptop'); + setCachedTunnels([{ ...tunnel, name: 'Old name', protocolVersion: 5, clusterId: 'old', authProvider: 'microsoft' }]); + const discovered = { ...tunnel, name: 'New name', tags: ['updated'], clusterId: 'new' }; + setTunnelDiscovery(async () => [discovered]); + setTunnelAuthentication(async () => 'github'); + await model.refresh(CancellationToken.None); + await model.setConnected('tunnel:laptop', true); + assert.deepStrictEqual(tunnelConnections, [{ tunnel: discovered, authProvider: 'github' }]); + }); + + test('missing discovery falls back to live cache updates while failed and canceled discovery retain the snapshot', async () => { + const { model, setCachedTunnels, setTunnelDiscovery } = setup(); + setCachedTunnels([{ ...tunnel, name: 'Cached name' }]); + setTunnelDiscovery(async () => [{ ...tunnel, name: 'Discovered name' }]); + await model.refresh(CancellationToken.None); + const name = () => model.remotes.get().find(remote => remote.id === 'tunnel:laptop')!.name; + setTunnelDiscovery(async () => { throw new Error('Discovery failed'); }); + await model.refresh(CancellationToken.None); + setCachedTunnels([{ ...tunnel, name: 'Updated cache' }]); + const afterFailure = name(); + const pending = new DeferredPromise(); + setTunnelDiscovery(() => pending.p); + const cancellation = store.add(new CancellationTokenSource()); + const refresh = model.refresh(cancellation.token); + cancellation.cancel(); + await pending.complete([]); + await refresh; + const afterCancellation = name(); + setTunnelDiscovery(async () => []); + await model.refresh(CancellationToken.None); + const fallback = name(); + setCachedTunnels([{ ...tunnel, name: 'Latest cache' }]); + assert.deepStrictEqual({ afterFailure, afterCancellation, fallback, cacheUpdate: name() }, { + afterFailure: 'Discovered name', afterCancellation: 'Discovered name', fallback: 'Updated cache', cacheUpdate: 'Latest cache', + }); + }); + + test('copies each remote address without connecting or copying its display name', async () => { + const { model, configuration, environmentAvailable, environmentHosts, copiedAddresses, connectionChanges, reconnects, setDisplayName } = setup(); + await configuration.setUserConfiguration(GitHubEnvironmentsEnabledSettingId, true); + environmentAvailable.set(true, undefined); + environmentHosts.set([{ id: 'desktop', name: 'Friendly desktop', kind: 'user-local', status: 'offline' }], undefined); + await model.refresh(CancellationToken.None); + setDisplayName('ssh:build', 'Renamed SSH'); + const addresses = model.remotes.get().map(remote => remote.id); + for (const address of addresses) { + await model.copyAddress(address); + } + assert.deepStrictEqual({ copiedAddresses, connectionChanges, reconnects }, { + copiedAddresses: ['ssh:build', 'wsl:Ubuntu', 'tunnel:laptop', 'cloudsandbox:desktop'], connectionChanges: [], reconnects: [], + }); + }); + + for (const id of ['ssh:build', 'tunnel:laptop']) { + for (const preference of [undefined, 'editor', 'dedicated'] as const) { + test(`${id} location action reuses preference persistence and reconnect (${preference})`, async () => { + const { model, locationPromptResult, locationPreferences, connectionChanges } = setup(); + await model.refresh(CancellationToken.None); + locationPromptResult.result = preference; + await model.changePreferredAgentLocation(id); + assert.deepStrictEqual({ preferences: [...locationPreferences], connectionChanges }, { + preferences: preference ? [[id, preference]] : [], + connectionChanges: preference ? [{ id, connected: true }] : [], + }); + }); + } + } + + for (const sshConfigHost of ['remote', undefined]) { + test(`SSH location uses a stable ${sshConfigHost ? 'alias' : 'credential'} key, including without a provider`, async () => { + const { model, storage, providers, providersChanged, createProvider, locationPromptResult, locationPreferences, connectionChanges } = setup(); + const address = 'localhost:4321'; + addSSHRemoteAgentHostEntry(storage, { + name: 'Forwarded SSH', connection: { + type: RemoteAgentHostEntryType.SSH, address, sshConfigHost, + hostName: 'remote.example.com', user: 'user', port: 2222, + }, + }); + const key = sshConfigHost ? 'ssh:remote' : 'user@remote.example.com:2222'; + providers.set(address, createProvider(address, key)); + providersChanged.fire({ added: [], removed: [] }); + locationPromptResult.result = 'editor'; + await model.changePreferredAgentLocation(address); + providers.delete(address); + providersChanged.fire({ added: [], removed: [] }); + locationPromptResult.result = 'dedicated'; + await model.changePreferredAgentLocation(address); + assert.deepStrictEqual({ preferences: [...locationPreferences], connectionChanges }, { + preferences: [[key, 'dedicated']], connectionChanges: [{ id: address, connected: true }], + }); + }); + } + + test('Update Server uses the advertised method and waits before reconnecting', () => runWithFakedTimers({ useFakeTimers: true }, async () => { + const { model, providers, upgrade, upgrades, connectionChanges } = setup(); + await model.refresh(CancellationToken.None); + providers.get('tunnel:laptop')!.connectionStatus.set(RemoteAgentHostConnectionStatus.incompatible('Update required', ['1'], ['2'], 'vendor/update'), undefined); + upgrade.result = { ok: true, upgradeStarted: true, restartDelayMs: 1000 }; + const start = Date.now(); + await model.updateServer('tunnel:laptop'); + assert.deepStrictEqual({ upgrades, connectionChanges, elapsed: Date.now() - start }, { + upgrades: [{ address: 'tunnel:laptop', method: 'vendor/update' }], + connectionChanges: [{ id: 'tunnel:laptop', connected: true }], elapsed: 3000, + }); + })); + + test('Update Server preserves already-current and failure notifications', async () => { + const { model, providers, upgrade, upgrades, notifications, connectionChanges } = setup(); + await model.refresh(CancellationToken.None); + providers.get('tunnel:laptop')!.connectionStatus.set(RemoteAgentHostConnectionStatus.incompatible('Update required', ['1'], undefined, 'vendor/update'), undefined); + await model.updateServer('tunnel:laptop'); + upgrade.result = { ok: false, error: 'Download failed' }; + await model.updateServer('tunnel:laptop'); + upgrade.failure = new Error('Connection lost'); + await model.updateServer('tunnel:laptop'); + assert.deepStrictEqual({ calls: upgrades.length, messages: notifications.map(notification => notification.message), connectionChanges }, { + calls: 3, messages: ['Laptop is already on the latest version.', 'Failed to update Laptop: Download failed', 'Failed to update Laptop: Connection lost'], connectionChanges: [], + }); + }); + + test('Update Server rejects unsupported, stale, removed, disabled and disposed targets', async () => { + const { model, providers, setConfiguration, upgrades } = setup(); + await model.refresh(CancellationToken.None); + const provider = providers.get('tunnel:laptop')!; + for (const status of [RemoteAgentHostConnectionStatus.disconnected, RemoteAgentHostConnectionStatus.incompatible('Too old', ['1'])]) { + provider.connectionStatus.set(status, undefined); + await assert.rejects(model.updateServer('tunnel:laptop'), /does not support updating/); + } + provider.connectionStatus.set(RemoteAgentHostConnectionStatus.incompatible('Too old', ['1'], undefined, 'vendor/update'), undefined); + provider.connectionStatus.set(RemoteAgentHostConnectionStatus.connected, undefined); + await assert.rejects(model.updateServer('tunnel:laptop'), /does not support updating/); + await assert.rejects(model.updateServer('missing'), /no longer available/); + providers.delete('tunnel:laptop'); + await assert.rejects(model.updateServer('tunnel:laptop'), /no longer available/); + providers.set('tunnel:laptop', provider); + provider.connectionStatus.set(RemoteAgentHostConnectionStatus.incompatible('Too old', ['1'], undefined, 'vendor/update'), undefined); + await setConfiguration(RemoteAgentHostsEnabledSettingId, false); + await assert.rejects(model.updateServer('tunnel:laptop'), /no longer available/); + await setConfiguration(RemoteAgentHostsEnabledSettingId, true); + model.dispose(); + await assert.rejects(model.updateServer('tunnel:laptop'), /no longer available/); + assert.deepStrictEqual(upgrades, []); + }); + + test('tunnel location saves without a provider and reports reconnect failures', async () => { + const { model, providers, locationPromptResult, locationPreferences, notifications, failConnection } = setup(); + await model.refresh(CancellationToken.None); + locationPromptResult.result = 'editor'; + failConnection(new Error('Connection refused')); + await model.changePreferredAgentLocation('tunnel:laptop'); + providers.delete('tunnel:laptop'); + await model.changePreferredAgentLocation('tunnel:laptop'); + assert.deepStrictEqual({ preferences: [...locationPreferences], messages: notifications.map(notification => notification.message) }, { + preferences: [['tunnel:laptop', 'editor']], + messages: ['Preference saved for Laptop, but reconnection failed: Connection refused', 'Preference saved for Laptop, but no active connection was found. This takes effect the next time it connects.'], + }); + }); + + test('new management actions reject absent, disabled and unsupported targets', async () => { + const { model, setConfiguration, copiedAddresses, locationPreferences } = setup(); + await model.refresh(CancellationToken.None); + await assert.rejects(model.changePreferredAgentLocation('wsl:Ubuntu'), /only be changed for SSH hosts and tunnels/); + await assert.rejects(model.copyAddress('missing'), /no longer available/); + await assert.rejects(model.changePreferredAgentLocation('missing'), /no longer available/); + await setConfiguration(RemoteAgentHostsEnabledSettingId, false); + await assert.rejects(model.copyAddress('tunnel:laptop'), /no longer available/); + await assert.rejects(model.changePreferredAgentLocation('tunnel:laptop'), /no longer available/); + assert.deepStrictEqual({ copiedAddresses, preferences: [...locationPreferences] }, { copiedAddresses: [], preferences: [] }); + }); + test('Remove forgets registered remotes and discovery can find them again', async () => { const { model, storage, removals } = setup(); await model.refresh(CancellationToken.None); @@ -851,6 +1131,145 @@ suite('RemotesModel', () => { }, { environmentDeletions: ['online', 'offline'], removals: [], remaining: [] }); }); + for (const confirmed of [true, false]) { + test(`tunnel deletion ${confirmed ? 'deletes the service-side registration' : 'cancels without deleting'} after confirmation`, async () => { + const { model, setTunnelDiscovery, setDisplayName, environmentState, confirmations, deletedTunnels, removals, connectionChanges } = setup(); + const offline = { ...tunnel, hostConnectionCount: 0 }; + setTunnelDiscovery(async () => [offline, { ...tunnel, tunnelId: 'other' }]); + await model.refresh(CancellationToken.None); + setDisplayName('tunnel:laptop', 'My test tunnel'); + environmentState.pendingConfirmation = new DeferredPromise(); + const deletion = model.deleteTunnel('tunnel:laptop'); + const before = { confirmations: [...confirmations], deletedTunnels: [...deletedTunnels] }; + await environmentState.pendingConfirmation.complete({ confirmed }); + await deletion; + assert.deepStrictEqual({ + before, deletedTunnels, removals, connectionChanges, + remaining: model.remotes.get().filter(remote => remote.kind === 'tunnel').map(remote => remote.id).sort(), + }, { + before: { + confirmations: [{ + type: 'warning', + message: 'Are you sure you want to delete dev tunnel \'My test tunnel\'?', + detail: 'This deletes the tunnel registration and disconnects its clients. Files on the remote machine will not be deleted. The tunnel may be recreated if a machine starts hosting it again.', + primaryButton: '&&Delete', + }], + deletedTunnels: [], + }, + deletedTunnels: confirmed ? [{ tunnel: offline, authProvider: 'github' }] : [], + removals: [], connectionChanges: [], + remaining: confirmed ? ['tunnel:other'] : ['tunnel:laptop', 'tunnel:other'], + }); + }); + } + + test('failed tunnel deletion keeps the row and cache and can be retried', async () => { + const { model, setCachedTunnels, getCachedTunnels, tunnelState, deletedTunnels } = setup(); + setCachedTunnels([{ ...tunnel, authProvider: 'github' }]); + await model.refresh(CancellationToken.None); + tunnelState.failure = new Error('Forbidden'); + await assert.rejects(model.deleteTunnel('tunnel:laptop'), /Forbidden/); + const retained = { row: model.remotes.get().some(remote => remote.id === 'tunnel:laptop'), cached: getCachedTunnels().length }; + tunnelState.failure = undefined; + await model.deleteTunnel('tunnel:laptop'); + assert.deepStrictEqual({ retained, calls: deletedTunnels.length, row: model.remotes.get().some(remote => remote.id === 'tunnel:laptop'), cached: getCachedTunnels() }, { + retained: { row: true, cached: 1 }, calls: 2, row: false, cached: [], + }); + }); + + test('tunnel deletion rejects unsupported targets and protects the currently hosted tunnel by ID', async () => { + const { model, setTunnelStatus, tunnelState, deletedTunnels, confirmations, providersChanged } = setup(); + await model.refresh(CancellationToken.None); + await assert.rejects(model.deleteTunnel('ssh:build'), /no longer available/); + await assert.rejects(model.deleteTunnel('missing'), /no longer available/); + tunnelState.canDelete = false; + providersChanged.fire({ added: [], removed: [] }); + const unsupported = model.remotes.get().find(remote => remote.id === 'tunnel:laptop')!.canDelete; + await assert.rejects(model.deleteTunnel('tunnel:laptop'), /not supported/); + tunnelState.canDelete = true; + setTunnelStatus({ type: 'connected', info: { tunnelId: 'laptop', tunnelName: 'Different display name', isAttached: false }, serviceInstallFailed: false }); + const hosted = model.remotes.get().find(remote => remote.id === 'tunnel:laptop')!.canDelete; + await assert.rejects(model.deleteTunnel('tunnel:laptop'), /currently hosting/); + setTunnelStatus({ type: 'disconnected' }); + assert.deepStrictEqual({ unsupported, hosted, stopped: model.remotes.get().find(remote => remote.id === 'tunnel:laptop')!.canDelete, deletedTunnels, confirmations }, { + unsupported: false, hosted: false, stopped: true, deletedTunnels: [], confirmations: [], + }); + }); + + test('initial hosted tunnel status cannot overwrite a newer event', async () => { + const initialStatus = new DeferredPromise(); + const { model, setTunnelStatus } = setup(undefined, false, [], initialStatus.p); + await model.refresh(CancellationToken.None); + const loading = model.remotes.get().find(remote => remote.id === 'tunnel:laptop')!.canDelete; + await assert.rejects(model.deleteTunnel('tunnel:laptop'), /hosting status is not available/); + setTunnelStatus({ type: 'connected', info: { tunnelId: 'laptop', tunnelName: 'Laptop', isAttached: false }, serviceInstallFailed: false }); + await initialStatus.complete({ type: 'disconnected' }); + assert.deepStrictEqual({ loading, hosted: model.remotes.get().find(remote => remote.id === 'tunnel:laptop')!.canDelete }, { + loading: false, hosted: false, + }); + }); + + for (const change of ['inventory', 'configuration', 'AI', 'disposal', 'hosting', 'capability', 'authentication', 'cluster'] as const) { + test(`tunnel deletion revalidates ${change} after confirmation`, async () => { + const { model, setConfiguration, setTunnelDiscovery, setTunnelAuthentication, setTunnelStatus, tunnelState, environmentState, deletedTunnels } = setup(); + await model.refresh(CancellationToken.None); + environmentState.pendingConfirmation = new DeferredPromise(); + const deletion = model.deleteTunnel('tunnel:laptop'); + const rejected = assert.rejects(deletion, change === 'hosting' ? /currently hosting/ : change === 'capability' ? /not supported/ : isCancellationError); + switch (change) { + case 'inventory': + setTunnelDiscovery(async () => []); + await model.refresh(CancellationToken.None); + break; + case 'configuration': + await setConfiguration(RemoteAgentHostsEnabledSettingId, false); + break; + case 'AI': + await setConfiguration('chat.disableAIFeatures', true); + break; + case 'disposal': + model.dispose(); + break; + case 'hosting': + setTunnelStatus({ type: 'connected', info: { tunnelId: 'laptop', tunnelName: 'Laptop', isAttached: false }, serviceInstallFailed: false }); + break; + case 'capability': + tunnelState.canDelete = false; + break; + case 'authentication': + setTunnelAuthentication(async () => 'microsoft'); + await model.refresh(CancellationToken.None); + break; + case 'cluster': + setTunnelDiscovery(async () => [{ ...tunnel, clusterId: 'other' }]); + await model.refresh(CancellationToken.None); + break; + } + await environmentState.pendingConfirmation.complete({ confirmed: true }); + await rejected; + assert.deepStrictEqual(deletedTunnels, []); + }); + } + + test('an in-flight refresh cannot restore a deleted tunnel but a later discovery can', async () => { + const { model, setCachedTunnels, setTunnelDiscovery, deletedTunnels } = setup(); + setCachedTunnels([{ ...tunnel, authProvider: 'github' }]); + await model.refresh(CancellationToken.None); + const pendingDiscovery = new DeferredPromise(); + setTunnelDiscovery(() => pendingDiscovery.p); + const refresh = model.refresh(CancellationToken.None); + await model.deleteTunnel('tunnel:laptop'); + await pendingDiscovery.complete([tunnel]); + await refresh; + const afterStaleRefresh = model.remotes.get().some(remote => remote.id === 'tunnel:laptop'); + await assert.rejects(model.deleteTunnel('tunnel:laptop'), /no longer available/); + setTunnelDiscovery(async () => [{ ...tunnel, name: 'Recreated tunnel' }]); + await model.refresh(CancellationToken.None); + assert.deepStrictEqual({ afterStaleRefresh, recreated: model.remotes.get().find(remote => remote.id === 'tunnel:laptop')!.name, deletions: deletedTunnels.length }, { + afterStaleRefresh: false, recreated: 'Recreated tunnel', deletions: 1, + }); + }); + for (const confirmed of [true, false]) { test(`environment deletion waits for confirmation and ${confirmed ? 'deletes only the chosen host' : 'preserves inventory on cancellation'}`, async () => { const { model, configuration, environmentAvailable, environmentHosts, environmentState, environmentDeletions, confirmations } = setup(); diff --git a/src/vs/sessions/contrib/providers/remoteAgentHost/test/browser/remotesWidget.fixture.ts b/src/vs/sessions/contrib/providers/remoteAgentHost/test/browser/remotesWidget.fixture.ts index 70752e5381891d..424e215316b8f4 100644 --- a/src/vs/sessions/contrib/providers/remoteAgentHost/test/browser/remotesWidget.fixture.ts +++ b/src/vs/sessions/contrib/providers/remoteAgentHost/test/browser/remotesWidget.fixture.ts @@ -13,6 +13,9 @@ import { URI } from '../../../../../../base/common/uri.js'; import { mock } from '../../../../../../base/test/common/mock.js'; import { ISSHRemoteAgentHostService } from '../../../../../../platform/agentHost/common/sshRemoteAgentHost.js'; import { RemoteAgentHostConnectionStatus } from '../../../../../../platform/agentHost/common/remoteAgentHostService.js'; +import { IClipboardService } from '../../../../../../platform/clipboard/common/clipboardService.js'; +import { IDialogService } from '../../../../../../platform/dialogs/common/dialogs.js'; +import { promptRemoteAgentHostLocationPreference } from '../../../../../../platform/agentHost/common/remoteAgentHostLocationPreferenceDialog.js'; import { IHoverService } from '../../../../../../platform/hover/browser/hover.js'; import { HoverService } from '../../../../../../platform/hover/browser/hoverService.js'; import { ILayoutService } from '../../../../../../platform/layout/browser/layoutService.js'; @@ -24,14 +27,15 @@ import { IProgress } from '../../../../../../platform/progress/common/progress.j import { ComponentFixtureContext, createEditorServices, defineComponentFixture, defineThemedFixtureGroup, registerWorkbenchServices, waitForFixtureCondition } from '../../../../../../workbench/test/browser/componentFixtures/fixtureUtils.js'; import { ICustomizationRemote, IRemoteRefreshProgress, IRemotesModel, RemoteKind } from '../../browser/remotesModel.js'; import { RemotesWidget } from '../../browser/remotesWidget.js'; +import { confirmTunnelDeletion } from '../../browser/remoteHostOptions.js'; const remotes: ICustomizationRemote[] = [ { id: 'ssh:build', kind: 'ssh', name: 'Build server', detail: 'build.example.com', canRemove: true, connectionStatus: RemoteAgentHostConnectionStatus.connected }, { id: 'ssh:staging', kind: 'ssh', name: 'Staging', detail: 'staging.example.com', canRemove: false, hidden: true, connectionStatus: RemoteAgentHostConnectionStatus.disconnected }, { id: 'wsl:Ubuntu', kind: 'wsl', name: 'Ubuntu', detail: 'Ubuntu-24.04', canRemove: true, connectionStatus: RemoteAgentHostConnectionStatus.connecting }, { id: 'wsl:Debian', kind: 'wsl', name: 'Debian', detail: 'Debian', canRemove: false, connectionStatus: RemoteAgentHostConnectionStatus.disconnected, pickerUnavailableReason: 'Connect once to add this remote to the picker.' }, - { id: 'tunnel:laptop', kind: 'tunnel', name: 'Work laptop', detail: 'work-laptop-europe', canRemove: true, connectionStatus: RemoteAgentHostConnectionStatus.reconnecting }, - { id: 'tunnel:home', kind: 'tunnel', name: 'Home workstation', detail: 'home-workstation', canRemove: true, connectionStatus: RemoteAgentHostConnectionStatus.incompatible('The remote host needs an update.', ['1']) }, + { id: 'tunnel:laptop', kind: 'tunnel', name: 'Work laptop', detail: 'work-laptop-europe', canRemove: true, canDelete: true, connectionStatus: RemoteAgentHostConnectionStatus.reconnecting }, + { id: 'tunnel:home', kind: 'tunnel', name: 'Home workstation', detail: 'home-workstation', canRemove: true, canDelete: true, connectionStatus: RemoteAgentHostConnectionStatus.incompatible('The remote host needs an update.', ['1'], undefined, 'vscode/upgrade') }, { id: 'environment:desktop', kind: 'environment', name: 'Shared desktop', detail: 'env-desktop', canRemove: true, connectionStatus: RemoteAgentHostConnectionStatus.connected }, { id: 'environment:laptop', kind: 'environment', name: 'Shared laptop', detail: 'env-laptop', canRemove: true, connectionStatus: RemoteAgentHostConnectionStatus.disconnected, unavailableReason: 'This environment is not online. Start its owning application before connecting.', pickerUnavailableReason: 'Available in the picker when the owning application is online.' }, { id: 'container:project', kind: 'devContainer', name: 'Project development container', detail: '/Users/developer/projects/example-project-with-a-long-source-folder-name', canRemove: true, connectionStatus: RemoteAgentHostConnectionStatus.connected }, @@ -100,6 +104,21 @@ async function render({ container, disposableStore, theme }: ComponentFixtureCon async rename(id: string) { this.remotes.set(this.remotes.get().map(remote => remote.id === id ? { ...remote, name: `${remote.name} (renamed)` } : remote), undefined); } + async copyAddress(id: string) { + await instantiationService.get(IClipboardService).writeText(id); + } + async changePreferredAgentLocation(id: string) { + await promptRemoteAgentHostLocationPreference(instantiationService.get(IDialogService), id, 'Code - OSS'); + } + async updateServer(id: string) { + await this.setConnected(id, true); + } + async deleteTunnel(id: string) { + const remote = this.remotes.get().find(remote => remote.id === id); + if (remote && await confirmTunnelDeletion(instantiationService.get(IDialogService), remote.name)) { + await this.remove(id); + } + } async remove(id: string) { this.remotes.set(this.remotes.get().filter(remote => remote.id !== id), undefined); } @@ -123,6 +142,15 @@ async function render({ container, disposableStore, theme }: ComponentFixtureCon } export default defineThemedFixtureGroup({ path: 'sessions/remotes/' }, { + InlineActions: defineComponentFixture({ + labels: { kind: 'screenshot' }, additionalThemes: ['darkHighContrast', 'lightHighContrast'], + render: async context => { + await render(context, 1000, OS, false, [], true); + for (const row of context.container.querySelectorAll('[data-remote-id]')) { + row.closest('.monaco-list-row')?.classList.add('hovered'); + } + }, + }), ConnectionError: defineComponentFixture({ labels: { kind: 'screenshot' }, additionalThemes: ['darkHighContrast', 'lightHighContrast'], render: context => render(context, 1000, OperatingSystem.Linux, false, [], false, undefined, false, { connectionError: 'connect ECONNREFUSED 127.0.0.1:43192' }), diff --git a/src/vs/sessions/contrib/providers/remoteAgentHost/test/browser/remotesWidget.test.ts b/src/vs/sessions/contrib/providers/remoteAgentHost/test/browser/remotesWidget.test.ts index 6edf47a27e0286..514ae0b91e7af3 100644 --- a/src/vs/sessions/contrib/providers/remoteAgentHost/test/browser/remotesWidget.test.ts +++ b/src/vs/sessions/contrib/providers/remoteAgentHost/test/browser/remotesWidget.test.ts @@ -43,12 +43,17 @@ class TestRemotesModel extends Disposable implements IRemotesModel { readonly remotes = observableValue(this, [ { id: 'ssh:build', kind: 'ssh', name: 'Build server', detail: 'build.example.com', canRemove: true, connectionStatus: RemoteAgentHostConnectionStatus.connected }, { id: 'wsl:Ubuntu', kind: 'wsl', name: 'Ubuntu', detail: 'Ubuntu', canRemove: true, connectionStatus: RemoteAgentHostConnectionStatus.disconnected }, - { id: 'tunnel:laptop', kind: 'tunnel', name: 'Laptop', detail: 'work-laptop', canRemove: false, connectionStatus: RemoteAgentHostConnectionStatus.disconnected }, + { id: 'tunnel:laptop', kind: 'tunnel', name: 'Laptop', detail: 'work-laptop', canRemove: false, canDelete: true, connectionStatus: RemoteAgentHostConnectionStatus.disconnected }, { id: 'container:project', kind: 'devContainer', name: 'Project', detail: '/project', canRemove: true, connectionStatus: RemoteAgentHostConnectionStatus.connected }, ]); readonly managementChanges: { id: string; action: 'rename' | 'remove' }[] = []; readonly connectionChanges: { id: string; connected: boolean }[] = []; readonly visibilityChanges: { id: string; hidden: boolean }[] = []; + readonly copiedAddresses: string[] = []; + readonly locationChanges: string[] = []; + readonly serverUpdates: string[] = []; + readonly tunnelDeletions: string[] = []; + copyFailure: Error | undefined; pendingConnection: DeferredPromise | undefined; pendingManagement: DeferredPromise | undefined; failedKinds: readonly RemoteKind[] = []; @@ -82,6 +87,30 @@ class TestRemotesModel extends Disposable implements IRemotesModel { this.remotes.set(this.remotes.get().map(remote => remote.id === id ? { ...remote, name: 'Renamed remote' } : remote).sort((a, b) => a.name.localeCompare(b.name)), undefined); } + async copyAddress(id: string): Promise { + if (this.copyFailure) { + throw this.copyFailure; + } + this.copiedAddresses.push(id); + } + + async changePreferredAgentLocation(id: string): Promise { + this.locationChanges.push(id); + await this.pendingManagement?.p; + } + + async updateServer(id: string): Promise { + this.serverUpdates.push(id); + await this.pendingManagement?.p; + this.remotes.set(this.remotes.get().map(remote => remote.id === id ? { ...remote, connectionStatus: RemoteAgentHostConnectionStatus.connected } : remote), undefined); + } + + async deleteTunnel(id: string): Promise { + this.tunnelDeletions.push(id); + await this.pendingManagement?.p; + this.remotes.set(this.remotes.get().filter(remote => remote.id !== id), undefined); + } + async remove(id: string): Promise { this.managementChanges.push({ id, action: 'remove' }); await this.pendingManagement?.p; @@ -220,7 +249,7 @@ suite('RemotesWidget', () => { rowHeight: row.getBoundingClientRect().height, groupHeight: parent.querySelector('.remotes-group-row')!.getBoundingClientRect().height, headerHeight: headers[1].getBoundingClientRect().height, - actionsWidth: headers[3].getBoundingClientRect().width === 72, + actionsWidth: headers[3].getBoundingClientRect().width === 144, sashes: parent.querySelectorAll('.monaco-sash.vertical').length > 0, }, { values: ['Build server', 'Connected'], aligned: true, @@ -517,7 +546,7 @@ suite('RemotesWidget', () => { }, { changes: [{ id: 'ssh:build', action: 'rename' }], sameControl: true, focused: true, name: 'Renamed remote' }); }); - test('only environment rows offer Delete, including after refresh', async () => { + test('environment and deletable tunnel rows offer Delete, including after refresh', async () => { const { parent, model, widget } = createWidget(OperatingSystem.Windows); model.environmentsEnabled.set(true, undefined); model.remotes.set([...model.remotes.get(), { @@ -530,10 +559,10 @@ suite('RemotesWidget', () => { widget.setVisible(true); await timeout(0); const expected = [ - ['Connect to Desktop', 'Rename Desktop...', 'Delete Desktop'], - ['Disconnect Build server', 'Rename Build server...'], - ['Connect to Laptop', 'Rename Laptop...'], - ['Connect to Ubuntu', 'Rename Ubuntu...'], + ['Connect to Desktop', 'Rename Desktop...', 'Copy Address of Desktop', 'Delete Desktop'], + ['Disconnect Build server', 'Rename Build server...', 'Copy Address of Build server', 'Change Preferred Agent Location for Build server'], + ['Connect to Laptop', 'Rename Laptop...', 'Copy Address of Laptop', 'Change Preferred Agent Location for Laptop', 'Delete Laptop'], + ['Connect to Ubuntu', 'Rename Ubuntu...', 'Copy Address of Ubuntu'], ]; assert.deepStrictEqual({ before, after: actions(), changes: model.managementChanges }, { before: expected, after: expected, changes: [] }); }); @@ -567,6 +596,174 @@ suite('RemotesWidget', () => { }, { busy: { deletion: 'true', rename: 'true' }, changes: [{ id: 'environment:desktop', action: 'remove' }], remaining: false, focused: true }); }); + test('tunnel Delete is keyboard accessible, distinct from Remove and guarded while pending', async () => { + const { parent, model } = createWidget(); + const row = parent.querySelector('[data-remote-id="tunnel:laptop"]')!; + const location = row.querySelector('.codicon-server-process')!; + location.focus(); + location.dispatchEvent(new KeyboardEvent('keyup', { key: 'Tab', keyCode: 9, bubbles: true })); + location.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowRight', keyCode: 39, bubbles: true, cancelable: true })); + const deletion = row.querySelector('.codicon-trash')!; + assert.strictEqual(dom.getActiveElement(), deletion); + model.pendingManagement = new DeferredPromise(); + for (const type of ['keydown', 'keyup']) { + deletion.dispatchEvent(new KeyboardEvent(type, { key: 'Enter', keyCode: 13, bubbles: true, cancelable: true })); + } + deletion.click(); + const disabled = deletion.getAttribute('aria-disabled'); + row.querySelector('.codicon-copy')!.click(); + await model.pendingManagement.complete(); + await timeout(0); + assert.deepStrictEqual({ + disabled, deleted: model.tunnelDeletions, removed: model.managementChanges, copied: model.copiedAddresses, + present: !!parent.querySelector('[data-remote-id="tunnel:laptop"]'), + focused: dom.getActiveElement() === parent.querySelector('.monaco-table .monaco-list'), + }, { disabled: 'true', deleted: ['tunnel:laptop'], removed: [], copied: ['tunnel:laptop'], present: false, focused: true }); + }); + + test('failed tunnel deletion reports the error and keeps Delete available for retry', async () => { + const { parent, model, errors } = createWidget(); + const deletion = parent.querySelector('[data-remote-id="tunnel:laptop"] .codicon-trash')!; + model.pendingManagement = new DeferredPromise(); + deletion.click(); + const error = new Error('Tunnel deletion forbidden'); + await model.pendingManagement.error(error); + await timeout(0); + assert.deepStrictEqual({ errors, enabled: deletion.getAttribute('aria-disabled'), deleted: model.tunnelDeletions }, { + errors: [error], enabled: null, deleted: ['tunnel:laptop'], + }); + }); + + test('tunnel Delete tracks deletion capability independently of local removal', () => { + const { parent, model } = createWidget(); + const remote = model.remotes.get().find(remote => remote.kind === 'tunnel')!; + const states = [false, true, false].map(canDelete => { + model.remotes.set([{ ...remote, canDelete }], undefined); + return !!parent.querySelector('[data-remote-id="tunnel:laptop"] .codicon-trash'); + }); + assert.deepStrictEqual(states, [false, true, false]); + }); + + test('Copy Address works on every visible remote without changing connections or focus', async () => { + const { parent, model } = createWidget(OperatingSystem.Windows); + model.environmentsEnabled.set(true, undefined); + model.remotes.set([...model.remotes.get(), { + id: 'environment:offline', name: 'Offline', kind: 'environment', detail: 'host', canRemove: true, + connectionStatus: RemoteAgentHostConnectionStatus.disconnected, unavailableReason: 'Offline', + }], undefined); + const buttons = [...parent.querySelectorAll('[data-remote-id] .remotes-actions-column .codicon-copy')]; + const ids = buttons.map(button => button.closest('[data-remote-id]')!.dataset.remoteId); + for (const button of buttons) { + button.focus(); + button.click(); + await timeout(0); + assert.strictEqual(dom.getActiveElement(), button); + } + assert.deepStrictEqual({ copied: model.copiedAddresses, connections: model.connectionChanges }, { copied: ids, connections: [] }); + }); + + for (const id of ['ssh:build', 'tunnel:laptop']) { + test(`${id} location action is keyboard accessible and guards duplicate requests while copying stays available`, async () => { + const { parent, model } = createWidget(); + const row = parent.querySelector(`[data-remote-id="${id}"]`)!; + const copy = row.querySelector('.codicon-copy')!; + const location = row.querySelector('.codicon-server-process')!; + copy.focus(); + copy.dispatchEvent(new KeyboardEvent('keyup', { key: 'Tab', keyCode: 9, bubbles: true })); + copy.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowRight', keyCode: 39, bubbles: true, cancelable: true })); + assert.strictEqual(dom.getActiveElement(), location); + model.pendingManagement = new DeferredPromise(); + for (const type of ['keydown', 'keyup']) { + location.dispatchEvent(new KeyboardEvent(type, { key: 'Enter', keyCode: 13, bubbles: true, cancelable: true })); + } + location.click(); + const busy = location.getAttribute('aria-disabled'); + copy.click(); + await model.pendingManagement.complete(); + await timeout(0); + assert.deepStrictEqual({ + busy, enabled: location.getAttribute('aria-disabled'), calls: model.locationChanges, copied: model.copiedAddresses, + }, { busy: 'true', enabled: null, calls: [id], copied: [id] }); + }); + } + + test('Update Server follows advertised capability as a row changes status', () => { + const { parent, model } = createWidget(); + const remote = model.remotes.get()[0]; + const statuses = [ + RemoteAgentHostConnectionStatus.disconnected, + RemoteAgentHostConnectionStatus.incompatible('Too old', ['1']), + RemoteAgentHostConnectionStatus.incompatible('Too old', ['1'], ['2'], 'vendor/update'), + RemoteAgentHostConnectionStatus.connecting, + RemoteAgentHostConnectionStatus.connected, + ]; + const labels = statuses.map(connectionStatus => { + model.remotes.set([{ ...remote, connectionStatus }], undefined); + return parent.querySelector('.remotes-actions-column .codicon-cloud-download')?.getAttribute('aria-label') ?? null; + }); + assert.deepStrictEqual(labels, [null, null, 'Update Server for Build server', null, null]); + }); + + test('Update Server supports keyboard activation, blocks duplicates and restores focus when it disappears', async () => { + const { parent, model } = createWidget(); + model.remotes.set([{ + ...model.remotes.get()[0], + connectionStatus: RemoteAgentHostConnectionStatus.incompatible('Too old', ['1'], undefined, 'vendor/update'), + }], undefined); + const location = parent.querySelector('.remotes-actions-column .codicon-server-process')!; + location.focus(); + location.dispatchEvent(new KeyboardEvent('keyup', { key: 'Tab', keyCode: 9, bubbles: true })); + location.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowRight', keyCode: 39, bubbles: true, cancelable: true })); + const update = parent.querySelector('.remotes-actions-column .codicon-cloud-download')!; + assert.strictEqual(dom.getActiveElement(), update); + model.pendingManagement = new DeferredPromise(); + for (const type of ['keydown', 'keyup']) { + update.dispatchEvent(new KeyboardEvent(type, { key: 'Enter', keyCode: 13, bubbles: true, cancelable: true })); + } + update.click(); + const busy = update.getAttribute('aria-disabled'); + parent.querySelector('.remotes-actions-column .codicon-copy')!.click(); + await model.pendingManagement.complete(); + await timeout(0); + assert.deepStrictEqual({ + busy, updates: model.serverUpdates, copies: model.copiedAddresses, + present: !!parent.querySelector('.remotes-actions-column .codicon-cloud-download'), + focused: dom.getActiveElement() === parent.querySelector('.monaco-table .monaco-list'), + }, { busy: 'true', updates: ['ssh:build'], copies: ['ssh:build'], present: false, focused: true }); + }); + + test('failed Update Server reports the error and re-enables the action', async () => { + const { parent, model, errors } = createWidget(); + model.remotes.set([{ + ...model.remotes.get()[0], + connectionStatus: RemoteAgentHostConnectionStatus.incompatible('Too old', ['1'], undefined, 'vendor/update'), + }], undefined); + const update = parent.querySelector('.remotes-actions-column .codicon-cloud-download')!; + model.pendingManagement = new DeferredPromise(); + update.click(); + const error = new Error('Cannot update server'); + await model.pendingManagement.error(error); + await timeout(0); + assert.deepStrictEqual({ errors, enabled: update.getAttribute('aria-disabled'), updates: model.serverUpdates }, { + errors: [error], enabled: null, updates: ['ssh:build'], + }); + }); + + test('new inline actions report failures and location changes re-enable for retry', async () => { + const { parent, model, errors } = createWidget(); + const row = parent.querySelector('[data-remote-id="tunnel:laptop"]')!; + const copyError = new Error('Clipboard unavailable'); + const locationError = new Error('Cannot save preference'); + model.copyFailure = copyError; + row.querySelector('.codicon-copy')!.click(); + model.pendingManagement = new DeferredPromise(); + const location = row.querySelector('.codicon-server-process')!; + location.click(); + await model.pendingManagement.error(locationError); + await timeout(0); + assert.deepStrictEqual({ errors, enabled: location.getAttribute('aria-disabled') }, { errors: [copyError, locationError], enabled: null }); + }); + test('failed Delete retains the row, reports the error and allows retry', async () => { const { parent, model, errors } = createWidget(); model.environmentsEnabled.set(true, undefined); @@ -1105,6 +1302,12 @@ suite('RemotesWidget', () => { } await timeout(0); rename.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowRight', keyCode: 39, bubbles: true, cancelable: true })); + const copy = parent.querySelector('[data-remote-id="ssh:build"] .codicon-copy')!; + assert.strictEqual(dom.getActiveElement(), copy); + copy.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowRight', keyCode: 39, bubbles: true, cancelable: true })); + const location = parent.querySelector('[data-remote-id="ssh:build"] .codicon-server-process')!; + assert.strictEqual(dom.getActiveElement(), location); + location.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowRight', keyCode: 39, bubbles: true, cancelable: true })); assert.deepStrictEqual({ changes: model.managementChanges, focused: dom.getActiveElement() === connection }, { changes: [{ id: 'ssh:build', action: 'rename' }], focused: true, }); @@ -1277,7 +1480,7 @@ suite('RemotesWidget', () => { link.click(); } assert.deepStrictEqual(documentation, Array.from({ length: 4 }, () => [ - 'https://aka.ms/vscode-agent-environments', 'https://aka.ms/vscode-agent-ssh', 'https://aka.ms/vscode-agent-tunnel', 'https://aka.ms/vscode-remote/wsl/install-wsl', + 'https://aka.ms/vscode-agent-environments', 'https://aka.ms/vscode-agent-ssh', 'https://aka.ms/vscode-agent-tunnel', 'https://aka.ms/vscode-agent-wsl', ]).flat()); }); @@ -1300,7 +1503,7 @@ suite('RemotesWidget', () => { documentation: [ 'https://aka.ms/vscode-agent-ssh', 'https://aka.ms/vscode-agent-tunnel', - 'https://aka.ms/vscode-remote/wsl/install-wsl', + 'https://aka.ms/vscode-agent-wsl', ], accessibleGuidance: true, }); diff --git a/src/vs/sessions/contrib/providers/remoteAgentHost/test/electron-browser/remotesEditor.test.ts b/src/vs/sessions/contrib/providers/remoteAgentHost/test/electron-browser/remotesEditor.test.ts index 78eaf119d6acd1..59fe97e6c523ae 100644 --- a/src/vs/sessions/contrib/providers/remoteAgentHost/test/electron-browser/remotesEditor.test.ts +++ b/src/vs/sessions/contrib/providers/remoteAgentHost/test/electron-browser/remotesEditor.test.ts @@ -55,6 +55,10 @@ class TestRemotesModel extends Disposable implements IRemotesModel { async setConnected(): Promise { } async rename(): Promise { } + async copyAddress(): Promise { } + async changePreferredAgentLocation(): Promise { } + async updateServer(): Promise { } + async deleteTunnel(): Promise { } async remove(): Promise { } setHidden(): void { }