Path: blob/main/src/vs/workbench/browser/parts/notifications/notificationsToasts.ts
5310 views
/*---------------------------------------------------------------------------------------------1* Copyright (c) Microsoft Corporation. All rights reserved.2* Licensed under the MIT License. See License.txt in the project root for license information.3*--------------------------------------------------------------------------------------------*/45import './media/notificationsToasts.css';6import { localize } from '../../../../nls.js';7import { INotificationsModel, NotificationChangeType, INotificationChangeEvent, INotificationViewItem, NotificationViewItemContentChangeKind } from '../../../common/notifications.js';8import { IDisposable, dispose, toDisposable, DisposableStore } from '../../../../base/common/lifecycle.js';9import { addDisposableListener, EventType, Dimension, scheduleAtNextAnimationFrame, isAncestorOfActiveElement, getWindow, $, isElementInBottomRightQuarter, isHTMLElement, isEditableElement, getActiveElement } from '../../../../base/browser/dom.js';10import { IInstantiationService } from '../../../../platform/instantiation/common/instantiation.js';11import { NotificationsList } from './notificationsList.js';12import { Event, Emitter } from '../../../../base/common/event.js';13import { IWorkbenchLayoutService, Parts } from '../../../services/layout/browser/layoutService.js';14import { NOTIFICATIONS_TOAST_BORDER, NOTIFICATIONS_BACKGROUND } from '../../../common/theme.js';15import { IThemeService, Themable } from '../../../../platform/theme/common/themeService.js';16import { widgetShadow } from '../../../../platform/theme/common/colorRegistry.js';17import { IEditorGroupsService } from '../../../services/editor/common/editorGroupsService.js';18import { INotificationsToastController } from './notificationsCommands.js';19import { IContextKey, IContextKeyService } from '../../../../platform/contextkey/common/contextkey.js';20import { Severity, NotificationsFilter, NotificationPriority, withSeverityPrefix } from '../../../../platform/notification/common/notification.js';21import { ScrollbarVisibility } from '../../../../base/common/scrollable.js';22import { ILifecycleService, LifecyclePhase } from '../../../services/lifecycle/common/lifecycle.js';23import { IHostService } from '../../../services/host/browser/host.js';24import { IntervalCounter } from '../../../../base/common/async.js';25import { assertReturnsDefined } from '../../../../base/common/types.js';26import { NotificationsToastsVisibleContext } from '../../../common/contextkeys.js';27import { mainWindow } from '../../../../base/browser/window.js';28import { IWorkbenchEnvironmentService } from '../../../services/environment/common/environmentService.js';2930interface INotificationToast {31readonly item: INotificationViewItem;32readonly list: NotificationsList;33readonly container: HTMLElement;34readonly toast: HTMLElement;35}3637enum ToastVisibility {38HIDDEN_OR_VISIBLE,39HIDDEN,40VISIBLE41}4243export class NotificationsToasts extends Themable implements INotificationsToastController {4445private static readonly MAX_WIDTH = 450;46private static readonly MAX_NOTIFICATIONS = 3;4748private static readonly PURGE_TIMEOUT: { [severity: number]: number } = {49[Severity.Info]: 10000,50[Severity.Warning]: 12000,51[Severity.Error]: 1500052};5354private static readonly SPAM_PROTECTION = {55// Count for the number of notifications over 800ms...56interval: 800,57// ...and ensure we are not showing more than MAX_NOTIFICATIONS58limit: this.MAX_NOTIFICATIONS59};6061private readonly _onDidChangeVisibility = this._register(new Emitter<void>());62readonly onDidChangeVisibility = this._onDidChangeVisibility.event;6364private _isVisible = false;65get isVisible(): boolean { return !!this._isVisible; }6667private notificationsToastsContainer: HTMLElement | undefined;68private workbenchDimensions: Dimension | undefined;69private isNotificationsCenterVisible: boolean | undefined;7071private readonly mapNotificationToToast = new Map<INotificationViewItem, INotificationToast>();72private readonly mapNotificationToDisposable = new Map<INotificationViewItem, IDisposable>();7374private readonly notificationsToastsVisibleContextKey: IContextKey<boolean>;7576private readonly addedToastsIntervalCounter = new IntervalCounter(NotificationsToasts.SPAM_PROTECTION.interval);7778constructor(79private readonly container: HTMLElement,80private 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@IWorkbenchEnvironmentService private readonly environmentService: IWorkbenchEnvironmentService89) {90super(themeService);9192this.notificationsToastsVisibleContextKey = NotificationsToastsVisibleContext.bindTo(contextKeyService);9394this.registerListeners();95}9697private registerListeners(): void {9899// Layout100this._register(this.layoutService.onDidLayoutMainContainer(dimension => this.layout(Dimension.lift(dimension))));101102// Delay some tasks until after we have restored103// to reduce UI pressure from the startup phase104this.lifecycleService.when(LifecyclePhase.Restored).then(() => {105106// Show toast for initial notifications if any107this.model.notifications.forEach(notification => this.addToast(notification));108109// Update toasts on notification changes110this._register(this.model.onDidChangeNotification(e => this.onDidChangeNotification(e)));111});112113// Filter114this._register(this.model.onDidChangeFilter(({ global, sources }) => {115if (global === NotificationsFilter.ERROR) {116this.hide();117} else if (sources) {118for (const [notification] of this.mapNotificationToToast) {119if (typeof notification.sourceId === 'string' && sources.get(notification.sourceId) === NotificationsFilter.ERROR && notification.severity !== Severity.Error && notification.priority !== NotificationPriority.URGENT) {120this.removeToast(notification);121}122}123}124}));125}126127private onDidChangeNotification(e: INotificationChangeEvent): void {128switch (e.kind) {129case NotificationChangeType.ADD:130return this.addToast(e.item);131case NotificationChangeType.REMOVE:132return this.removeToast(e.item);133}134}135136private addToast(item: INotificationViewItem): void {137if (this.isNotificationsCenterVisible) {138return; // do not show toasts while notification center is visible139}140141if (this.environmentService.enableSmokeTestDriver) {142return; // disable in smoke tests to prevent covering elements143}144145if (item.priority === NotificationPriority.SILENT) {146return; // do not show toasts for silenced notifications147}148149if (item.priority === NotificationPriority.OPTIONAL) {150const activeElement = getActiveElement();151if (isHTMLElement(activeElement) && isEditableElement(activeElement) && isElementInBottomRightQuarter(activeElement, this.layoutService.mainContainer)) {152return; // skip showing optional toast that potentially covers input fields153}154}155156// Optimization: it is possible that a lot of notifications are being157// added in a very short time. To prevent this kind of spam, we protect158// against showing too many notifications at once. Since they can always159// be accessed from the notification center, a user can always get to160// them later on.161// (see also https://github.com/microsoft/vscode/issues/107935)162if (this.addedToastsIntervalCounter.increment() > NotificationsToasts.SPAM_PROTECTION.limit) {163return;164}165166// Optimization: showing a notification toast can be expensive167// because of the associated animation. If the renderer is busy168// doing actual work, the animation can cause a lot of slowdown169// As such we use `scheduleAtNextAnimationFrame` to push out170// the toast until the renderer has time to process it.171// (see also https://github.com/microsoft/vscode/issues/107935)172const itemDisposables = new DisposableStore();173this.mapNotificationToDisposable.set(item, itemDisposables);174itemDisposables.add(scheduleAtNextAnimationFrame(getWindow(this.container), () => this.doAddToast(item, itemDisposables)));175}176177private doAddToast(item: INotificationViewItem, itemDisposables: DisposableStore): void {178179// Lazily create toasts containers180let notificationsToastsContainer = this.notificationsToastsContainer;181if (!notificationsToastsContainer) {182notificationsToastsContainer = this.notificationsToastsContainer = $('.notifications-toasts');183184this.container.appendChild(notificationsToastsContainer);185}186187// Make Visible188notificationsToastsContainer.classList.add('visible');189190// Container191const notificationToastContainer = $('.notification-toast-container');192193const firstToast = notificationsToastsContainer.firstChild;194if (firstToast) {195notificationsToastsContainer.insertBefore(notificationToastContainer, firstToast); // always first196} else {197notificationsToastsContainer.appendChild(notificationToastContainer);198}199200// Toast201const notificationToast = $('.notification-toast');202notificationToastContainer.appendChild(notificationToast);203204// Create toast with item and show205const notificationList = this.instantiationService.createInstance(NotificationsList, notificationToast, {206verticalScrollMode: ScrollbarVisibility.Hidden,207widgetAriaLabel: (() => {208if (!item.source) {209return withSeverityPrefix(localize('notificationAriaLabel', "{0}, notification", item.message.raw), item.severity);210}211212return withSeverityPrefix(localize('notificationWithSourceAriaLabel', "{0}, source: {1}, notification", item.message.raw, item.source), item.severity);213})()214});215itemDisposables.add(notificationList);216217const toast: INotificationToast = { item, list: notificationList, container: notificationToastContainer, toast: notificationToast };218this.mapNotificationToToast.set(item, toast);219220// When disposed, remove as visible221itemDisposables.add(toDisposable(() => this.updateToastVisibility(toast, false)));222223// Make visible224notificationList.show();225226// Layout lists227const maxDimensions = this.computeMaxDimensions();228this.layoutLists(maxDimensions.width);229230// Show notification231notificationList.updateNotificationsList(0, 0, [item]);232233// Layout container: only after we show the notification to ensure that234// the height computation takes the content of it into account!235this.layoutContainer(maxDimensions.height);236237// Re-draw entire item when expansion changes to reveal or hide details238itemDisposables.add(item.onDidChangeExpansion(() => {239notificationList.updateNotificationsList(0, 1, [item]);240}));241242// Handle content changes243// - actions: re-draw to properly show them244// - message: update notification height unless collapsed245itemDisposables.add(item.onDidChangeContent(e => {246switch (e.kind) {247case NotificationViewItemContentChangeKind.ACTIONS:248notificationList.updateNotificationsList(0, 1, [item]);249break;250case NotificationViewItemContentChangeKind.MESSAGE:251if (item.expanded) {252notificationList.updateNotificationHeight(item);253}254break;255}256}));257258// Remove when item gets closed259Event.once(item.onDidClose)(() => {260this.removeToast(item);261});262263// Automatically purge non-sticky notifications264this.purgeNotification(item, notificationToastContainer, notificationList, itemDisposables);265266// Theming267this.updateStyles();268269// Context Key270this.notificationsToastsVisibleContextKey.set(true);271272// Animate in273notificationToast.classList.add('notification-fade-in');274itemDisposables.add(addDisposableListener(notificationToast, 'transitionend', () => {275notificationToast.classList.remove('notification-fade-in');276notificationToast.classList.add('notification-fade-in-done');277}));278279// Mark as visible280item.updateVisibility(true);281282// Events283if (!this._isVisible) {284this._isVisible = true;285this._onDidChangeVisibility.fire();286}287}288289private purgeNotification(item: INotificationViewItem, notificationToastContainer: HTMLElement, notificationList: NotificationsList, disposables: DisposableStore): void {290291// Track mouse over item292let isMouseOverToast = false;293disposables.add(addDisposableListener(notificationToastContainer, EventType.MOUSE_OVER, () => isMouseOverToast = true));294disposables.add(addDisposableListener(notificationToastContainer, EventType.MOUSE_OUT, () => isMouseOverToast = false));295296// Install Timers to Purge Notification297let purgeTimeoutHandle: Timeout;298let listener: IDisposable;299300const hideAfterTimeout = () => {301302purgeTimeoutHandle = setTimeout(() => {303304// If the window does not have focus, we wait for the window to gain focus305// again before triggering the timeout again. This prevents an issue where306// focussing the window could immediately hide the notification because the307// timeout was triggered again.308if (!this.hostService.hasFocus) {309if (!listener) {310listener = this.hostService.onDidChangeFocus(focus => {311if (focus) {312hideAfterTimeout();313}314});315disposables.add(listener);316}317}318319// Otherwise...320else if (321item.sticky || // never hide sticky notifications322notificationList.hasFocus() || // never hide notifications with focus323isMouseOverToast // never hide notifications under mouse324) {325hideAfterTimeout();326} else {327this.removeToast(item);328}329}, NotificationsToasts.PURGE_TIMEOUT[item.severity]);330};331332hideAfterTimeout();333334disposables.add(toDisposable(() => clearTimeout(purgeTimeoutHandle)));335}336337private removeToast(item: INotificationViewItem): void {338let focusEditor = false;339340// UI341const notificationToast = this.mapNotificationToToast.get(item);342if (notificationToast) {343const toastHasDOMFocus = isAncestorOfActiveElement(notificationToast.container);344if (toastHasDOMFocus) {345focusEditor = !(this.focusNext() || this.focusPrevious()); // focus next if any, otherwise focus editor346}347348this.mapNotificationToToast.delete(item);349}350351// Disposables352const notificationDisposables = this.mapNotificationToDisposable.get(item);353if (notificationDisposables) {354dispose(notificationDisposables);355356this.mapNotificationToDisposable.delete(item);357}358359// Layout if we still have toasts360if (this.mapNotificationToToast.size > 0) {361this.layout(this.workbenchDimensions);362}363364// Otherwise hide if no more toasts to show365else {366this.doHide();367368// Move focus back to editor group as needed369if (focusEditor) {370this.editorGroupService.activeGroup.focus();371}372}373}374375private removeToasts(): void {376377// Toast378this.mapNotificationToToast.clear();379380// Disposables381this.mapNotificationToDisposable.forEach(disposable => dispose(disposable));382this.mapNotificationToDisposable.clear();383384this.doHide();385}386387private doHide(): void {388this.notificationsToastsContainer?.classList.remove('visible');389390// Context Key391this.notificationsToastsVisibleContextKey.set(false);392393// Events394if (this._isVisible) {395this._isVisible = false;396this._onDidChangeVisibility.fire();397}398}399400hide(): void {401const focusEditor = this.notificationsToastsContainer ? isAncestorOfActiveElement(this.notificationsToastsContainer) : false;402403this.removeToasts();404405if (focusEditor) {406this.editorGroupService.activeGroup.focus();407}408}409410focus(): boolean {411const toasts = this.getToasts(ToastVisibility.VISIBLE);412if (toasts.length > 0) {413toasts[0].list.focusFirst();414415return true;416}417418return false;419}420421focusNext(): boolean {422const toasts = this.getToasts(ToastVisibility.VISIBLE);423for (let i = 0; i < toasts.length; i++) {424const toast = toasts[i];425if (toast.list.hasFocus()) {426const nextToast = toasts[i + 1];427if (nextToast) {428nextToast.list.focusFirst();429430return true;431}432433break;434}435}436437return false;438}439440focusPrevious(): boolean {441const toasts = this.getToasts(ToastVisibility.VISIBLE);442for (let i = 0; i < toasts.length; i++) {443const toast = toasts[i];444if (toast.list.hasFocus()) {445const previousToast = toasts[i - 1];446if (previousToast) {447previousToast.list.focusFirst();448449return true;450}451452break;453}454}455456return false;457}458459focusFirst(): boolean {460const toast = this.getToasts(ToastVisibility.VISIBLE)[0];461if (toast) {462toast.list.focusFirst();463464return true;465}466467return false;468}469470focusLast(): boolean {471const toasts = this.getToasts(ToastVisibility.VISIBLE);472if (toasts.length > 0) {473toasts[toasts.length - 1].list.focusFirst();474475return true;476}477478return false;479}480481update(isCenterVisible: boolean): void {482if (this.isNotificationsCenterVisible !== isCenterVisible) {483this.isNotificationsCenterVisible = isCenterVisible;484485// Hide all toasts when the notificationcenter gets visible486if (this.isNotificationsCenterVisible) {487this.removeToasts();488}489}490}491492override updateStyles(): void {493this.mapNotificationToToast.forEach(({ toast }) => {494const backgroundColor = this.getColor(NOTIFICATIONS_BACKGROUND);495toast.style.background = backgroundColor ? backgroundColor : '';496497const widgetShadowColor = this.getColor(widgetShadow);498toast.style.boxShadow = widgetShadowColor ? `0 0 8px 2px ${widgetShadowColor}` : '';499500const borderColor = this.getColor(NOTIFICATIONS_TOAST_BORDER);501toast.style.border = borderColor ? `1px solid ${borderColor}` : '';502});503}504505private getToasts(state: ToastVisibility): INotificationToast[] {506const notificationToasts: INotificationToast[] = [];507508this.mapNotificationToToast.forEach(toast => {509switch (state) {510case ToastVisibility.HIDDEN_OR_VISIBLE:511notificationToasts.push(toast);512break;513case ToastVisibility.HIDDEN:514if (!this.isToastInDOM(toast)) {515notificationToasts.push(toast);516}517break;518case ToastVisibility.VISIBLE:519if (this.isToastInDOM(toast)) {520notificationToasts.push(toast);521}522break;523}524});525526return notificationToasts.reverse(); // from newest to oldest527}528529layout(dimension: Dimension | undefined): void {530this.workbenchDimensions = dimension;531532const maxDimensions = this.computeMaxDimensions();533534// Hide toasts that exceed height535if (maxDimensions.height) {536this.layoutContainer(maxDimensions.height);537}538539// Layout all lists of toasts540this.layoutLists(maxDimensions.width);541}542543private computeMaxDimensions(): Dimension {544const maxWidth = NotificationsToasts.MAX_WIDTH;545546let availableWidth = maxWidth;547let availableHeight: number | undefined;548549if (this.workbenchDimensions) {550551// Make sure notifications are not exceding available width552availableWidth = this.workbenchDimensions.width;553availableWidth -= (2 * 8); // adjust for paddings left and right554555// Make sure notifications are not exceeding available height556availableHeight = this.workbenchDimensions.height;557if (this.layoutService.isVisible(Parts.STATUSBAR_PART, mainWindow)) {558availableHeight -= 22; // adjust for status bar559}560561if (this.layoutService.isVisible(Parts.TITLEBAR_PART, mainWindow)) {562availableHeight -= 22; // adjust for title bar563}564565availableHeight -= (2 * 12); // adjust for paddings top and bottom566}567568return new Dimension(Math.min(maxWidth, availableWidth), availableHeight ?? 0);569}570571private layoutLists(width: number): void {572this.mapNotificationToToast.forEach(({ list }) => list.layout(width));573}574575private layoutContainer(heightToGive: number): void {576577// Allow the full height for 1 toast but adjust for multiple toasts578// so that a stack of notifications does not exceed all the way up579580let singleToastHeightToGive = heightToGive;581let multipleToastsHeightToGive = Math.round(heightToGive * 0.618);582583let visibleToasts = 0;584for (const toast of this.getToasts(ToastVisibility.HIDDEN_OR_VISIBLE)) {585586// In order to measure the client height, the element cannot have display: none587toast.container.style.opacity = '0';588this.updateToastVisibility(toast, true);589590singleToastHeightToGive -= toast.container.offsetHeight;591multipleToastsHeightToGive -= toast.container.offsetHeight;592593let makeVisible = false;594if (visibleToasts === NotificationsToasts.MAX_NOTIFICATIONS) {595makeVisible = false; // never show more than MAX_NOTIFICATIONS596} else if ((visibleToasts === 0 && singleToastHeightToGive >= 0) || (visibleToasts > 0 && multipleToastsHeightToGive >= 0)) {597makeVisible = true; // hide toast if available height is too little598}599600// Hide or show toast based on context601this.updateToastVisibility(toast, makeVisible);602toast.container.style.opacity = '';603604if (makeVisible) {605visibleToasts++;606}607}608}609610private updateToastVisibility(toast: INotificationToast, visible: boolean): void {611if (this.isToastInDOM(toast) === visible) {612return;613}614615// Update visibility in DOM616const notificationsToastsContainer = assertReturnsDefined(this.notificationsToastsContainer);617if (visible) {618notificationsToastsContainer.appendChild(toast.container);619} else {620toast.container.remove();621}622623// Update visibility in model624toast.item.updateVisibility(visible);625}626627private isToastInDOM(toast: INotificationToast): boolean {628return !!toast.container.parentElement;629}630}631632633