Path: blob/main/src/vs/workbench/common/notifications.ts
3291 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 { INotification, INotificationHandle, INotificationActions, INotificationProgress, NoOpNotification, Severity, NotificationMessage, IPromptChoice, IStatusMessageOptions, NotificationsFilter, INotificationProgressProperties, IPromptChoiceWithMenu, NotificationPriority, INotificationSource, isNotificationSource, IStatusHandle } from '../../platform/notification/common/notification.js';6import { toErrorMessage, isErrorWithActions } from '../../base/common/errorMessage.js';7import { Event, Emitter } from '../../base/common/event.js';8import { Disposable } from '../../base/common/lifecycle.js';9import { isCancellationError } from '../../base/common/errors.js';10import { Action } from '../../base/common/actions.js';11import { equals } from '../../base/common/arrays.js';12import { parseLinkedText, LinkedText } from '../../base/common/linkedText.js';13import { mapsStrictEqualIgnoreOrder } from '../../base/common/map.js';1415export interface INotificationsModel {1617//#region Notifications as Toasts/Center1819readonly notifications: INotificationViewItem[];2021readonly onDidChangeNotification: Event<INotificationChangeEvent>;22readonly onDidChangeFilter: Event<Partial<INotificationsFilter>>;2324addNotification(notification: INotification): INotificationHandle;2526setFilter(filter: Partial<INotificationsFilter>): void;2728//#endregion293031//#region Notifications as Status3233readonly statusMessage: IStatusMessageViewItem | undefined;3435readonly onDidChangeStatusMessage: Event<IStatusMessageChangeEvent>;3637showStatusMessage(message: NotificationMessage, options?: IStatusMessageOptions): IStatusHandle;3839//#endregion40}4142export const enum NotificationChangeType {4344/**45* A notification was added.46*/47ADD,4849/**50* A notification changed. Check `detail` property51* on the event for additional information.52*/53CHANGE,5455/**56* A notification expanded or collapsed.57*/58EXPAND_COLLAPSE,5960/**61* A notification was removed.62*/63REMOVE64}6566export interface INotificationChangeEvent {6768/**69* The index this notification has in the list of notifications.70*/71index: number;7273/**74* The notification this change is about.75*/76item: INotificationViewItem;7778/**79* The kind of notification change.80*/81kind: NotificationChangeType;8283/**84* Additional detail about the item change. Only applies to85* `NotificationChangeType.CHANGE`.86*/87detail?: NotificationViewItemContentChangeKind;88}8990export const enum StatusMessageChangeType {91ADD,92REMOVE93}9495export interface IStatusMessageViewItem {96message: string;97options?: IStatusMessageOptions;98}99100export interface IStatusMessageChangeEvent {101102/**103* The status message item this change is about.104*/105item: IStatusMessageViewItem;106107/**108* The kind of status message change.109*/110kind: StatusMessageChangeType;111}112113export class NotificationHandle extends Disposable implements INotificationHandle {114115private readonly _onDidClose = this._register(new Emitter<void>());116readonly onDidClose = this._onDidClose.event;117118private readonly _onDidChangeVisibility = this._register(new Emitter<boolean>());119readonly onDidChangeVisibility = this._onDidChangeVisibility.event;120121constructor(private readonly item: INotificationViewItem, private readonly onClose: (item: INotificationViewItem) => void) {122super();123124this.registerListeners();125}126127private registerListeners(): void {128129// Visibility130this._register(this.item.onDidChangeVisibility(visible => this._onDidChangeVisibility.fire(visible)));131132// Closing133Event.once(this.item.onDidClose)(() => {134this._onDidClose.fire();135136this.dispose();137});138}139140get progress(): INotificationProgress {141return this.item.progress;142}143144updateSeverity(severity: Severity): void {145this.item.updateSeverity(severity);146}147148updateMessage(message: NotificationMessage): void {149this.item.updateMessage(message);150}151152updateActions(actions?: INotificationActions): void {153this.item.updateActions(actions);154}155156close(): void {157this.onClose(this.item);158159this.dispose();160}161}162163export interface INotificationsFilter {164readonly global: NotificationsFilter;165readonly sources: Map<string, NotificationsFilter>;166}167168export class NotificationsModel extends Disposable implements INotificationsModel {169170private static readonly NO_OP_NOTIFICATION = new NoOpNotification();171172private readonly _onDidChangeNotification = this._register(new Emitter<INotificationChangeEvent>());173readonly onDidChangeNotification = this._onDidChangeNotification.event;174175private readonly _onDidChangeStatusMessage = this._register(new Emitter<IStatusMessageChangeEvent>());176readonly onDidChangeStatusMessage = this._onDidChangeStatusMessage.event;177178private readonly _onDidChangeFilter = this._register(new Emitter<Partial<INotificationsFilter>>());179readonly onDidChangeFilter = this._onDidChangeFilter.event;180181private readonly _notifications: INotificationViewItem[] = [];182get notifications(): INotificationViewItem[] { return this._notifications; }183184private _statusMessage: IStatusMessageViewItem | undefined;185get statusMessage(): IStatusMessageViewItem | undefined { return this._statusMessage; }186187private readonly filter = {188global: NotificationsFilter.OFF,189sources: new Map<string, NotificationsFilter>()190};191192setFilter(filter: Partial<INotificationsFilter>): void {193let globalChanged = false;194if (typeof filter.global === 'number') {195globalChanged = this.filter.global !== filter.global;196this.filter.global = filter.global;197}198199let sourcesChanged = false;200if (filter.sources) {201sourcesChanged = !mapsStrictEqualIgnoreOrder(this.filter.sources, filter.sources);202this.filter.sources = filter.sources;203}204205if (globalChanged || sourcesChanged) {206this._onDidChangeFilter.fire({207global: globalChanged ? filter.global : undefined,208sources: sourcesChanged ? filter.sources : undefined209});210}211}212213addNotification(notification: INotification): INotificationHandle {214const item = this.createViewItem(notification);215if (!item) {216return NotificationsModel.NO_OP_NOTIFICATION; // return early if this is a no-op217}218219// Deduplicate220const duplicate = this.findNotification(item);221duplicate?.close();222223// Add to list as first entry224this._notifications.splice(0, 0, item);225226// Events227this._onDidChangeNotification.fire({ item, index: 0, kind: NotificationChangeType.ADD });228229// Wrap into handle230return new NotificationHandle(item, item => this.onClose(item));231}232233private onClose(item: INotificationViewItem): void {234const liveItem = this.findNotification(item);235if (liveItem && liveItem !== item) {236liveItem.close(); // item could have been replaced with another one, make sure to close the live item237} else {238item.close(); // otherwise just close the item that was passed in239}240}241242private findNotification(item: INotificationViewItem): INotificationViewItem | undefined {243return this._notifications.find(notification => notification.equals(item));244}245246private createViewItem(notification: INotification): INotificationViewItem | undefined {247const item = NotificationViewItem.create(notification, this.filter);248if (!item) {249return undefined;250}251252// Item Events253const fireNotificationChangeEvent = (kind: NotificationChangeType, detail?: NotificationViewItemContentChangeKind) => {254const index = this._notifications.indexOf(item);255if (index >= 0) {256this._onDidChangeNotification.fire({ item, index, kind, detail });257}258};259260const itemExpansionChangeListener = item.onDidChangeExpansion(() => fireNotificationChangeEvent(NotificationChangeType.EXPAND_COLLAPSE));261const itemContentChangeListener = item.onDidChangeContent(e => fireNotificationChangeEvent(NotificationChangeType.CHANGE, e.kind));262263Event.once(item.onDidClose)(() => {264itemExpansionChangeListener.dispose();265itemContentChangeListener.dispose();266267const index = this._notifications.indexOf(item);268if (index >= 0) {269this._notifications.splice(index, 1);270this._onDidChangeNotification.fire({ item, index, kind: NotificationChangeType.REMOVE });271}272});273274return item;275}276277showStatusMessage(message: NotificationMessage, options?: IStatusMessageOptions): IStatusHandle {278const item = StatusMessageViewItem.create(message, options);279if (!item) {280return { close: () => { } };281}282283this._statusMessage = item;284this._onDidChangeStatusMessage.fire({ kind: StatusMessageChangeType.ADD, item });285286return {287close: () => {288if (this._statusMessage === item) {289this._statusMessage = undefined;290this._onDidChangeStatusMessage.fire({ kind: StatusMessageChangeType.REMOVE, item });291}292}293};294}295}296297export interface INotificationViewItem {298readonly id: string | undefined;299readonly severity: Severity;300readonly sticky: boolean;301readonly priority: NotificationPriority;302readonly message: INotificationMessage;303readonly source: string | undefined;304readonly sourceId: string | undefined;305readonly actions: INotificationActions | undefined;306readonly progress: INotificationViewItemProgress;307308readonly expanded: boolean;309readonly visible: boolean;310readonly canCollapse: boolean;311readonly hasProgress: boolean;312313readonly onDidChangeExpansion: Event<void>;314readonly onDidChangeVisibility: Event<boolean>;315readonly onDidChangeContent: Event<INotificationViewItemContentChangeEvent>;316readonly onDidClose: Event<void>;317318expand(): void;319collapse(skipEvents?: boolean): void;320toggle(): void;321322updateSeverity(severity: Severity): void;323updateMessage(message: NotificationMessage): void;324updateActions(actions?: INotificationActions): void;325326updateVisibility(visible: boolean): void;327328close(): void;329330equals(item: INotificationViewItem): boolean;331}332333export function isNotificationViewItem(obj: unknown): obj is INotificationViewItem {334return obj instanceof NotificationViewItem;335}336337export const enum NotificationViewItemContentChangeKind {338SEVERITY,339MESSAGE,340ACTIONS,341PROGRESS342}343344export interface INotificationViewItemContentChangeEvent {345kind: NotificationViewItemContentChangeKind;346}347348export interface INotificationViewItemProgressState {349infinite?: boolean;350total?: number;351worked?: number;352done?: boolean;353}354355export interface INotificationViewItemProgress extends INotificationProgress {356readonly state: INotificationViewItemProgressState;357358dispose(): void;359}360361export class NotificationViewItemProgress extends Disposable implements INotificationViewItemProgress {362private readonly _state: INotificationViewItemProgressState;363364private readonly _onDidChange = this._register(new Emitter<void>());365readonly onDidChange = this._onDidChange.event;366367constructor() {368super();369370this._state = Object.create(null);371}372373get state(): INotificationViewItemProgressState {374return this._state;375}376377infinite(): void {378if (this._state.infinite) {379return;380}381382this._state.infinite = true;383384this._state.total = undefined;385this._state.worked = undefined;386this._state.done = undefined;387388this._onDidChange.fire();389}390391done(): void {392if (this._state.done) {393return;394}395396this._state.done = true;397398this._state.infinite = undefined;399this._state.total = undefined;400this._state.worked = undefined;401402this._onDidChange.fire();403}404405total(value: number): void {406if (this._state.total === value) {407return;408}409410this._state.total = value;411412this._state.infinite = undefined;413this._state.done = undefined;414415this._onDidChange.fire();416}417418worked(value: number): void {419if (typeof this._state.worked === 'number') {420this._state.worked += value;421} else {422this._state.worked = value;423}424425this._state.infinite = undefined;426this._state.done = undefined;427428this._onDidChange.fire();429}430}431432export interface IMessageLink {433href: string;434name: string;435title: string;436offset: number;437length: number;438}439440export interface INotificationMessage {441raw: string;442original: NotificationMessage;443linkedText: LinkedText;444}445446export class NotificationViewItem extends Disposable implements INotificationViewItem {447448private static readonly MAX_MESSAGE_LENGTH = 1000;449450private _expanded: boolean | undefined;451private _visible: boolean = false;452453private _actions: INotificationActions | undefined;454private _progress: NotificationViewItemProgress | undefined;455456private readonly _onDidChangeExpansion = this._register(new Emitter<void>());457readonly onDidChangeExpansion = this._onDidChangeExpansion.event;458459private readonly _onDidClose = this._register(new Emitter<void>());460readonly onDidClose = this._onDidClose.event;461462private readonly _onDidChangeContent = this._register(new Emitter<INotificationViewItemContentChangeEvent>());463readonly onDidChangeContent = this._onDidChangeContent.event;464465private readonly _onDidChangeVisibility = this._register(new Emitter<boolean>());466readonly onDidChangeVisibility = this._onDidChangeVisibility.event;467468static create(notification: INotification, filter: INotificationsFilter): INotificationViewItem | undefined {469if (!notification || !notification.message || isCancellationError(notification.message)) {470return undefined; // we need a message to show471}472473let severity: Severity;474if (typeof notification.severity === 'number') {475severity = notification.severity;476} else {477severity = Severity.Info;478}479480const message = NotificationViewItem.parseNotificationMessage(notification.message);481if (!message) {482return undefined; // we need a message to show483}484485let actions: INotificationActions | undefined;486if (notification.actions) {487actions = notification.actions;488} else if (isErrorWithActions(notification.message)) {489actions = { primary: notification.message.actions };490}491492let priority = notification.priority ?? NotificationPriority.DEFAULT;493if ((priority === NotificationPriority.DEFAULT || priority === NotificationPriority.OPTIONAL) && severity !== Severity.Error) {494if (filter.global === NotificationsFilter.ERROR) {495priority = NotificationPriority.SILENT; // filtered globally496} else if (isNotificationSource(notification.source) && filter.sources.get(notification.source.id) === NotificationsFilter.ERROR) {497priority = NotificationPriority.SILENT; // filtered by source498}499}500501return new NotificationViewItem(notification.id, severity, notification.sticky, priority, message, notification.source, notification.progress, actions);502}503504private static parseNotificationMessage(input: NotificationMessage): INotificationMessage | undefined {505let message: string | undefined;506if (input instanceof Error) {507message = toErrorMessage(input, false);508} else if (typeof input === 'string') {509message = input;510}511512if (!message) {513return undefined; // we need a message to show514}515516const raw = message;517518// Make sure message is in the limits519if (message.length > NotificationViewItem.MAX_MESSAGE_LENGTH) {520message = `${message.substr(0, NotificationViewItem.MAX_MESSAGE_LENGTH)}...`;521}522523// Remove newlines from messages as we do not support that and it makes link parsing hard524message = message.replace(/(\r\n|\n|\r)/gm, ' ').trim();525526// Parse Links527const linkedText = parseLinkedText(message);528529return { raw, linkedText, original: input };530}531532private constructor(533readonly id: string | undefined,534private _severity: Severity,535private _sticky: boolean | undefined,536private _priority: NotificationPriority,537private _message: INotificationMessage,538private _source: string | INotificationSource | undefined,539progress: INotificationProgressProperties | undefined,540actions?: INotificationActions541) {542super();543544if (progress) {545this.setProgress(progress);546}547548this.setActions(actions);549}550551private setProgress(progress: INotificationProgressProperties): void {552if (progress.infinite) {553this.progress.infinite();554} else if (progress.total) {555this.progress.total(progress.total);556557if (progress.worked) {558this.progress.worked(progress.worked);559}560}561}562563private setActions(actions: INotificationActions = { primary: [], secondary: [] }): void {564this._actions = {565primary: Array.isArray(actions.primary) ? actions.primary : [],566secondary: Array.isArray(actions.secondary) ? actions.secondary : []567};568569this._expanded = actions.primary && actions.primary.length > 0;570}571572get canCollapse(): boolean {573return !this.hasActions;574}575576get expanded(): boolean {577return !!this._expanded;578}579580get severity(): Severity {581return this._severity;582}583584get sticky(): boolean {585if (this._sticky) {586return true; // explicitly sticky587}588589const hasActions = this.hasActions;590if (591(hasActions && this._severity === Severity.Error) || // notification errors with actions are sticky592(!hasActions && this._expanded) || // notifications that got expanded are sticky593(this._progress && !this._progress.state.done) // notifications with running progress are sticky594) {595return true;596}597598return false; // not sticky599}600601get priority(): NotificationPriority {602return this._priority;603}604605private get hasActions(): boolean {606if (!this._actions) {607return false;608}609610if (!this._actions.primary) {611return false;612}613614return this._actions.primary.length > 0;615}616617get hasProgress(): boolean {618return !!this._progress;619}620621get progress(): INotificationViewItemProgress {622if (!this._progress) {623this._progress = this._register(new NotificationViewItemProgress());624this._register(this._progress.onDidChange(() => this._onDidChangeContent.fire({ kind: NotificationViewItemContentChangeKind.PROGRESS })));625}626627return this._progress;628}629630get message(): INotificationMessage {631return this._message;632}633634get source(): string | undefined {635return typeof this._source === 'string' ? this._source : (this._source ? this._source.label : undefined);636}637638get sourceId(): string | undefined {639return (this._source && typeof this._source !== 'string' && 'id' in this._source) ? this._source.id : undefined;640}641642get actions(): INotificationActions | undefined {643return this._actions;644}645646get visible(): boolean {647return this._visible;648}649650updateSeverity(severity: Severity): void {651if (severity === this._severity) {652return;653}654655this._severity = severity;656this._onDidChangeContent.fire({ kind: NotificationViewItemContentChangeKind.SEVERITY });657}658659updateMessage(input: NotificationMessage): void {660const message = NotificationViewItem.parseNotificationMessage(input);661if (!message || message.raw === this._message.raw) {662return;663}664665this._message = message;666this._onDidChangeContent.fire({ kind: NotificationViewItemContentChangeKind.MESSAGE });667}668669updateActions(actions?: INotificationActions): void {670this.setActions(actions);671this._onDidChangeContent.fire({ kind: NotificationViewItemContentChangeKind.ACTIONS });672}673674updateVisibility(visible: boolean): void {675if (this._visible !== visible) {676this._visible = visible;677678this._onDidChangeVisibility.fire(visible);679}680}681682expand(): void {683if (this._expanded || !this.canCollapse) {684return;685}686687this._expanded = true;688this._onDidChangeExpansion.fire();689}690691collapse(skipEvents?: boolean): void {692if (!this._expanded || !this.canCollapse) {693return;694}695696this._expanded = false;697698if (!skipEvents) {699this._onDidChangeExpansion.fire();700}701}702703toggle(): void {704if (this._expanded) {705this.collapse();706} else {707this.expand();708}709}710711close(): void {712this._onDidClose.fire();713714this.dispose();715}716717equals(other: INotificationViewItem): boolean {718if (this.hasProgress || other.hasProgress) {719return false;720}721722if (typeof this.id === 'string' || typeof other.id === 'string') {723return this.id === other.id;724}725726if (typeof this._source === 'object') {727if (this._source.label !== other.source || this._source.id !== other.sourceId) {728return false;729}730} else if (this._source !== other.source) {731return false;732}733734if (this._message.raw !== other.message.raw) {735return false;736}737738const primaryActions = (this._actions && this._actions.primary) || [];739const otherPrimaryActions = (other.actions && other.actions.primary) || [];740return equals(primaryActions, otherPrimaryActions, (action, otherAction) => (action.id + action.label) === (otherAction.id + otherAction.label));741}742}743744export class ChoiceAction extends Action {745746private readonly _onDidRun = this._register(new Emitter<void>());747readonly onDidRun = this._onDidRun.event;748749private readonly _keepOpen: boolean;750private readonly _menu: ChoiceAction[] | undefined;751752constructor(id: string, choice: IPromptChoice) {753super(id, choice.label, undefined, true, async () => {754755// Pass to runner756choice.run();757758// Emit Event759this._onDidRun.fire();760});761762this._keepOpen = !!choice.keepOpen;763this._menu = !choice.isSecondary && (<IPromptChoiceWithMenu>choice).menu ? (<IPromptChoiceWithMenu>choice).menu.map((c, index) => new ChoiceAction(`${id}.${index}`, c)) : undefined;764}765766get menu(): ChoiceAction[] | undefined {767return this._menu;768}769770get keepOpen(): boolean {771return this._keepOpen;772}773}774775class StatusMessageViewItem {776777static create(notification: NotificationMessage, options?: IStatusMessageOptions): IStatusMessageViewItem | undefined {778if (!notification || isCancellationError(notification)) {779return undefined; // we need a message to show780}781782let message: string | undefined;783if (notification instanceof Error) {784message = toErrorMessage(notification, false);785} else if (typeof notification === 'string') {786message = notification;787}788789if (!message) {790return undefined; // we need a message to show791}792793return { message, options };794}795}796797798