| 1 | import { CloudOff, Flag, Undo2, Workflow } from "lucide-react"; |
| 2 | import type { ReactNode } from "react"; |
| 3 | import { Form, Link, useRouteLoaderData } from "react-router"; |
| 4 | |
| 5 | import type { RepoMirror } from "@g1t/contracts"; |
| 6 | |
| 7 | import { ConfirmDialog } from "./repo-lifecycle"; |
| 8 | import { SubmitButton, TimeAgo } from "./ui"; |
| 9 | import { Badge } from "./ui/badge"; |
| 10 | import { Hint } from "./ui/hint"; |
| 11 | import { |
| 12 | type MirrorBrief, |
| 13 | type MirroredRepo, |
| 14 | followersOf, |
| 15 | leaderOf, |
| 16 | mirrorBadge, |
| 17 | mirrorBanner, |
| 18 | mirrorReason, |
| 19 | mirroringSettings, |
| 20 | workflowReason, |
| 21 | } from "../lib/mirror"; |
| 22 | |
| 23 | /** |
| 24 | * The repository the page is in, its remotes in brief and whether the |
| 25 | * viewer manages them, from the repository layout's loader. Empty outside |
| 26 | * a repository. |
| 27 | */ |
| 28 | export function useRepoMirror(): { repo: MirroredRepo | null; briefs: MirrorBrief[]; admin: boolean } { |
| 29 | const layout = useRouteLoaderData("routes/repo/layout") as |
| 30 | | { repo?: MirroredRepo; mirrorBriefs?: MirrorBrief[]; access?: { can?: { manage_integrations?: boolean } } } |
| 31 | | undefined; |
| 32 | return { |
| 33 | repo: layout?.repo ?? null, |
| 34 | briefs: layout?.mirrorBriefs ?? [], |
| 35 | admin: Boolean(layout?.access?.can?.manage_integrations), |
| 36 | }; |
| 37 | } |
| 38 | |
| 39 | /** |
| 40 | * Why pushing, merging, opening issues and pull requests and assigning |
| 41 | * agents are off in the repository the page is in; null when they are not |
| 42 | * off because it is a mirror. |
| 43 | */ |
| 44 | export function useMirrorReason(): string | null { |
| 45 | return mirrorReason(useRepoMirror().repo); |
| 46 | } |
| 47 | |
| 48 | /** Why running workflows is off: a mirror standing by or handing back runs nothing. */ |
| 49 | export function useWorkflowReason(): string | null { |
| 50 | return workflowReason(useRepoMirror().repo); |
| 51 | } |
| 52 | |
| 53 | /** What the badge means, said on hover. */ |
| 54 | function badgeHint(mirror: RepoMirror | null | undefined, briefs: readonly MirrorBrief[]): string { |
| 55 | if (mirror) { |
| 56 | switch (mirror.state) { |
| 57 | case "standby": |
| 58 | return `A read-only copy that follows ${mirror.remote}. Work happens there; clone and fetch freely here.`; |
| 59 | case "ci": |
| 60 | return `${mirror.remote} keeps the code; g1t runs its workflows and sends the results back.`; |
| 61 | case "takeover": |
| 62 | return `g1t leads for now: everything works here until it is handed back to ${mirror.remote}.`; |
| 63 | case "handing_back": |
| 64 | return `Going back to ${mirror.remote} branch by branch. Read-only until it is done.`; |
| 65 | } |
| 66 | } |
| 67 | const names = followersOf(briefs).map((brief) => brief.name); |
| 68 | return `g1t leads. Every push here goes to ${names.join(", ")}.`; |
| 69 | } |
| 70 | |
| 71 | /** |
| 72 | * Beside the repository's name: what it is to its remotes. An amber dot |
| 73 | * when the remote a mirror follows is not answering. |
| 74 | */ |
| 75 | export function MirrorBadge({ mirror, briefs }: { mirror: RepoMirror | null | undefined; briefs: readonly MirrorBrief[] }) { |
| 76 | const badge = mirrorBadge(mirror, briefs); |
| 77 | if (!badge) return null; |
| 78 | const silent = Boolean(mirror) && leaderOf(briefs)?.reachable === false; |
| 79 | return ( |
| 80 | <Hint label={badgeHint(mirror, briefs)}> |
| 81 | <Badge tone={badge.tone} tabIndex={0} className="min-w-0 max-w-full px-2 py-0.5 text-xs font-normal"> |
| 82 | {silent && <span aria-label="Not answering" className="size-1.5 shrink-0 rounded-full bg-warn" />} |
| 83 | <span className="truncate">{badge.label}</span> |
| 84 | {badge.more > 0 && <span className="shrink-0 opacity-70">+{badge.more} more</span>} |
| 85 | </Badge> |
| 86 | </Hint> |
| 87 | ); |
| 88 | } |
| 89 | |
| 90 | const BANNER_TONES = { |
| 91 | warn: { box: "border-warn/40 bg-warn/5", icon: "text-warn", action: "text-warn" }, |
| 92 | info: { box: "border-info/40 bg-info/5", icon: "text-info", action: "text-info" }, |
| 93 | } as const; |
| 94 | |
| 95 | /** A compact line across the top of the page, with what it is about and one action. */ |
| 96 | function BannerBox({ tone, icon, title, children, action, className = "" }: { |
| 97 | tone: keyof typeof BANNER_TONES; |
| 98 | className?: string; |
| 99 | icon: ReactNode; |
| 100 | title: ReactNode; |
| 101 | children?: ReactNode; |
| 102 | action?: ReactNode; |
| 103 | }) { |
| 104 | const colours = BANNER_TONES[tone]; |
| 105 | return ( |
| 106 | <div |
| 107 | role="status" |
| 108 | className={`flex flex-col gap-2 rounded-lg border px-4 py-2.5 text-sm sm:flex-row sm:items-center sm:justify-between ${colours.box} ${className}`} |
| 109 | > |
| 110 | <p className="flex min-w-0 gap-2.5"> |
| 111 | <span className={`mt-0.5 shrink-0 ${colours.icon} [&_svg]:size-4`}>{icon}</span> |
| 112 | <span className="min-w-0"> |
| 113 | <span className="font-medium text-fg">{title}</span> |
| 114 | {children && <> <span className="text-muted">{children}</span></>} |
| 115 | </span> |
| 116 | </p> |
| 117 | {action && <div className={`shrink-0 pl-6.5 font-medium sm:pl-0 ${colours.action}`}>{action}</div>} |
| 118 | </div> |
| 119 | ); |
| 120 | } |
| 121 | |
| 122 | const BANNER_ACTION = "inline-flex items-center gap-1.5 font-medium hover:underline disabled:opacity-50"; |
| 123 | |
| 124 | /** |
| 125 | * Taking over, from anywhere: says what it does and posts to the |
| 126 | * mirroring settings, which then show it. |
| 127 | */ |
| 128 | export function TakeOverDialog({ |
| 129 | base, |
| 130 | full, |
| 131 | mirror, |
| 132 | error, |
| 133 | trigger, |
| 134 | }: { |
| 135 | base: string; |
| 136 | full: string; |
| 137 | mirror: Pick<RepoMirror, "remote" | "holdDeploys">; |
| 138 | error?: string | null; |
| 139 | trigger: (open: () => void) => ReactNode; |
| 140 | }) { |
| 141 | return ( |
| 142 | <ConfirmDialog |
| 143 | intent="take-over" |
| 144 | action={mirroringSettings(base)} |
| 145 | title={`Take over ${full}?`} |
| 146 | description={`g1t leads ${full} until you hand it back. Pushes, pull requests, agents and workflows work here meanwhile.`} |
| 147 | submit="Take over" |
| 148 | busy="Taking over…" |
| 149 | danger={false} |
| 150 | error={error} |
| 151 | trigger={trigger} |
| 152 | > |
| 153 | <li>{mirror.remote} keeps what it has. Nothing goes back to it until you hand it back, branch by branch.</li> |
| 154 | {mirror.holdDeploys !== false && <li>Workflows that deploy wait for someone to approve them.</li>} |
| 155 | </ConfirmDialog> |
| 156 | ); |
| 157 | } |
| 158 | |
| 159 | /** |
| 160 | * Across the top of a mirror's pages, when there is something to know: its |
| 161 | * remote is not answering, g1t runs its CI, has taken over, or is handing |
| 162 | * back. A mirror standing by whose remote answers gets none: the badge |
| 163 | * beside its name says it. |
| 164 | */ |
| 165 | export function MirrorBanner({ |
| 166 | base, |
| 167 | full, |
| 168 | mirror, |
| 169 | briefs, |
| 170 | admin, |
| 171 | className, |
| 172 | }: { |
| 173 | base: string; |
| 174 | full: string; |
| 175 | mirror: RepoMirror | null | undefined; |
| 176 | briefs: readonly MirrorBrief[]; |
| 177 | /** Whether the viewer manages its mirroring: they get its one action. */ |
| 178 | admin: boolean; |
| 179 | className?: string; |
| 180 | }) { |
| 181 | const kind = mirrorBanner(mirror, briefs); |
| 182 | if (!kind || !mirror) return null; |
| 183 | const settings = mirroringSettings(base); |
| 184 | switch (kind) { |
| 185 | case "unreachable": { |
| 186 | const synced = leaderOf(briefs)?.syncedAt; |
| 187 | return ( |
| 188 | <BannerBox |
| 189 | className={className} |
| 190 | tone="warn" |
| 191 | icon={<CloudOff />} |
| 192 | title={`${mirror.remote} isn't answering.`} |
| 193 | action={ |
| 194 | admin ? ( |
| 195 | <TakeOverDialog |
| 196 | base={base} |
| 197 | full={full} |
| 198 | mirror={mirror} |
| 199 | trigger={(open) => ( |
| 200 | <button type="button" onClick={open} className={BANNER_ACTION}> |
| 201 | Take over |
| 202 | </button> |
| 203 | )} |
| 204 | /> |
| 205 | ) : undefined |
| 206 | } |
| 207 | > |
| 208 | {synced ? ( |
| 209 | <> |
| 210 | g1t has its copy as of <TimeAgo at={synced} />. |
| 211 | </> |
| 212 | ) : ( |
| 213 | "g1t has its copy as of the last sync." |
| 214 | )} |
| 215 | {mirror.state === "ci" && " Its workflows keep running here."} |
| 216 | </BannerBox> |
| 217 | ); |
| 218 | } |
| 219 | case "ci": |
| 220 | return ( |
| 221 | <BannerBox |
| 222 | className={className} |
| 223 | tone="info" |
| 224 | icon={<Workflow />} |
| 225 | title={`Running ${mirror.remote}'s workflows on g1t.`} |
| 226 | action={ |
| 227 | admin ? ( |
| 228 | <Form method="post" action={settings}> |
| 229 | <SubmitButton name="intent" value="ci-off" pending="Ending…" className={BANNER_ACTION}> |
| 230 | End CI failover |
| 231 | </SubmitButton> |
| 232 | </Form> |
| 233 | ) : undefined |
| 234 | } |
| 235 | > |
| 236 | CI failover started <TimeAgo at={mirror.since} />. |
| 237 | {mirror.holdDeploys !== false && " Deploying workflows wait for approval."} |
| 238 | </BannerBox> |
| 239 | ); |
| 240 | case "takeover": |
| 241 | return ( |
| 242 | <BannerBox |
| 243 | className={className} |
| 244 | tone="warn" |
| 245 | icon={<Flag />} |
| 246 | title={`g1t is leading ${full} for now.`} |
| 247 | action={ |
| 248 | admin ? ( |
| 249 | <Link to={`${settings}?plan=1#hand-back`} className={BANNER_ACTION}> |
| 250 | Hand back… |
| 251 | </Link> |
| 252 | ) : undefined |
| 253 | } |
| 254 | > |
| 255 | Everything works here; when you're done, hand it back to {mirror.remote}. |
| 256 | </BannerBox> |
| 257 | ); |
| 258 | case "handing_back": |
| 259 | return ( |
| 260 | <BannerBox |
| 261 | className={className} |
| 262 | tone="warn" |
| 263 | icon={<Undo2 />} |
| 264 | title={`Handing back to ${mirror.remote}…`} |
| 265 | action={ |
| 266 | admin ? ( |
| 267 | <Link to={settings} className={BANNER_ACTION}> |
| 268 | Progress |
| 269 | </Link> |
| 270 | ) : undefined |
| 271 | } |
| 272 | > |
| 273 | {full} is read-only until it's done. |
| 274 | </BannerBox> |
| 275 | ); |
| 276 | } |
| 277 | } |
| 278 | |
| 279 | /** |
| 280 | * Under the clone address: what pushing here does when the repository |
| 281 | * mirrors a remote. Null when it leads. |
| 282 | */ |
| 283 | export function MirrorCloneNote({ mirror, base, admin }: { mirror: RepoMirror | null | undefined; base: string; admin: boolean }) { |
| 284 | if (!mirror) return null; |
| 285 | const where = admin ? ( |
| 286 | <Link to={mirroringSettings(base)} className="text-fg underline underline-offset-4"> |
| 287 | Settings → Mirroring |
| 288 | </Link> |
| 289 | ) : ( |
| 290 | "Settings → Mirroring" |
| 291 | ); |
| 292 | return ( |
| 293 | <p className="mt-2 text-xs text-muted"> |
| 294 | {mirror.state === "takeover" ? ( |
| 295 | <>g1t leads for now: pushes here go back to {mirror.remote} when it is handed back.</> |
| 296 | ) : mirror.state === "handing_back" ? ( |
| 297 | <>Handing back to {mirror.remote}: clone and fetch freely; pushes wait until it is done.</> |
| 298 | ) : ( |
| 299 | <> |
| 300 | A mirror of {mirror.remote}. Clone and fetch freely; to push, push there, or take over in {where}. |
| 301 | </> |
| 302 | )} |
| 303 | </p> |
| 304 | ); |
| 305 | } |
| 306 | |
| 307 | /** |
| 308 | * On the repository's overview, one quiet line when no banner says it: a |
| 309 | * mirror standing by, or the remotes that follow it, and when it last synced. |
| 310 | */ |
| 311 | export function MirrorOverviewNote({ |
| 312 | base, |
| 313 | mirror, |
| 314 | briefs, |
| 315 | admin, |
| 316 | syncedAt, |
| 317 | lastError, |
| 318 | }: { |
| 319 | base: string; |
| 320 | mirror: RepoMirror | null | undefined; |
| 321 | briefs: readonly MirrorBrief[]; |
| 322 | admin: boolean; |
| 323 | syncedAt?: string | null; |
| 324 | lastError?: string | null; |
| 325 | }) { |
| 326 | const followers = followersOf(briefs); |
| 327 | // A banner says it already. |
| 328 | if (mirror && mirrorBanner(mirror, briefs)) return null; |
| 329 | if (!mirror && followers.length === 0) return null; |
| 330 | const stuck = followers.filter((brief) => brief.state === "stuck"); |
| 331 | return ( |
| 332 | <div className="flex flex-wrap items-baseline gap-x-3 gap-y-1 text-sm text-muted"> |
| 333 | <p className="min-w-0"> |
| 334 | {mirror ? ( |
| 335 | <> |
| 336 | A read-only mirror of{" "} |
| 337 | <a href={mirror.url} target="_blank" rel="noreferrer" className="font-mono break-all text-fg hover:text-accent"> |
| 338 | {mirror.remote} |
| 339 | </a> |
| 340 | . |
| 341 | </> |
| 342 | ) : ( |
| 343 | <> |
| 344 | Mirrored to <span className="font-mono text-fg">{followers[0]!.name}</span> |
| 345 | {followers.length > 1 && ` and ${followers.length - 1} more`}: every push here goes there too. |
| 346 | </> |
| 347 | )} |
| 348 | {syncedAt && ( |
| 349 | <span className="text-faint"> |
| 350 | {" "} |
| 351 | Synced <TimeAgo at={syncedAt} />. |
| 352 | </span> |
| 353 | )} |
| 354 | </p> |
| 355 | {admin && ( |
| 356 | <Link to={mirroringSettings(base)} className="text-xs text-muted hover:text-fg"> |
| 357 | Mirroring settings |
| 358 | </Link> |
| 359 | )} |
| 360 | {(lastError || stuck.length > 0) && ( |
| 361 | <p className="w-full text-xs text-warn"> |
| 362 | {lastError ?? `${stuck.map((brief) => brief.name).join(", ")} stopped taking pushes. See Settings → Mirroring.`} |
| 363 | </p> |
| 364 | )} |
| 365 | </div> |
| 366 | ); |
| 367 | } |
| 368 | |
| 369 | /** What an action did that people should know, such as the pull requests it opened, with addresses as links. */ |
| 370 | export function MirrorNotes({ notes }: { notes: readonly string[] | null | undefined }) { |
| 371 | if (!notes || notes.length === 0) return null; |
| 372 | return ( |
| 373 | <div role="status" className="rounded-lg border border-success/40 bg-success/5 px-4 py-3 text-sm"> |
| 374 | <ul className="space-y-1"> |
| 375 | {notes.map((note, at) => ( |
| 376 | <li key={at} className="text-fg-soft"> |
| 377 | {note.split(/(https?:\/\/\S+?)(?=[.,;)]*(?:\s|$))/).map((piece, index) => |
| 378 | /^https?:\/\//.test(piece) ? ( |
| 379 | <a key={index} href={piece} className="break-all text-fg underline underline-offset-2 hover:text-accent"> |
| 380 | {piece} |
| 381 | </a> |
| 382 | ) : ( |
| 383 | piece |
| 384 | ), |
| 385 | )} |
| 386 | </li> |
| 387 | ))} |
| 388 | </ul> |
| 389 | </div> |
| 390 | ); |
| 391 | } |