Skip to content
753 linesCodeBlameRaw
1import { ExternalLink, RefreshCw, Trash2, TriangleAlert } from "lucide-react";
2import { type ReactNode, useEffect, useId, useState } from "react";
3import { Form, Link } from "react-router";
4
5import type { HandbackPlan, MirrorView, RefDecision, Remote, RepoMirror } from "@g1t/contracts";
6
7import { DangerAction, DangerZone } from "./danger-zone";
8import { MirrorNotes, TakeOverDialog } from "./mirror";
9import { ConfirmDialog } from "./repo-lifecycle";
10import { SettingsSection as Section, SettingToggle } from "./settings-section";
11import { ButtonLink, ErrorText, Field, Input, SubmitButton, TimeAgo } from "./ui";
12import { Badge, type BadgeTone } from "./ui/badge";
13import { Button } from "./ui/button";
14import { Card } from "./ui/card";
15import { Checkbox, CheckboxOption } from "./ui/checkbox";
16import { Hint } from "./ui/hint";
17import { RadioGroup, RadioOption } from "./ui/radio-group";
18import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "./ui/select";
19import {
20 MIRRORING_DOCS,
21 TAKE_OVER_MINUTES,
22 decisionChoices,
23 decisionField,
24 handBackReady,
25 refActionWords,
26 remoteStateWords,
27 shortSha,
28} from "../lib/mirror";
29
30/** What a form on the mirroring page came back with. */
31export type MirrorOutcome = {
32 intent: string;
33 ok: boolean;
34 message: string | null;
35 error: string | null;
36 /** What it did that people should know: pull requests it opened, and so on. */
37 notes?: string[];
38};
39
40const STATE_TONES: Record<string, BadgeTone> = {
41 standby: "neutral",
42 ci: "info",
43 takeover: "warn",
44 handing_back: "warn",
45 following: "success",
46 stuck: "warn",
47};
48
49const PROVIDERS: Record<Remote["provider"], string> = {
50 github: "GitHub",
51 g1t: "Another g1t",
52 git: "A git host",
53};
54
55/** Who put it in this state, in words. */
56function byWhom(by: string | null): string {
57 if (!by) return "";
58 return by === "g1t" ? ", on its own" : `, by ${by}`;
59}
60
61/** A green dot when the remote answers, amber when it does not. */
62function ReachDot({ reachable }: { reachable: boolean }) {
63 return (
64 <span
65 aria-hidden="true"
66 className={`inline-block size-2 shrink-0 rounded-full ${reachable ? "bg-success" : "bg-warn shadow-[0_0_0_3px_color-mix(in_srgb,var(--color-warn)_20%,transparent)]"}`}
67 />
68 );
69}
70
71function Fact({ label, children }: { label: string; children: ReactNode }) {
72 return (
73 <div className="min-w-0">
74 <dt className="text-xs text-faint">{label}</dt>
75 <dd className="mt-0.5 text-sm text-fg">{children}</dd>
76 </div>
77 );
78}
79
80/** A result line under the form that sent it. */
81function Result({ result }: { result: MirrorOutcome | undefined }) {
82 if (!result) return null;
83 if (!result.ok) return <ErrorText>{result.error}</ErrorText>;
84 return (
85 <>
86 {result.message && (
87 <p className="text-sm text-success" role="status">
88 {result.message}
89 </p>
90 )}
91 <MirrorNotes notes={result.notes} />
92 </>
93 );
94}
95
96const DOCS_LINK = (
97 <a href={MIRRORING_DOCS} className="text-fg underline-offset-2 hover:underline">
98 How mirroring works
99 </a>
100);
101
102/**
103 * The page under Settings → Mirroring: for a mirror, the remote that leads,
104 * what can be done about it and how it behaves; for a repository that
105 * leads, the remotes that follow it and adding one.
106 */
107export function MirroringSettings({
108 base,
109 full,
110 mirror,
111 view,
112 planRequested,
113 planError,
114 result,
115}: {
116 base: string;
117 full: string;
118 mirror: RepoMirror | null;
119 view: MirrorView;
120 /** Whether the hand-back was asked to be reviewed (the plan is then filled, or `planError` says why not). */
121 planRequested: boolean;
122 planError: string | null;
123 result: MirrorOutcome | undefined;
124}) {
125 const leader = view.remotes.find((remote) => remote.role === "leader") ?? null;
126 const followers = view.remotes.filter((remote) => remote.role === "follower");
127 const resultFor = (...intents: string[]) => (result && intents.includes(result.intent) ? result : undefined);
128 if (leader && mirror) {
129 return (
130 <div className="max-w-4xl space-y-10">
131 <Section
132 title="Where work happens"
133 about={
134 <>
135 {mirror.state === "takeover"
136 ? `g1t has taken over from ${leader.name}, which leads otherwise. Work here as usual, then hand it back.`
137 : `${full} follows ${leader.name}, which leads. While it stands by it is an exact, read-only copy that runs nothing. Take over whenever you need to work here.`}{" "}
138 {DOCS_LINK}.
139 </>
140 }
141 >
142 <LeaderCard base={base} full={full} remote={leader} mirror={mirror} canManage={view.canManage} result={resultFor("take-over", "ci-on", "ci-off", "sync")} />
143 </Section>
144
145 {(mirror.state === "takeover" || mirror.state === "handing_back") && (
146 <Section
147 id="hand-back"
148 title="Hand back"
149 about={`When you're done, everything g1t did goes back to ${leader.name}, branch by branch, and ${full} follows it again.`}
150 >
151 {mirror.state === "handing_back" ? (
152 <Card asChild className="p-4 text-sm text-muted">
153 <p>
154 Handing back to {leader.name} now. {full} is read-only until every branch is across; this page shows it
155 standing by again once it is.
156 </p>
157 </Card>
158 ) : view.plan ? (
159 <HandBackForm full={full} remote={leader.name} plan={view.plan} canManage={view.canManage} result={resultFor("hand-back")} />
160 ) : (
161 <Card className="space-y-3 p-4">
162 <p className="text-sm text-muted">
163 g1t compares each branch with {leader.name} and shows what will happen to it before anything goes back.
164 </p>
165 {planRequested && planError && <ErrorText>{planError}</ErrorText>}
166 <ButtonLink to="?plan=1#hand-back" preventScrollReset>
167 Review hand-back
168 </ButtonLink>
169 </Card>
170 )}
171 </Section>
172 )}
173
174 <LeaderSettings remote={leader} full={full} canManage={view.canManage} result={resultFor("settings")} />
175
176 {mirror.state !== "handing_back" && view.canManage && (
177 <div className="border-t border-line pt-8">
178 <DangerZone>
179 <MoveToG1t full={full} remote={leader.name} error={resultFor("move-in")?.error ?? null} />
180 </DangerZone>
181 </div>
182 )}
183 </div>
184 );
185 }
186 return (
187 <div className="max-w-4xl space-y-10">
188 <Section
189 title="Mirrored to"
190 about={
191 <>
192 Remotes that follow {full}. g1t leads, and every push here goes to each of them. {DOCS_LINK}.
193 </>
194 }
195 >
196 <Result result={resultFor("sync", "remove", "settings")} />
197 {followers.length === 0 ? (
198 <Card asChild tone="plain" className="border-dashed px-4 py-6 text-center text-sm text-muted">
199 <p>
200 Not mirrored anywhere yet.
201 </p>
202 </Card>
203 ) : (
204 <>
205 <Card asChild tone="plain" divided>
206 <ul>
207 {followers.map((remote) => (
208 <FollowerRow key={remote.id} remote={remote} canManage={view.canManage} />
209 ))}
210 </ul>
211 </Card>
212 {view.canManage && (
213 <Form method="post" className="flex flex-wrap items-center gap-3">
214 <SubmitButton variant="outline" name="intent" value="sync" pending="Syncing…">
215 <RefreshCw size={14} />
216 Sync now
217 </SubmitButton>
218 <span className="text-xs text-muted">Pushes g1t's branches to every remote here now, over any that moved.</span>
219 </Form>
220 )}
221 </>
222 )}
223 </Section>
224
225 {view.canManage && (
226 <Section
227 title="Add a remote"
228 about={
229 <>
230 Another g1t, or any host that speaks git over HTTPS. For GitHub, use{" "}
231 <Link to="/new/github" className="text-fg underline-offset-2 hover:underline">
232 New → Import from GitHub
233 </Link>
234 .
235 </>
236 }
237 >
238 <AddRemote result={resultFor("add")} />
239 </Section>
240 )}
241 </div>
242 );
243}
244
245/** The remote a mirror follows: how it stands, and what can be done now. */
246function LeaderCard({
247 base,
248 full,
249 remote,
250 mirror,
251 canManage,
252 result,
253}: {
254 base: string;
255 full: string;
256 remote: Remote;
257 mirror: RepoMirror;
258 canManage: boolean;
259 result: MirrorOutcome | undefined;
260}) {
261 const state = mirror.state;
262 const silent = !remote.reachable;
263 return (
264 <div className="space-y-3">
265 <Card>
266 <div className="flex flex-wrap items-center gap-x-3 gap-y-1.5 border-b border-line px-4 py-3">
267 <ReachDot reachable={remote.reachable} />
268 <a
269 href={remote.url}
270 target="_blank"
271 rel="noreferrer"
272 className="inline-flex min-w-0 items-center gap-1.5 font-mono text-sm font-medium break-all hover:text-accent"
273 >
274 {remote.name}
275 <ExternalLink size={12} className="shrink-0 text-faint" />
276 </a>
277 <Badge tone={STATE_TONES[state]}>{remoteStateWords(state)}</Badge>
278 <span className="ml-auto text-xs text-faint">{PROVIDERS[remote.provider]}</span>
279 </div>
280 <dl className="grid gap-x-6 gap-y-3 px-4 py-3 sm:grid-cols-3">
281 <Fact label="State">
282 {remoteStateWords(state)}{" "}
283 <span className="text-muted">
284 <TimeAgo at={mirror.since} />
285 {byWhom(remote.stateBy)}
286 </span>
287 </Fact>
288 <Fact label="Remote">
289 {silent ? (
290 <span className="text-warn">
291 Not answering
292 {remote.unreachableSince && (
293 <span className="text-muted">
294 {" "}
295 for <Since at={remote.unreachableSince} />
296 </span>
297 )}
298 </span>
299 ) : (
300 "Answering"
301 )}
302 </Fact>
303 <Fact label="Last synced">{remote.syncedAt ? <TimeAgo at={remote.syncedAt} /> : <span className="text-muted">Not yet</span>}</Fact>
304 </dl>
305 {remote.lastError && (
306 <p className="mx-4 mb-3 flex items-start gap-2 rounded-lg border border-warn/40 bg-warn/5 px-3 py-2 text-xs text-fg-soft">
307 <TriangleAlert size={13} className="mt-px shrink-0 text-warn" aria-hidden="true" />
308 <span className="min-w-0 break-words">{remote.lastError}</span>
309 </p>
310 )}
311 {remote.checksProblem && remote.settings.reportChecks && (
312 <p className="mx-4 mb-3 flex items-start gap-2 rounded-lg border border-warn/40 bg-warn/5 px-3 py-2 text-xs text-fg-soft">
313 <TriangleAlert size={13} className="mt-px shrink-0 text-warn" aria-hidden="true" />
314 <span className="min-w-0 break-words">{remote.checksProblem}</span>
315 </p>
316 )}
317 {canManage && state !== "handing_back" && (
318 <div className="flex flex-wrap items-center gap-2 border-t border-line px-4 py-3">
319 {(state === "standby" || state === "ci") && (
320 <TakeOverDialog
321 base={base}
322 full={full}
323 mirror={mirror}
324 error={result?.intent === "take-over" ? result.error : null}
325 trigger={(open) => (
326 <Button type="button" variant={silent ? "default" : "outline"} onClick={open}>
327 Take over
328 </Button>
329 )}
330 />
331 )}
332 {(state === "standby" || state === "ci") && (
333 <Form method="post" className="contents">
334 {state === "standby" ? (
335 <SubmitButton variant="outline" name="intent" value="ci-on" pending="Starting…">
336 Start CI failover
337 </SubmitButton>
338 ) : (
339 <SubmitButton variant="outline" name="intent" value="ci-off" pending="Ending…">
340 End CI failover
341 </SubmitButton>
342 )}
343 {state === "standby" && (
344 <SubmitButton variant="outline" name="intent" value="sync" pending="Syncing…">
345 <RefreshCw size={14} />
346 Sync now
347 </SubmitButton>
348 )}
349 </Form>
350 )}
351 {state === "takeover" && (
352 <ButtonLink to="?plan=1#hand-back" preventScrollReset>
353 Review hand-back
354 </ButtonLink>
355 )}
356 </div>
357 )}
358 </Card>
359 <p className="text-xs text-muted">
360 {state === "standby"
361 ? `CI failover keeps the code on ${remote.name} and runs its workflows here, results going back. Taking over makes g1t lead until you hand it back.`
362 : state === "ci"
363 ? `${remote.name} keeps the code; g1t runs its workflows. ${full} stays read-only for code, issues and pull requests.`
364 : state === "takeover"
365 ? `Everything works here. Nothing goes back to ${remote.name} until you hand it back.`
366 : `Read-only until every branch is back on ${remote.name}.`}
367 </p>
368 {/* A refused takeover says why in its dialog, which opens again. */}
369 {(result?.ok || result?.intent !== "take-over") && <Result result={result} />}
370 </div>
371 );
372}
373
374/** How long since: "5 minutes", "3 hours". */
375function Since({ at }: { at: string }) {
376 const minutes = Math.max(1, Math.round((Date.now() - new Date(at).getTime()) / 60_000));
377 const words =
378 minutes < 60
379 ? `${minutes} minute${minutes === 1 ? "" : "s"}`
380 : minutes < 48 * 60
381 ? `${Math.round(minutes / 60)} hour${Math.round(minutes / 60) === 1 ? "" : "s"}`
382 : `${Math.round(minutes / 1440)} days`;
383 return (
384 <time dateTime={new Date(at).toISOString()} suppressHydrationWarning>
385 {words}
386 </time>
387 );
388}
389
390/** Each branch, what happens to it, and a choice for those both sides moved. */
391function HandBackForm({
392 full,
393 remote,
394 plan,
395 canManage,
396 result,
397}: {
398 full: string;
399 remote: string;
400 plan: HandbackPlan;
401 canManage: boolean;
402 result: MirrorOutcome | undefined;
403}) {
404 const [chosen, setChosen] = useState<Record<string, RefDecision | undefined>>({});
405 const moving = plan.refs.filter((ref) => ref.action !== "same");
406 const same = plan.refs.length - moving.length;
407 const ready = handBackReady(plan, chosen);
408 const choices = decisionChoices(remote);
409 return (
410 <Form method="post" preventScrollReset className="space-y-4">
411 <input type="hidden" name="intent" value="hand-back" />
412 {!plan.reachable && (
413 <p className="flex items-start gap-2 rounded-lg border border-warn/40 bg-warn/5 px-3.5 py-2.5 text-sm text-fg-soft">
414 <TriangleAlert size={15} className="mt-0.5 shrink-0 text-warn" aria-hidden="true" />
415 {remote} isn't answering yet; hand back once it is.
416 </p>
417 )}
418 {moving.length === 0 ? (
419 <Card asChild tone="plain" className="border-dashed px-4 py-6 text-center text-sm text-muted">
420 <p>
421 Every branch is already the same on both sides. Handing back only makes {remote} lead again.
422 </p>
423 </Card>
424 ) : (
425 <Card tone="plain" className="overflow-hidden">
426 <div className="hidden grid-cols-[minmax(0,1fr)_5.5rem_5.5rem_minmax(0,1.5fr)] gap-4 border-b border-line bg-surface px-4 py-2 text-xs font-medium text-muted sm:grid">
427 <span>Branch</span>
428 <span>g1t</span>
429 <span className="truncate">{remote.split("/")[0]}</span>
430 <span>What happens</span>
431 </div>
432 <ul className="divide-y divide-line">
433 {moving.map((ref) => (
434 <li
435 key={ref.ref}
436 className="grid gap-x-4 gap-y-1.5 px-4 py-3 text-sm sm:grid-cols-[minmax(0,1fr)_5.5rem_5.5rem_minmax(0,1.5fr)] sm:items-start"
437 >
438 <span className="min-w-0 truncate font-mono font-medium">{ref.ref}</span>
439 <div className="flex gap-4 sm:contents">
440 <span className="font-mono text-xs text-muted sm:pt-0.5">
441 <span className="text-faint sm:hidden">g1t </span>
442 {shortSha(ref.ours)}
443 </span>
444 <span className="font-mono text-xs text-muted sm:pt-0.5">
445 <span className="text-faint sm:hidden">{remote.split("/")[0]} </span>
446 {shortSha(ref.theirs)}
447 </span>
448 </div>
449 {ref.action === "diverged" ? (
450 <div className="space-y-1.5">
451 <p className="flex items-center gap-1.5 text-warn">
452 <TriangleAlert size={13} aria-hidden="true" />
453 Both moved: choose
454 </p>
455 <RadioGroup
456 name={decisionField(ref.ref)}
457 value={chosen[ref.ref] ?? ref.decision ?? undefined}
458 onValueChange={(value) => setChosen((now) => ({ ...now, [ref.ref]: value as RefDecision }))}
459 disabled={!canManage}
460 aria-label={`What happens to ${ref.ref}`}
461 className="gap-1.5"
462 >
463 {choices.map((choice) => (
464 <RadioOption key={choice.value} value={choice.value} label={choice.label} />
465 ))}
466 </RadioGroup>
467 </div>
468 ) : (
469 <span className={ref.action === "pull_request" ? "text-info" : "text-fg-soft"}>
470 {refActionWords(ref.action, remote)}
471 {ref.action === "pull_request" && (
472 <span className="mt-0.5 block font-mono text-xs text-muted">g1t/handback/{ref.ref}</span>
473 )}
474 </span>
475 )}
476 </li>
477 ))}
478 </ul>
479 </Card>
480 )}
481 {same > 0 && moving.length > 0 && (
482 <p className="text-xs text-muted">
483 {same} other branch{same === 1 ? " is" : "es are"} already the same on both sides.
484 </p>
485 )}
486 <Result result={result} />
487 {canManage && (
488 <div className="flex flex-wrap items-center gap-3">
489 <SubmitButton variant="accent" disabled={!ready} pending="Handing back…">
490 Hand back
491 </SubmitButton>
492 <span className="text-xs text-muted">
493 {ready
494 ? `${remote} leads again once every branch is across. Until then ${full} is read-only.`
495 : plan.reachable
496 ? "Choose what happens to each branch both sides moved."
497 : `Waiting for ${remote} to answer.`}
498 </span>
499 </div>
500 )}
501 </Form>
502 );
503}
504
505/** How a mirror behaves when its remote stops answering, and what runs in CI failover and takeovers. */
506function LeaderSettings({
507 remote,
508 full,
509 canManage,
510 result,
511}: {
512 remote: Remote;
513 full: string;
514 canManage: boolean;
515 result: MirrorOutcome | undefined;
516}) {
517 const settings = remote.settings;
518 const [auto, setAuto] = useState(settings.takeOverAfter != null);
519 const id = useId();
520 const [least, most] = TAKE_OVER_MINUTES;
521 return (
522 <Form method="post" className="space-y-10">
523 <input type="hidden" name="intent" value="settings" />
524 <input type="hidden" name="kind" value="leader" />
525 <input type="hidden" name="provider" value={remote.provider} />
526 <input type="hidden" name="remoteId" value={remote.id} />
527 <fieldset disabled={!canManage} className="space-y-10">
528 <Section
529 title={`When ${remote.name} stops answering`}
530 about="g1t checks every minute. Unless you choose otherwise, it only says so here: nobody is paged, and nothing happens on its own."
531 >
532 <RadioGroup name="notify" defaultValue={settings.notify} className="gap-3">
533 <RadioOption value="banner" label="Show it on the repository" description={`A line across ${full}'s pages, and an amber dot beside its name.`} />
534 <RadioOption value="inbox" label="Also tell workspace owners in their notifications" description="Once when it stops answering, and once when it answers again." />
535 </RadioGroup>
536 <Card className="p-4">
537 <div className="flex flex-wrap items-center gap-x-2.5 gap-y-2">
538 <Checkbox id={`${id}-auto`} name="autoTakeOver" checked={auto} onCheckedChange={(checked) => setAuto(checked === true)} />
539 <label htmlFor={`${id}-auto`} className="cursor-pointer text-sm font-medium">
540 Take over automatically after
541 </label>
542 <span className="flex items-center gap-2">
543 <span className="w-20">
544 <Input
545 type="number"
546 name="takeOverAfter"
547 aria-label="Minutes"
548 min={least}
549 max={most}
550 step={1}
551 defaultValue={settings.takeOverAfter ?? 30}
552 disabled={!auto}
553 />
554 </span>
555 <span className="text-sm text-muted">minutes</span>
556 </span>
557 </div>
558 <p className="mt-1.5 pl-6.5 text-xs text-faint">
559 Off unless you turn it on: people take over when they want. From {least} minutes to a day.
560 </p>
561 </Card>
562 <SettingToggle name="handBackWhenClean" on={settings.handBack === "when_clean"} title="Hand back on its own when every branch goes back cleanly">
563 Only after an automatic takeover. A branch that moved on both sides always waits for someone to choose.
564 </SettingToggle>
565 </Section>
566
567 <Section title="Workflows" about="What runs on g1t during CI failover and takeovers. A mirror standing by runs nothing unless CI is kept warm.">
568 <SettingToggle name="keepCiWarm" on={settings.keepCiWarm} title="Keep CI warm">
569 Run .g1t/workflows on every push copied in from {remote.name}, so CI is ready the moment you need it.
570 </SettingToggle>
571 <SettingToggle name="githubWorkflows" on={settings.githubWorkflows} title={`Run ${remote.name}'s .github workflows`}>
572 In CI failover and takeovers, workflows in .github/workflows run on g1t too, beside .g1t/workflows.
573 </SettingToggle>
574 <SettingToggle name="holdDeploys" on={settings.holdDeploys} title="Hold workflows that deploy for approval">
575 A job that deploys waits for someone to approve it, so taking over never ships anything by surprise.
576 </SettingToggle>
577 {remote.provider === "github" && (
578 <SettingToggle name="reportChecks" on={settings.reportChecks} title="Report checks to GitHub">
579 While g1t runs workflows here, each run and deploy shows on its commit on {remote.name} as a status such as g1t / CI /
580 push, linking back to g1t. Needs the GitHub App's Commit statuses permission.
581 </SettingToggle>
582 )}
583 </Section>
584 </fieldset>
585 {canManage && (
586 <div className="sticky bottom-0 -mx-4 flex flex-wrap items-center gap-4 border-t border-line bg-bg/90 px-4 py-4 backdrop-blur">
587 <SubmitButton pending="Saving…">Save settings</SubmitButton>
588 {result?.ok && <span className="text-sm text-muted">Saved.</span>}
589 <ErrorText>{result && !result.ok ? result.error : null}</ErrorText>
590 </div>
591 )}
592 </Form>
593 );
594}
595
596/** Making g1t the leader for good, said plainly, with the remote kept as a follower if they like. */
597function MoveToG1t({ full, remote, error }: { full: string; remote: string; error: string | null }) {
598 return (
599 <ConfirmDialog
600 intent="move-in"
601 title={`Move ${full} to g1t?`}
602 description={`g1t will no longer track ${remote}. Pushes made there won't come here.`}
603 confirm={full}
604 submit="Move to g1t"
605 busy="Moving…"
606 error={error}
607 trigger={(open) => (
608 <DangerAction
609 title="Move to g1t"
610 action={
611 <Button type="button" variant="destructive" onClick={open}>
612 Move to g1t
613 </Button>
614 }
615 >
616 Make g1t lead {full} for good. It stops being a mirror, and g1t stops tracking {remote}.
617 </DangerAction>
618 )}
619 extra={
620 <CheckboxOption
621 name="keepRemoteUpdated"
622 label={`Keep ${remote} updated from g1t`}
623 description={`${remote} follows instead: every push here is pushed there. Off, g1t leaves it as it is.`}
624 />
625 }
626 >
627 <li>{full} stops being a mirror: pushes, pull requests, issues, agents and workflows work here from now on.</li>
628 <li>This is for good. There is nothing to hand back afterwards.</li>
629 </ConfirmDialog>
630 );
631}
632
633/** One remote that follows: how it stands, what happens to pushes made on it, and removing it. */
634function FollowerRow({ remote, canManage }: { remote: Remote; canManage: boolean }) {
635 const stuck = remote.state === "stuck";
636 return (
637 <li className="space-y-2 px-4 py-3">
638 <div className="flex flex-wrap items-center gap-x-2.5 gap-y-1">
639 <ReachDot reachable={remote.reachable} />
640 <a href={remote.url} target="_blank" rel="noreferrer" className="min-w-0 font-mono text-sm font-medium break-all hover:text-accent">
641 {remote.name}
642 </a>
643 <Hint label={stuck ? "It refused a push or moved on its own. Sync now pushes g1t's branches over it." : null}>
644 <Badge tone={STATE_TONES[remote.state] ?? "neutral"} tabIndex={stuck ? 0 : undefined}>
645 {remoteStateWords(remote.state)}
646 </Badge>
647 </Hint>
648 {canManage && (
649 <span className="ml-auto">
650 <ConfirmDialog
651 intent="remove"
652 fields={{ remoteId: remote.id }}
653 title={`Stop mirroring to ${remote.name}?`}
654 description={`${remote.name} keeps what it has; g1t stops pushing to it. Its token is deleted.`}
655 submit="Remove"
656 busy="Removing…"
657 trigger={(open) => (
658 <Button type="button" variant="outline" onClick={open} aria-label={`Remove ${remote.name}`}>
659 <Trash2 size={14} />
660 <span className="hidden sm:inline">Remove</span>
661 </Button>
662 )}
663 />
664 </span>
665 )}
666 </div>
667 <p className="text-xs text-faint">
668 {PROVIDERS[remote.provider]} · {remote.syncedAt ? <>Synced <TimeAgo at={remote.syncedAt} /></> : "Not synced yet"}
669 {!remote.reachable && <span className="text-warn"> · Not answering</span>}
670 </p>
671 {remote.lastError && (
672 <p className="flex items-start gap-2 rounded-lg border border-warn/40 bg-warn/5 px-3 py-2 text-xs text-fg-soft">
673 <TriangleAlert size={13} className="mt-px shrink-0 text-warn" aria-hidden="true" />
674 <span className="min-w-0 break-words">{remote.lastError}</span>
675 </p>
676 )}
677 {canManage && (
678 <Form method="post" className="flex flex-wrap items-center gap-2 text-sm">
679 <input type="hidden" name="intent" value="settings" />
680 <input type="hidden" name="kind" value="follower" />
681 <input type="hidden" name="remoteId" value={remote.id} />
682 <span className="text-muted">Pushes made on {remote.name}:</span>
683 <Select name="remotePushes" defaultValue={remote.settings.remotePushes}>
684 <SelectTrigger size="sm" aria-label={`Pushes made on ${remote.name}`} className="w-auto min-w-44">
685 <SelectValue />
686 </SelectTrigger>
687 <SelectContent>
688 <SelectItem value="adopt">Take in fast-forwards</SelectItem>
689 <SelectItem value="overwrite">Overwrite with g1t's</SelectItem>
690 </SelectContent>
691 </Select>
692 <SubmitButton
693 variant="outline"
694 match={{ intent: "settings", remoteId: remote.id }}
695 pending="Saving…"
696 className="inline-flex items-center gap-1 text-xs text-muted hover:text-fg disabled:opacity-50"
697 >
698 Save
699 </SubmitButton>
700 </Form>
701 )}
702 </li>
703 );
704}
705
706/** A remote to add: where, which one leads, and how to sign in to it. */
707function AddRemote({ result }: { result: MirrorOutcome | undefined }) {
708 const [round, setRound] = useState(0);
709 // Cleared once added, ready for the next.
710 useEffect(() => {
711 if (result?.ok) setRound((n) => n + 1);
712 }, [result]);
713 return (
714 <Card asChild key={round} className="space-y-5 p-4">
715 <Form method="post">
716 <input type="hidden" name="intent" value="add" />
717 <fieldset className="space-y-2">
718 <legend className="mb-1.5 text-sm font-medium text-muted">Provider</legend>
719 <RadioGroup name="provider" defaultValue="git" className="gap-2.5 sm:grid-cols-2">
720 <RadioOption value="g1t" label="Another g1t" description="A repository on another g1t, such as one you host yourself." />
721 <RadioOption value="git" label="Any git host" description="Anywhere that takes git over HTTPS." />
722 </RadioGroup>
723 </fieldset>
724 <fieldset className="space-y-2">
725 <legend className="mb-1.5 text-sm font-medium text-muted">Which one leads</legend>
726 <RadioGroup name="role" defaultValue="follower" className="gap-2.5">
727 <RadioOption value="follower" label="g1t leads; the remote follows" description="Every push here is pushed there." />
728 <RadioOption
729 value="leader"
730 label="The remote leads; this repository mirrors it"
731 description="Only for an empty repository: g1t copies the remote, and this repository becomes a read-only mirror of it."
732 />
733 </RadioGroup>
734 </fieldset>
735 <Field label="HTTPS URL">
736 <Input name="url" type="url" required inputMode="url" spellCheck={false} placeholder="https://git.example.com/acme/web.git" />
737 </Field>
738 <div className="grid gap-4 sm:grid-cols-2">
739 <Field label="Username (optional)">
740 <Input name="username" spellCheck={false} autoCapitalize="off" />
741 </Field>
742 <Field label="Token" hint="Stored encrypted; never shown again.">
743 <Input name="token" type="password" spellCheck={false} />
744 </Field>
745 </div>
746 <Result result={result} />
747 <SubmitButton pending="Adding…" match={{ intent: "add" }}>
748 Add remote
749 </SubmitButton>
750 </Form>
751 </Card>
752 );
753}