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