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