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