Line data Source code
1 : import * as React from 'react';
2 : import { createPortal } from 'react-dom';
3 :
4 : import { useLogger } from '../../platform/logger/logger_provider.js';
5 : import { LogBuffer, LogLevel, LogRecord } from '../../platform/logger/log_buffer.js';
6 : import { classNames } from '../../utils/classnames.js';
7 :
8 : import * as styles from './logger_toast.module.css';
9 : import { SymbolIcon } from '../foundations/symbol_icon.js';
10 : import { IconButton } from '../foundations/button.js';
11 :
12 1 : const TOAST_DURATION_MS = 6000;
13 1 : const TOAST_EXIT_DURATION_MS = 200;
14 1 : const MAX_VISIBLE_TOASTS = 5;
15 :
16 : interface ToastItem {
17 : id: number;
18 : record: LogRecord;
19 : exiting: boolean;
20 : }
21 :
22 : interface ToastProps {
23 : item: ToastItem;
24 : onDismiss: (id: number) => void;
25 : }
26 :
27 1 : export function shouldShowLoggerToast(record: LogRecord): boolean {
28 1 : return record.level === LogLevel.Error;
29 1 : }
30 :
31 0 : function Toast({ item, onDismiss }: ToastProps) {
32 0 : const CloseIcon = SymbolIcon("x_16");
33 0 : const ErrorIcon = SymbolIcon("alert_fill_16");
34 :
35 0 : return (
36 0 : <div
37 0 : className={classNames(styles.toast, styles.toast_error, {
38 0 : [styles.toast_exiting]: item.exiting,
39 0 : })}
40 0 : role="alert"
41 0 : aria-live="assertive"
42 : >
43 0 : <div className={styles.toast_icon}>
44 0 : <ErrorIcon />
45 0 : </div>
46 0 : <div className={styles.toast_content}>
47 0 : <div className={styles.toast_message}>{item.record.message}</div>
48 0 : <div className={styles.toast_keyvalues}>
49 0 : {Object.entries(item.record.keyValues).map(([k, v], i) => (
50 0 : <React.Fragment key={i}>
51 0 : <span key={i * 2 + 0} className={styles.toast_kv_key}>{k}</span>
52 0 : <span key={i * 2 + 1} className={styles.toast_kv_value}>{v}</span>
53 0 : </React.Fragment>
54 0 : ))}
55 0 : </div>
56 0 : </div>
57 0 : <IconButton
58 0 : className={styles.toast_close}
59 0 : onClick={() => onDismiss(item.id)}
60 0 : aria-label="Dismiss notification"
61 : >
62 0 : <CloseIcon />
63 0 : </IconButton>
64 0 : </div>
65 : );
66 0 : }
67 :
68 1 : export function LoggerToast() {
69 1 : const logger = useLogger();
70 1 : const [toasts, setToasts] = React.useState<ToastItem[]>([]);
71 1 : const nextIdRef = React.useRef(0);
72 :
73 1 : const dismissToast = React.useCallback((id: number) => {
74 : // Start exit animation
75 0 : setToasts(prev => prev.map(t => (t.id === id ? { ...t, exiting: true } : t)));
76 :
77 : // Remove after animation completes
78 0 : setTimeout(() => {
79 0 : setToasts(prev => prev.filter(t => t.id !== id));
80 0 : }, TOAST_EXIT_DURATION_MS);
81 1 : }, []);
82 :
83 1 : React.useEffect(() => {
84 1 : const buffer = logger.buffer;
85 : // Track how many entries we've already seen
86 1 : let lastSeenLength = buffer.length;
87 :
88 1 : const observer = (buf: LogBuffer) => {
89 1 : const currentLength = buf.length;
90 :
91 : // Process all new entries since last observation
92 1 : for (let i = lastSeenLength; i < currentLength; i++) {
93 1 : const record = buf.at(i);
94 1 : if (!record) continue;
95 :
96 : // Errors require user attention. Warnings remain available in the logs without
97 : // interrupting the user's current workflow.
98 1 : if (!shouldShowLoggerToast(record)) {
99 1 : continue;
100 : }
101 :
102 1 : const id = nextIdRef.current++;
103 1 : setToasts(prev => {
104 0 : const newToasts = [...prev, { id, record, exiting: false }];
105 : // Limit visible toasts
106 0 : if (newToasts.length > MAX_VISIBLE_TOASTS) {
107 0 : return newToasts.slice(-MAX_VISIBLE_TOASTS);
108 : }
109 0 : return newToasts;
110 1 : });
111 :
112 : // Auto-dismiss after duration
113 1 : setTimeout(() => {
114 0 : dismissToast(id);
115 1 : }, TOAST_DURATION_MS);
116 1 : }
117 :
118 1 : lastSeenLength = currentLength;
119 1 : };
120 :
121 1 : buffer.subscribe(observer);
122 :
123 1 : return () => {
124 0 : buffer.observers.delete(observer);
125 0 : };
126 1 : }, [logger, dismissToast]);
127 :
128 1 : if (toasts.length === 0) {
129 1 : return null;
130 : }
131 :
132 1 : return createPortal(
133 1 : <div className={styles.toast_container}>
134 1 : {toasts.map(item => (
135 0 : <Toast key={item.id} item={item} onDismiss={dismissToast} />
136 1 : ))}
137 1 : </div>,
138 1 : document.body
139 1 : );
140 1 : }
|