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-mode | 1 | import { ExternalLink, RefreshCw, Trash2, TriangleAlert } from "lucide-react"; |
| 2 | import { type ReactNode, useEffect, useId, useState } from "react"; | |
| 3 | import { Form, Link } from "react-router"; | |
| 4 | ||
| 5 | import type { HandbackPlan, MirrorView, RefDecision, Remote, RepoMirror } from "@g1t/contracts"; | |
| 6 | ||
| 7 | import { DangerAction, DangerZone } from "./danger-zone"; | |
| 8 | import { MirrorNotes, TakeOverDialog } from "./mirror"; | |
| 9 | import { ConfirmDialog } from "./repo-lifecycle"; | |
| 10 | import { SettingsSection as Section, SettingToggle } from "./settings-section"; | |
| 11 | import { Button, ButtonLink, ErrorText, Field, Input, SubmitButton, TimeAgo } from "./ui"; | |
| 12 | import { Badge, type BadgeTone } from "./ui/badge"; | |
| 13 | import { Checkbox, CheckboxOption } from "./ui/checkbox"; | |
| 14 | import { Hint } from "./ui/hint"; | |
| 15 | import { RadioGroup, RadioOption } from "./ui/radio-group"; | |
| 16 | import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "./ui/select"; | |
| 17 | import { | |
| 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. */ | |
| 29 | export 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 | ||
| 38 | const 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 | ||
| 47 | const 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. */ | |
| 54 | function 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. */ | |
| 60 | function 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 | ||
| 69 | function 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. */ | |
| 79 | function 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 | ||
| 94 | const 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 | */ | |
| 105 | export 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. */ | |
| 238 | function 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". */ | |
| 361 | function 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. */ | |
| 377 | function 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. */ | |
| 490 | function 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-mode | 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. */ | |
| 574 | function 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. */ | |
| 611 | function 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. */ | |
| 684 | function 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.