A very long step log stays fast. Each chunk of a job's log is read once, into its step's log, as it arrives (`appendLog` in lib/log-lines.ts), where before every chunk joined the step's text again and read all of it from the start: a group a chunk leaves open holds the next chunk's lines, a line still being written shows until it ends, and a search reads the lines already read, once a change. A step of more than 2,000 rows draws only those on screen and 800 pixels either side (lib/log-window.ts), each row one line high until it is drawn and measured, so the space kept above and below is right however lines wrap; groups fold in the page's state rather than in a `details`, and search, following the newest line and Jump to latest work as on a short log. Each line's number links to it as GitHub's do (`#step:3:12`), opening its step and its group, scrolling to it and marking it. With a synthetic 50,000-line log in 500 chunks of 100 lines, reading it took 24.6 s in all and 105 ms for the last chunk before, and 0.5 s and 1.3 ms now; in Chromium the page drew the whole log in 2.2 s and each new chunk in 213 ms (157,000 elements), and now in 51 ms and 17 ms (385 elements). The Actions guide says so.
- Claude-Session:
- https://claude.ai/code/session_01PnwTRSNaC7vEHTAuBzjhrn
7 files+579−970/7 viewed
| 1168 | 1168 | logs** shows or hides the times; your browser remembers which. | |
| 1169 | 1169 | - A download (the job's `log.txt` and the run's zip) has the time at the | |
| 1170 | 1170 | start of every line, as GitHub's downloaded logs do. | |
| 1171 | + | - Each line's number links to it, as on GitHub: `#step:3:12` is line 12 | |
| 1172 | + | of step 3 (0 is **Set up job**). Opening the link opens the step and | |
| 1173 | + | any group the line is in, scrolls to the line and marks it. | |
| 1174 | + | - A long step's log stays fast. Each new piece of a running step's log is | |
| 1175 | + | read once as it arrives, and a step of more than 2,000 lines draws only | |
| 1176 | + | the lines on screen and a few around them. Search, groups, line links, | |
| 1177 | + | following the newest line and **Jump to latest** work as they do on a | |
| 1178 | + | short log. Your browser's own find (Ctrl+F) only sees the lines that are | |
| 1179 | + | drawn, so use **Search logs** in a long log. | |
| 1171 | 1180 | - The runner numbers each batch of the log it sends. When it sends a | |
| 1172 | 1181 | batch again because g1t's answer didn't reach it, g1t keeps the batch | |
| 1173 | 1182 | once, so a line never shows twice. |
| 4 | 4 | * its errors and warnings marked. | |
| 5 | 5 | */ | |
| 6 | 6 | import { AlertTriangle, Ban, Check, ChevronRight, CircleDashed, CircleSlash, Clock, Hourglass, Info, Loader2, ShieldAlert, X } from "lucide-react"; | |
| 7 | − | import { Fragment, memo, useEffect, useMemo, useRef, useState } from "react"; | |
| 7 | + | import { Fragment, memo, useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react"; | |
| 8 | 8 | import { Link } from "react-router"; | |
| 9 | 9 | ||
| 10 | 10 | import type { CommitStatus, Conclusion, LogChunk, WorkflowNote } from "@g1t/contracts"; | |
| ⋯ | |||
| 12 | 12 | import { Card } from "./ui/card"; | |
| 13 | 13 | import { Hint } from "./ui/hint"; | |
| 14 | 14 | import { markSpans, parseAnsi, spanLook } from "../lib/ansi"; | |
| 15 | − | import { type Line, blocks, searchLog } from "../lib/log-lines"; | |
| 15 | + | import { type Line, type Row, type StepLog, appendLog, emptyLog, groupOf, logRows, rowKey, searchStepLog } from "../lib/log-lines"; | |
| 16 | + | import { WINDOW_FROM, rowStarts, windowOf } from "../lib/log-window"; | |
| 16 | 17 | import { type PollAnswer, nextPoll, retryAfterMs } from "../lib/log-poll"; | |
| 17 | 18 | ||
| 18 | 19 | /** | |
| ⋯ | |||
| 147 | 148 | ); | |
| 148 | 149 | } | |
| 149 | 150 | ||
| 150 | − | /** One line of a step's log: its number, its time when asked for, and its text. Drawn again only when it changes. */ | |
| 151 | − | type LogLineProps = { line: Line; number: number; query?: string; timestamps?: boolean }; | |
| 151 | + | /** One line of a step's log: its number (a link to it), its time when asked for, and its text. Drawn again only when it changes. */ | |
| 152 | + | type LogLineProps = { line: Line; number: number; query?: string; timestamps?: boolean; anchor?: string; targeted?: boolean }; | |
| 152 | 153 | ||
| 153 | − | const LogLine = memo(function LogLine({ line, number, query = "", timestamps = false }: LogLineProps) { | |
| 154 | + | const LogLine = memo(function LogLine({ line, number, query = "", timestamps = false, anchor, targeted = false }: LogLineProps) { | |
| 154 | 155 | const label = /^(Error|Warning|Notice): /; | |
| 155 | 156 | const text = line.text.replace(label, ""); | |
| 156 | 157 | const ansi = line.ansi?.replace(label, ""); | |
| 158 | + | const id = anchor ? `${anchor}:${number}` : undefined; | |
| 157 | 159 | return ( | |
| 158 | − | <div className={`flex gap-4 px-4 hover:bg-raised/40 ${line.kind === "error" ? "bg-danger/5" : ""}`}> | |
| 159 | − | <span className="w-8 shrink-0 select-none text-right text-faint/70">{number}</span> | |
| 160 | + | <div | |
| 161 | + | id={id} | |
| 162 | + | className={`flex scroll-mt-24 gap-4 px-4 hover:bg-raised/40 ${targeted ? "bg-accent/15" : line.kind === "error" ? "bg-danger/5" : ""}`} | |
| 163 | + | > | |
| 164 | + | {id ? ( | |
| 165 | + | <a href={`#${id}`} className="w-8 shrink-0 select-none text-right text-faint/70 hover:text-fg hover:underline"> | |
| 166 | + | {number} | |
| 167 | + | </a> | |
| 168 | + | ) : ( | |
| 169 | + | <span className="w-8 shrink-0 select-none text-right text-faint/70">{number}</span> | |
| 170 | + | )} | |
| 160 | 171 | {timestamps && ( | |
| 161 | 172 | <time dateTime={line.at} className="w-[24ch] shrink-0 select-none text-faint/70 tabular-nums"> | |
| 162 | 173 | {line.at ? shownTime(line.at) : ""} | |
| ⋯ | |||
| 171 | 182 | ); | |
| 172 | 183 | }, sameLine); | |
| 173 | 184 | ||
| 174 | − | /** Whether a line would draw the same: a log read again as it grows makes new objects of the lines it had. */ | |
| 185 | + | /** Whether a line would draw the same: a log that grows keeps its lines, but a search finds them again. */ | |
| 175 | 186 | function sameLine(a: LogLineProps, b: LogLineProps): boolean { | |
| 176 | 187 | return ( | |
| 177 | 188 | a.number === b.number && | |
| 178 | 189 | a.query === b.query && | |
| 179 | 190 | a.timestamps === b.timestamps && | |
| 180 | − | a.line.kind === b.line.kind && | |
| 181 | − | a.line.text === b.line.text && | |
| 182 | − | a.line.ansi === b.line.ansi && | |
| 183 | − | a.line.at === b.line.at | |
| 191 | + | a.anchor === b.anchor && | |
| 192 | + | a.targeted === b.targeted && | |
| 193 | + | a.line === b.line | |
| 184 | 194 | ); | |
| 185 | 195 | } | |
| 186 | 196 | ||
| 197 | + | /** A group's title: a row that folds and opens the lines under it. */ | |
| 198 | + | function GroupRow({ row, timestamps, onToggle }: { row: Extract<Row, { kind: "group" }>; timestamps: boolean; onToggle: (block: number) => void }) { | |
| 199 | + | return ( | |
| 200 | + | <button | |
| 201 | + | type="button" | |
| 202 | + | aria-expanded={row.open} | |
| 203 | + | onClick={() => onToggle(row.block)} | |
| 204 | + | className="flex w-full cursor-pointer items-center gap-1.5 px-4 text-left text-fg/85 hover:bg-raised/40" | |
| 205 | + | > | |
| 206 | + | <span className="w-8 shrink-0" /> | |
| 207 | + | {timestamps && ( | |
| 208 | + | <time dateTime={row.at} className="ml-2.5 w-[24ch] shrink-0 select-none text-faint/70 tabular-nums"> | |
| 209 | + | {row.at ? shownTime(row.at) : ""} | |
| 210 | + | </time> | |
| 211 | + | )} | |
| 212 | + | <ChevronRight size={12} className={`shrink-0 text-faint transition-transform ${row.open ? "rotate-90" : ""}`} /> | |
| 213 | + | <span className="min-w-0 whitespace-pre-wrap break-all">{row.title}</span> | |
| 214 | + | </button> | |
| 215 | + | ); | |
| 216 | + | } | |
| 217 | + | ||
| 187 | 218 | /** | |
| 188 | 219 | * A step's log. With `query`, only the lines holding it (any case), groups | |
| 189 | 220 | * opened, each match marked. With `timestamps`, each line's time beside it. | |
| 221 | + | * `anchor` names the step in each line's link (`step:3` gives `#step:3:12`), | |
| 222 | + | * and `target` is the line such a link names: its group opens, and it is | |
| 223 | + | * scrolled to and marked. A log of more than a few thousand rows draws only | |
| 224 | + | * those on screen (lib/log-window.ts). | |
| 190 | 225 | */ | |
| 191 | − | export function LogText({ text, query = "", timestamps = false }: { text: string; query?: string; timestamps?: boolean }) { | |
| 192 | − | const parsed = useMemo(() => blocks(text), [text]); | |
| 193 | − | const found = useMemo(() => searchLog(text, query), [text, query]); | |
| 194 | − | if (!text.trim()) return <p className="px-4 py-2 text-xs text-faint">No output.</p>; | |
| 195 | − | if (query.trim()) { | |
| 226 | + | export function LogText({ | |
| 227 | + | log, | |
| 228 | + | query = "", | |
| 229 | + | timestamps = false, | |
| 230 | + | anchor, | |
| 231 | + | target = null, | |
| 232 | + | }: { | |
| 233 | + | log: StepLog; | |
| 234 | + | query?: string; | |
| 235 | + | timestamps?: boolean; | |
| 236 | + | anchor?: string; | |
| 237 | + | target?: number | null; | |
| 238 | + | }) { | |
| 239 | + | const [open, setOpen] = useState<ReadonlySet<number>>(() => new Set()); | |
| 240 | + | const searching = query.trim().length > 0; | |
| 241 | + | const found = useMemo(() => searchStepLog(log, query), [log, query]); | |
| 242 | + | const rows = useMemo<Row[]>(() => (searching ? found.map((entry) => ({ kind: "line", entry })) : logRows(log, open)), [searching, found, log, open]); | |
| 243 | + | // A link to a line in a folded group opens the group. | |
| 244 | + | const targetGroup = target === null ? null : groupOf(log, target); | |
| 245 | + | useEffect(() => { | |
| 246 | + | if (targetGroup !== null) setOpen((known) => (known.has(targetGroup) ? known : new Set(known).add(targetGroup))); | |
| 247 | + | }, [targetGroup]); | |
| 248 | + | const toggle = useCallback((block: number) => { | |
| 249 | + | setOpen((known) => { | |
| 250 | + | const next = new Set(known); | |
| 251 | + | if (!next.delete(block)) next.add(block); | |
| 252 | + | return next; | |
| 253 | + | }); | |
| 254 | + | }, []); | |
| 255 | + | if (!log.hasText && !log.tail) return <p className="px-4 py-2 text-xs text-faint">No output.</p>; | |
| 256 | + | return <LogRows rows={rows} query={searching ? query : ""} timestamps={timestamps} anchor={anchor} target={target} onToggle={toggle} />; | |
| 257 | + | } | |
| 258 | + | ||
| 259 | + | /** The rows of a step's log, every one, or past `WINDOW_FROM` those on screen and a few around them. */ | |
| 260 | + | function LogRows({ | |
| 261 | + | rows, | |
| 262 | + | query, | |
| 263 | + | timestamps, | |
| 264 | + | anchor, | |
| 265 | + | target, | |
| 266 | + | onToggle, | |
| 267 | + | }: { | |
| 268 | + | rows: Row[]; | |
| 269 | + | query: string; | |
| 270 | + | timestamps: boolean; | |
| 271 | + | anchor?: string; | |
| 272 | + | target: number | null; | |
| 273 | + | onToggle: (block: number) => void; | |
| 274 | + | }) { | |
| 275 | + | const windowed = rows.length > WINDOW_FROM; | |
| 276 | + | const box = useRef<HTMLDivElement>(null); | |
| 277 | + | /** Each drawn row's height, by its key, as it was last measured. */ | |
| 278 | + | const heights = useRef(new Map<string, number>()); | |
| 279 | + | const [measured, setMeasured] = useState(0); | |
| 280 | + | /** The part of the log on screen, from its top, in steps of `VIEW_STEP` pixels. */ | |
| 281 | + | const [view, setView] = useState<[number, number]>([0, 0]); | |
| 282 | + | const starts = useMemo( | |
| 283 | + | () => (windowed ? rowStarts(rows.length, (index) => heights.current.get(rowKey(rows[index]!))) : null), | |
| 284 | + | // `measured` changes when a row's height does. | |
| 285 | + | // eslint-disable-next-line react-hooks/exhaustive-deps | |
| 286 | + | [windowed, rows, measured], | |
| 287 | + | ); | |
| 288 | + | const [from, to] = starts ? windowOf(starts, view[0], view[1]) : [0, rows.length]; | |
| 289 | + | // Follow the screen: the view, as the window scrolls or is resized. | |
| 290 | + | useEffect(() => { | |
| 291 | + | if (!windowed) return; | |
| 292 | + | let frame = 0; | |
| 293 | + | const look = () => { | |
| 294 | + | frame = 0; | |
| 295 | + | const rect = box.current?.getBoundingClientRect(); | |
| 296 | + | if (!rect) return; | |
| 297 | + | const top = Math.floor(-rect.top / VIEW_STEP) * VIEW_STEP; | |
| 298 | + | const bottom = Math.ceil((window.innerHeight - rect.top) / VIEW_STEP) * VIEW_STEP; | |
| 299 | + | setView((known) => (known[0] === top && known[1] === bottom ? known : [top, bottom])); | |
| 300 | + | }; | |
| 301 | + | const soon = () => { | |
| 302 | + | if (!frame) frame = requestAnimationFrame(look); | |
| 303 | + | }; | |
| 304 | + | look(); | |
| 305 | + | window.addEventListener("scroll", soon, { passive: true }); | |
| 306 | + | window.addEventListener("resize", soon); | |
| 307 | + | // A step folded or opened moves the log without a scroll. | |
| 308 | + | document.addEventListener("toggle", soon, true); | |
| 309 | + | return () => { | |
| 310 | + | if (frame) cancelAnimationFrame(frame); | |
| 311 | + | window.removeEventListener("scroll", soon); | |
| 312 | + | window.removeEventListener("resize", soon); | |
| 313 | + | document.removeEventListener("toggle", soon, true); | |
| 314 | + | }; | |
| 315 | + | }, [windowed]); | |
| 316 | + | // Measure what was drawn; a row whose height is not what was kept for it | |
| 317 | + | // has its space corrected. | |
| 318 | + | useLayoutEffect(() => { | |
| 319 | + | if (!windowed || !box.current) return; | |
| 320 | + | let changed = false; | |
| 321 | + | for (const element of box.current.querySelectorAll<HTMLElement>("[data-row]")) { | |
| 322 | + | const height = element.offsetHeight; | |
| 323 | + | const key = element.dataset.row!; | |
| 324 | + | if (height > 0 && heights.current.get(key) !== height) { | |
| 325 | + | heights.current.set(key, height); | |
| 326 | + | changed = true; | |
| 327 | + | } | |
| 328 | + | } | |
| 329 | + | if (changed) setMeasured((n) => n + 1); | |
| 330 | + | }); | |
| 331 | + | // A link to a line: scroll to it once it is in the log. | |
| 332 | + | const targetRow = target === null ? -1 : rows.findIndex((row) => row.kind === "line" && row.entry.number === target); | |
| 333 | + | const scrolledTo = useRef<number | null>(null); | |
| 334 | + | useLayoutEffect(() => { | |
| 335 | + | if (targetRow === -1 || scrolledTo.current === target || !box.current) return; | |
| 336 | + | scrolledTo.current = target; | |
| 337 | + | const top = box.current.getBoundingClientRect().top + window.scrollY + (starts ? starts[targetRow]! : 0); | |
| 338 | + | if (starts) window.scrollTo({ top: Math.max(0, top - window.innerHeight / 3) }); | |
| 339 | + | else document.getElementById(`${anchor}:${target}`)?.scrollIntoView({ block: "center" }); | |
| 340 | + | }, [targetRow, target, starts, anchor]); | |
| 341 | + | const drawn = rows.slice(from, to).map((row) => { | |
| 342 | + | const key = rowKey(row); | |
| 196 | 343 | return ( | |
| 197 | − | <div className="py-1 font-mono text-xs leading-5"> | |
| 198 | − | {found.map(({ line, number }) => ( | |
| 199 | − | <LogLine key={number} line={line} number={number} query={query} timestamps={timestamps} /> | |
| 200 | − | ))} | |
| 344 | + | <div key={key} data-row={windowed ? key : undefined}> | |
| 345 | + | {row.kind === "line" ? ( | |
| 346 | + | <LogLine | |
| 347 | + | line={row.entry.line} | |
| 348 | + | number={row.entry.number} | |
| 349 | + | query={query} | |
| 350 | + | timestamps={timestamps} | |
| 351 | + | anchor={anchor} | |
| 352 | + | targeted={row.entry.number === target} | |
| 353 | + | /> | |
| 354 | + | ) : ( | |
| 355 | + | <GroupRow row={row} timestamps={timestamps} onToggle={onToggle} /> | |
| 356 | + | )} | |
| 201 | 357 | </div> | |
| 202 | 358 | ); | |
| 203 | − | } | |
| 359 | + | }); | |
| 204 | 360 | return ( | |
| 205 | − | <div className="py-1 font-mono text-xs leading-5"> | |
| 206 | − | {parsed.map((block, index) => | |
| 207 | − | block.kind === "line" ? ( | |
| 208 | − | <LogLine key={index} line={block.line} number={block.number} timestamps={timestamps} /> | |
| 209 | − | ) : ( | |
| 210 | − | <details key={index} className="group/log"> | |
| 211 | − | <summary className="flex cursor-pointer list-none items-center gap-1.5 px-4 text-fg/85 hover:bg-raised/40"> | |
| 212 | − | <span className="w-8 shrink-0" /> | |
| 213 | − | {timestamps && ( | |
| 214 | − | <time dateTime={block.at} className="ml-2.5 w-[24ch] shrink-0 select-none text-faint/70 tabular-nums"> | |
| 215 | − | {block.at ? shownTime(block.at) : ""} | |
| 216 | − | </time> | |
| 217 | − | )} | |
| 218 | − | <ChevronRight size={12} className="shrink-0 text-faint transition-transform group-open/log:rotate-90" /> | |
| 219 | − | {block.title} | |
| 220 | − | </summary> | |
| 221 | − | {block.lines.map(({ line, number }) => ( | |
| 222 | − | <LogLine key={number} line={line} number={number} timestamps={timestamps} /> | |
| 223 | − | ))} | |
| 224 | − | </details> | |
| 225 | − | ), | |
| 226 | − | )} | |
| 361 | + | <div ref={box} className="py-1 font-mono text-xs leading-5"> | |
| 362 | + | {starts && <div aria-hidden style={{ height: starts[from] }} />} | |
| 363 | + | {drawn} | |
| 364 | + | {starts && <div aria-hidden style={{ height: starts[rows.length]! - starts[to]! }} />} | |
| 227 | 365 | </div> | |
| 228 | 366 | ); | |
| 229 | 367 | } | |
| 230 | 368 | ||
| 231 | − | /** A job's log as the page has it: each step's text (0 is the job's setup), when each last grew, and whether asking for more is failing. */ | |
| 369 | + | /** How finely the view of a windowed log is followed, in pixels: a scroll of less draws nothing new. */ | |
| 370 | + | const VIEW_STEP = 200; | |
| 371 | + | ||
| 372 | + | /** A job's log as the page has it: each step's log, read as it came (0 is the job's setup), when each last grew, and whether asking for more is failing. */ | |
| 232 | 373 | export type JobLog = { | |
| 233 | − | byStep: Map<number, string>; | |
| 374 | + | byStep: Map<number, StepLog>; | |
| 234 | 375 | /** When a chunk of each step's last arrived, by this browser's clock. */ | |
| 235 | 376 | grewAt: Map<number, number>; | |
| 236 | 377 | /** The last ask failed, and the page is waiting to ask again. */ | |
| ⋯ | |||
| 244 | 385 | * `running` is whether one of the job's steps is running now. | |
| 245 | 386 | */ | |
| 246 | 387 | export function useJobLog(url: string, live: boolean, running: boolean): JobLog { | |
| 247 | − | const [chunks, setChunks] = useState<LogChunk[]>([]); | |
| 388 | + | // Each chunk is read once, into its step's log, as it arrives: a long | |
| 389 | + | // step's log is not read again from its start for every chunk. | |
| 390 | + | const steps = useRef(new Map<number, StepLog>()); | |
| 391 | + | const [byStep, setByStep] = useState<Map<number, StepLog>>(new Map()); | |
| 248 | 392 | const [grewAt, setGrewAt] = useState<Map<number, number>>(new Map()); | |
| 249 | 393 | const [retrying, setRetrying] = useState(false); | |
| 250 | 394 | const after = useRef(0); | |
| ⋯ | |||
| 252 | 396 | runningNow.current = running; | |
| 253 | 397 | useEffect(() => { | |
| 254 | 398 | after.current = 0; | |
| 255 | − | setChunks([]); | |
| 399 | + | steps.current = new Map(); | |
| 400 | + | setByStep(new Map()); | |
| 256 | 401 | setGrewAt(new Map()); | |
| 257 | 402 | }, [url]); | |
| 258 | 403 | useEffect(() => { | |
| ⋯ | |||
| 283 | 428 | if (page.chunks.length > 0) { | |
| 284 | 429 | after.current = page.chunks[page.chunks.length - 1]!.seq; | |
| 285 | 430 | const now = Date.now(); | |
| 286 | − | setChunks((known) => [...known, ...page.chunks]); | |
| 431 | + | for (const chunk of page.chunks) steps.current.set(chunk.step, appendLog(steps.current.get(chunk.step) ?? emptyLog(), chunk.text)); | |
| 432 | + | setByStep(new Map(steps.current)); | |
| 287 | 433 | setGrewAt((known) => { | |
| 288 | 434 | const next = new Map(known); | |
| 289 | 435 | for (const chunk of page.chunks) next.set(chunk.step, now); | |
| ⋯ | |||
| 315 | 461 | document.removeEventListener("visibilitychange", shown); | |
| 316 | 462 | }; | |
| 317 | 463 | }, [url, live]); | |
| 318 | − | const byStep = useMemo(() => { | |
| 319 | − | const byStep = new Map<number, string>(); | |
| 320 | − | for (const chunk of chunks) byStep.set(chunk.step, (byStep.get(chunk.step) ?? "") + chunk.text); | |
| 321 | − | return byStep; | |
| 322 | − | }, [chunks]); | |
| 323 | 464 | return { byStep, grewAt, retrying }; | |
| 324 | 465 | } | |
| 325 | 466 | ||
| 3 | 3 | ||
| 4 | 4 | import type { JobLogText } from "@g1t/contracts"; | |
| 5 | 5 | ||
| 6 | − | import { archiveFiles, blocks, classify, fileName, highlight, lastStamp, searchLog, splitStamp } from "./log-lines.ts"; | |
| 6 | + | import { | |
| 7 | + | type StepLog, | |
| 8 | + | appendLog, | |
| 9 | + | archiveFiles, | |
| 10 | + | blocks, | |
| 11 | + | classify, | |
| 12 | + | emptyLog, | |
| 13 | + | fileName, | |
| 14 | + | groupOf, | |
| 15 | + | highlight, | |
| 16 | + | lastStamp, | |
| 17 | + | logRows, | |
| 18 | + | readLog, | |
| 19 | + | rowKey, | |
| 20 | + | searchLog, | |
| 21 | + | searchStepLog, | |
| 22 | + | splitStamp, | |
| 23 | + | } from "./log-lines.ts"; | |
| 24 | + | ||
| 25 | + | /** `text` read as chunks cut at `cuts` (offsets into it), one at a time. */ | |
| 26 | + | function readInChunks(text: string, cuts: number[]): StepLog { | |
| 27 | + | let log = emptyLog(); | |
| 28 | + | let at = 0; | |
| 29 | + | for (const cut of [...cuts, text.length]) { | |
| 30 | + | log = appendLog(log, text.slice(at, cut)); | |
| 31 | + | at = cut; | |
| 32 | + | } | |
| 33 | + | return log; | |
| 34 | + | } | |
| 35 | + | ||
| 36 | + | /** What a log holds, for comparing two ways of reading it. */ | |
| 37 | + | function shape(log: StepLog) { | |
| 38 | + | return { blocks: log.blocks, lines: log.lines, blockOf: log.blockOf, group: log.group, lastAt: log.lastAt, hasText: log.hasText }; | |
| 39 | + | } | |
| 40 | + | ||
| 41 | + | test("a log read chunk by chunk is the log read at once, wherever the chunks fall", () => { | |
| 42 | + | const text = [ | |
| 43 | + | "2026-10-12T09:41:07.1000000Z Job: test", | |
| 44 | + | "2026-10-12T09:41:07.2000000Z ##[group]Run cargo test", | |
| 45 | + | "2026-10-12T09:41:07.3000000Z cargo test --locked", | |
| 46 | + | "2026-10-12T09:41:07.4000000Z \u001b[32mok\u001b[0m", | |
| 47 | + | "2026-10-12T09:41:08.0000000Z ##[endgroup]", | |
| 48 | + | "2026-10-12T09:41:09.0000000Z ##[error]Process completed with exit code 101.", | |
| 49 | + | "", | |
| 50 | + | ].join("\n"); | |
| 51 | + | const whole = readLog(text); | |
| 52 | + | // Every cut, and many cuts, including inside a line and inside an escape code. | |
| 53 | + | for (let cut = 0; cut <= text.length; cut++) assert.deepEqual(shape(readInChunks(text, [cut])), shape(whole), `cut at ${cut}`); | |
| 54 | + | assert.deepEqual(shape(readInChunks(text, Array.from({ length: text.length }, (_, i) => i))), shape(whole)); | |
| 55 | + | assert.equal(whole.lastAt, "2026-10-12T09:41:09.0000000Z"); | |
| 56 | + | assert.deepEqual(whole.blocks, blocks(text)); | |
| 57 | + | }); | |
| 58 | + | ||
| 59 | + | test("a group a chunk leaves open holds the next chunk's lines", () => { | |
| 60 | + | let log = appendLog(emptyLog(), "before\n##[group]Install\none\n"); | |
| 61 | + | assert.equal(log.group, 1); | |
| 62 | + | log = appendLog(log, "two\n##[endgroup]\nafter\n"); | |
| 63 | + | assert.deepEqual( | |
| 64 | + | log.blocks.map((block) => (block.kind === "line" ? block.line.text : `${block.title}: ${block.lines.map((entry) => entry.line.text).join(",")}`)), | |
| 65 | + | ["before", "Install: one,two", "after"], | |
| 66 | + | ); | |
| 67 | + | assert.equal(log.group, null); | |
| 68 | + | assert.equal(groupOf(log, 3), 1); | |
| 69 | + | assert.equal(groupOf(log, 4), null); | |
| 70 | + | }); | |
| 71 | + | ||
| 72 | + | test("a line still being written shows, and is read once it ends", () => { | |
| 73 | + | let log = appendLog(emptyLog(), "one\ntw"); | |
| 74 | + | assert.equal(log.lines.length, 1); | |
| 75 | + | assert.deepEqual(log.tail, { line: { kind: "text", text: "tw" }, number: 2 }); | |
| 76 | + | assert.deepEqual(searchStepLog(log, "TW").map((found) => found.number), [2]); | |
| 77 | + | log = appendLog(log, "o\n"); | |
| 78 | + | assert.equal(log.tail, undefined); | |
| 79 | + | assert.deepEqual(log.lines.map((entry) => entry.line.text), ["one", "two"]); | |
| 80 | + | // A group's marker cut in two opens the group once it is whole. | |
| 81 | + | log = appendLog(log, "##[gro"); | |
| 82 | + | assert.equal(log.tail, undefined, "half a marker is not a line"); | |
| 83 | + | log = appendLog(log, "up]Later\nthree\n"); | |
| 84 | + | assert.equal(groupOf(log, 3), 2); | |
| 85 | + | assert.equal(readLog(" \n\n").hasText, false); | |
| 86 | + | }); | |
| 87 | + | ||
| 88 | + | test("reading a long log chunk by chunk reads each line once", () => { | |
| 89 | + | const line = (n: number) => `2026-10-12T09:41:07.0000000Z ${n % 500 === 0 ? "##[group]Batch" : n % 500 === 20 ? "##[endgroup]" : `test case_${n} ... ok`}\n`; | |
| 90 | + | let log = emptyLog(); | |
| 91 | + | const started = performance.now(); | |
| 92 | + | for (let chunk = 0; chunk < 500; chunk++) { | |
| 93 | + | let text = ""; | |
| 94 | + | for (let n = chunk * 100; n < chunk * 100 + 100; n++) text += line(n); | |
| 95 | + | log = appendLog(log, text); | |
| 96 | + | } | |
| 97 | + | const took = performance.now() - started; | |
| 98 | + | assert.equal(log.lines.length, 50_000 - 200); | |
| 99 | + | // Reading the whole text again for every chunk took about 25 s here. | |
| 100 | + | assert.ok(took < 2000, `50,000 lines in 500 chunks took ${Math.round(took)} ms`); | |
| 101 | + | }); | |
| 102 | + | ||
| 103 | + | test("a log's rows are its lines and groups' titles, open groups' lines under them", () => { | |
| 104 | + | const log = readLog("a\n##[group]G\nb\nc\n##[endgroup]\nd\n"); | |
| 105 | + | const keys = (open: number[]) => logRows(log, new Set(open)).map(rowKey); | |
| 106 | + | assert.deepEqual(keys([]), ["L1", "G1", "L4"]); | |
| 107 | + | assert.deepEqual(keys([1]), ["L1", "G1", "L2", "L3", "L4"]); | |
| 108 | + | assert.deepEqual(logRows(log, new Set())[1], { kind: "group", block: 1, title: "G", at: undefined, open: false, size: 2 }); | |
| 109 | + | // A search reads a log once a change. | |
| 110 | + | assert.equal(searchStepLog(log, "b"), searchStepLog(log, " B ")); | |
| 111 | + | }); | |
| 7 | 112 | ||
| 8 | 113 | const LOG = ["Job: test", "##[group]Run cargo test", "cargo test --locked", "##[endgroup]", "test greet ... FAILED", "##[error]Process completed with exit code 101."].join("\n"); | |
| 9 | 114 |
| 58 | 58 | return lineOf("text", raw, at); | |
| 59 | 59 | } | |
| 60 | 60 | ||
| 61 | + | /** A line and its number in the step's log. */ | |
| 62 | + | export type Numbered = { line: Line; number: number }; | |
| 63 | + | ||
| 64 | + | /** | |
| 65 | + | * A step's log, read as it arrives (`appendLog`): each chunk's lines are | |
| 66 | + | * read once, however long the log grows, and a group a chunk leaves open | |
| 67 | + | * holds the next chunk's lines until its `##[endgroup]`. | |
| 68 | + | */ | |
| 69 | + | export type StepLog = { | |
| 70 | + | /** Plain lines and groups, as `blocks` reads them. */ | |
| 71 | + | blocks: Block[]; | |
| 72 | + | /** Every line in order, groups' lines among them: `lines[n - 1]` is line `n`. */ | |
| 73 | + | lines: Numbered[]; | |
| 74 | + | /** The index in `blocks` of the block that holds each line, by the same index. */ | |
| 75 | + | blockOf: number[]; | |
| 76 | + | /** The group still open at the end of what has arrived, by its index in `blocks`. */ | |
| 77 | + | group: number | null; | |
| 78 | + | /** What came after the last newline: a line still being written, shown as `tail` meanwhile. */ | |
| 79 | + | partial: string; | |
| 80 | + | tail?: Numbered; | |
| 81 | + | /** The time of the last stamped line. */ | |
| 82 | + | lastAt?: string; | |
| 83 | + | /** Whether any line holds more than spaces. */ | |
| 84 | + | hasText: boolean; | |
| 85 | + | }; | |
| 86 | + | ||
| 87 | + | export function emptyLog(): StepLog { | |
| 88 | + | return { blocks: [], lines: [], blockOf: [], group: null, partial: "", hasText: false }; | |
| 89 | + | } | |
| 90 | + | ||
| 91 | + | /** | |
| 92 | + | * `log` with `text` added to its end. Only the new lines are read: the | |
| 93 | + | * answer shares `log`'s arrays, adding to them, so `log` is not to be read | |
| 94 | + | * or added to again. | |
| 95 | + | */ | |
| 96 | + | export function appendLog(log: StepLog, text: string): StepLog { | |
| 97 | + | const next: StepLog = { ...log }; | |
| 98 | + | delete next.tail; | |
| 99 | + | const joined = next.partial + text; | |
| 100 | + | const end = joined.lastIndexOf("\n"); | |
| 101 | + | const whole = end === -1 ? "" : joined.slice(0, end); | |
| 102 | + | next.partial = end === -1 ? joined : joined.slice(end + 1); | |
| 103 | + | if (end !== -1) { | |
| 104 | + | for (const raw of whole.split("\n")) readLine(next, raw); | |
| 105 | + | } | |
| 106 | + | // A line that may yet be a group's marker (`##[gro`) waits to end. | |
| 107 | + | if (next.partial.trim() && !splitStamp(next.partial).rest.startsWith("##")) { | |
| 108 | + | const line = classify(next.partial); | |
| 109 | + | if (typeof line === "object" && !("group" in line)) next.tail = { line, number: next.lines.length + 1 }; | |
| 110 | + | } | |
| 111 | + | return next; | |
| 112 | + | } | |
| 113 | + | ||
| 114 | + | /** Reads one whole line into `log`, in place. */ | |
| 115 | + | function readLine(log: StepLog, raw: string): void { | |
| 116 | + | // A log ends with a newline; nothing follows the last one. | |
| 117 | + | if (raw === "" && log.lines.length === 0) return; | |
| 118 | + | const line = classify(raw); | |
| 119 | + | if (line === "group-end") { | |
| 120 | + | log.group = null; | |
| 121 | + | return; | |
| 122 | + | } | |
| 123 | + | if ("group" in line) { | |
| 124 | + | log.group = log.blocks.length; | |
| 125 | + | log.blocks.push(line.at ? { kind: "group", title: line.group, lines: [], at: line.at } : { kind: "group", title: line.group, lines: [] }); | |
| 126 | + | return; | |
| 127 | + | } | |
| 128 | + | const entry = { line, number: log.lines.length + 1 }; | |
| 129 | + | log.lines.push(entry); | |
| 130 | + | if (line.at) log.lastAt = line.at; | |
| 131 | + | if (!log.hasText && line.text.trim()) log.hasText = true; | |
| 132 | + | const open = log.group === null ? undefined : log.blocks[log.group]; | |
| 133 | + | if (open?.kind === "group") { | |
| 134 | + | open.lines.push(entry); | |
| 135 | + | log.blockOf.push(log.group!); | |
| 136 | + | } else { | |
| 137 | + | log.blockOf.push(log.blocks.length); | |
| 138 | + | log.blocks.push({ kind: "line", ...entry }); | |
| 139 | + | } | |
| 140 | + | } | |
| 141 | + | ||
| 142 | + | /** A whole log, read at once. */ | |
| 143 | + | export function readLog(text: string): StepLog { | |
| 144 | + | return appendLog(emptyLog(), text.endsWith("\n") || text === "" ? text : `${text}\n`); | |
| 145 | + | } | |
| 146 | + | ||
| 61 | 147 | /** Lines into blocks: plain lines, and groups that fold. Lines are numbered from 1, group markers aside. */ | |
| 62 | 148 | export function blocks(text: string): Block[] { | |
| 63 | − | const out: Block[] = []; | |
| 64 | − | let group: Extract<Block, { kind: "group" }> | null = null; | |
| 65 | − | let number = 0; | |
| 66 | − | // A log ends with a newline; nothing follows the last one. | |
| 67 | − | for (const raw of (text.endsWith("\n") ? text.slice(0, -1) : text).split("\n")) { | |
| 68 | − | if (raw === "" && number === 0) continue; | |
| 69 | − | const line = classify(raw); | |
| 70 | − | if (line === "group-end") { | |
| 71 | − | group = null; | |
| 72 | − | continue; | |
| 73 | − | } | |
| 74 | − | if ("group" in line) { | |
| 75 | − | group = line.at ? { kind: "group", title: line.group, lines: [], at: line.at } : { kind: "group", title: line.group, lines: [] }; | |
| 76 | − | out.push(group); | |
| 77 | − | continue; | |
| 78 | − | } | |
| 79 | − | number += 1; | |
| 80 | − | if (group) group.lines.push({ line, number }); | |
| 81 | − | else out.push({ kind: "line", line, number }); | |
| 82 | − | } | |
| 83 | − | return out; | |
| 149 | + | return readLog(text).blocks; | |
| 84 | 150 | } | |
| 85 | 151 | ||
| 86 | − | /** The lines of a log holding `query` (any case), numbered as `blocks` numbers them, folded groups opened. */ | |
| 87 | − | export function searchLog(text: string, query: string): { line: Line; number: number }[] { | |
| 152 | + | /** The last search of each log, so a step's count and its lines are found once a change. */ | |
| 153 | + | const searched = new WeakMap<StepLog, { query: string; found: Numbered[] }>(); | |
| 154 | + | ||
| 155 | + | /** The lines of a step's log holding `query` (any case), in order, folded groups' lines among them. */ | |
| 156 | + | export function searchStepLog(log: StepLog, query: string): Numbered[] { | |
| 88 | 157 | const wanted = query.trim().toLowerCase(); | |
| 89 | 158 | if (!wanted) return []; | |
| 90 | − | const found: { line: Line; number: number }[] = []; | |
| 91 | − | for (const block of blocks(text)) { | |
| 92 | − | const lines = block.kind === "line" ? [{ line: block.line, number: block.number }] : block.lines; | |
| 93 | − | for (const entry of lines) { | |
| 94 | − | if (entry.line.text.toLowerCase().includes(wanted)) found.push(entry); | |
| 95 | − | } | |
| 159 | + | const known = searched.get(log); | |
| 160 | + | if (known && known.query === wanted) return known.found; | |
| 161 | + | const found: Numbered[] = []; | |
| 162 | + | for (const entry of log.lines) { | |
| 163 | + | if (entry.line.text.toLowerCase().includes(wanted)) found.push(entry); | |
| 96 | 164 | } | |
| 165 | + | if (log.tail && log.tail.line.text.toLowerCase().includes(wanted)) found.push(log.tail); | |
| 166 | + | searched.set(log, { query: wanted, found }); | |
| 97 | 167 | return found; | |
| 98 | 168 | } | |
| 99 | 169 | ||
| 170 | + | /** The lines of a log holding `query` (any case), numbered as `blocks` numbers them, folded groups opened. */ | |
| 171 | + | export function searchLog(text: string, query: string): Numbered[] { | |
| 172 | + | return searchStepLog(readLog(text), query); | |
| 173 | + | } | |
| 174 | + | ||
| 175 | + | /** | |
| 176 | + | * What a step's log draws, one row each, top to bottom: its lines, and | |
| 177 | + | * each group's title, with the lines of the groups in `open` (by their | |
| 178 | + | * index in `blocks`) under it. | |
| 179 | + | */ | |
| 180 | + | export type Row = | |
| 181 | + | | { kind: "line"; entry: Numbered } | |
| 182 | + | | { kind: "group"; block: number; title: string; at?: string; open: boolean; size: number }; | |
| 183 | + | ||
| 184 | + | export function logRows(log: StepLog, open: ReadonlySet<number>): Row[] { | |
| 185 | + | const rows: Row[] = []; | |
| 186 | + | log.blocks.forEach((block, index) => { | |
| 187 | + | if (block.kind === "line") { | |
| 188 | + | rows.push({ kind: "line", entry: block }); | |
| 189 | + | return; | |
| 190 | + | } | |
| 191 | + | const isOpen = open.has(index); | |
| 192 | + | rows.push({ kind: "group", block: index, title: block.title, at: block.at, open: isOpen, size: block.lines.length }); | |
| 193 | + | if (isOpen) for (const entry of block.lines) rows.push({ kind: "line", entry }); | |
| 194 | + | }); | |
| 195 | + | if (log.tail) rows.push({ kind: "line", entry: log.tail }); | |
| 196 | + | return rows; | |
| 197 | + | } | |
| 198 | + | ||
| 199 | + | /** The group that holds line `number`, by its index in `blocks`, or null when it is no group's. */ | |
| 200 | + | export function groupOf(log: StepLog, number: number): number | null { | |
| 201 | + | const index = log.blockOf[number - 1]; | |
| 202 | + | return index !== undefined && log.blocks[index]?.kind === "group" ? index : null; | |
| 203 | + | } | |
| 204 | + | ||
| 205 | + | /** A row's key, the same however the rows above it change. */ | |
| 206 | + | export function rowKey(row: Row): string { | |
| 207 | + | return row.kind === "line" ? `L${row.entry.number}` : `G${row.block}`; | |
| 208 | + | } | |
| 209 | + | ||
| 100 | 210 | /** `text` cut where `query` (any case) appears, each piece marked as a match or not. */ | |
| 101 | 211 | export function highlight(text: string, query: string): { text: string; match: boolean }[] { | |
| 102 | 212 | const wanted = query.trim().toLowerCase(); |
| 1 | + | import assert from "node:assert/strict"; | |
| 2 | + | import { test } from "node:test"; | |
| 3 | + | ||
| 4 | + | import { ROW_HEIGHT, rowAt, rowStarts, windowOf } from "./log-window.ts"; | |
| 5 | + | ||
| 6 | + | test("rows start where the rows above them end, measured or not", () => { | |
| 7 | + | const starts = rowStarts(4, (index) => (index === 1 ? 60 : undefined)); | |
| 8 | + | assert.deepEqual([...starts], [0, ROW_HEIGHT, ROW_HEIGHT + 60, 2 * ROW_HEIGHT + 60, 3 * ROW_HEIGHT + 60]); | |
| 9 | + | assert.equal(rowAt(starts, 0), 0); | |
| 10 | + | assert.equal(rowAt(starts, ROW_HEIGHT), 1); | |
| 11 | + | assert.equal(rowAt(starts, ROW_HEIGHT + 59), 1); | |
| 12 | + | assert.equal(rowAt(starts, ROW_HEIGHT + 60), 2); | |
| 13 | + | assert.equal(rowAt(starts, 10_000), 3, "past the end is the last row"); | |
| 14 | + | }); | |
| 15 | + | ||
| 16 | + | test("only the rows on screen and a little around them are drawn", () => { | |
| 17 | + | const starts = rowStarts(50_000, () => undefined); | |
| 18 | + | // The screen shows rows 1000 to 1040. | |
| 19 | + | const [from, to] = windowOf(starts, 1000 * ROW_HEIGHT, 1040 * ROW_HEIGHT, 200); | |
| 20 | + | assert.deepEqual([from, to], [990, 1051]); | |
| 21 | + | // At the very end, following the newest line. | |
| 22 | + | const end = starts[50_000]!; | |
| 23 | + | assert.deepEqual(windowOf(starts, end - 800, end, 200), [49_950, 50_000]); | |
| 24 | + | // The log above or below the screen draws a few rows at the near end. | |
| 25 | + | assert.deepEqual(windowOf(starts, -5000, -4000, 200), [0, 20]); | |
| 26 | + | assert.deepEqual(windowOf(starts, end + 4000, end + 5000, 200), [49_980, 50_000]); | |
| 27 | + | assert.deepEqual(windowOf(rowStarts(0, () => undefined), 0, 100), [0, 0]); | |
| 28 | + | }); |
| 1 | + | /** | |
| 2 | + | * Drawing a long log a screenful at a time. A step's log past a few | |
| 3 | + | * thousand rows draws only the rows on screen and a few around them, with | |
| 4 | + | * space above and below standing in for the rest, so a 50,000-line log | |
| 5 | + | * opens, grows and scrolls as fast as a short one. Rows are one line high | |
| 6 | + | * until they are drawn and measured (a long line wraps), and the space | |
| 7 | + | * kept for them is corrected then. | |
| 8 | + | */ | |
| 9 | + | ||
| 10 | + | /** Past this many rows, a step's log draws only what is on screen. */ | |
| 11 | + | export const WINDOW_FROM = 2000; | |
| 12 | + | /** A row's height before it is measured: one line of `text-xs leading-5`. */ | |
| 13 | + | export const ROW_HEIGHT = 20; | |
| 14 | + | /** How far past the screen rows are drawn, above and below, in pixels. */ | |
| 15 | + | export const OVERSCAN = 800; | |
| 16 | + | ||
| 17 | + | /** | |
| 18 | + | * Where each row starts, from the top of the log: `starts[i]` for row `i`, | |
| 19 | + | * and `starts[rows]` for the log's whole height. `height(i)` is a row's | |
| 20 | + | * measured height, or undefined for the estimate. | |
| 21 | + | */ | |
| 22 | + | export function rowStarts(rows: number, height: (index: number) => number | undefined): Float64Array { | |
| 23 | + | const starts = new Float64Array(rows + 1); | |
| 24 | + | for (let i = 0; i < rows; i++) starts[i + 1] = starts[i]! + (height(i) ?? ROW_HEIGHT); | |
| 25 | + | return starts; | |
| 26 | + | } | |
| 27 | + | ||
| 28 | + | /** The first row that ends below `y`: the row at `y`, or the last. */ | |
| 29 | + | export function rowAt(starts: Float64Array, y: number): number { | |
| 30 | + | const rows = starts.length - 1; | |
| 31 | + | let low = 0; | |
| 32 | + | let high = rows; | |
| 33 | + | while (low < high) { | |
| 34 | + | const middle = (low + high) >> 1; | |
| 35 | + | if (starts[middle + 1]! <= y) low = middle + 1; | |
| 36 | + | else high = middle; | |
| 37 | + | } | |
| 38 | + | return Math.min(low, Math.max(0, rows - 1)); | |
| 39 | + | } | |
| 40 | + | ||
| 41 | + | /** | |
| 42 | + | * The rows to draw, `[from, to)`, for a view from `top` to `bottom` | |
| 43 | + | * (pixels from the top of the log; either may be past its ends), with | |
| 44 | + | * `overscan` pixels more either side. | |
| 45 | + | */ | |
| 46 | + | export function windowOf(starts: Float64Array, top: number, bottom: number, overscan = OVERSCAN): [number, number] { | |
| 47 | + | const rows = starts.length - 1; | |
| 48 | + | if (rows === 0) return [0, 0]; | |
| 49 | + | const total = starts[rows]!; | |
| 50 | + | const from = top - overscan; | |
| 51 | + | const to = bottom + overscan; | |
| 52 | + | if (to <= 0 || from >= total) { | |
| 53 | + | // Off screen: draw a few rows at the end nearest the view, so the | |
| 54 | + | // log has something to measure and its end can be followed. | |
| 55 | + | return from >= total ? [Math.max(0, rows - 20), rows] : [0, Math.min(rows, 20)]; | |
| 56 | + | } | |
| 57 | + | return [rowAt(starts, Math.max(0, from)), rowAt(starts, to) + 1]; | |
| 58 | + | } |
| 49 | 49 | import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from "../../components/ui/dropdown-menu"; | |
| 50 | 50 | import { Hint } from "../../components/ui/hint"; | |
| 51 | 51 | import { useWorkflowReason } from "../../components/mirror"; | |
| 52 | − | import { lastStamp, searchLog } from "../../lib/log-lines"; | |
| 52 | + | import { type StepLog, searchStepLog } from "../../lib/log-lines"; | |
| 53 | 53 | import { quietWords } from "../../lib/log-poll"; | |
| 54 | 54 | import { listArtifacts, withLegacyArtifacts } from "../../lib/run-artifacts.server"; | |
| 55 | 55 | import { expiresIn, formatBytes, legacyArtifactsWorthAsking } from "../../lib/run-artifacts"; | |
| ⋯ | |||
| 140 | 140 | ||
| 141 | 141 | function StepRow({ | |
| 142 | 142 | step, | |
| 143 | − | text, | |
| 143 | + | log, | |
| 144 | 144 | defaultOpen, | |
| 145 | 145 | query = "", | |
| 146 | 146 | matches = 0, | |
| ⋯ | |||
| 148 | 148 | lastOutput = null, | |
| 149 | 149 | now, | |
| 150 | 150 | end, | |
| 151 | + | target = null, | |
| 151 | 152 | }: { | |
| 152 | 153 | step: StepState; | |
| 153 | − | text: string | undefined; | |
| 154 | + | log: StepLog | undefined; | |
| 154 | 155 | defaultOpen: boolean; | |
| 155 | 156 | /** A search of the job's log: only its matching lines are shown. */ | |
| 156 | 157 | query?: string; | |
| ⋯ | |||
| 162 | 163 | now: number; | |
| 163 | 164 | /** Marks the end of the running step's log, for following it. */ | |
| 164 | 165 | end?: Ref<HTMLDivElement>; | |
| 166 | + | /** The line of this step a link names (`#step:3:12`). */ | |
| 167 | + | target?: number | null; | |
| 165 | 168 | }) { | |
| 166 | 169 | const running = step.status === "in_progress"; | |
| 167 | 170 | const quiet = running && !query ? quietWords(step.startedAt ? new Date(step.startedAt).getTime() : null, lastOutput, now) : null; | |
| ⋯ | |||
| 181 | 184 | </span> | |
| 182 | 185 | </summary> | |
| 183 | 186 | <div className="border-t border-line bg-bg/60"> | |
| 184 | − | {text === undefined && !running ? ( | |
| 187 | + | {log === undefined && !running ? ( | |
| 185 | 188 | <p className="px-4 py-2 text-xs text-faint">{step.status === "queued" ? "Not started." : step.conclusion === "skipped" ? "Skipped." : "No output."}</p> | |
| 186 | 189 | ) : ( | |
| 187 | − | text !== undefined && <LogText text={text} query={query} timestamps={timestamps} /> | |
| 190 | + | log !== undefined && <LogText log={log} query={query} timestamps={timestamps} anchor={`step:${step.number}`} target={target} /> | |
| 188 | 191 | )} | |
| 189 | 192 | {quiet && ( | |
| 190 | 193 | <p className="flex items-center gap-2 px-4 py-2 font-mono text-xs text-muted" aria-live="polite" suppressHydrationWarning> | |
| ⋯ | |||
| 198 | 201 | ); | |
| 199 | 202 | } | |
| 200 | 203 | ||
| 204 | + | /** | |
| 205 | + | * The line of a job's log the page's address names, as GitHub's do: | |
| 206 | + | * `#step:3:12` is line 12 of step 3 (0 is Set up job). A line's number | |
| 207 | + | * links to it. | |
| 208 | + | */ | |
| 209 | + | function useLineTarget(): { step: number; line: number } | null { | |
| 210 | + | const [target, setTarget] = useState<{ step: number; line: number } | null>(null); | |
| 211 | + | useEffect(() => { | |
| 212 | + | const read = () => { | |
| 213 | + | const found = /^#step:(\d+):(\d+)$/.exec(window.location.hash); | |
| 214 | + | setTarget((known) => { | |
| 215 | + | if (!found) return null; | |
| 216 | + | const next = { step: Number(found[1]), line: Number(found[2]) }; | |
| 217 | + | return known && known.step === next.step && known.line === next.line ? known : next; | |
| 218 | + | }); | |
| 219 | + | }; | |
| 220 | + | read(); | |
| 221 | + | window.addEventListener("hashchange", read); | |
| 222 | + | return () => window.removeEventListener("hashchange", read); | |
| 223 | + | }, []); | |
| 224 | + | return target; | |
| 225 | + | } | |
| 226 | + | ||
| 201 | 227 | /** Whether the viewer wants each log line's time shown, kept in this browser. */ | |
| 202 | 228 | function useTimestamps(): [boolean, (on: boolean) => void] { | |
| 203 | 229 | const [on, setOn] = useState(false); | |
| ⋯ | |||
| 478 | 504 | const [query, setQuery] = useState(""); | |
| 479 | 505 | const [timestamps, setTimestamps] = useTimestamps(); | |
| 480 | 506 | const now = useNow(live); | |
| 481 | − | const runningText = running ? log.get(running.number) : undefined; | |
| 507 | + | const runningLog = running ? log.get(running.number) : undefined; | |
| 508 | + | const target = useLineTarget(); | |
| 482 | 509 | // The running step's log is followed while its end is in view, unless a | |
| 483 | 510 | // failed step is the one held open. | |
| 484 | 511 | const followed = !failed && running ? running.number : null; | |
| 485 | − | const { end, behind, jump } = useFollow(followed, runningText); | |
| 512 | + | const { end, behind, jump } = useFollow(followed, runningLog); | |
| 486 | 513 | // When the running step last printed: its last line's time, else when | |
| 487 | 514 | // its last chunk came. | |
| 488 | 515 | const lastOutput = useMemo(() => { | |
| 489 | 516 | if (!running) return null; | |
| 490 | − | const at = runningText ? lastStamp(runningText) : undefined; | |
| 517 | + | const at = runningLog?.lastAt; | |
| 491 | 518 | if (at) return new Date(at).getTime(); | |
| 492 | − | return runningText ? (grewAt.get(running.number) ?? null) : null; | |
| 493 | − | }, [running, runningText, grewAt]); | |
| 519 | + | return runningLog ? (grewAt.get(running.number) ?? null) : null; | |
| 520 | + | }, [running, runningLog, grewAt]); | |
| 494 | 521 | const searching = query.trim().length > 0; | |
| 495 | 522 | const setUp: StepState = { | |
| 496 | 523 | number: 0, | |
| ⋯ | |||
| 502 | 529 | }; | |
| 503 | 530 | // While searching, the steps with a match, each with how many. | |
| 504 | 531 | const steps = [setUp, ...job.steps] | |
| 505 | − | .map((step) => ({ step, matches: searching ? searchLog(log.get(step.number) ?? "", query).length : 0 })) | |
| 532 | + | .map((step) => { | |
| 533 | + | const stepLog = log.get(step.number); | |
| 534 | + | return { step, matches: searching && stepLog ? searchStepLog(stepLog, query).length : 0 }; | |
| 535 | + | }) | |
| 506 | 536 | .filter(({ matches }) => !searching || matches > 0); | |
| 507 | 537 | const total = steps.reduce((sum, { matches }) => sum + matches, 0); | |
| 508 | 538 | return ( | |
| ⋯ | |||
| 605 | 635 | // Searching opens every step with a match; clearing it puts them back. | |
| 606 | 636 | key={`${step.number}:${searching}`} | |
| 607 | 637 | step={step} | |
| 608 | − | text={log.get(step.number)} | |
| 609 | − | defaultOpen={searching || (step.number !== 0 && step.number === (failed ?? running)?.number)} | |
| 638 | + | log={log.get(step.number)} | |
| 639 | + | defaultOpen={searching || step.number === target?.step || (step.number !== 0 && step.number === (failed ?? running)?.number)} | |
| 610 | 640 | query={searching ? query : ""} | |
| 611 | 641 | matches={matches} | |
| 612 | 642 | timestamps={timestamps} | |
| 613 | 643 | now={now} | |
| 614 | 644 | lastOutput={step.number === running?.number ? lastOutput : null} | |
| 615 | 645 | end={step.number === followed ? end : undefined} | |
| 646 | + | target={step.number === target?.step ? target.line : null} | |
| 616 | 647 | /> | |
| 617 | 648 | ))} | |
| 618 | 649 | </Card> | |