| 1 | "use client"; |
| 2 | |
| 3 | import { useEffect, useMemo, useRef, useState, useCallback } from "react"; |
| 4 | import type { StepLog } from "@/lib/api"; |
| 5 | |
| 6 | // --- Parsing types --- |
| 7 | |
| 8 | type LogLevel = "error" | "warn" | "info" | "debug" | "plain"; |
| 9 | |
| 10 | interface ParsedLine { |
| 11 | text: string; |
| 12 | displayText: string; |
| 13 | level: LogLevel; |
| 14 | } |
| 15 | |
| 16 | interface DockerGroup { |
| 17 | stepNumber: number; |
| 18 | headerText: string; |
| 19 | duration: string | null; |
| 20 | lines: ParsedLine[]; |
| 21 | isDone: boolean; |
| 22 | hasError: boolean; |
| 23 | hasWarning: boolean; |
| 24 | } |
| 25 | |
| 26 | type LogSegment = |
| 27 | | { type: "docker-group"; group: DockerGroup } |
| 28 | | { type: "plain"; lines: ParsedLine[] }; |
| 29 | |
| 30 | // --- Regexes --- |
| 31 | |
| 32 | const DOCKER_STEP_RE = /^#(\d+)\s/; |
| 33 | const DOCKER_STEP_HEADER_RE = /^#\d+\s+(?:\[.*?\]|FROM\s|CACHED)/; |
| 34 | const DOCKER_DONE_RE = /^#\d+\s+DONE\s+([\d.]+s)/; |
| 35 | |
| 36 | const ERROR_RE = |
| 37 | /\b(ERROR|FAILED|FATAL|PANIC|error:|Error:|cannot find|no such file|permission denied|exit code [1-9]|segmentation fault)\b/i; |
| 38 | const WARN_RE = |
| 39 | /\b(WARN|WARNING|warn:|warning:|deprecated|DEPRECATED)\b/i; |
| 40 | const INFO_RE = |
| 41 | /\b(INFO|info:|notice:|NOTE:)\b/i; |
| 42 | const DEBUG_RE = |
| 43 | /\b(DEBUG|debug:|TRACE|trace:)\b/i; |
| 44 | |
| 45 | function classifyLine(text: string): LogLevel { |
| 46 | if (ERROR_RE.test(text)) return "error"; |
| 47 | if (WARN_RE.test(text)) return "warn"; |
| 48 | if (INFO_RE.test(text)) return "info"; |
| 49 | if (DEBUG_RE.test(text)) return "debug"; |
| 50 | return "plain"; |
| 51 | } |
| 52 | |
| 53 | // --- Level colors --- |
| 54 | |
| 55 | const levelColors: Record<LogLevel, { color: string; bg?: string; border: string }> = { |
| 56 | error: { |
| 57 | color: "var(--status-closed-text)", |
| 58 | bg: "var(--status-closed-bg)", |
| 59 | border: "var(--status-closed-border)", |
| 60 | }, |
| 61 | warn: { |
| 62 | color: "var(--status-merged-text)", |
| 63 | border: "var(--status-merged-border)", |
| 64 | }, |
| 65 | info: { |
| 66 | color: "var(--status-open-text)", |
| 67 | border: "var(--status-open-border)", |
| 68 | }, |
| 69 | debug: { |
| 70 | color: "var(--text-faint)", |
| 71 | border: "var(--text-faint)", |
| 72 | }, |
| 73 | plain: { |
| 74 | color: "var(--text-primary)", |
| 75 | border: "transparent", |
| 76 | }, |
| 77 | }; |
| 78 | |
| 79 | // --- Parsing --- |
| 80 | |
| 81 | function parseLogSegments(logs: StepLog[]): LogSegment[] { |
| 82 | // Join all content first, then split — chunks from Node streams can split |
| 83 | // mid-line at 64KB boundaries, so we must concatenate before splitting. |
| 84 | const raw = logs.map((l) => l.content).join(""); |
| 85 | const lines = raw.split("\n"); |
| 86 | const allLines: ParsedLine[] = []; |
| 87 | for (let i = 0; i < lines.length; i++) { |
| 88 | if (i === lines.length - 1 && lines[i] === "") continue; |
| 89 | const text = lines[i]; |
| 90 | allLines.push({ |
| 91 | text, |
| 92 | displayText: text, |
| 93 | level: classifyLine(text), |
| 94 | }); |
| 95 | } |
| 96 | |
| 97 | // Detect Docker mode |
| 98 | let dockerLineCount = 0; |
| 99 | for (const line of allLines) { |
| 100 | if (DOCKER_STEP_RE.test(line.text)) dockerLineCount++; |
| 101 | if (dockerLineCount >= 3) break; |
| 102 | } |
| 103 | |
| 104 | if (dockerLineCount < 3) { |
| 105 | return [{ type: "plain", lines: allLines }]; |
| 106 | } |
| 107 | |
| 108 | // Group Docker lines |
| 109 | const segments: LogSegment[] = []; |
| 110 | const groupMap = new Map<number, DockerGroup>(); |
| 111 | let plainBuffer: ParsedLine[] = []; |
| 112 | |
| 113 | for (const line of allLines) { |
| 114 | const match = line.text.match(DOCKER_STEP_RE); |
| 115 | if (match) { |
| 116 | // Flush plain buffer (skip if all empty/whitespace) |
| 117 | if (plainBuffer.length > 0) { |
| 118 | if (plainBuffer.some((l) => l.text.trim() !== "")) { |
| 119 | segments.push({ type: "plain", lines: plainBuffer }); |
| 120 | } |
| 121 | plainBuffer = []; |
| 122 | } |
| 123 | |
| 124 | const stepNum = parseInt(match[1], 10); |
| 125 | let group = groupMap.get(stepNum); |
| 126 | |
| 127 | if (!group) { |
| 128 | group = { |
| 129 | stepNumber: stepNum, |
| 130 | headerText: "", |
| 131 | duration: null, |
| 132 | lines: [], |
| 133 | isDone: false, |
| 134 | hasError: false, |
| 135 | hasWarning: false, |
| 136 | }; |
| 137 | groupMap.set(stepNum, group); |
| 138 | segments.push({ type: "docker-group", group }); |
| 139 | } |
| 140 | |
| 141 | // Determine header text |
| 142 | if (!group.headerText && DOCKER_STEP_HEADER_RE.test(line.text)) { |
| 143 | group.headerText = line.text.replace(DOCKER_STEP_RE, ""); |
| 144 | } |
| 145 | |
| 146 | // Check for DONE |
| 147 | const doneMatch = line.text.match(DOCKER_DONE_RE); |
| 148 | if (doneMatch) { |
| 149 | group.duration = doneMatch[1]; |
| 150 | group.isDone = true; |
| 151 | } |
| 152 | |
| 153 | if (line.level === "error") group.hasError = true; |
| 154 | if (line.level === "warn") group.hasWarning = true; |
| 155 | // Strip `#N ` prefix for display — already shown in group header |
| 156 | line.displayText = line.text.replace(DOCKER_STEP_RE, ""); |
| 157 | group.lines.push(line); |
| 158 | } else { |
| 159 | plainBuffer.push(line); |
| 160 | } |
| 161 | } |
| 162 | |
| 163 | // Flush remaining plain buffer (skip if all empty/whitespace in Docker mode) |
| 164 | if (plainBuffer.length > 0) { |
| 165 | const hasContent = plainBuffer.some((l) => l.text.trim() !== ""); |
| 166 | if (hasContent) { |
| 167 | segments.push({ type: "plain", lines: plainBuffer }); |
| 168 | } |
| 169 | } |
| 170 | |
| 171 | // Set fallback headers for groups that didn't match the header regex |
| 172 | for (const group of groupMap.values()) { |
| 173 | if (!group.headerText && group.lines.length > 0) { |
| 174 | group.headerText = group.lines[0].text.replace(DOCKER_STEP_RE, ""); |
| 175 | } |
| 176 | } |
| 177 | |
| 178 | return segments; |
| 179 | } |
| 180 | |
| 181 | // --- Component --- |
| 182 | |
| 183 | export interface LogViewerProps { |
| 184 | logs: StepLog[]; |
| 185 | stickToBottom: boolean; |
| 186 | onStickToBottomChange: (value: boolean) => void; |
| 187 | style?: React.CSSProperties; |
| 188 | } |
| 189 | |
| 190 | function copyText(text: string): void { |
| 191 | if (navigator.clipboard?.writeText) { |
| 192 | navigator.clipboard.writeText(text); |
| 193 | return; |
| 194 | } |
| 195 | const ta = document.createElement("textarea"); |
| 196 | ta.value = text; |
| 197 | ta.style.position = "fixed"; |
| 198 | ta.style.opacity = "0"; |
| 199 | document.body.appendChild(ta); |
| 200 | ta.select(); |
| 201 | document.execCommand("copy"); |
| 202 | document.body.removeChild(ta); |
| 203 | } |
| 204 | |
| 205 | const FONT_SIZES = [0.65, 0.7, 0.75, 0.85, 0.95]; |
| 206 | const DEFAULT_SIZE_INDEX = 2; // 0.75rem |
| 207 | |
| 208 | export function LogViewer({ |
| 209 | logs, |
| 210 | stickToBottom, |
| 211 | onStickToBottomChange, |
| 212 | style, |
| 213 | }: LogViewerProps) { |
| 214 | const scrollRef = useRef<HTMLDivElement>(null); |
| 215 | const [copied, setCopied] = useState(false); |
| 216 | const [copiedGroup, setCopiedGroup] = useState<number | null>(null); |
| 217 | const [showScrollBtn, setShowScrollBtn] = useState(false); |
| 218 | const [fontSizeIdx, setFontSizeIdx] = useState(DEFAULT_SIZE_INDEX); |
| 219 | const [userToggles, setUserToggles] = useState<Map<number, boolean>>( |
| 220 | new Map() |
| 221 | ); |
| 222 | |
| 223 | const segments = useMemo(() => parseLogSegments(logs), [logs]); |
| 224 | |
| 225 | // Count total lines |
| 226 | const lineCount = useMemo(() => { |
| 227 | let count = 0; |
| 228 | for (const seg of segments) { |
| 229 | if (seg.type === "plain") count += seg.lines.length; |
| 230 | else count += seg.group.lines.length; |
| 231 | } |
| 232 | return count; |
| 233 | }, [segments]); |
| 234 | |
| 235 | // Find the last Docker group step number (for default-expand logic) |
| 236 | const lastDockerStep = useMemo(() => { |
| 237 | let last: number | null = null; |
| 238 | for (const seg of segments) { |
| 239 | if (seg.type === "docker-group") last = seg.group.stepNumber; |
| 240 | } |
| 241 | return last; |
| 242 | }, [segments]); |
| 243 | |
| 244 | // Auto-scroll when logs change and stickToBottom is true |
| 245 | useEffect(() => { |
| 246 | if (stickToBottom && scrollRef.current) { |
| 247 | scrollRef.current.scrollTop = scrollRef.current.scrollHeight; |
| 248 | } |
| 249 | }, [logs, stickToBottom]); |
| 250 | |
| 251 | const handleScroll = useCallback(() => { |
| 252 | const el = scrollRef.current; |
| 253 | if (!el) return; |
| 254 | const distance = el.scrollHeight - el.scrollTop - el.clientHeight; |
| 255 | setShowScrollBtn(distance > 200); |
| 256 | // Only disable follow on scroll-up, never re-enable from scroll position. |
| 257 | if (distance > 24) { |
| 258 | onStickToBottomChange(false); |
| 259 | } |
| 260 | }, [onStickToBottomChange]); |
| 261 | |
| 262 | const scrollToBottom = useCallback(() => { |
| 263 | if (scrollRef.current) { |
| 264 | scrollRef.current.scrollTop = scrollRef.current.scrollHeight; |
| 265 | } |
| 266 | onStickToBottomChange(true); |
| 267 | }, [onStickToBottomChange]); |
| 268 | |
| 269 | const handleCopy = useCallback(() => { |
| 270 | const allText = logs.map((l) => l.content).join(""); |
| 271 | copyText(allText); |
| 272 | setCopied(true); |
| 273 | setTimeout(() => setCopied(false), 2000); |
| 274 | }, [logs]); |
| 275 | |
| 276 | const toggleGroup = useCallback((stepNumber: number) => { |
| 277 | setUserToggles((prev) => { |
| 278 | const next = new Map(prev); |
| 279 | const current = next.get(stepNumber); |
| 280 | if (current === undefined) { |
| 281 | next.set(stepNumber, true); |
| 282 | } else { |
| 283 | next.set(stepNumber, !current); |
| 284 | } |
| 285 | return next; |
| 286 | }); |
| 287 | }, []); |
| 288 | |
| 289 | const copyGroup = useCallback((group: DockerGroup, e: React.MouseEvent) => { |
| 290 | e.stopPropagation(); |
| 291 | const text = group.lines.map((l) => l.text).join("\n"); |
| 292 | copyText(text); |
| 293 | setCopiedGroup(group.stepNumber); |
| 294 | setTimeout(() => setCopiedGroup(null), 2000); |
| 295 | }, []); |
| 296 | |
| 297 | function isGroupCollapsed(group: DockerGroup): boolean { |
| 298 | const userToggle = userToggles.get(group.stepNumber); |
| 299 | if (userToggle !== undefined) return userToggle; |
| 300 | if (group.hasError) return false; |
| 301 | if (group.stepNumber === lastDockerStep) return false; |
| 302 | return group.isDone; |
| 303 | } |
| 304 | |
| 305 | function groupBorderColor(group: DockerGroup): string { |
| 306 | if (group.hasError) return "var(--status-closed-border)"; |
| 307 | if (group.hasWarning) return "var(--status-merged-border)"; |
| 308 | if (group.isDone) return "var(--status-open-border)"; |
| 309 | return "var(--accent)"; |
| 310 | } |
| 311 | |
| 312 | return ( |
| 313 | <div |
| 314 | style={{ |
| 315 | display: "flex", |
| 316 | flexDirection: "column", |
| 317 | ...style, |
| 318 | }} |
| 319 | > |
| 320 | {/* Toolbar */} |
| 321 | <div |
| 322 | style={{ |
| 323 | display: "flex", |
| 324 | alignItems: "center", |
| 325 | gap: "0.5rem", |
| 326 | padding: "0.25rem 0.75rem", |
| 327 | borderBottom: "1px solid var(--divide)", |
| 328 | fontSize: "0.7rem", |
| 329 | color: "var(--text-faint)", |
| 330 | height: "1.75rem", |
| 331 | flexShrink: 0, |
| 332 | userSelect: "none", |
| 333 | }} |
| 334 | > |
| 335 | <span>{lineCount.toLocaleString()} lines</span> |
| 336 | <span style={{ flex: 1 }} /> |
| 337 | <button |
| 338 | onClick={() => setFontSizeIdx((i) => Math.max(0, i - 1))} |
| 339 | disabled={fontSizeIdx === 0} |
| 340 | style={{ |
| 341 | background: "none", |
| 342 | border: "none", |
| 343 | cursor: fontSizeIdx === 0 ? "default" : "pointer", |
| 344 | font: "inherit", |
| 345 | color: fontSizeIdx === 0 ? "var(--divide)" : "var(--text-faint)", |
| 346 | padding: "0.125rem 0.25rem", |
| 347 | fontSize: "0.8rem", |
| 348 | lineHeight: 1, |
| 349 | }} |
| 350 | title="Decrease font size" |
| 351 | > |
| 352 | A− |
| 353 | </button> |
| 354 | <button |
| 355 | onClick={() => setFontSizeIdx(DEFAULT_SIZE_INDEX)} |
| 356 | disabled={fontSizeIdx === DEFAULT_SIZE_INDEX} |
| 357 | style={{ |
| 358 | background: "none", |
| 359 | border: "none", |
| 360 | cursor: fontSizeIdx !== DEFAULT_SIZE_INDEX ? "pointer" : "default", |
| 361 | font: "inherit", |
| 362 | color: fontSizeIdx !== DEFAULT_SIZE_INDEX ? "var(--text-faint)" : "var(--border)", |
| 363 | padding: "0.125rem 0.25rem", |
| 364 | fontSize: "0.7rem", |
| 365 | lineHeight: 1, |
| 366 | }} |
| 367 | title="Reset font size" |
| 368 | > |
| 369 | Reset |
| 370 | </button> |
| 371 | <button |
| 372 | onClick={() => setFontSizeIdx((i) => Math.min(FONT_SIZES.length - 1, i + 1))} |
| 373 | disabled={fontSizeIdx === FONT_SIZES.length - 1} |
| 374 | style={{ |
| 375 | background: "none", |
| 376 | border: "none", |
| 377 | cursor: fontSizeIdx === FONT_SIZES.length - 1 ? "default" : "pointer", |
| 378 | font: "inherit", |
| 379 | color: fontSizeIdx === FONT_SIZES.length - 1 ? "var(--divide)" : "var(--text-faint)", |
| 380 | padding: "0.125rem 0.25rem", |
| 381 | fontSize: "0.8rem", |
| 382 | lineHeight: 1, |
| 383 | }} |
| 384 | title="Increase font size" |
| 385 | > |
| 386 | A+ |
| 387 | </button> |
| 388 | <span style={{ width: "1px", height: "0.75rem", backgroundColor: "var(--divide)" }} /> |
| 389 | <button |
| 390 | onClick={handleCopy} |
| 391 | style={{ |
| 392 | background: "none", |
| 393 | border: "none", |
| 394 | cursor: "pointer", |
| 395 | font: "inherit", |
| 396 | color: copied ? "var(--accent)" : "var(--text-faint)", |
| 397 | padding: "0.125rem 0.375rem", |
| 398 | }} |
| 399 | > |
| 400 | {copied ? "Copied!" : "Copy"} |
| 401 | </button> |
| 402 | <button |
| 403 | onClick={() => { |
| 404 | if (!stickToBottom) scrollToBottom(); |
| 405 | else onStickToBottomChange(false); |
| 406 | }} |
| 407 | style={{ |
| 408 | background: "none", |
| 409 | border: "none", |
| 410 | cursor: "pointer", |
| 411 | font: "inherit", |
| 412 | color: stickToBottom ? "var(--accent)" : "var(--text-faint)", |
| 413 | padding: "0.125rem 0.375rem", |
| 414 | }} |
| 415 | title={ |
| 416 | stickToBottom |
| 417 | ? "Following output (click to stop)" |
| 418 | : "Click to follow output" |
| 419 | } |
| 420 | > |
| 421 | {stickToBottom ? "Following ↓" : "Follow"} |
| 422 | </button> |
| 423 | </div> |
| 424 | |
| 425 | {/* Scrollable log area */} |
| 426 | <div |
| 427 | ref={scrollRef} |
| 428 | onScroll={handleScroll} |
| 429 | style={{ |
| 430 | flex: 1, |
| 431 | overflowY: "auto", |
| 432 | overflowX: "hidden", |
| 433 | position: "relative", |
| 434 | minHeight: 0, |
| 435 | fontSize: `${FONT_SIZES[fontSizeIdx]}rem`, |
| 436 | lineHeight: 1.5, |
| 437 | fontFamily: "var(--font-mono, ui-monospace, monospace)", |
| 438 | }} |
| 439 | > |
| 440 | <div style={{ padding: "0.5rem 0" }}> |
| 441 | {segments.map((seg, i) => { |
| 442 | if (seg.type === "plain") { |
| 443 | return ( |
| 444 | <div key={`p-${i}`}> |
| 445 | {seg.lines.map((line, j) => ( |
| 446 | <LogLine key={j} line={line} /> |
| 447 | ))} |
| 448 | </div> |
| 449 | ); |
| 450 | } |
| 451 | |
| 452 | const { group } = seg; |
| 453 | const collapsed = isGroupCollapsed(group); |
| 454 | const borderColor = groupBorderColor(group); |
| 455 | |
| 456 | return ( |
| 457 | <div key={`g-${group.stepNumber}`} className="docker-group" style={{ marginBottom: "0.125rem" }}> |
| 458 | {/* Group header */} |
| 459 | <div |
| 460 | className="docker-group-header" |
| 461 | onClick={() => toggleGroup(group.stepNumber)} |
| 462 | style={{ |
| 463 | display: "flex", |
| 464 | alignItems: "center", |
| 465 | gap: "0.5rem", |
| 466 | cursor: "pointer", |
| 467 | padding: "0.4rem 0.75rem", |
| 468 | userSelect: "none", |
| 469 | position: "sticky", |
| 470 | top: 0, |
| 471 | backgroundColor: "var(--bg-page)", |
| 472 | zIndex: 1, |
| 473 | borderBottom: "1px solid var(--divide)", |
| 474 | borderLeft: `3px solid ${borderColor}`, |
| 475 | transition: "background-color 0.1s", |
| 476 | }} |
| 477 | > |
| 478 | <span |
| 479 | style={{ |
| 480 | color: "var(--text-faint)", |
| 481 | width: "0.75em", |
| 482 | flexShrink: 0, |
| 483 | fontSize: "0.9em", |
| 484 | }} |
| 485 | > |
| 486 | {collapsed ? "▸" : "▾"} |
| 487 | </span> |
| 488 | <span |
| 489 | style={{ |
| 490 | color: "var(--text-primary)", |
| 491 | fontWeight: 600, |
| 492 | flexShrink: 0, |
| 493 | fontSize: "1em", |
| 494 | }} |
| 495 | > |
| 496 | #{group.stepNumber} |
| 497 | </span> |
| 498 | <span |
| 499 | style={{ |
| 500 | color: group.hasError |
| 501 | ? "var(--status-closed-text)" |
| 502 | : "var(--text-primary)", |
| 503 | flex: 1, |
| 504 | minWidth: 0, |
| 505 | overflow: "hidden", |
| 506 | textOverflow: "ellipsis", |
| 507 | whiteSpace: "nowrap", |
| 508 | fontWeight: 500, |
| 509 | }} |
| 510 | > |
| 511 | {group.headerText} |
| 512 | </span> |
| 513 | <button |
| 514 | className="docker-group-copy" |
| 515 | onClick={(e) => copyGroup(group, e)} |
| 516 | style={{ |
| 517 | background: "none", |
| 518 | border: "none", |
| 519 | cursor: "pointer", |
| 520 | font: "inherit", |
| 521 | color: copiedGroup === group.stepNumber ? "var(--accent)" : "var(--text-faint)", |
| 522 | padding: "0.125rem 0.375rem", |
| 523 | fontSize: "0.7rem", |
| 524 | flexShrink: 0, |
| 525 | transition: "opacity 0.15s", |
| 526 | }} |
| 527 | > |
| 528 | {copiedGroup === group.stepNumber ? "Copied" : "Copy"} |
| 529 | </button> |
| 530 | {group.duration && ( |
| 531 | <span |
| 532 | style={{ |
| 533 | color: "var(--text-faint)", |
| 534 | flexShrink: 0, |
| 535 | fontSize: "0.9em", |
| 536 | }} |
| 537 | > |
| 538 | {group.duration} |
| 539 | </span> |
| 540 | )} |
| 541 | <span |
| 542 | style={{ |
| 543 | color: "var(--text-faint)", |
| 544 | flexShrink: 0, |
| 545 | fontSize: "0.65rem", |
| 546 | }} |
| 547 | > |
| 548 | {group.lines.length} lines |
| 549 | </span> |
| 550 | </div> |
| 551 | {/* Group body */} |
| 552 | {!collapsed && ( |
| 553 | <div |
| 554 | className="docker-group-body" |
| 555 | style={{ |
| 556 | borderLeft: `3px solid color-mix(in srgb, ${borderColor} 40%, transparent)`, |
| 557 | }} |
| 558 | > |
| 559 | {group.lines.map((line, j) => ( |
| 560 | <LogLine key={j} line={line} /> |
| 561 | ))} |
| 562 | </div> |
| 563 | )} |
| 564 | </div> |
| 565 | ); |
| 566 | })} |
| 567 | </div> |
| 568 | |
| 569 | {/* Scroll to bottom button */} |
| 570 | {showScrollBtn && ( |
| 571 | <button |
| 572 | onClick={scrollToBottom} |
| 573 | style={{ |
| 574 | position: "sticky", |
| 575 | bottom: "0.75rem", |
| 576 | float: "right", |
| 577 | marginRight: "0.75rem", |
| 578 | backgroundColor: "var(--bg-card)", |
| 579 | border: "1px solid var(--border)", |
| 580 | color: "var(--text-muted)", |
| 581 | padding: "0.25rem 0.625rem", |
| 582 | fontSize: "0.7rem", |
| 583 | cursor: "pointer", |
| 584 | boxShadow: "0 2px 8px rgba(0,0,0,0.1)", |
| 585 | }} |
| 586 | > |
| 587 | ↓ Bottom |
| 588 | </button> |
| 589 | )} |
| 590 | </div> |
| 591 | </div> |
| 592 | ); |
| 593 | } |
| 594 | |
| 595 | function LogLine({ line }: { line: ParsedLine }) { |
| 596 | const lc = levelColors[line.level]; |
| 597 | return ( |
| 598 | <div |
| 599 | className="log-line" |
| 600 | style={{ |
| 601 | padding: "0 0.75rem", |
| 602 | whiteSpace: "pre-wrap", |
| 603 | overflowWrap: "anywhere", |
| 604 | color: lc.color, |
| 605 | backgroundColor: lc.bg, |
| 606 | borderLeft: line.level !== "plain" ? `3px solid ${lc.border}` : undefined, |
| 607 | marginLeft: line.level !== "plain" ? "-3px" : undefined, |
| 608 | tabSize: 2, |
| 609 | }} |
| 610 | > |
| 611 | {line.displayText} |
| 612 | </div> |
| 613 | ); |
| 614 | } |
| 615 | |