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.

363 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, { now: Date.now(), visible: visibleNow(), busy: busyNow(), navigating: latest.current.navigating, gap: latest.current.options.gap });
248 if (decision.action === "run") run();
249 else if (decision.action === "wait") timer.current = setTimeout(check, decision.ms);
250 // "hold": out of view; coming back into view (`back` below) asks again.
251 }, [run]);
252
253 /** Something to refresh for: `now` skips the second's wait for more. */
254 const queue = useCallback(
255 (now = false) => {
256 const at = Date.now();
257 const s = state.current;
258 state.current = { ...s, pendingSince: s.pendingSince ?? at, lastChangeAt: now ? at - LIVE_DEBOUNCE_MS : at };
259 check();
260 },
261 [check],
262 );
263
264 // Watching the repositories (and the workspace): on opening the page,
265 // whenever the socket (re)connects, and before the watch runs out.
266 useEffect(() => {
267 if (!enabled || !signedIn || !connected || (!paths && !slug)) {
268 setWatching(false);
269 return;
270 }
271 let cancelled = false;
272 const ask = (force: boolean) =>
273 void Promise.all([paths ? watchRepos(paths.split(" "), force) : false, slug ? watchWorkspace(slug, force) : false]).then(([repos, workspace]) => {
274 if (!cancelled) setWatching(repos || workspace);
275 });
276 ask(true);
277 const renew = setInterval(() => ask(true), REWATCH_MS);
278 return () => {
279 cancelled = true;
280 clearInterval(renew);
281 };
282 }, [enabled, signedIn, connected, paths, slug]);
283
284 // Changes as they arrive.
285 useEffect(() => {
286 if (!enabled || !ids) return;
287 const watched = new Set(ids.split(" "));
288 return onRepoChanges((changes) => {
289 const { match, hold } = latest.current.options;
290 const mine = changes.filter((change) => watched.has(change.repo_id));
291 const holding = hold ? mine.filter((change) => hold(change)) : [];
292 const matched = mine.filter((change) => !holding.includes(change) && match(change));
293 if (holding.length) setHeld((before) => [...before, ...holding]);
294 // While something is held, the rest waits with it: shown together.
295 if (matched.length && !holding.length && heldCount.current === 0) queue();
296 });
297 }, [enabled, ids, queue]);
298
299 // What changes to the workspace's work items, as it arrives.
300 useEffect(() => {
301 if (!enabled || !slug) return;
302 return onWorkspaceChanges((changes) => {
303 const match = latest.current.options.workspace?.match;
304 const mine = changes.filter((change) => change.workspace?.toLowerCase() === slug && !!match?.(change));
305 if (mine.length && heldCount.current === 0) queue();
306 });
307 }, [enabled, slug, queue]);
308
309 // Another branch or page: what was held is no longer about it.
310 useEffect(() => {
311 setHeld([]);
312 }, [pathname, search]);
313
314 // Back into view: whatever waited goes now; without the socket, the page
315 // refreshes itself (at most every 30 s), and once a minute while in view.
316 useEffect(() => {
317 if (!enabled) return;
318 const fallback = (reason: "focus" | "poll") => {
319 if (fallbackDue({ live: latest.current.live, visible: visibleNow(), now: Date.now(), lastRunAt: state.current.lastRunAt ?? null, reason })) queue(true);
320 };
321 const back = () => {
322 if (!visibleNow()) return;
323 if (state.current.pendingSince != null) check();
324 fallback("focus");
325 };
326 // Starts counting from the page's own load.
327 if (state.current.lastRunAt == null) state.current = { ...state.current, lastRunAt: Date.now() };
328 const poll = setInterval(() => fallback("poll"), FALLBACK_FOCUS_MS / 2);
329 document.addEventListener("visibilitychange", back);
330 window.addEventListener("focus", back);
331 // Someone stopped typing, or closed a dialog: what waited goes.
332 const settled = () => {
333 if (state.current.pendingSince != null) setTimeout(check, 0);
334 };
335 document.addEventListener("focusout", settled);
336 return () => {
337 clearInterval(poll);
338 document.removeEventListener("visibilitychange", back);
339 window.removeEventListener("focus", back);
340 document.removeEventListener("focusout", settled);
341 if (timer.current) clearTimeout(timer.current);
342 timer.current = null;
343 };
344 }, [enabled, check, queue]);
345
346 // A reader who was at the bottom follows what was added there.
347 useEffect(() => {
348 if (revalidator.state !== "idle" || !follow.current) return;
349 follow.current = false;
350 requestAnimationFrame(() => window.scrollTo({ top: document.documentElement.scrollHeight, behavior: "smooth" }));
351 }, [revalidator.state]);
352
353 const show = useCallback(() => {
354 setHeld([]);
355 run();
356 }, [run]);
357
358 const nudge = useCallback(() => {
359 if (enabled && heldCount.current === 0) queue();
360 }, [enabled, queue]);
361
362 return { live, held, show, nudge };
363}