Skip to content

Where people and agents ship software together. The open-source git platform for the whole job: issues, agents, checks and deploys to the edge.

g1t.sh

Commit

Live pages refresh only for what they show, and less often: on a busy repository every branch's pushes and every commit's checks reached the Code page, so it reloaded every few seconds for changes it does not draw. A page of files now hears pushes to its own branch, a branch made or deleted (the branch menu and count), tags, releases, deployments and the checks of the commit it shows, not another branch's pushes or another commit's checks; Commits hears checks on the commits it lists, and the overview its own commit's checks and pull requests' finished ones. Whatever arrives, a page refreshes at most every two seconds, and a tab out of view no longer refreshes once a minute: it waits and catches up once when it comes back. Pages that ask again on a timer while something runs (a pull request's checks, a run, the Work page) count from the page's last refresh, whoever made it, so a live change and the timer no longer refresh twice in a row, and a tab out of view asks once on coming back. Checked: the site's typecheck, its 904 tests (new: what a page of files, Commits and the overview hear, a burst of changes every 100 ms for a minute refreshing no more than once per two seconds yet at least every five, a hidden tab holding with no timer and catching up at once) and a production build.

syntaqxcommitted Parent8542133Browse files
8 files+169−500/8 viewed
+25−8
55 export const REFRESH_MS = 4000;
66
77 /**
8− * Revalidates the page every `everyMs` while `on` and the tab is visible.
8+ * Revalidates the page `everyMs` after it last loaded while `on` and the
9+ * tab is visible. Whatever refreshed it last (this, a live change, a form,
10+ * a navigation) starts the count again, so a page that also follows live
11+ * changes is not refreshed twice in a row. Out of view it waits, and asks
12+ * once when the tab comes back if the time has passed meanwhile.
13+ *
914 * Never while a navigation is pending: a revalidation then restarts the
1015 * navigation, aborting its loaders, so a click to a page slower than the
1116 * interval would never arrive. Never on top of a revalidation still going.
1217 */
1318 export function useRefreshWhile(on: boolean, everyMs = REFRESH_MS) {
1419 const revalidator = useRevalidator();
15− const navigating = useNavigation().state !== "idle";
20+ const idle = useNavigation().state === "idle" && revalidator.state === "idle";
1621 useEffect(() => {
17− if (!on || navigating) return;
18− const timer = setInterval(() => {
19− if (document.visibilityState === "visible" && revalidator.state === "idle") revalidator.revalidate();
20− }, everyMs);
21− return () => clearInterval(timer);
22− }, [on, navigating, everyMs, revalidator]);
22+ if (!on || !idle) return;
23+ let due = false;
24+ const ask = () => {
25+ if (document.visibilityState === "visible") revalidator.revalidate();
26+ else due = true;
27+ };
28+ const timer = setTimeout(ask, everyMs);
29+ const back = () => {
30+ if (due && document.visibilityState === "visible") ask();
31+ };
32+ document.addEventListener("visibilitychange", back);
33+ return () => {
34+ clearTimeout(timer);
35+ document.removeEventListener("visibilitychange", back);
36+ };
37+ // `revalidator` is a new object whenever its state changes, which `idle` already follows.
38+ // eslint-disable-next-line react-hooks/exhaustive-deps
39+ }, [on, idle, everyMs]);
2340 }
+66−7
44 import {
55 FALLBACK_FOCUS_MS,
66 FALLBACK_POLL_MS,
7− HIDDEN_MIN_MS,
87 LIVE_DEBOUNCE_MS,
98 LIVE_HOLD_CHECK_MS,
109 LIVE_MAX_WAIT_MS,
10+ LIVE_MIN_GAP_MS,
1111 type RepoChange,
1212 atBottom,
1313 branchOf,
5353 assert.equal(overview(change("comment.created", { number: 3 })), false);
5454 });
5555
56+test("the overview hears its own commit's checks, and every pull request's finished ones", () => {
57+ const overview = repoPages.overview("abc");
58+ assert.equal(overview(change("status.created", { sha: "abc" })), true);
59+ assert.equal(overview(change("run.updated", { sha: "abc", run: "run_1" })), true);
60+ assert.equal(overview(change("status.created", { sha: "f00d" })), false, "another commit's");
61+ assert.equal(overview(change("run.updated", { sha: "f00d", run: "run_2" })), false);
62+ assert.equal(overview(change("checks.completed", { sha: "f00d", number: 4 })), true, "how often checks pass");
63+ assert.equal(overview(change("status.created")), true, "a check that names no commit may be its");
64+});
65+
5666 test("the files page follows refs, checks, releases and deployments", () => {
5767 const files = repoPages.files();
5868 for (const c of [push("main"), push("other"), change("git.deleted", { ref: "refs/heads/old" }), change("status.created", { sha: "abc" }), change("release.published"), change("deployment_status.created")]) {
6272 assert.equal(files(change("comment.created", { number: 3 })), false);
6373 });
6474
75+test("a page of files hears its own branch and its commit's checks, not every branch's", () => {
76+ const files = repoPages.files({ branch: "main", branches: ["main", "feature"], sha: "abc" });
77+ assert.equal(files(push("main")), true, "its files and latest commit");
78+ assert.equal(files(push("feature")), false, "another branch moving changes nothing here");
79+ assert.equal(files(push("brand-new")), true, "a new branch: the menu and count");
80+ assert.equal(files(change("git.deleted", { ref: "refs/heads/feature" })), true, "one of the menu's gone");
81+ assert.equal(files(change("git.deleted", { ref: "refs/heads/never-listed" })), false);
82+ assert.equal(files(change("git.push", { ref: "refs/tags/v2" })), true, "the About's tags");
83+ assert.equal(files(change("branch.renamed", { ref: "refs/heads/feature" })), true);
84+ assert.equal(files(change("repo.default_branch_changed")), true);
85+ assert.equal(files(change("status.created", { sha: "abc" })), true, "its latest commit's checks");
86+ assert.equal(files(change("check_run.completed", { sha: "f00d" })), false, "another commit's checks");
87+ assert.equal(files(change("run.updated", { run: "run_1" })), true, "a check that names no commit may be its");
88+ assert.equal(files(change("deployment_status.created")), true);
89+ assert.equal(files(change("release.published")), true);
90+ // A tag or a commit shown: no branch of its own moves it.
91+ const tag = repoPages.files({ branch: null, branches: ["main"], sha: "abc" });
92+ assert.equal(tag(push("main")), false);
93+ // Without the branches, any branch may be new: only its own and deletions count.
94+ const unknown = repoPages.files({ branch: "main", branches: null, sha: "abc" });
95+ assert.equal(unknown(push("other")), false);
96+ assert.equal(unknown(change("git.deleted", { ref: "refs/heads/other" })), true);
97+});
98+
6599 test("the commits page hears pushes to its own branch only", () => {
66100 const commits = repoPages.commits("main");
67101 assert.equal(commits(push("main")), true);
69103 assert.equal(commits(change("git.push", { ref: "refs/tags/v1" })), false);
70104 assert.equal(commits(change("workflow.completed", { sha: "abc", run: "run_1" })), true, "checks on its commits");
71105 assert.equal(commits(change("pull.opened", { number: 1 })), false);
106+ const shown = repoPages.commits("main", ["abc", "def"]);
107+ assert.equal(shown(change("status.created", { sha: "def" })), true, "checks on a commit it lists");
108+ assert.equal(shown(change("status.created", { sha: "f00d" })), false, "not on one it does not");
72109 });
73110
74111 test("branches, tags, and the lists of pull requests and issues", () => {
183220 assert.deepEqual(gate(due, { now: NOW, visible: true, busy: false, navigating: false }), { action: "run" });
184221 });
185222
186−test("a tab out of view refreshes at most once a minute", () => {
223+test("a burst of changes refreshes at most every two seconds", () => {
224+ const idle = { now: NOW, visible: true, busy: false, navigating: false };
225+ // Quiet for a second, but the page refreshed half a second ago: it waits out the gap.
226+ const justRan = { pendingSince: NOW - LIVE_DEBOUNCE_MS, lastChangeAt: NOW - LIVE_DEBOUNCE_MS, lastRunAt: NOW - 500 };
227+ assert.deepEqual(gate(justRan, idle), { action: "wait", ms: LIVE_MIN_GAP_MS - 500 });
228+ assert.deepEqual(gate(justRan, { ...idle, now: NOW - 500 + LIVE_MIN_GAP_MS }), { action: "run" });
229+ // A steady stream after a refresh: one more at most five seconds on, never sooner than the gap.
230+ const stream = { pendingSince: NOW - LIVE_MAX_WAIT_MS, lastChangeAt: NOW, lastRunAt: NOW - LIVE_MAX_WAIT_MS - 10 };
231+ assert.deepEqual(gate(stream, idle), { action: "run" });
232+ // Changes arriving every 100 ms for a minute refresh no more than once per gap.
233+ let state = { pendingSince: null as number | null, lastChangeAt: null as number | null, lastRunAt: NOW };
234+ let runs = 0;
235+ for (let t = NOW; t < NOW + 60_000; t += 100) {
236+ state = { ...state, pendingSince: state.pendingSince ?? t, lastChangeAt: t };
237+ if (gate(state, { ...idle, now: t }).action === "run") {
238+ runs++;
239+ state = { pendingSince: null, lastChangeAt: null, lastRunAt: t };
240+ }
241+ }
242+ assert.ok(runs <= 60_000 / LIVE_MIN_GAP_MS, `${runs} runs`);
243+ assert.ok(runs >= 60_000 / LIVE_MAX_WAIT_MS - 1, `${runs} runs: still kept up to date`);
244+});
245+
246+test("a tab out of view never refreshes; back in view, it catches up at once", () => {
187247 const hidden = { now: NOW, visible: false, busy: false, navigating: false };
188− const ranLately = { pendingSince: NOW - 5000, lastChangeAt: NOW - 5000, lastRunAt: NOW - 20_000 };
189− assert.deepEqual(gate(ranLately, hidden), { action: "wait", ms: HIDDEN_MIN_MS - 20_000 });
190− assert.deepEqual(gate({ ...ranLately, lastRunAt: NOW - HIDDEN_MIN_MS }, hidden), { action: "run" });
191− // Back in view: at once.
192− assert.deepEqual(gate(ranLately, { ...hidden, visible: true }), { action: "run" });
248+ const waiting = { pendingSince: NOW - 5000, lastChangeAt: NOW - 5000, lastRunAt: NOW - 20 * 60_000 };
249+ assert.deepEqual(gate(waiting, hidden), { action: "hold" }, "no timer: coming back asks again");
250+ assert.deepEqual(gate({ pendingSince: null, lastChangeAt: null, lastRunAt: null }, hidden), { action: "idle" });
251+ assert.deepEqual(gate(waiting, { ...hidden, visible: true }), { action: "run" });
193252 });
194253
195254 test("without the socket: back in view every 30 s at most, a poll every minute in view, never with it", () => {
+62−26
88 * then every push, pull request, issue, comment, review, check, run,
99 * release and security alert there arrives as a small `RepoChange`. A page
1010 * refreshes its loader when a change matches it, a second after the last
11− * one, and never under someone typing or with a dialog open. Signed out,
12− * or while the socket is down, it refreshes when the tab comes back into
13− * view and once a minute while it is in view instead.
11+ * one, at most every two seconds, never under someone typing or with a
12+ * dialog open, and not at all while the tab is out of view: it catches up
13+ * once when it comes back. A busy repository sends changes all the time
14+ * (every branch's pushes, every commit's checks), so each page matches
15+ * only what it shows: the Code page its own branch and the checks of the
16+ * commit it shows, not another branch's. Signed out, or while the socket
17+ * is down, it refreshes when the tab comes back into view and once a
18+ * minute while it is in view instead.
1419 */
1520 import type { RepoChange } from "@g1t/contracts";
1621
2328 export const LIVE_DEBOUNCE_MS = 1000;
2429 /** However steady the changes, a page waits no longer than this before refreshing. */
2530 export const LIVE_MAX_WAIT_MS = 5000;
31+/** However many changes arrive, a page refreshes at most this often. */
32+export const LIVE_MIN_GAP_MS = 2000;
2633 /** How often a page held back (someone typing, a dialog open) looks again. */
2734 export const LIVE_HOLD_CHECK_MS = 1000;
28−/** A tab out of view refreshes at most this often. */
29−export const HIDDEN_MIN_MS = 60_000;
3035 /** Without the socket: coming back into view refreshes at most this often. */
3136 export const FALLBACK_FOCUS_MS = 30_000;
3237 /** Without the socket: how often a page in view refreshes. */
7378
7479 const ALERTS = ["secret_scanning_alert", "code_scanning_alert", "vulnerability_alert"];
7580
81+/** A check on one of `shas`: any check when they are not known, and one that names no commit. */
82+function isCheckOn(change: RepoChange, shas: readonly string[] | null | undefined): boolean {
83+ return isCheck(change) && (shas == null || !change.sha || shas.includes(change.sha));
84+}
85+
86+/** What a page of files shows, for matching only what can change it. */
87+export type ShownFiles = {
88+ /** The branch shown; null for a tag or a commit. */
89+ branch: string | null;
90+ /** Every branch, for the branch menu and count: a new one, or one of them deleted, changes them. */
91+ branches?: readonly string[] | null;
92+ /** The commit shown, whose checks are on the page. */
93+ sha?: string | null;
94+};
95+
7696 /** What each kind of repository page shows. */
7797 export const repoPages = {
7898 /**
79− * Files and a folder: any ref moving (the files and latest
80− * commit, the branch menu, recent pushes), checks on commits, releases
81− * and deployments.
99+ * Files and a folder: its own branch moving (the files and the latest
100+ * commit), a branch made, deleted or renamed and the default changing
101+ * (the branch menu and count), any tag (the About's), checks on the
102+ * commit shown, releases and deployments. Without `shown`, any ref and
103+ * any check.
82104 */
83− files(): RepoMatch {
84− return (change) => isRefChange(change) || isCheck(change) || ["release", "deployment", "deployment_status"].includes(family(change));
105+ files(shown?: ShownFiles): RepoMatch {
106+ return (change) => {
107+ if (["release", "deployment", "deployment_status"].includes(family(change))) return true;
108+ if (!shown) return isRefChange(change) || isCheck(change);
109+ if (isCheck(change)) return isCheckOn(change, shown.sha ? [shown.sha] : null);
110+ if (!isRefChange(change)) return false;
111+ const branch = branchOf(change.ref);
112+ if (branch == null || change.kind === "branch.renamed" || change.kind === "repo.default_branch_changed") return true;
113+ if (branch === shown.branch) return true;
114+ // Another branch: only made or deleted matters, by the menu it is in or not.
115+ const known = shown.branches == null || shown.branches.includes(branch);
116+ return change.kind === "git.deleted" ? known : !known;
117+ };
85118 },
86119 /**
87120 * A project's overview: what it runs and how it is doing (deployments,
88121 * checks, releases), who is working on it (claims and sessions), and the
89122 * pull requests that may need someone (opened, readied, reviewed, merged).
123+ * Of the checks, only the shown commit's (`sha`) and finished pull
124+ * requests' (how often checks pass); without `sha`, all.
90125 */
91− overview(): RepoMatch {
126+ overview(sha?: string | null): RepoMatch {
92127 return (change) =>
93− isCheck(change) ||
128+ (change.kind === "checks.completed" || isCheckOn(change, sha ? [sha] : null)) ||
94129 ["release", "deployment", "deployment_status", "work", "pull", "review"].includes(family(change)) ||
95130 SESSION_KINDS.includes(change.kind);
96131 },
97− /** Commits: pushes to the branch shown, and checks on commits. */
98− commits(branch: string | null): RepoMatch {
99− return (change) => (branch == null ? change.kind === "git.push" && !tagOf(change.ref) : isPushTo(change, branch)) || isCheck(change);
132+ /** Commits: pushes to the branch shown, and checks on the commits shown (on any without `shas`). */
133+ commits(branch: string | null, shas?: readonly string[] | null): RepoMatch {
134+ return (change) => (branch == null ? change.kind === "git.push" && !tagOf(change.ref) : isPushTo(change, branch)) || isCheckOn(change, shas);
100135 },
101136 /** Branches and tags: any ref moving, pull requests (a branch's), checks. */
102137 refs(): RepoMatch {
196231 navigating: boolean;
197232 };
198233
199−export type GateDecision = { action: "idle" } | { action: "run" } | { action: "wait"; ms: number };
234+/**
235+ * `hold`: nothing to do until the tab comes back into view, which asks
236+ * again; no timer runs meanwhile.
237+ */
238+export type GateDecision = { action: "idle" } | { action: "run" } | { action: "hold" } | { action: "wait"; ms: number };
200239
201240 /**
202241 * Whether to refresh now for the changes waiting, or how long to wait
203242 * before asking again. A second after the last change (at most five after
204− * the first), never under someone typing or with a dialog open, and at
205− * most once a minute for a tab out of view, which refreshes as soon as it
206− * comes back instead.
243+ * the first), at most every two seconds, never under someone typing or
244+ * with a dialog open, and never for a tab out of view: what waited is
245+ * fetched once, as soon as it comes back.
207246 */
208247 export function gate(state: GateState, input: GateInput): GateDecision {
209248 if (state.pendingSince == null) return { action: "idle" };
249+ if (!input.visible) return { action: "hold" };
210250 if (input.busy || input.navigating) return { action: "wait", ms: LIVE_HOLD_CHECK_MS };
211− if (!input.visible && state.lastRunAt != null && input.now - state.lastRunAt < HIDDEN_MIN_MS) {
212− return { action: "wait", ms: HIDDEN_MIN_MS - (input.now - state.lastRunAt) };
213− }
214251 const quiet = input.now - (state.lastChangeAt ?? state.pendingSince);
215252 const waited = input.now - state.pendingSince;
216− if (quiet < LIVE_DEBOUNCE_MS && waited < LIVE_MAX_WAIT_MS) {
217− return { action: "wait", ms: Math.min(LIVE_DEBOUNCE_MS - quiet, LIVE_MAX_WAIT_MS - waited) };
218− }
219− return { action: "run" };
253+ const since = state.lastRunAt == null ? Infinity : input.now - state.lastRunAt;
254+ const ms = Math.max(quiet < LIVE_DEBOUNCE_MS && waited < LIVE_MAX_WAIT_MS ? Math.min(LIVE_DEBOUNCE_MS - quiet, LIVE_MAX_WAIT_MS - waited) : 0, LIVE_MIN_GAP_MS - since);
255+ return ms > 0 ? { action: "wait", ms } : { action: "run" };
220256 }
221257
222258 /**
+5−3
66 * Signed in, the tab watches the repository over its live socket: the
77 * site checks the person may read it (`/-/notify`, `watch_repo`), then
88 * every change there arrives as it happens, and the page's loader runs
9− * again a second after the last matching one. Never under someone typing
10− * on the page or with a dialog open (it waits until they are done), and
11− * at most once a minute for a tab out of view. Signed out, or while the
9+ * again a second after the last matching one, at most every two seconds.
10+ * Never under someone typing on the page or with a dialog open (it waits
11+ * until they are done), and never for a tab out of view: it catches up
12+ * once when the tab comes back. Signed out, or while the
1213 * socket is down, the page refreshes when the tab comes back into view
1314 * (at most every 30 s) and once a minute while it is in view.
1415 *
147148 const decision = gate(state.current, { now: Date.now(), visible: visibleNow(), busy: busyNow(), navigating: latest.current.navigating });
148149 if (decision.action === "run") run();
149150 else if (decision.action === "wait") timer.current = setTimeout(check, decision.ms);
151+ // "hold": out of view; coming back into view (`back` below) asks again.
150152 }, [run]);
151153
152154 /** Something to refresh for: `now` skips the second's wait for more. */
+4−2
4141
4242 export default function Code({ loaderData }: Route.ComponentProps) {
4343 const project = useProject();
44− // The files and latest commit follow pushes; checks update as they report.
45− useRepoLive({ match: repoPages.files() });
44+ // The files and latest commit follow pushes to the branch; the latest
45+ // commit's checks update as they report.
46+ const { tree, branches } = loaderData;
47+ useRepoLive({ match: repoPages.files({ branch: tree.ref, branches: branches?.map((branch) => branch.name), sha: tree.head?.hash }) });
4648 return (
4749 <TreeView
4850 tree={loaderData.tree}
+1−1
6464 // moving the list under the reader; checks on the shown ones update.
6565 const branch = useProject()?.repo.defaultBranch ?? null;
6666 const pushed = (change: Parameters<typeof isPushTo>[0]) => change.kind === "git.push" && isPushTo(change, branch);
67− const { held, show } = useRepoLive({ match: repoPages.commits(branch), hold: commits.length > 0 ? pushed : undefined });
67+ const { held, show } = useRepoLive({ match: repoPages.commits(branch, commits.map((commit) => commit.hash)), hold: commits.length > 0 ? pushed : undefined });
6868 if (commits.length === 0) {
6969 return <EmptyState title="No commits yet" />;
7070 }
+1−1
819819 const elsewhereUrl = loaderData.deployment?.url ?? project?.productionUrl ?? null;
820820 const working = claims.length + sessions.length > 0;
821821 // Deployments, checks, releases, claims and sessions show as they happen.
822− useRepoLive({ match: repoPages.overview() });
822+ useRepoLive({ match: repoPages.overview(loaderData.commit?.hash) });
823823 // Claims lapse and sessions go idle with no event to say so.
824824 useRefreshWhile(working, 15_000);
825825 const live = liveByIssue(claims, sessions);
+5−2
4545
4646 export default function Tree({ loaderData }: Route.ComponentProps) {
4747 const project = useProject();
48− // The files and latest commit follow pushes; checks update as they report.
49− useRepoLive({ match: repoPages.files() });
48+ // The files and latest commit follow pushes to the branch (none for a
49+ // tag or a commit); the latest commit's checks update as they report.
50+ const { tree, branches } = loaderData;
51+ const names = branches?.map((branch) => branch.name);
52+ useRepoLive({ match: repoPages.files({ branch: !names || names.includes(tree.ref) ? tree.ref : null, branches: names, sha: tree.head?.hash }) });
5053 return (
5154 <TreeView
5255 tree={loaderData.tree}

Comments

Sign in to comment.