Path: blob/main/src/vs/workbench/contrib/languageStatus/browser/languageStatus.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 './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());235const node = targetWindow.document.querySelector('.monaco-workbench .statusbar DIV#status\\.languageStatus A>SPAN.codicon');236const container = targetWindow.document.querySelector('.monaco-workbench .statusbar DIV#status\\.languageStatus');237if (dom.isHTMLElement(node) && container) {238const _wiggle = 'wiggle';239const _flash = 'flash';240if (!isOneBusy) {241// wiggle icon when severe or "new"242node.classList.toggle(_wiggle, showSeverity || !userHasInteractedWithStatus);243this._renderDisposables.add(dom.addDisposableListener(node, 'animationend', _e => node.classList.remove(_wiggle)));244// flash background when severe245container.classList.toggle(_flash, showSeverity);246this._renderDisposables.add(dom.addDisposableListener(container, 'animationend', _e => container.classList.remove(_flash)));247} else {248node.classList.remove(_wiggle);249container.classList.remove(_flash);250}251}252253// track when the hover shows (this is automagic and DOM mutation spying is needed...)254// use that as signal that the user has interacted/learned language status items work255if (!userHasInteractedWithStatus) {256const hoverTarget = targetWindow.document.querySelector('.monaco-workbench .context-view');257if (dom.isHTMLElement(hoverTarget)) {258const observer = new MutationObserver(() => {259if (targetWindow.document.contains(this._combinedEntryTooltip)) {260this._interactionCounter.increment();261observer.disconnect();262}263});264observer.observe(hoverTarget, { childList: true, subtree: true });265this._renderDisposables.add(toDisposable(() => observer.disconnect()));266}267}268}269270// dedicated status bar items are shows as-is in the status bar271const newDedicatedEntries = new Map<string, IStatusbarEntryAccessor>();272for (const status of model.dedicated) {273const props = LanguageStatus._asStatusbarEntry(status);274let entry = this._dedicatedEntries.get(status.id);275if (!entry) {276entry = this._statusBarService.addEntry(props, status.id, StatusbarAlignment.RIGHT, { location: { id: 'status.editor.mode', priority: 100.1 }, alignment: StatusbarAlignment.RIGHT });277} else {278entry.update(props);279this._dedicatedEntries.delete(status.id);280}281newDedicatedEntries.set(status.id, entry);282}283dispose(this._dedicatedEntries.values());284this._dedicatedEntries = newDedicatedEntries;285}286287private _renderStatus(container: HTMLElement, status: ILanguageStatus, showSeverity: boolean, isPinned: boolean, store: DisposableStore): HTMLElement {288289const parent = document.createElement('div');290parent.classList.add('hover-language-status');291292container.appendChild(parent);293store.add(toDisposable(() => parent.remove()));294295const severity = document.createElement('div');296severity.classList.add('severity', `sev${status.severity}`);297severity.classList.toggle('show', showSeverity);298const severityText = LanguageStatus._severityToSingleCodicon(status.severity);299dom.append(severity, ...renderLabelWithIcons(severityText));300parent.appendChild(severity);301302const element = document.createElement('div');303element.classList.add('element');304parent.appendChild(element);305306const left = document.createElement('div');307left.classList.add('left');308element.appendChild(left);309310const label = typeof status.label === 'string' ? status.label : status.label.value;311dom.append(left, ...renderLabelWithIcons(computeText(label, status.busy)));312313this._renderTextPlus(left, status.detail, store);314315const right = document.createElement('div');316right.classList.add('right');317element.appendChild(right);318319// -- command (if available)320const { command } = status;321if (command) {322store.add(new Link(right, {323label: command.title,324title: command.tooltip,325href: URI.from({326scheme: 'command', path: command.id, query: command.arguments && JSON.stringify(command.arguments)327}).toString()328}, { hoverDelegate: nativeHoverDelegate }, this._hoverService, this._openerService));329}330331// -- pin332const actionBar = new ActionBar(right, { hoverDelegate: nativeHoverDelegate });333const actionLabel: string = isPinned ? localize('unpin', "Remove from Status Bar") : localize('pin', "Add to Status Bar");334actionBar.setAriaLabel(actionLabel);335store.add(actionBar);336let action: Action;337if (!isPinned) {338action = new Action('pin', actionLabel, ThemeIcon.asClassName(Codicon.pin), true, () => {339this._dedicated.add(status.id);340this._statusBarService.updateEntryVisibility(status.id, true);341this._update();342this._storeState();343});344} else {345action = new Action('unpin', actionLabel, ThemeIcon.asClassName(Codicon.pinned), true, () => {346this._dedicated.delete(status.id);347this._statusBarService.updateEntryVisibility(status.id, false);348this._update();349this._storeState();350});351}352actionBar.push(action, { icon: true, label: false });353store.add(action);354355return parent;356}357358private static _severityToComboCodicon(sev: Severity): string {359switch (sev) {360case Severity.Error: return '$(bracket-error)';361case Severity.Warning: return '$(bracket-dot)';362default: return '$(bracket)';363}364}365366private static _severityToSingleCodicon(sev: Severity): string {367switch (sev) {368case Severity.Error: return '$(error)';369case Severity.Warning: return '$(info)';370default: return '$(check)';371}372}373374private _renderTextPlus(target: HTMLElement, text: string, store: DisposableStore): void {375let didRenderSeparator = false;376for (const node of parseLinkedText(text).nodes) {377if (!didRenderSeparator) {378dom.append(target, dom.$('span.separator'));379didRenderSeparator = true;380}381if (typeof node === 'string') {382const parts = renderLabelWithIcons(node);383dom.append(target, ...parts);384} else {385store.add(new Link(target, node, undefined, this._hoverService, this._openerService));386}387}388}389390private static _accessibilityInformation(status: ILanguageStatus): IAccessibilityInformation {391if (status.accessibilityInfo) {392return status.accessibilityInfo;393}394const textValue = typeof status.label === 'string' ? status.label : status.label.value;395if (status.detail) {396return { label: localize('aria.1', '{0}, {1}', textValue, status.detail) };397} else {398return { label: localize('aria.2', '{0}', textValue) };399}400}401402// ---403404private static _asStatusbarEntry(item: ILanguageStatus): IStatusbarEntry {405406let kind: StatusbarEntryKind | undefined;407if (item.severity === Severity.Warning) {408kind = 'warning';409} else if (item.severity === Severity.Error) {410kind = 'error';411}412413const textValue = typeof item.label === 'string' ? item.label : item.label.shortValue;414415return {416name: localize('name.pattern', '{0} (Language Status)', item.name),417text: computeText(textValue, item.busy),418ariaLabel: LanguageStatus._accessibilityInformation(item).label,419role: item.accessibilityInfo?.role,420tooltip: item.command?.tooltip || new MarkdownString(item.detail, { isTrusted: true, supportThemeIcons: true }),421kind,422command: item.command423};424}425}426427export class ResetAction extends Action2 {428429constructor() {430super({431id: 'editor.inlayHints.Reset',432title: localize2('reset', "Reset Language Status Interaction Counter"),433category: Categories.View,434f1: true435});436}437438run(accessor: ServicesAccessor): void {439accessor.get(IStorageService).remove('languageStatus.interactCount', StorageScope.PROFILE);440}441}442443function computeText(text: string, loading: boolean): string {444return joinStrings([text !== '' && text, loading && '$(loading~spin)'], '\u00A0\u00A0');445}446447448