Skip to content
728 linesCodeBlameRaw

Pick any line to see why it is the way it is: the commit, the pull request and issue it came from, and what the agent was thinking.

Merge branch 'mirroring' into artifacts-mode1import { 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.`} />
g1t is described as the product it is in alpha, and its shell takes the new shape: the README, the docs home and docs/PLAN.md label every feature Live, Preview or Coming; a floating dock with the g1t mark, Today, Chat, Notifications, Agents, Code, Artifacts, your pinned apps and the Apps launcher, with People, Workspace and the account menu (now holding help) at its foot; the workspace's logo and switcher heading a flat sidebar that folds away with Ctrl B, or leading the header's breadcrumbs where there is none; the page in a rounded panel with a full-width header; Today as the front page, with first-time acceptance of agents' pull requests, what needs you and what's at stake, spend today and one item to start with, every number from a service; Notifications in place of the Inbox at /notifications; a bottom bar and More sheet on phones; and sign-in, sign-up, two-factor, reset, invites and choosing a workspace on standalone pages with no app around them.517 <RadioOption value="inbox" label="Also tell workspace owners in their notifications" description="Once when it stops answering, and once when it answers again." />
Merge branch 'mirroring' into artifacts-mode518 </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}

This file's history is long; its oldest lines are credited to the oldest commit read.