diff --git a/build/lib/i18n.resources.json b/build/lib/i18n.resources.json index 024fa8f6b68..58617f9b13c 100644 --- a/build/lib/i18n.resources.json +++ b/build/lib/i18n.resources.json @@ -322,10 +322,6 @@ "name": "vs/workbench/contrib/bracketPairColorizer2Telemetry", "project": "vscode-workbench" }, - { - "name": "vs/workbench/contrib/offline", - "project": "vscode-workbench" - }, { "name": "vs/workbench/contrib/remoteTunnel", "project": "vscode-workbench" diff --git a/extensions/vscode-test-resolver/package.json b/extensions/vscode-test-resolver/package.json index 921bbba555c..d54d12ab527 100644 --- a/extensions/vscode-test-resolver/package.json +++ b/extensions/vscode-test-resolver/package.json @@ -95,6 +95,11 @@ "title": "Pause Connection (Test Reconnect)", "category": "Remote-TestResolver", "command": "vscode-testresolver.toggleConnectionPause" + }, + { + "title": "Slowdown Connection (Test Slow Down Indicator)", + "category": "Remote-TestResolver", + "command": "vscode-testresolver.toggleConnectionSlowdown" } ], "menus": { diff --git a/extensions/vscode-test-resolver/src/extension.ts b/extensions/vscode-test-resolver/src/extension.ts index 4178c2b823e..8e12e622e05 100644 --- a/extensions/vscode-test-resolver/src/extension.ts +++ b/extensions/vscode-test-resolver/src/extension.ts @@ -22,11 +22,25 @@ const enum CharCode { let outputChannel: vscode.OutputChannel; +const SLOWED_DOWN_CONNECTION_DELAY = 800; + export function activate(context: vscode.ExtensionContext) { let connectionPaused = false; const connectionPausedEvent = new vscode.EventEmitter(); + let connectionSlowedDown = false; + const connectionSlowedDownEvent = new vscode.EventEmitter(); + const slowedDownConnections = new Set(); + connectionSlowedDownEvent.event(slowed => { + if (!slowed) { + for (const cb of slowedDownConnections) { + cb(); + } + slowedDownConnections.clear(); + } + }); + function getTunnelFeatures(): vscode.TunnelInformation['tunnelFeatures'] { return { elevation: true, @@ -50,6 +64,22 @@ export function activate(context: vscode.ExtensionContext) { }; } + function maybeSlowdown(): Promise | void { + if (connectionSlowedDown) { + return new Promise(resolve => { + const handle = setTimeout(() => { + resolve(); + slowedDownConnections.delete(resolve); + }, SLOWED_DOWN_CONNECTION_DELAY); + + slowedDownConnections.add(() => { + resolve(); + clearTimeout(handle); + }); + }); + } + } + function doResolve(authority: string, progress: vscode.Progress<{ message?: string; increment?: number }>): Promise { if (connectionPaused) { throw vscode.RemoteAuthorityResolverError.TemporarilyNotAvailable('Not available right now'); @@ -237,13 +267,15 @@ export function activate(context: vscode.ExtensionContext) { connectionPausedEvent.event(_ => handleConnectionPause()); handleConnectionPause(); - proxySocket.on('data', (data) => { + proxySocket.on('data', async (data) => { + await maybeSlowdown(); remoteReady = remoteSocket.write(data); if (!remoteReady) { proxySocket.pause(); } }); - remoteSocket.on('data', (data) => { + remoteSocket.on('data', async (data) => { + await maybeSlowdown(); localReady = proxySocket.write(data); if (!localReady) { remoteSocket.pause(); @@ -358,6 +390,22 @@ export function activate(context: vscode.ExtensionContext) { connectionPausedEvent.fire(connectionPaused); })); + const slowdownStatusBarEntry = vscode.window.createStatusBarItem(vscode.StatusBarAlignment.Left); + slowdownStatusBarEntry.text = 'Remote connection slowed down. Click to undo'; + slowdownStatusBarEntry.command = 'vscode-testresolver.toggleConnectionSlowdown'; + slowdownStatusBarEntry.backgroundColor = new vscode.ThemeColor('statusBarItem.errorBackground'); + + context.subscriptions.push(vscode.commands.registerCommand('vscode-testresolver.toggleConnectionSlowdown', () => { + if (!connectionSlowedDown) { + connectionSlowedDown = true; + slowdownStatusBarEntry.show(); + } else { + connectionSlowedDown = false; + slowdownStatusBarEntry.hide(); + } + connectionSlowedDownEvent.fire(connectionSlowedDown); + })); + context.subscriptions.push(vscode.commands.registerCommand('vscode-testresolver.openTunnel', async () => { const result = await vscode.window.showInputBox({ prompt: 'Enter the remote port for the tunnel', diff --git a/src/vs/code/browser/workbench/workbench.ts b/src/vs/code/browser/workbench/workbench.ts index 8478d7ba947..62a0406d7f5 100644 --- a/src/vs/code/browser/workbench/workbench.ts +++ b/src/vs/code/browser/workbench/workbench.ts @@ -500,11 +500,10 @@ function doCreateUri(path: string, queryValues: Map): URI { // Create workbench create(document.body, { ...config, - settingsSyncOptions: config.settingsSyncOptions ? { - enabled: config.settingsSyncOptions.enabled, - } : undefined, + windowIndicator: config.windowIndicator ?? { label: '$(remote)', tooltip: `${product.nameShort} Web` }, + settingsSyncOptions: config.settingsSyncOptions ? { enabled: config.settingsSyncOptions.enabled, } : undefined, workspaceProvider: WorkspaceProvider.create(config), urlCallbackProvider: new LocalStorageURLCallbackProvider(config.callbackRoute), - credentialsProvider: config.remoteAuthority ? undefined : new LocalStorageCredentialsProvider() // with a remote, we don't use a local credentials provider + credentialsProvider: config.remoteAuthority ? undefined /* with a remote, we don't use a local credentials provider */ : new LocalStorageCredentialsProvider() }); })(); diff --git a/src/vs/code/electron-sandbox/issue/IssueReporterService.ts b/src/vs/code/electron-sandbox/issue/IssueReporterService.ts index d0c47890e4b..89fcc9d0e75 100644 --- a/src/vs/code/electron-sandbox/issue/IssueReporterService.ts +++ b/src/vs/code/electron-sandbox/issue/IssueReporterService.ts @@ -991,7 +991,7 @@ export class IssueReporter extends Disposable { const remoteDataTable = $('table', undefined, $('tr', undefined, $('td', undefined, 'Remote'), - $('td', undefined, remote.hostName) + $('td', undefined, remote.latency ? `${remote.hostName} (latency: ${remote.latency.current.toFixed(2)}ms last, ${remote.latency.average.toFixed(2)}ms average)` : remote.hostName) ), $('tr', undefined, $('td', undefined, 'OS'), diff --git a/src/vs/code/electron-sandbox/issue/issueReporterModel.ts b/src/vs/code/electron-sandbox/issue/issueReporterModel.ts index d886b1952e5..d72d5fcb24d 100644 --- a/src/vs/code/electron-sandbox/issue/issueReporterModel.ts +++ b/src/vs/code/electron-sandbox/issue/issueReporterModel.ts @@ -185,7 +185,7 @@ ${this.getInfos()} |Item|Value| |---|---| -|Remote|${remote.hostName}| +|Remote|${remote.latency ? `${remote.hostName} (latency: ${remote.latency.current.toFixed(2)}ms last, ${remote.latency.average.toFixed(2)}ms average)` : remote.hostName}| |OS|${remote.machineInfo.os}| |CPUs|${remote.machineInfo.cpus}| |Memory (System)|${remote.machineInfo.memory}| diff --git a/src/vs/platform/diagnostics/common/diagnostics.ts b/src/vs/platform/diagnostics/common/diagnostics.ts index 043ca044182..7d5af4f92da 100644 --- a/src/vs/platform/diagnostics/common/diagnostics.ts +++ b/src/vs/platform/diagnostics/common/diagnostics.ts @@ -52,6 +52,10 @@ export interface SystemInfo extends IMachineInfo { export interface IRemoteDiagnosticInfo extends IDiagnosticInfo { hostName: string; + latency?: { + current: number; + average: number; + }; } export interface IRemoteDiagnosticError { diff --git a/src/vs/workbench/contrib/offline/browser/offline.contribution.ts b/src/vs/workbench/contrib/offline/browser/offline.contribution.ts deleted file mode 100644 index 75b1671e327..00000000000 --- a/src/vs/workbench/contrib/offline/browser/offline.contribution.ts +++ /dev/null @@ -1,97 +0,0 @@ -/*--------------------------------------------------------------------------------------------- - * Copyright (c) Microsoft Corporation. All rights reserved. - * Licensed under the MIT License. See License.txt in the project root for license information. - *--------------------------------------------------------------------------------------------*/ - -import { LifecyclePhase } from 'vs/workbench/services/lifecycle/common/lifecycle'; -import { Registry } from 'vs/platform/registry/common/platform'; -import { Extensions, IWorkbenchContribution, IWorkbenchContributionsRegistry } from 'vs/workbench/common/contributions'; -import { registerColor } from 'vs/platform/theme/common/colorRegistry'; -import { IWorkspaceContextService } from 'vs/platform/workspace/common/workspace'; -import { STATUS_BAR_FOREGROUND, STATUS_BAR_BORDER } from 'vs/workbench/common/theme'; -import { IDebugService } from 'vs/workbench/contrib/debug/common/debug'; -import { localize } from 'vs/nls'; -import { combinedDisposable, DisposableStore, IDisposable } from 'vs/base/common/lifecycle'; -import { Event } from 'vs/base/common/event'; -import { DomEmitter } from 'vs/base/browser/event'; -import { IStatusbarService, StatusbarAlignment } from 'vs/workbench/services/statusbar/browser/statusbar'; - -export const STATUS_BAR_OFFLINE_BACKGROUND = registerColor('statusBar.offlineBackground', { - dark: '#6c1717', - light: '#6c1717', - hcDark: '#6c1717', - hcLight: '#6c1717' -}, localize('statusBarOfflineBackground', "Status bar background color when the workbench is offline. The status bar is shown in the bottom of the window")); - -export const STATUS_BAR_OFFLINE_FOREGROUND = registerColor('statusBar.offlineForeground', { - dark: STATUS_BAR_FOREGROUND, - light: STATUS_BAR_FOREGROUND, - hcDark: STATUS_BAR_FOREGROUND, - hcLight: STATUS_BAR_FOREGROUND -}, localize('statusBarOfflineForeground', "Status bar foreground color when the workbench is offline. The status bar is shown in the bottom of the window")); - -export const STATUS_BAR_OFFLINE_BORDER = registerColor('statusBar.offlineBorder', { - dark: STATUS_BAR_BORDER, - light: STATUS_BAR_BORDER, - hcDark: STATUS_BAR_BORDER, - hcLight: STATUS_BAR_BORDER -}, localize('statusBarOfflineBorder', "Status bar border color separating to the sidebar and editor when the workbench is offline. The status bar is shown in the bottom of the window")); - -export class OfflineStatusBarController implements IWorkbenchContribution { - - private readonly disposables = new DisposableStore(); - private disposable: IDisposable | undefined; - - private set enabled(enabled: boolean) { - if (enabled === !!this.disposable) { - return; - } - - if (enabled) { - this.disposable = combinedDisposable( - this.statusbarService.overrideStyle({ - priority: 100, - foreground: STATUS_BAR_OFFLINE_FOREGROUND, - background: STATUS_BAR_OFFLINE_BACKGROUND, - border: STATUS_BAR_OFFLINE_BORDER, - }), - this.statusbarService.addEntry({ - name: 'Offline Indicator', - text: '$(debug-disconnect) Offline', - ariaLabel: 'Network is offline.', - tooltip: localize('offline', "Network appears to be offline, certain features might be unavailable.") - }, 'offline', StatusbarAlignment.LEFT, 10000) - ); - } else { - this.disposable!.dispose(); - this.disposable = undefined; - } - } - - constructor( - @IDebugService private readonly debugService: IDebugService, - @IWorkspaceContextService private readonly contextService: IWorkspaceContextService, - @IStatusbarService private readonly statusbarService: IStatusbarService - ) { - Event.any( - this.disposables.add(new DomEmitter(window, 'online')).event, - this.disposables.add(new DomEmitter(window, 'offline')).event - )(this.update, this, this.disposables); - - this.debugService.onDidChangeState(this.update, this, this.disposables); - this.contextService.onDidChangeWorkbenchState(this.update, this, this.disposables); - this.update(); - } - - protected update(): void { - this.enabled = !navigator.onLine; - } - - dispose(): void { - this.disposable?.dispose(); - this.disposables.dispose(); - } -} - -Registry.as(Extensions.Workbench) - .registerWorkbenchContribution(OfflineStatusBarController, LifecyclePhase.Restored); diff --git a/src/vs/workbench/contrib/remote/browser/remoteIndicator.ts b/src/vs/workbench/contrib/remote/browser/remoteIndicator.ts index 9a749bc4a25..34eb2b777e4 100644 --- a/src/vs/workbench/contrib/remote/browser/remoteIndicator.ts +++ b/src/vs/workbench/contrib/remote/browser/remoteIndicator.ts @@ -6,7 +6,9 @@ import * as nls from 'vs/nls'; import { STATUS_BAR_HOST_NAME_BACKGROUND, STATUS_BAR_HOST_NAME_FOREGROUND } from 'vs/workbench/common/theme'; import { themeColorFromId } from 'vs/platform/theme/common/themeService'; -import { IRemoteAgentService } from 'vs/workbench/services/remote/common/remoteAgentService'; +import { IRemoteAgentService, remoteConnectionLatencyMeasurer } from 'vs/workbench/services/remote/common/remoteAgentService'; +import { RunOnceScheduler } from 'vs/base/common/async'; +import { Event } from 'vs/base/common/event'; import { Disposable, dispose } from 'vs/base/common/lifecycle'; import { MenuId, IMenuService, MenuItemAction, MenuRegistry, registerAction2, Action2, SubmenuItemAction } from 'vs/platform/actions/common/actions'; import { IWorkbenchContribution } from 'vs/workbench/common/contributions'; @@ -42,6 +44,22 @@ import { WorkbenchActionExecutedClassification, WorkbenchActionExecutedEvent } f import { KeybindingWeight } from 'vs/platform/keybinding/common/keybindingsRegistry'; import { KeyCode, KeyMod } from 'vs/base/common/keyCodes'; import { IProductService } from 'vs/platform/product/common/productService'; +import { DomEmitter } from 'vs/base/browser/event'; +import { registerColor } from 'vs/platform/theme/common/colorRegistry'; + +export const STATUS_BAR_OFFLINE_BACKGROUND = registerColor('statusBar.offlineBackground', { + dark: '#6c1717', + light: '#6c1717', + hcDark: '#6c1717', + hcLight: '#6c1717' +}, nls.localize('statusBarOfflineBackground', "Status bar background color when the workbench is offline. The status bar is shown in the bottom of the window")); + +export const STATUS_BAR_OFFLINE_FOREGROUND = registerColor('statusBar.offlineForeground', { + dark: STATUS_BAR_HOST_NAME_FOREGROUND, + light: STATUS_BAR_HOST_NAME_FOREGROUND, + hcDark: STATUS_BAR_HOST_NAME_FOREGROUND, + hcLight: STATUS_BAR_HOST_NAME_FOREGROUND +}, nls.localize('statusBarOfflineForeground', "Status bar foreground color when the workbench is offline. The status bar is shown in the bottom of the window")); type ActionGroup = [string, Array]; export class RemoteStatusIndicator extends Disposable implements IWorkbenchContribution { @@ -53,6 +71,9 @@ export class RemoteStatusIndicator extends Disposable implements IWorkbenchContr private static readonly REMOTE_STATUS_LABEL_MAX_LENGTH = 40; + private static readonly REMOTE_CONNECTION_LATENCY_SCHEDULER_DELAY = 60 * 1000; + private static readonly REMOTE_CONNECTION_LATENCY_SCHEDULER_FIRST_RUN_DELAY = 10 * 1000; + private remoteStatusEntry: IStatusbarEntryAccessor | undefined; private readonly legacyIndicatorMenu = this._register(this.menuService.createMenu(MenuId.StatusBarWindowIndicatorMenu, this.contextKeyService)); // to be removed once migration completed @@ -67,6 +88,9 @@ export class RemoteStatusIndicator extends Disposable implements IWorkbenchContr private connectionState: 'initializing' | 'connected' | 'reconnecting' | 'disconnected' | undefined = undefined; private readonly connectionStateContextKey = new RawContextKey<'' | 'initializing' | 'disconnected' | 'connected'>('remoteConnectionState', '').bindTo(this.contextKeyService); + private networkState: 'online' | 'offline' | 'high-latency' | undefined = undefined; + private measureNetworkConnectionLatencyScheduler: RunOnceScheduler | undefined = undefined; + private loggedInvalidGroupNames: { [group: string]: boolean } = Object.create(null); constructor( @@ -172,8 +196,6 @@ export class RemoteStatusIndicator extends Disposable implements IWorkbenchContr }; }); } - - } private registerListeners(): void { @@ -205,13 +227,13 @@ export class RemoteStatusIndicator extends Disposable implements IWorkbenchContr case PersistentConnectionEventType.ConnectionLost: case PersistentConnectionEventType.ReconnectionRunning: case PersistentConnectionEventType.ReconnectionWait: - this.setState('reconnecting'); + this.setConnectionState('reconnecting'); break; case PersistentConnectionEventType.ReconnectionPermanentFailure: - this.setState('disconnected'); + this.setConnectionState('disconnected'); break; case PersistentConnectionEventType.ConnectionGain: - this.setState('connected'); + this.setConnectionState('connected'); break; } })); @@ -222,6 +244,14 @@ export class RemoteStatusIndicator extends Disposable implements IWorkbenchContr this.updateRemoteStatusIndicator(); })); } + + // Online / Offline changes (web only) + if (isWeb) { + this._register(Event.any( + this._register(new DomEmitter(window, 'online')).event, + this._register(new DomEmitter(window, 'offline')).event + )(() => this.setNetworkState(navigator.onLine ? 'online' : 'offline'))); + } } private updateVirtualWorkspaceLocation() { @@ -239,9 +269,9 @@ export class RemoteStatusIndicator extends Disposable implements IWorkbenchContr try { await this.remoteAuthorityResolverService.resolveAuthority(remoteAuthority); - this.setState('connected'); + this.setConnectionState('connected'); } catch (error) { - this.setState('disconnected'); + this.setConnectionState('disconnected'); } })(); } @@ -249,7 +279,7 @@ export class RemoteStatusIndicator extends Disposable implements IWorkbenchContr this.updateRemoteStatusIndicator(); } - private setState(newState: 'disconnected' | 'connected' | 'reconnecting'): void { + private setConnectionState(newState: 'disconnected' | 'connected' | 'reconnecting'): void { if (this.connectionState !== newState) { this.connectionState = newState; @@ -260,6 +290,53 @@ export class RemoteStatusIndicator extends Disposable implements IWorkbenchContr this.connectionStateContextKey.set(this.connectionState); } + // indicate status + this.updateRemoteStatusIndicator(); + + // start measuring connection latency once connected + if (newState === 'connected') { + this.scheduleMeasureNetworkConnectionLatency(); + } + } + } + + private scheduleMeasureNetworkConnectionLatency(): void { + if ( + !this.remoteAuthority || // only when having a remote connection + this.measureNetworkConnectionLatencyScheduler // already scheduled + ) { + return; + } + + this.measureNetworkConnectionLatencyScheduler = this._register(new RunOnceScheduler(() => this.measureNetworkConnectionLatency(), RemoteStatusIndicator.REMOTE_CONNECTION_LATENCY_SCHEDULER_DELAY)); + this.measureNetworkConnectionLatencyScheduler.schedule(RemoteStatusIndicator.REMOTE_CONNECTION_LATENCY_SCHEDULER_FIRST_RUN_DELAY); + } + + private async measureNetworkConnectionLatency(): Promise { + + // Measure latency if we are online + // but only when the window has focus to prevent constantly + // waking up the connection to the remote + + if (this.hostService.hasFocus && this.networkState !== 'offline') { + const measurement = await remoteConnectionLatencyMeasurer.measure(this.remoteAgentService); + if (measurement) { + if (measurement.high) { + this.setNetworkState('high-latency'); + } else if (this.networkState === 'high-latency') { + this.setNetworkState('online'); + } + } + } + + this.measureNetworkConnectionLatencyScheduler?.schedule(); + } + + private setNetworkState(newState: 'online' | 'offline' | 'high-latency'): void { + if (this.networkState !== newState) { + this.networkState = newState; + + // update status this.updateRemoteStatusIndicator(); } } @@ -287,7 +364,12 @@ export class RemoteStatusIndicator extends Disposable implements IWorkbenchContr // Remote Indicator: show if provided via options, e.g. by the web embedder API const remoteIndicator = this.environmentService.options?.windowIndicator; if (remoteIndicator) { - this.renderRemoteStatusIndicator(truncate(remoteIndicator.label, RemoteStatusIndicator.REMOTE_STATUS_LABEL_MAX_LENGTH), remoteIndicator.tooltip, remoteIndicator.command); + let remoteIndicatorLabel = remoteIndicator.label.trim(); + if (!remoteIndicatorLabel.startsWith('$(')) { + remoteIndicatorLabel = `$(remote) ${remoteIndicatorLabel}`; // ensure the indicator has a codicon + } + + this.renderRemoteStatusIndicator(truncate(remoteIndicatorLabel, RemoteStatusIndicator.REMOTE_STATUS_LABEL_MAX_LENGTH), remoteIndicator.tooltip, remoteIndicator.command); return; } @@ -299,7 +381,7 @@ export class RemoteStatusIndicator extends Disposable implements IWorkbenchContr this.renderRemoteStatusIndicator(nls.localize('host.open', "Opening Remote..."), nls.localize('host.open', "Opening Remote..."), undefined, true /* progress */); break; case 'reconnecting': - this.renderRemoteStatusIndicator(`${nls.localize('host.reconnecting', "Reconnecting to {0}...", truncate(hostLabel, RemoteStatusIndicator.REMOTE_STATUS_LABEL_MAX_LENGTH))}`, undefined, undefined, true); + this.renderRemoteStatusIndicator(`${nls.localize('host.reconnecting', "Reconnecting to {0}...", truncate(hostLabel, RemoteStatusIndicator.REMOTE_STATUS_LABEL_MAX_LENGTH))}`, undefined, undefined, true /* progress */); break; case 'disconnected': this.renderRemoteStatusIndicator(`$(alert) ${nls.localize('disconnectedFrom', "Disconnected from {0}", truncate(hostLabel, RemoteStatusIndicator.REMOTE_STATUS_LABEL_MAX_LENGTH))}`); @@ -319,6 +401,7 @@ export class RemoteStatusIndicator extends Disposable implements IWorkbenchContr } // Show when in a virtual workspace if (this.virtualWorkspaceLocation) { + // Workspace with label: indicate editing source const workspaceLabel = this.labelService.getHostLabel(this.virtualWorkspaceLocation.scheme, this.virtualWorkspaceLocation.authority); if (workspaceLabel) { @@ -341,6 +424,7 @@ export class RemoteStatusIndicator extends Disposable implements IWorkbenchContr return; } } + // Show when there are commands other than the 'install additional remote extensions' command. if (this.hasRemoteMenuCommands(true)) { this.renderRemoteStatusIndicator(`$(remote)`, nls.localize('noHost.tooltip', "Open a Remote Window")); @@ -352,22 +436,18 @@ export class RemoteStatusIndicator extends Disposable implements IWorkbenchContr this.remoteStatusEntry = undefined; } - private renderRemoteStatusIndicator(text: string, tooltip?: string | IMarkdownString, command?: string, showProgress?: boolean): void { - const name = nls.localize('remoteHost', "Remote Host"); - if (typeof command !== 'string' && (this.hasRemoteMenuCommands(false))) { - command = RemoteStatusIndicator.REMOTE_ACTIONS_COMMAND_ID; - } + private renderRemoteStatusIndicator(initialText: string, initialTooltip?: string | MarkdownString, command?: string, showProgress?: boolean): void { + const { text, tooltip, ariaLabel } = this.withNetworkStatus(initialText, initialTooltip); - const ariaLabel = getCodiconAriaLabel(text); const properties: IStatusbarEntry = { - name, - backgroundColor: themeColorFromId(STATUS_BAR_HOST_NAME_BACKGROUND), - color: themeColorFromId(STATUS_BAR_HOST_NAME_FOREGROUND), + name: nls.localize('remoteHost', "Remote Host"), + backgroundColor: themeColorFromId(this.networkState === 'offline' ? STATUS_BAR_OFFLINE_BACKGROUND : STATUS_BAR_HOST_NAME_BACKGROUND), + color: themeColorFromId(this.networkState === 'offline' ? STATUS_BAR_OFFLINE_FOREGROUND : STATUS_BAR_HOST_NAME_FOREGROUND), ariaLabel, text, showProgress, tooltip, - command + command: command ?? this.hasRemoteMenuCommands(false) ? RemoteStatusIndicator.REMOTE_ACTIONS_COMMAND_ID : undefined }; if (this.remoteStatusEntry) { @@ -377,6 +457,59 @@ export class RemoteStatusIndicator extends Disposable implements IWorkbenchContr } } + private withNetworkStatus(initialText: string, initialTooltip?: string | MarkdownString): { text: string; tooltip: string | IMarkdownString | undefined; ariaLabel: string } { + let text = initialText; + let tooltip = initialTooltip; + let ariaLabel = getCodiconAriaLabel(text); + + // `initialText` can have a "$(remote)" codicon in the beginning + // but it may not have it depending on the environment. + // the following function will replace the codicon in the beginning with + // another icon or add it to the beginning if no icon + + function insertOrReplaceCodicon(target: string, codicon: string): string { + if (target.startsWith('$(remote)')) { + return target.replace('$(remote)', codicon); + } + + return `${codicon} ${target}`; + } + + switch (this.networkState) { + case 'offline': { + text = insertOrReplaceCodicon(initialText, '$(alert)'); + + const offlineMessage = nls.localize('networkStatusOfflineTooltip', "Network appears to be offline, certain features might be unavailable."); + tooltip = this.appendTooltipLine(tooltip, offlineMessage); + ariaLabel = `${ariaLabel}, ${offlineMessage}`; + break; + } + case 'high-latency': + text = insertOrReplaceCodicon(initialText, '$(alert)'); + tooltip = this.appendTooltipLine(tooltip, nls.localize('networkStatusHighLatencyTooltip', "Network appears to have high latency ({0}ms last, {1}ms average), certain features may be slow to respond.", remoteConnectionLatencyMeasurer.latency?.current?.toFixed(2), remoteConnectionLatencyMeasurer.latency?.average?.toFixed(2))); + break; + } + + return { text, tooltip, ariaLabel }; + } + + private appendTooltipLine(tooltip: string | MarkdownString | undefined, line: string): MarkdownString { + let markdownTooltip: MarkdownString; + if (typeof tooltip === 'string') { + markdownTooltip = new MarkdownString(tooltip, { isTrusted: true, supportThemeIcons: true }); + } else { + markdownTooltip = tooltip ?? new MarkdownString('', { isTrusted: true, supportThemeIcons: true }); + } + + if (markdownTooltip.value.length > 0) { + markdownTooltip.appendText('\n\n'); + } + + markdownTooltip.appendText(line); + + return markdownTooltip; + } + private showRemoteMenu() { const getCategoryLabel = (action: MenuItemAction) => { if (action.item.category) { diff --git a/src/vs/workbench/contrib/remote/common/remote.contribution.ts b/src/vs/workbench/contrib/remote/common/remote.contribution.ts index e3f6f68808b..7cf33d8f9f2 100644 --- a/src/vs/workbench/contrib/remote/common/remote.contribution.ts +++ b/src/vs/workbench/contrib/remote/common/remote.contribution.ts @@ -9,7 +9,7 @@ import { LifecyclePhase } from 'vs/workbench/services/lifecycle/common/lifecycle import { ILabelService, ResourceLabelFormatting } from 'vs/platform/label/common/label'; import { OperatingSystem, isWeb, OS } from 'vs/base/common/platform'; import { Schemas } from 'vs/base/common/network'; -import { IRemoteAgentService } from 'vs/workbench/services/remote/common/remoteAgentService'; +import { IRemoteAgentService, remoteConnectionLatencyMeasurer } from 'vs/workbench/services/remote/common/remoteAgentService'; import { ILoggerService } from 'vs/platform/log/common/log'; import { localize } from 'vs/nls'; import { Disposable } from 'vs/base/common/lifecycle'; @@ -28,7 +28,6 @@ import { ITelemetryService } from 'vs/platform/telemetry/common/telemetry'; import { getRemoteName } from 'vs/platform/remote/common/remoteHosts'; import { IDownloadService } from 'vs/platform/download/common/download'; import { DownloadServiceChannel } from 'vs/platform/download/common/downloadIpc'; -import { timeout } from 'vs/base/common/async'; import { RemoteLoggerChannelClient } from 'vs/platform/log/common/logIpc'; export class LabelContribution implements IWorkbenchContribution { @@ -140,9 +139,6 @@ class RemoteInvalidWorkspaceDetector extends Disposable implements IWorkbenchCon } } -const EXT_HOST_LATENCY_SAMPLES = 5; -const EXT_HOST_LATENCY_DELAY = 2_000; - class InitialRemoteConnectionHealthContribution implements IWorkbenchContribution { constructor( @@ -206,15 +202,9 @@ class InitialRemoteConnectionHealthContribution implements IWorkbenchContributio } private async _measureExtHostLatency() { - // Get the minimum latency, since latency spikes could be caused by a busy extension host. - let bestLatency = Infinity; - for (let i = 0; i < EXT_HOST_LATENCY_SAMPLES; i++) { - const rtt = await this._remoteAgentService.getRoundTripTime(); - if (rtt === undefined) { - return; - } - bestLatency = Math.min(bestLatency, rtt / 2); - await timeout(EXT_HOST_LATENCY_DELAY); + const measurement = await remoteConnectionLatencyMeasurer.measure(this._remoteAgentService); + if (measurement === undefined) { + return; } type RemoteConnectionLatencyClassification = { @@ -233,7 +223,7 @@ class InitialRemoteConnectionHealthContribution implements IWorkbenchContributio this._telemetryService.publicLog2('remoteConnectionLatency', { web: isWeb, remoteName: getRemoteName(this._environmentService.remoteAuthority), - latencyMs: bestLatency + latencyMs: measurement.current }); } } diff --git a/src/vs/workbench/contrib/remote/electron-sandbox/remote.contribution.ts b/src/vs/workbench/contrib/remote/electron-sandbox/remote.contribution.ts index 9cc04b27a86..b16657f9486 100644 --- a/src/vs/workbench/contrib/remote/electron-sandbox/remote.contribution.ts +++ b/src/vs/workbench/contrib/remote/electron-sandbox/remote.contribution.ts @@ -5,7 +5,7 @@ import * as nls from 'vs/nls'; import { Registry } from 'vs/platform/registry/common/platform'; -import { IRemoteAgentService } from 'vs/workbench/services/remote/common/remoteAgentService'; +import { IRemoteAgentService, remoteConnectionLatencyMeasurer } from 'vs/workbench/services/remote/common/remoteAgentService'; import { Disposable } from 'vs/base/common/lifecycle'; import { isMacintosh, isWindows } from 'vs/base/common/platform'; import { KeyMod, KeyChord, KeyCode } from 'vs/base/common/keyCodes'; @@ -44,6 +44,12 @@ class RemoteAgentDiagnosticListener implements IWorkbenchContribution { .then(info => { if (info) { (info as IRemoteDiagnosticInfo).hostName = hostName; + if (remoteConnectionLatencyMeasurer.latency?.high) { + (info as IRemoteDiagnosticInfo).latency = { + average: remoteConnectionLatencyMeasurer.latency.average, + current: remoteConnectionLatencyMeasurer.latency.current + }; + } } ipcRenderer.send(request.replyChannel, info); diff --git a/src/vs/workbench/services/remote/common/remoteAgentService.ts b/src/vs/workbench/services/remote/common/remoteAgentService.ts index 147d9728c80..3fc8be68725 100644 --- a/src/vs/workbench/services/remote/common/remoteAgentService.ts +++ b/src/vs/workbench/services/remote/common/remoteAgentService.ts @@ -10,6 +10,7 @@ import { IDiagnosticInfoOptions, IDiagnosticInfo } from 'vs/platform/diagnostics import { Event } from 'vs/base/common/event'; import { PersistentConnectionEvent } from 'vs/platform/remote/common/remoteAgentConnection'; import { ITelemetryData, TelemetryLevel } from 'vs/platform/telemetry/common/telemetry'; +import { timeout } from 'vs/base/common/async'; export const IRemoteAgentService = createDecorator('remoteAgentService'); @@ -59,3 +60,94 @@ export interface IRemoteAgentConnection { registerChannel>(channelName: string, channel: T): void; getInitialConnectionTimeMs(): Promise; } + +export interface IRemoteConnectionLatencyMeasurement { + + readonly initial: number | undefined; + readonly current: number; + readonly average: number; + + readonly high: boolean; +} + +export const remoteConnectionLatencyMeasurer = new class { + + readonly #maxSampleCount = 5; + readonly #sampleDelay = 2000; + + readonly #initial: number[] = []; + readonly #maxInitialCount = 3; + + readonly #average: number[] = []; + readonly #maxAverageCount = 100; + + readonly #highLatencyMultiple = 2; + readonly #highLatencyMinThreshold = 500; + readonly #highLatencyMaxThreshold = 1500; + + #lastMeasurement: IRemoteConnectionLatencyMeasurement | undefined = undefined; + get latency() { return this.#lastMeasurement; } + + async measure(remoteAgentService: IRemoteAgentService): Promise { + let currentLatency = Infinity; + + // Measure up to samples count + for (let i = 0; i < this.#maxSampleCount; i++) { + const rtt = await remoteAgentService.getRoundTripTime(); + if (rtt === undefined) { + return undefined; + } + + currentLatency = Math.min(currentLatency, rtt / 2 /* we want just one way, not round trip time */); + await timeout(this.#sampleDelay); + } + + // Keep track of average latency + this.#average.push(currentLatency); + if (this.#average.length > this.#maxAverageCount) { + this.#average.shift(); + } + + // Keep track of initial latency + let initialLatency: number | undefined = undefined; + if (this.#initial.length < this.#maxInitialCount) { + this.#initial.push(currentLatency); + } else { + initialLatency = this.#initial.reduce((sum, value) => sum + value, 0) / this.#initial.length; + } + + // Remember as last measurement + this.#lastMeasurement = { + initial: initialLatency, + current: currentLatency, + average: this.#average.reduce((sum, value) => sum + value, 0) / this.#average.length, + high: (() => { + + // based on the initial, average and current latency, try to decide + // if the connection has high latency + // Some rules: + // - we require the initial latency to be computed + // - we only consider latency above highLatencyMinThreshold as potentially high + // - we require the current latency to be above the average latency by a factor of highLatencyMultiple + // - but not if the latency is actually above highLatencyMaxThreshold + + if (typeof initialLatency === 'undefined') { + return false; + } + + if (currentLatency > this.#highLatencyMaxThreshold) { + return true; + } + + if (currentLatency > this.#highLatencyMinThreshold && currentLatency > initialLatency * this.#highLatencyMultiple) { + return true; + } + + return false; + })() + }; + + return this.#lastMeasurement; + } +}; + diff --git a/src/vs/workbench/workbench.web.main.ts b/src/vs/workbench/workbench.web.main.ts index b61bfdc854b..8b9ea7ec90f 100644 --- a/src/vs/workbench/workbench.web.main.ts +++ b/src/vs/workbench/workbench.web.main.ts @@ -157,9 +157,6 @@ import 'vs/workbench/contrib/issue/browser/issue.contribution'; // Splash import 'vs/workbench/contrib/splash/browser/splash.contribution'; -// Offline -import 'vs/workbench/contrib/offline/browser/offline.contribution'; - //#endregion