Skip to content

Commit

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
Claudeagent and Claudeagentcommitted Parent22780b3Browse files
7 files+579−970/7 viewed
+9−0
11681168 logs** shows or hides the times; your browser remembers which.
11691169 - A download (the job's `log.txt` and the run's zip) has the time at the
11701170 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.
11711180 - The runner numbers each batch of the log it sends. When it sends a
11721181 batch again because g1t's answer didn't reach it, g1t keeps the batch
11731182 once, so a line never shows twice.
+195−54
44 * its errors and warnings marked.
55 */
66 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";
88 import { Link } from "react-router";
99
1010 import type { CommitStatus, Conclusion, LogChunk, WorkflowNote } from "@g1t/contracts";
1212 import { Card } from "./ui/card";
1313 import { Hint } from "./ui/hint";
1414 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";
1617 import { type PollAnswer, nextPoll, retryAfterMs } from "../lib/log-poll";
1718
1819 /**
147148 );
148149 }
149150
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 };
152153
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) {
154155 const label = /^(Error|Warning|Notice): /;
155156 const text = line.text.replace(label, "");
156157 const ansi = line.ansi?.replace(label, "");
158+ const id = anchor ? `${anchor}:${number}` : undefined;
157159 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+ )}
160171 {timestamps && (
161172 <time dateTime={line.at} className="w-[24ch] shrink-0 select-none text-faint/70 tabular-nums">
162173 {line.at ? shownTime(line.at) : ""}
171182 );
172183 }, sameLine);
173184
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. */
175186 function sameLine(a: LogLineProps, b: LogLineProps): boolean {
176187 return (
177188 a.number === b.number &&
178189 a.query === b.query &&
179190 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
184194 );
185195 }
186196
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+
187218 /**
188219 * A step's log. With `query`, only the lines holding it (any case), groups
189220 * 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).
190225 */
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);
196343 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+ )}
201357 </div>
202358 );
203− }
359+ });
204360 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]! }} />}
227365 </div>
228366 );
229367 }
230368
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. */
232373 export type JobLog = {
233− byStep: Map<number, string>;
374+ byStep: Map<number, StepLog>;
234375 /** When a chunk of each step's last arrived, by this browser's clock. */
235376 grewAt: Map<number, number>;
236377 /** The last ask failed, and the page is waiting to ask again. */
244385 * `running` is whether one of the job's steps is running now.
245386 */
246387 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());
248392 const [grewAt, setGrewAt] = useState<Map<number, number>>(new Map());
249393 const [retrying, setRetrying] = useState(false);
250394 const after = useRef(0);
252396 runningNow.current = running;
253397 useEffect(() => {
254398 after.current = 0;
255− setChunks([]);
399+ steps.current = new Map();
400+ setByStep(new Map());
256401 setGrewAt(new Map());
257402 }, [url]);
258403 useEffect(() => {
283428 if (page.chunks.length > 0) {
284429 after.current = page.chunks[page.chunks.length - 1]!.seq;
285430 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));
287433 setGrewAt((known) => {
288434 const next = new Map(known);
289435 for (const chunk of page.chunks) next.set(chunk.step, now);
315461 document.removeEventListener("visibilitychange", shown);
316462 };
317463 }, [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]);
323464 return { byStep, grewAt, retrying };
324465 }
325466
+106−1
33
44 import type { JobLogText } from "@g1t/contracts";
55
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+});
7112
8113 const LOG = ["Job: test", "##[group]Run cargo test", "cargo test --locked", "##[endgroup]", "test greet ... FAILED", "##[error]Process completed with exit code 101."].join("\n");
9114
+139−29
5858 return lineOf("text", raw, at);
5959 }
6060
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+
61147 /** Lines into blocks: plain lines, and groups that fold. Lines are numbered from 1, group markers aside. */
62148 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;
84150 }
85151
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[] {
88157 const wanted = query.trim().toLowerCase();
89158 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);
96164 }
165+ if (log.tail && log.tail.line.text.toLowerCase().includes(wanted)) found.push(log.tail);
166+ searched.set(log, { query: wanted, found });
97167 return found;
98168 }
99169
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+
100210 /** `text` cut where `query` (any case) appears, each piece marked as a match or not. */
101211 export function highlight(text: string, query: string): { text: string; match: boolean }[] {
102212 const wanted = query.trim().toLowerCase();
+28−0
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+});
+58−0
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+}
+44−13
4949 import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from "../../components/ui/dropdown-menu";
5050 import { Hint } from "../../components/ui/hint";
5151 import { useWorkflowReason } from "../../components/mirror";
52−import { lastStamp, searchLog } from "../../lib/log-lines";
52+import { type StepLog, searchStepLog } from "../../lib/log-lines";
5353 import { quietWords } from "../../lib/log-poll";
5454 import { listArtifacts, withLegacyArtifacts } from "../../lib/run-artifacts.server";
5555 import { expiresIn, formatBytes, legacyArtifactsWorthAsking } from "../../lib/run-artifacts";
140140
141141 function StepRow({
142142 step,
143− text,
143+ log,
144144 defaultOpen,
145145 query = "",
146146 matches = 0,
148148 lastOutput = null,
149149 now,
150150 end,
151+ target = null,
151152 }: {
152153 step: StepState;
153− text: string | undefined;
154+ log: StepLog | undefined;
154155 defaultOpen: boolean;
155156 /** A search of the job's log: only its matching lines are shown. */
156157 query?: string;
162163 now: number;
163164 /** Marks the end of the running step's log, for following it. */
164165 end?: Ref<HTMLDivElement>;
166+ /** The line of this step a link names (`#step:3:12`). */
167+ target?: number | null;
165168 }) {
166169 const running = step.status === "in_progress";
167170 const quiet = running && !query ? quietWords(step.startedAt ? new Date(step.startedAt).getTime() : null, lastOutput, now) : null;
181184 </span>
182185 </summary>
183186 <div className="border-t border-line bg-bg/60">
184− {text === undefined && !running ? (
187+ {log === undefined && !running ? (
185188 <p className="px-4 py-2 text-xs text-faint">{step.status === "queued" ? "Not started." : step.conclusion === "skipped" ? "Skipped." : "No output."}</p>
186189 ) : (
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} />
188191 )}
189192 {quiet && (
190193 <p className="flex items-center gap-2 px-4 py-2 font-mono text-xs text-muted" aria-live="polite" suppressHydrationWarning>
198201 );
199202 }
200203
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+
201227 /** Whether the viewer wants each log line's time shown, kept in this browser. */
202228 function useTimestamps(): [boolean, (on: boolean) => void] {
203229 const [on, setOn] = useState(false);
478504 const [query, setQuery] = useState("");
479505 const [timestamps, setTimestamps] = useTimestamps();
480506 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();
482509 // The running step's log is followed while its end is in view, unless a
483510 // failed step is the one held open.
484511 const followed = !failed && running ? running.number : null;
485− const { end, behind, jump } = useFollow(followed, runningText);
512+ const { end, behind, jump } = useFollow(followed, runningLog);
486513 // When the running step last printed: its last line's time, else when
487514 // its last chunk came.
488515 const lastOutput = useMemo(() => {
489516 if (!running) return null;
490− const at = runningText ? lastStamp(runningText) : undefined;
517+ const at = runningLog?.lastAt;
491518 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]);
494521 const searching = query.trim().length > 0;
495522 const setUp: StepState = {
496523 number: 0,
502529 };
503530 // While searching, the steps with a match, each with how many.
504531 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+ })
506536 .filter(({ matches }) => !searching || matches > 0);
507537 const total = steps.reduce((sum, { matches }) => sum + matches, 0);
508538 return (
605635 // Searching opens every step with a match; clearing it puts them back.
606636 key={`${step.number}:${searching}`}
607637 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)}
610640 query={searching ? query : ""}
611641 matches={matches}
612642 timestamps={timestamps}
613643 now={now}
614644 lastOutput={step.number === running?.number ? lastOutput : null}
615645 end={step.number === followed ? end : undefined}
646+ target={step.number === target?.step ? target.line : null}
616647 />
617648 ))}
618649 </Card>