Skip to content

Commit

A running job's log follows to its real end. Following counted the end of the log as reached while it was anywhere up to 160 pixels below the window, so a log whose output paused, or one the viewer scrolled near the end of, stopped with its last lines cut off by the window's bottom edge and no Jump to latest to reach them; on a phone the tab bar covered the last lines too, and the Jump to latest bar sat under it. Jump to latest scrolled smoothly to where the end was when it was pressed, and the scroll it made read as the viewer scrolling, so lines arriving on the way and its own bar going away left it short. Now the end of the log is reached only with the last line wholly above whatever covers the window's bottom (endPlace, lib/log-follow.ts, with tests): the end marker's scroll-margin-bottom is the tab bar's height and a little room, and both scrolling to the end and judging whether it is in view read it. Following pins the end there after each new chunk and again once that has been laid out, keeps it there when the window resizes, and stops as soon as the viewer scrolls up. Jump to latest scrolls smoothly, follows again, and pins the end once the scroll is over. Its bar sits above a phone's tab bar. The Actions guide says so.

syntaqxcommitted Parent73a1fc9Browse files
4 files+114−150/4 viewed
+4−2
12171217 page asks for more every second while a step is running.
12181218
12191219 - The running step opens by itself, and its timer counts up. Its log
1220− follows the newest line while the end of it is on screen. Scroll up and
1221− it stays where you are, with **Jump to latest** to go back to the end.
1220+ follows the newest line while the end of it is on screen, with the last
1221+ line wholly in view (above the tab bar on a phone). Scroll up and it
1222+ stays where you are, with **Jump to latest** to go back to the end and
1223+ follow it again.
12221224 - A step that prints nothing for a while says so under its last line:
12231225 **Running · no output for 2m 10s**, or **no output yet** before its
12241226 first line. The job's header names the step it is on.
+32−0
1+import assert from "node:assert/strict";
2+import { test } from "node:test";
3+
4+import { FOLLOW_SLACK, endPlace } from "./log-follow.ts";
5+
6+const end = (bottom: number) => ({ top: bottom - 1, bottom, width: 600, height: 1 });
7+
8+test("the end above the window's bottom edge is in view", () => {
9+ assert.equal(endPlace(end(700), { height: 800, covered: 12 }), "here");
10+ assert.equal(endPlace(end(788), { height: 800, covered: 12 }), "here");
11+ // Scrolled past it, the latest is still on screen.
12+ assert.equal(endPlace(end(-40), { height: 800, covered: 12 }), "here");
13+});
14+
15+test("an end one row below the edge is not reached: following stops there", () => {
16+ // Before, anything within 160 px below the window counted, so a log
17+ // could stop with its last lines cut off and no Jump to latest.
18+ assert.equal(endPlace(end(800 - 12 + 20), { height: 800, covered: 12 }), "below");
19+ assert.equal(endPlace(end(800 + 20), { height: 800, covered: 0 }), "below");
20+ assert.equal(endPlace(end(800 + FOLLOW_SLACK), { height: 800, covered: 0 }), "here");
21+});
22+
23+test("what covers the bottom of the window hides the end: a phone's tab bar", () => {
24+ // 56 px of tab bar and 12 px of room: an end at 760 is under the bar.
25+ assert.equal(endPlace(end(760), { height: 812, covered: 68 }), "below");
26+ assert.equal(endPlace(end(744), { height: 812, covered: 68 }), "here");
27+});
28+
29+test("a folded step has no end to follow", () => {
30+ assert.equal(endPlace(null, { height: 800, covered: 0 }), "folded");
31+ assert.equal(endPlace({ top: 0, bottom: 0, width: 0, height: 0 }, { height: 800, covered: 0 }), "folded");
32+});
+25−0
1+/**
2+ * Following a running step's log: where the end of the log is against what
3+ * the viewer can see. Pure; the hook that uses it is `useFollow` in
4+ * routes/repo/actions-run.tsx.
5+ *
6+ * The end of the log is a marker after its last line. What covers the
7+ * bottom of the window (a phone's tab bar, and room to breathe under the
8+ * last line) is the marker's `scroll-margin-bottom`, so scrolling it into
9+ * view and judging whether it is in view read the same number.
10+ */
11+
12+/** How far past what can be seen the end may be and still count as reached: rounding, not a row. */
13+export const FOLLOW_SLACK = 2;
14+
15+export type EndBox = { top: number; bottom: number; width: number; height: number };
16+
17+/**
18+ * Where the end of the log is: folded away (a closed step draws nothing),
19+ * in view above whatever covers the window's bottom (the viewer is
20+ * reading the latest), or below it.
21+ */
22+export function endPlace(end: EndBox | null, view: { height: number; covered: number }): "folded" | "here" | "below" {
23+ if (!end || (end.width === 0 && end.height === 0)) return "folded";
24+ return end.bottom <= view.height - view.covered + FOLLOW_SLACK ? "here" : "below";
25+}
+53−13
5858 import { accessTo, refusal } from "../../lib/access.server";
5959 import { useRefreshWhile } from "../../lib/refresh";
6060 import { runControls } from "../../lib/run-controls";
61+import { endPlace } from "../../lib/log-follow";
6162 import { type GraphJob, type Unit, groupJobs, jobsOf, unitStanding } from "../../lib/run-graph";
6263 import { repoPages } from "../../lib/repo-live";
6364 import { useRepoLive } from "../../lib/use-repo-live";
198199 {quiet}
199200 </p>
200201 )}
201− {running && <div ref={end} aria-hidden className="h-px" />}
202+ {/* The end of the log: kept clear of what covers the window's bottom (a phone's tab bar), with room under the last line. */}
203+ {running && <div ref={end} aria-hidden className="h-px scroll-mb-[calc(var(--tabbar-h)+0.75rem)]" />}
202204 </div>
203205 </details>
204206 );
254256 /**
255257 * Following the running step's log as it grows, as GitHub does: while the
256258 * end of it is in view, new lines keep it in view; once the viewer
257− * scrolls up, it stays put and `behind` says there is more below.
259+ * scrolls up, it stays put and `behind` says there is more below. The end
260+ * is in view only with the last line wholly above whatever covers the
261+ * window's bottom (lib/log-follow.ts), and following pins it there again
262+ * once the new lines have been laid out.
258263 */
259264 function useFollow(running: number | null, grew: unknown): { end: RefObject<HTMLDivElement | null>; behind: boolean; jump: () => void } {
260265 const end = useRef<HTMLDivElement | null>(null);
261266 const following = useRef(false);
267+ // Jump to latest's smooth scroll is on its way: the scrolls it makes are
268+ // not the viewer scrolling up.
269+ const jumping = useRef(false);
262270 const [behind, setBehind] = useState(false);
263− // Where the end of the log is: folded away (a closed step draws nothing),
264− // on screen or nearly (the viewer is reading the latest), or below.
265− const where = (): "folded" | "here" | "below" => {
266− const box = end.current?.getBoundingClientRect();
267− if (!box || (box.width === 0 && box.height === 0)) return "folded";
268− return box.top <= window.innerHeight + 160 ? "here" : "below";
271+ const where = () => {
272+ const marker = end.current;
273+ const covered = marker ? parseFloat(getComputedStyle(marker).scrollMarginBottom) || 0 : 0;
274+ return endPlace(marker?.getBoundingClientRect() ?? null, { height: window.innerHeight, covered });
275+ };
276+ // The end of the log at the bottom of what can be seen, now and once
277+ // anything still settling (a long log's rows being measured, the Jump to
278+ // latest bar going away) has been laid out.
279+ const pin = () => {
280+ end.current?.scrollIntoView({ block: "end" });
281+ requestAnimationFrame(() => {
282+ if (following.current && where() !== "folded") end.current?.scrollIntoView({ block: "end" });
283+ });
269284 };
270285 useEffect(() => {
271286 if (running === null) {
275290 }
276291 // The viewer moving decides: at the end, follow; scrolled up, stay.
277292 const moved = () => {
293+ if (jumping.current) return;
278294 const at = where();
279295 following.current = at === "here";
280296 setBehind(at === "below");
281297 };
282− // A step folded or opened moves nothing the viewer chose; it only
283− // changes whether there is anything below to jump to.
298+ // A step folded or opened, or the window resized (a phone's address
299+ // bar or keyboard), moves nothing the viewer chose: a log followed
300+ // stays at its end, and whether there is anything below to jump to may
301+ // change.
284302 const toggled = () => {
285303 const at = where();
286304 if (at === "folded") following.current = false;
305+ else if (following.current && at === "below" && !jumping.current) {
306+ pin();
307+ return;
308+ }
287309 setBehind(at === "below");
288310 };
289311 window.addEventListener("scroll", moved, { passive: true });
298320 useLayoutEffect(() => {
299321 if (running === null || !end.current) return;
300322 if (following.current && where() !== "folded") {
301− end.current.scrollIntoView({ block: "end" });
323+ pin();
302324 return;
303325 }
304326 // Not following: once there is a log and its end is on screen, follow
308330 if (grew && at === "here") following.current = true;
309331 setBehind(at === "below");
310332 }, [grew, running]);
333+ // Smoothly there, then pinned where the end is once the scroll is over:
334+ // lines that arrived on the way, and the bar this button sits in going
335+ // away, move it.
311336 const jump = () => {
312337 following.current = true;
313− end.current?.scrollIntoView({ block: "end", behavior: "smooth" });
338+ const marker = end.current;
339+ if (!marker) return;
340+ jumping.current = true;
341+ setBehind(false);
342+ let settled = false;
343+ const settle = () => {
344+ if (settled) return;
345+ settled = true;
346+ jumping.current = false;
347+ window.removeEventListener("scrollend", settle);
348+ if (following.current && where() !== "folded") pin();
349+ };
350+ window.addEventListener("scrollend", settle);
351+ // A browser without `scrollend`, or nothing left to scroll.
352+ window.setTimeout(settle, 800);
353+ marker.scrollIntoView({ block: "end", behavior: "smooth" });
314354 };
315355 return { end, behind, jump };
316356 }
651691 ))}
652692 </Card>
653693 {behind && !searching && (
654− <div className="pointer-events-none sticky bottom-4 z-10 flex justify-center">
694+ <div className="pointer-events-none sticky bottom-[calc(var(--tabbar-h)+1rem)] z-10 flex justify-center">
655695 <Button variant="secondary" size="sm" onClick={jump} className="pointer-events-auto shadow-md ring-1 ring-line">
656696 <ArrowDown size={14} />
657697 Jump to latest