Skip to content
439 linesCodeBlameRaw
1import { CloudOff, Flag, Undo2, Workflow } from "lucide-react";
2import type { ReactNode } from "react";
3import { Form, Link, useRouteLoaderData } from "react-router";
4
5import type { RepoMirror } from "@g1t/contracts";
6
7import { ConfirmDialog } from "./repo-lifecycle";
8import { ButtonLink, EmptyState, SubmitButton, TimeAgo } from "./ui";
9import { Badge } from "./ui/badge";
10import { Button } from "./ui/button";
11import { Hint } from "./ui/hint";
12import {
13 MIRRORING_DOCS,
14 type MirrorBrief,
15 type MirroredRepo,
16 followersOf,
17 leaderOf,
18 mirrorBadge,
19 mirrorBanner,
20 mirrorReason,
21 mirroringSettings,
22 workflowReason,
23} from "../lib/mirror";
24
25/**
26 * The repository the page is in, its remotes in brief and whether the
27 * viewer manages them, from the repository layout's loader. Empty outside
28 * a repository.
29 */
30export function useRepoMirror(): { repo: MirroredRepo | null; briefs: MirrorBrief[]; admin: boolean } {
31 const layout = useRouteLoaderData("routes/repo/layout") as
32 | { repo?: MirroredRepo; mirrorBriefs?: MirrorBrief[]; access?: { can?: { manage_integrations?: boolean } } }
33 | undefined;
34 return {
35 repo: layout?.repo ?? null,
36 briefs: layout?.mirrorBriefs ?? [],
37 admin: Boolean(layout?.access?.can?.manage_integrations),
38 };
39}
40
41/**
42 * Why pushing, merging, opening issues and pull requests and assigning
43 * agents are off in the repository the page is in; null when they are not
44 * off because it is a mirror.
45 */
46export function useMirrorReason(): string | null {
47 return mirrorReason(useRepoMirror().repo);
48}
49
50/** Why running workflows is off: a mirror standing by or handing back runs nothing. */
51export function useWorkflowReason(): string | null {
52 return workflowReason(useRepoMirror().repo);
53}
54
55/** What the badge means, said on hover. */
56function badgeHint(mirror: RepoMirror | null | undefined, briefs: readonly MirrorBrief[]): string {
57 if (mirror) {
58 switch (mirror.state) {
59 case "standby":
60 return `A read-only copy that follows ${mirror.remote}. Work happens there; clone and fetch freely here.`;
61 case "ci":
62 return `${mirror.remote} keeps the code; g1t runs its workflows and sends the results back.`;
63 case "takeover":
64 return `g1t leads for now: everything works here until it is handed back to ${mirror.remote}.`;
65 case "handing_back":
66 return `Going back to ${mirror.remote} branch by branch. Read-only until it is done.`;
67 }
68 }
69 const names = followersOf(briefs).map((brief) => brief.name);
70 return `g1t leads. Every push here goes to ${names.join(", ")}.`;
71}
72
73/**
74 * Beside the repository's name: what it is to its remotes. An amber dot
75 * when the remote a mirror follows is not answering.
76 */
77export function MirrorBadge({ mirror, briefs }: { mirror: RepoMirror | null | undefined; briefs: readonly MirrorBrief[] }) {
78 const badge = mirrorBadge(mirror, briefs);
79 if (!badge) return null;
80 const silent = Boolean(mirror) && leaderOf(briefs)?.reachable === false;
81 return (
82 <Hint label={badgeHint(mirror, briefs)}>
83 <Badge tone={badge.tone} tabIndex={0} className="min-w-0 max-w-full px-2 py-0.5 text-xs font-normal">
84 {silent && <span aria-label="Not answering" className="size-1.5 shrink-0 rounded-full bg-warn" />}
85 <span className="truncate">{badge.label}</span>
86 {badge.more > 0 && <span className="shrink-0 opacity-70">+{badge.more} more</span>}
87 </Badge>
88 </Hint>
89 );
90}
91
92const BANNER_TONES = {
93 warn: { box: "border-warn/40 bg-warn/5", icon: "text-warn", action: "text-warn" },
94 info: { box: "border-info/40 bg-info/5", icon: "text-info", action: "text-info" },
95} as const;
96
97/** A compact line across the top of the page, with what it is about and one action. */
98function BannerBox({ tone, icon, title, children, action, className = "" }: {
99 tone: keyof typeof BANNER_TONES;
100 className?: string;
101 icon: ReactNode;
102 title: ReactNode;
103 children?: ReactNode;
104 action?: ReactNode;
105}) {
106 const colours = BANNER_TONES[tone];
107 return (
108 <div
109 role="status"
110 className={`flex flex-col gap-2 rounded-lg border px-4 py-2.5 text-sm sm:flex-row sm:items-center sm:justify-between ${colours.box} ${className}`}
111 >
112 <p className="flex min-w-0 gap-2.5">
113 <span className={`mt-0.5 shrink-0 ${colours.icon} [&_svg]:size-4`}>{icon}</span>
114 <span className="min-w-0">
115 <span className="font-medium text-fg">{title}</span>
116 {children && <> <span className="text-muted">{children}</span></>}
117 </span>
118 </p>
119 {action && <div className={`shrink-0 pl-6.5 font-medium sm:pl-0 ${colours.action}`}>{action}</div>}
120 </div>
121 );
122}
123
124/**
125 * Taking over, from anywhere: says what it does and posts to the
126 * mirroring settings, which then show it.
127 */
128export function TakeOverDialog({
129 base,
130 full,
131 mirror,
132 error,
133 trigger,
134}: {
135 base: string;
136 full: string;
137 mirror: Pick<RepoMirror, "remote" | "holdDeploys">;
138 error?: string | null;
139 trigger: (open: () => void) => ReactNode;
140}) {
141 return (
142 <ConfirmDialog
143 intent="take-over"
144 action={mirroringSettings(base)}
145 title={`Take over ${full}?`}
146 description={`g1t leads ${full} until you hand it back. Pushes, pull requests, agents and workflows work here meanwhile.`}
147 submit="Take over"
148 busy="Taking over…"
149 danger={false}
150 error={error}
151 trigger={trigger}
152 >
153 <li>{mirror.remote} keeps what it has. Nothing goes back to it until you hand it back, branch by branch.</li>
154 {mirror.holdDeploys !== false && <li>Workflows that deploy wait for someone to approve them.</li>}
155 </ConfirmDialog>
156 );
157}
158
159/**
160 * Across the top of a mirror's pages, when there is something to know: its
161 * remote is not answering, g1t runs its CI, has taken over, or is handing
162 * back. A mirror standing by whose remote answers gets none: the badge
163 * beside its name says it.
164 */
165export function MirrorBanner({
166 base,
167 full,
168 mirror,
169 briefs,
170 admin,
171 className,
172}: {
173 base: string;
174 full: string;
175 mirror: RepoMirror | null | undefined;
176 briefs: readonly MirrorBrief[];
177 /** Whether the viewer manages its mirroring: they get its one action. */
178 admin: boolean;
179 className?: string;
180}) {
181 const kind = mirrorBanner(mirror, briefs);
182 if (!kind || !mirror) return null;
183 const settings = mirroringSettings(base);
184 switch (kind) {
185 case "unreachable": {
186 const synced = leaderOf(briefs)?.syncedAt;
187 return (
188 <BannerBox
189 className={className}
190 tone="warn"
191 icon={<CloudOff />}
192 title={`${mirror.remote} isn't answering.`}
193 action={
194 admin ? (
195 <TakeOverDialog
196 base={base}
197 full={full}
198 mirror={mirror}
199 trigger={(open) => (
200 <Button variant="link" size="inline" onClick={open} className="text-inherit">
201 Take over
202 </Button>
203 )}
204 />
205 ) : undefined
206 }
207 >
208 {synced ? (
209 <>
210 g1t has its copy as of <TimeAgo at={synced} />.
211 </>
212 ) : (
213 "g1t has its copy as of the last sync."
214 )}
215 {mirror.state === "ci" && " Its workflows keep running here."}
216 </BannerBox>
217 );
218 }
219 case "ci":
220 return (
221 <BannerBox
222 className={className}
223 tone="info"
224 icon={<Workflow />}
225 title={`Running ${mirror.remote}'s workflows on g1t.`}
226 action={
227 admin ? (
228 <Form method="post" action={settings}>
229 <SubmitButton name="intent" value="ci-off" pending="Ending…" variant="link" size="inline" className="text-inherit">
230 End CI failover
231 </SubmitButton>
232 </Form>
233 ) : undefined
234 }
235 >
236 CI failover started <TimeAgo at={mirror.since} />.
237 {mirror.holdDeploys !== false && " Deploying workflows wait for approval."}
238 </BannerBox>
239 );
240 case "takeover":
241 return (
242 <BannerBox
243 className={className}
244 tone="warn"
245 icon={<Flag />}
246 title={`g1t is leading ${full} for now.`}
247 action={
248 admin ? (
249 <ButtonLink to={`${settings}?plan=1#hand-back`} variant="link" size="inline" className="text-inherit">
250 Hand back…
251 </ButtonLink>
252 ) : undefined
253 }
254 >
255 Everything works here; when you're done, hand it back to {mirror.remote}.
256 </BannerBox>
257 );
258 case "handing_back":
259 return (
260 <BannerBox
261 className={className}
262 tone="warn"
263 icon={<Undo2 />}
264 title={`Handing back to ${mirror.remote}…`}
265 action={
266 admin ? (
267 <ButtonLink to={settings} variant="link" size="inline" className="text-inherit">
268 Progress
269 </ButtonLink>
270 ) : undefined
271 }
272 >
273 {full} is read-only until it's done.
274 </BannerBox>
275 );
276 }
277}
278
279/**
280 * Under the clone address: what pushing here does when the repository
281 * mirrors a remote. Null when it leads.
282 */
283export function MirrorCloneNote({ mirror, base, admin }: { mirror: RepoMirror | null | undefined; base: string; admin: boolean }) {
284 if (!mirror) return null;
285 const where = admin ? (
286 <Link to={mirroringSettings(base)} className="text-fg underline underline-offset-4">
287 Settings → Mirroring
288 </Link>
289 ) : (
290 "Settings → Mirroring"
291 );
292 return (
293 <p className="mt-2 text-xs text-muted">
294 {mirror.state === "takeover" ? (
295 <>g1t leads for now: pushes here go back to {mirror.remote} when it is handed back.</>
296 ) : mirror.state === "handing_back" ? (
297 <>Handing back to {mirror.remote}: clone and fetch freely; pushes wait until it is done.</>
298 ) : (
299 <>
300 A mirror of {mirror.remote}. Clone and fetch freely; to push, push there, or take over in {where}.
301 </>
302 )}
303 </p>
304 );
305}
306
307/**
308 * In place of a page a mirror does not offer now (lib/chrome.ts
309 * `hiddenOnMirror`: issues, pull requests, agents, workflows, deployments,
310 * a new release): what the repository is, where the work happens, and the
311 * way to take over.
312 */
313export function MirrorReadOnlyPage({ base, repo, mirror, admin, what }: {
314 base: string;
315 repo: { namespace: string; name: string };
316 mirror: RepoMirror;
317 /** Whether the viewer manages its mirroring: they get the way to its settings. */
318 admin: boolean;
319 /** What the page would list, lowercase: "issues", "pull requests". */
320 what: string;
321}) {
322 const full = `${repo.namespace}/${repo.name}`;
323 const remote = (
324 <a href={mirror.url} target="_blank" rel="noreferrer" className="font-mono text-fg underline underline-offset-4 hover:text-accent">
325 {mirror.remote}
326 </a>
327 );
328 return (
329 <EmptyState title={`This is a mirror of ${mirror.remote}`}>
330 <p className="mx-auto max-w-xl">
331 {mirror.state === "handing_back" ? (
332 <>
333 {full} is handing back to {remote}, so there are no {what} here until that is done.
334 </>
335 ) : (
336 <>
337 {full} is a read-only copy until someone takes over, so there are no {what} here. Its {what} are on {remote}.
338 </>
339 )}
340 </p>
341 <div className="mt-4 flex flex-wrap items-center justify-center gap-2">
342 {admin && (
343 <ButtonLink to={mirroringSettings(base)} variant="outline" size="sm">
344 Mirroring settings
345 </ButtonLink>
346 )}
347 <ButtonLink to={MIRRORING_DOCS} variant="ghost" size="sm" target="_blank" rel="noreferrer">
348 How mirroring works
349 </ButtonLink>
350 </div>
351 </EmptyState>
352 );
353}
354
355/**
356 * On the repository's overview, one quiet line when no banner says it: a
357 * mirror standing by, or the remotes that follow it, and when it last synced.
358 */
359export function MirrorOverviewNote({
360 base,
361 mirror,
362 briefs,
363 admin,
364 syncedAt,
365 lastError,
366}: {
367 base: string;
368 mirror: RepoMirror | null | undefined;
369 briefs: readonly MirrorBrief[];
370 admin: boolean;
371 syncedAt?: string | null;
372 lastError?: string | null;
373}) {
374 const followers = followersOf(briefs);
375 // A banner says it already.
376 if (mirror && mirrorBanner(mirror, briefs)) return null;
377 if (!mirror && followers.length === 0) return null;
378 const stuck = followers.filter((brief) => brief.state === "stuck");
379 return (
380 <div className="flex flex-wrap items-baseline gap-x-3 gap-y-1 text-sm text-muted">
381 <p className="min-w-0">
382 {mirror ? (
383 <>
384 A read-only mirror of{" "}
385 <a href={mirror.url} target="_blank" rel="noreferrer" className="font-mono break-all text-fg hover:text-accent">
386 {mirror.remote}
387 </a>
388 .
389 </>
390 ) : (
391 <>
392 Mirrored to <span className="font-mono text-fg">{followers[0]!.name}</span>
393 {followers.length > 1 && ` and ${followers.length - 1} more`}: every push here goes there too.
394 </>
395 )}
396 {syncedAt && (
397 <span className="text-faint">
398 {" "}
399 Synced <TimeAgo at={syncedAt} />.
400 </span>
401 )}
402 </p>
403 {admin && (
404 <Link to={mirroringSettings(base)} className="text-xs text-muted hover:text-fg">
405 Mirroring settings
406 </Link>
407 )}
408 {(lastError || stuck.length > 0) && (
409 <p className="w-full text-xs text-warn">
410 {lastError ?? `${stuck.map((brief) => brief.name).join(", ")} stopped taking pushes. See Settings → Mirroring.`}
411 </p>
412 )}
413 </div>
414 );
415}
416
417/** What an action did that people should know, such as the pull requests it opened, with addresses as links. */
418export function MirrorNotes({ notes }: { notes: readonly string[] | null | undefined }) {
419 if (!notes || notes.length === 0) return null;
420 return (
421 <div role="status" className="rounded-lg border border-success/40 bg-success/5 px-4 py-3 text-sm">
422 <ul className="space-y-1">
423 {notes.map((note, at) => (
424 <li key={at} className="text-fg-soft">
425 {note.split(/(https?:\/\/\S+?)(?=[.,;)]*(?:\s|$))/).map((piece, index) =>
426 /^https?:\/\//.test(piece) ? (
427 <a key={index} href={piece} className="break-all text-fg underline underline-offset-2 hover:text-accent">
428 {piece}
429 </a>
430 ) : (
431 piece
432 ),
433 )}
434 </li>
435 ))}
436 </ul>
437 </div>
438 );
439}