@rxtx4816/cockpit-plugin-base-react 1.0.5 → 1.0.6
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/src/components/LogViewer.tsx +227 -53
- package/src/lib/logParser.tsx +85 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@rxtx4816/cockpit-plugin-base-react",
|
|
3
|
-
"version": "1.0.
|
|
3
|
+
"version": "1.0.6",
|
|
4
4
|
"description": "Shared infrastructure for Cockpit plugins: i18n, dark theme, test setup, config presets, CI/CD workflows, and QEMU VM harness",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"author": "RXTX4816",
|
|
@@ -1,6 +1,7 @@
|
|
|
1
|
-
import { useState } from "react";
|
|
1
|
+
import { useState, useEffect, useRef, useCallback, type CSSProperties, type ReactNode } from "react";
|
|
2
2
|
import {
|
|
3
3
|
Alert,
|
|
4
|
+
AlertActionCloseButton,
|
|
4
5
|
Button,
|
|
5
6
|
SearchInput,
|
|
6
7
|
Spinner,
|
|
@@ -9,51 +10,188 @@ import {
|
|
|
9
10
|
Toolbar,
|
|
10
11
|
ToolbarContent,
|
|
11
12
|
ToolbarItem,
|
|
13
|
+
ToolbarGroup,
|
|
12
14
|
} from "@patternfly/react-core";
|
|
15
|
+
import { highlightWithSearch } from "../lib/logParser";
|
|
13
16
|
|
|
14
17
|
interface Props {
|
|
15
|
-
/** Log lines to display. Each string becomes one line
|
|
18
|
+
/** Log lines to display. Each string becomes one highlighted line. */
|
|
16
19
|
lines: string[];
|
|
17
|
-
/** When `true`, shows a spinner instead of
|
|
20
|
+
/** When `true`, shows a spinner instead of log content on first load. */
|
|
18
21
|
loading?: boolean;
|
|
19
|
-
/** If set, renders a danger alert at the top
|
|
22
|
+
/** If set, renders a danger alert at the top. */
|
|
20
23
|
error?: string | null;
|
|
21
|
-
/** When provided, adds a refresh button
|
|
24
|
+
/** When provided, adds a refresh button. */
|
|
22
25
|
onRefresh?: () => void;
|
|
23
|
-
/**
|
|
26
|
+
/** When `true`, log output is frozen (auto-scroll stops). */
|
|
27
|
+
paused?: boolean;
|
|
28
|
+
/** Called when the user clicks the Pause button. */
|
|
29
|
+
onPause?: () => void;
|
|
30
|
+
/** Called when the user clicks the Resume button. */
|
|
31
|
+
onResume?: () => void;
|
|
32
|
+
/**
|
|
33
|
+
* Suggested download file name (without extension). When provided a download
|
|
34
|
+
* button is shown. First tries the File System Access API; falls back to
|
|
35
|
+
* saving via cockpit to ~/Downloads/ (Firefox/Cockpit-iframe fallback).
|
|
36
|
+
*/
|
|
37
|
+
downloadFileName?: string;
|
|
38
|
+
/** Pre-fills and drives the search box externally. */
|
|
39
|
+
filterValue?: string;
|
|
40
|
+
/** Called whenever the search input changes. */
|
|
41
|
+
onFilterChange?: (value: string) => void;
|
|
42
|
+
/** Placeholder text for the search input. */
|
|
24
43
|
searchPlaceholder?: string;
|
|
25
|
-
/** Message shown when `lines` is empty
|
|
44
|
+
/** Message shown when `lines` is empty and not loading. */
|
|
26
45
|
emptyMessage?: string;
|
|
27
|
-
/** Message shown when the
|
|
46
|
+
/** Message shown when the filter matches nothing. */
|
|
28
47
|
noMatchesMessage?: string;
|
|
29
|
-
/** Title of the danger alert when `error` is set.
|
|
48
|
+
/** Title of the danger alert when `error` is set. */
|
|
30
49
|
errorTitle?: string;
|
|
31
|
-
/** `aria-label` for the refresh button.
|
|
50
|
+
/** `aria-label` for the refresh button. */
|
|
32
51
|
refreshAriaLabel?: string;
|
|
33
52
|
}
|
|
34
53
|
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
54
|
+
const VIEWER_STYLE: CSSProperties = {
|
|
55
|
+
overflowY: "auto",
|
|
56
|
+
maxHeight: "60vh",
|
|
57
|
+
background: "#0d1117",
|
|
58
|
+
borderRadius: "var(--pf-v6-global--BorderRadius--sm, 4px)",
|
|
59
|
+
padding: "0.4rem 0",
|
|
60
|
+
fontFamily: "var(--pf-t--global--font--family--mono, monospace)",
|
|
61
|
+
fontSize: "0.78rem",
|
|
62
|
+
lineHeight: 1.6,
|
|
63
|
+
};
|
|
64
|
+
|
|
65
|
+
const LINE_BASE: CSSProperties = {
|
|
66
|
+
padding: "0.05rem 0.75rem",
|
|
67
|
+
whiteSpace: "pre-wrap",
|
|
68
|
+
wordBreak: "break-all",
|
|
69
|
+
color: "#e6edf3",
|
|
70
|
+
};
|
|
71
|
+
|
|
72
|
+
function levelBg(line: string): string {
|
|
73
|
+
if (/\b(FATAL|CRITICAL|ERROR|ERR)\b/i.test(line)) return "rgba(248,81,73,0.10)";
|
|
74
|
+
if (/\bWARN(ING)?\b/i.test(line)) return "rgba(227,179,65,0.08)";
|
|
75
|
+
return "";
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function LogLine({ line, search, isRegex, index }: {
|
|
79
|
+
line: string; search: string; isRegex: boolean; index: number;
|
|
80
|
+
}): ReactNode {
|
|
81
|
+
const bg = levelBg(line) || (index % 2 !== 0 ? "rgba(255,255,255,0.015)" : "transparent");
|
|
82
|
+
return (
|
|
83
|
+
<div style={{ ...LINE_BASE, background: bg }}>
|
|
84
|
+
{highlightWithSearch(line, search, isRegex)}
|
|
85
|
+
</div>
|
|
86
|
+
);
|
|
87
|
+
}
|
|
88
|
+
|
|
41
89
|
export function LogViewer({
|
|
42
90
|
lines,
|
|
43
91
|
loading = false,
|
|
44
92
|
error,
|
|
45
93
|
onRefresh,
|
|
94
|
+
paused = false,
|
|
95
|
+
onPause,
|
|
96
|
+
onResume,
|
|
97
|
+
downloadFileName,
|
|
98
|
+
filterValue,
|
|
99
|
+
onFilterChange,
|
|
46
100
|
searchPlaceholder = "Search logs…",
|
|
47
101
|
emptyMessage = "No log entries.",
|
|
48
102
|
noMatchesMessage = "No matching entries.",
|
|
49
103
|
errorTitle = "Failed to load logs",
|
|
50
104
|
refreshAriaLabel = "Refresh",
|
|
51
105
|
}: Props) {
|
|
52
|
-
const [
|
|
106
|
+
const [internalSearch, setInternalSearch] = useState(filterValue ?? "");
|
|
107
|
+
const [isRegex, setIsRegex] = useState(false);
|
|
108
|
+
const [downloadNote, setDownloadNote] = useState<string | null>(null);
|
|
109
|
+
const [downloadError, setDownloadError] = useState<string | null>(null);
|
|
110
|
+
const logRef = useRef<HTMLDivElement>(null);
|
|
111
|
+
|
|
112
|
+
useEffect(() => {
|
|
113
|
+
setInternalSearch(filterValue ?? "");
|
|
114
|
+
}, [filterValue]);
|
|
115
|
+
|
|
116
|
+
const search = filterValue !== undefined ? filterValue : internalSearch;
|
|
117
|
+
|
|
118
|
+
const setSearch = useCallback((v: string) => {
|
|
119
|
+
if (onFilterChange) onFilterChange(v);
|
|
120
|
+
else setInternalSearch(v);
|
|
121
|
+
}, [onFilterChange]);
|
|
122
|
+
|
|
123
|
+
useEffect(() => {
|
|
124
|
+
if (!paused && logRef.current) {
|
|
125
|
+
logRef.current.scrollTop = logRef.current.scrollHeight;
|
|
126
|
+
}
|
|
127
|
+
}, [lines, paused]);
|
|
128
|
+
|
|
129
|
+
const scrollToTop = () => { if (logRef.current) logRef.current.scrollTop = 0; };
|
|
130
|
+
const scrollToBottom = () => { if (logRef.current) logRef.current.scrollTop = logRef.current.scrollHeight; };
|
|
131
|
+
|
|
132
|
+
const filtered = lines.filter(l => {
|
|
133
|
+
if (!search) return true;
|
|
134
|
+
try {
|
|
135
|
+
return isRegex ? new RegExp(search, "i").test(l) : l.toLowerCase().includes(search.toLowerCase());
|
|
136
|
+
} catch { return true; }
|
|
137
|
+
});
|
|
53
138
|
|
|
54
|
-
const
|
|
55
|
-
|
|
56
|
-
|
|
139
|
+
const handleDownload = useCallback(async () => {
|
|
140
|
+
if (!downloadFileName) return;
|
|
141
|
+
const text = filtered.join("\n");
|
|
142
|
+
if ("showSaveFilePicker" in window) {
|
|
143
|
+
try {
|
|
144
|
+
const handle = await (window as Window & {
|
|
145
|
+
showSaveFilePicker(o: object): Promise<{
|
|
146
|
+
createWritable(): Promise<{ write(s: string): Promise<void>; close(): Promise<void> }>;
|
|
147
|
+
}>;
|
|
148
|
+
}).showSaveFilePicker({
|
|
149
|
+
suggestedName: `${downloadFileName}.txt`,
|
|
150
|
+
types: [{ description: "Text files", accept: { "text/plain": [".txt"] } }],
|
|
151
|
+
});
|
|
152
|
+
const w = await handle.createWritable();
|
|
153
|
+
await w.write(text);
|
|
154
|
+
await w.close();
|
|
155
|
+
return;
|
|
156
|
+
} catch (e) {
|
|
157
|
+
if ((e as { name?: string }).name === "AbortError") return;
|
|
158
|
+
// SecurityError in Cockpit's iframe — fall through
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
try {
|
|
162
|
+
const user = await cockpit.user();
|
|
163
|
+
const ts = new Date().toISOString().replace(/[:.]/g, "-").slice(0, 19);
|
|
164
|
+
const savePath = `${user.home}/Downloads/${downloadFileName}-${ts}.txt`;
|
|
165
|
+
await cockpit.spawn(["mkdir", "-p", "--", `${user.home}/Downloads`], { err: "message" });
|
|
166
|
+
await cockpit.file(savePath).replace(text);
|
|
167
|
+
setDownloadNote(savePath);
|
|
168
|
+
} catch (err) {
|
|
169
|
+
setDownloadError((err as { message?: string })?.message ?? String(err));
|
|
170
|
+
}
|
|
171
|
+
}, [filtered, downloadFileName]);
|
|
172
|
+
|
|
173
|
+
const lineCount = filtered.length !== lines.length
|
|
174
|
+
? `${filtered.length} / ${lines.length}`
|
|
175
|
+
: String(lines.length);
|
|
176
|
+
|
|
177
|
+
const hasPauseControl = onPause !== undefined || onResume !== undefined;
|
|
178
|
+
|
|
179
|
+
const regexBtnStyle: CSSProperties = {
|
|
180
|
+
height: "var(--pf-t--global--control--size--base, 36px)",
|
|
181
|
+
padding: "0 0.5rem",
|
|
182
|
+
fontFamily: "var(--pf-t--global--font--family--mono, monospace)",
|
|
183
|
+
fontSize: "0.8rem",
|
|
184
|
+
fontWeight: 600,
|
|
185
|
+
border: "1px solid var(--pf-t--global--border--color--default)",
|
|
186
|
+
borderRadius: "var(--pf-t--global--border--radius--100, 4px)",
|
|
187
|
+
cursor: "pointer",
|
|
188
|
+
transition: "background 120ms ease, color 120ms ease",
|
|
189
|
+
background: isRegex
|
|
190
|
+
? "var(--pf-t--global--color--brand--default, #06c)"
|
|
191
|
+
: "var(--pf-t--global--background--color--primary--default)",
|
|
192
|
+
color: isRegex ? "#fff" : "var(--pf-t--global--text--color--subtle)",
|
|
193
|
+
borderColor: isRegex ? "var(--pf-t--global--color--brand--default, #06c)" : undefined,
|
|
194
|
+
};
|
|
57
195
|
|
|
58
196
|
return (
|
|
59
197
|
<Stack hasGutter>
|
|
@@ -62,31 +200,79 @@ export function LogViewer({
|
|
|
62
200
|
<Alert
|
|
63
201
|
variant="danger"
|
|
64
202
|
title={errorTitle}
|
|
65
|
-
actionLinks={onRefresh &&
|
|
66
|
-
<Button variant="link" onClick={onRefresh}>Retry</Button>
|
|
67
|
-
)}
|
|
203
|
+
actionLinks={onRefresh && <Button variant="link" onClick={onRefresh}>Retry</Button>}
|
|
68
204
|
>
|
|
69
205
|
{error}
|
|
70
206
|
</Alert>
|
|
71
207
|
</StackItem>
|
|
72
208
|
)}
|
|
209
|
+
{downloadNote && (
|
|
210
|
+
<StackItem>
|
|
211
|
+
<Alert variant="info" isInline title={`Saved to ${downloadNote}`}
|
|
212
|
+
actionClose={<AlertActionCloseButton onClose={() => setDownloadNote(null)} />}
|
|
213
|
+
/>
|
|
214
|
+
</StackItem>
|
|
215
|
+
)}
|
|
216
|
+
{downloadError && (
|
|
217
|
+
<StackItem>
|
|
218
|
+
<Alert variant="danger" isInline title={downloadError}
|
|
219
|
+
actionClose={<AlertActionCloseButton onClose={() => setDownloadError(null)} />}
|
|
220
|
+
/>
|
|
221
|
+
</StackItem>
|
|
222
|
+
)}
|
|
73
223
|
|
|
74
224
|
<StackItem>
|
|
75
|
-
<Toolbar>
|
|
225
|
+
<Toolbar style={{ paddingInline: 0 }}>
|
|
76
226
|
<ToolbarContent>
|
|
77
|
-
<
|
|
78
|
-
<
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
227
|
+
<ToolbarGroup variant="filter-group">
|
|
228
|
+
<ToolbarItem>
|
|
229
|
+
<div style={{ display: "flex", alignItems: "center", gap: "0.25rem" }}>
|
|
230
|
+
<SearchInput
|
|
231
|
+
placeholder={searchPlaceholder}
|
|
232
|
+
value={search}
|
|
233
|
+
onChange={(_e, v) => setSearch(v)}
|
|
234
|
+
onClear={() => setSearch("")}
|
|
235
|
+
style={{ width: 220 }}
|
|
236
|
+
/>
|
|
237
|
+
<button type="button" aria-label="Toggle regex" aria-pressed={isRegex}
|
|
238
|
+
title="Toggle regex filter" onClick={() => setIsRegex(r => !r)}
|
|
239
|
+
style={regexBtnStyle}
|
|
240
|
+
>.*</button>
|
|
241
|
+
</div>
|
|
88
242
|
</ToolbarItem>
|
|
89
|
-
|
|
243
|
+
<ToolbarItem>
|
|
244
|
+
<span style={{ fontSize: "0.75rem", color: "var(--pf-v6-global--Color--200)" }}>
|
|
245
|
+
{lineCount} lines
|
|
246
|
+
</span>
|
|
247
|
+
</ToolbarItem>
|
|
248
|
+
</ToolbarGroup>
|
|
249
|
+
|
|
250
|
+
<ToolbarGroup variant="action-group-plain" align={{ default: "alignEnd" }}>
|
|
251
|
+
<ToolbarItem>
|
|
252
|
+
<Button variant="plain" size="sm" onClick={scrollToTop} aria-label="Jump to top" title="Jump to top">⇑</Button>
|
|
253
|
+
</ToolbarItem>
|
|
254
|
+
<ToolbarItem>
|
|
255
|
+
<Button variant="plain" size="sm" onClick={scrollToBottom} aria-label="Jump to bottom" title="Jump to bottom">⇓</Button>
|
|
256
|
+
</ToolbarItem>
|
|
257
|
+
{hasPauseControl && (
|
|
258
|
+
<ToolbarItem>
|
|
259
|
+
{paused
|
|
260
|
+
? <Button variant="primary" size="sm" onClick={onResume}>▶ Resume</Button>
|
|
261
|
+
: <Button variant="secondary" size="sm" onClick={onPause}>⏸ Pause</Button>
|
|
262
|
+
}
|
|
263
|
+
</ToolbarItem>
|
|
264
|
+
)}
|
|
265
|
+
{downloadFileName && filtered.length > 0 && (
|
|
266
|
+
<ToolbarItem>
|
|
267
|
+
<Button variant="plain" size="sm" onClick={() => void handleDownload()} aria-label="Download logs" title="Download logs">⬇</Button>
|
|
268
|
+
</ToolbarItem>
|
|
269
|
+
)}
|
|
270
|
+
{onRefresh && (
|
|
271
|
+
<ToolbarItem>
|
|
272
|
+
<Button variant="plain" size="sm" onClick={onRefresh} aria-label={refreshAriaLabel} title={refreshAriaLabel}>↺</Button>
|
|
273
|
+
</ToolbarItem>
|
|
274
|
+
)}
|
|
275
|
+
</ToolbarGroup>
|
|
90
276
|
</ToolbarContent>
|
|
91
277
|
</Toolbar>
|
|
92
278
|
</StackItem>
|
|
@@ -99,23 +285,11 @@ export function LogViewer({
|
|
|
99
285
|
{lines.length === 0 ? emptyMessage : noMatchesMessage}
|
|
100
286
|
</p>
|
|
101
287
|
) : (
|
|
102
|
-
<
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
maxHeight: "60vh",
|
|
108
|
-
overflowY: "auto",
|
|
109
|
-
background: "var(--pf-v6-global--BackgroundColor--dark-100, #1b1d21)",
|
|
110
|
-
color: "var(--pf-v6-global--Color--light-100, #e8e8e8)",
|
|
111
|
-
padding: "1rem",
|
|
112
|
-
borderRadius: "var(--pf-v6-global--BorderRadius--sm, 4px)",
|
|
113
|
-
whiteSpace: "pre-wrap",
|
|
114
|
-
wordBreak: "break-all",
|
|
115
|
-
}}
|
|
116
|
-
>
|
|
117
|
-
{filtered.join("\n")}
|
|
118
|
-
</pre>
|
|
288
|
+
<div ref={logRef} style={VIEWER_STYLE}>
|
|
289
|
+
{filtered.map((line, i) => (
|
|
290
|
+
<LogLine key={i} line={line} search={search} isRegex={isRegex} index={i} />
|
|
291
|
+
))}
|
|
292
|
+
</div>
|
|
119
293
|
)}
|
|
120
294
|
</StackItem>
|
|
121
295
|
</Stack>
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
import { type ReactNode } from "react";
|
|
2
|
+
|
|
3
|
+
// Token types in priority order; first match wins at each position.
|
|
4
|
+
export const TOKEN_RE = new RegExp(
|
|
5
|
+
[
|
|
6
|
+
String.raw`\b(FATAL|CRITICAL|ERROR|ERR|WARN(?:ING)?|INFO|DEBUG|TRACE)\b`,
|
|
7
|
+
String.raw`\b5\d{2}\b`,
|
|
8
|
+
String.raw`\b4\d{2}\b`,
|
|
9
|
+
String.raw`\b[23]\d{2}\b`,
|
|
10
|
+
String.raw`"[^"]*"|'[^']*'`,
|
|
11
|
+
String.raw`(?:\/[\w.\-]+){2,}`,
|
|
12
|
+
String.raw`\b\d{1,3}(?:\.\d{1,3}){3}\b`,
|
|
13
|
+
].join("|"),
|
|
14
|
+
"gi",
|
|
15
|
+
);
|
|
16
|
+
|
|
17
|
+
export function tokenColor(token: string): string {
|
|
18
|
+
const u = token.toUpperCase();
|
|
19
|
+
if (/^(FATAL|CRITICAL|ERROR|ERR)$/.test(u)) return "#f85149";
|
|
20
|
+
if (/^WARN/.test(u)) return "#e3b341";
|
|
21
|
+
if (u === "INFO") return "#79c0ff";
|
|
22
|
+
if (u === "DEBUG") return "#8b949e";
|
|
23
|
+
if (u === "TRACE") return "#6e7681";
|
|
24
|
+
if (/^5\d{2}$/.test(token)) return "#f85149";
|
|
25
|
+
if (/^4\d{2}$/.test(token)) return "#e3b341";
|
|
26
|
+
if (/^[23]\d{2}$/.test(token)) return "#56d364";
|
|
27
|
+
if (token.startsWith('"') || token.startsWith("'")) return "#a5d6ff";
|
|
28
|
+
if (token.startsWith("/")) return "#d2a8ff";
|
|
29
|
+
return "#ffa657";
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export function tokenWeight(token: string): string | number {
|
|
33
|
+
const u = token.toUpperCase();
|
|
34
|
+
if (/^(FATAL|CRITICAL|ERROR|ERR|WARN)/.test(u)) return 700;
|
|
35
|
+
return "inherit";
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** Renders a log message string with syntax-highlighted tokens as React nodes. */
|
|
39
|
+
export function highlightMessage(msg: string): ReactNode {
|
|
40
|
+
const parts: ReactNode[] = [];
|
|
41
|
+
let last = 0;
|
|
42
|
+
let k = 0;
|
|
43
|
+
TOKEN_RE.lastIndex = 0;
|
|
44
|
+
let m: RegExpExecArray | null;
|
|
45
|
+
while ((m = TOKEN_RE.exec(msg)) !== null) {
|
|
46
|
+
if (m.index > last) parts.push(<span key={k++}>{msg.slice(last, m.index)}</span>);
|
|
47
|
+
parts.push(
|
|
48
|
+
<span key={k++} style={{ color: tokenColor(m[0]), fontWeight: tokenWeight(m[0]) }}>
|
|
49
|
+
{m[0]}
|
|
50
|
+
</span>,
|
|
51
|
+
);
|
|
52
|
+
last = m.index + m[0].length;
|
|
53
|
+
}
|
|
54
|
+
if (last < msg.length) parts.push(<span key={k++}>{msg.slice(last)}</span>);
|
|
55
|
+
return parts.length ? <>{parts}</> : msg;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** Renders a log message with search-term highlighting layered on top of syntax coloring. */
|
|
59
|
+
export function highlightWithSearch(msg: string, term: string, isRegex: boolean): ReactNode {
|
|
60
|
+
if (!term) return highlightMessage(msg);
|
|
61
|
+
try {
|
|
62
|
+
const re = isRegex
|
|
63
|
+
? new RegExp(term, "gi")
|
|
64
|
+
: new RegExp(term.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"), "gi");
|
|
65
|
+
const parts: ReactNode[] = [];
|
|
66
|
+
let last = 0;
|
|
67
|
+
let m: RegExpExecArray | null;
|
|
68
|
+
while ((m = re.exec(msg)) !== null) {
|
|
69
|
+
if (m.index > last) {
|
|
70
|
+
parts.push(<span key={`pre-${last}`}>{highlightMessage(msg.slice(last, m.index))}</span>);
|
|
71
|
+
}
|
|
72
|
+
parts.push(
|
|
73
|
+
<mark key={`match-${m.index}`} style={{ background: "rgba(255,213,0,0.35)", color: "inherit", borderRadius: 2 }}>
|
|
74
|
+
{m[0]}
|
|
75
|
+
</mark>,
|
|
76
|
+
);
|
|
77
|
+
last = m.index + m[0].length;
|
|
78
|
+
if (m[0].length === 0) re.lastIndex++;
|
|
79
|
+
}
|
|
80
|
+
if (last < msg.length) parts.push(<span key={`tail-${last}`}>{highlightMessage(msg.slice(last))}</span>);
|
|
81
|
+
return parts.length > 0 ? parts : highlightMessage(msg);
|
|
82
|
+
} catch {
|
|
83
|
+
return highlightMessage(msg);
|
|
84
|
+
}
|
|
85
|
+
}
|