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.

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