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