18.4 KB615 lines
Blame
1"use client";
2
3import { useEffect, useMemo, useRef, useState, useCallback } from "react";
4import type { StepLog } from "@/lib/api";
5
6// --- Parsing types ---
7
8type LogLevel = "error" | "warn" | "info" | "debug" | "plain";
9
10interface ParsedLine {
11 text: string;
12 displayText: string;
13 level: LogLevel;
14}
15
16interface 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
26type LogSegment =
27 | { type: "docker-group"; group: DockerGroup }
28 | { type: "plain"; lines: ParsedLine[] };
29
30// --- Regexes ---
31
32const DOCKER_STEP_RE = /^#(\d+)\s/;
33const DOCKER_STEP_HEADER_RE = /^#\d+\s+(?:\[.*?\]|FROM\s|CACHED)/;
34const DOCKER_DONE_RE = /^#\d+\s+DONE\s+([\d.]+s)/;
35
36const 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;
38const WARN_RE =
39 /\b(WARN|WARNING|warn:|warning:|deprecated|DEPRECATED)\b/i;
40const INFO_RE =
41 /\b(INFO|info:|notice:|NOTE:)\b/i;
42const DEBUG_RE =
43 /\b(DEBUG|debug:|TRACE|trace:)\b/i;
44
45function 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
55const 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
81function 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
183export interface LogViewerProps {
184 logs: StepLog[];
185 stickToBottom: boolean;
186 onStickToBottomChange: (value: boolean) => void;
187 style?: React.CSSProperties;
188}
189
190function 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
205const FONT_SIZES = [0.65, 0.7, 0.75, 0.85, 0.95];
206const DEFAULT_SIZE_INDEX = 2; // 0.75rem
207
208export 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
595function 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