Path: blob/main/src/vs/workbench/browser/parts/notifications/notificationsViewer.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 { IListVirtualDelegate, IListRenderer } from '../../../../base/browser/ui/list/list.js';6import { clearNode, addDisposableListener, EventType, EventHelper, $, isEventLike } from '../../../../base/browser/dom.js';7import { IOpenerService } from '../../../../platform/opener/common/opener.js';8import { URI } from '../../../../base/common/uri.js';9import { localize } from '../../../../nls.js';10import { ButtonBar, IButtonOptions } from '../../../../base/browser/ui/button/button.js';11import { ActionBar } from '../../../../base/browser/ui/actionbar/actionbar.js';12import { ActionRunner, IAction, IActionRunner, Separator, toAction } from '../../../../base/common/actions.js';13import { IInstantiationService } from '../../../../platform/instantiation/common/instantiation.js';14import { dispose, DisposableStore, Disposable } from '../../../../base/common/lifecycle.js';15import { IContextMenuService } from '../../../../platform/contextview/browser/contextView.js';16import { INotificationViewItem, NotificationViewItem, NotificationViewItemContentChangeKind, INotificationMessage, ChoiceAction } from '../../../common/notifications.js';17import { ClearNotificationAction, ExpandNotificationAction, CollapseNotificationAction, ConfigureNotificationAction } from './notificationsActions.js';18import { IKeybindingService } from '../../../../platform/keybinding/common/keybinding.js';19import { ProgressBar } from '../../../../base/browser/ui/progressbar/progressbar.js';20import { INotificationService, NotificationsFilter, Severity, isNotificationSource } from '../../../../platform/notification/common/notification.js';21import { isNonEmptyArray } from '../../../../base/common/arrays.js';22import { Codicon } from '../../../../base/common/codicons.js';23import { ThemeIcon } from '../../../../base/common/themables.js';24import { DropdownMenuActionViewItem } from '../../../../base/browser/ui/dropdown/dropdownActionViewItem.js';25import { DomEmitter } from '../../../../base/browser/event.js';26import { Gesture, EventType as GestureEventType } from '../../../../base/browser/touch.js';27import { Event } from '../../../../base/common/event.js';28import { defaultButtonStyles, defaultProgressBarStyles } from '../../../../platform/theme/browser/defaultStyles.js';29import { KeyCode } from '../../../../base/common/keyCodes.js';30import { StandardKeyboardEvent } from '../../../../base/browser/keyboardEvent.js';31import { getDefaultHoverDelegate } from '../../../../base/browser/ui/hover/hoverDelegateFactory.js';32import type { IManagedHover } from '../../../../base/browser/ui/hover/hover.js';33import { IHoverService } from '../../../../platform/hover/browser/hover.js';3435export class NotificationsListDelegate implements IListVirtualDelegate<INotificationViewItem> {3637private static readonly ROW_HEIGHT = 42;38private static readonly LINE_HEIGHT = 22;3940private offsetHelper: HTMLElement;4142constructor(container: HTMLElement) {43this.offsetHelper = this.createOffsetHelper(container);44}4546private createOffsetHelper(container: HTMLElement): HTMLElement {47return container.appendChild($('.notification-offset-helper'));48}4950getHeight(notification: INotificationViewItem): number {51if (!notification.expanded) {52return NotificationsListDelegate.ROW_HEIGHT; // return early if there are no more rows to show53}5455// First row: message and actions56let expandedHeight = NotificationsListDelegate.ROW_HEIGHT;5758// Dynamic height: if message overflows59const preferredMessageHeight = this.computePreferredHeight(notification);60const messageOverflows = NotificationsListDelegate.LINE_HEIGHT < preferredMessageHeight;61if (messageOverflows) {62const overflow = preferredMessageHeight - NotificationsListDelegate.LINE_HEIGHT;63expandedHeight += overflow;64}6566// Last row: source and buttons if we have any67if (notification.source || isNonEmptyArray(notification.actions && notification.actions.primary)) {68expandedHeight += NotificationsListDelegate.ROW_HEIGHT;69}7071// If the expanded height is same as collapsed, unset the expanded state72// but skip events because there is no change that has visual impact73if (expandedHeight === NotificationsListDelegate.ROW_HEIGHT) {74notification.collapse(true /* skip events, no change in height */);75}7677return expandedHeight;78}7980private computePreferredHeight(notification: INotificationViewItem): number {8182// Prepare offset helper depending on toolbar actions count83let actions = 0;84if (!notification.hasProgress) {85actions++; // close86}87if (notification.canCollapse) {88actions++; // expand/collapse89}90if (isNonEmptyArray(notification.actions && notification.actions.secondary)) {91actions++; // secondary actions92}93this.offsetHelper.style.width = `${450 /* notifications container width */ - (10 /* padding */ + 30 /* severity icon */ + (actions * 30) /* actions */ - (Math.max(actions - 1, 0) * 4) /* less padding for actions > 1 */)}px`;9495// Render message into offset helper96const renderedMessage = NotificationMessageRenderer.render(notification.message);97this.offsetHelper.appendChild(renderedMessage);9899// Compute height100const preferredHeight = Math.max(this.offsetHelper.offsetHeight, this.offsetHelper.scrollHeight);101102// Always clear offset helper after use103clearNode(this.offsetHelper);104105return preferredHeight;106}107108getTemplateId(element: INotificationViewItem): string {109if (element instanceof NotificationViewItem) {110return NotificationRenderer.TEMPLATE_ID;111}112113throw new Error('unknown element type: ' + element);114}115}116117export interface INotificationTemplateData {118container: HTMLElement;119toDispose: DisposableStore;120121mainRow: HTMLElement;122icon: HTMLElement;123message: HTMLElement;124toolbar: ActionBar;125126detailsRow: HTMLElement;127source: HTMLElement;128buttonsContainer: HTMLElement;129progress: ProgressBar;130131renderer: NotificationTemplateRenderer;132}133134interface IMessageActionHandler {135readonly toDispose: DisposableStore;136137callback: (href: string) => void;138}139140class NotificationMessageRenderer {141142static render(message: INotificationMessage, actionHandler?: IMessageActionHandler): HTMLElement {143const messageContainer = $('span');144145for (const node of message.linkedText.nodes) {146if (typeof node === 'string') {147messageContainer.appendChild(document.createTextNode(node));148} else {149let title = node.title;150151if (!title && node.href.startsWith('command:')) {152title = localize('executeCommand', "Click to execute command '{0}'", node.href.substr('command:'.length));153} else if (!title) {154title = node.href;155}156157const anchor = $('a', { href: node.href, title, tabIndex: 0 }, node.label);158159if (actionHandler) {160const handleOpen = (e: unknown) => {161if (isEventLike(e)) {162EventHelper.stop(e, true);163}164165actionHandler.callback(node.href);166};167168const onClick = actionHandler.toDispose.add(new DomEmitter(anchor, EventType.CLICK)).event;169170const onKeydown = actionHandler.toDispose.add(new DomEmitter(anchor, EventType.KEY_DOWN)).event;171const onSpaceOrEnter = Event.chain(onKeydown, $ => $.filter(e => {172const event = new StandardKeyboardEvent(e);173174return event.equals(KeyCode.Space) || event.equals(KeyCode.Enter);175}));176177actionHandler.toDispose.add(Gesture.addTarget(anchor));178const onTap = actionHandler.toDispose.add(new DomEmitter(anchor, GestureEventType.Tap)).event;179180Event.any(onClick, onTap, onSpaceOrEnter)(handleOpen, null, actionHandler.toDispose);181}182183messageContainer.appendChild(anchor);184}185}186187return messageContainer;188}189}190191export class NotificationRenderer implements IListRenderer<INotificationViewItem, INotificationTemplateData> {192193static readonly TEMPLATE_ID = 'notification';194195constructor(196private actionRunner: IActionRunner,197@IContextMenuService private readonly contextMenuService: IContextMenuService,198@IInstantiationService private readonly instantiationService: IInstantiationService,199@INotificationService private readonly notificationService: INotificationService200) {201}202203get templateId() {204return NotificationRenderer.TEMPLATE_ID;205}206207renderTemplate(container: HTMLElement): INotificationTemplateData {208const data: INotificationTemplateData = Object.create(null);209data.toDispose = new DisposableStore();210211// Container212data.container = $('.notification-list-item');213214// Main Row215data.mainRow = $('.notification-list-item-main-row');216217// Icon218data.icon = $('.notification-list-item-icon.codicon');219220// Message221data.message = $('.notification-list-item-message');222223// Toolbar224const that = this;225const toolbarContainer = $('.notification-list-item-toolbar-container');226data.toolbar = new ActionBar(227toolbarContainer,228{229ariaLabel: localize('notificationActions', "Notification Actions"),230actionViewItemProvider: (action, options) => {231if (action instanceof ConfigureNotificationAction) {232return data.toDispose.add(new DropdownMenuActionViewItem(action, {233getActions() {234const actions: IAction[] = [];235236const source = { id: action.notification.sourceId, label: action.notification.source };237if (isNotificationSource(source)) {238const isSourceFiltered = that.notificationService.getFilter(source) === NotificationsFilter.ERROR;239actions.push(toAction({240id: source.id,241label: isSourceFiltered ? localize('turnOnNotifications', "Turn On All Notifications from '{0}'", source.label) : localize('turnOffNotifications', "Turn Off Info and Warning Notifications from '{0}'", source.label),242run: () => that.notificationService.setFilter({ ...source, filter: isSourceFiltered ? NotificationsFilter.OFF : NotificationsFilter.ERROR })243}));244245if (action.notification.actions?.secondary?.length) {246actions.push(new Separator());247}248}249250if (Array.isArray(action.notification.actions?.secondary)) {251actions.push(...action.notification.actions.secondary);252}253254return actions;255},256}, this.contextMenuService, {257...options,258actionRunner: this.actionRunner,259classNames: action.class260}));261}262263return undefined;264},265actionRunner: this.actionRunner266}267);268data.toDispose.add(data.toolbar);269270// Details Row271data.detailsRow = $('.notification-list-item-details-row');272273// Source274data.source = $('.notification-list-item-source');275276// Buttons Container277data.buttonsContainer = $('.notification-list-item-buttons-container');278279container.appendChild(data.container);280281// the details row appears first in order for better keyboard access to notification buttons282data.container.appendChild(data.detailsRow);283data.detailsRow.appendChild(data.source);284data.detailsRow.appendChild(data.buttonsContainer);285286// main row287data.container.appendChild(data.mainRow);288data.mainRow.appendChild(data.icon);289data.mainRow.appendChild(data.message);290data.mainRow.appendChild(toolbarContainer);291292// Progress: below the rows to span the entire width of the item293data.progress = new ProgressBar(container, defaultProgressBarStyles);294data.toDispose.add(data.progress);295296// Renderer297data.renderer = this.instantiationService.createInstance(NotificationTemplateRenderer, data, this.actionRunner);298data.toDispose.add(data.renderer);299300return data;301}302303renderElement(notification: INotificationViewItem, index: number, data: INotificationTemplateData): void {304data.renderer.setInput(notification);305}306307disposeTemplate(templateData: INotificationTemplateData): void {308dispose(templateData.toDispose);309}310}311312export class NotificationTemplateRenderer extends Disposable {313314private static closeNotificationAction: ClearNotificationAction;315private static expandNotificationAction: ExpandNotificationAction;316private static collapseNotificationAction: CollapseNotificationAction;317318private static readonly SEVERITIES = [Severity.Info, Severity.Warning, Severity.Error];319320private readonly inputDisposables = this._register(new DisposableStore());321322constructor(323private template: INotificationTemplateData,324private actionRunner: IActionRunner,325@IOpenerService private readonly openerService: IOpenerService,326@IInstantiationService private readonly instantiationService: IInstantiationService,327@IKeybindingService private readonly keybindingService: IKeybindingService,328@IContextMenuService private readonly contextMenuService: IContextMenuService,329@IHoverService private readonly hoverService: IHoverService,330) {331super();332333if (!NotificationTemplateRenderer.closeNotificationAction) {334NotificationTemplateRenderer.closeNotificationAction = instantiationService.createInstance(ClearNotificationAction, ClearNotificationAction.ID, ClearNotificationAction.LABEL);335NotificationTemplateRenderer.expandNotificationAction = instantiationService.createInstance(ExpandNotificationAction, ExpandNotificationAction.ID, ExpandNotificationAction.LABEL);336NotificationTemplateRenderer.collapseNotificationAction = instantiationService.createInstance(CollapseNotificationAction, CollapseNotificationAction.ID, CollapseNotificationAction.LABEL);337}338}339340setInput(notification: INotificationViewItem): void {341this.inputDisposables.clear();342343this.render(notification);344}345346private render(notification: INotificationViewItem): void {347348// Container349this.template.container.classList.toggle('expanded', notification.expanded);350this.inputDisposables.add(addDisposableListener(this.template.container, EventType.MOUSE_UP, e => {351if (e.button === 1 /* Middle Button */) {352// Prevent firing the 'paste' event in the editor textarea - #109322353EventHelper.stop(e, true);354}355}));356this.inputDisposables.add(addDisposableListener(this.template.container, EventType.AUXCLICK, e => {357if (!notification.hasProgress && e.button === 1 /* Middle Button */) {358EventHelper.stop(e, true);359360notification.close();361}362}));363364// Severity Icon365this.renderSeverity(notification);366367// Message368const messageCustomHover = this.inputDisposables.add(this.hoverService.setupManagedHover(getDefaultHoverDelegate('mouse'), this.template.message, ''));369const messageOverflows = this.renderMessage(notification, messageCustomHover);370371// Secondary Actions372this.renderSecondaryActions(notification, messageOverflows);373374// Source375const sourceCustomHover = this.inputDisposables.add(this.hoverService.setupManagedHover(getDefaultHoverDelegate('mouse'), this.template.source, ''));376this.renderSource(notification, sourceCustomHover);377378// Buttons379this.renderButtons(notification);380381// Progress382this.renderProgress(notification);383384// Label Change Events that we can handle directly385// (changes to actions require an entire redraw of386// the notification because it has an impact on387// epxansion state)388this.inputDisposables.add(notification.onDidChangeContent(event => {389switch (event.kind) {390case NotificationViewItemContentChangeKind.SEVERITY:391this.renderSeverity(notification);392break;393case NotificationViewItemContentChangeKind.PROGRESS:394this.renderProgress(notification);395break;396case NotificationViewItemContentChangeKind.MESSAGE:397this.renderMessage(notification, messageCustomHover);398break;399}400}));401}402403private renderSeverity(notification: INotificationViewItem): void {404// first remove, then set as the codicon class names overlap405NotificationTemplateRenderer.SEVERITIES.forEach(severity => {406if (notification.severity !== severity) {407this.template.icon.classList.remove(...ThemeIcon.asClassNameArray(this.toSeverityIcon(severity)));408}409});410this.template.icon.classList.add(...ThemeIcon.asClassNameArray(this.toSeverityIcon(notification.severity)));411}412413private renderMessage(notification: INotificationViewItem, customHover: IManagedHover): boolean {414clearNode(this.template.message);415this.template.message.appendChild(NotificationMessageRenderer.render(notification.message, {416callback: link => this.openerService.open(URI.parse(link), { allowCommands: true }),417toDispose: this.inputDisposables418}));419420const messageOverflows = notification.canCollapse && !notification.expanded && this.template.message.scrollWidth > this.template.message.clientWidth;421422customHover.update(messageOverflows ? this.template.message.textContent + '' : '');423424return messageOverflows;425}426427private renderSecondaryActions(notification: INotificationViewItem, messageOverflows: boolean): void {428const actions: IAction[] = [];429430// Secondary Actions431if (isNonEmptyArray(notification.actions?.secondary)) {432const configureNotificationAction = this.instantiationService.createInstance(ConfigureNotificationAction, ConfigureNotificationAction.ID, ConfigureNotificationAction.LABEL, notification);433actions.push(configureNotificationAction);434this.inputDisposables.add(configureNotificationAction);435}436437// Expand / Collapse438let showExpandCollapseAction = false;439if (notification.canCollapse) {440if (notification.expanded) {441showExpandCollapseAction = true; // allow to collapse an expanded message442} else if (notification.source) {443showExpandCollapseAction = true; // allow to expand to details row444} else if (messageOverflows) {445showExpandCollapseAction = true; // allow to expand if message overflows446}447}448449if (showExpandCollapseAction) {450actions.push(notification.expanded ? NotificationTemplateRenderer.collapseNotificationAction : NotificationTemplateRenderer.expandNotificationAction);451}452453// Close (unless progress is showing)454if (!notification.hasProgress) {455actions.push(NotificationTemplateRenderer.closeNotificationAction);456}457458this.template.toolbar.clear();459this.template.toolbar.context = notification;460actions.forEach(action => this.template.toolbar.push(action, { icon: true, label: false, keybinding: this.getKeybindingLabel(action) }));461}462463private renderSource(notification: INotificationViewItem, sourceCustomHover: IManagedHover): void {464if (notification.expanded && notification.source) {465this.template.source.textContent = localize('notificationSource', "Source: {0}", notification.source);466sourceCustomHover.update(notification.source);467} else {468this.template.source.textContent = '';469sourceCustomHover.update('');470}471}472473private renderButtons(notification: INotificationViewItem): void {474clearNode(this.template.buttonsContainer);475476const primaryActions = notification.actions ? notification.actions.primary : undefined;477if (notification.expanded && isNonEmptyArray(primaryActions)) {478const that = this;479480const actionRunner: IActionRunner = this.inputDisposables.add(new class extends ActionRunner {481protected override async runAction(action: IAction): Promise<void> {482483// Run action484that.actionRunner.run(action, notification);485486// Hide notification (unless explicitly prevented)487if (!(action instanceof ChoiceAction) || !action.keepOpen) {488notification.close();489}490}491}());492493const buttonToolbar = this.inputDisposables.add(new ButtonBar(this.template.buttonsContainer));494for (let i = 0; i < primaryActions.length; i++) {495const action = primaryActions[i];496497const options: IButtonOptions = {498title: true, // assign titles to buttons in case they overflow499secondary: i > 0,500...defaultButtonStyles501};502503const dropdownActions = action instanceof ChoiceAction ? action.menu : undefined;504const button = this.inputDisposables.add(dropdownActions ?505buttonToolbar.addButtonWithDropdown({506...options,507contextMenuProvider: this.contextMenuService,508actions: dropdownActions,509actionRunner510}) :511buttonToolbar.addButton(options)512);513514button.label = action.label;515516this.inputDisposables.add(button.onDidClick(e => {517if (e) {518EventHelper.stop(e, true);519}520521actionRunner.run(action);522}));523}524}525}526527private renderProgress(notification: INotificationViewItem): void {528529// Return early if the item has no progress530if (!notification.hasProgress) {531this.template.progress.stop().hide();532533return;534}535536// Infinite537const state = notification.progress.state;538if (state.infinite) {539this.template.progress.infinite().show();540}541542// Total / Worked543else if (typeof state.total === 'number' || typeof state.worked === 'number') {544if (typeof state.total === 'number' && !this.template.progress.hasTotal()) {545this.template.progress.total(state.total);546}547548if (typeof state.worked === 'number') {549this.template.progress.setWorked(state.worked).show();550}551}552553// Done554else {555this.template.progress.done().hide();556}557}558559private toSeverityIcon(severity: Severity): ThemeIcon {560switch (severity) {561case Severity.Warning:562return Codicon.warning;563case Severity.Error:564return Codicon.error;565}566return Codicon.info;567}568569private getKeybindingLabel(action: IAction): string | null {570const keybinding = this.keybindingService.lookupKeybinding(action.id);571572return keybinding ? keybinding.getLabel() : null;573}574}575576577