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