/*--------------------------------------------------------------------------------------------- * Copyright (c) Microsoft Corporation. All rights reserved. * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ import { INotification, INotificationHandle, INotificationActions, INotificationProgress, NoOpNotification, Severity, NotificationMessage, IPromptChoice, IStatusMessageOptions, NotificationsFilter, INotificationProgressProperties } from 'vs/platform/notification/common/notification'; import { toErrorMessage } from 'vs/base/common/errorMessage'; import { Event, Emitter } from 'vs/base/common/event'; import { Disposable, IDisposable, toDisposable } from 'vs/base/common/lifecycle'; import { isPromiseCanceledError } from 'vs/base/common/errors'; import { Action } from 'vs/base/common/actions'; import { isErrorWithActions } from 'vs/base/common/errorsWithActions'; import { equals } from 'vs/base/common/arrays'; import { parseLinkedText, LinkedText } from 'vs/base/common/linkedText'; export interface INotificationsModel { //#region Notifications as Toasts/Center readonly notifications: INotificationViewItem[]; readonly onDidChangeNotification: Event; readonly onDidChangeFilter: Event; addNotification(notification: INotification): INotificationHandle; setFilter(filter: NotificationsFilter): void; //#endregion //#region Notifications as Status readonly statusMessage: IStatusMessageViewItem | undefined; readonly onDidChangeStatusMessage: Event; showStatusMessage(message: NotificationMessage, options?: IStatusMessageOptions): IDisposable; //#endregion } export const enum NotificationChangeType { /** * A notification was added. */ ADD, /** * A notification changed. Check `detail` property * on the event for additional information. */ CHANGE, /** * A notification expanded or collapsed. */ EXPAND_COLLAPSE, /** * A notification was removed. */ REMOVE } export interface INotificationChangeEvent { /** * The index this notification has in the list of notifications. */ index: number; /** * The notification this change is about. */ item: INotificationViewItem; /** * The kind of notification change. */ kind: NotificationChangeType; /** * Additional detail about the item change. Only applies to * `NotificationChangeType.CHANGE`. */ detail?: NotificationViewItemContentChangeKind } export const enum StatusMessageChangeType { ADD, REMOVE } export interface IStatusMessageViewItem { message: string; options?: IStatusMessageOptions; } export interface IStatusMessageChangeEvent { /** * The status message item this change is about. */ item: IStatusMessageViewItem; /** * The kind of status message change. */ kind: StatusMessageChangeType; } export class NotificationHandle extends Disposable implements INotificationHandle { private readonly _onDidClose = this._register(new Emitter()); readonly onDidClose = this._onDidClose.event; private readonly _onDidChangeVisibility = this._register(new Emitter()); readonly onDidChangeVisibility = this._onDidChangeVisibility.event; constructor(private readonly item: INotificationViewItem, private readonly onClose: (item: INotificationViewItem) => void) { super(); this.registerListeners(); } private registerListeners(): void { // Visibility this._register(this.item.onDidChangeVisibility(visible => this._onDidChangeVisibility.fire(visible))); // Closing Event.once(this.item.onDidClose)(() => { this._onDidClose.fire(); this.dispose(); }); } get progress(): INotificationProgress { return this.item.progress; } updateSeverity(severity: Severity): void { this.item.updateSeverity(severity); } updateMessage(message: NotificationMessage): void { this.item.updateMessage(message); } updateActions(actions?: INotificationActions): void { this.item.updateActions(actions); } close(): void { this.onClose(this.item); this.dispose(); } } export class NotificationsModel extends Disposable implements INotificationsModel { private static readonly NO_OP_NOTIFICATION = new NoOpNotification(); private readonly _onDidChangeNotification = this._register(new Emitter()); readonly onDidChangeNotification = this._onDidChangeNotification.event; private readonly _onDidChangeStatusMessage = this._register(new Emitter()); readonly onDidChangeStatusMessage = this._onDidChangeStatusMessage.event; private readonly _onDidChangeFilter = this._register(new Emitter()); readonly onDidChangeFilter = this._onDidChangeFilter.event; private readonly _notifications: INotificationViewItem[] = []; get notifications(): INotificationViewItem[] { return this._notifications; } private _statusMessage: IStatusMessageViewItem | undefined; get statusMessage(): IStatusMessageViewItem | undefined { return this._statusMessage; } private filter = NotificationsFilter.OFF; setFilter(filter: NotificationsFilter): void { this.filter = filter; this._onDidChangeFilter.fire(filter); } addNotification(notification: INotification): INotificationHandle { const item = this.createViewItem(notification); if (!item) { return NotificationsModel.NO_OP_NOTIFICATION; // return early if this is a no-op } // Deduplicate const duplicate = this.findNotification(item); if (duplicate) { duplicate.close(); } // Add to list as first entry this._notifications.splice(0, 0, item); // Events this._onDidChangeNotification.fire({ item, index: 0, kind: NotificationChangeType.ADD }); // Wrap into handle return new NotificationHandle(item, item => this.onClose(item)); } private onClose(item: INotificationViewItem): void { const liveItem = this.findNotification(item); if (liveItem && liveItem !== item) { liveItem.close(); // item could have been replaced with another one, make sure to close the live item } else { item.close(); // otherwise just close the item that was passed in } } private findNotification(item: INotificationViewItem): INotificationViewItem | undefined { return this._notifications.find(notification => notification.equals(item)); } private createViewItem(notification: INotification): INotificationViewItem | undefined { const item = NotificationViewItem.create(notification, this.filter); if (!item) { return undefined; } // Item Events const fireNotificationChangeEvent = (kind: NotificationChangeType, detail?: NotificationViewItemContentChangeKind) => { const index = this._notifications.indexOf(item); if (index >= 0) { this._onDidChangeNotification.fire({ item, index, kind, detail }); } }; const itemExpansionChangeListener = item.onDidChangeExpansion(() => fireNotificationChangeEvent(NotificationChangeType.EXPAND_COLLAPSE)); const itemContentChangeListener = item.onDidChangeContent(e => fireNotificationChangeEvent(NotificationChangeType.CHANGE, e.kind)); Event.once(item.onDidClose)(() => { itemExpansionChangeListener.dispose(); itemContentChangeListener.dispose(); const index = this._notifications.indexOf(item); if (index >= 0) { this._notifications.splice(index, 1); this._onDidChangeNotification.fire({ item, index, kind: NotificationChangeType.REMOVE }); } }); return item; } showStatusMessage(message: NotificationMessage, options?: IStatusMessageOptions): IDisposable { const item = StatusMessageViewItem.create(message, options); if (!item) { return Disposable.None; } // Remember as current status message and fire events this._statusMessage = item; this._onDidChangeStatusMessage.fire({ kind: StatusMessageChangeType.ADD, item }); return toDisposable(() => { // Only reset status message if the item is still the one we had remembered if (this._statusMessage === item) { this._statusMessage = undefined; this._onDidChangeStatusMessage.fire({ kind: StatusMessageChangeType.REMOVE, item }); } }); } } export interface INotificationViewItem { readonly severity: Severity; readonly sticky: boolean; readonly silent: boolean; readonly message: INotificationMessage; readonly source: string | undefined; readonly actions: INotificationActions | undefined; readonly progress: INotificationViewItemProgress; readonly expanded: boolean; readonly canCollapse: boolean; readonly hasProgress: boolean; readonly onDidChangeExpansion: Event; readonly onDidChangeVisibility: Event; readonly onDidChangeContent: Event; readonly onDidClose: Event; expand(): void; collapse(skipEvents?: boolean): void; toggle(): void; updateSeverity(severity: Severity): void; updateMessage(message: NotificationMessage): void; updateActions(actions?: INotificationActions): void; updateVisibility(visible: boolean): void; close(): void; equals(item: INotificationViewItem): boolean; } export function isNotificationViewItem(obj: unknown): obj is INotificationViewItem { return obj instanceof NotificationViewItem; } export const enum NotificationViewItemContentChangeKind { SEVERITY, MESSAGE, ACTIONS, PROGRESS } export interface INotificationViewItemContentChangeEvent { kind: NotificationViewItemContentChangeKind; } export interface INotificationViewItemProgressState { infinite?: boolean; total?: number; worked?: number; done?: boolean; } export interface INotificationViewItemProgress extends INotificationProgress { readonly state: INotificationViewItemProgressState; dispose(): void; } export class NotificationViewItemProgress extends Disposable implements INotificationViewItemProgress { private readonly _state: INotificationViewItemProgressState; private readonly _onDidChange = this._register(new Emitter()); readonly onDidChange = this._onDidChange.event; constructor() { super(); this._state = Object.create(null); } get state(): INotificationViewItemProgressState { return this._state; } infinite(): void { if (this._state.infinite) { return; } this._state.infinite = true; this._state.total = undefined; this._state.worked = undefined; this._state.done = undefined; this._onDidChange.fire(); } done(): void { if (this._state.done) { return; } this._state.done = true; this._state.infinite = undefined; this._state.total = undefined; this._state.worked = undefined; this._onDidChange.fire(); } total(value: number): void { if (this._state.total === value) { return; } this._state.total = value; this._state.infinite = undefined; this._state.done = undefined; this._onDidChange.fire(); } worked(value: number): void { if (typeof this._state.worked === 'number') { this._state.worked += value; } else { this._state.worked = value; } this._state.infinite = undefined; this._state.done = undefined; this._onDidChange.fire(); } } export interface IMessageLink { href: string; name: string; title: string; offset: number; length: number; } export interface INotificationMessage { raw: string; original: NotificationMessage; linkedText: LinkedText; } export class NotificationViewItem extends Disposable implements INotificationViewItem { private static readonly MAX_MESSAGE_LENGTH = 1000; private _expanded: boolean | undefined; private _visible: boolean = false; private _actions: INotificationActions | undefined; private _progress: NotificationViewItemProgress | undefined; private readonly _onDidChangeExpansion = this._register(new Emitter()); readonly onDidChangeExpansion = this._onDidChangeExpansion.event; private readonly _onDidClose = this._register(new Emitter()); readonly onDidClose = this._onDidClose.event; private readonly _onDidChangeContent = this._register(new Emitter()); readonly onDidChangeContent = this._onDidChangeContent.event; private readonly _onDidChangeVisibility = this._register(new Emitter()); readonly onDidChangeVisibility = this._onDidChangeVisibility.event; static create(notification: INotification, filter: NotificationsFilter = NotificationsFilter.OFF): INotificationViewItem | undefined { if (!notification || !notification.message || isPromiseCanceledError(notification.message)) { return undefined; // we need a message to show } let severity: Severity; if (typeof notification.severity === 'number') { severity = notification.severity; } else { severity = Severity.Info; } const message = NotificationViewItem.parseNotificationMessage(notification.message); if (!message) { return undefined; // we need a message to show } let actions: INotificationActions | undefined; if (notification.actions) { actions = notification.actions; } else if (isErrorWithActions(notification.message)) { actions = { primary: notification.message.actions }; } return new NotificationViewItem(severity, notification.sticky, notification.silent || filter === NotificationsFilter.SILENT || (filter === NotificationsFilter.ERROR && notification.severity !== Severity.Error), message, notification.source, notification.progress, actions); } private static parseNotificationMessage(input: NotificationMessage): INotificationMessage | undefined { let message: string | undefined; if (input instanceof Error) { message = toErrorMessage(input, false); } else if (typeof input === 'string') { message = input; } if (!message) { return undefined; // we need a message to show } const raw = message; // Make sure message is in the limits if (message.length > NotificationViewItem.MAX_MESSAGE_LENGTH) { message = `${message.substr(0, NotificationViewItem.MAX_MESSAGE_LENGTH)}...`; } // Remove newlines from messages as we do not support that and it makes link parsing hard message = message.replace(/(\r\n|\n|\r)/gm, ' ').trim(); // Parse Links const linkedText = parseLinkedText(message); return { raw, linkedText, original: input }; } private constructor( private _severity: Severity, private _sticky: boolean | undefined, private _silent: boolean | undefined, private _message: INotificationMessage, private _source: string | undefined, progress: INotificationProgressProperties | undefined, actions?: INotificationActions ) { super(); if (progress) { this.setProgress(progress); } this.setActions(actions); } private setProgress(progress: INotificationProgressProperties): void { if (progress.infinite) { this.progress.infinite(); } else if (progress.total) { this.progress.total(progress.total); if (progress.worked) { this.progress.worked(progress.worked); } } } private setActions(actions: INotificationActions = { primary: [], secondary: [] }): void { this._actions = { primary: Array.isArray(actions.primary) ? actions.primary : [], secondary: Array.isArray(actions.secondary) ? actions.secondary : [] }; this._expanded = actions.primary && actions.primary.length > 0; } get canCollapse(): boolean { return !this.hasActions; } get expanded(): boolean { return !!this._expanded; } get severity(): Severity { return this._severity; } get sticky(): boolean { if (this._sticky) { return true; // explicitly sticky } const hasActions = this.hasActions; if ( (hasActions && this._severity === Severity.Error) || // notification errors with actions are sticky (!hasActions && this._expanded) || // notifications that got expanded are sticky (this._progress && !this._progress.state.done) // notifications with running progress are sticky ) { return true; } return false; // not sticky } get silent(): boolean { return !!this._silent; } private get hasActions(): boolean { if (!this._actions) { return false; } if (!this._actions.primary) { return false; } return this._actions.primary.length > 0; } get hasProgress(): boolean { return !!this._progress; } get progress(): INotificationViewItemProgress { if (!this._progress) { this._progress = this._register(new NotificationViewItemProgress()); this._register(this._progress.onDidChange(() => this._onDidChangeContent.fire({ kind: NotificationViewItemContentChangeKind.PROGRESS }))); } return this._progress; } get message(): INotificationMessage { return this._message; } get source(): string | undefined { return this._source; } get actions(): INotificationActions | undefined { return this._actions; } updateSeverity(severity: Severity): void { this._severity = severity; this._onDidChangeContent.fire({ kind: NotificationViewItemContentChangeKind.SEVERITY }); } updateMessage(input: NotificationMessage): void { const message = NotificationViewItem.parseNotificationMessage(input); if (!message) { return; } this._message = message; this._onDidChangeContent.fire({ kind: NotificationViewItemContentChangeKind.MESSAGE }); } updateActions(actions?: INotificationActions): void { this.setActions(actions); this._onDidChangeContent.fire({ kind: NotificationViewItemContentChangeKind.ACTIONS }); } updateVisibility(visible: boolean): void { if (this._visible !== visible) { this._visible = visible; this._onDidChangeVisibility.fire(visible); } } expand(): void { if (this._expanded || !this.canCollapse) { return; } this._expanded = true; this._onDidChangeExpansion.fire(); } collapse(skipEvents?: boolean): void { if (!this._expanded || !this.canCollapse) { return; } this._expanded = false; if (!skipEvents) { this._onDidChangeExpansion.fire(); } } toggle(): void { if (this._expanded) { this.collapse(); } else { this.expand(); } } close(): void { this._onDidClose.fire(); this.dispose(); } equals(other: INotificationViewItem): boolean { if (this.hasProgress || other.hasProgress) { return false; } if (this._source !== other.source) { return false; } if (this._message.raw !== other.message.raw) { return false; } const primaryActions = (this._actions && this._actions.primary) || []; const otherPrimaryActions = (other.actions && other.actions.primary) || []; return equals(primaryActions, otherPrimaryActions, (a, b) => (a.id + a.label) === (b.id + b.label)); } } export class ChoiceAction extends Action { private readonly _onDidRun = this._register(new Emitter()); readonly onDidRun = this._onDidRun.event; private readonly _keepOpen: boolean; constructor(id: string, choice: IPromptChoice) { super(id, choice.label, undefined, true, async () => { // Pass to runner choice.run(); // Emit Event this._onDidRun.fire(); }); this._keepOpen = !!choice.keepOpen; } get keepOpen(): boolean { return this._keepOpen; } } class StatusMessageViewItem { static create(notification: NotificationMessage, options?: IStatusMessageOptions): IStatusMessageViewItem | undefined { if (!notification || isPromiseCanceledError(notification)) { return undefined; // we need a message to show } let message: string | undefined; if (notification instanceof Error) { message = toErrorMessage(notification, false); } else if (typeof notification === 'string') { message = notification; } if (!message) { return undefined; // we need a message to show } return { message, options }; } }