Skip to content
740 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 {canManage && state !== "handing_back" && (
312 <div className="flex flex-wrap items-center gap-2 border-t border-line px-4 py-3">
313 {(state === "standby" || state === "ci") && (
314 <TakeOverDialog
315 base={base}
316 full={full}
317 mirror={mirror}
318 error={result?.intent === "take-over" ? result.error : null}
319 trigger={(open) => (
320 <Button type="button" variant={silent ? "default" : "outline"} onClick={open}>
321 Take over
322 </Button>
323 )}
324 />
325 )}
326 {(state === "standby" || state === "ci") && (
327 <Form method="post" className="contents">
328 {state === "standby" ? (
329 <SubmitButton variant="outline" name="intent" value="ci-on" pending="Starting…">
330 Start CI failover
331 </SubmitButton>
332 ) : (
333 <SubmitButton variant="outline" name="intent" value="ci-off" pending="Ending…">
334 End CI failover
335 </SubmitButton>
336 )}
337 {state === "standby" && (
338 <SubmitButton variant="outline" name="intent" value="sync" pending="Syncing…">
339 <RefreshCw size={14} />
340 Sync now
341 </SubmitButton>
342 )}
343 </Form>
344 )}
345 {state === "takeover" && (
346 <ButtonLink to="?plan=1#hand-back" preventScrollReset>
347 Review hand-back
348 </ButtonLink>
349 )}
350 </div>
351 )}
352 </Card>
353 <p className="text-xs text-muted">
354 {state === "standby"
355 ? `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.`
356 : state === "ci"
357 ? `${remote.name} keeps the code; g1t runs its workflows. ${full} stays read-only for code, issues and pull requests.`
358 : state === "takeover"
359 ? `Everything works here. Nothing goes back to ${remote.name} until you hand it back.`
360 : `Read-only until every branch is back on ${remote.name}.`}
361 </p>
362 {/* A refused takeover says why in its dialog, which opens again. */}
363 {(result?.ok || result?.intent !== "take-over") && <Result result={result} />}
364 </div>
365 );
366}
367
368/** How long since: "5 minutes", "3 hours". */
369function Since({ at }: { at: string }) {
370 const minutes = Math.max(1, Math.round((Date.now() - new Date(at).getTime()) / 60_000));
371 const words =
372 minutes < 60
373 ? `${minutes} minute${minutes === 1 ? "" : "s"}`
374 : minutes < 48 * 60
375 ? `${Math.round(minutes / 60)} hour${Math.round(minutes / 60) === 1 ? "" : "s"}`
376 : `${Math.round(minutes / 1440)} days`;
377 return (
378 <time dateTime={new Date(at).toISOString()} suppressHydrationWarning>
379 {words}
380 </time>
381 );
382}
383
384/** Each branch, what happens to it, and a choice for those both sides moved. */
385function HandBackForm({
386 full,
387 remote,
388 plan,
389 canManage,
390 result,
391}: {
392 full: string;
393 remote: string;
394 plan: HandbackPlan;
395 canManage: boolean;
396 result: MirrorOutcome | undefined;
397}) {
398 const [chosen, setChosen] = useState<Record<string, RefDecision | undefined>>({});
399 const moving = plan.refs.filter((ref) => ref.action !== "same");
400 const same = plan.refs.length - moving.length;
401 const ready = handBackReady(plan, chosen);
402 const choices = decisionChoices(remote);
403 return (
404 <Form method="post" preventScrollReset className="space-y-4">
405 <input type="hidden" name="intent" value="hand-back" />
406 {!plan.reachable && (
407 <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">
408 <TriangleAlert size={15} className="mt-0.5 shrink-0 text-warn" aria-hidden="true" />
409 {remote} isn't answering yet; hand back once it is.
410 </p>
411 )}
412 {moving.length === 0 ? (
413 <Card asChild tone="plain" className="border-dashed px-4 py-6 text-center text-sm text-muted">
414 <p>
415 Every branch is already the same on both sides. Handing back only makes {remote} lead again.
416 </p>
417 </Card>
418 ) : (
419 <Card tone="plain" className="overflow-hidden">
420 <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">
421 <span>Branch</span>
422 <span>g1t</span>
423 <span className="truncate">{remote.split("/")[0]}</span>
424 <span>What happens</span>
425 </div>
426 <ul className="divide-y divide-line">
427 {moving.map((ref) => (
428 <li
429 key={ref.ref}
430 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"
431 >
432 <span className="min-w-0 truncate font-mono font-medium">{ref.ref}</span>
433 <div className="flex gap-4 sm:contents">
434 <span className="font-mono text-xs text-muted sm:pt-0.5">
435 <span className="text-faint sm:hidden">g1t </span>
436 {shortSha(ref.ours)}
437 </span>
438 <span className="font-mono text-xs text-muted sm:pt-0.5">
439 <span className="text-faint sm:hidden">{remote.split("/")[0]} </span>
440 {shortSha(ref.theirs)}
441 </span>
442 </div>
443 {ref.action === "diverged" ? (
444 <div className="space-y-1.5">
445 <p className="flex items-center gap-1.5 text-warn">
446 <TriangleAlert size={13} aria-hidden="true" />
447 Both moved: choose
448 </p>
449 <RadioGroup
450 name={decisionField(ref.ref)}
451 value={chosen[ref.ref] ?? ref.decision ?? undefined}
452 onValueChange={(value) => setChosen((now) => ({ ...now, [ref.ref]: value as RefDecision }))}
453 disabled={!canManage}
454 aria-label={`What happens to ${ref.ref}`}
455 className="gap-1.5"
456 >
457 {choices.map((choice) => (
458 <RadioOption key={choice.value} value={choice.value} label={choice.label} />
459 ))}
460 </RadioGroup>
461 </div>
462 ) : (
463 <span className={ref.action === "pull_request" ? "text-info" : "text-fg-soft"}>
464 {refActionWords(ref.action, remote)}
465 {ref.action === "pull_request" && (
466 <span className="mt-0.5 block font-mono text-xs text-muted">g1t/handback/{ref.ref}</span>
467 )}
468 </span>
469 )}
470 </li>
471 ))}
472 </ul>
473 </Card>
474 )}
475 {same > 0 && moving.length > 0 && (
476 <p className="text-xs text-muted">
477 {same} other branch{same === 1 ? " is" : "es are"} already the same on both sides.
478 </p>
479 )}
480 <Result result={result} />
481 {canManage && (
482 <div className="flex flex-wrap items-center gap-3">
483 <SubmitButton variant="accent" disabled={!ready} pending="Handing back…">
484 Hand back
485 </SubmitButton>
486 <span className="text-xs text-muted">
487 {ready
488 ? `${remote} leads again once every branch is across. Until then ${full} is read-only.`
489 : plan.reachable
490 ? "Choose what happens to each branch both sides moved."
491 : `Waiting for ${remote} to answer.`}
492 </span>
493 </div>
494 )}
495 </Form>
496 );
497}
498
499/** How a mirror behaves when its remote stops answering, and what runs in CI failover and takeovers. */
500function LeaderSettings({
501 remote,
502 full,
503 canManage,
504 result,
505}: {
506 remote: Remote;
507 full: string;
508 canManage: boolean;
509 result: MirrorOutcome | undefined;
510}) {
511 const settings = remote.settings;
512 const [auto, setAuto] = useState(settings.takeOverAfter != null);
513 const id = useId();
514 const [least, most] = TAKE_OVER_MINUTES;
515 return (
516 <Form method="post" className="space-y-10">
517 <input type="hidden" name="intent" value="settings" />
518 <input type="hidden" name="kind" value="leader" />
519 <input type="hidden" name="remoteId" value={remote.id} />
520 <fieldset disabled={!canManage} className="space-y-10">
521 <Section
522 title={`When ${remote.name} stops answering`}
523 about="g1t checks every minute. Unless you choose otherwise, it only says so here: nobody is paged, and nothing happens on its own."
524 >
525 <RadioGroup name="notify" defaultValue={settings.notify} className="gap-3">
526 <RadioOption value="banner" label="Show it on the repository" description={`A line across ${full}'s pages, and an amber dot beside its name.`} />
527 <RadioOption value="inbox" label="Also tell workspace owners in their notifications" description="Once when it stops answering, and once when it answers again." />
528 </RadioGroup>
529 <Card className="p-4">
530 <div className="flex flex-wrap items-center gap-x-2.5 gap-y-2">
531 <Checkbox id={`${id}-auto`} name="autoTakeOver" checked={auto} onCheckedChange={(checked) => setAuto(checked === true)} />
532 <label htmlFor={`${id}-auto`} className="cursor-pointer text-sm font-medium">
533 Take over automatically after
534 </label>
535 <span className="flex items-center gap-2">
536 <span className="w-20">
537 <Input
538 type="number"
539 name="takeOverAfter"
540 aria-label="Minutes"
541 min={least}
542 max={most}
543 step={1}
544 defaultValue={settings.takeOverAfter ?? 30}
545 disabled={!auto}
546 />
547 </span>
548 <span className="text-sm text-muted">minutes</span>
549 </span>
550 </div>
551 <p className="mt-1.5 pl-6.5 text-xs text-faint">
552 Off unless you turn it on: people take over when they want. From {least} minutes to a day.
553 </p>
554 </Card>
555 <SettingToggle name="handBackWhenClean" on={settings.handBack === "when_clean"} title="Hand back on its own when every branch goes back cleanly">
556 Only after an automatic takeover. A branch that moved on both sides always waits for someone to choose.
557 </SettingToggle>
558 </Section>
559
560 <Section title="Workflows" about="What runs on g1t during CI failover and takeovers. A mirror standing by runs nothing unless CI is kept warm.">
561 <SettingToggle name="keepCiWarm" on={settings.keepCiWarm} title="Keep CI warm">
562 Run .g1t/workflows on every push copied in from {remote.name}, so CI is ready the moment you need it.
563 </SettingToggle>
564 <SettingToggle name="githubWorkflows" on={settings.githubWorkflows} title={`Run ${remote.name}'s .github workflows`}>
565 In CI failover and takeovers, workflows in .github/workflows run on g1t too, beside .g1t/workflows.
566 </SettingToggle>
567 <SettingToggle name="holdDeploys" on={settings.holdDeploys} title="Hold workflows that deploy for approval">
568 A job that deploys waits for someone to approve it, so taking over never ships anything by surprise.
569 </SettingToggle>
570 </Section>
571 </fieldset>
572 {canManage && (
573 <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">
574 <SubmitButton pending="Saving…">Save settings</SubmitButton>
575 {result?.ok && <span className="text-sm text-muted">Saved.</span>}
576 <ErrorText>{result && !result.ok ? result.error : null}</ErrorText>
577 </div>
578 )}
579 </Form>
580 );
581}
582
583/** Making g1t the leader for good, said plainly, with the remote kept as a follower if they like. */
584function MoveToG1t({ full, remote, error }: { full: string; remote: string; error: string | null }) {
585 return (
586 <ConfirmDialog
587 intent="move-in"
588 title={`Move ${full} to g1t?`}
589 description={`g1t will no longer track ${remote}. Pushes made there won't come here.`}
590 confirm={full}
591 submit="Move to g1t"
592 busy="Moving…"
593 error={error}
594 trigger={(open) => (
595 <DangerAction
596 title="Move to g1t"
597 action={
598 <Button type="button" variant="destructive" onClick={open}>
599 Move to g1t
600 </Button>
601 }
602 >
603 Make g1t lead {full} for good. It stops being a mirror, and g1t stops tracking {remote}.
604 </DangerAction>
605 )}
606 extra={
607 <CheckboxOption
608 name="keepRemoteUpdated"
609 label={`Keep ${remote} updated from g1t`}
610 description={`${remote} follows instead: every push here is pushed there. Off, g1t leaves it as it is.`}
611 />
612 }
613 >
614 <li>{full} stops being a mirror: pushes, pull requests, issues, agents and workflows work here from now on.</li>
615 <li>This is for good. There is nothing to hand back afterwards.</li>
616 </ConfirmDialog>
617 );
618}
619
620/** One remote that follows: how it stands, what happens to pushes made on it, and removing it. */
621function FollowerRow({ remote, canManage }: { remote: Remote; canManage: boolean }) {
622 const stuck = remote.state === "stuck";
623 return (
624 <li className="space-y-2 px-4 py-3">
625 <div className="flex flex-wrap items-center gap-x-2.5 gap-y-1">
626 <ReachDot reachable={remote.reachable} />
627 <a href={remote.url} target="_blank" rel="noreferrer" className="min-w-0 font-mono text-sm font-medium break-all hover:text-accent">
628 {remote.name}
629 </a>
630 <Hint label={stuck ? "It refused a push or moved on its own. Sync now pushes g1t's branches over it." : null}>
631 <Badge tone={STATE_TONES[remote.state] ?? "neutral"} tabIndex={stuck ? 0 : undefined}>
632 {remoteStateWords(remote.state)}
633 </Badge>
634 </Hint>
635 {canManage && (
636 <span className="ml-auto">
637 <ConfirmDialog
638 intent="remove"
639 fields={{ remoteId: remote.id }}
640 title={`Stop mirroring to ${remote.name}?`}
641 description={`${remote.name} keeps what it has; g1t stops pushing to it. Its token is deleted.`}
642 submit="Remove"
643 busy="Removing…"
644 trigger={(open) => (
645 <Button type="button" variant="outline" onClick={open} aria-label={`Remove ${remote.name}`}>
646 <Trash2 size={14} />
647 <span className="hidden sm:inline">Remove</span>
648 </Button>
649 )}
650 />
651 </span>
652 )}
653 </div>
654 <p className="text-xs text-faint">
655 {PROVIDERS[remote.provider]} · {remote.syncedAt ? <>Synced <TimeAgo at={remote.syncedAt} /></> : "Not synced yet"}
656 {!remote.reachable && <span className="text-warn"> · Not answering</span>}
657 </p>
658 {remote.lastError && (
659 <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">
660 <TriangleAlert size={13} className="mt-px shrink-0 text-warn" aria-hidden="true" />
661 <span className="min-w-0 break-words">{remote.lastError}</span>
662 </p>
663 )}
664 {canManage && (
665 <Form method="post" className="flex flex-wrap items-center gap-2 text-sm">
666 <input type="hidden" name="intent" value="settings" />
667 <input type="hidden" name="kind" value="follower" />
668 <input type="hidden" name="remoteId" value={remote.id} />
669 <span className="text-muted">Pushes made on {remote.name}:</span>
670 <Select name="remotePushes" defaultValue={remote.settings.remotePushes}>
671 <SelectTrigger size="sm" aria-label={`Pushes made on ${remote.name}`} className="w-auto min-w-44">
672 <SelectValue />
673 </SelectTrigger>
674 <SelectContent>
675 <SelectItem value="adopt">Take in fast-forwards</SelectItem>
676 <SelectItem value="overwrite">Overwrite with g1t's</SelectItem>
677 </SelectContent>
678 </Select>
679 <SubmitButton
680 variant="outline"
681 match={{ intent: "settings", remoteId: remote.id }}
682 pending="Saving…"
683 className="inline-flex items-center gap-1 text-xs text-muted hover:text-fg disabled:opacity-50"
684 >
685 Save
686 </SubmitButton>
687 </Form>
688 )}
689 </li>
690 );
691}
692
693/** A remote to add: where, which one leads, and how to sign in to it. */
694function AddRemote({ result }: { result: MirrorOutcome | undefined }) {
695 const [round, setRound] = useState(0);
696 // Cleared once added, ready for the next.
697 useEffect(() => {
698 if (result?.ok) setRound((n) => n + 1);
699 }, [result]);
700 return (
701 <Card asChild key={round} className="space-y-5 p-4">
702 <Form method="post">
703 <input type="hidden" name="intent" value="add" />
704 <fieldset className="space-y-2">
705 <legend className="mb-1.5 text-sm font-medium text-muted">Provider</legend>
706 <RadioGroup name="provider" defaultValue="git" className="gap-2.5 sm:grid-cols-2">
707 <RadioOption value="g1t" label="Another g1t" description="A repository on another g1t, such as one you host yourself." />
708 <RadioOption value="git" label="Any git host" description="Anywhere that takes git over HTTPS." />
709 </RadioGroup>
710 </fieldset>
711 <fieldset className="space-y-2">
712 <legend className="mb-1.5 text-sm font-medium text-muted">Which one leads</legend>
713 <RadioGroup name="role" defaultValue="follower" className="gap-2.5">
714 <RadioOption value="follower" label="g1t leads; the remote follows" description="Every push here is pushed there." />
715 <RadioOption
716 value="leader"
717 label="The remote leads; this repository mirrors it"
718 description="Only for an empty repository: g1t copies the remote, and this repository becomes a read-only mirror of it."
719 />
720 </RadioGroup>
721 </fieldset>
722 <Field label="HTTPS URL">
723 <Input name="url" type="url" required inputMode="url" spellCheck={false} placeholder="https://git.example.com/acme/web.git" />
724 </Field>
725 <div className="grid gap-4 sm:grid-cols-2">
726 <Field label="Username (optional)">
727 <Input name="username" spellCheck={false} autoCapitalize="off" />
728 </Field>
729 <Field label="Token" hint="Stored encrypted; never shown again.">
730 <Input name="token" type="password" spellCheck={false} />
731 </Field>
732 </div>
733 <Result result={result} />
734 <SubmitButton pending="Adding…" match={{ intent: "add" }}>
735 Add remote
736 </SubmitButton>
737 </Form>
738 </Card>
739 );
740}