Book a Demo!
CoCalc Logo Icon
StoreFeaturesDocsShareSupportNewsAboutPoliciesSign UpSign In
microsoft
GitHub Repository: microsoft/vscode
Path: blob/main/src/vs/workbench/browser/parts/notifications/notificationsToasts.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/notificationsToasts.css';
7
import { localize } from '../../../../nls.js';
8
import { INotificationsModel, NotificationChangeType, INotificationChangeEvent, INotificationViewItem, NotificationViewItemContentChangeKind } from '../../../common/notifications.js';
9
import { IDisposable, dispose, toDisposable, DisposableStore } from '../../../../base/common/lifecycle.js';
10
import { addDisposableListener, EventType, Dimension, scheduleAtNextAnimationFrame, isAncestorOfActiveElement, getWindow, $, isElementInBottomRightQuarter, isHTMLElement, isEditableElement, getActiveElement } from '../../../../base/browser/dom.js';
11
import { IInstantiationService } from '../../../../platform/instantiation/common/instantiation.js';
12
import { NotificationsList } from './notificationsList.js';
13
import { Event, Emitter } from '../../../../base/common/event.js';
14
import { IWorkbenchLayoutService, Parts } from '../../../services/layout/browser/layoutService.js';
15
import { NOTIFICATIONS_TOAST_BORDER, NOTIFICATIONS_BACKGROUND } from '../../../common/theme.js';
16
import { IThemeService, Themable } from '../../../../platform/theme/common/themeService.js';
17
import { widgetShadow } from '../../../../platform/theme/common/colorRegistry.js';
18
import { IEditorGroupsService } from '../../../services/editor/common/editorGroupsService.js';
19
import { INotificationsToastController } from './notificationsCommands.js';
20
import { IContextKey, IContextKeyService } from '../../../../platform/contextkey/common/contextkey.js';
21
import { Severity, NotificationsFilter, NotificationPriority, withSeverityPrefix } from '../../../../platform/notification/common/notification.js';
22
import { ScrollbarVisibility } from '../../../../base/common/scrollable.js';
23
import { ILifecycleService, LifecyclePhase } from '../../../services/lifecycle/common/lifecycle.js';
24
import { IHostService } from '../../../services/host/browser/host.js';
25
import { IntervalCounter } from '../../../../base/common/async.js';
26
import { assertReturnsDefined } from '../../../../base/common/types.js';
27
import { NotificationsToastsVisibleContext } from '../../../common/contextkeys.js';
28
import { mainWindow } from '../../../../base/browser/window.js';
29
30
interface INotificationToast {
31
readonly item: INotificationViewItem;
32
readonly list: NotificationsList;
33
readonly container: HTMLElement;
34
readonly toast: HTMLElement;
35
}
36
37
enum ToastVisibility {
38
HIDDEN_OR_VISIBLE,
39
HIDDEN,
40
VISIBLE
41
}
42
43
export class NotificationsToasts extends Themable implements INotificationsToastController {
44
45
private static readonly MAX_WIDTH = 450;
46
private static readonly MAX_NOTIFICATIONS = 3;
47
48
private static readonly PURGE_TIMEOUT: { [severity: number]: number } = {
49
[Severity.Info]: 10000,
50
[Severity.Warning]: 12000,
51
[Severity.Error]: 15000
52
};
53
54
private static readonly SPAM_PROTECTION = {
55
// Count for the number of notifications over 800ms...
56
interval: 800,
57
// ...and ensure we are not showing more than MAX_NOTIFICATIONS
58
limit: this.MAX_NOTIFICATIONS
59
};
60
61
private readonly _onDidChangeVisibility = this._register(new Emitter<void>());
62
readonly onDidChangeVisibility = this._onDidChangeVisibility.event;
63
64
private _isVisible = false;
65
get isVisible(): boolean { return !!this._isVisible; }
66
67
private notificationsToastsContainer: HTMLElement | undefined;
68
private workbenchDimensions: Dimension | undefined;
69
private isNotificationsCenterVisible: boolean | undefined;
70
71
private readonly mapNotificationToToast = new Map<INotificationViewItem, INotificationToast>();
72
private readonly mapNotificationToDisposable = new Map<INotificationViewItem, IDisposable>();
73
74
private readonly notificationsToastsVisibleContextKey: IContextKey<boolean>;
75
76
private readonly addedToastsIntervalCounter = new IntervalCounter(NotificationsToasts.SPAM_PROTECTION.interval);
77
78
constructor(
79
private readonly container: HTMLElement,
80
private readonly model: INotificationsModel,
81
@IInstantiationService private readonly instantiationService: IInstantiationService,
82
@IWorkbenchLayoutService private readonly layoutService: IWorkbenchLayoutService,
83
@IThemeService themeService: IThemeService,
84
@IEditorGroupsService private readonly editorGroupService: IEditorGroupsService,
85
@IContextKeyService contextKeyService: IContextKeyService,
86
@ILifecycleService private readonly lifecycleService: ILifecycleService,
87
@IHostService private readonly hostService: IHostService
88
) {
89
super(themeService);
90
91
this.notificationsToastsVisibleContextKey = NotificationsToastsVisibleContext.bindTo(contextKeyService);
92
93
this.registerListeners();
94
}
95
96
private registerListeners(): void {
97
98
// Layout
99
this._register(this.layoutService.onDidLayoutMainContainer(dimension => this.layout(Dimension.lift(dimension))));
100
101
// Delay some tasks until after we have restored
102
// to reduce UI pressure from the startup phase
103
this.lifecycleService.when(LifecyclePhase.Restored).then(() => {
104
105
// Show toast for initial notifications if any
106
this.model.notifications.forEach(notification => this.addToast(notification));
107
108
// Update toasts on notification changes
109
this._register(this.model.onDidChangeNotification(e => this.onDidChangeNotification(e)));
110
});
111
112
// Filter
113
this._register(this.model.onDidChangeFilter(({ global, sources }) => {
114
if (global === NotificationsFilter.ERROR) {
115
this.hide();
116
} else if (sources) {
117
for (const [notification] of this.mapNotificationToToast) {
118
if (typeof notification.sourceId === 'string' && sources.get(notification.sourceId) === NotificationsFilter.ERROR && notification.severity !== Severity.Error && notification.priority !== NotificationPriority.URGENT) {
119
this.removeToast(notification);
120
}
121
}
122
}
123
}));
124
}
125
126
private onDidChangeNotification(e: INotificationChangeEvent): void {
127
switch (e.kind) {
128
case NotificationChangeType.ADD:
129
return this.addToast(e.item);
130
case NotificationChangeType.REMOVE:
131
return this.removeToast(e.item);
132
}
133
}
134
135
private addToast(item: INotificationViewItem): void {
136
if (this.isNotificationsCenterVisible) {
137
return; // do not show toasts while notification center is visible
138
}
139
140
if (item.priority === NotificationPriority.SILENT) {
141
return; // do not show toasts for silenced notifications
142
}
143
144
if (item.priority === NotificationPriority.OPTIONAL) {
145
const activeElement = getActiveElement();
146
if (isHTMLElement(activeElement) && isEditableElement(activeElement) && isElementInBottomRightQuarter(activeElement, this.layoutService.mainContainer)) {
147
return; // skip showing optional toast that potentially covers input fields
148
}
149
}
150
151
// Optimization: it is possible that a lot of notifications are being
152
// added in a very short time. To prevent this kind of spam, we protect
153
// against showing too many notifications at once. Since they can always
154
// be accessed from the notification center, a user can always get to
155
// them later on.
156
// (see also https://github.com/microsoft/vscode/issues/107935)
157
if (this.addedToastsIntervalCounter.increment() > NotificationsToasts.SPAM_PROTECTION.limit) {
158
return;
159
}
160
161
// Optimization: showing a notification toast can be expensive
162
// because of the associated animation. If the renderer is busy
163
// doing actual work, the animation can cause a lot of slowdown
164
// As such we use `scheduleAtNextAnimationFrame` to push out
165
// the toast until the renderer has time to process it.
166
// (see also https://github.com/microsoft/vscode/issues/107935)
167
const itemDisposables = new DisposableStore();
168
this.mapNotificationToDisposable.set(item, itemDisposables);
169
itemDisposables.add(scheduleAtNextAnimationFrame(getWindow(this.container), () => this.doAddToast(item, itemDisposables)));
170
}
171
172
private doAddToast(item: INotificationViewItem, itemDisposables: DisposableStore): void {
173
174
// Lazily create toasts containers
175
let notificationsToastsContainer = this.notificationsToastsContainer;
176
if (!notificationsToastsContainer) {
177
notificationsToastsContainer = this.notificationsToastsContainer = $('.notifications-toasts');
178
179
this.container.appendChild(notificationsToastsContainer);
180
}
181
182
// Make Visible
183
notificationsToastsContainer.classList.add('visible');
184
185
// Container
186
const notificationToastContainer = $('.notification-toast-container');
187
188
const firstToast = notificationsToastsContainer.firstChild;
189
if (firstToast) {
190
notificationsToastsContainer.insertBefore(notificationToastContainer, firstToast); // always first
191
} else {
192
notificationsToastsContainer.appendChild(notificationToastContainer);
193
}
194
195
// Toast
196
const notificationToast = $('.notification-toast');
197
notificationToastContainer.appendChild(notificationToast);
198
199
// Create toast with item and show
200
const notificationList = this.instantiationService.createInstance(NotificationsList, notificationToast, {
201
verticalScrollMode: ScrollbarVisibility.Hidden,
202
widgetAriaLabel: (() => {
203
if (!item.source) {
204
return withSeverityPrefix(localize('notificationAriaLabel', "{0}, notification", item.message.raw), item.severity);
205
}
206
207
return withSeverityPrefix(localize('notificationWithSourceAriaLabel', "{0}, source: {1}, notification", item.message.raw, item.source), item.severity);
208
})()
209
});
210
itemDisposables.add(notificationList);
211
212
const toast: INotificationToast = { item, list: notificationList, container: notificationToastContainer, toast: notificationToast };
213
this.mapNotificationToToast.set(item, toast);
214
215
// When disposed, remove as visible
216
itemDisposables.add(toDisposable(() => this.updateToastVisibility(toast, false)));
217
218
// Make visible
219
notificationList.show();
220
221
// Layout lists
222
const maxDimensions = this.computeMaxDimensions();
223
this.layoutLists(maxDimensions.width);
224
225
// Show notification
226
notificationList.updateNotificationsList(0, 0, [item]);
227
228
// Layout container: only after we show the notification to ensure that
229
// the height computation takes the content of it into account!
230
this.layoutContainer(maxDimensions.height);
231
232
// Re-draw entire item when expansion changes to reveal or hide details
233
itemDisposables.add(item.onDidChangeExpansion(() => {
234
notificationList.updateNotificationsList(0, 1, [item]);
235
}));
236
237
// Handle content changes
238
// - actions: re-draw to properly show them
239
// - message: update notification height unless collapsed
240
itemDisposables.add(item.onDidChangeContent(e => {
241
switch (e.kind) {
242
case NotificationViewItemContentChangeKind.ACTIONS:
243
notificationList.updateNotificationsList(0, 1, [item]);
244
break;
245
case NotificationViewItemContentChangeKind.MESSAGE:
246
if (item.expanded) {
247
notificationList.updateNotificationHeight(item);
248
}
249
break;
250
}
251
}));
252
253
// Remove when item gets closed
254
Event.once(item.onDidClose)(() => {
255
this.removeToast(item);
256
});
257
258
// Automatically purge non-sticky notifications
259
this.purgeNotification(item, notificationToastContainer, notificationList, itemDisposables);
260
261
// Theming
262
this.updateStyles();
263
264
// Context Key
265
this.notificationsToastsVisibleContextKey.set(true);
266
267
// Animate in
268
notificationToast.classList.add('notification-fade-in');
269
itemDisposables.add(addDisposableListener(notificationToast, 'transitionend', () => {
270
notificationToast.classList.remove('notification-fade-in');
271
notificationToast.classList.add('notification-fade-in-done');
272
}));
273
274
// Mark as visible
275
item.updateVisibility(true);
276
277
// Events
278
if (!this._isVisible) {
279
this._isVisible = true;
280
this._onDidChangeVisibility.fire();
281
}
282
}
283
284
private purgeNotification(item: INotificationViewItem, notificationToastContainer: HTMLElement, notificationList: NotificationsList, disposables: DisposableStore): void {
285
286
// Track mouse over item
287
let isMouseOverToast = false;
288
disposables.add(addDisposableListener(notificationToastContainer, EventType.MOUSE_OVER, () => isMouseOverToast = true));
289
disposables.add(addDisposableListener(notificationToastContainer, EventType.MOUSE_OUT, () => isMouseOverToast = false));
290
291
// Install Timers to Purge Notification
292
let purgeTimeoutHandle: Timeout;
293
let listener: IDisposable;
294
295
const hideAfterTimeout = () => {
296
297
purgeTimeoutHandle = setTimeout(() => {
298
299
// If the window does not have focus, we wait for the window to gain focus
300
// again before triggering the timeout again. This prevents an issue where
301
// focussing the window could immediately hide the notification because the
302
// timeout was triggered again.
303
if (!this.hostService.hasFocus) {
304
if (!listener) {
305
listener = this.hostService.onDidChangeFocus(focus => {
306
if (focus) {
307
hideAfterTimeout();
308
}
309
});
310
disposables.add(listener);
311
}
312
}
313
314
// Otherwise...
315
else if (
316
item.sticky || // never hide sticky notifications
317
notificationList.hasFocus() || // never hide notifications with focus
318
isMouseOverToast // never hide notifications under mouse
319
) {
320
hideAfterTimeout();
321
} else {
322
this.removeToast(item);
323
}
324
}, NotificationsToasts.PURGE_TIMEOUT[item.severity]);
325
};
326
327
hideAfterTimeout();
328
329
disposables.add(toDisposable(() => clearTimeout(purgeTimeoutHandle)));
330
}
331
332
private removeToast(item: INotificationViewItem): void {
333
let focusEditor = false;
334
335
// UI
336
const notificationToast = this.mapNotificationToToast.get(item);
337
if (notificationToast) {
338
const toastHasDOMFocus = isAncestorOfActiveElement(notificationToast.container);
339
if (toastHasDOMFocus) {
340
focusEditor = !(this.focusNext() || this.focusPrevious()); // focus next if any, otherwise focus editor
341
}
342
343
this.mapNotificationToToast.delete(item);
344
}
345
346
// Disposables
347
const notificationDisposables = this.mapNotificationToDisposable.get(item);
348
if (notificationDisposables) {
349
dispose(notificationDisposables);
350
351
this.mapNotificationToDisposable.delete(item);
352
}
353
354
// Layout if we still have toasts
355
if (this.mapNotificationToToast.size > 0) {
356
this.layout(this.workbenchDimensions);
357
}
358
359
// Otherwise hide if no more toasts to show
360
else {
361
this.doHide();
362
363
// Move focus back to editor group as needed
364
if (focusEditor) {
365
this.editorGroupService.activeGroup.focus();
366
}
367
}
368
}
369
370
private removeToasts(): void {
371
372
// Toast
373
this.mapNotificationToToast.clear();
374
375
// Disposables
376
this.mapNotificationToDisposable.forEach(disposable => dispose(disposable));
377
this.mapNotificationToDisposable.clear();
378
379
this.doHide();
380
}
381
382
private doHide(): void {
383
this.notificationsToastsContainer?.classList.remove('visible');
384
385
// Context Key
386
this.notificationsToastsVisibleContextKey.set(false);
387
388
// Events
389
if (this._isVisible) {
390
this._isVisible = false;
391
this._onDidChangeVisibility.fire();
392
}
393
}
394
395
hide(): void {
396
const focusEditor = this.notificationsToastsContainer ? isAncestorOfActiveElement(this.notificationsToastsContainer) : false;
397
398
this.removeToasts();
399
400
if (focusEditor) {
401
this.editorGroupService.activeGroup.focus();
402
}
403
}
404
405
focus(): boolean {
406
const toasts = this.getToasts(ToastVisibility.VISIBLE);
407
if (toasts.length > 0) {
408
toasts[0].list.focusFirst();
409
410
return true;
411
}
412
413
return false;
414
}
415
416
focusNext(): boolean {
417
const toasts = this.getToasts(ToastVisibility.VISIBLE);
418
for (let i = 0; i < toasts.length; i++) {
419
const toast = toasts[i];
420
if (toast.list.hasFocus()) {
421
const nextToast = toasts[i + 1];
422
if (nextToast) {
423
nextToast.list.focusFirst();
424
425
return true;
426
}
427
428
break;
429
}
430
}
431
432
return false;
433
}
434
435
focusPrevious(): boolean {
436
const toasts = this.getToasts(ToastVisibility.VISIBLE);
437
for (let i = 0; i < toasts.length; i++) {
438
const toast = toasts[i];
439
if (toast.list.hasFocus()) {
440
const previousToast = toasts[i - 1];
441
if (previousToast) {
442
previousToast.list.focusFirst();
443
444
return true;
445
}
446
447
break;
448
}
449
}
450
451
return false;
452
}
453
454
focusFirst(): boolean {
455
const toast = this.getToasts(ToastVisibility.VISIBLE)[0];
456
if (toast) {
457
toast.list.focusFirst();
458
459
return true;
460
}
461
462
return false;
463
}
464
465
focusLast(): boolean {
466
const toasts = this.getToasts(ToastVisibility.VISIBLE);
467
if (toasts.length > 0) {
468
toasts[toasts.length - 1].list.focusFirst();
469
470
return true;
471
}
472
473
return false;
474
}
475
476
update(isCenterVisible: boolean): void {
477
if (this.isNotificationsCenterVisible !== isCenterVisible) {
478
this.isNotificationsCenterVisible = isCenterVisible;
479
480
// Hide all toasts when the notificationcenter gets visible
481
if (this.isNotificationsCenterVisible) {
482
this.removeToasts();
483
}
484
}
485
}
486
487
override updateStyles(): void {
488
this.mapNotificationToToast.forEach(({ toast }) => {
489
const backgroundColor = this.getColor(NOTIFICATIONS_BACKGROUND);
490
toast.style.background = backgroundColor ? backgroundColor : '';
491
492
const widgetShadowColor = this.getColor(widgetShadow);
493
toast.style.boxShadow = widgetShadowColor ? `0 0 8px 2px ${widgetShadowColor}` : '';
494
495
const borderColor = this.getColor(NOTIFICATIONS_TOAST_BORDER);
496
toast.style.border = borderColor ? `1px solid ${borderColor}` : '';
497
});
498
}
499
500
private getToasts(state: ToastVisibility): INotificationToast[] {
501
const notificationToasts: INotificationToast[] = [];
502
503
this.mapNotificationToToast.forEach(toast => {
504
switch (state) {
505
case ToastVisibility.HIDDEN_OR_VISIBLE:
506
notificationToasts.push(toast);
507
break;
508
case ToastVisibility.HIDDEN:
509
if (!this.isToastInDOM(toast)) {
510
notificationToasts.push(toast);
511
}
512
break;
513
case ToastVisibility.VISIBLE:
514
if (this.isToastInDOM(toast)) {
515
notificationToasts.push(toast);
516
}
517
break;
518
}
519
});
520
521
return notificationToasts.reverse(); // from newest to oldest
522
}
523
524
layout(dimension: Dimension | undefined): void {
525
this.workbenchDimensions = dimension;
526
527
const maxDimensions = this.computeMaxDimensions();
528
529
// Hide toasts that exceed height
530
if (maxDimensions.height) {
531
this.layoutContainer(maxDimensions.height);
532
}
533
534
// Layout all lists of toasts
535
this.layoutLists(maxDimensions.width);
536
}
537
538
private computeMaxDimensions(): Dimension {
539
const maxWidth = NotificationsToasts.MAX_WIDTH;
540
541
let availableWidth = maxWidth;
542
let availableHeight: number | undefined;
543
544
if (this.workbenchDimensions) {
545
546
// Make sure notifications are not exceding available width
547
availableWidth = this.workbenchDimensions.width;
548
availableWidth -= (2 * 8); // adjust for paddings left and right
549
550
// Make sure notifications are not exceeding available height
551
availableHeight = this.workbenchDimensions.height;
552
if (this.layoutService.isVisible(Parts.STATUSBAR_PART, mainWindow)) {
553
availableHeight -= 22; // adjust for status bar
554
}
555
556
if (this.layoutService.isVisible(Parts.TITLEBAR_PART, mainWindow)) {
557
availableHeight -= 22; // adjust for title bar
558
}
559
560
availableHeight -= (2 * 12); // adjust for paddings top and bottom
561
}
562
563
return new Dimension(Math.min(maxWidth, availableWidth), availableHeight ?? 0);
564
}
565
566
private layoutLists(width: number): void {
567
this.mapNotificationToToast.forEach(({ list }) => list.layout(width));
568
}
569
570
private layoutContainer(heightToGive: number): void {
571
572
// Allow the full height for 1 toast but adjust for multiple toasts
573
// so that a stack of notifications does not exceed all the way up
574
575
let singleToastHeightToGive = heightToGive;
576
let multipleToastsHeightToGive = Math.round(heightToGive * 0.618);
577
578
let visibleToasts = 0;
579
for (const toast of this.getToasts(ToastVisibility.HIDDEN_OR_VISIBLE)) {
580
581
// In order to measure the client height, the element cannot have display: none
582
toast.container.style.opacity = '0';
583
this.updateToastVisibility(toast, true);
584
585
singleToastHeightToGive -= toast.container.offsetHeight;
586
multipleToastsHeightToGive -= toast.container.offsetHeight;
587
588
let makeVisible = false;
589
if (visibleToasts === NotificationsToasts.MAX_NOTIFICATIONS) {
590
makeVisible = false; // never show more than MAX_NOTIFICATIONS
591
} else if ((visibleToasts === 0 && singleToastHeightToGive >= 0) || (visibleToasts > 0 && multipleToastsHeightToGive >= 0)) {
592
makeVisible = true; // hide toast if available height is too little
593
}
594
595
// Hide or show toast based on context
596
this.updateToastVisibility(toast, makeVisible);
597
toast.container.style.opacity = '';
598
599
if (makeVisible) {
600
visibleToasts++;
601
}
602
}
603
}
604
605
private updateToastVisibility(toast: INotificationToast, visible: boolean): void {
606
if (this.isToastInDOM(toast) === visible) {
607
return;
608
}
609
610
// Update visibility in DOM
611
const notificationsToastsContainer = assertReturnsDefined(this.notificationsToastsContainer);
612
if (visible) {
613
notificationsToastsContainer.appendChild(toast.container);
614
} else {
615
toast.container.remove();
616
}
617
618
// Update visibility in model
619
toast.item.updateVisibility(visible);
620
}
621
622
private isToastInDOM(toast: INotificationToast): boolean {
623
return !!toast.container.parentElement;
624
}
625
}
626
627