Path: blob/main/src/vs/workbench/browser/parts/notifications/notificationsToasts.ts
3296 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';2829interface INotificationToast {30readonly item: INotificationViewItem;31readonly list: NotificationsList;32readonly container: HTMLElement;33readonly toast: HTMLElement;34}3536enum ToastVisibility {37HIDDEN_OR_VISIBLE,38HIDDEN,39VISIBLE40}4142export class NotificationsToasts extends Themable implements INotificationsToastController {4344private static readonly MAX_WIDTH = 450;45private static readonly MAX_NOTIFICATIONS = 3;4647private static readonly PURGE_TIMEOUT: { [severity: number]: number } = {48[Severity.Info]: 10000,49[Severity.Warning]: 12000,50[Severity.Error]: 1500051};5253private static readonly SPAM_PROTECTION = {54// Count for the number of notifications over 800ms...55interval: 800,56// ...and ensure we are not showing more than MAX_NOTIFICATIONS57limit: this.MAX_NOTIFICATIONS58};5960private readonly _onDidChangeVisibility = this._register(new Emitter<void>());61readonly onDidChangeVisibility = this._onDidChangeVisibility.event;6263private _isVisible = false;64get isVisible(): boolean { return !!this._isVisible; }6566private notificationsToastsContainer: HTMLElement | undefined;67private workbenchDimensions: Dimension | undefined;68private isNotificationsCenterVisible: boolean | undefined;6970private readonly mapNotificationToToast = new Map<INotificationViewItem, INotificationToast>();71private readonly mapNotificationToDisposable = new Map<INotificationViewItem, IDisposable>();7273private readonly notificationsToastsVisibleContextKey: IContextKey<boolean>;7475private readonly addedToastsIntervalCounter = new IntervalCounter(NotificationsToasts.SPAM_PROTECTION.interval);7677constructor(78private readonly container: HTMLElement,79private readonly model: INotificationsModel,80@IInstantiationService private readonly instantiationService: IInstantiationService,81@IWorkbenchLayoutService private readonly layoutService: IWorkbenchLayoutService,82@IThemeService themeService: IThemeService,83@IEditorGroupsService private readonly editorGroupService: IEditorGroupsService,84@IContextKeyService contextKeyService: IContextKeyService,85@ILifecycleService private readonly lifecycleService: ILifecycleService,86@IHostService private readonly hostService: IHostService87) {88super(themeService);8990this.notificationsToastsVisibleContextKey = NotificationsToastsVisibleContext.bindTo(contextKeyService);9192this.registerListeners();93}9495private registerListeners(): void {9697// Layout98this._register(this.layoutService.onDidLayoutMainContainer(dimension => this.layout(Dimension.lift(dimension))));99100// Delay some tasks until after we have restored101// to reduce UI pressure from the startup phase102this.lifecycleService.when(LifecyclePhase.Restored).then(() => {103104// Show toast for initial notifications if any105this.model.notifications.forEach(notification => this.addToast(notification));106107// Update toasts on notification changes108this._register(this.model.onDidChangeNotification(e => this.onDidChangeNotification(e)));109});110111// Filter112this._register(this.model.onDidChangeFilter(({ global, sources }) => {113if (global === NotificationsFilter.ERROR) {114this.hide();115} else if (sources) {116for (const [notification] of this.mapNotificationToToast) {117if (typeof notification.sourceId === 'string' && sources.get(notification.sourceId) === NotificationsFilter.ERROR && notification.severity !== Severity.Error && notification.priority !== NotificationPriority.URGENT) {118this.removeToast(notification);119}120}121}122}));123}124125private onDidChangeNotification(e: INotificationChangeEvent): void {126switch (e.kind) {127case NotificationChangeType.ADD:128return this.addToast(e.item);129case NotificationChangeType.REMOVE:130return this.removeToast(e.item);131}132}133134private addToast(item: INotificationViewItem): void {135if (this.isNotificationsCenterVisible) {136return; // do not show toasts while notification center is visible137}138139if (item.priority === NotificationPriority.SILENT) {140return; // do not show toasts for silenced notifications141}142143if (item.priority === NotificationPriority.OPTIONAL) {144const activeElement = getActiveElement();145if (isHTMLElement(activeElement) && isEditableElement(activeElement) && isElementInBottomRightQuarter(activeElement, this.layoutService.mainContainer)) {146return; // skip showing optional toast that potentially covers input fields147}148}149150// Optimization: it is possible that a lot of notifications are being151// added in a very short time. To prevent this kind of spam, we protect152// against showing too many notifications at once. Since they can always153// be accessed from the notification center, a user can always get to154// them later on.155// (see also https://github.com/microsoft/vscode/issues/107935)156if (this.addedToastsIntervalCounter.increment() > NotificationsToasts.SPAM_PROTECTION.limit) {157return;158}159160// Optimization: showing a notification toast can be expensive161// because of the associated animation. If the renderer is busy162// doing actual work, the animation can cause a lot of slowdown163// As such we use `scheduleAtNextAnimationFrame` to push out164// the toast until the renderer has time to process it.165// (see also https://github.com/microsoft/vscode/issues/107935)166const itemDisposables = new DisposableStore();167this.mapNotificationToDisposable.set(item, itemDisposables);168itemDisposables.add(scheduleAtNextAnimationFrame(getWindow(this.container), () => this.doAddToast(item, itemDisposables)));169}170171private doAddToast(item: INotificationViewItem, itemDisposables: DisposableStore): void {172173// Lazily create toasts containers174let notificationsToastsContainer = this.notificationsToastsContainer;175if (!notificationsToastsContainer) {176notificationsToastsContainer = this.notificationsToastsContainer = $('.notifications-toasts');177178this.container.appendChild(notificationsToastsContainer);179}180181// Make Visible182notificationsToastsContainer.classList.add('visible');183184// Container185const notificationToastContainer = $('.notification-toast-container');186187const firstToast = notificationsToastsContainer.firstChild;188if (firstToast) {189notificationsToastsContainer.insertBefore(notificationToastContainer, firstToast); // always first190} else {191notificationsToastsContainer.appendChild(notificationToastContainer);192}193194// Toast195const notificationToast = $('.notification-toast');196notificationToastContainer.appendChild(notificationToast);197198// Create toast with item and show199const notificationList = this.instantiationService.createInstance(NotificationsList, notificationToast, {200verticalScrollMode: ScrollbarVisibility.Hidden,201widgetAriaLabel: (() => {202if (!item.source) {203return withSeverityPrefix(localize('notificationAriaLabel', "{0}, notification", item.message.raw), item.severity);204}205206return withSeverityPrefix(localize('notificationWithSourceAriaLabel', "{0}, source: {1}, notification", item.message.raw, item.source), item.severity);207})()208});209itemDisposables.add(notificationList);210211const toast: INotificationToast = { item, list: notificationList, container: notificationToastContainer, toast: notificationToast };212this.mapNotificationToToast.set(item, toast);213214// When disposed, remove as visible215itemDisposables.add(toDisposable(() => this.updateToastVisibility(toast, false)));216217// Make visible218notificationList.show();219220// Layout lists221const maxDimensions = this.computeMaxDimensions();222this.layoutLists(maxDimensions.width);223224// Show notification225notificationList.updateNotificationsList(0, 0, [item]);226227// Layout container: only after we show the notification to ensure that228// the height computation takes the content of it into account!229this.layoutContainer(maxDimensions.height);230231// Re-draw entire item when expansion changes to reveal or hide details232itemDisposables.add(item.onDidChangeExpansion(() => {233notificationList.updateNotificationsList(0, 1, [item]);234}));235236// Handle content changes237// - actions: re-draw to properly show them238// - message: update notification height unless collapsed239itemDisposables.add(item.onDidChangeContent(e => {240switch (e.kind) {241case NotificationViewItemContentChangeKind.ACTIONS:242notificationList.updateNotificationsList(0, 1, [item]);243break;244case NotificationViewItemContentChangeKind.MESSAGE:245if (item.expanded) {246notificationList.updateNotificationHeight(item);247}248break;249}250}));251252// Remove when item gets closed253Event.once(item.onDidClose)(() => {254this.removeToast(item);255});256257// Automatically purge non-sticky notifications258this.purgeNotification(item, notificationToastContainer, notificationList, itemDisposables);259260// Theming261this.updateStyles();262263// Context Key264this.notificationsToastsVisibleContextKey.set(true);265266// Animate in267notificationToast.classList.add('notification-fade-in');268itemDisposables.add(addDisposableListener(notificationToast, 'transitionend', () => {269notificationToast.classList.remove('notification-fade-in');270notificationToast.classList.add('notification-fade-in-done');271}));272273// Mark as visible274item.updateVisibility(true);275276// Events277if (!this._isVisible) {278this._isVisible = true;279this._onDidChangeVisibility.fire();280}281}282283private purgeNotification(item: INotificationViewItem, notificationToastContainer: HTMLElement, notificationList: NotificationsList, disposables: DisposableStore): void {284285// Track mouse over item286let isMouseOverToast = false;287disposables.add(addDisposableListener(notificationToastContainer, EventType.MOUSE_OVER, () => isMouseOverToast = true));288disposables.add(addDisposableListener(notificationToastContainer, EventType.MOUSE_OUT, () => isMouseOverToast = false));289290// Install Timers to Purge Notification291let purgeTimeoutHandle: Timeout;292let listener: IDisposable;293294const hideAfterTimeout = () => {295296purgeTimeoutHandle = setTimeout(() => {297298// If the window does not have focus, we wait for the window to gain focus299// again before triggering the timeout again. This prevents an issue where300// focussing the window could immediately hide the notification because the301// timeout was triggered again.302if (!this.hostService.hasFocus) {303if (!listener) {304listener = this.hostService.onDidChangeFocus(focus => {305if (focus) {306hideAfterTimeout();307}308});309disposables.add(listener);310}311}312313// Otherwise...314else if (315item.sticky || // never hide sticky notifications316notificationList.hasFocus() || // never hide notifications with focus317isMouseOverToast // never hide notifications under mouse318) {319hideAfterTimeout();320} else {321this.removeToast(item);322}323}, NotificationsToasts.PURGE_TIMEOUT[item.severity]);324};325326hideAfterTimeout();327328disposables.add(toDisposable(() => clearTimeout(purgeTimeoutHandle)));329}330331private removeToast(item: INotificationViewItem): void {332let focusEditor = false;333334// UI335const notificationToast = this.mapNotificationToToast.get(item);336if (notificationToast) {337const toastHasDOMFocus = isAncestorOfActiveElement(notificationToast.container);338if (toastHasDOMFocus) {339focusEditor = !(this.focusNext() || this.focusPrevious()); // focus next if any, otherwise focus editor340}341342this.mapNotificationToToast.delete(item);343}344345// Disposables346const notificationDisposables = this.mapNotificationToDisposable.get(item);347if (notificationDisposables) {348dispose(notificationDisposables);349350this.mapNotificationToDisposable.delete(item);351}352353// Layout if we still have toasts354if (this.mapNotificationToToast.size > 0) {355this.layout(this.workbenchDimensions);356}357358// Otherwise hide if no more toasts to show359else {360this.doHide();361362// Move focus back to editor group as needed363if (focusEditor) {364this.editorGroupService.activeGroup.focus();365}366}367}368369private removeToasts(): void {370371// Toast372this.mapNotificationToToast.clear();373374// Disposables375this.mapNotificationToDisposable.forEach(disposable => dispose(disposable));376this.mapNotificationToDisposable.clear();377378this.doHide();379}380381private doHide(): void {382this.notificationsToastsContainer?.classList.remove('visible');383384// Context Key385this.notificationsToastsVisibleContextKey.set(false);386387// Events388if (this._isVisible) {389this._isVisible = false;390this._onDidChangeVisibility.fire();391}392}393394hide(): void {395const focusEditor = this.notificationsToastsContainer ? isAncestorOfActiveElement(this.notificationsToastsContainer) : false;396397this.removeToasts();398399if (focusEditor) {400this.editorGroupService.activeGroup.focus();401}402}403404focus(): boolean {405const toasts = this.getToasts(ToastVisibility.VISIBLE);406if (toasts.length > 0) {407toasts[0].list.focusFirst();408409return true;410}411412return false;413}414415focusNext(): boolean {416const toasts = this.getToasts(ToastVisibility.VISIBLE);417for (let i = 0; i < toasts.length; i++) {418const toast = toasts[i];419if (toast.list.hasFocus()) {420const nextToast = toasts[i + 1];421if (nextToast) {422nextToast.list.focusFirst();423424return true;425}426427break;428}429}430431return false;432}433434focusPrevious(): boolean {435const toasts = this.getToasts(ToastVisibility.VISIBLE);436for (let i = 0; i < toasts.length; i++) {437const toast = toasts[i];438if (toast.list.hasFocus()) {439const previousToast = toasts[i - 1];440if (previousToast) {441previousToast.list.focusFirst();442443return true;444}445446break;447}448}449450return false;451}452453focusFirst(): boolean {454const toast = this.getToasts(ToastVisibility.VISIBLE)[0];455if (toast) {456toast.list.focusFirst();457458return true;459}460461return false;462}463464focusLast(): boolean {465const toasts = this.getToasts(ToastVisibility.VISIBLE);466if (toasts.length > 0) {467toasts[toasts.length - 1].list.focusFirst();468469return true;470}471472return false;473}474475update(isCenterVisible: boolean): void {476if (this.isNotificationsCenterVisible !== isCenterVisible) {477this.isNotificationsCenterVisible = isCenterVisible;478479// Hide all toasts when the notificationcenter gets visible480if (this.isNotificationsCenterVisible) {481this.removeToasts();482}483}484}485486override updateStyles(): void {487this.mapNotificationToToast.forEach(({ toast }) => {488const backgroundColor = this.getColor(NOTIFICATIONS_BACKGROUND);489toast.style.background = backgroundColor ? backgroundColor : '';490491const widgetShadowColor = this.getColor(widgetShadow);492toast.style.boxShadow = widgetShadowColor ? `0 0 8px 2px ${widgetShadowColor}` : '';493494const borderColor = this.getColor(NOTIFICATIONS_TOAST_BORDER);495toast.style.border = borderColor ? `1px solid ${borderColor}` : '';496});497}498499private getToasts(state: ToastVisibility): INotificationToast[] {500const notificationToasts: INotificationToast[] = [];501502this.mapNotificationToToast.forEach(toast => {503switch (state) {504case ToastVisibility.HIDDEN_OR_VISIBLE:505notificationToasts.push(toast);506break;507case ToastVisibility.HIDDEN:508if (!this.isToastInDOM(toast)) {509notificationToasts.push(toast);510}511break;512case ToastVisibility.VISIBLE:513if (this.isToastInDOM(toast)) {514notificationToasts.push(toast);515}516break;517}518});519520return notificationToasts.reverse(); // from newest to oldest521}522523layout(dimension: Dimension | undefined): void {524this.workbenchDimensions = dimension;525526const maxDimensions = this.computeMaxDimensions();527528// Hide toasts that exceed height529if (maxDimensions.height) {530this.layoutContainer(maxDimensions.height);531}532533// Layout all lists of toasts534this.layoutLists(maxDimensions.width);535}536537private computeMaxDimensions(): Dimension {538const maxWidth = NotificationsToasts.MAX_WIDTH;539540let availableWidth = maxWidth;541let availableHeight: number | undefined;542543if (this.workbenchDimensions) {544545// Make sure notifications are not exceding available width546availableWidth = this.workbenchDimensions.width;547availableWidth -= (2 * 8); // adjust for paddings left and right548549// Make sure notifications are not exceeding available height550availableHeight = this.workbenchDimensions.height;551if (this.layoutService.isVisible(Parts.STATUSBAR_PART, mainWindow)) {552availableHeight -= 22; // adjust for status bar553}554555if (this.layoutService.isVisible(Parts.TITLEBAR_PART, mainWindow)) {556availableHeight -= 22; // adjust for title bar557}558559availableHeight -= (2 * 12); // adjust for paddings top and bottom560}561562return new Dimension(Math.min(maxWidth, availableWidth), availableHeight ?? 0);563}564565private layoutLists(width: number): void {566this.mapNotificationToToast.forEach(({ list }) => list.layout(width));567}568569private layoutContainer(heightToGive: number): void {570571// Allow the full height for 1 toast but adjust for multiple toasts572// so that a stack of notifications does not exceed all the way up573574let singleToastHeightToGive = heightToGive;575let multipleToastsHeightToGive = Math.round(heightToGive * 0.618);576577let visibleToasts = 0;578for (const toast of this.getToasts(ToastVisibility.HIDDEN_OR_VISIBLE)) {579580// In order to measure the client height, the element cannot have display: none581toast.container.style.opacity = '0';582this.updateToastVisibility(toast, true);583584singleToastHeightToGive -= toast.container.offsetHeight;585multipleToastsHeightToGive -= toast.container.offsetHeight;586587let makeVisible = false;588if (visibleToasts === NotificationsToasts.MAX_NOTIFICATIONS) {589makeVisible = false; // never show more than MAX_NOTIFICATIONS590} else if ((visibleToasts === 0 && singleToastHeightToGive >= 0) || (visibleToasts > 0 && multipleToastsHeightToGive >= 0)) {591makeVisible = true; // hide toast if available height is too little592}593594// Hide or show toast based on context595this.updateToastVisibility(toast, makeVisible);596toast.container.style.opacity = '';597598if (makeVisible) {599visibleToasts++;600}601}602}603604private updateToastVisibility(toast: INotificationToast, visible: boolean): void {605if (this.isToastInDOM(toast) === visible) {606return;607}608609// Update visibility in DOM610const notificationsToastsContainer = assertReturnsDefined(this.notificationsToastsContainer);611if (visible) {612notificationsToastsContainer.appendChild(toast.container);613} else {614toast.container.remove();615}616617// Update visibility in model618toast.item.updateVisibility(visible);619}620621private isToastInDOM(toast: INotificationToast): boolean {622return !!toast.container.parentElement;623}624}625626627