Path: blob/main/src/vs/workbench/browser/parts/statusbar/statusbarItem.ts
3296 views
/*---------------------------------------------------------------------------------------------1* Copyright (c) Microsoft Corporation. All rights reserved.2* Licensed under the MIT License. See License.txt in the project root for license information.3*--------------------------------------------------------------------------------------------*/45import { toErrorMessage } from '../../../../base/common/errorMessage.js';6import { Disposable, MutableDisposable } from '../../../../base/common/lifecycle.js';7import { SimpleIconLabel } from '../../../../base/browser/ui/iconLabel/simpleIconLabel.js';8import { ICommandService } from '../../../../platform/commands/common/commands.js';9import { ITelemetryService } from '../../../../platform/telemetry/common/telemetry.js';10import { IStatusbarEntry, isTooltipWithCommands, ShowTooltipCommand, StatusbarEntryKinds, TooltipContent } from '../../../services/statusbar/browser/statusbar.js';11import { WorkbenchActionExecutedEvent, WorkbenchActionExecutedClassification } from '../../../../base/common/actions.js';12import { IThemeService } from '../../../../platform/theme/common/themeService.js';13import { ThemeColor } from '../../../../base/common/themables.js';14import { isThemeColor } from '../../../../editor/common/editorCommon.js';15import { addDisposableListener, EventType, hide, show, append, EventHelper, $ } from '../../../../base/browser/dom.js';16import { INotificationService } from '../../../../platform/notification/common/notification.js';17import { assertReturnsDefined } from '../../../../base/common/types.js';18import { Command } from '../../../../editor/common/languages.js';19import { StandardKeyboardEvent } from '../../../../base/browser/keyboardEvent.js';20import { KeyCode } from '../../../../base/common/keyCodes.js';21import { renderIcon, renderLabelWithIcons } from '../../../../base/browser/ui/iconLabel/iconLabels.js';22import { spinningLoading, syncing } from '../../../../platform/theme/common/iconRegistry.js';23import { isMarkdownString, markdownStringEqual } from '../../../../base/common/htmlContent.js';24import { IHoverDelegate } from '../../../../base/browser/ui/hover/hoverDelegate.js';25import { Gesture, EventType as TouchEventType } from '../../../../base/browser/touch.js';26import { IManagedHover, IManagedHoverOptions } from '../../../../base/browser/ui/hover/hover.js';27import { IHoverService } from '../../../../platform/hover/browser/hover.js';2829export class StatusbarEntryItem extends Disposable {3031private readonly label: StatusBarCodiconLabel;3233private entry: IStatusbarEntry | undefined = undefined;3435private readonly foregroundListener = this._register(new MutableDisposable());36private readonly backgroundListener = this._register(new MutableDisposable());3738private readonly commandMouseListener = this._register(new MutableDisposable());39private readonly commandTouchListener = this._register(new MutableDisposable());40private readonly commandKeyboardListener = this._register(new MutableDisposable());4142private hover: IManagedHover | undefined = undefined;4344readonly labelContainer: HTMLElement;45readonly beakContainer: HTMLElement;4647get name(): string {48return assertReturnsDefined(this.entry).name;49}5051get hasCommand(): boolean {52return typeof this.entry?.command !== 'undefined';53}5455constructor(56private container: HTMLElement,57entry: IStatusbarEntry,58private readonly hoverDelegate: IHoverDelegate,59@ICommandService private readonly commandService: ICommandService,60@IHoverService private readonly hoverService: IHoverService,61@INotificationService private readonly notificationService: INotificationService,62@ITelemetryService private readonly telemetryService: ITelemetryService,63@IThemeService private readonly themeService: IThemeService64) {65super();6667// Label Container68this.labelContainer = $('a.statusbar-item-label', {69role: 'button',70tabIndex: -1 // allows screen readers to read title, but still prevents tab focus.71});72this._register(Gesture.addTarget(this.labelContainer)); // enable touch7374// Label (with support for progress)75this.label = this._register(new StatusBarCodiconLabel(this.labelContainer));76this.container.appendChild(this.labelContainer);7778// Beak Container79this.beakContainer = $('.status-bar-item-beak-container');80this.container.appendChild(this.beakContainer);8182if (entry.content) {83this.container.appendChild(entry.content);84}8586this.update(entry);87}8889update(entry: IStatusbarEntry): void {9091// Update: Progress92this.label.showProgress = entry.showProgress ?? false;9394// Update: Text95if (!this.entry || entry.text !== this.entry.text) {96this.label.text = entry.text;9798if (entry.text) {99show(this.labelContainer);100} else {101hide(this.labelContainer);102}103}104105// Update: ARIA label106//107// Set the aria label on both elements so screen readers would read108// the correct thing without duplication #96210109110if (!this.entry || entry.ariaLabel !== this.entry.ariaLabel) {111this.container.setAttribute('aria-label', entry.ariaLabel);112this.labelContainer.setAttribute('aria-label', entry.ariaLabel);113}114115if (!this.entry || entry.role !== this.entry.role) {116this.labelContainer.setAttribute('role', entry.role || 'button');117}118119// Update: Hover120if (!this.entry || !this.isEqualTooltip(this.entry, entry)) {121let hoverOptions: IManagedHoverOptions | undefined;122let hoverTooltip: TooltipContent | undefined;123if (isTooltipWithCommands(entry.tooltip)) {124hoverTooltip = entry.tooltip.content;125hoverOptions = {126actions: entry.tooltip.commands.map(command => ({127commandId: command.id,128label: command.title,129run: () => this.executeCommand(command)130}))131};132} else {133hoverTooltip = entry.tooltip;134}135136const hoverContents = isMarkdownString(hoverTooltip) ? { markdown: hoverTooltip, markdownNotSupportedFallback: undefined } : hoverTooltip;137if (this.hover) {138this.hover.update(hoverContents, hoverOptions);139} else {140this.hover = this._register(this.hoverService.setupManagedHover(this.hoverDelegate, this.container, hoverContents, hoverOptions));141}142}143144// Update: Command145if (!this.entry || entry.command !== this.entry.command) {146this.commandMouseListener.clear();147this.commandTouchListener.clear();148this.commandKeyboardListener.clear();149150const command = entry.command;151if (command && (command !== ShowTooltipCommand || this.hover) /* "Show Hover" is only valid when we have a hover */) {152this.commandMouseListener.value = addDisposableListener(this.labelContainer, EventType.CLICK, () => this.executeCommand(command));153this.commandTouchListener.value = addDisposableListener(this.labelContainer, TouchEventType.Tap, () => this.executeCommand(command));154this.commandKeyboardListener.value = addDisposableListener(this.labelContainer, EventType.KEY_DOWN, e => {155const event = new StandardKeyboardEvent(e);156if (event.equals(KeyCode.Space) || event.equals(KeyCode.Enter)) {157EventHelper.stop(e);158159this.executeCommand(command);160} else if (event.equals(KeyCode.Escape) || event.equals(KeyCode.LeftArrow) || event.equals(KeyCode.RightArrow)) {161EventHelper.stop(e);162163this.hover?.hide();164}165});166167this.labelContainer.classList.remove('disabled');168} else {169this.labelContainer.classList.add('disabled');170}171}172173// Update: Beak174if (!this.entry || entry.showBeak !== this.entry.showBeak) {175if (entry.showBeak) {176this.container.classList.add('has-beak');177} else {178this.container.classList.remove('has-beak');179}180}181182const hasBackgroundColor = !!entry.backgroundColor || (entry.kind && entry.kind !== 'standard');183184// Update: Kind185if (!this.entry || entry.kind !== this.entry.kind) {186for (const kind of StatusbarEntryKinds) {187this.container.classList.remove(`${kind}-kind`);188}189190if (entry.kind && entry.kind !== 'standard') {191this.container.classList.add(`${entry.kind}-kind`);192}193194this.container.classList.toggle('has-background-color', hasBackgroundColor);195}196197// Update: Foreground198if (!this.entry || entry.color !== this.entry.color) {199this.applyColor(this.labelContainer, entry.color);200}201202// Update: Background203if (!this.entry || entry.backgroundColor !== this.entry.backgroundColor) {204this.container.classList.toggle('has-background-color', hasBackgroundColor);205this.applyColor(this.container, entry.backgroundColor, true);206}207208// Remember for next round209this.entry = entry;210}211212private isEqualTooltip({ tooltip }: IStatusbarEntry, { tooltip: otherTooltip }: IStatusbarEntry) {213if (tooltip === undefined) {214return otherTooltip === undefined;215}216217if (isMarkdownString(tooltip)) {218return isMarkdownString(otherTooltip) && markdownStringEqual(tooltip, otherTooltip);219}220221return tooltip === otherTooltip;222}223224private async executeCommand(command: string | Command): Promise<void> {225226// Custom command from us: Show tooltip227if (command === ShowTooltipCommand) {228this.hover?.show(true /* focus */);229}230231// Any other command is going through command service232else {233const id = typeof command === 'string' ? command : command.id;234const args = typeof command === 'string' ? [] : command.arguments ?? [];235236this.telemetryService.publicLog2<WorkbenchActionExecutedEvent, WorkbenchActionExecutedClassification>('workbenchActionExecuted', { id, from: 'status bar' });237try {238await this.commandService.executeCommand(id, ...args);239} catch (error) {240this.notificationService.error(toErrorMessage(error));241}242}243}244245private applyColor(container: HTMLElement, color: string | ThemeColor | undefined, isBackground?: boolean): void {246let colorResult: string | undefined = undefined;247248if (isBackground) {249this.backgroundListener.clear();250} else {251this.foregroundListener.clear();252}253254if (color) {255if (isThemeColor(color)) {256colorResult = this.themeService.getColorTheme().getColor(color.id)?.toString();257258const listener = this.themeService.onDidColorThemeChange(theme => {259const colorValue = theme.getColor(color.id)?.toString();260261if (isBackground) {262container.style.backgroundColor = colorValue ?? '';263} else {264container.style.color = colorValue ?? '';265}266});267268if (isBackground) {269this.backgroundListener.value = listener;270} else {271this.foregroundListener.value = listener;272}273} else {274colorResult = color;275}276}277278if (isBackground) {279container.style.backgroundColor = colorResult ?? '';280} else {281container.style.color = colorResult ?? '';282}283}284}285286class StatusBarCodiconLabel extends SimpleIconLabel {287288private progressCodicon = renderIcon(syncing);289290private currentText = '';291private currentShowProgress: boolean | 'loading' | 'syncing' = false;292293constructor(294private readonly container: HTMLElement295) {296super(container);297}298299set showProgress(showProgress: boolean | 'loading' | 'syncing') {300if (this.currentShowProgress !== showProgress) {301this.currentShowProgress = showProgress;302this.progressCodicon = renderIcon(showProgress === 'syncing' ? syncing : spinningLoading);303this.text = this.currentText;304}305}306307override set text(text: string) {308309// Progress: insert progress codicon as first element as needed310// but keep it stable so that the animation does not reset311if (this.currentShowProgress) {312313// Append as needed314if (this.container.firstChild !== this.progressCodicon) {315this.container.appendChild(this.progressCodicon);316}317318// Remove others319for (const node of Array.from(this.container.childNodes)) {320if (node !== this.progressCodicon) {321node.remove();322}323}324325// If we have text to show, add a space to separate from progress326let textContent = text ?? '';327if (textContent) {328textContent = `\u00A0${textContent}`; // prepend non-breaking space329}330331// Append new elements332append(this.container, ...renderLabelWithIcons(textContent));333}334335// No Progress: no special handling336else {337super.text = text;338}339}340}341342343