Path: blob/main/src/vs/workbench/contrib/languageStatus/browser/languageStatus.ts
5249 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 './media/languageStatus.css';6import * as dom from '../../../../base/browser/dom.js';7import { renderLabelWithIcons } from '../../../../base/browser/ui/iconLabel/iconLabels.js';8import { Disposable, DisposableStore, dispose, toDisposable } from '../../../../base/common/lifecycle.js';9import Severity from '../../../../base/common/severity.js';10import { getCodeEditor, ICodeEditor } from '../../../../editor/browser/editorBrowser.js';11import { localize, localize2 } from '../../../../nls.js';12import { ThemeIcon } from '../../../../base/common/themables.js';13import { IWorkbenchContribution } from '../../../common/contributions.js';14import { IEditorService } from '../../../services/editor/common/editorService.js';15import { ILanguageStatus, ILanguageStatusService } from '../../../services/languageStatus/common/languageStatusService.js';16import { IStatusbarEntry, IStatusbarEntryAccessor, IStatusbarService, ShowTooltipCommand, StatusbarAlignment, StatusbarEntryKind } from '../../../services/statusbar/browser/statusbar.js';17import { parseLinkedText } from '../../../../base/common/linkedText.js';18import { Link } from '../../../../platform/opener/browser/link.js';19import { IOpenerService } from '../../../../platform/opener/common/opener.js';20import { MarkdownString } from '../../../../base/common/htmlContent.js';21import { ActionBar } from '../../../../base/browser/ui/actionbar/actionbar.js';22import { Action } from '../../../../base/common/actions.js';23import { Codicon } from '../../../../base/common/codicons.js';24import { IStorageService, StorageScope, StorageTarget } from '../../../../platform/storage/common/storage.js';25import { equals } from '../../../../base/common/arrays.js';26import { URI } from '../../../../base/common/uri.js';27import { Action2 } from '../../../../platform/actions/common/actions.js';28import { ServicesAccessor } from '../../../../platform/instantiation/common/instantiation.js';29import { Categories } from '../../../../platform/action/common/actionCommonCategories.js';30import { IAccessibilityInformation } from '../../../../platform/accessibility/common/accessibility.js';31import { IEditorGroupsService, IEditorPart } from '../../../services/editor/common/editorGroupsService.js';32import { IHoverService, nativeHoverDelegate } from '../../../../platform/hover/browser/hover.js';33import { Event } from '../../../../base/common/event.js';34import { joinStrings } from '../../../../base/common/strings.js';3536class LanguageStatusViewModel {3738constructor(39readonly combined: readonly ILanguageStatus[],40readonly dedicated: readonly ILanguageStatus[]41) { }4243isEqual(other: LanguageStatusViewModel) {44return equals(this.combined, other.combined) && equals(this.dedicated, other.dedicated);45}46}4748class StoredCounter {4950constructor(@IStorageService private readonly _storageService: IStorageService, private readonly _key: string) { }5152get value() {53return this._storageService.getNumber(this._key, StorageScope.PROFILE, 0);54}5556increment(): number {57const n = this.value + 1;58this._storageService.store(this._key, n, StorageScope.PROFILE, StorageTarget.MACHINE);59return n;60}61}6263export class LanguageStatusContribution extends Disposable implements IWorkbenchContribution {6465static readonly Id = 'status.languageStatus';6667constructor(68@IEditorGroupsService private readonly editorGroupService: IEditorGroupsService,69) {70super();7172for (const part of editorGroupService.parts) {73this.createLanguageStatus(part);74}7576this._register(editorGroupService.onDidCreateAuxiliaryEditorPart(part => this.createLanguageStatus(part)));77}7879private createLanguageStatus(part: IEditorPart): void {80const disposables = new DisposableStore();81Event.once(part.onWillDispose)(() => disposables.dispose());8283const scopedInstantiationService = this.editorGroupService.getScopedInstantiationService(part);84disposables.add(scopedInstantiationService.createInstance(LanguageStatus));85}86}8788class LanguageStatus {8990private static readonly _id = 'status.languageStatus';9192private static readonly _keyDedicatedItems = 'languageStatus.dedicated';9394private readonly _disposables = new DisposableStore();95private readonly _interactionCounter: StoredCounter;9697private _dedicated = new Set<string>();9899private _model?: LanguageStatusViewModel;100private _combinedEntry?: IStatusbarEntryAccessor;101private _dedicatedEntries = new Map<string, IStatusbarEntryAccessor>();102private readonly _renderDisposables = new DisposableStore();103104private readonly _combinedEntryTooltip = document.createElement('div');105106constructor(107@ILanguageStatusService private readonly _languageStatusService: ILanguageStatusService,108@IStatusbarService private readonly _statusBarService: IStatusbarService,109@IEditorService private readonly _editorService: IEditorService,110@IHoverService private readonly _hoverService: IHoverService,111@IOpenerService private readonly _openerService: IOpenerService,112@IStorageService private readonly _storageService: IStorageService,113) {114_storageService.onDidChangeValue(StorageScope.PROFILE, LanguageStatus._keyDedicatedItems, this._disposables)(this._handleStorageChange, this, this._disposables);115this._restoreState();116this._interactionCounter = new StoredCounter(_storageService, 'languageStatus.interactCount');117118_languageStatusService.onDidChange(this._update, this, this._disposables);119_editorService.onDidActiveEditorChange(this._update, this, this._disposables);120this._update();121122_statusBarService.onDidChangeEntryVisibility(e => {123if (!e.visible && this._dedicated.has(e.id)) {124this._dedicated.delete(e.id);125this._update();126this._storeState();127}128}, undefined, this._disposables);129130}131132dispose(): void {133this._disposables.dispose();134this._combinedEntry?.dispose();135dispose(this._dedicatedEntries.values());136this._renderDisposables.dispose();137}138139// --- persisting dedicated items140141private _handleStorageChange() {142this._restoreState();143this._update();144}145146private _restoreState(): void {147const raw = this._storageService.get(LanguageStatus._keyDedicatedItems, StorageScope.PROFILE, '[]');148try {149const ids = <string[]>JSON.parse(raw);150this._dedicated = new Set(ids);151} catch {152this._dedicated.clear();153}154}155156private _storeState(): void {157if (this._dedicated.size === 0) {158this._storageService.remove(LanguageStatus._keyDedicatedItems, StorageScope.PROFILE);159} else {160const raw = JSON.stringify(Array.from(this._dedicated.keys()));161this._storageService.store(LanguageStatus._keyDedicatedItems, raw, StorageScope.PROFILE, StorageTarget.USER);162}163}164165// --- language status model and UI166167private _createViewModel(editor: ICodeEditor | null): LanguageStatusViewModel {168if (!editor?.hasModel()) {169return new LanguageStatusViewModel([], []);170}171const all = this._languageStatusService.getLanguageStatus(editor.getModel());172const combined: ILanguageStatus[] = [];173const dedicated: ILanguageStatus[] = [];174for (const item of all) {175if (this._dedicated.has(item.id)) {176dedicated.push(item);177}178combined.push(item);179}180return new LanguageStatusViewModel(combined, dedicated);181}182183private _update(): void {184const editor = getCodeEditor(this._editorService.activeTextEditorControl);185const model = this._createViewModel(editor);186187if (this._model?.isEqual(model)) {188return;189}190this._renderDisposables.clear();191192this._model = model;193194// update when editor language changes195editor?.onDidChangeModelLanguage(this._update, this, this._renderDisposables);196197// combined status bar item is a single item which hover shows198// each status item199if (model.combined.length === 0) {200// nothing201this._combinedEntry?.dispose();202this._combinedEntry = undefined;203204} else {205const [first] = model.combined;206const showSeverity = first.severity >= Severity.Warning;207const text = LanguageStatus._severityToComboCodicon(first.severity);208209let isOneBusy = false;210const ariaLabels: string[] = [];211for (const status of model.combined) {212const isPinned = model.dedicated.includes(status);213this._renderStatus(this._combinedEntryTooltip, status, showSeverity, isPinned, this._renderDisposables);214ariaLabels.push(LanguageStatus._accessibilityInformation(status).label);215isOneBusy = isOneBusy || (!isPinned && status.busy); // unpinned items contribute to the busy-indicator of the composite status item216}217218const props: IStatusbarEntry = {219name: localize('langStatus.name', "Editor Language Status"),220ariaLabel: localize('langStatus.aria', "Editor Language Status: {0}", ariaLabels.join(', next: ')),221tooltip: this._combinedEntryTooltip,222command: ShowTooltipCommand,223text: isOneBusy ? '$(loading~spin)' : text,224};225if (!this._combinedEntry) {226this._combinedEntry = this._statusBarService.addEntry(props, LanguageStatus._id, StatusbarAlignment.RIGHT, { location: { id: 'status.editor.mode', priority: 100.1 }, alignment: StatusbarAlignment.LEFT, compact: true });227} else {228this._combinedEntry.update(props);229}230231// animate the status bar icon whenever language status changes, repeat animation232// when severity is warning or error, don't show animation when showing progress/busy233const userHasInteractedWithStatus = this._interactionCounter.value >= 3;234const targetWindow = dom.getWindow(editor?.getContainerDomNode());235// eslint-disable-next-line no-restricted-syntax236const node = targetWindow.document.querySelector('.monaco-workbench .statusbar DIV#status\\.languageStatus A>SPAN.codicon');237// eslint-disable-next-line no-restricted-syntax238const container = targetWindow.document.querySelector('.monaco-workbench .statusbar DIV#status\\.languageStatus');239if (dom.isHTMLElement(node) && container) {240const _wiggle = 'wiggle';241const _flash = 'flash';242if (!isOneBusy) {243// wiggle icon when severe or "new"244node.classList.toggle(_wiggle, showSeverity || !userHasInteractedWithStatus);245this._renderDisposables.add(dom.addDisposableListener(node, 'animationend', _e => node.classList.remove(_wiggle)));246// flash background when severe247container.classList.toggle(_flash, showSeverity);248this._renderDisposables.add(dom.addDisposableListener(container, 'animationend', _e => container.classList.remove(_flash)));249} else {250node.classList.remove(_wiggle);251container.classList.remove(_flash);252}253}254255// track when the hover shows (this is automagic and DOM mutation spying is needed...)256// use that as signal that the user has interacted/learned language status items work257if (!userHasInteractedWithStatus) {258// eslint-disable-next-line no-restricted-syntax259const hoverTarget = targetWindow.document.querySelector('.monaco-workbench .context-view');260if (dom.isHTMLElement(hoverTarget)) {261const observer = new MutationObserver(() => {262if (targetWindow.document.contains(this._combinedEntryTooltip)) {263this._interactionCounter.increment();264observer.disconnect();265}266});267observer.observe(hoverTarget, { childList: true, subtree: true });268this._renderDisposables.add(toDisposable(() => observer.disconnect()));269}270}271}272273// dedicated status bar items are shows as-is in the status bar274const newDedicatedEntries = new Map<string, IStatusbarEntryAccessor>();275for (const status of model.dedicated) {276const props = LanguageStatus._asStatusbarEntry(status);277let entry = this._dedicatedEntries.get(status.id);278if (!entry) {279entry = this._statusBarService.addEntry(props, status.id, StatusbarAlignment.RIGHT, { location: { id: 'status.editor.mode', priority: 100.1 }, alignment: StatusbarAlignment.RIGHT });280} else {281entry.update(props);282this._dedicatedEntries.delete(status.id);283}284newDedicatedEntries.set(status.id, entry);285}286dispose(this._dedicatedEntries.values());287this._dedicatedEntries = newDedicatedEntries;288}289290private _renderStatus(container: HTMLElement, status: ILanguageStatus, showSeverity: boolean, isPinned: boolean, store: DisposableStore): HTMLElement {291292const parent = document.createElement('div');293parent.classList.add('hover-language-status');294295container.appendChild(parent);296store.add(toDisposable(() => parent.remove()));297298const severity = document.createElement('div');299severity.classList.add('severity', `sev${status.severity}`);300severity.classList.toggle('show', showSeverity);301const severityText = LanguageStatus._severityToSingleCodicon(status.severity);302dom.append(severity, ...renderLabelWithIcons(severityText));303parent.appendChild(severity);304305const element = document.createElement('div');306element.classList.add('element');307parent.appendChild(element);308309const left = document.createElement('div');310left.classList.add('left');311element.appendChild(left);312313const label = typeof status.label === 'string' ? status.label : status.label.value;314dom.append(left, ...renderLabelWithIcons(computeText(label, status.busy)));315316this._renderTextPlus(left, status.detail, store);317318const right = document.createElement('div');319right.classList.add('right');320element.appendChild(right);321322// -- command (if available)323const { command } = status;324if (command) {325store.add(new Link(right, {326label: command.title,327title: command.tooltip,328href: URI.from({329scheme: 'command', path: command.id, query: command.arguments && JSON.stringify(command.arguments)330}).toString()331}, { hoverDelegate: nativeHoverDelegate }, this._hoverService, this._openerService));332}333334// -- pin335const actionBar = new ActionBar(right, { hoverDelegate: nativeHoverDelegate });336const actionLabel: string = isPinned ? localize('unpin', "Remove from Status Bar") : localize('pin', "Add to Status Bar");337actionBar.setAriaLabel(actionLabel);338store.add(actionBar);339let action: Action;340if (!isPinned) {341action = new Action('pin', actionLabel, ThemeIcon.asClassName(Codicon.pin), true, () => {342this._dedicated.add(status.id);343this._statusBarService.updateEntryVisibility(status.id, true);344this._update();345this._storeState();346});347} else {348action = new Action('unpin', actionLabel, ThemeIcon.asClassName(Codicon.pinned), true, () => {349this._dedicated.delete(status.id);350this._statusBarService.updateEntryVisibility(status.id, false);351this._update();352this._storeState();353});354}355actionBar.push(action, { icon: true, label: false });356store.add(action);357358return parent;359}360361private static _severityToComboCodicon(sev: Severity): string {362switch (sev) {363case Severity.Error: return '$(bracket-error)';364case Severity.Warning: return '$(bracket-dot)';365default: return '$(bracket)';366}367}368369private static _severityToSingleCodicon(sev: Severity): string {370switch (sev) {371case Severity.Error: return '$(error)';372case Severity.Warning: return '$(info)';373default: return '$(check)';374}375}376377private _renderTextPlus(target: HTMLElement, text: string, store: DisposableStore): void {378let didRenderSeparator = false;379for (const node of parseLinkedText(text).nodes) {380if (!didRenderSeparator) {381dom.append(target, dom.$('span.separator'));382didRenderSeparator = true;383}384if (typeof node === 'string') {385const parts = renderLabelWithIcons(node);386dom.append(target, ...parts);387} else {388store.add(new Link(target, node, undefined, this._hoverService, this._openerService));389}390}391}392393private static _accessibilityInformation(status: ILanguageStatus): IAccessibilityInformation {394if (status.accessibilityInfo) {395return status.accessibilityInfo;396}397const textValue = typeof status.label === 'string' ? status.label : status.label.value;398if (status.detail) {399return { label: localize('aria.1', '{0}, {1}', textValue, status.detail) };400} else {401return { label: localize('aria.2', '{0}', textValue) };402}403}404405// ---406407private static _asStatusbarEntry(item: ILanguageStatus): IStatusbarEntry {408409let kind: StatusbarEntryKind | undefined;410if (item.severity === Severity.Warning) {411kind = 'warning';412} else if (item.severity === Severity.Error) {413kind = 'error';414}415416const textValue = typeof item.label === 'string' ? item.label : item.label.shortValue;417418return {419name: localize('name.pattern', '{0} (Language Status)', item.name),420text: computeText(textValue, item.busy),421ariaLabel: LanguageStatus._accessibilityInformation(item).label,422role: item.accessibilityInfo?.role,423tooltip: item.command?.tooltip || new MarkdownString(item.detail, { isTrusted: true, supportThemeIcons: true }),424kind,425command: item.command426};427}428}429430export class ResetAction extends Action2 {431432constructor() {433super({434id: 'editor.inlayHints.Reset',435title: localize2('reset', "Reset Language Status Interaction Counter"),436category: Categories.View,437f1: true438});439}440441run(accessor: ServicesAccessor): void {442accessor.get(IStorageService).remove('languageStatus.interactCount', StorageScope.PROFILE);443}444}445446function computeText(text: string, loading: boolean): string {447return joinStrings([text !== '' && text, loading && '$(loading~spin)'], '\u00A0\u00A0');448}449450451