Book a Demo!
CoCalc Logo Icon
StoreFeaturesDocsShareSupportNewsAboutPoliciesSign UpSign In
microsoft
GitHub Repository: microsoft/vscode
Path: blob/main/src/vs/workbench/browser/parts/notifications/notificationsViewer.ts
3296 views
1
/*---------------------------------------------------------------------------------------------
2
* Copyright (c) Microsoft Corporation. All rights reserved.
3
* Licensed under the MIT License. See License.txt in the project root for license information.
4
*--------------------------------------------------------------------------------------------*/
5
6
import { IListVirtualDelegate, IListRenderer } from '../../../../base/browser/ui/list/list.js';
7
import { clearNode, addDisposableListener, EventType, EventHelper, $, isEventLike } from '../../../../base/browser/dom.js';
8
import { IOpenerService } from '../../../../platform/opener/common/opener.js';
9
import { URI } from '../../../../base/common/uri.js';
10
import { localize } from '../../../../nls.js';
11
import { ButtonBar, IButtonOptions } from '../../../../base/browser/ui/button/button.js';
12
import { ActionBar } from '../../../../base/browser/ui/actionbar/actionbar.js';
13
import { ActionRunner, IAction, IActionRunner, Separator, toAction } from '../../../../base/common/actions.js';
14
import { IInstantiationService } from '../../../../platform/instantiation/common/instantiation.js';
15
import { dispose, DisposableStore, Disposable } from '../../../../base/common/lifecycle.js';
16
import { IContextMenuService } from '../../../../platform/contextview/browser/contextView.js';
17
import { INotificationViewItem, NotificationViewItem, NotificationViewItemContentChangeKind, INotificationMessage, ChoiceAction } from '../../../common/notifications.js';
18
import { ClearNotificationAction, ExpandNotificationAction, CollapseNotificationAction, ConfigureNotificationAction } from './notificationsActions.js';
19
import { IKeybindingService } from '../../../../platform/keybinding/common/keybinding.js';
20
import { ProgressBar } from '../../../../base/browser/ui/progressbar/progressbar.js';
21
import { INotificationService, NotificationsFilter, Severity, isNotificationSource } from '../../../../platform/notification/common/notification.js';
22
import { isNonEmptyArray } from '../../../../base/common/arrays.js';
23
import { Codicon } from '../../../../base/common/codicons.js';
24
import { ThemeIcon } from '../../../../base/common/themables.js';
25
import { DropdownMenuActionViewItem } from '../../../../base/browser/ui/dropdown/dropdownActionViewItem.js';
26
import { DomEmitter } from '../../../../base/browser/event.js';
27
import { Gesture, EventType as GestureEventType } from '../../../../base/browser/touch.js';
28
import { Event } from '../../../../base/common/event.js';
29
import { defaultButtonStyles, defaultProgressBarStyles } from '../../../../platform/theme/browser/defaultStyles.js';
30
import { KeyCode } from '../../../../base/common/keyCodes.js';
31
import { StandardKeyboardEvent } from '../../../../base/browser/keyboardEvent.js';
32
import { getDefaultHoverDelegate } from '../../../../base/browser/ui/hover/hoverDelegateFactory.js';
33
import type { IManagedHover } from '../../../../base/browser/ui/hover/hover.js';
34
import { IHoverService } from '../../../../platform/hover/browser/hover.js';
35
36
export class NotificationsListDelegate implements IListVirtualDelegate<INotificationViewItem> {
37
38
private static readonly ROW_HEIGHT = 42;
39
private static readonly LINE_HEIGHT = 22;
40
41
private offsetHelper: HTMLElement;
42
43
constructor(container: HTMLElement) {
44
this.offsetHelper = this.createOffsetHelper(container);
45
}
46
47
private createOffsetHelper(container: HTMLElement): HTMLElement {
48
return container.appendChild($('.notification-offset-helper'));
49
}
50
51
getHeight(notification: INotificationViewItem): number {
52
if (!notification.expanded) {
53
return NotificationsListDelegate.ROW_HEIGHT; // return early if there are no more rows to show
54
}
55
56
// First row: message and actions
57
let expandedHeight = NotificationsListDelegate.ROW_HEIGHT;
58
59
// Dynamic height: if message overflows
60
const preferredMessageHeight = this.computePreferredHeight(notification);
61
const messageOverflows = NotificationsListDelegate.LINE_HEIGHT < preferredMessageHeight;
62
if (messageOverflows) {
63
const overflow = preferredMessageHeight - NotificationsListDelegate.LINE_HEIGHT;
64
expandedHeight += overflow;
65
}
66
67
// Last row: source and buttons if we have any
68
if (notification.source || isNonEmptyArray(notification.actions && notification.actions.primary)) {
69
expandedHeight += NotificationsListDelegate.ROW_HEIGHT;
70
}
71
72
// If the expanded height is same as collapsed, unset the expanded state
73
// but skip events because there is no change that has visual impact
74
if (expandedHeight === NotificationsListDelegate.ROW_HEIGHT) {
75
notification.collapse(true /* skip events, no change in height */);
76
}
77
78
return expandedHeight;
79
}
80
81
private computePreferredHeight(notification: INotificationViewItem): number {
82
83
// Prepare offset helper depending on toolbar actions count
84
let actions = 0;
85
if (!notification.hasProgress) {
86
actions++; // close
87
}
88
if (notification.canCollapse) {
89
actions++; // expand/collapse
90
}
91
if (isNonEmptyArray(notification.actions && notification.actions.secondary)) {
92
actions++; // secondary actions
93
}
94
this.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`;
95
96
// Render message into offset helper
97
const renderedMessage = NotificationMessageRenderer.render(notification.message);
98
this.offsetHelper.appendChild(renderedMessage);
99
100
// Compute height
101
const preferredHeight = Math.max(this.offsetHelper.offsetHeight, this.offsetHelper.scrollHeight);
102
103
// Always clear offset helper after use
104
clearNode(this.offsetHelper);
105
106
return preferredHeight;
107
}
108
109
getTemplateId(element: INotificationViewItem): string {
110
if (element instanceof NotificationViewItem) {
111
return NotificationRenderer.TEMPLATE_ID;
112
}
113
114
throw new Error('unknown element type: ' + element);
115
}
116
}
117
118
export interface INotificationTemplateData {
119
container: HTMLElement;
120
toDispose: DisposableStore;
121
122
mainRow: HTMLElement;
123
icon: HTMLElement;
124
message: HTMLElement;
125
toolbar: ActionBar;
126
127
detailsRow: HTMLElement;
128
source: HTMLElement;
129
buttonsContainer: HTMLElement;
130
progress: ProgressBar;
131
132
renderer: NotificationTemplateRenderer;
133
}
134
135
interface IMessageActionHandler {
136
readonly toDispose: DisposableStore;
137
138
callback: (href: string) => void;
139
}
140
141
class NotificationMessageRenderer {
142
143
static render(message: INotificationMessage, actionHandler?: IMessageActionHandler): HTMLElement {
144
const messageContainer = $('span');
145
146
for (const node of message.linkedText.nodes) {
147
if (typeof node === 'string') {
148
messageContainer.appendChild(document.createTextNode(node));
149
} else {
150
let title = node.title;
151
152
if (!title && node.href.startsWith('command:')) {
153
title = localize('executeCommand', "Click to execute command '{0}'", node.href.substr('command:'.length));
154
} else if (!title) {
155
title = node.href;
156
}
157
158
const anchor = $('a', { href: node.href, title, tabIndex: 0 }, node.label);
159
160
if (actionHandler) {
161
const handleOpen = (e: unknown) => {
162
if (isEventLike(e)) {
163
EventHelper.stop(e, true);
164
}
165
166
actionHandler.callback(node.href);
167
};
168
169
const onClick = actionHandler.toDispose.add(new DomEmitter(anchor, EventType.CLICK)).event;
170
171
const onKeydown = actionHandler.toDispose.add(new DomEmitter(anchor, EventType.KEY_DOWN)).event;
172
const onSpaceOrEnter = Event.chain(onKeydown, $ => $.filter(e => {
173
const event = new StandardKeyboardEvent(e);
174
175
return event.equals(KeyCode.Space) || event.equals(KeyCode.Enter);
176
}));
177
178
actionHandler.toDispose.add(Gesture.addTarget(anchor));
179
const onTap = actionHandler.toDispose.add(new DomEmitter(anchor, GestureEventType.Tap)).event;
180
181
Event.any(onClick, onTap, onSpaceOrEnter)(handleOpen, null, actionHandler.toDispose);
182
}
183
184
messageContainer.appendChild(anchor);
185
}
186
}
187
188
return messageContainer;
189
}
190
}
191
192
export class NotificationRenderer implements IListRenderer<INotificationViewItem, INotificationTemplateData> {
193
194
static readonly TEMPLATE_ID = 'notification';
195
196
constructor(
197
private actionRunner: IActionRunner,
198
@IContextMenuService private readonly contextMenuService: IContextMenuService,
199
@IInstantiationService private readonly instantiationService: IInstantiationService,
200
@INotificationService private readonly notificationService: INotificationService
201
) {
202
}
203
204
get templateId() {
205
return NotificationRenderer.TEMPLATE_ID;
206
}
207
208
renderTemplate(container: HTMLElement): INotificationTemplateData {
209
const data: INotificationTemplateData = Object.create(null);
210
data.toDispose = new DisposableStore();
211
212
// Container
213
data.container = $('.notification-list-item');
214
215
// Main Row
216
data.mainRow = $('.notification-list-item-main-row');
217
218
// Icon
219
data.icon = $('.notification-list-item-icon.codicon');
220
221
// Message
222
data.message = $('.notification-list-item-message');
223
224
// Toolbar
225
const that = this;
226
const toolbarContainer = $('.notification-list-item-toolbar-container');
227
data.toolbar = new ActionBar(
228
toolbarContainer,
229
{
230
ariaLabel: localize('notificationActions', "Notification Actions"),
231
actionViewItemProvider: (action, options) => {
232
if (action instanceof ConfigureNotificationAction) {
233
return data.toDispose.add(new DropdownMenuActionViewItem(action, {
234
getActions() {
235
const actions: IAction[] = [];
236
237
const source = { id: action.notification.sourceId, label: action.notification.source };
238
if (isNotificationSource(source)) {
239
const isSourceFiltered = that.notificationService.getFilter(source) === NotificationsFilter.ERROR;
240
actions.push(toAction({
241
id: source.id,
242
label: isSourceFiltered ? localize('turnOnNotifications', "Turn On All Notifications from '{0}'", source.label) : localize('turnOffNotifications', "Turn Off Info and Warning Notifications from '{0}'", source.label),
243
run: () => that.notificationService.setFilter({ ...source, filter: isSourceFiltered ? NotificationsFilter.OFF : NotificationsFilter.ERROR })
244
}));
245
246
if (action.notification.actions?.secondary?.length) {
247
actions.push(new Separator());
248
}
249
}
250
251
if (Array.isArray(action.notification.actions?.secondary)) {
252
actions.push(...action.notification.actions.secondary);
253
}
254
255
return actions;
256
},
257
}, this.contextMenuService, {
258
...options,
259
actionRunner: this.actionRunner,
260
classNames: action.class
261
}));
262
}
263
264
return undefined;
265
},
266
actionRunner: this.actionRunner
267
}
268
);
269
data.toDispose.add(data.toolbar);
270
271
// Details Row
272
data.detailsRow = $('.notification-list-item-details-row');
273
274
// Source
275
data.source = $('.notification-list-item-source');
276
277
// Buttons Container
278
data.buttonsContainer = $('.notification-list-item-buttons-container');
279
280
container.appendChild(data.container);
281
282
// the details row appears first in order for better keyboard access to notification buttons
283
data.container.appendChild(data.detailsRow);
284
data.detailsRow.appendChild(data.source);
285
data.detailsRow.appendChild(data.buttonsContainer);
286
287
// main row
288
data.container.appendChild(data.mainRow);
289
data.mainRow.appendChild(data.icon);
290
data.mainRow.appendChild(data.message);
291
data.mainRow.appendChild(toolbarContainer);
292
293
// Progress: below the rows to span the entire width of the item
294
data.progress = new ProgressBar(container, defaultProgressBarStyles);
295
data.toDispose.add(data.progress);
296
297
// Renderer
298
data.renderer = this.instantiationService.createInstance(NotificationTemplateRenderer, data, this.actionRunner);
299
data.toDispose.add(data.renderer);
300
301
return data;
302
}
303
304
renderElement(notification: INotificationViewItem, index: number, data: INotificationTemplateData): void {
305
data.renderer.setInput(notification);
306
}
307
308
disposeTemplate(templateData: INotificationTemplateData): void {
309
dispose(templateData.toDispose);
310
}
311
}
312
313
export class NotificationTemplateRenderer extends Disposable {
314
315
private static closeNotificationAction: ClearNotificationAction;
316
private static expandNotificationAction: ExpandNotificationAction;
317
private static collapseNotificationAction: CollapseNotificationAction;
318
319
private static readonly SEVERITIES = [Severity.Info, Severity.Warning, Severity.Error];
320
321
private readonly inputDisposables = this._register(new DisposableStore());
322
323
constructor(
324
private template: INotificationTemplateData,
325
private actionRunner: IActionRunner,
326
@IOpenerService private readonly openerService: IOpenerService,
327
@IInstantiationService private readonly instantiationService: IInstantiationService,
328
@IKeybindingService private readonly keybindingService: IKeybindingService,
329
@IContextMenuService private readonly contextMenuService: IContextMenuService,
330
@IHoverService private readonly hoverService: IHoverService,
331
) {
332
super();
333
334
if (!NotificationTemplateRenderer.closeNotificationAction) {
335
NotificationTemplateRenderer.closeNotificationAction = instantiationService.createInstance(ClearNotificationAction, ClearNotificationAction.ID, ClearNotificationAction.LABEL);
336
NotificationTemplateRenderer.expandNotificationAction = instantiationService.createInstance(ExpandNotificationAction, ExpandNotificationAction.ID, ExpandNotificationAction.LABEL);
337
NotificationTemplateRenderer.collapseNotificationAction = instantiationService.createInstance(CollapseNotificationAction, CollapseNotificationAction.ID, CollapseNotificationAction.LABEL);
338
}
339
}
340
341
setInput(notification: INotificationViewItem): void {
342
this.inputDisposables.clear();
343
344
this.render(notification);
345
}
346
347
private render(notification: INotificationViewItem): void {
348
349
// Container
350
this.template.container.classList.toggle('expanded', notification.expanded);
351
this.inputDisposables.add(addDisposableListener(this.template.container, EventType.MOUSE_UP, e => {
352
if (e.button === 1 /* Middle Button */) {
353
// Prevent firing the 'paste' event in the editor textarea - #109322
354
EventHelper.stop(e, true);
355
}
356
}));
357
this.inputDisposables.add(addDisposableListener(this.template.container, EventType.AUXCLICK, e => {
358
if (!notification.hasProgress && e.button === 1 /* Middle Button */) {
359
EventHelper.stop(e, true);
360
361
notification.close();
362
}
363
}));
364
365
// Severity Icon
366
this.renderSeverity(notification);
367
368
// Message
369
const messageCustomHover = this.inputDisposables.add(this.hoverService.setupManagedHover(getDefaultHoverDelegate('mouse'), this.template.message, ''));
370
const messageOverflows = this.renderMessage(notification, messageCustomHover);
371
372
// Secondary Actions
373
this.renderSecondaryActions(notification, messageOverflows);
374
375
// Source
376
const sourceCustomHover = this.inputDisposables.add(this.hoverService.setupManagedHover(getDefaultHoverDelegate('mouse'), this.template.source, ''));
377
this.renderSource(notification, sourceCustomHover);
378
379
// Buttons
380
this.renderButtons(notification);
381
382
// Progress
383
this.renderProgress(notification);
384
385
// Label Change Events that we can handle directly
386
// (changes to actions require an entire redraw of
387
// the notification because it has an impact on
388
// epxansion state)
389
this.inputDisposables.add(notification.onDidChangeContent(event => {
390
switch (event.kind) {
391
case NotificationViewItemContentChangeKind.SEVERITY:
392
this.renderSeverity(notification);
393
break;
394
case NotificationViewItemContentChangeKind.PROGRESS:
395
this.renderProgress(notification);
396
break;
397
case NotificationViewItemContentChangeKind.MESSAGE:
398
this.renderMessage(notification, messageCustomHover);
399
break;
400
}
401
}));
402
}
403
404
private renderSeverity(notification: INotificationViewItem): void {
405
// first remove, then set as the codicon class names overlap
406
NotificationTemplateRenderer.SEVERITIES.forEach(severity => {
407
if (notification.severity !== severity) {
408
this.template.icon.classList.remove(...ThemeIcon.asClassNameArray(this.toSeverityIcon(severity)));
409
}
410
});
411
this.template.icon.classList.add(...ThemeIcon.asClassNameArray(this.toSeverityIcon(notification.severity)));
412
}
413
414
private renderMessage(notification: INotificationViewItem, customHover: IManagedHover): boolean {
415
clearNode(this.template.message);
416
this.template.message.appendChild(NotificationMessageRenderer.render(notification.message, {
417
callback: link => this.openerService.open(URI.parse(link), { allowCommands: true }),
418
toDispose: this.inputDisposables
419
}));
420
421
const messageOverflows = notification.canCollapse && !notification.expanded && this.template.message.scrollWidth > this.template.message.clientWidth;
422
423
customHover.update(messageOverflows ? this.template.message.textContent + '' : '');
424
425
return messageOverflows;
426
}
427
428
private renderSecondaryActions(notification: INotificationViewItem, messageOverflows: boolean): void {
429
const actions: IAction[] = [];
430
431
// Secondary Actions
432
if (isNonEmptyArray(notification.actions?.secondary)) {
433
const configureNotificationAction = this.instantiationService.createInstance(ConfigureNotificationAction, ConfigureNotificationAction.ID, ConfigureNotificationAction.LABEL, notification);
434
actions.push(configureNotificationAction);
435
this.inputDisposables.add(configureNotificationAction);
436
}
437
438
// Expand / Collapse
439
let showExpandCollapseAction = false;
440
if (notification.canCollapse) {
441
if (notification.expanded) {
442
showExpandCollapseAction = true; // allow to collapse an expanded message
443
} else if (notification.source) {
444
showExpandCollapseAction = true; // allow to expand to details row
445
} else if (messageOverflows) {
446
showExpandCollapseAction = true; // allow to expand if message overflows
447
}
448
}
449
450
if (showExpandCollapseAction) {
451
actions.push(notification.expanded ? NotificationTemplateRenderer.collapseNotificationAction : NotificationTemplateRenderer.expandNotificationAction);
452
}
453
454
// Close (unless progress is showing)
455
if (!notification.hasProgress) {
456
actions.push(NotificationTemplateRenderer.closeNotificationAction);
457
}
458
459
this.template.toolbar.clear();
460
this.template.toolbar.context = notification;
461
actions.forEach(action => this.template.toolbar.push(action, { icon: true, label: false, keybinding: this.getKeybindingLabel(action) }));
462
}
463
464
private renderSource(notification: INotificationViewItem, sourceCustomHover: IManagedHover): void {
465
if (notification.expanded && notification.source) {
466
this.template.source.textContent = localize('notificationSource', "Source: {0}", notification.source);
467
sourceCustomHover.update(notification.source);
468
} else {
469
this.template.source.textContent = '';
470
sourceCustomHover.update('');
471
}
472
}
473
474
private renderButtons(notification: INotificationViewItem): void {
475
clearNode(this.template.buttonsContainer);
476
477
const primaryActions = notification.actions ? notification.actions.primary : undefined;
478
if (notification.expanded && isNonEmptyArray(primaryActions)) {
479
const that = this;
480
481
const actionRunner: IActionRunner = this.inputDisposables.add(new class extends ActionRunner {
482
protected override async runAction(action: IAction): Promise<void> {
483
484
// Run action
485
that.actionRunner.run(action, notification);
486
487
// Hide notification (unless explicitly prevented)
488
if (!(action instanceof ChoiceAction) || !action.keepOpen) {
489
notification.close();
490
}
491
}
492
}());
493
494
const buttonToolbar = this.inputDisposables.add(new ButtonBar(this.template.buttonsContainer));
495
for (let i = 0; i < primaryActions.length; i++) {
496
const action = primaryActions[i];
497
498
const options: IButtonOptions = {
499
title: true, // assign titles to buttons in case they overflow
500
secondary: i > 0,
501
...defaultButtonStyles
502
};
503
504
const dropdownActions = action instanceof ChoiceAction ? action.menu : undefined;
505
const button = this.inputDisposables.add(dropdownActions ?
506
buttonToolbar.addButtonWithDropdown({
507
...options,
508
contextMenuProvider: this.contextMenuService,
509
actions: dropdownActions,
510
actionRunner
511
}) :
512
buttonToolbar.addButton(options)
513
);
514
515
button.label = action.label;
516
517
this.inputDisposables.add(button.onDidClick(e => {
518
if (e) {
519
EventHelper.stop(e, true);
520
}
521
522
actionRunner.run(action);
523
}));
524
}
525
}
526
}
527
528
private renderProgress(notification: INotificationViewItem): void {
529
530
// Return early if the item has no progress
531
if (!notification.hasProgress) {
532
this.template.progress.stop().hide();
533
534
return;
535
}
536
537
// Infinite
538
const state = notification.progress.state;
539
if (state.infinite) {
540
this.template.progress.infinite().show();
541
}
542
543
// Total / Worked
544
else if (typeof state.total === 'number' || typeof state.worked === 'number') {
545
if (typeof state.total === 'number' && !this.template.progress.hasTotal()) {
546
this.template.progress.total(state.total);
547
}
548
549
if (typeof state.worked === 'number') {
550
this.template.progress.setWorked(state.worked).show();
551
}
552
}
553
554
// Done
555
else {
556
this.template.progress.done().hide();
557
}
558
}
559
560
private toSeverityIcon(severity: Severity): ThemeIcon {
561
switch (severity) {
562
case Severity.Warning:
563
return Codicon.warning;
564
case Severity.Error:
565
return Codicon.error;
566
}
567
return Codicon.info;
568
}
569
570
private getKeybindingLabel(action: IAction): string | null {
571
const keybinding = this.keybindingService.lookupKeybinding(action.id);
572
573
return keybinding ? keybinding.getLabel() : null;
574
}
575
}
576
577