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.

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