diff --git a/src/vs/base/browser/codicons.ts b/src/vs/base/browser/codicons.ts index ab29f13ce8b..cf55c1a9b92 100644 --- a/src/vs/base/browser/codicons.ts +++ b/src/vs/base/browser/codicons.ts @@ -18,7 +18,7 @@ export function renderCodicons(text: string): Array { textStart = (match.index || 0) + match[0].length; const [, escaped, codicon, name, animation] = match; - elements.push(escaped ? `$(${codicon})` : dom.$(`span.codicon.codicon-${name}${animation ? `.codicon-animation-${animation}` : ''}`)); + elements.push(escaped ? `$(${codicon})` : renderCodicon(name, animation)); } if (textStart < text.length) { @@ -26,3 +26,7 @@ export function renderCodicons(text: string): Array { } return elements; } + +export function renderCodicon(name: string, animation: string): HTMLSpanElement { + return dom.$(`span.codicon.codicon-${name}${animation ? `.codicon-animation-${animation}` : ''}`); +} diff --git a/src/vs/base/browser/dom.ts b/src/vs/base/browser/dom.ts index 0c19deeb873..8a3e808a871 100644 --- a/src/vs/base/browser/dom.ts +++ b/src/vs/base/browser/dom.ts @@ -998,8 +998,15 @@ export function prepend(parent: HTMLElement, child: T): T { /** * Removes all children from `parent` and appends `children` */ -export function reset(parent: HTMLElement, ...children: Array) { +export function reset(parent: HTMLElement, ...children: Array): void { parent.innerText = ''; + appendChildren(parent, ...children); +} + +/** + * Appends `children` to `parent` + */ +export function appendChildren(parent: HTMLElement, ...children: Array): void { for (const child of children) { if (child instanceof Node) { parent.appendChild(child); diff --git a/src/vs/workbench/browser/parts/statusbar/statusbarPart.ts b/src/vs/workbench/browser/parts/statusbar/statusbarPart.ts index ce83ddf403b..ced7b6480a6 100644 --- a/src/vs/workbench/browser/parts/statusbar/statusbarPart.ts +++ b/src/vs/workbench/browser/parts/statusbar/statusbarPart.ts @@ -21,7 +21,7 @@ import { IWorkspaceContextService, WorkbenchState } from 'vs/platform/workspace/ import { contrastBorder, activeContrastBorder } from 'vs/platform/theme/common/colorRegistry'; import { isThemeColor } from 'vs/editor/common/editorCommon'; import { Color } from 'vs/base/common/color'; -import { EventHelper, createStyleSheet, addDisposableListener, EventType, hide, show, isAncestor } from 'vs/base/browser/dom'; +import { EventHelper, createStyleSheet, addDisposableListener, EventType, hide, show, isAncestor, appendChildren } from 'vs/base/browser/dom'; import { INotificationService } from 'vs/platform/notification/common/notification'; import { IStorageService, StorageScope, IWorkspaceStorageChangeEvent } from 'vs/platform/storage/common/storage'; import { Parts, IWorkbenchLayoutService } from 'vs/workbench/services/layout/browser/layoutService'; @@ -39,6 +39,7 @@ import { KeybindingsRegistry, KeybindingWeight } from 'vs/platform/keybinding/co import { ServicesAccessor } from 'vs/editor/browser/editorExtensions'; import { RawContextKey, IContextKeyService } from 'vs/platform/contextkey/common/contextkey'; import { ColorScheme } from 'vs/platform/theme/common/theme'; +import { renderCodicon, renderCodicons } from 'vs/base/browser/codicons'; interface IPendingStatusbarEntry { id: string; @@ -64,17 +65,18 @@ class StatusbarViewModel extends Disposable { static readonly HIDDEN_ENTRIES_KEY = 'workbench.statusbar.hidden'; + private readonly _onDidChangeEntryVisibility = this._register(new Emitter<{ id: string, visible: boolean }>()); + readonly onDidChangeEntryVisibility = this._onDidChangeEntryVisibility.event; + private readonly _entries: IStatusbarViewModelEntry[] = []; get entries(): IStatusbarViewModelEntry[] { return this._entries; } - private hidden!: Set; + private _lastFocusedEntry: IStatusbarViewModelEntry | undefined; get lastFocusedEntry(): IStatusbarViewModelEntry | undefined { return this._lastFocusedEntry && !this.isHidden(this._lastFocusedEntry.id) ? this._lastFocusedEntry : undefined; } - private _lastFocusedEntry: IStatusbarViewModelEntry | undefined; - private readonly _onDidChangeEntryVisibility = this._register(new Emitter<{ id: string, visible: boolean }>()); - readonly onDidChangeEntryVisibility = this._onDidChangeEntryVisibility.event; + private hidden!: Set; constructor(private readonly storageService: IStorageService) { super(); @@ -706,12 +708,67 @@ export class StatusbarPart extends Part implements IStatusbarService { } } +class StatusBarCodiconLabel extends CodiconLabel { + + private readonly progressCodicon = renderCodicon('sync', 'spin'); + + private currentText = ''; + private currentShowProgress = false; + + constructor( + private readonly container: HTMLElement + ) { + super(container); + } + + set showProgress(showProgress: boolean) { + if (this.currentShowProgress !== showProgress) { + this.currentShowProgress = showProgress; + this.text = this.currentText; + } + } + + set text(text: string) { + + // Progress: insert progress codicon as first element as needed + // but keep it stable so that the animation does not reset + if (this.currentShowProgress) { + + // Append as needed + if (this.container.firstChild !== this.progressCodicon) { + this.container.appendChild(this.progressCodicon); + } + + // Remove others + for (const node of Array.from(this.container.childNodes)) { + if (node !== this.progressCodicon) { + node.remove(); + } + } + + // If we have text to show, add a space to separate from progress + let textContent = text ?? ''; + if (textContent) { + textContent = ` ${textContent}`; + } + + // Append new elements + appendChildren(this.container, ...renderCodicons(textContent)); + } + + // No Progress: no special handling + else { + super.text = text; + } + } +} + class StatusbarEntryItem extends Disposable { - private entry!: IStatusbarEntry; + readonly labelContainer: HTMLElement; + private readonly label: StatusBarCodiconLabel; - labelContainer!: HTMLElement; - private label!: CodiconLabel; + private entry: IStatusbarEntry | undefined = undefined; private readonly foregroundListener = this._register(new MutableDisposable()); private readonly backgroundListener = this._register(new MutableDisposable()); @@ -729,26 +786,25 @@ class StatusbarEntryItem extends Disposable { ) { super(); - this.create(); - this.update(entry); - } - - private create(): void { - // Label Container this.labelContainer = document.createElement('a'); this.labelContainer.tabIndex = -1; // allows screen readers to read title, but still prevents tab focus. this.labelContainer.setAttribute('role', 'button'); - // Label - this.label = new CodiconLabel(this.labelContainer); + // Label (with support for progress) + this.label = new StatusBarCodiconLabel(this.labelContainer); // Add to parent this.container.appendChild(this.labelContainer); + + this.update(entry); } update(entry: IStatusbarEntry): void { + // Update: Progress + this.label.showProgress = !!entry.showProgress; + // Update: Text if (!this.entry || entry.text !== this.entry.text) { this.label.text = entry.text; @@ -760,8 +816,9 @@ class StatusbarEntryItem extends Disposable { } } + // Set the aria label on both elements so screen readers would read + // the correct thing without duplication #96210 if (!this.entry || entry.ariaLabel !== this.entry.ariaLabel) { - // Set the aria label on both elements so screen readers would read the correct thing without duplication #96210 this.container.setAttribute('aria-label', entry.ariaLabel); this.labelContainer.setAttribute('aria-label', entry.ariaLabel); } diff --git a/src/vs/workbench/contrib/extensions/electron-browser/extensionProfileService.ts b/src/vs/workbench/contrib/extensions/electron-browser/extensionProfileService.ts index d491483b338..34cdcea7efa 100644 --- a/src/vs/workbench/contrib/extensions/electron-browser/extensionProfileService.ts +++ b/src/vs/workbench/contrib/extensions/electron-browser/extensionProfileService.ts @@ -82,7 +82,8 @@ export class ExtensionHostProfileService extends Disposable implements IExtensio if (visible) { const indicator: IStatusbarEntry = { - text: '$(sync~spin) ' + nls.localize('profilingExtensionHost', "Profiling Extension Host"), + text: nls.localize('profilingExtensionHost', "Profiling Extension Host"), + showProgress: true, ariaLabel: nls.localize('profilingExtensionHost', "Profiling Extension Host"), tooltip: nls.localize('selectAndStartDebug', "Click to stop profiling."), command: 'workbench.action.extensionHostProfilder.stop' @@ -91,7 +92,7 @@ export class ExtensionHostProfileService extends Disposable implements IExtensio const timeStarted = Date.now(); const handle = setInterval(() => { if (this.profilingStatusBarIndicator) { - this.profilingStatusBarIndicator.update({ ...indicator, text: '$(sync~spin) ' + nls.localize('profilingExtensionHostTime', "Profiling Extension Host ({0} sec)", Math.round((new Date().getTime() - timeStarted) / 1000)), }); + this.profilingStatusBarIndicator.update({ ...indicator, text: nls.localize('profilingExtensionHostTime', "Profiling Extension Host ({0} sec)", Math.round((new Date().getTime() - timeStarted) / 1000)), }); } }, 1000); this.profilingStatusBarIndicatorLabelUpdater.value = toDisposable(() => clearInterval(handle)); diff --git a/src/vs/workbench/contrib/remote/browser/remoteIndicator.ts b/src/vs/workbench/contrib/remote/browser/remoteIndicator.ts index 8a1a986c824..83e5499b936 100644 --- a/src/vs/workbench/contrib/remote/browser/remoteIndicator.ts +++ b/src/vs/workbench/contrib/remote/browser/remoteIndicator.ts @@ -197,7 +197,7 @@ export class RemoteStatusIndicator extends Disposable implements IWorkbenchContr const hostLabel = this.labelService.getHostLabel(Schemas.vscodeRemote, this.remoteAuthority) || this.remoteAuthority; switch (this.connectionState) { case 'initializing': - this.renderRemoteStatusIndicator(`$(sync~spin) ${nls.localize('host.open', "Opening Remote...")}`, nls.localize('host.open', "Opening Remote...")); + this.renderRemoteStatusIndicator(nls.localize('host.open', "Opening Remote..."), nls.localize('host.open', "Opening Remote..."), undefined, true /* progress */); break; case 'disconnected': this.renderRemoteStatusIndicator(`$(alert) ${nls.localize('disconnectedFrom', "Disconnected from {0}", hostLabel)}`, nls.localize('host.tooltipDisconnected', "Disconnected from {0}", hostLabel)); @@ -219,7 +219,7 @@ export class RemoteStatusIndicator extends Disposable implements IWorkbenchContr } } - private renderRemoteStatusIndicator(text: string, tooltip?: string, command?: string): void { + private renderRemoteStatusIndicator(text: string, tooltip?: string, command?: string, showProgress?: boolean): void { const name = nls.localize('remoteHost', "Remote Host"); if (typeof command !== 'string' && this.remoteMenu.getActions().length > 0) { command = RemoteStatusIndicator.REMOTE_ACTIONS_COMMAND_ID; @@ -230,6 +230,7 @@ export class RemoteStatusIndicator extends Disposable implements IWorkbenchContr color: themeColorFromId(STATUS_BAR_HOST_NAME_FOREGROUND), ariaLabel: name, text, + showProgress, tooltip, command }; diff --git a/src/vs/workbench/services/progress/browser/progressService.ts b/src/vs/workbench/services/progress/browser/progressService.ts index 597aaf8c80c..481205112be 100644 --- a/src/vs/workbench/services/progress/browser/progressService.ts +++ b/src/vs/workbench/services/progress/browser/progressService.ts @@ -151,7 +151,8 @@ export class ProgressService extends Disposable implements IProgressService { } const statusEntryProperties: IStatusbarEntry = { - text: `$(sync~spin) ${text}`, + text, + showProgress: true, ariaLabel: text, tooltip: title, command: progressCommand diff --git a/src/vs/workbench/services/statusbar/common/statusbar.ts b/src/vs/workbench/services/statusbar/common/statusbar.ts index 14a52e61d68..3d65b4fbaa1 100644 --- a/src/vs/workbench/services/statusbar/common/statusbar.ts +++ b/src/vs/workbench/services/statusbar/common/statusbar.ts @@ -63,6 +63,11 @@ export interface IStatusbarEntry { * Whether to show a beak above the status bar entry. */ readonly showBeak?: boolean; + + /** + * Will enable a spinning icon in front of the text to indicate progress. + */ + readonly showProgress?: boolean; } export interface IStatusbarService {