Book a Demo!
CoCalc Logo Icon
StoreFeaturesDocsShareSupportNewsAboutPoliciesSign UpSign In
microsoft
GitHub Repository: microsoft/vscode
Path: blob/main/src/vs/workbench/contrib/languageStatus/browser/languageStatus.ts
5249 views
1
/*---------------------------------------------------------------------------------------------
2
* Copyright (c) Microsoft Corporation. All rights reserved.
3
* Licensed under the MIT License. See License.txt in the project root for license information.
4
*--------------------------------------------------------------------------------------------*/
5
6
import './media/languageStatus.css';
7
import * as dom from '../../../../base/browser/dom.js';
8
import { renderLabelWithIcons } from '../../../../base/browser/ui/iconLabel/iconLabels.js';
9
import { Disposable, DisposableStore, dispose, toDisposable } from '../../../../base/common/lifecycle.js';
10
import Severity from '../../../../base/common/severity.js';
11
import { getCodeEditor, ICodeEditor } from '../../../../editor/browser/editorBrowser.js';
12
import { localize, localize2 } from '../../../../nls.js';
13
import { ThemeIcon } from '../../../../base/common/themables.js';
14
import { IWorkbenchContribution } from '../../../common/contributions.js';
15
import { IEditorService } from '../../../services/editor/common/editorService.js';
16
import { ILanguageStatus, ILanguageStatusService } from '../../../services/languageStatus/common/languageStatusService.js';
17
import { IStatusbarEntry, IStatusbarEntryAccessor, IStatusbarService, ShowTooltipCommand, StatusbarAlignment, StatusbarEntryKind } from '../../../services/statusbar/browser/statusbar.js';
18
import { parseLinkedText } from '../../../../base/common/linkedText.js';
19
import { Link } from '../../../../platform/opener/browser/link.js';
20
import { IOpenerService } from '../../../../platform/opener/common/opener.js';
21
import { MarkdownString } from '../../../../base/common/htmlContent.js';
22
import { ActionBar } from '../../../../base/browser/ui/actionbar/actionbar.js';
23
import { Action } from '../../../../base/common/actions.js';
24
import { Codicon } from '../../../../base/common/codicons.js';
25
import { IStorageService, StorageScope, StorageTarget } from '../../../../platform/storage/common/storage.js';
26
import { equals } from '../../../../base/common/arrays.js';
27
import { URI } from '../../../../base/common/uri.js';
28
import { Action2 } from '../../../../platform/actions/common/actions.js';
29
import { ServicesAccessor } from '../../../../platform/instantiation/common/instantiation.js';
30
import { Categories } from '../../../../platform/action/common/actionCommonCategories.js';
31
import { IAccessibilityInformation } from '../../../../platform/accessibility/common/accessibility.js';
32
import { IEditorGroupsService, IEditorPart } from '../../../services/editor/common/editorGroupsService.js';
33
import { IHoverService, nativeHoverDelegate } from '../../../../platform/hover/browser/hover.js';
34
import { Event } from '../../../../base/common/event.js';
35
import { joinStrings } from '../../../../base/common/strings.js';
36
37
class LanguageStatusViewModel {
38
39
constructor(
40
readonly combined: readonly ILanguageStatus[],
41
readonly dedicated: readonly ILanguageStatus[]
42
) { }
43
44
isEqual(other: LanguageStatusViewModel) {
45
return equals(this.combined, other.combined) && equals(this.dedicated, other.dedicated);
46
}
47
}
48
49
class StoredCounter {
50
51
constructor(@IStorageService private readonly _storageService: IStorageService, private readonly _key: string) { }
52
53
get value() {
54
return this._storageService.getNumber(this._key, StorageScope.PROFILE, 0);
55
}
56
57
increment(): number {
58
const n = this.value + 1;
59
this._storageService.store(this._key, n, StorageScope.PROFILE, StorageTarget.MACHINE);
60
return n;
61
}
62
}
63
64
export class LanguageStatusContribution extends Disposable implements IWorkbenchContribution {
65
66
static readonly Id = 'status.languageStatus';
67
68
constructor(
69
@IEditorGroupsService private readonly editorGroupService: IEditorGroupsService,
70
) {
71
super();
72
73
for (const part of editorGroupService.parts) {
74
this.createLanguageStatus(part);
75
}
76
77
this._register(editorGroupService.onDidCreateAuxiliaryEditorPart(part => this.createLanguageStatus(part)));
78
}
79
80
private createLanguageStatus(part: IEditorPart): void {
81
const disposables = new DisposableStore();
82
Event.once(part.onWillDispose)(() => disposables.dispose());
83
84
const scopedInstantiationService = this.editorGroupService.getScopedInstantiationService(part);
85
disposables.add(scopedInstantiationService.createInstance(LanguageStatus));
86
}
87
}
88
89
class LanguageStatus {
90
91
private static readonly _id = 'status.languageStatus';
92
93
private static readonly _keyDedicatedItems = 'languageStatus.dedicated';
94
95
private readonly _disposables = new DisposableStore();
96
private readonly _interactionCounter: StoredCounter;
97
98
private _dedicated = new Set<string>();
99
100
private _model?: LanguageStatusViewModel;
101
private _combinedEntry?: IStatusbarEntryAccessor;
102
private _dedicatedEntries = new Map<string, IStatusbarEntryAccessor>();
103
private readonly _renderDisposables = new DisposableStore();
104
105
private readonly _combinedEntryTooltip = document.createElement('div');
106
107
constructor(
108
@ILanguageStatusService private readonly _languageStatusService: ILanguageStatusService,
109
@IStatusbarService private readonly _statusBarService: IStatusbarService,
110
@IEditorService private readonly _editorService: IEditorService,
111
@IHoverService private readonly _hoverService: IHoverService,
112
@IOpenerService private readonly _openerService: IOpenerService,
113
@IStorageService private readonly _storageService: IStorageService,
114
) {
115
_storageService.onDidChangeValue(StorageScope.PROFILE, LanguageStatus._keyDedicatedItems, this._disposables)(this._handleStorageChange, this, this._disposables);
116
this._restoreState();
117
this._interactionCounter = new StoredCounter(_storageService, 'languageStatus.interactCount');
118
119
_languageStatusService.onDidChange(this._update, this, this._disposables);
120
_editorService.onDidActiveEditorChange(this._update, this, this._disposables);
121
this._update();
122
123
_statusBarService.onDidChangeEntryVisibility(e => {
124
if (!e.visible && this._dedicated.has(e.id)) {
125
this._dedicated.delete(e.id);
126
this._update();
127
this._storeState();
128
}
129
}, undefined, this._disposables);
130
131
}
132
133
dispose(): void {
134
this._disposables.dispose();
135
this._combinedEntry?.dispose();
136
dispose(this._dedicatedEntries.values());
137
this._renderDisposables.dispose();
138
}
139
140
// --- persisting dedicated items
141
142
private _handleStorageChange() {
143
this._restoreState();
144
this._update();
145
}
146
147
private _restoreState(): void {
148
const raw = this._storageService.get(LanguageStatus._keyDedicatedItems, StorageScope.PROFILE, '[]');
149
try {
150
const ids = <string[]>JSON.parse(raw);
151
this._dedicated = new Set(ids);
152
} catch {
153
this._dedicated.clear();
154
}
155
}
156
157
private _storeState(): void {
158
if (this._dedicated.size === 0) {
159
this._storageService.remove(LanguageStatus._keyDedicatedItems, StorageScope.PROFILE);
160
} else {
161
const raw = JSON.stringify(Array.from(this._dedicated.keys()));
162
this._storageService.store(LanguageStatus._keyDedicatedItems, raw, StorageScope.PROFILE, StorageTarget.USER);
163
}
164
}
165
166
// --- language status model and UI
167
168
private _createViewModel(editor: ICodeEditor | null): LanguageStatusViewModel {
169
if (!editor?.hasModel()) {
170
return new LanguageStatusViewModel([], []);
171
}
172
const all = this._languageStatusService.getLanguageStatus(editor.getModel());
173
const combined: ILanguageStatus[] = [];
174
const dedicated: ILanguageStatus[] = [];
175
for (const item of all) {
176
if (this._dedicated.has(item.id)) {
177
dedicated.push(item);
178
}
179
combined.push(item);
180
}
181
return new LanguageStatusViewModel(combined, dedicated);
182
}
183
184
private _update(): void {
185
const editor = getCodeEditor(this._editorService.activeTextEditorControl);
186
const model = this._createViewModel(editor);
187
188
if (this._model?.isEqual(model)) {
189
return;
190
}
191
this._renderDisposables.clear();
192
193
this._model = model;
194
195
// update when editor language changes
196
editor?.onDidChangeModelLanguage(this._update, this, this._renderDisposables);
197
198
// combined status bar item is a single item which hover shows
199
// each status item
200
if (model.combined.length === 0) {
201
// nothing
202
this._combinedEntry?.dispose();
203
this._combinedEntry = undefined;
204
205
} else {
206
const [first] = model.combined;
207
const showSeverity = first.severity >= Severity.Warning;
208
const text = LanguageStatus._severityToComboCodicon(first.severity);
209
210
let isOneBusy = false;
211
const ariaLabels: string[] = [];
212
for (const status of model.combined) {
213
const isPinned = model.dedicated.includes(status);
214
this._renderStatus(this._combinedEntryTooltip, status, showSeverity, isPinned, this._renderDisposables);
215
ariaLabels.push(LanguageStatus._accessibilityInformation(status).label);
216
isOneBusy = isOneBusy || (!isPinned && status.busy); // unpinned items contribute to the busy-indicator of the composite status item
217
}
218
219
const props: IStatusbarEntry = {
220
name: localize('langStatus.name', "Editor Language Status"),
221
ariaLabel: localize('langStatus.aria', "Editor Language Status: {0}", ariaLabels.join(', next: ')),
222
tooltip: this._combinedEntryTooltip,
223
command: ShowTooltipCommand,
224
text: isOneBusy ? '$(loading~spin)' : text,
225
};
226
if (!this._combinedEntry) {
227
this._combinedEntry = this._statusBarService.addEntry(props, LanguageStatus._id, StatusbarAlignment.RIGHT, { location: { id: 'status.editor.mode', priority: 100.1 }, alignment: StatusbarAlignment.LEFT, compact: true });
228
} else {
229
this._combinedEntry.update(props);
230
}
231
232
// animate the status bar icon whenever language status changes, repeat animation
233
// when severity is warning or error, don't show animation when showing progress/busy
234
const userHasInteractedWithStatus = this._interactionCounter.value >= 3;
235
const targetWindow = dom.getWindow(editor?.getContainerDomNode());
236
// eslint-disable-next-line no-restricted-syntax
237
const node = targetWindow.document.querySelector('.monaco-workbench .statusbar DIV#status\\.languageStatus A>SPAN.codicon');
238
// eslint-disable-next-line no-restricted-syntax
239
const container = targetWindow.document.querySelector('.monaco-workbench .statusbar DIV#status\\.languageStatus');
240
if (dom.isHTMLElement(node) && container) {
241
const _wiggle = 'wiggle';
242
const _flash = 'flash';
243
if (!isOneBusy) {
244
// wiggle icon when severe or "new"
245
node.classList.toggle(_wiggle, showSeverity || !userHasInteractedWithStatus);
246
this._renderDisposables.add(dom.addDisposableListener(node, 'animationend', _e => node.classList.remove(_wiggle)));
247
// flash background when severe
248
container.classList.toggle(_flash, showSeverity);
249
this._renderDisposables.add(dom.addDisposableListener(container, 'animationend', _e => container.classList.remove(_flash)));
250
} else {
251
node.classList.remove(_wiggle);
252
container.classList.remove(_flash);
253
}
254
}
255
256
// track when the hover shows (this is automagic and DOM mutation spying is needed...)
257
// use that as signal that the user has interacted/learned language status items work
258
if (!userHasInteractedWithStatus) {
259
// eslint-disable-next-line no-restricted-syntax
260
const hoverTarget = targetWindow.document.querySelector('.monaco-workbench .context-view');
261
if (dom.isHTMLElement(hoverTarget)) {
262
const observer = new MutationObserver(() => {
263
if (targetWindow.document.contains(this._combinedEntryTooltip)) {
264
this._interactionCounter.increment();
265
observer.disconnect();
266
}
267
});
268
observer.observe(hoverTarget, { childList: true, subtree: true });
269
this._renderDisposables.add(toDisposable(() => observer.disconnect()));
270
}
271
}
272
}
273
274
// dedicated status bar items are shows as-is in the status bar
275
const newDedicatedEntries = new Map<string, IStatusbarEntryAccessor>();
276
for (const status of model.dedicated) {
277
const props = LanguageStatus._asStatusbarEntry(status);
278
let entry = this._dedicatedEntries.get(status.id);
279
if (!entry) {
280
entry = this._statusBarService.addEntry(props, status.id, StatusbarAlignment.RIGHT, { location: { id: 'status.editor.mode', priority: 100.1 }, alignment: StatusbarAlignment.RIGHT });
281
} else {
282
entry.update(props);
283
this._dedicatedEntries.delete(status.id);
284
}
285
newDedicatedEntries.set(status.id, entry);
286
}
287
dispose(this._dedicatedEntries.values());
288
this._dedicatedEntries = newDedicatedEntries;
289
}
290
291
private _renderStatus(container: HTMLElement, status: ILanguageStatus, showSeverity: boolean, isPinned: boolean, store: DisposableStore): HTMLElement {
292
293
const parent = document.createElement('div');
294
parent.classList.add('hover-language-status');
295
296
container.appendChild(parent);
297
store.add(toDisposable(() => parent.remove()));
298
299
const severity = document.createElement('div');
300
severity.classList.add('severity', `sev${status.severity}`);
301
severity.classList.toggle('show', showSeverity);
302
const severityText = LanguageStatus._severityToSingleCodicon(status.severity);
303
dom.append(severity, ...renderLabelWithIcons(severityText));
304
parent.appendChild(severity);
305
306
const element = document.createElement('div');
307
element.classList.add('element');
308
parent.appendChild(element);
309
310
const left = document.createElement('div');
311
left.classList.add('left');
312
element.appendChild(left);
313
314
const label = typeof status.label === 'string' ? status.label : status.label.value;
315
dom.append(left, ...renderLabelWithIcons(computeText(label, status.busy)));
316
317
this._renderTextPlus(left, status.detail, store);
318
319
const right = document.createElement('div');
320
right.classList.add('right');
321
element.appendChild(right);
322
323
// -- command (if available)
324
const { command } = status;
325
if (command) {
326
store.add(new Link(right, {
327
label: command.title,
328
title: command.tooltip,
329
href: URI.from({
330
scheme: 'command', path: command.id, query: command.arguments && JSON.stringify(command.arguments)
331
}).toString()
332
}, { hoverDelegate: nativeHoverDelegate }, this._hoverService, this._openerService));
333
}
334
335
// -- pin
336
const actionBar = new ActionBar(right, { hoverDelegate: nativeHoverDelegate });
337
const actionLabel: string = isPinned ? localize('unpin', "Remove from Status Bar") : localize('pin', "Add to Status Bar");
338
actionBar.setAriaLabel(actionLabel);
339
store.add(actionBar);
340
let action: Action;
341
if (!isPinned) {
342
action = new Action('pin', actionLabel, ThemeIcon.asClassName(Codicon.pin), true, () => {
343
this._dedicated.add(status.id);
344
this._statusBarService.updateEntryVisibility(status.id, true);
345
this._update();
346
this._storeState();
347
});
348
} else {
349
action = new Action('unpin', actionLabel, ThemeIcon.asClassName(Codicon.pinned), true, () => {
350
this._dedicated.delete(status.id);
351
this._statusBarService.updateEntryVisibility(status.id, false);
352
this._update();
353
this._storeState();
354
});
355
}
356
actionBar.push(action, { icon: true, label: false });
357
store.add(action);
358
359
return parent;
360
}
361
362
private static _severityToComboCodicon(sev: Severity): string {
363
switch (sev) {
364
case Severity.Error: return '$(bracket-error)';
365
case Severity.Warning: return '$(bracket-dot)';
366
default: return '$(bracket)';
367
}
368
}
369
370
private static _severityToSingleCodicon(sev: Severity): string {
371
switch (sev) {
372
case Severity.Error: return '$(error)';
373
case Severity.Warning: return '$(info)';
374
default: return '$(check)';
375
}
376
}
377
378
private _renderTextPlus(target: HTMLElement, text: string, store: DisposableStore): void {
379
let didRenderSeparator = false;
380
for (const node of parseLinkedText(text).nodes) {
381
if (!didRenderSeparator) {
382
dom.append(target, dom.$('span.separator'));
383
didRenderSeparator = true;
384
}
385
if (typeof node === 'string') {
386
const parts = renderLabelWithIcons(node);
387
dom.append(target, ...parts);
388
} else {
389
store.add(new Link(target, node, undefined, this._hoverService, this._openerService));
390
}
391
}
392
}
393
394
private static _accessibilityInformation(status: ILanguageStatus): IAccessibilityInformation {
395
if (status.accessibilityInfo) {
396
return status.accessibilityInfo;
397
}
398
const textValue = typeof status.label === 'string' ? status.label : status.label.value;
399
if (status.detail) {
400
return { label: localize('aria.1', '{0}, {1}', textValue, status.detail) };
401
} else {
402
return { label: localize('aria.2', '{0}', textValue) };
403
}
404
}
405
406
// ---
407
408
private static _asStatusbarEntry(item: ILanguageStatus): IStatusbarEntry {
409
410
let kind: StatusbarEntryKind | undefined;
411
if (item.severity === Severity.Warning) {
412
kind = 'warning';
413
} else if (item.severity === Severity.Error) {
414
kind = 'error';
415
}
416
417
const textValue = typeof item.label === 'string' ? item.label : item.label.shortValue;
418
419
return {
420
name: localize('name.pattern', '{0} (Language Status)', item.name),
421
text: computeText(textValue, item.busy),
422
ariaLabel: LanguageStatus._accessibilityInformation(item).label,
423
role: item.accessibilityInfo?.role,
424
tooltip: item.command?.tooltip || new MarkdownString(item.detail, { isTrusted: true, supportThemeIcons: true }),
425
kind,
426
command: item.command
427
};
428
}
429
}
430
431
export class ResetAction extends Action2 {
432
433
constructor() {
434
super({
435
id: 'editor.inlayHints.Reset',
436
title: localize2('reset', "Reset Language Status Interaction Counter"),
437
category: Categories.View,
438
f1: true
439
});
440
}
441
442
run(accessor: ServicesAccessor): void {
443
accessor.get(IStorageService).remove('languageStatus.interactCount', StorageScope.PROFILE);
444
}
445
}
446
447
function computeText(text: string, loading: boolean): string {
448
return joinStrings([text !== '' && text, loading && '$(loading~spin)'], '\u00A0\u00A0');
449
}
450
451