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