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
249 linesCodeBlameRaw
1/**
2 * `useRepoLive`: a repository page that refreshes itself as the
3 * repository changes (lib/repo-live.ts says which changes each page
4 * cares about, and when it may refresh).
5 *
6 * Signed in, the tab watches the repository over its live socket: the
7 * site checks the person may read it (`/-/notify`, `watch_repo`), then
8 * every change there arrives as it happens, and the page's loader runs
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
13 * socket is down, the page refreshes when the tab comes back into view
14 * (at most every 30 s) and once a minute while it is in view.
15 *
16 * Browser-only work happens in effects; on the server the hook does
17 * nothing.
18 */
19import { useCallback, useEffect, useRef, useState } from "react";
20import { useLocation, useNavigation, useRevalidator, useRouteLoaderData } from "react-router";
21
22import { onRepoChanges, useFeedConnected } from "./notify-client";
23import {
24 FALLBACK_FOCUS_MS,
25 LIVE_DEBOUNCE_MS,
26 REWATCH_MS,
27 type GateState,
28 type RepoChange,
29 type RepoMatch,
30 atBottom,
31 fallbackDue,
32 gate,
33 isTyping,
34} from "./repo-live";
35
36/** What the project layout's loader gives every page under it (routes/repo/layout.tsx). */
37type LayoutData = { repo?: { id: string; namespace: string; name: string }; signedIn?: boolean };
38
39export type RepoLiveOptions = {
40 /** Which changes refresh the page. */
41 match: RepoMatch;
42 /**
43 * Changes held back for the reader instead (the Commits page's "N new
44 * commits"): `held` lists them, and `show` refreshes. While any are held,
45 * other changes wait with them, so nothing moves under the reader.
46 */
47 hold?: RepoMatch;
48 /** A reader at the bottom of the page stays there as new items arrive (a pull request's timeline). */
49 followBottom?: boolean;
50 /** Off: nothing is heard and nothing refreshes (a page that cannot change). */
51 enabled?: boolean;
52};
53
54export type RepoLive = {
55 /** Whether changes arrive as they happen (signed in, socket up, watching). */
56 live: boolean;
57 /** Changes held back by `hold`, oldest first. */
58 held: RepoChange[];
59 /** Shows what was held: the page refreshes now. */
60 show: () => void;
61};
62
63// ── Watching ─────────────────────────────────────────────────────────────
64
65/** When each repository was last asked for, by `owner/name`, so pages and reconnects ask once. */
66const asked = new Map<string, number>();
67/** Asking again sooner than this is the same ask. */
68const ASK_AGAIN_MS = 5000;
69
70async function watchRepo(path: string, force: boolean): Promise<boolean> {
71 const now = Date.now();
72 const last = asked.get(path);
73 if (!force && last != null && now - last < ASK_AGAIN_MS) return true;
74 asked.set(path, now);
75 try {
76 const answer = await fetch("/-/notify", {
77 method: "POST",
78 headers: { "content-type": "application/json", accept: "application/json" },
79 credentials: "same-origin",
80 body: JSON.stringify({ intent: "watch_repo", repo: path }),
81 });
82 if (!answer.ok) throw new Error(`status ${answer.status}`);
83 const body = (await answer.json().catch(() => null)) as { ok?: unknown } | null;
84 if (body?.ok !== true) throw new Error("refused");
85 return true;
86 } catch {
87 asked.delete(path);
88 return false;
89 }
90}
91
92// ── The page ─────────────────────────────────────────────────────────────
93
94/** Someone typing on the page, or a dialog (or a popover) open over it. */
95function busyNow(): boolean {
96 if (typeof document === "undefined") return false;
97 const focused = document.activeElement as (HTMLElement & { type?: string; readOnly?: boolean; disabled?: boolean }) | null;
98 if (isTyping(focused && focused !== document.body ? focused : null)) return true;
99 return document.querySelector('[role="dialog"], [role="alertdialog"], dialog[open]') != null;
100}
101
102function visibleNow(): boolean {
103 return typeof document === "undefined" || document.visibilityState === "visible";
104}
105
106function pageAtBottom(): boolean {
107 const root = document.documentElement;
108 return atBottom({ top: window.scrollY, height: root.scrollHeight, viewport: window.innerHeight });
109}
110
111export function useRepoLive(options: RepoLiveOptions): RepoLive {
112 const layout = useRouteLoaderData("routes/repo/layout") as LayoutData | undefined;
113 const repo = layout?.repo ?? null;
114 const signedIn = layout?.signedIn === true;
115 const enabled = options.enabled !== false && !!repo;
116 const path = repo ? `${repo.namespace}/${repo.name}` : null;
117 const repoId = repo?.id ?? null;
118
119 const connected = useFeedConnected();
120 const [watching, setWatching] = useState(false);
121 const live = enabled && signedIn && connected && watching;
122
123 const revalidator = useRevalidator();
124 const navigation = useNavigation();
125 const { pathname, search } = useLocation();
126 const [held, setHeld] = useState<RepoChange[]>([]);
127
128 // What the timers read: always the latest, without starting them again.
129 const latest = useRef({ options, revalidator, navigating: false, live });
130 latest.current = { options, revalidator, navigating: navigation.state !== "idle" || revalidator.state !== "idle", live };
131 const state = useRef<GateState>({ pendingSince: null, lastChangeAt: null, lastRunAt: null });
132 const heldCount = useRef(0);
133 heldCount.current = held.length;
134 const timer = useRef<ReturnType<typeof setTimeout> | null>(null);
135 const follow = useRef(false);
136
137 const run = useCallback(() => {
138 const now = Date.now();
139 state.current = { pendingSince: null, lastChangeAt: null, lastRunAt: now };
140 follow.current = !!latest.current.options.followBottom && pageAtBottom();
141 latest.current.revalidator.revalidate();
142 }, []);
143
144 /** Refreshes when the rules allow, or looks again when they say. */
145 const check = useCallback(() => {
146 if (timer.current) clearTimeout(timer.current);
147 timer.current = null;
148 const decision = gate(state.current, { now: Date.now(), visible: visibleNow(), busy: busyNow(), navigating: latest.current.navigating });
149 if (decision.action === "run") run();
150 else if (decision.action === "wait") timer.current = setTimeout(check, decision.ms);
151 // "hold": out of view; coming back into view (`back` below) asks again.
152 }, [run]);
153
154 /** Something to refresh for: `now` skips the second's wait for more. */
155 const queue = useCallback(
156 (now = false) => {
157 const at = Date.now();
158 const s = state.current;
159 state.current = { ...s, pendingSince: s.pendingSince ?? at, lastChangeAt: now ? at - LIVE_DEBOUNCE_MS : at };
160 check();
161 },
162 [check],
163 );
164
165 // Watching the repository: on opening the page, whenever the socket
166 // (re)connects, and before the watch runs out.
167 useEffect(() => {
168 if (!enabled || !signedIn || !connected || !path) {
169 setWatching(false);
170 return;
171 }
172 let cancelled = false;
173 const ask = (force: boolean) =>
174 void watchRepo(path, force).then((ok) => {
175 if (!cancelled) setWatching(ok);
176 });
177 ask(true);
178 const renew = setInterval(() => ask(true), REWATCH_MS);
179 return () => {
180 cancelled = true;
181 clearInterval(renew);
182 };
183 }, [enabled, signedIn, connected, path]);
184
185 // Changes as they arrive.
186 useEffect(() => {
187 if (!enabled || !repoId) return;
188 return onRepoChanges((changes) => {
189 const { match, hold } = latest.current.options;
190 const mine = changes.filter((change) => change.repo_id === repoId);
191 const holding = hold ? mine.filter((change) => hold(change)) : [];
192 const matched = mine.filter((change) => !holding.includes(change) && match(change));
193 if (holding.length) setHeld((before) => [...before, ...holding]);
194 // While something is held, the rest waits with it: shown together.
195 if (matched.length && !holding.length && heldCount.current === 0) queue();
196 });
197 }, [enabled, repoId, queue]);
198
199 // Another branch or page: what was held is no longer about it.
200 useEffect(() => {
201 setHeld([]);
202 }, [pathname, search]);
203
204 // Back into view: whatever waited goes now; without the socket, the page
205 // refreshes itself (at most every 30 s), and once a minute while in view.
206 useEffect(() => {
207 if (!enabled) return;
208 const fallback = (reason: "focus" | "poll") => {
209 if (fallbackDue({ live: latest.current.live, visible: visibleNow(), now: Date.now(), lastRunAt: state.current.lastRunAt ?? null, reason })) queue(true);
210 };
211 const back = () => {
212 if (!visibleNow()) return;
213 if (state.current.pendingSince != null) check();
214 fallback("focus");
215 };
216 // Starts counting from the page's own load.
217 if (state.current.lastRunAt == null) state.current = { ...state.current, lastRunAt: Date.now() };
218 const poll = setInterval(() => fallback("poll"), FALLBACK_FOCUS_MS / 2);
219 document.addEventListener("visibilitychange", back);
220 window.addEventListener("focus", back);
221 // Someone stopped typing, or closed a dialog: what waited goes.
222 const settled = () => {
223 if (state.current.pendingSince != null) setTimeout(check, 0);
224 };
225 document.addEventListener("focusout", settled);
226 return () => {
227 clearInterval(poll);
228 document.removeEventListener("visibilitychange", back);
229 window.removeEventListener("focus", back);
230 document.removeEventListener("focusout", settled);
231 if (timer.current) clearTimeout(timer.current);
232 timer.current = null;
233 };
234 }, [enabled, check, queue]);
235
236 // A reader who was at the bottom follows what was added there.
237 useEffect(() => {
238 if (revalidator.state !== "idle" || !follow.current) return;
239 follow.current = false;
240 requestAnimationFrame(() => window.scrollTo({ top: document.documentElement.scrollHeight, behavior: "smooth" }));
241 }, [revalidator.state]);
242
243 const show = useCallback(() => {
244 setHeld([]);
245 run();
246 }, [run]);
247
248 return { live, held, show };
249}