vscode/src/vs/workbench/contrib/languageStatus/browser/languageStatus.contribution.ts

333 lines
12 KiB
TypeScript

/*---------------------------------------------------------------------------------------------
* Copyright (c) Microsoft Corporation. All rights reserved.
* Licensed under the MIT License. See License.txt in the project root for license information.
*--------------------------------------------------------------------------------------------*/
import 'vs/css!./media/languageStatus';
import * as dom from 'vs/base/browser/dom';
import { renderLabelWithIcons } from 'vs/base/browser/ui/iconLabel/iconLabels';
import { DisposableStore, dispose } from 'vs/base/common/lifecycle';
import Severity from 'vs/base/common/severity';
import { getCodeEditor } from 'vs/editor/browser/editorBrowser';
import { localize } from 'vs/nls';
import { Registry } from 'vs/platform/registry/common/platform';
import { ThemeColor, themeColorFromId } from 'vs/platform/theme/common/themeService';
import { IWorkbenchContributionsRegistry, Extensions as WorkbenchExtensions, IWorkbenchContribution } from 'vs/workbench/common/contributions';
import { STATUS_BAR_ERROR_ITEM_BACKGROUND, STATUS_BAR_ERROR_ITEM_FOREGROUND, STATUS_BAR_WARNING_ITEM_BACKGROUND, STATUS_BAR_WARNING_ITEM_FOREGROUND } from 'vs/workbench/common/theme';
import { IEditorService } from 'vs/workbench/services/editor/common/editorService';
import { ILanguageStatus, ILanguageStatusService } from 'vs/workbench/services/languageStatus/common/languageStatusService';
import { LifecyclePhase } from 'vs/workbench/services/lifecycle/common/lifecycle';
import { IStatusbarEntry, IStatusbarEntryAccessor, IStatusbarService, ShowTooltipCommand, StatusbarAlignment } from 'vs/workbench/services/statusbar/browser/statusbar';
import { parseLinkedText } from 'vs/base/common/linkedText';
import { Link } from 'vs/platform/opener/browser/link';
import { IOpenerService } from 'vs/platform/opener/common/opener';
import { MarkdownString } from 'vs/base/common/htmlContent';
import { ActionBar } from 'vs/base/browser/ui/actionbar/actionbar';
import { Action } from 'vs/base/common/actions';
import { Codicon } from 'vs/base/common/codicons';
import { IStorageService, IStorageValueChangeEvent, StorageScope, StorageTarget } from 'vs/platform/storage/common/storage';
import { equals } from 'vs/base/common/arrays';
import { URI } from 'vs/base/common/uri';
class LanguageStatusViewModel {
constructor(
readonly combined: readonly ILanguageStatus[],
readonly dedicated: readonly ILanguageStatus[]
) { }
isEqual(other: LanguageStatusViewModel) {
return equals(this.combined, other.combined) && equals(this.dedicated, other.dedicated);
}
}
class EditorStatusContribution implements IWorkbenchContribution {
private static readonly _id = 'status.languageStatus';
private static readonly _keyDedicatedItems = 'languageStatus.dedicated';
private readonly _disposables = new DisposableStore();
private _dedicated = new Set<string>();
private _model?: LanguageStatusViewModel;
private _combinedEntry?: IStatusbarEntryAccessor;
private _dedicatedEntries = new Map<string, IStatusbarEntryAccessor>();
private _renderDisposables = new DisposableStore();
constructor(
@ILanguageStatusService private readonly _languageStatusService: ILanguageStatusService,
@IStatusbarService private readonly _statusBarService: IStatusbarService,
@IEditorService private readonly _editorService: IEditorService,
@IOpenerService private readonly _openerService: IOpenerService,
@IStorageService private readonly _storageService: IStorageService,
) {
_storageService.onDidChangeValue(this._handleStorageChange, this, this._disposables);
this._restoreState();
_editorService.onDidActiveEditorChange(() => this._update(), this, this._disposables);
_languageStatusService.onDidChange(() => this._update(), this, this._disposables);
_languageStatusService.onDidChangeBusy(() => this._update(true), this, this._disposables);
this._update();
_statusBarService.onDidChangeEntryVisibility(e => {
if (!e.visible && this._dedicated.has(e.id)) {
this._dedicated.delete(e.id);
this._update();
this._storeState();
}
}, this._disposables);
}
dispose(): void {
this._disposables.dispose();
this._combinedEntry?.dispose();
dispose(this._dedicatedEntries.values());
this._renderDisposables.dispose();
}
// --- persisting dedicated items
private _handleStorageChange(e: IStorageValueChangeEvent) {
if (e.key !== EditorStatusContribution._keyDedicatedItems) {
return;
}
this._restoreState();
this._update();
}
private _restoreState(): void {
const raw = this._storageService.get(EditorStatusContribution._keyDedicatedItems, StorageScope.GLOBAL, '[]');
try {
const ids = <string[]>JSON.parse(raw);
this._dedicated = new Set(ids);
} catch {
this._dedicated.clear();
}
}
private _storeState(): void {
if (this._dedicated.size === 0) {
this._storageService.remove(EditorStatusContribution._keyDedicatedItems, StorageScope.GLOBAL);
} else {
const raw = JSON.stringify(Array.from(this._dedicated.keys()));
this._storageService.store(EditorStatusContribution._keyDedicatedItems, raw, StorageScope.GLOBAL, StorageTarget.USER);
}
}
// --- language status model and UI
private _createViewModel(): LanguageStatusViewModel {
const editor = getCodeEditor(this._editorService.activeTextEditorControl);
if (!editor?.hasModel()) {
return new LanguageStatusViewModel([], []);
}
const all = this._languageStatusService.getLanguageStatus(editor.getModel());
const combined: ILanguageStatus[] = [];
const dedicated: ILanguageStatus[] = [];
for (let item of all) {
if (this._dedicated.has(item.id)) {
dedicated.push(item);
} else {
combined.push(item);
}
}
return new LanguageStatusViewModel(combined, dedicated);
}
private _update(force?: boolean): void {
const model = this._createViewModel();
if (this._model?.isEqual(model) && !force) {
return;
}
this._model = model;
this._renderDisposables.clear();
// combined status bar item is a single item which hover shows
// each status item
if (model.combined.length === 0) {
// nothing
this._combinedEntry?.dispose();
this._combinedEntry = undefined;
} else {
const [first] = model.combined;
const showSeverity = first.severity >= Severity.Warning;
const text = EditorStatusContribution._severityToComboCodicon(first.severity);
let isBusy = false;
const ariaLabels: string[] = [];
const element = document.createElement('div');
for (const status of model.combined) {
const thisIsBusy = this._languageStatusService.isBusy(status);
element.appendChild(this._renderStatus(status, showSeverity, thisIsBusy, this._renderDisposables));
ariaLabels.push(this._asAriaLabel(status));
isBusy = isBusy || thisIsBusy;
}
const props: IStatusbarEntry = {
name: localize('langStatus.name', "Editor Language Status"),
ariaLabel: localize('langStatus.aria', "Editor Language Status: {0}", ariaLabels.join(', next: ')),
tooltip: element,
command: ShowTooltipCommand,
text: isBusy ? `${text}\u00A0\u00A0$(loading~spin)` : text,
};
if (!this._combinedEntry) {
this._combinedEntry = this._statusBarService.addEntry(props, EditorStatusContribution._id, StatusbarAlignment.RIGHT, { id: 'status.editor.mode', alignment: StatusbarAlignment.LEFT, compact: true });
} else {
this._combinedEntry.update(props);
}
}
// dedicated status bar items are shows as-is in the status bar
const newDedicatedEntries = new Map<string, IStatusbarEntryAccessor>();
for (const status of model.dedicated) {
const props = EditorStatusContribution._asStatusbarEntry(status, this._languageStatusService.isBusy(status));
let entry = this._dedicatedEntries.get(status.id);
if (!entry) {
entry = this._statusBarService.addEntry(props, status.id, StatusbarAlignment.RIGHT, 100.09999);
} else {
entry.update(props);
this._dedicatedEntries.delete(status.id);
}
newDedicatedEntries.set(status.id, entry);
}
dispose(this._dedicatedEntries.values());
this._dedicatedEntries = newDedicatedEntries;
}
private _renderStatus(status: ILanguageStatus, showSeverity: boolean, isBusy: boolean, store: DisposableStore): HTMLElement {
const parent = document.createElement('div');
parent.classList.add('hover-language-status');
const severity = document.createElement('div');
severity.classList.add('severity', `sev${status.severity}`);
severity.classList.toggle('show', showSeverity);
const severityText = EditorStatusContribution._severityToSingleCodicon(status.severity);
dom.append(severity, ...renderLabelWithIcons(severityText));
parent.appendChild(severity);
const element = document.createElement('div');
element.classList.add('element');
parent.appendChild(element);
const left = document.createElement('div');
left.classList.add('left');
element.appendChild(left);
const label = document.createElement('span');
label.classList.add('label');
if (isBusy) {
dom.append(label, ...renderLabelWithIcons('$(loading~spin)\u00A0\u00A0'));
}
dom.append(label, ...renderLabelWithIcons(status.label));
left.appendChild(label);
const detail = document.createElement('span');
detail.classList.add('detail');
this._renderTextPlus(detail, status.detail, store);
left.appendChild(detail);
const right = document.createElement('div');
right.classList.add('right');
element.appendChild(right);
// -- command (if available)
const { command } = status;
if (command) {
store.add(new Link(right, {
label: command.title,
title: command.tooltip,
href: URI.from({
scheme: 'command', path: command.id, query: command.arguments && JSON.stringify(command.arguments)
}).toString()
}, undefined, this._openerService));
}
// -- pin
const actionBar = new ActionBar(right, {});
store.add(actionBar);
const action = new Action('pin', localize('pin', "Pin to Status Bar"), Codicon.pin.classNames, true, () => {
this._dedicated.add(status.id);
this._statusBarService.updateEntryVisibility(status.id, true);
this._update();
this._storeState();
});
actionBar.push(action, { icon: true, label: false });
store.add(action);
return parent;
}
private static _severityToComboCodicon(sev: Severity): string {
switch (sev) {
case Severity.Error: return '$(bracket-error)';
case Severity.Warning: return '$(bracket-dot)';
default: return '$(bracket)';
}
}
private static _severityToSingleCodicon(sev: Severity): string {
switch (sev) {
case Severity.Error: return '$(error)';
case Severity.Warning: return '$(info)';
default: return '$(check)';
}
}
private _renderTextPlus(target: HTMLElement, text: string, store: DisposableStore): void {
for (let node of parseLinkedText(text).nodes) {
if (typeof node === 'string') {
const parts = renderLabelWithIcons(node);
dom.append(target, ...parts);
} else {
store.add(new Link(target, node, undefined, this._openerService));
}
}
}
private _asAriaLabel(status: ILanguageStatus): string {
if (status.accessibilityInfo) {
return status.accessibilityInfo.label;
} else if (status.detail) {
return localize('aria.1', '{0}, {1}', status.label, status.detail);
} else {
return localize('aria.2', '{0}', status.label);
}
}
// ---
private static _asStatusbarEntry(item: ILanguageStatus, isBusy: boolean): IStatusbarEntry {
let color: ThemeColor | undefined;
let backgroundColor: ThemeColor | undefined;
if (item.severity === Severity.Warning) {
color = themeColorFromId(STATUS_BAR_WARNING_ITEM_FOREGROUND);
backgroundColor = themeColorFromId(STATUS_BAR_WARNING_ITEM_BACKGROUND);
} else if (item.severity === Severity.Error) {
color = themeColorFromId(STATUS_BAR_ERROR_ITEM_FOREGROUND);
backgroundColor = themeColorFromId(STATUS_BAR_ERROR_ITEM_BACKGROUND);
}
return {
name: localize('name.pattern', '{0} (Language Status)', item.name),
text: isBusy ? `${item.label}\u00A0\u00A0$(loading~spin)` : item.label,
ariaLabel: item.accessibilityInfo?.label ?? item.label,
role: item.accessibilityInfo?.role,
tooltip: item.command?.tooltip || new MarkdownString(item.detail, true),
color,
backgroundColor,
command: item.command
};
}
}
Registry.as<IWorkbenchContributionsRegistry>(WorkbenchExtensions.Workbench).registerWorkbenchContribution(EditorStatusContribution, LifecyclePhase.Restored);