Merge branch 'worktree-agent-a985c01f43538af01'
| 1 | 1 | import { Form } from "react-router"; | |
| 2 | 2 | ||
| 3 | − | import { Button } from "./ui"; | |
| 3 | + | import { SubmitButton } from "./ui"; | |
| 4 | 4 | ||
| 5 | − | /** One row's way out of a list in your settings: a key, a token, an application. */ | |
| 5 | + | /** | |
| 6 | + | * One row's way out of a list in your settings: a key, a token, an | |
| 7 | + | * application. It says it is working until the row has gone. | |
| 8 | + | */ | |
| 6 | 9 | export function DeleteButton({ | |
| 7 | 10 | intent, | |
| 8 | 11 | id, | |
| 9 | 12 | label = "Delete", | |
| 13 | + | pending = "Deleting…", | |
| 10 | 14 | }: { | |
| 11 | 15 | intent: string; | |
| 12 | 16 | id: string; | |
| 13 | 17 | label?: string; | |
| 18 | + | /** The words while it works. */ | |
| 19 | + | pending?: string; | |
| 14 | 20 | }) { | |
| 15 | 21 | return ( | |
| 16 | 22 | <Form method="post" className="ml-auto"> | |
| 17 | 23 | <input type="hidden" name="intent" value={intent} /> | |
| 18 | 24 | <input type="hidden" name="id" value={id} /> | |
| 19 | − | <Button variant="quiet" type="submit"> | |
| 25 | + | <SubmitButton variant="quiet" pending={pending} match={{ intent, id }}> | |
| 20 | 26 | {label} | |
| 21 | − | </Button> | |
| 27 | + | </SubmitButton> | |
| 22 | 28 | </Form> | |
| 23 | 29 | ); | |
| 24 | 30 | } |
| 3 | 3 | * nothing proves a change works, and a button that opens a pull request | |
| 4 | 4 | * adding a starter workflow for the stack g1t finds (routes/repo/add-ci.ts). | |
| 5 | 5 | */ | |
| 6 | − | import { FlaskConical, LoaderCircle, Plus } from "lucide-react"; | |
| 6 | + | import { FlaskConical, Plus } from "lucide-react"; | |
| 7 | 7 | import { useFetcher } from "react-router"; | |
| 8 | 8 | ||
| 9 | − | import { Button, ErrorText } from "./ui"; | |
| 9 | + | import { ErrorText, SubmitButton } from "./ui"; | |
| 10 | 10 | ||
| 11 | 11 | export function AddCiPrompt({ | |
| 12 | 12 | owner, | |
| 22 | 22 | compact?: boolean; | |
| 23 | 23 | }) { | |
| 24 | 24 | const fetcher = useFetcher<{ error?: string }>(); | |
| 25 | − | const adding = fetcher.state !== "idle"; | |
| 25 | + | ||
| 26 | 26 | return ( | |
| 27 | 27 | <div className={compact ? "flex gap-3 px-4 py-3" : "flex gap-3 rounded-xl border border-dashed border-line-strong bg-surface p-4"}> | |
| 28 | 28 | <FlaskConical size={16} className="mt-0.5 shrink-0 text-warn" /> | |
| 34 | 34 | </p> | |
| 35 | 35 | {canAdd && ( | |
| 36 | 36 | <fetcher.Form method="post" action={`/${owner}/${repo}/add-ci`} className="mt-3 flex flex-wrap items-center gap-3"> | |
| 37 | − | <Button variant="primary" type="submit" disabled={adding}> | |
| 38 | − | {adding ? <LoaderCircle size={14} className="animate-spin" /> : <Plus size={14} />} | |
| 39 | − | {adding ? "Opening a pull request…" : "Add CI"} | |
| 40 | − | </Button> | |
| 37 | + | <SubmitButton variant="primary" fetcher={fetcher} pending="Opening a pull request…"> | |
| 38 | + | <Plus size={14} /> | |
| 39 | + | Add CI | |
| 40 | + | </SubmitButton> | |
| 41 | 41 | <span className="text-xs text-faint"> | |
| 42 | 42 | Opens a pull request with a starter workflow for the stack g1t finds. You can change it before merging. | |
| 43 | 43 | </span> |
| 5 | 5 | * `agents.json` resource route, so every place behaves the same. | |
| 6 | 6 | */ | |
| 7 | 7 | import { Bot, CircleSlash, Clock, Coins, Gauge, Loader2, MessageSquare, OctagonX, Square, TriangleAlert } from "lucide-react"; | |
| 8 | − | import { type ReactNode, useEffect, useMemo, useState } from "react"; | |
| 8 | + | import { type ReactNode, useEffect, useMemo, useRef, useState } from "react"; | |
| 9 | 9 | import { Link, useFetcher } from "react-router"; | |
| 10 | 10 | import { useRefreshWhile } from "../lib/refresh"; | |
| 11 | 11 | ||
| 23 | 23 | ||
| 24 | 24 | import { RunAudit } from "./audit"; | |
| 25 | 25 | import { STAGE_LABEL, StageDots } from "./lifecycle"; | |
| 26 | − | import { Avatar, TimeAgo } from "./ui"; | |
| 26 | + | import { Avatar, SubmitButton, TimeAgo } from "./ui"; | |
| 27 | 27 | import { | |
| 28 | 28 | AlertDialog, | |
| 29 | 29 | AlertDialogAction, | |
| 157 | 157 | export function MessageRun({ run }: { run: AgentRun }) { | |
| 158 | 158 | const fetcher = useFetcher<ActionResult>(); | |
| 159 | 159 | const [open, setOpen] = useState(false); | |
| 160 | + | const form = useRef<HTMLFormElement>(null); | |
| 161 | + | // Sent: the box empties, and the notice below says where it went. | |
| 162 | + | useEffect(() => { | |
| 163 | + | if (fetcher.state === "idle" && fetcher.data?.ok) form.current?.reset(); | |
| 164 | + | }, [fetcher.state, fetcher.data]); | |
| 160 | 165 | const live = takesMessages(run.kind) && isActiveRun(run.status); | |
| 161 | 166 | if (run.number == null) return null; | |
| 162 | 167 | return ( | |
| 174 | 179 | : "This run does not read messages while it works. Your message waits on the pull request and is given to the agent's next run there."} | |
| 175 | 180 | </DialogDescription> | |
| 176 | 181 | </DialogHeader> | |
| 177 | − | <fetcher.Form method="post" action={actionUrl(run)} className="space-y-3"> | |
| 182 | + | <fetcher.Form ref={form} method="post" action={actionUrl(run)} className="space-y-3"> | |
| 178 | 183 | <input type="hidden" name="intent" value="message" /> | |
| 179 | 184 | <input type="hidden" name="number" value={run.number} /> | |
| 180 | 185 | <textarea | |
| 189 | 194 | {fetcher.data?.error && <p className="text-sm text-danger">{fetcher.data.error}</p>} | |
| 190 | 195 | {fetcher.data?.ok && fetcher.data.notice && <p className="text-sm text-accent">{fetcher.data.notice}</p>} | |
| 191 | 196 | <div className="flex justify-end"> | |
| 192 | − | <button | |
| 193 | − | type="submit" | |
| 194 | − | disabled={fetcher.state !== "idle"} | |
| 197 | + | <SubmitButton | |
| 198 | + | fetcher={fetcher} | |
| 199 | + | pending="Sending…" | |
| 195 | 200 | className="inline-flex items-center gap-2 rounded-md bg-fg px-3.5 py-2 text-sm font-medium text-bg hover:bg-white disabled:opacity-50" | |
| 196 | 201 | > | |
| 197 | − | {fetcher.state !== "idle" ? "Sending…" : "Send"} | |
| 198 | − | </button> | |
| 202 | + | Send | |
| 203 | + | </SubmitButton> | |
| 199 | 204 | </div> | |
| 200 | 205 | </fetcher.Form> | |
| 201 | 206 | </DialogContent> |
| 1 | − | import { ImageUp, Trash2 } from "lucide-react"; | |
| 1 | + | import { ImageUp, LoaderCircle, Trash2 } from "lucide-react"; | |
| 2 | 2 | import { useRef } from "react"; | |
| 3 | − | import { Form, useNavigation } from "react-router"; | |
| 3 | + | import { Form } from "react-router"; | |
| 4 | 4 | ||
| 5 | 5 | import { AVATAR_ACCEPT } from "../lib/avatar-upload"; | |
| 6 | − | import { Avatar, Button, ErrorText } from "./ui"; | |
| 6 | + | import { Avatar, ErrorText, SubmitButton, usePending } from "./ui"; | |
| 7 | 7 | ||
| 8 | 8 | /** | |
| 9 | 9 | * Uploading an avatar, as GitHub's settings do: the one shown now, a | |
| 25 | 25 | error?: string; | |
| 26 | 26 | about: string; | |
| 27 | 27 | }) { | |
| 28 | − | const navigation = useNavigation(); | |
| 29 | 28 | const form = useRef<HTMLFormElement>(null); | |
| 30 | − | const intent = navigation.formData?.get("intent"); | |
| 31 | − | const busy = navigation.state === "submitting" && (intent === "avatar" || intent === "remove-avatar"); | |
| 29 | + | // Working until the new picture (or the letter) is the one shown. | |
| 30 | + | const uploading = usePending({ intent: "avatar" }); | |
| 31 | + | const removing = usePending({ intent: "remove-avatar" }); | |
| 32 | + | const busy = uploading || removing; | |
| 32 | 33 | return ( | |
| 33 | 34 | <div className="flex items-start gap-4"> | |
| 34 | 35 | <Avatar name={name} image={image} size={64} square={square} /> | |
| 42 | 43 | busy ? "pointer-events-none opacity-50" : "" | |
| 43 | 44 | }`} | |
| 44 | 45 | > | |
| 45 | − | <ImageUp size={15} /> | |
| 46 | − | {busy && intent === "avatar" ? "Uploading…" : image ? "Upload a new image" : "Upload an image"} | |
| 46 | + | {uploading ? ( | |
| 47 | + | <LoaderCircle size={15} aria-hidden="true" className="animate-spin" /> | |
| 48 | + | ) : ( | |
| 49 | + | <ImageUp size={15} /> | |
| 50 | + | )} | |
| 51 | + | {uploading ? "Uploading…" : image ? "Upload a new image" : "Upload an image"} | |
| 47 | 52 | <input | |
| 48 | 53 | type="file" | |
| 49 | 54 | name="avatar" | |
| 58 | 63 | {image && ( | |
| 59 | 64 | <Form method="post"> | |
| 60 | 65 | <input type="hidden" name="intent" value="remove-avatar" /> | |
| 61 | − | <Button type="submit" variant="quiet" disabled={busy}> | |
| 66 | + | <SubmitButton variant="quiet" pending="Removing…" match={{ intent: "remove-avatar" }} disabled={uploading}> | |
| 62 | 67 | <Trash2 size={15} /> | |
| 63 | 68 | Remove | |
| 64 | − | </Button> | |
| 69 | + | </SubmitButton> | |
| 65 | 70 | </Form> | |
| 66 | 71 | )} | |
| 67 | 72 | </div> |
| 24 | 24 | spendRange, | |
| 25 | 25 | wholeDollars, | |
| 26 | 26 | } from "../lib/billing"; | |
| 27 | − | import { Button, ErrorText } from "./ui"; | |
| 27 | + | import { ErrorText, SubmitButton } from "./ui"; | |
| 28 | 28 | import { RadioGroup, RadioGroupItem } from "./ui/radio-group"; | |
| 29 | 29 | ||
| 30 | 30 | /** An error the action returned, for the section it belongs to. */ | |
| 181 | 181 | owner, | |
| 182 | 182 | enabled, | |
| 183 | 183 | live, | |
| 184 | − | busy, | |
| 185 | 184 | meters, | |
| 186 | 185 | error, | |
| 187 | 186 | }: { | |
| 191 | 190 | owner: boolean; | |
| 192 | 191 | enabled: boolean; | |
| 193 | 192 | live: boolean; | |
| 194 | − | busy: boolean; | |
| 195 | 193 | /** This month's usage by meter, from billing's `usage_meters`. */ | |
| 196 | 194 | meters: MeterUsage[] | null; | |
| 197 | 195 | error?: string; | |
| 271 | 269 | <Form method="post" className="mt-5 flex flex-wrap items-center gap-3"> | |
| 272 | 270 | <input type="hidden" name="feature" value={plan?.feature ?? "plan"} /> | |
| 273 | 271 | {status.kind === "free" || status.kind === "trial" ? ( | |
| 274 | − | <Button variant="accent" type="submit" name="intent" value="subscribe" disabled={busy}> | |
| 272 | + | <SubmitButton variant="accent" name="intent" value="subscribe" pending="Opening Stripe…"> | |
| 275 | 273 | <CreditCard size={14} /> | |
| 276 | 274 | Start the g1t plan | |
| 277 | − | </Button> | |
| 275 | + | </SubmitButton> | |
| 278 | 276 | ) : status.kind === "canceling" ? ( | |
| 279 | − | <Button variant="accent" type="submit" name="intent" value="resume" disabled={busy}> | |
| 277 | + | <SubmitButton variant="accent" name="intent" value="resume" pending="Saving…"> | |
| 280 | 278 | Keep the plan | |
| 281 | − | </Button> | |
| 279 | + | </SubmitButton> | |
| 282 | 280 | ) : null} | |
| 283 | 281 | {(status.kind === "paid" || status.kind === "canceling" || status.kind === "past_due") && ( | |
| 284 | − | <Button variant={status.kind === "past_due" ? "accent" : "quiet"} type="submit" name="intent" value="portal" disabled={busy}> | |
| 282 | + | <SubmitButton variant={status.kind === "past_due" ? "accent" : "quiet"} name="intent" value="portal" pending="Opening Stripe…"> | |
| 285 | 283 | {status.kind === "past_due" ? "Update payment on Stripe" : "Manage on Stripe"} | |
| 286 | 284 | <ArrowUpRight size={14} /> | |
| 287 | − | </Button> | |
| 285 | + | </SubmitButton> | |
| 288 | 286 | )} | |
| 289 | 287 | {status.kind === "paid" && subscription && ( | |
| 290 | − | <Button variant="quiet" type="submit" name="intent" value="cancel" disabled={busy}> | |
| 288 | + | <SubmitButton variant="quiet" name="intent" value="cancel" pending="Saving…"> | |
| 291 | 289 | End at the end of the period | |
| 292 | − | </Button> | |
| 290 | + | </SubmitButton> | |
| 293 | 291 | )} | |
| 294 | 292 | {!live && (status.kind === "free" || status.kind === "trial") && ( | |
| 295 | 293 | <span className="text-xs text-faint">Test mode: card 4242 4242 4242 4242, any future date and code.</span> | |
| 369 | 367 | trialMicros, | |
| 370 | 368 | owner, | |
| 371 | 369 | enabled, | |
| 372 | − | busy, | |
| 373 | 370 | error, | |
| 374 | 371 | }: { | |
| 375 | 372 | entitlements: Entitlements | null; | |
| 376 | 373 | trialMicros: number; | |
| 377 | 374 | owner: boolean; | |
| 378 | 375 | enabled: boolean; | |
| 379 | − | busy: boolean; | |
| 380 | 376 | error?: string; | |
| 381 | 377 | }) { | |
| 382 | 378 | const verified = entitlements?.trialVerified ?? false; | |
| 397 | 393 | {verified && left > 0 && <Meter label="Trial used" used={Math.max(0, trialMicros - left)} of={trialMicros} />} | |
| 398 | 394 | {!verified && enabled && owner && ( | |
| 399 | 395 | <Form method="post" className="mt-4 flex flex-wrap items-center gap-3"> | |
| 400 | − | <Button variant="quiet" type="submit" name="intent" value="card-check" disabled={busy}> | |
| 396 | + | <SubmitButton variant="quiet" name="intent" value="card-check" pending="Opening Stripe…"> | |
| 401 | 397 | <ShieldCheck size={14} /> | |
| 402 | 398 | Check a card | |
| 403 | − | </Button> | |
| 399 | + | </SubmitButton> | |
| 404 | 400 | <span className="text-xs text-faint">Trials come from a monthly pool; when it is given out, new ones start on the 1st.</span> | |
| 405 | 401 | </Form> | |
| 406 | 402 | )} | |
| 412 | 408 | ||
| 413 | 409 | // --- Spend limit ----------------------------------------------------------------------- | |
| 414 | 410 | ||
| 415 | − | export function SpendLimitCard({ limit, owner, busy, error }: { limit: Limit; owner: boolean; busy: boolean; error?: string }) { | |
| 411 | + | export function SpendLimitCard({ limit, owner, error }: { limit: Limit; owner: boolean; error?: string }) { | |
| 416 | 412 | const range = spendRange(limit); | |
| 417 | 413 | const spent = limit.spentMicros ?? 0; | |
| 418 | 414 | const spendLimit = limit.spendLimitMicros; | |
| 513 | 509 | </RadioGroup> | |
| 514 | 510 | </fieldset> | |
| 515 | 511 | <div className="mt-3 flex flex-wrap items-center gap-3"> | |
| 516 | − | <Button variant="quiet" type="submit" disabled={busy}> | |
| 512 | + | <SubmitButton variant="quiet" match={{ intent: "spend-limit" }} pending="Saving…"> | |
| 517 | 513 | Save limit | |
| 518 | − | </Button> | |
| 514 | + | </SubmitButton> | |
| 519 | 515 | <a href="#raise" className="text-sm text-muted hover:text-fg"> | |
| 520 | 516 | Need more? Raise my limit | |
| 521 | 517 | </a> | |
| 559 | 555 | ); | |
| 560 | 556 | } | |
| 561 | 557 | ||
| 562 | − | export function RaiseCard({ requests, owner, busy, error }: { requests: LimitRequest[]; owner: boolean; busy: boolean; error?: string }) { | |
| 558 | + | export function RaiseCard({ requests, owner, error }: { requests: LimitRequest[]; owner: boolean; error?: string }) { | |
| 563 | 559 | const mine = requests.filter((request) => request.kind === "limit"); | |
| 564 | 560 | const waiting = mine.some((request) => request.status === "open"); | |
| 565 | 561 | return ( | |
| 592 | 588 | className={`${CONTROL} w-full`} | |
| 593 | 589 | /> | |
| 594 | 590 | </label> | |
| 595 | − | <Button variant="quiet" type="submit" disabled={busy}> | |
| 591 | + | <SubmitButton variant="quiet" match={{ intent: "request", kind: "limit" }} pending="Sending…"> | |
| 596 | 592 | Send the request | |
| 597 | − | </Button> | |
| 593 | + | </SubmitButton> | |
| 598 | 594 | <ErrorText>{error}</ErrorText> | |
| 599 | 595 | </Form> | |
| 600 | 596 | )} | |
| 607 | 603 | ||
| 608 | 604 | // --- Prepay ----------------------------------------------------------------------- | |
| 609 | 605 | ||
| 610 | − | export function PrepayCard({ prepaidMicros, owner, live, busy, error }: { prepaidMicros: number; owner: boolean; live: boolean; busy: boolean; error?: string }) { | |
| 606 | + | export function PrepayCard({ prepaidMicros, owner, live, error }: { prepaidMicros: number; owner: boolean; live: boolean; error?: string }) { | |
| 611 | 607 | return ( | |
| 612 | 608 | <Card | |
| 613 | 609 | id="prepay" | |
| 626 | 622 | <input type="hidden" name="intent" value="prepay" /> | |
| 627 | 623 | <div className="flex flex-wrap items-center gap-2"> | |
| 628 | 624 | {PREPAY.presets.map((amount) => ( | |
| 629 | − | <Button key={amount} variant="quiet" type="submit" name="amount" value={amount} disabled={busy}> | |
| 625 | + | <SubmitButton key={amount} variant="quiet" name="amount" value={amount} match={{ intent: "prepay" }} pending="Opening Stripe…"> | |
| 630 | 626 | {wholeDollars(amount * 1_000_000)} | |
| 631 | − | </Button> | |
| 627 | + | </SubmitButton> | |
| 632 | 628 | ))} | |
| 633 | 629 | <span className="text-sm text-muted">or</span> | |
| 634 | 630 | <DollarInput name="custom" label="Amount to prepay in dollars" placeholder="250" /> | |
| 635 | − | <Button variant="accent" type="submit" disabled={busy}> | |
| 631 | + | {/* An empty amount, so a preset's press is not this button's: the typed amount is used. */} | |
| 632 | + | <SubmitButton variant="accent" name="amount" value="" match={{ intent: "prepay" }} pending="Opening Stripe…"> | |
| 636 | 633 | <CreditCard size={14} /> | |
| 637 | 634 | Prepay | |
| 638 | − | </Button> | |
| 635 | + | </SubmitButton> | |
| 639 | 636 | </div> | |
| 640 | 637 | <RadioGroup name="method" defaultValue="card" className="flex flex-wrap gap-x-5 gap-y-2 text-sm"> | |
| 641 | 638 | <label className="flex cursor-pointer items-center gap-2"> | |
| 659 | 656 | ||
| 660 | 657 | // --- Caps ----------------------------------------------------------------------- | |
| 661 | 658 | ||
| 662 | − | export function CapsCard({ entitlements, owner, busy, error }: { entitlements: Entitlements; owner: boolean; busy: boolean; error?: string }) { | |
| 659 | + | export function CapsCard({ entitlements, owner, error }: { entitlements: Entitlements; owner: boolean; error?: string }) { | |
| 663 | 660 | return ( | |
| 664 | 661 | <Card | |
| 665 | 662 | id="caps" | |
| 697 | 694 | </span> | |
| 698 | 695 | <DollarInput name="issue" label="Issue cap in dollars" defaultValue={String(entitlements.issueCapMicros / 1_000_000)} /> | |
| 699 | 696 | </label> | |
| 700 | − | <Button variant="quiet" type="submit" disabled={busy}> | |
| 697 | + | <SubmitButton variant="quiet" match={{ intent: "caps" }} pending="Saving…"> | |
| 701 | 698 | Save caps | |
| 702 | − | </Button> | |
| 699 | + | </SubmitButton> | |
| 703 | 700 | </Form> | |
| 704 | 701 | )} | |
| 705 | 702 | <ErrorText>{error}</ErrorText> | |
| 713 | 710 | ||
| 714 | 711 | // --- Overage ----------------------------------------------------------------------- | |
| 715 | 712 | ||
| 716 | − | export function OverageCard({ requests, owner, busy, error }: { requests: LimitRequest[]; owner: boolean; busy: boolean; error?: string }) { | |
| 713 | + | export function OverageCard({ requests, owner, error }: { requests: LimitRequest[]; owner: boolean; error?: string }) { | |
| 717 | 714 | const mine = requests.filter((request) => request.kind === "overage"); | |
| 718 | 715 | const waiting = mine.some((request) => request.status === "open"); | |
| 719 | 716 | return ( | |
| 734 | 731 | placeholder="An agent kept retrying a failing check on Tuesday night." | |
| 735 | 732 | className={`${CONTROL} w-full`} | |
| 736 | 733 | /> | |
| 737 | − | <Button variant="quiet" type="submit" disabled={busy}> | |
| 734 | + | <SubmitButton variant="quiet" match={{ intent: "request", kind: "overage" }} pending="Sending…"> | |
| 738 | 735 | Tell g1t | |
| 739 | − | </Button> | |
| 736 | + | </SubmitButton> | |
| 740 | 737 | <ErrorText>{error}</ErrorText> | |
| 741 | 738 | </Form> | |
| 742 | 739 | )} |
| 44 | 44 | type User as Actor, | |
| 45 | 45 | } from "@g1t/contracts"; | |
| 46 | 46 | ||
| 47 | − | import { TimeAgo } from "./ui"; | |
| 47 | + | import { SubmitButton, TimeAgo } from "./ui"; | |
| 48 | 48 | import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle, DialogTrigger } from "./ui/dialog"; | |
| 49 | 49 | import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "./ui/select"; | |
| 50 | 50 | ||
| 157 | 157 | <textarea name="text" required rows={4} maxLength={1000} defaultValue={memory.text} className={TEXTAREA} /> | |
| 158 | 158 | <div className="flex items-center justify-between gap-3"> | |
| 159 | 159 | <KindSelect value={memory.kind} /> | |
| 160 | − | <button type="submit" disabled={fetcher.state !== "idle"} className="rounded-md bg-fg px-3.5 py-2 text-sm font-medium text-bg hover:bg-white disabled:opacity-50"> | |
| 160 | + | <SubmitButton fetcher={fetcher} pending="Keeping…"> | |
| 161 | 161 | Keep | |
| 162 | − | </button> | |
| 162 | + | </SubmitButton> | |
| 163 | 163 | </div> | |
| 164 | 164 | {fetcher.data?.error && <p className="text-sm text-danger">{fetcher.data.error}</p>} | |
| 165 | 165 | </fetcher.Form> | |
| 497 | 497 | <input type="hidden" name="intent" value="fix" /> | |
| 498 | 498 | <input type="hidden" name="project" value={project} /> | |
| 499 | 499 | <input type="hidden" name="rule" value={rule.rule} /> | |
| 500 | − | <button | |
| 501 | − | type="submit" | |
| 502 | − | disabled={fetcher.state !== "idle"} | |
| 500 | + | <SubmitButton | |
| 501 | + | fetcher={fetcher} | |
| 502 | + | pending="Opening…" | |
| 503 | 503 | title={`Opens “${rule.fix.title}” and puts an agent on it`} | |
| 504 | 504 | className="inline-flex shrink-0 items-center gap-1 rounded-md border border-line px-2 py-1 text-xs text-muted hover:border-accent-dim hover:text-fg disabled:opacity-50" | |
| 505 | 505 | > | |
| 506 | 506 | <Bot size={12} /> | |
| 507 | − | {fetcher.state !== "idle" ? "Opening…" : "Fix with an agent"} | |
| 508 | − | </button> | |
| 507 | + | Fix with an agent | |
| 508 | + | </SubmitButton> | |
| 509 | 509 | </fetcher.Form> | |
| 510 | 510 | )} | |
| 511 | 511 | </li> |
| 21 | 21 | HighlightedLine, | |
| 22 | 22 | } from "../lib/diff"; | |
| 23 | 23 | import { Markdown } from "./markdown"; | |
| 24 | − | import { Avatar, Button, EmptyState, Textarea, TimeAgo } from "./ui"; | |
| 24 | + | import { Avatar, Button, EmptyState, SubmitButton, Textarea, TimeAgo } from "./ui"; | |
| 25 | 25 | import { Checkbox } from "./ui/checkbox"; | |
| 26 | 26 | ||
| 27 | 27 | const ROW_STYLES: Record<DiffLine["kind"], string> = { | |
| 134 | 134 | open: boolean; | |
| 135 | 135 | onClose: () => void; | |
| 136 | 136 | }) { | |
| 137 | + | // The box stays, saying it is posting, until the new comment shows above it. | |
| 138 | + | const count = useRef(comments.length); | |
| 139 | + | useEffect(() => { | |
| 140 | + | if (comments.length > count.current) onClose(); | |
| 141 | + | count.current = comments.length; | |
| 142 | + | // eslint-disable-next-line react-hooks/exhaustive-deps -- only a new comment closes it | |
| 143 | + | }, [comments.length]); | |
| 137 | 144 | return ( | |
| 138 | 145 | <div className="max-w-2xl space-y-2"> | |
| 139 | 146 | {comments.map((comment) => ( | |
| 140 | 147 | <LineComment key={comment.id} comment={comment} /> | |
| 141 | 148 | ))} | |
| 142 | 149 | {open && ( | |
| 143 | − | <Form method="post" className="space-y-2 font-sans" onSubmit={onClose}> | |
| 150 | + | <Form method="post" className="space-y-2 font-sans"> | |
| 144 | 151 | <input type="hidden" name="action" value="comment" /> | |
| 145 | 152 | <input type="hidden" name="path" value={path} /> | |
| 146 | 153 | <input type="hidden" name="line" value={line} /> | |
| 147 | 154 | <Textarea name="body" rows={3} required autoFocus placeholder={`Comment on line ${line}`} /> | |
| 148 | 155 | <div className="flex gap-2"> | |
| 149 | − | <Button type="submit">Comment</Button> | |
| 156 | + | <SubmitButton match={{ action: "comment", path, line: String(line) }} pending="Commenting…"> | |
| 157 | + | Comment | |
| 158 | + | </SubmitButton> | |
| 150 | 159 | <Button variant="quiet" type="button" onClick={onClose}> | |
| 151 | 160 | Cancel | |
| 152 | 161 | </Button> |
| 5 | 5 | ||
| 6 | 6 | import { addressActions, backupChoices } from "../lib/emails"; | |
| 7 | 7 | import type { EmailActionData } from "../lib/emails.server"; | |
| 8 | − | import { Button, ErrorText, Field, Input, Pill, TimeAgo } from "./ui"; | |
| 8 | + | import { ErrorText, Field, Input, Pill, SubmitButton, TimeAgo } from "./ui"; | |
| 9 | 9 | import { SwitchCard } from "./ui/switch"; | |
| 10 | 10 | ||
| 11 | 11 | /** One small form posting one intent about one address. */ | |
| 12 | − | function AddressButton({ intent, email, label }: { intent: string; email: string; label: string }) { | |
| 12 | + | function AddressButton({ intent, email, label, pending }: { intent: string; email: string; label: string; pending: string }) { | |
| 13 | 13 | return ( | |
| 14 | 14 | <Form method="post"> | |
| 15 | 15 | <input type="hidden" name="intent" value={intent} /> | |
| 16 | 16 | <input type="hidden" name="email" value={email} /> | |
| 17 | − | <Button variant="quiet" type="submit"> | |
| 17 | + | <SubmitButton variant="quiet" pending={pending} match={{ intent, email }}> | |
| 18 | 18 | {label} | |
| 19 | − | </Button> | |
| 19 | + | </SubmitButton> | |
| 20 | 20 | </Form> | |
| 21 | 21 | ); | |
| 22 | 22 | } | |
| 64 | 64 | <Input name="password" type="password" autoComplete="current-password" required autoFocus /> | |
| 65 | 65 | </Field> | |
| 66 | 66 | </div> | |
| 67 | − | <Button type="submit">Confirm</Button> | |
| 67 | + | <SubmitButton pending="Confirming…" match={{ intent: pending.intent }}> | |
| 68 | + | Confirm | |
| 69 | + | </SubmitButton> | |
| 68 | 70 | </div> | |
| 69 | 71 | <p className="text-xs text-faint">You will not be asked again for 10 minutes.</p> | |
| 70 | 72 | </Form> | |
| 78 | 80 | ); | |
| 79 | 81 | } | |
| 80 | 82 | ||
| 81 | − | /** A person's email addresses, in their account settings. */ | |
| 83 | + | /** The answer to one form's post, under that form: what went wrong, or that it worked. */ | |
| 84 | + | function Answer({ actionData }: { actionData: EmailActionData | undefined }) { | |
| 85 | + | return ( | |
| 86 | + | <> | |
| 87 | + | <ErrorText>{actionData?.emailError}</ErrorText> | |
| 88 | + | {actionData?.emailNotice && ( | |
| 89 | + | <p role="status" className="mt-2 text-sm text-muted"> | |
| 90 | + | {actionData.emailNotice} | |
| 91 | + | </p> | |
| 92 | + | )} | |
| 93 | + | </> | |
| 94 | + | ); | |
| 95 | + | } | |
| 96 | + | ||
| 97 | + | /** | |
| 98 | + | * A person's email addresses, in their account settings. `intent` is the | |
| 99 | + | * form that last posted, so its answer shows beside it. | |
| 100 | + | */ | |
| 82 | 101 | export function EmailsSection({ | |
| 83 | 102 | data, | |
| 84 | 103 | actionData, | |
| 104 | + | intent, | |
| 85 | 105 | hasPassword, | |
| 86 | 106 | }: { | |
| 87 | 107 | data: AccountEmails | null; | |
| 88 | 108 | actionData: EmailActionData | undefined; | |
| 109 | + | intent?: string; | |
| 89 | 110 | hasPassword: boolean; | |
| 90 | 111 | }) { | |
| 91 | 112 | if (!data) return null; | |
| 113 | + | const answerFor = (one: string) => (intent === one ? actionData : undefined); | |
| 114 | + | const elsewhere = intent === "backup-email" || intent === "email-privacy"; | |
| 92 | 115 | const backups = backupChoices(data.emails); | |
| 93 | 116 | const backup = data.emails.find((email) => email.backup); | |
| 94 | 117 | const full = data.emails.length >= data.limit; | |
| 123 | 146 | </div> | |
| 124 | 147 | </div> | |
| 125 | 148 | <div className="flex shrink-0 flex-wrap gap-2 sm:justify-end"> | |
| 126 | − | {can.resend && <AddressButton intent="resend-email" email={email.email} label="Resend link" />} | |
| 127 | − | {can.makePrimary && <AddressButton intent="primary-email" email={email.email} label="Make primary" />} | |
| 128 | − | {can.remove && <AddressButton intent="remove-email" email={email.email} label="Remove" />} | |
| 149 | + | {can.resend && <AddressButton intent="resend-email" email={email.email} label="Resend link" pending="Sending…" />} | |
| 150 | + | {can.makePrimary && <AddressButton intent="primary-email" email={email.email} label="Make primary" pending="Saving…" />} | |
| 151 | + | {can.remove && <AddressButton intent="remove-email" email={email.email} label="Remove" pending="Removing…" />} | |
| 129 | 152 | </div> | |
| 130 | 153 | </li> | |
| 131 | 154 | ); | |
| 132 | 155 | })} | |
| 133 | 156 | </ul> | |
| 134 | 157 | ||
| 135 | − | <Form method="post" className="mt-4"> | |
| 158 | + | {/* Keyed on the list, so an address once added leaves the field empty. */} | |
| 159 | + | <Form key={data.emails.length} method="post" className="mt-4"> | |
| 136 | 160 | <input type="hidden" name="intent" value="add-email" /> | |
| 137 | 161 | <div className="flex flex-col gap-3 sm:flex-row sm:items-end"> | |
| 138 | 162 | <div className="grow"> | |
| 140 | 164 | <Input name="email" type="email" autoComplete="email" placeholder="you@example.com" required disabled={full} /> | |
| 141 | 165 | </Field> | |
| 142 | 166 | </div> | |
| 143 | − | <Button type="submit" disabled={full}> | |
| 167 | + | <SubmitButton pending="Adding…" match={{ intent: "add-email" }} disabled={full}> | |
| 144 | 168 | Add | |
| 145 | − | </Button> | |
| 169 | + | </SubmitButton> | |
| 146 | 170 | </div> | |
| 147 | 171 | <p className="mt-1.5 text-xs text-faint"> | |
| 148 | 172 | {full ? `You have ${data.limit} addresses, the most an account can have.` : "g1t sends it a link to confirm it."} | |
| 149 | 173 | </p> | |
| 150 | 174 | </Form> | |
| 151 | − | <ErrorText>{actionData?.emailError}</ErrorText> | |
| 152 | − | {actionData?.emailNotice && ( | |
| 153 | − | <p role="status" className="mt-2 text-sm text-muted"> | |
| 154 | − | {actionData.emailNotice} | |
| 155 | − | </p> | |
| 156 | − | )} | |
| 175 | + | <Answer actionData={elsewhere ? undefined : actionData} /> | |
| 157 | 176 | ||
| 158 | 177 | <div className="mt-8"> | |
| 159 | 178 | <h2 className="font-medium">Backup address</h2> | |
| 178 | 197 | ))} | |
| 179 | 198 | </select> | |
| 180 | 199 | </label> | |
| 181 | − | <Button variant="quiet" type="submit" disabled={backups.length === 0}> | |
| 200 | + | <SubmitButton variant="quiet" pending="Saving…" match={{ intent: "backup-email" }} disabled={backups.length === 0}> | |
| 182 | 201 | Save | |
| 183 | − | </Button> | |
| 202 | + | </SubmitButton> | |
| 184 | 203 | </Form> | |
| 204 | + | <Answer actionData={answerFor("backup-email")} /> | |
| 185 | 205 | {backups.length === 0 && ( | |
| 186 | 206 | <p className="mt-2 text-xs text-faint">Add and confirm a second address to choose a backup.</p> | |
| 187 | 207 | )} | |
| 203 | 223 | <p className="text-xs text-faint"> | |
| 204 | 224 | Commits g1t makes for you now carry <span className="font-mono [overflow-wrap:anywhere]">{data.commitEmail}</span>. | |
| 205 | 225 | </p> | |
| 206 | − | <Button variant="quiet" type="submit"> | |
| 226 | + | <SubmitButton variant="quiet" pending="Saving…" match={{ intent: "email-privacy" }}> | |
| 207 | 227 | Save | |
| 208 | − | </Button> | |
| 228 | + | </SubmitButton> | |
| 209 | 229 | </div> | |
| 210 | 230 | </Form> | |
| 231 | + | <Answer actionData={answerFor("email-privacy")} /> | |
| 211 | 232 | </div> | |
| 212 | 233 | </section> | |
| 213 | 234 | ); |
| 2 | 2 | import { Form } from "react-router"; | |
| 3 | 3 | import { siGithub } from "simple-icons"; | |
| 4 | 4 | ||
| 5 | − | import { TimeAgo } from "./ui"; | |
| 5 | + | import { SubmitButton, TimeAgo } from "./ui"; | |
| 6 | 6 | ||
| 7 | 7 | /** GitHub's mark (from Simple Icons), for the buttons that go there. */ | |
| 8 | 8 | export function GithubMark({ className = "size-4" }: { className?: string }) { | |
| 70 | 70 | )} | |
| 71 | 71 | {syncing && ( | |
| 72 | 72 | <Form method="post" className="ml-auto flex items-center gap-3"> | |
| 73 | − | <button type="submit" name="intent" value="github-sync" className="text-xs text-muted hover:text-fg"> | |
| 73 | + | <SubmitButton | |
| 74 | + | name="intent" | |
| 75 | + | value="github-sync" | |
| 76 | + | pending="Syncing…" | |
| 77 | + | className="inline-flex items-center gap-1 text-xs text-muted hover:text-fg disabled:opacity-50" | |
| 78 | + | > | |
| 74 | 79 | Sync now | |
| 75 | − | </button> | |
| 76 | − | <button type="submit" name="intent" value="github-stop" className="text-xs text-faint hover:text-danger"> | |
| 80 | + | </SubmitButton> | |
| 81 | + | <SubmitButton | |
| 82 | + | name="intent" | |
| 83 | + | value="github-stop" | |
| 84 | + | pending="Stopping…" | |
| 85 | + | className="inline-flex items-center gap-1 text-xs text-faint hover:text-danger disabled:opacity-50" | |
| 86 | + | > | |
| 77 | 87 | Stop {link.mode === "mirror" ? "mirroring" : "pushing"} | |
| 78 | − | </button> | |
| 88 | + | </SubmitButton> | |
| 79 | 89 | </Form> | |
| 80 | 90 | )} | |
| 81 | 91 | {link.lastError && <p className="w-full text-xs text-warn">{link.lastError}</p>} |
| 18 | 18 | ||
| 19 | 19 | import { formatCap, tri, workflowDomainLine } from "../lib/guardrails"; | |
| 20 | 20 | import { formatCost } from "./agents"; | |
| 21 | − | import { Button, ErrorText, Input, TimeAgo } from "./ui"; | |
| 21 | + | import { ErrorText, Input, SubmitButton, TimeAgo } from "./ui"; | |
| 22 | 22 | import { CheckboxOption } from "./ui/checkbox"; | |
| 23 | 23 | import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "./ui/select"; | |
| 24 | 24 | import { Textarea } from "./ui/textarea"; | |
| 97 | 97 | view: GuardrailsView; | |
| 98 | 98 | level: "workspace" | "project"; | |
| 99 | 99 | editable: boolean; | |
| 100 | − | saving: boolean; | |
| 100 | + | /** Working for a reason the Save button cannot see; it follows its own submission by itself. */ | |
| 101 | + | saving?: boolean; | |
| 101 | 102 | saved: boolean; | |
| 102 | 103 | error: string | null | undefined; | |
| 103 | 104 | }) { | |
| 111 | 112 | const inheritedDeny = level === "project" ? view.inherited.deny : []; | |
| 112 | 113 | const inheritedWorkflowDomains = level === "project" ? (view.inherited.workflowDomains ?? []) : []; | |
| 113 | 114 | return ( | |
| 114 | − | <Form method="post" className="max-w-4xl space-y-8"> | |
| 115 | + | // Keyed to the last change, so after a save the fields show what was kept, as it was tidied. | |
| 116 | + | <Form key={own.updatedAt ?? "unset"} method="post" className="max-w-4xl space-y-8"> | |
| 115 | 117 | <fieldset disabled={!editable} className="min-w-0 space-y-8"> | |
| 116 | 118 | <Section | |
| 117 | 119 | title="Network" | |
| 310 | 312 | ||
| 311 | 313 | {editable ? ( | |
| 312 | 314 | <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"> | |
| 313 | − | <Button type="submit" disabled={saving}> | |
| 314 | − | {saving ? "Saving…" : "Save guardrails"} | |
| 315 | − | </Button> | |
| 315 | + | <SubmitButton pending="Saving…" busy={saving}> | |
| 316 | + | Save guardrails | |
| 317 | + | </SubmitButton> | |
| 316 | 318 | {saved && <span className="text-sm text-muted">Saved. Runs that start from now on get these.</span>} | |
| 317 | 319 | <ErrorText>{error}</ErrorText> | |
| 318 | 320 | {own.updatedBy && own.updatedAt && !saved && !error && ( |
| 3 | 3 | ||
| 4 | 4 | import type { Invite, InvitesOverview } from "@g1t/contracts"; | |
| 5 | 5 | ||
| 6 | − | import { Button, CopyLine, ErrorText, Field, Input, TimeAgo } from "./ui"; | |
| 6 | + | import { CopyLine, ErrorText, Field, Input, SubmitButton, TimeAgo } from "./ui"; | |
| 7 | 7 | import { inviteFor, inviteLink, inviteState, moreInvitesMailto, remainingLine } from "../lib/invites"; | |
| 8 | 8 | ||
| 9 | 9 | const TONE: Record<"pending" | "done" | "dead", string> = { | |
| 23 | 23 | <Form method="post" className="ml-auto"> | |
| 24 | 24 | <input type="hidden" name="intent" value="revoke-invite" /> | |
| 25 | 25 | <input type="hidden" name="id" value={invite.id} /> | |
| 26 | − | <Button variant="quiet" type="submit"> | |
| 26 | + | <SubmitButton variant="quiet" match={{ intent: "revoke-invite", id: invite.id }} pending="Revoking…"> | |
| 27 | 27 | Revoke | |
| 28 | − | </Button> | |
| 28 | + | </SubmitButton> | |
| 29 | 29 | </Form> | |
| 30 | 30 | )} | |
| 31 | 31 | </div> | |
| 112 | 112 | </p> | |
| 113 | 113 | </div> | |
| 114 | 114 | ) : ( | |
| 115 | − | <Form method="post" className="mt-4 space-y-3"> | |
| 115 | + | // Empty again once the invite is made; kept as filled in when it failed. | |
| 116 | + | <Form method="post" key={created?.id ?? ""} className="mt-4 space-y-3"> | |
| 116 | 117 | <input type="hidden" name="intent" value="create-invite" /> | |
| 117 | 118 | <div className="flex flex-col gap-3 sm:flex-row sm:items-end"> | |
| 118 | 119 | <div className="grow"> | |
| 138 | 139 | </select> | |
| 139 | 140 | </label> | |
| 140 | 141 | )} | |
| 141 | − | <Button type="submit">Create invite</Button> | |
| 142 | + | <SubmitButton match={{ intent: "create-invite" }} pending="Creating…"> | |
| 143 | + | Create invite | |
| 144 | + | </SubmitButton> | |
| 142 | 145 | </div> | |
| 143 | 146 | <p className="text-xs text-faint"> | |
| 144 | 147 | With an email, the invite is sent there and only that address can use it. |
| 9 | 9 | ||
| 10 | 10 | import { MEMORY_KINDS, type Memory, type MemoryKind, type MemoryScope, type Result, type User as Actor } from "@g1t/contracts"; | |
| 11 | 11 | ||
| 12 | − | import { TimeAgo } from "./ui"; | |
| 12 | + | import { SubmitButton, TimeAgo } from "./ui"; | |
| 13 | 13 | import { | |
| 14 | 14 | AlertDialog, | |
| 15 | 15 | AlertDialogAction, | |
| 127 | 127 | <textarea name="text" required rows={4} maxLength={1000} defaultValue={memory.text} className={TEXTAREA} /> | |
| 128 | 128 | <div className="flex items-center justify-between gap-3"> | |
| 129 | 129 | <KindSelect value={memory.kind} /> | |
| 130 | − | <button | |
| 131 | − | type="submit" | |
| 132 | − | disabled={fetcher.state !== "idle"} | |
| 133 | − | className="rounded-md bg-fg px-3.5 py-2 text-sm font-medium text-bg hover:bg-white disabled:opacity-50" | |
| 134 | − | > | |
| 130 | + | <SubmitButton fetcher={fetcher} pending="Saving…"> | |
| 135 | 131 | Save | |
| 136 | − | </button> | |
| 132 | + | </SubmitButton> | |
| 137 | 133 | </div> | |
| 138 | 134 | {fetcher.data?.error && <p className="text-sm text-danger">{fetcher.data.error}</p>} | |
| 139 | 135 | </fetcher.Form> | |
| 176 | 172 | aria-label={pinned ? "Unpin" : "Pin"} | |
| 177 | 173 | title={pinned ? "Unpin" : "Pin: given to every agent first"} | |
| 178 | 174 | onClick={() => fetcher.submit({ intent: "update", id: memory.id, pinned: String(!pinned) }, { method: "post", action })} | |
| 179 | − | className="rounded p-1 text-faint transition-colors hover:bg-raised hover:text-fg" | |
| 175 | + | disabled={fetcher.state !== "idle"} | |
| 176 | + | className="rounded p-1 text-faint transition-colors hover:bg-raised hover:text-fg disabled:opacity-50" | |
| 180 | 177 | > | |
| 181 | 178 | {pinned ? <PinOff size={13} /> : <Pin size={13} />} | |
| 182 | 179 | </button> | |
| 252 | 249 | Pin it | |
| 253 | 250 | </label> | |
| 254 | 251 | <span className="grow" /> | |
| 255 | − | <button | |
| 256 | − | type="submit" | |
| 257 | − | disabled={fetcher.state !== "idle"} | |
| 258 | − | className="rounded-md bg-fg px-3.5 py-2 text-sm font-medium text-bg hover:bg-white disabled:opacity-50" | |
| 259 | − | > | |
| 260 | − | {fetcher.state !== "idle" ? "Adding…" : "Add"} | |
| 261 | − | </button> | |
| 252 | + | <SubmitButton fetcher={fetcher} pending="Adding…"> | |
| 253 | + | Add | |
| 254 | + | </SubmitButton> | |
| 262 | 255 | </div> | |
| 263 | 256 | {fetcher.data?.error && <p className="mt-2 text-sm text-danger">{fetcher.data.error}</p>} | |
| 264 | 257 | <p className="mt-2 text-xs text-faint"> |
| 28 | 28 | import { catchUpPhase, catchUpRun, catchUpTitle, catchUpWhy } from "../lib/catch-up"; | |
| 29 | 29 | import { duration } from "./actions"; | |
| 30 | 30 | import { Elapsed, useRuns } from "./agents"; | |
| 31 | − | import { Button, CopyLine, ErrorText, TimeAgo } from "./ui"; | |
| 31 | + | import { Button, CopyLine, ErrorText, SubmitButton, TimeAgo } from "./ui"; | |
| 32 | 32 | import { Tooltip, TooltipContent, TooltipTrigger } from "./ui/tooltip"; | |
| 33 | 33 | ||
| 34 | 34 | // --- Output --------------------------------------------------------------- | |
| 408 | 408 | <Form method="post" key={status.context}> | |
| 409 | 409 | <input type="hidden" name="action" value="rerun-workflow" /> | |
| 410 | 410 | <input type="hidden" name="run" value={runIdOf(status) ?? ""} /> | |
| 411 | − | <Button variant="quiet" type="submit" title={`Re-run the failed jobs of ${status.context}`}> | |
| 411 | + | <SubmitButton | |
| 412 | + | variant="quiet" | |
| 413 | + | match={{ action: "rerun-workflow", run: runIdOf(status) ?? "" }} | |
| 414 | + | pending="Re-running…" | |
| 415 | + | title={`Re-run the failed jobs of ${status.context}`} | |
| 416 | + | > | |
| 412 | 417 | <RotateCw size={13} /> | |
| 413 | 418 | Re-run failed jobs{failedRuns.length > 1 ? ` of ${status.context}` : ""} | |
| 414 | − | </Button> | |
| 419 | + | </SubmitButton> | |
| 415 | 420 | </Form> | |
| 416 | 421 | ))} | |
| 417 | 422 | </div> |
| 33 | 33 | import { useLiveRefresh } from "./agents"; | |
| 34 | 34 | import { Unavailable } from "./mission"; | |
| 35 | 35 | import { TokenUsagePanel } from "./token-usage"; | |
| 36 | − | import { Avatar, TimeAgo } from "./ui"; | |
| 36 | + | import { Avatar, SubmitButton, TimeAgo } from "./ui"; | |
| 37 | 37 | import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuLabel, DropdownMenuTrigger } from "./ui/dropdown-menu"; | |
| 38 | 38 | import type { Loaded } from "../routes/home"; | |
| 39 | 39 | ||
| 117 | 117 | ); | |
| 118 | 118 | } | |
| 119 | 119 | ||
| 120 | − | /** A form that acts on a pull request from here, saying so before the page catches up. */ | |
| 120 | + | /** | |
| 121 | + | * A form that acts on a pull request from here: working until the action | |
| 122 | + | * has answered and the page has reloaded what it changed, then done, or | |
| 123 | + | * the error beside it. | |
| 124 | + | */ | |
| 121 | 125 | function QuickForm({ quick, variant = "quiet" }: { quick: QuickAction; variant?: "quiet" | "accent" }) { | |
| 122 | 126 | const fetcher = useFetcher<{ error?: string } | null>(); | |
| 123 | 127 | const [sent, setSent] = useState(false); | |
| 124 | − | const busy = fetcher.state !== "idle"; | |
| 125 | − | // Shown as done the moment it is sent, and undone if it fails: the page | |
| 126 | − | // reloads behind it, which takes longer than the action. | |
| 127 | − | const done = sent && (busy || !fetcher.data?.error); | |
| 128 | + | const done = sent && fetcher.state === "idle" && !fetcher.data?.error; | |
| 128 | 129 | return ( | |
| 129 | 130 | <fetcher.Form method="post" action={quick.to} onSubmit={() => setSent(true)} className="contents"> | |
| 130 | 131 | {Object.entries(quick.fields).map(([name, value]) => ( | |
| 131 | 132 | <input key={name} type="hidden" name={name} value={value} /> | |
| 132 | 133 | ))} | |
| 133 | − | <button | |
| 134 | − | type="submit" | |
| 135 | − | disabled={busy || done} | |
| 134 | + | <SubmitButton | |
| 135 | + | fetcher={fetcher} | |
| 136 | + | disabled={done} | |
| 136 | 137 | className={cn( | |
| 137 | 138 | "inline-flex items-center gap-1.5 rounded-md px-3 py-1.5 text-sm font-medium transition-colors disabled:cursor-default", | |
| 138 | 139 | variant === "accent" | |
| 143 | 144 | > | |
| 144 | 145 | {done ? <Check size={14} /> : null} | |
| 145 | 146 | {done ? quick.done : quick.label} | |
| 146 | − | </button> | |
| 147 | + | </SubmitButton> | |
| 147 | 148 | {fetcher.data?.error && <span className="basis-full text-xs text-danger">{fetcher.data.error}</span>} | |
| 148 | 149 | </fetcher.Form> | |
| 149 | 150 | ); |
| 9 | 9 | ||
| 10 | 10 | import { type Connection, MODEL_TASKS, type ModelRoute, type ModelTask, type Provider, PROVIDERS } from "@g1t/contracts"; | |
| 11 | 11 | ||
| 12 | − | import { Button, Field, Input } from "./ui"; | |
| 12 | + | import { Field, Input, SubmitButton } from "./ui"; | |
| 13 | 13 | import { Select, SelectContent, SelectGroup, SelectItem, SelectLabel, SelectSeparator, SelectTrigger, SelectValue } from "./ui/select"; | |
| 14 | 14 | ||
| 15 | 15 | type Group = "labs" | "platforms" | "any"; | |
| 361 | 361 | hostedOpen, | |
| 362 | 362 | marginPercent, | |
| 363 | 363 | owner, | |
| 364 | − | busy, | |
| 365 | 364 | saved, | |
| 366 | 365 | }: { | |
| 367 | 366 | connections: Connection[]; | |
| 369 | 368 | hostedOpen: boolean; | |
| 370 | 369 | marginPercent: number; | |
| 371 | 370 | owner: boolean; | |
| 372 | − | busy: boolean; | |
| 373 | 371 | saved: boolean; | |
| 374 | 372 | }) { | |
| 375 | 373 | const fallback: Choice = hostedOpen || connections.length === 0 ? { target: "g1t", model: "" } : { target: connections[0].id, model: "" }; | |
| 477 | 475 | </ul> | |
| 478 | 476 | {owner && ( | |
| 479 | 477 | <div className="flex items-center gap-3 border-t border-line px-4 py-3"> | |
| 480 | − | <Button type="submit" variant="quiet" disabled={busy}> | |
| 478 | + | <SubmitButton variant="quiet" match={{ intent: "routes" }} pending="Saving…"> | |
| 481 | 479 | Save routing | |
| 482 | − | </Button> | |
| 480 | + | </SubmitButton> | |
| 483 | 481 | {saved && <span className="text-sm text-accent">Saved. The next runs use it.</span>} | |
| 484 | 482 | </div> | |
| 485 | 483 | )} |
| 6 | 6 | import { useSignUpCopy } from "../lib/registration"; | |
| 7 | 7 | import { missingKind, notFoundCopy } from "../lib/not-found"; | |
| 8 | 8 | import { Pixel404 } from "./logo"; | |
| 9 | − | import { ButtonLink } from "./ui"; | |
| 9 | + | import { ButtonLink, SubmitButton } from "./ui"; | |
| 10 | 10 | ||
| 11 | 11 | /** | |
| 12 | 12 | * The page for anything someone cannot see. It reads only the kind of thing | |
| 74 | 74 | className="mt-3 text-xs text-faint" | |
| 75 | 75 | > | |
| 76 | 76 | Signed in as <span className="font-mono text-muted">@{copy.signedInAs}</span> ·{" "} | |
| 77 | − | <button type="submit" className="text-muted underline-offset-4 hover:text-fg hover:underline"> | |
| 77 | + | <SubmitButton | |
| 78 | + | pending="Signing out…" | |
| 79 | + | className="inline-flex items-center gap-1 text-muted underline-offset-4 hover:text-fg hover:underline disabled:opacity-50" | |
| 80 | + | > | |
| 78 | 81 | Switch account | |
| 79 | − | </button> | |
| 82 | + | </SubmitButton> | |
| 80 | 83 | </Form> | |
| 81 | 84 | )} | |
| 82 | 85 | </main> |
| 1 | 1 | import { Check } from "lucide-react"; | |
| 2 | 2 | import { useState } from "react"; | |
| 3 | − | import { Form, Link, useNavigation } from "react-router"; | |
| 3 | + | import { Form, Link } from "react-router"; | |
| 4 | 4 | ||
| 5 | 5 | import { PROFILE_LIMITS, type Profile } from "@g1t/contracts"; | |
| 6 | 6 | ||
| 7 | − | import { Button } from "./ui"; | |
| 7 | + | import { SubmitButton, usePending } from "./ui"; | |
| 8 | 8 | import { Field, FieldDescription, FieldError, FieldLabel } from "./ui/field"; | |
| 9 | 9 | import { Input } from "./ui/input"; | |
| 10 | 10 | import { Textarea } from "./ui/textarea"; | |
| 24 | 24 | error?: string; | |
| 25 | 25 | saved: boolean; | |
| 26 | 26 | }) { | |
| 27 | − | const navigation = useNavigation(); | |
| 28 | − | const busy = navigation.state === "submitting" && navigation.formData?.get("intent") === "profile"; | |
| 27 | + | const busy = usePending({ intent: "profile" }); | |
| 29 | 28 | const [bio, setBio] = useState(profile?.bio ?? ""); | |
| 30 | 29 | return ( | |
| 31 | 30 | <section id="profile" className="scroll-mt-20"> | |
| 99 | 98 | <FieldDescription>An https:// address.</FieldDescription> | |
| 100 | 99 | </Field> | |
| 101 | 100 | <div className="flex flex-wrap items-center gap-3 sm:col-span-2"> | |
| 102 | − | <Button type="submit" disabled={busy}> | |
| 103 | − | {busy ? "Saving…" : "Save profile"} | |
| 104 | − | </Button> | |
| 101 | + | <SubmitButton pending="Saving…" match={{ intent: "profile" }}> | |
| 102 | + | Save profile | |
| 103 | + | </SubmitButton> | |
| 105 | 104 | {saved && !busy && !error && ( | |
| 106 | 105 | <span className="inline-flex items-center gap-1.5 text-sm text-muted"> | |
| 107 | 106 | <Check size={14} className="text-accent" /> |
| 1 | 1 | import { Archive } from "lucide-react"; | |
| 2 | − | import { type ReactNode, useId, useState } from "react"; | |
| 3 | − | import { Form, Link, useNavigation } from "react-router"; | |
| 2 | + | import { type ReactNode, useEffect, useId, useRef, useState } from "react"; | |
| 3 | + | import { Form, Link } from "react-router"; | |
| 4 | 4 | ||
| 5 | − | import { Button, ErrorText } from "./ui"; | |
| 5 | + | import { ErrorText, SubmitButton, usePending } from "./ui"; | |
| 6 | 6 | import { | |
| 7 | 7 | AlertDialog, | |
| 8 | 8 | AlertDialogCancel, | |
| 56 | 56 | }) { | |
| 57 | 57 | const [open, setOpen] = useState(Boolean(error)); | |
| 58 | 58 | const [typed, setTyped] = useState(""); | |
| 59 | − | const navigation = useNavigation(); | |
| 60 | 59 | const id = useId(); | |
| 61 | − | const posting = | |
| 62 | − | navigation.state !== "idle" && | |
| 63 | − | navigation.formData?.get("intent") === intent && | |
| 64 | − | Object.entries(fields ?? {}).every(([k, v]) => navigation.formData?.get(k) === v); | |
| 60 | + | const posting = usePending({ intent, ...fields }); | |
| 61 | + | // Done without an error, it closes: the page behind it now shows the change. | |
| 62 | + | const wasPosting = useRef(false); | |
| 63 | + | useEffect(() => { | |
| 64 | + | if (wasPosting.current && !posting && !error) { | |
| 65 | + | setOpen(false); | |
| 66 | + | setTyped(""); | |
| 67 | + | } | |
| 68 | + | wasPosting.current = posting; | |
| 69 | + | }, [posting, error]); | |
| 65 | 70 | const ready = confirm == null || confirmsName(typed, confirm); | |
| 66 | 71 | return ( | |
| 67 | 72 | <> | |
| 104 | 109 | <ErrorText>{error}</ErrorText> | |
| 105 | 110 | <AlertDialogFooter> | |
| 106 | 111 | <AlertDialogCancel type="button">Cancel</AlertDialogCancel> | |
| 107 | − | <Button type="submit" variant={danger ? "danger" : "primary"} disabled={!ready || posting}> | |
| 108 | − | {posting ? busy : submit} | |
| 109 | − | </Button> | |
| 112 | + | <SubmitButton variant={danger ? "danger" : "primary"} disabled={!ready} match={{ intent, ...fields }} pending={busy}> | |
| 113 | + | {submit} | |
| 114 | + | </SubmitButton> | |
| 110 | 115 | </AlertDialogFooter> | |
| 111 | 116 | </Form> | |
| 112 | 117 | </AlertDialogContent> |
| 1 | 1 | import { Bot, Boxes, Cpu, Plus, ServerCog, ShieldAlert, Trash2 } from "lucide-react"; | |
| 2 | − | import { useState } from "react"; | |
| 3 | − | import { Form, Link, useNavigation } from "react-router"; | |
| 2 | + | import { useEffect, useState } from "react"; | |
| 3 | + | import { Form, Link } from "react-router"; | |
| 4 | 4 | ||
| 5 | 5 | import { type RegistrationToken, RUNNER_DOWNLOADS, RUNNER_FILES, RUNNER_IMAGE, type Runner, type RunnerGroup } from "@g1t/contracts"; | |
| 6 | 6 | ||
| 7 | 7 | import type { RunnersAction, RunnersData } from "../lib/runners.server"; | |
| 8 | − | import { Button, CopyLine, EmptyState, ErrorText, Field, Input, Pill, TimeAgo } from "./ui"; | |
| 8 | + | import { Button, CopyLine, EmptyState, ErrorText, Field, Input, Pill, SubmitButton, TimeAgo } from "./ui"; | |
| 9 | 9 | import { Switch } from "./ui/switch"; | |
| 10 | 10 | import { Tabs, TabsList, TabsTrigger } from "./ui/tabs"; | |
| 11 | 11 | ||
| 34 | 34 | ||
| 35 | 35 | function RunnerRow({ runner, manage, base }: { runner: Runner; manage: boolean; base: (repo: string) => string }) { | |
| 36 | 36 | const status = STATUS[runner.status]; | |
| 37 | − | const busy = useNavigation().state === "submitting"; | |
| 38 | 37 | return ( | |
| 39 | 38 | <li className="flex flex-wrap items-start gap-x-4 gap-y-2 px-4 py-3"> | |
| 40 | 39 | <span className={`mt-1.5 size-2 shrink-0 rounded-full ${status.dot}`} aria-hidden /> | |
| 84 | 83 | <input type="hidden" name="intent" value="remove" /> | |
| 85 | 84 | <input type="hidden" name="id" value={runner.id} /> | |
| 86 | 85 | <input type="hidden" name="name" value={runner.name} /> | |
| 87 | − | <button | |
| 88 | − | type="submit" | |
| 89 | − | disabled={busy} | |
| 86 | + | <SubmitButton | |
| 87 | + | icon | |
| 88 | + | match={{ intent: "remove", id: runner.id }} | |
| 90 | 89 | aria-label={`Remove ${runner.name}`} | |
| 91 | − | className="rounded-md p-1.5 text-faint transition-colors hover:bg-raised hover:text-danger" | |
| 90 | + | className="rounded-md p-1.5 text-faint transition-colors hover:bg-raised hover:text-danger disabled:opacity-50" | |
| 92 | 91 | > | |
| 93 | 92 | <Trash2 size={15} /> | |
| 94 | − | </button> | |
| 93 | + | </SubmitButton> | |
| 95 | 94 | </Form> | |
| 96 | 95 | )} | |
| 97 | 96 | </li> | |
| 146 | 145 | function NewRunner({ token, groups, repoScoped }: { token: RegistrationToken | null; groups: RunnerGroup[]; repoScoped: boolean }) { | |
| 147 | 146 | const [platform, setPlatform] = useState<Platform>("linux"); | |
| 148 | 147 | const [arch, setArch] = useState<"x64" | "arm64">("x64"); | |
| 149 | − | const busy = useNavigation().state === "submitting"; | |
| 150 | 148 | return ( | |
| 151 | 149 | <div className="space-y-4 rounded-xl border border-line bg-surface p-4"> | |
| 152 | 150 | {!token ? ( | |
| 163 | 161 | </select> | |
| 164 | 162 | </Field> | |
| 165 | 163 | )} | |
| 166 | − | <Button type="submit" disabled={busy}> | |
| 164 | + | <SubmitButton match={{ intent: "token" }} pending="Making a token…"> | |
| 167 | 165 | <Plus size={15} /> | |
| 168 | 166 | New runner | |
| 169 | − | </Button> | |
| 167 | + | </SubmitButton> | |
| 170 | 168 | <p className="basis-full text-xs text-faint">Makes a registration token that lasts an hour and registers any number of runners. It is shown once.</p> | |
| 171 | 169 | </Form> | |
| 172 | 170 | ) : ( | |
| 239 | 237 | ); | |
| 240 | 238 | } | |
| 241 | 239 | ||
| 242 | − | function Groups({ groups, repositories, manage }: { groups: RunnerGroup[]; repositories: string[]; manage: boolean }) { | |
| 240 | + | function Groups({ | |
| 241 | + | groups, | |
| 242 | + | repositories, | |
| 243 | + | manage, | |
| 244 | + | action, | |
| 245 | + | }: { | |
| 246 | + | groups: RunnerGroup[]; | |
| 247 | + | repositories: string[]; | |
| 248 | + | manage: boolean; | |
| 249 | + | action: RunnersAction | undefined; | |
| 250 | + | }) { | |
| 243 | 251 | const [editing, setEditing] = useState<string | null>(null); | |
| 252 | + | // A group saved closes its form: the list above now shows it as saved. | |
| 253 | + | useEffect(() => { | |
| 254 | + | if (action?.notice) setEditing(null); | |
| 255 | + | }, [action]); | |
| 244 | 256 | return ( | |
| 245 | 257 | <div className="space-y-3"> | |
| 246 | 258 | <ul className="divide-y divide-line rounded-xl border border-line bg-surface"> | |
| 262 | 274 | <Form method="post" onSubmit={(event) => { if (!confirm(`Delete ${group.name}? Its runners join the default group.`)) event.preventDefault(); }}> | |
| 263 | 275 | <input type="hidden" name="intent" value="delete-group" /> | |
| 264 | 276 | <input type="hidden" name="id" value={group.id} /> | |
| 265 | − | <Button type="submit" variant="quiet"> | |
| 277 | + | <SubmitButton variant="quiet" match={{ intent: "delete-group", id: group.id }} pending="Deleting…"> | |
| 266 | 278 | Delete | |
| 267 | − | </Button> | |
| 279 | + | </SubmitButton> | |
| 268 | 280 | </Form> | |
| 269 | 281 | )} | |
| 270 | 282 | </span> | |
| 286 | 298 | ||
| 287 | 299 | function GroupForm({ group, repositories }: { group: RunnerGroup | null; repositories: string[] }) { | |
| 288 | 300 | const [reach, setReach] = useState(group && group.repositories.length > 0 ? "some" : "all"); | |
| 289 | − | const busy = useNavigation().state === "submitting"; | |
| 290 | 301 | return ( | |
| 291 | 302 | <Form method="post" className="mt-3 space-y-3 rounded-lg border border-line bg-bg p-3"> | |
| 292 | 303 | <input type="hidden" name="intent" value="group" /> | |
| 315 | 326 | </div> | |
| 316 | 327 | )} | |
| 317 | 328 | </fieldset> | |
| 318 | − | <Button type="submit" disabled={busy}> | |
| 329 | + | <SubmitButton match={{ intent: "group", id: group?.id }} pending="Saving…"> | |
| 319 | 330 | Save group | |
| 320 | − | </Button> | |
| 331 | + | </SubmitButton> | |
| 321 | 332 | </Form> | |
| 322 | 333 | ); | |
| 323 | 334 | } | |
| 324 | 335 | ||
| 325 | 336 | function Settings({ data, manage, scope }: { data: RunnersData; manage: boolean; scope: "workspace" | "project" }) { | |
| 326 | 337 | const settings = data.settings; | |
| 327 | − | const busy = useNavigation().state === "submitting"; | |
| 328 | 338 | const [agents, setAgents] = useState(settings?.agentsOnSelfHosted ?? false); | |
| 329 | 339 | const [forks, setForks] = useState(settings?.forkPullRequests ?? false); | |
| 330 | 340 | if (!settings) return null; | |
| 341 | + | // The intent comes from the button pressed: a hidden one before it would | |
| 342 | + | // win the form's first "intent", and Follow the workspace would only save. | |
| 331 | 343 | return ( | |
| 332 | 344 | <Form method="post" className="space-y-4"> | |
| 333 | − | <input type="hidden" name="intent" value="settings" /> | |
| 334 | 345 | {scope === "project" && settings.inherited && ( | |
| 335 | 346 | <p className="text-sm text-muted">These are the workspace's settings. Saving here gives this project its own.</p> | |
| 336 | 347 | )} | |
| 369 | 380 | </p> | |
| 370 | 381 | {manage && ( | |
| 371 | 382 | <div className="flex flex-wrap gap-3"> | |
| 372 | − | <Button type="submit" disabled={busy}> | |
| 383 | + | <SubmitButton name="intent" value="settings" pending="Saving…"> | |
| 373 | 384 | Save | |
| 374 | − | </Button> | |
| 385 | + | </SubmitButton> | |
| 375 | 386 | {scope === "project" && !settings.inherited && ( | |
| 376 | − | <Button type="submit" name="intent" value="inherit" variant="quiet" disabled={busy}> | |
| 387 | + | <SubmitButton name="intent" value="inherit" variant="quiet" pending="Following…"> | |
| 377 | 388 | Follow the workspace | |
| 378 | − | </Button> | |
| 389 | + | </SubmitButton> | |
| 379 | 390 | )} | |
| 380 | 391 | </div> | |
| 381 | 392 | )} | |
| 444 | 455 | title="Groups" | |
| 445 | 456 | about="Which repositories may use which runners. A runner joins the default group, every repository, unless its token names another." | |
| 446 | 457 | > | |
| 447 | − | <Groups groups={data.groups} repositories={data.repositories} manage={manage} /> | |
| 458 | + | <Groups groups={data.groups} repositories={data.repositories} manage={manage} action={action} /> | |
| 448 | 459 | </Section> | |
| 449 | 460 | )} | |
| 450 | 461 | <Section | |
| 452 | 463 | title="Where work runs" | |
| 453 | 464 | about={scope === "workspace" ? "For every project in the workspace, unless a project says otherwise." : "For this project."} | |
| 454 | 465 | > | |
| 455 | − | <Settings data={data} manage={manage} scope={scope} /> | |
| 466 | + | {/* Keyed to what is saved, so following the workspace again shows its settings, not the switches as they were. */} | |
| 467 | + | <Settings | |
| 468 | + | key={data.settings ? `${data.settings.inherited}:${data.settings.agentsOnSelfHosted}:${data.settings.forkPullRequests}:${data.settings.agentLabels.join(",")}` : "none"} | |
| 469 | + | data={data} | |
| 470 | + | manage={manage} | |
| 471 | + | scope={scope} | |
| 472 | + | /> | |
| 456 | 473 | </Section> | |
| 457 | 474 | </div> | |
| 458 | 475 | ); |
| 7 | 7 | */ | |
| 8 | 8 | import { Lock, Pencil, Plus, Search, SlidersHorizontal, Trash2, X } from "lucide-react"; | |
| 9 | 9 | import { useMemo, useState } from "react"; | |
| 10 | − | import { Form, Link, useLocation, useNavigation } from "react-router"; | |
| 10 | + | import { Form, Link, useLocation } from "react-router"; | |
| 11 | 11 | ||
| 12 | 12 | import type { Setting } from "@g1t/contracts"; | |
| 13 | 13 | ||
| 14 | 14 | import type { SecretsAction, SecretsData } from "../lib/secrets.server"; | |
| 15 | − | import { Button, ButtonLink, EmptyState, ErrorText, TimeAgo } from "./ui"; | |
| 15 | + | import { ButtonLink, EmptyState, ErrorText, SubmitButton, TimeAgo } from "./ui"; | |
| 16 | 16 | import { CheckboxOption } from "./ui/checkbox"; | |
| 17 | 17 | import { RadioCard, RadioGroup, RadioOption } from "./ui/radio-group"; | |
| 18 | 18 | import { Select, SelectContent, SelectItem, SelectSeparator, SelectTrigger, SelectValue } from "./ui/select"; | |
| 150 | 150 | </div> | |
| 151 | 151 | ||
| 152 | 152 | {manage && (adding || row) && ( | |
| 153 | − | <Drawer row={row} scope={scope} projects={data.projects} error={action?.error} /> | |
| 153 | + | // Keyed to the row, so going from one row's edit to another's starts from that row. | |
| 154 | + | <Drawer key={row?.id ?? "add"} row={row} scope={scope} projects={data.projects} error={action?.error} /> | |
| 154 | 155 | )} | |
| 155 | 156 | </div> | |
| 156 | 157 | ); | |
| 157 | 158 | } | |
| 158 | 159 | ||
| 159 | 160 | function Row({ row, inherited, manage }: { row: Setting; inherited: boolean; manage: boolean }) { | |
| 160 | − | const busy = useNavigation().state === "submitting"; | |
| 161 | 161 | const secret = row.kind === "secret"; | |
| 162 | 162 | return ( | |
| 163 | 163 | <li className="grid grid-cols-[minmax(0,1.4fr)_minmax(0,1fr)_auto] items-center gap-x-4 gap-y-1 border-t border-line px-4 py-3 text-sm first:border-t-0 md:grid-cols-[minmax(0,1.4fr)_minmax(0,1fr)_minmax(0,1fr)_6rem_6rem_auto]"> | |
| 200 | 200 | <input type="hidden" name="intent" value="delete" /> | |
| 201 | 201 | <input type="hidden" name="id" value={row.id} /> | |
| 202 | 202 | <input type="hidden" name="name" value={row.name} /> | |
| 203 | − | <button | |
| 204 | − | type="submit" | |
| 205 | − | disabled={busy} | |
| 203 | + | <SubmitButton | |
| 204 | + | icon | |
| 205 | + | match={{ intent: "delete", id: row.id }} | |
| 206 | 206 | aria-label={`Remove ${row.name}`} | |
| 207 | − | className="rounded-md p-1.5 text-faint transition-colors hover:bg-raised hover:text-danger" | |
| 207 | + | className="rounded-md p-1.5 text-faint transition-colors hover:bg-raised hover:text-danger disabled:opacity-50" | |
| 208 | 208 | > | |
| 209 | 209 | <Trash2 size={14} /> | |
| 210 | − | </button> | |
| 210 | + | </SubmitButton> | |
| 211 | 211 | </Form> | |
| 212 | 212 | </> | |
| 213 | 213 | )} | |
| 229 | 229 | projects: string[]; | |
| 230 | 230 | error: string | undefined; | |
| 231 | 231 | }) { | |
| 232 | − | const busy = useNavigation().state === "submitting"; | |
| 233 | 232 | const editing = !!row; | |
| 234 | 233 | const [type, setType] = useState<"secret" | "config">(row?.kind === "variable" ? "config" : "secret"); | |
| 235 | 234 | const [some, setSome] = useState(!!row && row.environments.length > 0); | |
| 403 | 402 | </div> | |
| 404 | 403 | <div className="flex items-center justify-between gap-4 border-t border-line px-6 py-4"> | |
| 405 | 404 | <p className="text-xs text-faint">{editing ? "" : "Paste .env contents into Key to add many."}</p> | |
| 406 | − | <Button type="submit" disabled={busy}> | |
| 405 | + | <SubmitButton match={{ intent: "save" }} pending="Saving…"> | |
| 407 | 406 | Save | |
| 408 | − | </Button> | |
| 407 | + | </SubmitButton> | |
| 409 | 408 | </div> | |
| 410 | 409 | </Form> | |
| 411 | 410 | </div> |
| 184 | 184 | const [open, setOpen] = useState(false); | |
| 185 | 185 | const [entries, setEntries] = useState<LedgerEntry[]>([]); | |
| 186 | 186 | const [done, setDone] = useState(false); | |
| 187 | − | const fetcher = useFetcher<LedgerEntry[]>(); | |
| 187 | + | const fetcher = useFetcher<LedgerEntry[] | { error: string }>(); | |
| 188 | 188 | const [seen, setSeen] = useState<LedgerEntry[] | undefined>(undefined); | |
| 189 | + | const failed = fetcher.data && !Array.isArray(fetcher.data) ? fetcher.data.error : null; | |
| 189 | 190 | ||
| 190 | − | // Each page that arrives is added once. | |
| 191 | − | if (fetcher.data && fetcher.data !== seen) { | |
| 192 | − | setSeen(fetcher.data); | |
| 193 | − | setEntries((before) => [...before, ...fetcher.data!]); | |
| 194 | − | if (fetcher.data.length < 50) setDone(true); | |
| 191 | + | // Each page that arrives is added once. A page can come again: the page | |
| 192 | + | // reloads what fetchers loaded after every change made on it, so an entry | |
| 193 | + | // already listed is not listed twice. | |
| 194 | + | if (Array.isArray(fetcher.data) && fetcher.data !== seen) { | |
| 195 | + | const page = fetcher.data; | |
| 196 | + | setSeen(page); | |
| 197 | + | setEntries((before) => { | |
| 198 | + | const listed = new Set(before.map((entry) => entry.id)); | |
| 199 | + | return [...before, ...page.filter((entry) => !listed.has(entry.id))]; | |
| 200 | + | }); | |
| 201 | + | if (page.length < 50) setDone(true); | |
| 195 | 202 | } | |
| 196 | 203 | ||
| 197 | 204 | const load = (before: string | null) => { | |
| 253 | 260 | </li> | |
| 254 | 261 | ))} | |
| 255 | 262 | </ul> | |
| 263 | + | {failed && fetcher.state === "idle" && <p className="py-2 pl-11 text-xs text-danger">{failed}</p>} | |
| 256 | 264 | {fetcher.state === "loading" && ( | |
| 257 | 265 | <div aria-busy="true" className="pl-8"> | |
| 258 | 266 | <SkeletonRows rows={3} rowClassName="h-12" /> |
| 1 | − | import { Check, Copy } from "lucide-react"; | |
| 1 | + | import { Check, Copy, LoaderCircle } from "lucide-react"; | |
| 2 | 2 | import { type ComponentProps, type ReactNode, useState } from "react"; | |
| 3 | − | import { Link, type LinkProps, NavLink, useLocation } from "react-router"; | |
| 3 | + | import { Link, type LinkProps, NavLink, useLocation, useNavigation } from "react-router"; | |
| 4 | 4 | ||
| 5 | 5 | import { isWaitingMessage, linkPaths } from "../../lib/compute"; | |
| 6 | + | import { type Submission, isPending } from "../../lib/pending"; | |
| 6 | 7 | import { Mark } from "../logo"; | |
| 7 | 8 | ||
| 8 | 9 | export function Field({ | |
| 124 | 125 | ); | |
| 125 | 126 | } | |
| 126 | 127 | ||
| 128 | + | /** | |
| 129 | + | * Whether the submission `fields` names is still working (lib/pending.ts): | |
| 130 | + | * the page's own navigation, or `fetcher`'s when the form is a fetcher's. | |
| 131 | + | */ | |
| 132 | + | export function usePending(fields?: Record<string, string | null | undefined>, fetcher?: Submission): boolean { | |
| 133 | + | const navigation = useNavigation(); | |
| 134 | + | return isPending(fetcher ?? navigation, fields); | |
| 135 | + | } | |
| 136 | + | ||
| 137 | + | /** | |
| 138 | + | * A form's submit button that says it is working: turned off, with a | |
| 139 | + | * spinner and `pending` ("Saving…") in place of its words, from the moment | |
| 140 | + | * it is pressed until the page has loaded what it changed. Its own | |
| 141 | + | * `name`/`value` say which submission is its; `match` names it otherwise, | |
| 142 | + | * such as the form's hidden `intent`. `fetcher` when the form is a | |
| 143 | + | * fetcher's. `className` replaces the button look, for icon buttons, and | |
| 144 | + | * `icon` says the spinner takes the place of everything inside it. Words | |
| 145 | + | * with a leading icon should pass `pending`, so the spinner replaces both. | |
| 146 | + | */ | |
| 147 | + | export function SubmitButton({ | |
| 148 | + | variant = "primary", | |
| 149 | + | pending, | |
| 150 | + | match, | |
| 151 | + | fetcher, | |
| 152 | + | busy, | |
| 153 | + | icon, | |
| 154 | + | disabled, | |
| 155 | + | className, | |
| 156 | + | children, | |
| 157 | + | ...props | |
| 158 | + | }: Omit<ComponentProps<"button">, "type"> & { | |
| 159 | + | variant?: Variant; | |
| 160 | + | /** The words while it works, such as "Saving…"; its own words when absent. */ | |
| 161 | + | pending?: ReactNode; | |
| 162 | + | match?: Record<string, string | null | undefined>; | |
| 163 | + | fetcher?: Submission; | |
| 164 | + | /** Working for a reason this button cannot see by itself. */ | |
| 165 | + | busy?: boolean; | |
| 166 | + | /** An icon button: while it works, the spinner is all it shows. */ | |
| 167 | + | icon?: boolean; | |
| 168 | + | }) { | |
| 169 | + | const own = | |
| 170 | + | typeof props.name === "string" && props.value != null ? { [props.name]: String(props.value) } : undefined; | |
| 171 | + | const working = usePending({ ...own, ...match }, fetcher) || Boolean(busy); | |
| 172 | + | return ( | |
| 173 | + | <button | |
| 174 | + | {...props} | |
| 175 | + | type="submit" | |
| 176 | + | disabled={disabled || working} | |
| 177 | + | aria-busy={working || undefined} | |
| 178 | + | className={className ?? `${BUTTON_BASE} ${BUTTON_VARIANTS[variant]}`} | |
| 179 | + | > | |
| 180 | + | {working ? ( | |
| 181 | + | <> | |
| 182 | + | <LoaderCircle size={14} aria-hidden="true" className="shrink-0 animate-spin" /> | |
| 183 | + | {icon ? null : (pending ?? children)} | |
| 184 | + | </> | |
| 185 | + | ) : ( | |
| 186 | + | children | |
| 187 | + | )} | |
| 188 | + | </button> | |
| 189 | + | ); | |
| 190 | + | } | |
| 191 | + | ||
| 127 | 192 | /** A link that looks like a button. */ | |
| 128 | 193 | export function ButtonLink({ | |
| 129 | 194 | variant = "primary", |
| 4 | 4 | */ | |
| 5 | 5 | import { ChevronRight, Pause, Play, RotateCw, Send, Trash2 } from "lucide-react"; | |
| 6 | 6 | import { useState } from "react"; | |
| 7 | − | import { Form, Link, useNavigation } from "react-router"; | |
| 7 | + | import { Form, Link } from "react-router"; | |
| 8 | 8 | ||
| 9 | 9 | import { EVENT_TYPES, type Hook, type HookDelivery } from "@g1t/contracts"; | |
| 10 | 10 | ||
| 11 | 11 | import type { WebhooksAction, WebhooksData } from "../lib/webhooks.server"; | |
| 12 | − | import { Button, CopyLine, EmptyState, ErrorText, Field, Input, TimeAgo } from "./ui"; | |
| 12 | + | import { CopyLine, EmptyState, ErrorText, Field, Input, SubmitButton, TimeAgo } from "./ui"; | |
| 13 | 13 | import { CheckboxOption } from "./ui/checkbox"; | |
| 14 | 14 | import { RadioGroup, RadioOption } from "./ui/radio-group"; | |
| 15 | 15 | ||
| 51 | 51 | } | |
| 52 | 52 | ||
| 53 | 53 | function DeliveryRow({ delivery, manage }: { delivery: HookDelivery; manage: boolean }) { | |
| 54 | − | const busy = useNavigation().state === "submitting"; | |
| 55 | 54 | return ( | |
| 56 | 55 | <details className="group border-t border-line first:border-t-0"> | |
| 57 | 56 | <summary className="flex cursor-pointer list-none items-center gap-3 px-4 py-2.5 text-sm hover:bg-raised/40"> | |
| 90 | 89 | <Form method="post" className="mt-3"> | |
| 91 | 90 | <input type="hidden" name="intent" value="redeliver" /> | |
| 92 | 91 | <input type="hidden" name="delivery" value={delivery.id} /> | |
| 93 | − | <Button type="submit" variant="quiet" disabled={busy}> | |
| 92 | + | <SubmitButton | |
| 93 | + | variant="quiet" | |
| 94 | + | match={{ intent: "redeliver", delivery: delivery.id }} | |
| 95 | + | pending="Redelivering…" | |
| 96 | + | > | |
| 94 | 97 | <RotateCw size={14} /> | |
| 95 | 98 | Redeliver | |
| 96 | − | </Button> | |
| 99 | + | </SubmitButton> | |
| 97 | 100 | </Form> | |
| 98 | 101 | )} | |
| 99 | 102 | </div> | |
| 103 | 106 | } | |
| 104 | 107 | ||
| 105 | 108 | function HookRow({ hook, open, deliveries, manage }: { hook: Hook; open: boolean; deliveries: HookDelivery[]; manage: boolean }) { | |
| 106 | − | const busy = useNavigation().state === "submitting"; | |
| 107 | 109 | return ( | |
| 108 | 110 | <li className="border-t border-line first:border-t-0"> | |
| 109 | 111 | <div className="flex items-center gap-3 px-4 py-3"> | |
| 132 | 134 | {manage && ( | |
| 133 | 135 | <Form method="post" className="flex shrink-0 gap-1"> | |
| 134 | 136 | <input type="hidden" name="id" value={hook.id} /> | |
| 135 | − | <IconButton intent="ping" label="Send a ping" disabled={busy}> | |
| 137 | + | <IconButton intent="ping" id={hook.id} label="Send a ping"> | |
| 136 | 138 | <Send size={14} /> | |
| 137 | 139 | </IconButton> | |
| 138 | 140 | <input type="hidden" name="active" value={hook.active ? "false" : "true"} /> | |
| 139 | − | <IconButton intent="toggle" label={hook.active ? "Pause" : "Resume"} disabled={busy}> | |
| 141 | + | <IconButton intent="toggle" id={hook.id} label={hook.active ? "Pause" : "Resume"}> | |
| 140 | 142 | {hook.active ? <Pause size={14} /> : <Play size={14} />} | |
| 141 | 143 | </IconButton> | |
| 142 | − | <IconButton intent="delete" label="Delete" disabled={busy}> | |
| 144 | + | <IconButton intent="delete" id={hook.id} label="Delete"> | |
| 143 | 145 | <Trash2 size={14} /> | |
| 144 | 146 | </IconButton> | |
| 145 | 147 | </Form> | |
| 158 | 160 | ); | |
| 159 | 161 | } | |
| 160 | 162 | ||
| 161 | − | function IconButton({ intent, label, disabled, children }: { intent: string; label: string; disabled: boolean; children: React.ReactNode }) { | |
| 163 | + | /** One webhook's action, as an icon: a spinner in its place while that action goes. */ | |
| 164 | + | function IconButton({ intent, id, label, children }: { intent: string; id: string; label: string; children: React.ReactNode }) { | |
| 162 | 165 | return ( | |
| 163 | − | <button | |
| 164 | − | type="submit" | |
| 166 | + | <SubmitButton | |
| 167 | + | icon | |
| 165 | 168 | name="intent" | |
| 166 | 169 | value={intent} | |
| 170 | + | match={{ id }} | |
| 167 | 171 | title={label} | |
| 168 | 172 | aria-label={label} | |
| 169 | − | disabled={disabled} | |
| 170 | 173 | className="rounded-md p-2 text-muted transition-colors hover:bg-raised hover:text-fg disabled:opacity-50" | |
| 171 | 174 | > | |
| 172 | 175 | {children} | |
| 173 | − | </button> | |
| 176 | + | </SubmitButton> | |
| 174 | 177 | ); | |
| 175 | 178 | } | |
| 176 | 179 | ||
| 177 | 180 | function AddWebhook() { | |
| 178 | − | const busy = useNavigation().state === "submitting"; | |
| 179 | 181 | const [which, setWhich] = useState<"all" | "some">("all"); | |
| 180 | 182 | return ( | |
| 181 | 183 | <Form method="post" className="space-y-4 rounded-xl border border-line bg-surface p-5"> | |
| 223 | 225 | <Field label="Secret" hint="Optional. Deliveries are signed with it. Leave it empty and g1t makes one, shown once."> | |
| 224 | 226 | <Input name="secret" type="password" autoComplete="off" /> | |
| 225 | 227 | </Field> | |
| 226 | − | <Button type="submit" disabled={busy}> | |
| 227 | − | {busy ? "Adding…" : "Add webhook"} | |
| 228 | − | </Button> | |
| 228 | + | <SubmitButton match={{ intent: "create" }} pending="Adding…"> | |
| 229 | + | Add webhook | |
| 230 | + | </SubmitButton> | |
| 229 | 231 | </Form> | |
| 230 | 232 | ); | |
| 231 | 233 | } | |
| 291 | 293 | </ul> | |
| 292 | 294 | )} | |
| 293 | 295 | ||
| 294 | − | {manage && <AddWebhook />} | |
| 296 | + | {/* Keyed to the webhook just added, so the form starts empty for the next. */} | |
| 297 | + | {manage && <AddWebhook key={created?.hook.id ?? "new"} />} | |
| 295 | 298 | <p className="text-xs text-faint"> | |
| 296 | 299 | Each delivery is an HTTPS POST of JSON with <code>X-G1t-Event</code>, <code>X-G1t-Delivery</code> and{" "} | |
| 297 | 300 | <code>X-G1t-Signature-256</code>. One that is not answered with a 2xx is tried again after 1 minute, 5 minutes, |
| 8 | 8 | import { Markdown } from "./markdown"; | |
| 9 | 9 | import { IssueIcon, PullIcon } from "./work-icons"; | |
| 10 | 10 | import { MentionTextarea } from "./mention-textarea"; | |
| 11 | − | import { Avatar, Button, TimeAgo } from "./ui"; | |
| 11 | + | import { Avatar, SubmitButton, TimeAgo } from "./ui"; | |
| 12 | 12 | import { CheckboxOption } from "./ui/checkbox"; | |
| 13 | 13 | ||
| 14 | 14 | export { IssueIcon, PullIcon }; | |
| 412 | 412 | } | |
| 413 | 413 | /> | |
| 414 | 414 | <div className="flex flex-wrap gap-2"> | |
| 415 | − | <Button type="submit">Comment</Button> | |
| 415 | + | {/* A plain comment's verdict is none the action knows, so each button tells its own post apart. */} | |
| 416 | + | <SubmitButton name="verdict" value="comment" match={{ action: "comment" }} pending="Commenting…"> | |
| 417 | + | Comment | |
| 418 | + | </SubmitButton> | |
| 416 | 419 | {review?.canJudge && ( | |
| 417 | 420 | <> | |
| 418 | − | <Button variant="quiet" type="submit" name="verdict" value="approve"> | |
| 421 | + | <SubmitButton variant="quiet" name="verdict" value="approve" match={{ action: "comment" }} pending="Approving…"> | |
| 419 | 422 | <CircleCheck size={14} className="text-accent" /> | |
| 420 | 423 | Approve | |
| 421 | − | </Button> | |
| 422 | − | <Button variant="quiet" type="submit" name="verdict" value="request_changes"> | |
| 424 | + | </SubmitButton> | |
| 425 | + | <SubmitButton | |
| 426 | + | variant="quiet" | |
| 427 | + | name="verdict" | |
| 428 | + | value="request_changes" | |
| 429 | + | match={{ action: "comment" }} | |
| 430 | + | pending="Requesting changes…" | |
| 431 | + | > | |
| 423 | 432 | <CircleSlash size={14} className="text-danger" /> | |
| 424 | 433 | Request changes | |
| 425 | − | </Button> | |
| 434 | + | </SubmitButton> | |
| 426 | 435 | </> | |
| 427 | 436 | )} | |
| 428 | 437 | </div> |
| 1 | + | import assert from "node:assert/strict"; | |
| 2 | + | import { test } from "node:test"; | |
| 3 | + | ||
| 4 | + | import { isPending } from "./pending.ts"; | |
| 5 | + | ||
| 6 | + | const posted = (entries: Record<string, string>) => { | |
| 7 | + | const form = new FormData(); | |
| 8 | + | for (const [name, value] of Object.entries(entries)) form.set(name, value); | |
| 9 | + | return form; | |
| 10 | + | }; | |
| 11 | + | ||
| 12 | + | test("nothing is pending while the page is idle", () => { | |
| 13 | + | assert.equal(isPending({ state: "idle" }), false); | |
| 14 | + | assert.equal(isPending({ state: "idle", formMethod: "POST", formData: posted({ intent: "save" }) }, { intent: "save" }), false); | |
| 15 | + | }); | |
| 16 | + | ||
| 17 | + | test("a post is pending while it is sent and while the page reloads after it", () => { | |
| 18 | + | const formData = posted({ intent: "save" }); | |
| 19 | + | assert.equal(isPending({ state: "submitting", formMethod: "POST", formData }), true); | |
| 20 | + | assert.equal(isPending({ state: "loading", formMethod: "POST", formData }), true); | |
| 21 | + | assert.equal(isPending({ state: "loading", formMethod: "post", formData }, { intent: "save" }), true); | |
| 22 | + | }); | |
| 23 | + | ||
| 24 | + | test("following a link, or a GET form, is not a pending write", () => { | |
| 25 | + | assert.equal(isPending({ state: "loading" }), false); | |
| 26 | + | assert.equal(isPending({ state: "loading", formMethod: "GET", formData: posted({ q: "x" }) }), false); | |
| 27 | + | assert.equal(isPending({ state: "submitting", formMethod: "GET", formData: posted({ q: "x" }) }), false); | |
| 28 | + | }); | |
| 29 | + | ||
| 30 | + | test("only the submission that matches is pending, so one row's button does not speak for another's", () => { | |
| 31 | + | const formData = posted({ intent: "delete", id: "hook_1" }); | |
| 32 | + | const sending = { state: "submitting" as const, formMethod: "POST", formData }; | |
| 33 | + | assert.equal(isPending(sending, { intent: "delete", id: "hook_1" }), true); | |
| 34 | + | assert.equal(isPending(sending, { intent: "delete", id: "hook_2" }), false); | |
| 35 | + | assert.equal(isPending(sending, { intent: "ping" }), false); | |
| 36 | + | // A field left out (undefined) does not have to match. | |
| 37 | + | assert.equal(isPending(sending, { intent: "delete", id: undefined }), true); | |
| 38 | + | }); |
| 1 | + | /** | |
| 2 | + | * Whether a button's submission is still working: posted and not yet | |
| 3 | + | * answered, or answered and the page still loading what it changed. A | |
| 4 | + | * button that only waits for "submitting" turns back on while the page | |
| 5 | + | * still shows the old figures, so it looks as if nothing happened. | |
| 6 | + | * | |
| 7 | + | * `fields` names the submission, such as `{ intent: "delete", id }`: every | |
| 8 | + | * one must match what was posted, so one row's button does not say | |
| 9 | + | * "Deleting…" while another row's is the one going. With no `fields`, any | |
| 10 | + | * submission that writes counts. A GET form (a search, a filter) is never | |
| 11 | + | * pending here: it only reads. | |
| 12 | + | */ | |
| 13 | + | export type Submission = { | |
| 14 | + | state: "idle" | "submitting" | "loading"; | |
| 15 | + | formMethod?: string | null; | |
| 16 | + | formData?: FormData | null; | |
| 17 | + | }; | |
| 18 | + | ||
| 19 | + | export function isPending(submission: Submission, fields?: Record<string, string | null | undefined>): boolean { | |
| 20 | + | if (submission.state === "idle" || !submission.formData) return false; | |
| 21 | + | if (!submission.formMethod || submission.formMethod.toUpperCase() === "GET") return false; | |
| 22 | + | const posted = submission.formData; | |
| 23 | + | return Object.entries(fields ?? {}).every(([name, value]) => value == null || posted.get(name) === value); | |
| 24 | + | } |
| 37 | 37 | import displayFont from "@g1t/theme/fonts/bricolage-grotesque-latin.woff2?url"; | |
| 38 | 38 | import sansFont from "@g1t/theme/fonts/hanken-grotesk-latin.woff2?url"; | |
| 39 | 39 | import { Logo } from "./components/logo"; | |
| 40 | − | import { Avatar, ButtonLink, notACredential } from "./components/ui"; | |
| 40 | + | import { Avatar, ButtonLink, SubmitButton, notACredential } from "./components/ui"; | |
| 41 | 41 | import { | |
| 42 | 42 | DropdownMenu, | |
| 43 | 43 | DropdownMenuContent, | |
| 429 | 429 | // Undefined when the root loader itself failed. | |
| 430 | 430 | const root = useRouteLoaderData<typeof loader>("root"); | |
| 431 | 431 | const user = root?.user; | |
| 432 | − | const { pathname } = useLocation(); | |
| 432 | + | const { pathname, search } = useLocation(); | |
| 433 | + | // /verify lands here with ?sent=1 once it has sent the link again. | |
| 434 | + | const sentAgain = new URLSearchParams(search).get("sent") === "1"; | |
| 433 | 435 | // Drawn around the error page too: a 404 keeps the sidebar out of a | |
| 434 | 436 | // project or workspace the viewer cannot see. | |
| 435 | 437 | const error = useRouteError(); | |
| 449 | 451 | action="/verify" | |
| 450 | 452 | className="flex flex-wrap items-center justify-center gap-x-3 gap-y-1 border-b border-warn/30 bg-warn/10 px-4 py-2 text-sm" | |
| 451 | 453 | > | |
| 452 | − | <span> | |
| 453 | − | Confirm your email address to create repositories and push. We | |
| 454 | − | sent you a link. | |
| 454 | + | <span role="status"> | |
| 455 | + | {sentAgain | |
| 456 | + | ? "We sent the link again. It can take a minute to arrive." | |
| 457 | + | : "Confirm your email address to create repositories and push. We sent you a link."} | |
| 455 | 458 | </span> | |
| 456 | − | <button type="submit" className="font-medium underline underline-offset-4"> | |
| 457 | − | Send it again | |
| 458 | − | </button> | |
| 459 | + | <SubmitButton | |
| 460 | + | name="intent" | |
| 461 | + | value="resend-verification" | |
| 462 | + | pending="Sending…" | |
| 463 | + | className="inline-flex items-center gap-1.5 font-medium underline underline-offset-4 disabled:opacity-50" | |
| 464 | + | > | |
| 465 | + | {sentAgain ? "Send another" : "Send it again"} | |
| 466 | + | </SubmitButton> | |
| 459 | 467 | </Form> | |
| 460 | 468 | ); | |
| 461 | 469 | const banner = |
| 1 | − | import { Form, Link, redirect, useNavigation } from "react-router"; | |
| 1 | + | import { Form, Link, redirect } from "react-router"; | |
| 2 | 2 | ||
| 3 | 3 | import type { Route } from "./+types/auth-github-username"; | |
| 4 | 4 | import { AuthCard } from "../components/auth-card"; | |
| 5 | 5 | import { GithubMark } from "../components/github"; | |
| 6 | − | import { Button, ErrorText, Field, Input } from "../components/ui"; | |
| 6 | + | import { ErrorText, Field, Input, SubmitButton } from "../components/ui"; | |
| 7 | 7 | import { PENDING_COOKIE, cookie, readCookie } from "../lib/github"; | |
| 8 | 8 | import { githubSignIn } from "../lib/github.server"; | |
| 9 | 9 | import { page } from "../lib/meta"; | |
| 44 | 44 | } | |
| 45 | 45 | ||
| 46 | 46 | export default function GithubUsername({ loaderData, actionData }: Route.ComponentProps) { | |
| 47 | − | const busy = useNavigation().state === "submitting"; | |
| 48 | 47 | return ( | |
| 49 | 48 | <AuthCard | |
| 50 | 49 | title="Create your account" | |
| 84 | 83 | )} | |
| 85 | 84 | <ErrorText>{actionData?.error}</ErrorText> | |
| 86 | 85 | <div className="pt-2 *:w-full"> | |
| 87 | − | <Button type="submit" disabled={busy}> | |
| 86 | + | <SubmitButton pending="Creating account…"> | |
| 88 | 87 | Create account | |
| 89 | − | </Button> | |
| 88 | + | </SubmitButton> | |
| 90 | 89 | </div> | |
| 91 | 90 | </Form> | |
| 92 | 91 | </AuthCard> |
| 3 | 3 | ||
| 4 | 4 | import type { Route } from "./+types/device"; | |
| 5 | 5 | import { page } from "../lib/meta"; | |
| 6 | − | import { Button, ErrorText, Field, Input } from "../components/ui"; | |
| 6 | + | import { Button, ErrorText, Field, Input, SubmitButton, usePending } from "../components/ui"; | |
| 7 | 7 | import { identity } from "../lib/services.server"; | |
| 8 | 8 | import { assertSameOrigin, requireUser } from "../lib/session.server"; | |
| 9 | 9 | ||
| 39 | 39 | ||
| 40 | 40 | export default function Device({ loaderData, actionData }: Route.ComponentProps) { | |
| 41 | 41 | const { user, code, pending } = loaderData; | |
| 42 | + | // Either answer turns both buttons off until it is in. | |
| 43 | + | const deciding = usePending(); | |
| 42 | 44 | ||
| 43 | 45 | if (actionData && "done" in actionData) { | |
| 44 | 46 | const approved = actionData.done === "approved"; | |
| 86 | 88 | <Form method="post" className="mt-6 flex gap-3"> | |
| 87 | 89 | <input type="hidden" name="code" value={pending.userCode} /> | |
| 88 | 90 | <div className="grow *:w-full"> | |
| 89 | − | <Button variant="accent" type="submit" name="decision" value="approve"> | |
| 91 | + | <SubmitButton variant="accent" name="decision" value="approve" pending="Approving…" disabled={deciding}> | |
| 90 | 92 | Approve | |
| 91 | − | </Button> | |
| 93 | + | </SubmitButton> | |
| 92 | 94 | </div> | |
| 93 | − | <Button variant="quiet" type="submit" name="decision" value="deny"> | |
| 95 | + | <SubmitButton variant="quiet" name="decision" value="deny" pending="Denying…" disabled={deciding}> | |
| 94 | 96 | Deny | |
| 95 | − | </Button> | |
| 97 | + | </SubmitButton> | |
| 96 | 98 | </Form> | |
| 97 | 99 | </> | |
| 98 | 100 | ) : ( |
| 3 | 3 | import type { Route } from "./+types/forgot"; | |
| 4 | 4 | import { page } from "../lib/meta"; | |
| 5 | 5 | import { AuthCard } from "../components/auth-card"; | |
| 6 | − | import { Button, Field, Input } from "../components/ui"; | |
| 6 | + | import { Field, Input, SubmitButton } from "../components/ui"; | |
| 7 | 7 | import { identity } from "../lib/services.server"; | |
| 8 | 8 | import { assertSameOrigin, clientOf } from "../lib/session.server"; | |
| 9 | 9 | ||
| 40 | 40 | <Input name="email" type="email" autoComplete="email" required autoFocus /> | |
| 41 | 41 | </Field> | |
| 42 | 42 | <div className="pt-2 *:w-full"> | |
| 43 | − | <Button type="submit">Send reset link</Button> | |
| 43 | + | <SubmitButton pending="Sending…">Send reset link</SubmitButton> | |
| 44 | 44 | </div> | |
| 45 | 45 | </Form> | |
| 46 | 46 | )} |
| 1 | 1 | import { CircleAlert, Lock, Ticket } from "lucide-react"; | |
| 2 | − | import { Form, Link, data, redirect, useNavigation } from "react-router"; | |
| 2 | + | import { Form, Link, data, redirect } from "react-router"; | |
| 3 | 3 | ||
| 4 | 4 | import type { InvitePreview, User } from "@g1t/contracts"; | |
| 5 | 5 | ||
| 8 | 8 | import { Mark } from "../components/logo"; | |
| 9 | 9 | import { ContinueWithGithub, OrDivider } from "../components/github"; | |
| 10 | 10 | import { Honeypot } from "../components/honeypot"; | |
| 11 | − | import { Avatar, Button, ButtonLink, ErrorText, Field, Input } from "../components/ui"; | |
| 11 | + | import { Avatar, ButtonLink, ErrorText, Field, Input, SubmitButton } from "../components/ui"; | |
| 12 | 12 | import { githubSignInEnabled } from "../lib/github.server"; | |
| 13 | 13 | import { identity } from "../lib/services.server"; | |
| 14 | 14 | import { cleanCode, landingFor, looksAutomated, suggestUsername, welcomeCookie } from "../lib/invites"; | |
| 115 | 115 | ||
| 116 | 116 | const user = requireUser(context, request); | |
| 117 | 117 | const result = await identity.acceptInvite(user, code); | |
| 118 | − | if (!result.ok) return data({ error: result.error.message }, { status: 422 }); | |
| 118 | + | // Said with a 200, so the page loads again and shows the invite as it | |
| 119 | + | // now stands (used up, revoked) beside the reason: after a 4xx answer | |
| 120 | + | // React Router keeps the page's data as it was. | |
| 121 | + | if (!result.ok) return { error: result.error.message }; | |
| 119 | 122 | throw landIn(request, invite, [], true); | |
| 120 | 123 | } | |
| 121 | 124 | ||
| 194 | 197 | /** Signing up, on this page: the address the invite was sent to, a username, a password. */ | |
| 195 | 198 | function SignUp({ loaded, error }: { loaded: Loaded; error: string | null }) { | |
| 196 | 199 | const invite = loaded.invite!; | |
| 197 | − | const busy = useNavigation().state === "submitting"; | |
| 198 | 200 | const here = `/invite/${loaded.code}`; | |
| 199 | 201 | const back = `${here}?accept=1`; | |
| 200 | 202 | const github = `/auth/github?${new URLSearchParams({ invite: loaded.code, next: back })}`; | |
| 247 | 249 | </Field> | |
| 248 | 250 | <ErrorText>{error}</ErrorText> | |
| 249 | 251 | <div className="pt-1 *:w-full"> | |
| 250 | − | <Button type="submit" disabled={busy}> | |
| 252 | + | <SubmitButton pending="Creating account…" match={{ intent: "register" }}> | |
| 251 | 253 | {invite.workspace | |
| 252 | 254 | ? `Create account and join ${invite.workspace.name}` | |
| 253 | 255 | : invite.repository | |
| 254 | 256 | ? "Create account and accept" | |
| 255 | 257 | : "Create account"} | |
| 256 | − | </Button> | |
| 258 | + | </SubmitButton> | |
| 257 | 259 | </div> | |
| 258 | 260 | </Form> | |
| 259 | 261 | <p className="mt-5 text-center text-sm text-muted"> | |
| 281 | 283 | Signed in as <span className="font-mono text-fg">{viewer.username}</span> | |
| 282 | 284 | </p> | |
| 283 | 285 | ); | |
| 286 | + | // Its own intent, which /logout ignores, so only its button says it is working. | |
| 284 | 287 | const signOut = (label: string, variant: "primary" | "quiet") => ( | |
| 285 | 288 | <Form method="post" action={`/logout?next=${encodeURIComponent(here)}`}> | |
| 286 | − | <Button type="submit" variant={variant}> | |
| 289 | + | <input type="hidden" name="intent" value="sign-out" /> | |
| 290 | + | <SubmitButton variant={variant} pending="Signing out…" match={{ intent: "sign-out" }}> | |
| 287 | 291 | {label} | |
| 288 | − | </Button> | |
| 292 | + | </SubmitButton> | |
| 289 | 293 | </Form> | |
| 290 | 294 | ); | |
| 291 | 295 | if (invite.forViewer === false) { | |
| 328 | 332 | ); | |
| 329 | 333 | } | |
| 330 | 334 | return ( | |
| 331 | − | <Form method="post" className="space-y-4"> | |
| 332 | − | <input type="hidden" name="intent" value="accept" /> | |
| 333 | − | {signedInAs} | |
| 334 | − | {invite.forViewer === null && ( | |
| 335 | − | <p className="text-sm text-muted">This invite is for anyone with the link. Accepting uses it up.</p> | |
| 336 | − | )} | |
| 337 | − | <ErrorText>{error}</ErrorText> | |
| 338 | − | <div className="flex flex-wrap items-center gap-3"> | |
| 339 | − | <Button type="submit">{joinLabel(invite)}</Button> | |
| 340 | − | <span className="text-sm text-muted"> | |
| 341 | − | Not you?{" "} | |
| 342 | − | <button | |
| 343 | − | type="submit" | |
| 344 | − | formAction={`/logout?next=${encodeURIComponent(here)}`} | |
| 345 | − | className="text-fg underline underline-offset-4" | |
| 346 | − | > | |
| 347 | − | Sign out | |
| 348 | − | </button> | |
| 349 | − | </span> | |
| 350 | − | </div> | |
| 351 | − | </Form> | |
| 335 | + | <> | |
| 336 | + | {/* "Not you?" posts this form, so it does not carry the accept form's intent. */} | |
| 337 | + | <Form id="invite-sign-out" method="post" action={`/logout?next=${encodeURIComponent(here)}`} hidden> | |
| 338 | + | <input type="hidden" name="intent" value="sign-out" /> | |
| 339 | + | </Form> | |
| 340 | + | <Form method="post" className="space-y-4"> | |
| 341 | + | <input type="hidden" name="intent" value="accept" /> | |
| 342 | + | {signedInAs} | |
| 343 | + | {invite.forViewer === null && ( | |
| 344 | + | <p className="text-sm text-muted">This invite is for anyone with the link. Accepting uses it up.</p> | |
| 345 | + | )} | |
| 346 | + | <ErrorText>{error}</ErrorText> | |
| 347 | + | <div className="flex flex-wrap items-center gap-3"> | |
| 348 | + | <SubmitButton pending={invite.workspace ? "Joining…" : "Accepting…"} match={{ intent: "accept" }}> | |
| 349 | + | {joinLabel(invite)} | |
| 350 | + | </SubmitButton> | |
| 351 | + | <span className="text-sm text-muted"> | |
| 352 | + | Not you?{" "} | |
| 353 | + | <SubmitButton | |
| 354 | + | form="invite-sign-out" | |
| 355 | + | pending="Signing out…" | |
| 356 | + | match={{ intent: "sign-out" }} | |
| 357 | + | className="inline-flex items-center gap-1 text-fg underline underline-offset-4 disabled:opacity-50" | |
| 358 | + | > | |
| 359 | + | Sign out | |
| 360 | + | </SubmitButton> | |
| 361 | + | </span> | |
| 362 | + | </div> | |
| 363 | + | </Form> | |
| 364 | + | </> | |
| 352 | 365 | ); | |
| 353 | 366 | } | |
| 354 | 367 |
| 7 | 7 | import { ContinueWithGithub, GithubMark, OrDivider } from "../components/github"; | |
| 8 | 8 | import { PENDING_COOKIE, cookie, readCookie } from "../lib/github"; | |
| 9 | 9 | import { githubSignIn, githubSignInEnabled } from "../lib/github.server"; | |
| 10 | − | import { Button, ErrorText, Field, Input } from "../components/ui"; | |
| 10 | + | import { ErrorText, Field, Input, SubmitButton } from "../components/ui"; | |
| 11 | 11 | import { identity } from "../lib/services.server"; | |
| 12 | 12 | import { | |
| 13 | 13 | assertSameOrigin, | |
| 101 | 101 | </Field> | |
| 102 | 102 | <ErrorText>{actionData?.error}</ErrorText> | |
| 103 | 103 | <div className="pt-2 *:w-full"> | |
| 104 | − | <Button type="submit">Sign in</Button> | |
| 104 | + | <SubmitButton pending="Signing in…">Sign in</SubmitButton> | |
| 105 | 105 | </div> | |
| 106 | 106 | <p className="text-center text-sm"> | |
| 107 | 107 | <Link to="/forgot" className="text-muted hover:text-fg"> |
| 1 | 1 | import { ArrowLeft, Lock, Plus } from "lucide-react"; | |
| 2 | − | import { Form, Link, redirect, useNavigation } from "react-router"; | |
| 2 | + | import { Form, Link, redirect } from "react-router"; | |
| 3 | 3 | ||
| 4 | 4 | import type { Route } from "./+types/new-github"; | |
| 5 | 5 | import { GithubMark } from "../components/github"; | |
| 6 | − | import { Button, ButtonLink, ErrorText, Pill } from "../components/ui"; | |
| 6 | + | import { ButtonLink, ErrorText, Pill, SubmitButton } from "../components/ui"; | |
| 7 | 7 | import { CheckboxOption } from "../components/ui/checkbox"; | |
| 8 | 8 | import { FieldLegend, FieldSet } from "../components/ui/field"; | |
| 9 | 9 | import { RadioCard, RadioGroup } from "../components/ui/radio-group"; | |
| 61 | 61 | const installationId = Number(form.get("installation")); | |
| 62 | 62 | if (form.get("intent") === "remove-installation") { | |
| 63 | 63 | const removed = await githubApp.removeInstallation(user, workspace, installationId); | |
| 64 | − | if (!removed.ok) return { error: removed.error.message, results: [] }; | |
| 64 | + | // Shown by the list it was removed from, which is there with or without an account chosen. | |
| 65 | + | if (!removed.ok) return { error: null, removeError: removed.error.message, results: [] }; | |
| 65 | 66 | throw redirect(`/new/github?workspace=${encodeURIComponent(workspace)}`); | |
| 66 | 67 | } | |
| 67 | 68 | const mode = (["import", "mirror", "push"] as const).find((option) => option === form.get("mode")) ?? "import"; | |
| 84 | 85 | ||
| 85 | 86 | export default function NewFromGithub({ loaderData, actionData }: Route.ComponentProps) { | |
| 86 | 87 | const { workspace, workspaces, installations, installation, repositories, linked, owner } = loaderData; | |
| 87 | − | const busy = useNavigation().state === "submitting"; | |
| 88 | 88 | const here = `/new/github?workspace=${workspace}`; | |
| 89 | 89 | const names = new Map((repositories?.repositories ?? []).map((repo) => [repo.id, repo.fullName])); | |
| 90 | 90 | return ( | |
| 191 | 191 | <input type="hidden" name="intent" value="remove-installation" /> | |
| 192 | 192 | <input type="hidden" name="workspace" value={workspace} /> | |
| 193 | 193 | <input type="hidden" name="installation" value={item.id} /> | |
| 194 | − | <button type="submit" className="text-xs text-faint hover:text-danger"> | |
| 194 | + | <SubmitButton | |
| 195 | + | pending="Removing…" | |
| 196 | + | match={{ intent: "remove-installation", installation: String(item.id) }} | |
| 197 | + | className="inline-flex items-center gap-1 text-xs text-faint hover:text-danger disabled:opacity-50" | |
| 198 | + | > | |
| 195 | 199 | Remove | |
| 196 | − | </button> | |
| 200 | + | </SubmitButton> | |
| 197 | 201 | </Form> | |
| 198 | 202 | )} | |
| 199 | 203 | </span> | |
| 201 | 205 | ))} | |
| 202 | 206 | </ul> | |
| 203 | 207 | )} | |
| 208 | + | <ErrorText>{actionData && "removeError" in actionData ? actionData.removeError : null}</ErrorText> | |
| 204 | 209 | </section> | |
| 205 | 210 | ||
| 206 | 211 | {installation && ( | |
| 207 | 212 | <Form method="post" className="mt-10 space-y-8"> | |
| 213 | + | <input type="hidden" name="intent" value="import" /> | |
| 208 | 214 | <input type="hidden" name="workspace" value={workspace} /> | |
| 209 | 215 | <input type="hidden" name="installation" value={installation.id} /> | |
| 210 | 216 | <FieldSet> | |
| 294 | 300 | )} | |
| 295 | 301 | <ErrorText>{actionData?.error}</ErrorText> | |
| 296 | 302 | <div className="flex items-center gap-3"> | |
| 297 | − | <Button type="submit" variant="accent" disabled={busy}> | |
| 298 | − | {busy ? "Bringing them across…" : "Bring to g1t"} | |
| 299 | − | </Button> | |
| 303 | + | <SubmitButton variant="accent" pending="Bringing them across…" match={{ intent: "import" }}> | |
| 304 | + | Bring to g1t | |
| 305 | + | </SubmitButton> | |
| 300 | 306 | <ButtonLink to={`/new?workspace=${workspace}`} variant="quiet"> | |
| 301 | 307 | Cancel | |
| 302 | 308 | </ButtonLink> |
| 1 | 1 | import { Box, Download, GitBranch, Globe, Lock, Sparkles } from "lucide-react"; | |
| 2 | 2 | import { useState } from "react"; | |
| 3 | − | import { Form, redirect, useNavigate, useNavigation } from "react-router"; | |
| 3 | + | import { Form, redirect, useNavigate } from "react-router"; | |
| 4 | 4 | ||
| 5 | 5 | import type { Route } from "./+types/new"; | |
| 6 | 6 | import { page } from "../lib/meta"; | |
| 7 | − | import { Avatar, Button, ErrorText } from "../components/ui"; | |
| 7 | + | import { Avatar, ErrorText, SubmitButton } from "../components/ui"; | |
| 8 | 8 | import { Field, FieldDescription, FieldLabel, FieldLegend, FieldSet } from "../components/ui/field"; | |
| 9 | 9 | import { Input, InputAddon, InputGroup } from "../components/ui/input"; | |
| 10 | 10 | import { RadioCard, RadioGroup } from "../components/ui/radio-group"; | |
| 93 | 93 | const sources = loaderData.github ? SOURCES.map((option) => (option.id === "mirror" ? GITHUB_SOURCE : option)) : SOURCES; | |
| 94 | 94 | const [workspace, setWorkspace] = useState(loaderData.selected); | |
| 95 | 95 | const [name, setName] = useState(""); | |
| 96 | − | const busy = useNavigation().state === "submitting"; | |
| 97 | 96 | return ( | |
| 98 | 97 | <main className="mx-auto max-w-2xl px-4 py-12"> | |
| 99 | 98 | <span className="flex size-10 items-center justify-center rounded-xl bg-accent/10 text-accent ring-1 ring-accent/30"> | |
| 212 | 211 | </FieldSet> | |
| 213 | 212 | ||
| 214 | 213 | <ErrorText>{actionData?.error}</ErrorText> | |
| 215 | − | <Button type="submit" variant="accent" disabled={busy}> | |
| 214 | + | <SubmitButton variant="accent" pending={source === "import" ? "Importing…" : "Creating…"}> | |
| 216 | 215 | Create project | |
| 217 | − | </Button> | |
| 216 | + | </SubmitButton> | |
| 218 | 217 | </Form> | |
| 219 | 218 | </main> | |
| 220 | 219 | ); |
| 9 | 9 | import { ContinueWithGithub, OrDivider } from "../components/github"; | |
| 10 | 10 | import { Honeypot } from "../components/honeypot"; | |
| 11 | 11 | import { githubSignInEnabled } from "../lib/github.server"; | |
| 12 | − | import { Avatar, Button, ErrorText, Field, Input } from "../components/ui"; | |
| 12 | + | import { Avatar, Button, ErrorText, Field, Input, SubmitButton } from "../components/ui"; | |
| 13 | 13 | import { identity } from "../lib/services.server"; | |
| 14 | 14 | import { cleanCode, looksAutomated } from "../lib/invites"; | |
| 15 | 15 | import { clientKey, registrationMode } from "../lib/registration.server"; | |
| 187 | 187 | </Field> | |
| 188 | 188 | <ErrorText>{error}</ErrorText> | |
| 189 | 189 | <div className="pt-2 *:w-full"> | |
| 190 | − | <Button type="submit">{invite?.workspace ? `Create account and join ${invite.workspace.slug}` : "Create account"}</Button> | |
| 190 | + | <SubmitButton pending="Creating account…"> | |
| 191 | + | {invite?.workspace ? `Create account and join ${invite.workspace.slug}` : "Create account"} | |
| 192 | + | </SubmitButton> | |
| 191 | 193 | </div> | |
| 192 | 194 | </Form> | |
| 193 | 195 | </> | |
| 262 | 264 | </Field> | |
| 263 | 265 | <ErrorText>{actionData && "requestError" in actionData ? actionData.requestError : undefined}</ErrorText> | |
| 264 | 266 | <div className="*:w-full"> | |
| 265 | − | <Button type="submit">Request access</Button> | |
| 267 | + | <SubmitButton pending="Sending…" match={{ intent: "request-access" }}> | |
| 268 | + | Request access | |
| 269 | + | </SubmitButton> | |
| 266 | 270 | </div> | |
| 267 | 271 | </Form> | |
| 268 | 272 | </> |
| 1 | 1 | import { AlertTriangle, ChevronRight, Cloud, Download, GitBranch, GitCommitHorizontal, Info, Package, RotateCw, ServerCog, Square, XCircle } from "lucide-react"; | |
| 2 | 2 | import { type ReactNode } from "react"; | |
| 3 | − | import { Form, Link, useLoaderData, useNavigation, useSearchParams } from "react-router"; | |
| 3 | + | import { Form, Link, useLoaderData, useSearchParams } from "react-router"; | |
| 4 | 4 | ||
| 5 | 5 | import type { Annotation, Job, StepState } from "@g1t/contracts"; | |
| 6 | 6 | ||
| 7 | 7 | import type { Route } from "./+types/actions-run"; | |
| 8 | 8 | import { page } from "../../lib/meta"; | |
| 9 | 9 | import { LogText, Notes, StatusIcon, duration, shortRef, standingWord, useJobLog } from "../../components/actions"; | |
| 10 | − | import { Button, ErrorText, TimeAgo } from "../../components/ui"; | |
| 10 | + | import { ErrorText, SubmitButton, TimeAgo, usePending } from "../../components/ui"; | |
| 11 | 11 | import { listArtifacts } from "../../lib/artifacts.server"; | |
| 12 | 12 | import { actions } from "../../lib/services.server"; | |
| 13 | 13 | import { assertSameOrigin, getViewer, requireUser, roleIn, unwrap } from "../../lib/session.server"; | |
| 184 | 184 | const { run, jobs, notes } = detail; | |
| 185 | 185 | const base = `/${params.owner}/${params.repo}`; | |
| 186 | 186 | const [search] = useSearchParams(); | |
| 187 | − | const busy = useNavigation().state === "submitting"; | |
| 187 | + | // One of the run's buttons is working: the others wait for it. | |
| 188 | + | const busy = usePending(); | |
| 188 | 189 | const live = run.status !== "completed"; | |
| 189 | 190 | useRefreshWhile(live, 2500); | |
| 190 | 191 | ||
| 211 | 212 | {member && !run.error && ( | |
| 212 | 213 | <Form method="post" className="flex gap-2"> | |
| 213 | 214 | {live ? ( | |
| 214 | − | <Button type="submit" name="intent" value="cancel" variant="quiet" disabled={busy}> | |
| 215 | + | <SubmitButton name="intent" value="cancel" variant="quiet" disabled={busy} pending="Cancelling…"> | |
| 215 | 216 | <Square size={13} /> | |
| 216 | 217 | Cancel run | |
| 217 | − | </Button> | |
| 218 | + | </SubmitButton> | |
| 218 | 219 | ) : ( | |
| 219 | 220 | <> | |
| 220 | 221 | {anyFailed && ( | |
| 221 | − | <Button type="submit" name="intent" value="rerun-failed" variant="quiet" disabled={busy}> | |
| 222 | + | <SubmitButton name="intent" value="rerun-failed" variant="quiet" disabled={busy} pending="Re-running…"> | |
| 222 | 223 | <RotateCw size={13} /> | |
| 223 | 224 | Re-run failed jobs | |
| 224 | − | </Button> | |
| 225 | + | </SubmitButton> | |
| 225 | 226 | )} | |
| 226 | − | <Button type="submit" name="intent" value="rerun" variant="quiet" disabled={busy}> | |
| 227 | + | <SubmitButton name="intent" value="rerun" variant="quiet" disabled={busy} pending="Re-running…"> | |
| 227 | 228 | <RotateCw size={13} /> | |
| 228 | 229 | Re-run all jobs | |
| 229 | − | </Button> | |
| 230 | + | </SubmitButton> | |
| 230 | 231 | </> | |
| 231 | 232 | )} | |
| 232 | 233 | </Form> | |
| 307 | 308 | </Link> | |
| 308 | 309 | ))} | |
| 309 | 310 | </nav> | |
| 310 | − | {selected && <JobView key={selected.id} job={selected} base={base} />} | |
| 311 | + | {/* Run again, a job keeps its id but its log starts afresh. */} | |
| 312 | + | {selected && <JobView key={`${selected.id}:${run.attempt}`} job={selected} base={base} />} | |
| 311 | 313 | </div> | |
| 312 | 314 | )} | |
| 313 | 315 | </div> |
| 1 | 1 | import { AlertTriangle, FileCode2, GitBranch, Play, PlayCircle } from "lucide-react"; | |
| 2 | − | import { useState } from "react"; | |
| 3 | − | import { Form, Link, useNavigation, useSearchParams } from "react-router"; | |
| 2 | + | import { useEffect, useRef, useState } from "react"; | |
| 3 | + | import { Form, Link, useSearchParams } from "react-router"; | |
| 4 | 4 | ||
| 5 | 5 | import type { DispatchInput, Workflow, WorkflowRun } from "@g1t/contracts"; | |
| 6 | 6 | ||
| 8 | 8 | import { page } from "../../lib/meta"; | |
| 9 | 9 | import { Notes, StatusIcon, duration, shortRef } from "../../components/actions"; | |
| 10 | 10 | import { AddCiPrompt } from "../../components/add-ci"; | |
| 11 | − | import { Button, ComputeNote, EmptyState, ErrorText, TimeAgo } from "../../components/ui"; | |
| 11 | + | import { Button, ComputeNote, EmptyState, ErrorText, SubmitButton, TimeAgo, usePending } from "../../components/ui"; | |
| 12 | 12 | import { CheckboxOption } from "../../components/ui/checkbox"; | |
| 13 | 13 | import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "../../components/ui/select"; | |
| 14 | 14 | import { computeNoteFor } from "../../lib/compute.server"; | |
| 178 | 178 | ||
| 179 | 179 | function RunWorkflow({ workflow }: { workflow: Workflow }) { | |
| 180 | 180 | const [open, setOpen] = useState(false); | |
| 181 | − | const busy = useNavigation().state === "submitting"; | |
| 181 | + | // Open, saying it is starting, until the run shows below or the error does. | |
| 182 | + | const running = usePending({ intent: "dispatch", workflow: workflow.id }); | |
| 183 | + | const was = useRef(false); | |
| 184 | + | useEffect(() => { | |
| 185 | + | if (was.current && !running) setOpen(false); | |
| 186 | + | was.current = running; | |
| 187 | + | }, [running]); | |
| 182 | 188 | const inputs = Object.entries(workflow.dispatch ?? {}); | |
| 183 | 189 | const booleans = inputs.filter(([, spec]) => spec.type === "boolean").map(([name]) => name); | |
| 184 | 190 | return ( | |
| 190 | 196 | {open && ( | |
| 191 | 197 | <Form | |
| 192 | 198 | method="post" | |
| 193 | − | onSubmit={() => setOpen(false)} | |
| 194 | 199 | className="absolute right-0 z-20 mt-2 w-80 space-y-3 rounded-xl border border-line bg-surface p-4 shadow-xl" | |
| 195 | 200 | > | |
| 201 | + | <input type="hidden" name="intent" value="dispatch" /> | |
| 196 | 202 | <input type="hidden" name="workflow" value={workflow.id} /> | |
| 197 | 203 | <input type="hidden" name="booleans" value={booleans.join(",")} /> | |
| 198 | 204 | <label className="block"> | |
| 207 | 213 | {inputs.map(([name, spec]) => ( | |
| 208 | 214 | <InputField key={name} name={name} spec={spec} /> | |
| 209 | 215 | ))} | |
| 210 | − | <Button type="submit" disabled={busy}> | |
| 216 | + | <SubmitButton match={{ intent: "dispatch", workflow: workflow.id }} pending="Starting…"> | |
| 211 | 217 | <PlayCircle size={14} /> | |
| 212 | 218 | Run | |
| 213 | − | </Button> | |
| 219 | + | </SubmitButton> | |
| 214 | 220 | </Form> | |
| 215 | 221 | )} | |
| 216 | 222 | </div> | |
| 218 | 224 | } | |
| 219 | 225 | ||
| 220 | 226 | function WorkflowHeader({ workflow, base, member, manage }: { workflow: Workflow; base: string; member: boolean; manage: boolean }) { | |
| 221 | − | const busy = useNavigation().state === "submitting"; | |
| 222 | 227 | return ( | |
| 223 | 228 | <div className="space-y-4"> | |
| 224 | 229 | <div className="flex flex-wrap items-start justify-between gap-3"> | |
| 239 | 244 | <input type="hidden" name="intent" value="toggle" /> | |
| 240 | 245 | <input type="hidden" name="workflow" value={workflow.id} /> | |
| 241 | 246 | <input type="hidden" name="enabled" value={workflow.state === "active" ? "false" : "true"} /> | |
| 242 | − | <Button type="submit" variant="quiet" disabled={busy}> | |
| 247 | + | <SubmitButton | |
| 248 | + | variant="quiet" | |
| 249 | + | match={{ intent: "toggle", workflow: workflow.id }} | |
| 250 | + | pending={workflow.state === "active" ? "Turning off…" : "Turning on…"} | |
| 251 | + | > | |
| 243 | 252 | {workflow.state === "active" ? "Turn off" : "Turn on"} | |
| 244 | − | </Button> | |
| 253 | + | </SubmitButton> | |
| 245 | 254 | </Form>} | |
| 246 | 255 | </div> | |
| 247 | 256 | )} |
| 1 | 1 | import { ExternalLink, Globe, Rocket, RotateCw, Trash2 } from "lucide-react"; | |
| 2 | 2 | import type { ReactNode } from "react"; | |
| 3 | − | import { Form, Link, useNavigation } from "react-router"; | |
| 3 | + | import { Form, Link } from "react-router"; | |
| 4 | 4 | ||
| 5 | 5 | import type { Deployment, FeatureState } from "@g1t/contracts"; | |
| 6 | 6 | ||
| 8 | 8 | ||
| 9 | 9 | import type { Route } from "./+types/deployments"; | |
| 10 | 10 | import { page } from "../../lib/meta"; | |
| 11 | − | import { Button, ButtonLink, ComputeNote, EmptyState, ErrorText, TimeAgo } from "../../components/ui"; | |
| 11 | + | import { ButtonLink, ComputeNote, EmptyState, ErrorText, SubmitButton, TimeAgo } from "../../components/ui"; | |
| 12 | 12 | import { computeNoteFor } from "../../lib/compute.server"; | |
| 13 | 13 | import { billing, deployments, projects } from "../../lib/services.server"; | |
| 14 | 14 | import { assertSameOrigin, getViewer, requireUser, unwrap } from "../../lib/session.server"; | |
| 72 | 72 | ||
| 73 | 73 | export default function RepoDeployments({ loaderData, actionData, params }: Route.ComponentProps) { | |
| 74 | 74 | const { settings, deployments: builds, live, plan, can } = loaderData; | |
| 75 | − | const busy = useNavigation().state === "submitting"; | |
| 76 | 75 | const base = `/${params.owner}/${params.repo}`; | |
| 77 | 76 | const production = live.find((app) => app.kind === "production"); | |
| 78 | 77 | const previews = live.filter((app) => app.kind === "preview"); | |
| 132 | 131 | </p> | |
| 133 | 132 | ) : ( | |
| 134 | 133 | <Form method="post" className="mt-4"> | |
| 135 | − | <Button variant="accent" type="submit" name="intent" value="enable" disabled={busy || !can.manage_integrations} title={whyNot(can, "manage_integrations")}> | |
| 134 | + | <SubmitButton | |
| 135 | + | variant="accent" | |
| 136 | + | name="intent" | |
| 137 | + | value="enable" | |
| 138 | + | pending="Turning on…" | |
| 139 | + | disabled={!can.manage_integrations} | |
| 140 | + | title={whyNot(can, "manage_integrations")} | |
| 141 | + | > | |
| 136 | 142 | <Rocket size={14} /> | |
| 137 | 143 | Turn on deployments | |
| 138 | − | </Button> | |
| 144 | + | </SubmitButton> | |
| 139 | 145 | </Form> | |
| 140 | 146 | )} | |
| 141 | 147 | </section> | |
| 148 | 154 | app={production} | |
| 149 | 155 | off={!settings.production} | |
| 150 | 156 | actions={ | |
| 151 | − | can.run ? <AppActions branch={null} up={!!production} busy={busy} /> : null | |
| 157 | + | can.run ? <AppActions branch={null} up={!!production} /> : null | |
| 152 | 158 | } | |
| 153 | 159 | /> | |
| 154 | 160 | <div className="rounded-xl border border-line bg-surface p-5"> | |
| 176 | 182 | {host(app.url)} | |
| 177 | 183 | </a> | |
| 178 | 184 | <span className="ml-auto shrink-0"> | |
| 179 | − | {can.run && <AppActions branch={app.branch} up busy={busy} compact />} | |
| 185 | + | {can.run && <AppActions branch={app.branch} up compact />} | |
| 180 | 186 | </span> | |
| 181 | 187 | </li> | |
| 182 | 188 | ))} | |
| 283 | 289 | ); | |
| 284 | 290 | } | |
| 285 | 291 | ||
| 286 | − | function AppActions({ branch, up, busy, compact }: { branch: string | null; up: boolean; busy: boolean; compact?: boolean }) { | |
| 292 | + | function AppActions({ branch, up, compact }: { branch: string | null; up: boolean; compact?: boolean }) { | |
| 293 | + | // Which app's buttons say they are working: production has no branch. | |
| 294 | + | const app = branch ?? "production"; | |
| 287 | 295 | return ( | |
| 288 | 296 | <Form method="post" className="flex items-center gap-2"> | |
| 289 | 297 | {branch != null && <input type="hidden" name="branch" value={branch} />} | |
| 290 | − | <Button variant="quiet" type="submit" name="intent" value="redeploy" disabled={busy} title="Build again from the current head"> | |
| 298 | + | <input type="hidden" name="app" value={app} /> | |
| 299 | + | <SubmitButton | |
| 300 | + | variant="quiet" | |
| 301 | + | name="intent" | |
| 302 | + | value="redeploy" | |
| 303 | + | match={{ app }} | |
| 304 | + | icon={compact} | |
| 305 | + | pending="Redeploying…" | |
| 306 | + | title="Build again from the current head" | |
| 307 | + | > | |
| 291 | 308 | <RotateCw size={13} /> | |
| 292 | 309 | {!compact && "Redeploy"} | |
| 293 | − | </Button> | |
| 310 | + | </SubmitButton> | |
| 294 | 311 | {up && ( | |
| 295 | − | <Button variant="quiet" type="submit" name="intent" value="take-down" disabled={busy} title="Take it down now"> | |
| 312 | + | <SubmitButton | |
| 313 | + | variant="quiet" | |
| 314 | + | name="intent" | |
| 315 | + | value="take-down" | |
| 316 | + | match={{ app }} | |
| 317 | + | icon={compact} | |
| 318 | + | pending="Taking down…" | |
| 319 | + | title="Take it down now" | |
| 320 | + | > | |
| 296 | 321 | <Trash2 size={13} /> | |
| 297 | 322 | {!compact && "Take down"} | |
| 298 | − | </Button> | |
| 323 | + | </SubmitButton> | |
| 299 | 324 | )} | |
| 300 | 325 | </Form> | |
| 301 | 326 | ); |
| 1 | 1 | import { Box, CircleAlert, MailOpen } from "lucide-react"; | |
| 2 | − | import { Form, data, redirect, useNavigation } from "react-router"; | |
| 2 | + | import { Form, data, redirect } from "react-router"; | |
| 3 | 3 | ||
| 4 | 4 | import { REPO_ROLE_LABELS, REPO_ROLE_SUMMARIES } from "@g1t/contracts"; | |
| 5 | 5 | ||
| 6 | 6 | import type { Route } from "./+types/invitations"; | |
| 7 | − | import { Avatar, Button, ButtonLink, ErrorText } from "../../components/ui"; | |
| 7 | + | import { Avatar, ButtonLink, ErrorText, SubmitButton, usePending } from "../../components/ui"; | |
| 8 | 8 | import { page } from "../../lib/meta"; | |
| 9 | 9 | import { identity } from "../../lib/services.server"; | |
| 10 | 10 | import { assertSameOrigin, requireUser } from "../../lib/session.server"; | |
| 34 | 34 | const form = await request.formData(); | |
| 35 | 35 | const accept = form.get("intent") === "accept"; | |
| 36 | 36 | const invitation = await invitationTo(user, params.owner, params.repo); | |
| 37 | − | if (!invitation) return data({ error: "This invitation is no longer open." }, { status: 404 }); | |
| 37 | + | // Refusals are said with a 200, so the page loads again and shows the | |
| 38 | + | // invitation as it now stands beside the reason: after a 4xx answer | |
| 39 | + | // React Router keeps the page's data as it was. | |
| 40 | + | if (!invitation) return { error: "This invitation is no longer open." }; | |
| 38 | 41 | const answered = await identity.respondRepoInvitation(user, invitation.id, accept); | |
| 39 | − | if (!answered.ok) return data({ error: answered.error.message }, { status: 422 }); | |
| 42 | + | if (!answered.ok) return { error: answered.error.message }; | |
| 40 | 43 | // The role is theirs from the next request on: straight to the repository. | |
| 41 | 44 | throw redirect(accept ? `/${invitation.repo}` : "/"); | |
| 42 | 45 | } | |
| 45 | 48 | ||
| 46 | 49 | export default function RepoInvitation({ loaderData, actionData, params }: Route.ComponentProps) { | |
| 47 | 50 | const { invitation, viewer } = loaderData; | |
| 48 | − | const navigation = useNavigation(); | |
| 49 | − | const answering = navigation.state !== "idle" ? navigation.formData?.get("intent") : null; | |
| 51 | + | // Either answer: the other button waits for it. | |
| 52 | + | const answering = usePending(); | |
| 50 | 53 | const full = `${params.owner}/${params.repo}`; | |
| 51 | 54 | if (!invitation) { | |
| 52 | 55 | return ( | |
| 117 | 120 | </p> | |
| 118 | 121 | <ErrorText>{actionData?.error}</ErrorText> | |
| 119 | 122 | <div className="flex flex-col gap-3 sm:flex-row"> | |
| 120 | − | <Button type="submit" name="intent" value="accept" disabled={answering != null}> | |
| 121 | − | {answering === "accept" ? "Accepting…" : "Accept invitation"} | |
| 122 | − | </Button> | |
| 123 | − | <Button type="submit" name="intent" value="decline" variant="quiet" disabled={answering != null}> | |
| 124 | − | {answering === "decline" ? "Declining…" : "Decline"} | |
| 125 | − | </Button> | |
| 123 | + | <SubmitButton name="intent" value="accept" disabled={answering} pending="Accepting…"> | |
| 124 | + | Accept invitation | |
| 125 | + | </SubmitButton> | |
| 126 | + | <SubmitButton name="intent" value="decline" variant="quiet" disabled={answering} pending="Declining…"> | |
| 127 | + | Decline | |
| 128 | + | </SubmitButton> | |
| 126 | 129 | </div> | |
| 127 | 130 | </Form> | |
| 128 | 131 | </div> |
| 1 | 1 | import { env } from "cloudflare:workers"; | |
| 2 | 2 | import { Download, Sparkles } from "lucide-react"; | |
| 3 | − | import { Form, Link, redirect, useNavigation, useSearchParams } from "react-router"; | |
| 3 | + | import { Form, Link, redirect, useSearchParams } from "react-router"; | |
| 4 | 4 | ||
| 5 | 5 | import { PROVIDERS } from "@g1t/contracts"; | |
| 6 | 6 | ||
| 7 | 7 | import type { Route } from "./+types/issue-new"; | |
| 8 | 8 | import { page } from "../../lib/meta"; | |
| 9 | − | import { Button, ErrorText, Field, Input, Textarea } from "../../components/ui"; | |
| 9 | + | import { ErrorText, Field, Input, SubmitButton, Textarea } from "../../components/ui"; | |
| 10 | 10 | import { CheckboxOption } from "../../components/ui/checkbox"; | |
| 11 | 11 | import { Label } from "../../components/work"; | |
| 12 | 12 | import { integrations, work } from "../../lib/services.server"; | |
| 78 | 78 | } | |
| 79 | 79 | ||
| 80 | 80 | export default function NewIssue({ loaderData, actionData }: Route.ComponentProps) { | |
| 81 | − | const busy = useNavigation().state === "submitting"; | |
| 82 | 81 | // "Put an agent on …" in the palette arrives with ?agent=1. | |
| 83 | 82 | const [params] = useSearchParams(); | |
| 84 | 83 | const refused = actionData && "notStarted" in actionData ? actionData.notStarted : null; | |
| 101 | 100 | <Input name="reference" required placeholder="TECH-1234" aria-label="Ticket key or address" /> | |
| 102 | 101 | </div> | |
| 103 | 102 | <CheckboxOption name="assign" label="Put an agent on it" className="items-center" labelClassName="text-muted" /> | |
| 104 | − | <Button type="submit" variant="quiet" disabled={busy}> | |
| 103 | + | <SubmitButton variant="quiet" match={{ intent: "import" }} pending="Importing…"> | |
| 105 | 104 | Import | |
| 106 | − | </Button> | |
| 105 | + | </SubmitButton> | |
| 107 | 106 | </div> | |
| 108 | 107 | {actionData && "importError" in actionData && ( | |
| 109 | 108 | <div className="mt-2"> | |
| 180 | 179 | </div> | |
| 181 | 180 | )} | |
| 182 | 181 | <ErrorText>{actionData && "error" in actionData ? actionData.error : null}</ErrorText> | |
| 183 | − | <Button type="submit">Open issue</Button> | |
| 182 | + | <SubmitButton name="intent" value="open" pending="Opening…"> | |
| 183 | + | Open issue | |
| 184 | + | </SubmitButton> | |
| 184 | 185 | </Form> | |
| 185 | 186 | </div> | |
| 186 | 187 | ); |
| 1 | 1 | import { env } from "cloudflare:workers"; | |
| 2 | 2 | import { Bot, ExternalLink, GitCommitHorizontal, GitMerge, Play, Sparkles } from "lucide-react"; | |
| 3 | − | import { Form, Link, redirect, useNavigation } from "react-router"; | |
| 3 | + | import { Form, Link, redirect } from "react-router"; | |
| 4 | 4 | ||
| 5 | 5 | import { type Pull, PROVIDERS, workOwner } from "@g1t/contracts"; | |
| 6 | 6 | ||
| 10 | 10 | import { AgentStepLine } from "../../components/agents"; | |
| 11 | 11 | import { | |
| 12 | 12 | Avatar, | |
| 13 | − | Button, | |
| 14 | 13 | CopyLine, | |
| 15 | 14 | EmptyState, | |
| 16 | 15 | ComputeNote, | |
| 17 | 16 | ErrorText, | |
| 18 | 17 | Input, | |
| 18 | + | SubmitButton, | |
| 19 | 19 | Textarea, | |
| 20 | 20 | TimeAgo, | |
| 21 | 21 | } from "../../components/ui"; | |
| 235 | 235 | const { issue, pulls, comments, viewer, labels, agentsEnabled, members, canManage, can } = loaderData; | |
| 236 | 236 | ||
| 237 | 237 | // Follow agents at work without a manual reload. | |
| 238 | − | const navigation = useNavigation(); | |
| 239 | 238 | const running = pulls.some( | |
| 240 | 239 | (pull) => | |
| 241 | 240 | pull.status === "draft" || | |
| 253 | 252 | ); | |
| 254 | 253 | useRefreshWhile(Boolean(running || assigned)); | |
| 255 | 254 | ||
| 256 | − | const starting = navigation.formData?.get("action") === "run-hosted"; | |
| 255 | + | // A refusal to start g1t shows by its button; every other error below the discussion. | |
| 256 | + | const runError = actionData && "action" in actionData && actionData.action === "run-hosted"; | |
| 257 | 257 | const base = `/${params.owner}/${params.repo}`; | |
| 258 | 258 | const open = issue.state === "open"; | |
| 259 | 259 | const reference = `${params.owner}/${params.repo}#${issue.number}`; | |
| 352 | 352 | <Form method="post" className="flex flex-wrap justify-end gap-2"> | |
| 353 | 353 | {open ? ( | |
| 354 | 354 | <> | |
| 355 | − | <Button variant="quiet" type="submit" name="action" value="close-not-planned"> | |
| 355 | + | <SubmitButton variant="quiet" name="action" value="close-not-planned" pending="Closing…"> | |
| 356 | 356 | Close as not planned | |
| 357 | − | </Button> | |
| 358 | − | <Button variant="quiet" type="submit" name="action" value="close-completed"> | |
| 357 | + | </SubmitButton> | |
| 358 | + | <SubmitButton variant="quiet" name="action" value="close-completed" pending="Closing…"> | |
| 359 | 359 | Close issue | |
| 360 | − | </Button> | |
| 360 | + | </SubmitButton> | |
| 361 | 361 | </> | |
| 362 | 362 | ) : ( | |
| 363 | − | <Button variant="quiet" type="submit" name="action" value="reopen"> | |
| 363 | + | <SubmitButton variant="quiet" name="action" value="reopen" pending="Reopening…"> | |
| 364 | 364 | Reopen issue | |
| 365 | − | </Button> | |
| 365 | + | </SubmitButton> | |
| 366 | 366 | )} | |
| 367 | 367 | </Form> | |
| 368 | 368 | )} | |
| 369 | 369 | </div> | |
| 370 | 370 | </div> | |
| 371 | 371 | <div className="mt-2"> | |
| 372 | − | {!(actionData && "action" in actionData) && <ErrorText>{actionData?.error}</ErrorText>} | |
| 372 | + | {!runError && <ErrorText>{actionData?.error}</ErrorText>} | |
| 373 | 373 | </div> | |
| 374 | 374 | </div> | |
| 375 | 375 | ||
| 489 | 489 | <Form method="post" className="mt-3 space-y-2"> | |
| 490 | 490 | <input type="hidden" name="action" value="run-hosted" /> | |
| 491 | 491 | <div className="*:w-full"> | |
| 492 | − | <Button variant="accent" type="submit" disabled={starting}> | |
| 492 | + | <SubmitButton variant="accent" match={{ action: "run-hosted" }} pending="Starting a sandbox…"> | |
| 493 | 493 | <Sparkles size={14} /> | |
| 494 | − | {starting ? "Starting a sandbox…" : "Assign to g1t"} | |
| 495 | − | </Button> | |
| 494 | + | Assign to g1t | |
| 495 | + | </SubmitButton> | |
| 496 | 496 | </div> | |
| 497 | 497 | <details> | |
| 498 | 498 | <summary className="cursor-pointer text-xs text-faint hover:text-fg"> | |
| 511 | 511 | agent and fixes. You get it back ready to merge. | |
| 512 | 512 | </p> | |
| 513 | 513 | <ComputeNote note={loaderData.computeNote} /> | |
| 514 | − | {actionData && "action" in actionData && actionData.action === "run-hosted" && ( | |
| 515 | − | <ErrorText>{actionData.error}</ErrorText> | |
| 516 | − | )} | |
| 514 | + | {runError && <ErrorText>{actionData.error}</ErrorText>} | |
| 517 | 515 | </Form> | |
| 518 | 516 | )} | |
| 519 | 517 | ||
| 547 | 545 | ))} | |
| 548 | 546 | <input type="hidden" name="assignee" value={viewer.username} /> | |
| 549 | 547 | <div className="*:w-full"> | |
| 550 | − | <Button variant="quiet" type="submit"> | |
| 548 | + | <SubmitButton variant="quiet" name="who" value="self" pending="Assigning…"> | |
| 551 | 549 | Assign yourself | |
| 552 | − | </Button> | |
| 550 | + | </SubmitButton> | |
| 553 | 551 | </div> | |
| 554 | 552 | </Form> | |
| 555 | 553 | )} | |
| 560 | 558 | <Form method="post" className="mt-2 space-y-2" key={issue.assignees.join()}> | |
| 561 | 559 | <input type="hidden" name="action" value="assign" /> | |
| 562 | 560 | <PeoplePicker name="assignee" members={members} chosen={issue.assignees} /> | |
| 563 | − | <Button variant="quiet" type="submit"> | |
| 561 | + | <SubmitButton variant="quiet" name="who" value="picked" pending="Saving…"> | |
| 564 | 562 | Save assignees | |
| 565 | − | </Button> | |
| 563 | + | </SubmitButton> | |
| 566 | 564 | </Form> | |
| 567 | 565 | </details> | |
| 568 | 566 | </div> | |
| 587 | 585 | </p> | |
| 588 | 586 | <Input name="agent" placeholder="Who is working, e.g. claude-code" maxLength={60} /> | |
| 589 | 587 | <div className="*:w-full"> | |
| 590 | − | <Button type="submit">Open pull request</Button> | |
| 588 | + | <SubmitButton match={{ action: "open-pull" }} pending="Opening…"> | |
| 589 | + | Open pull request | |
| 590 | + | </SubmitButton> | |
| 591 | 591 | </div> | |
| 592 | 592 | <p className="text-xs text-muted"> | |
| 593 | 593 | Already pushed a branch?{" "} | |
| 631 | 631 | ))} | |
| 632 | 632 | </div> | |
| 633 | 633 | <Input name="labels" placeholder="New labels, comma separated" /> | |
| 634 | − | <Button variant="quiet" type="submit"> | |
| 634 | + | <SubmitButton variant="quiet" match={{ action: "labels" }} pending="Saving…"> | |
| 635 | 635 | Save labels | |
| 636 | − | </Button> | |
| 636 | + | </SubmitButton> | |
| 637 | 637 | </Form> | |
| 638 | 638 | </details> | |
| 639 | 639 | )} |
| 1 | 1 | import { env } from "cloudflare:workers"; | |
| 2 | 2 | import { GitPullRequest, MessageSquare, Plus, Sparkles, X } from "lucide-react"; | |
| 3 | − | import { Form, Link, useNavigation, useRouteLoaderData } from "react-router"; | |
| 3 | + | import { Form, Link, useRouteLoaderData } from "react-router"; | |
| 4 | 4 | ||
| 5 | 5 | import type { Route } from "./+types/issues"; | |
| 6 | 6 | import { page } from "../../lib/meta"; | |
| 7 | − | import { Button, ButtonLink, ComputeNote, EmptyState, ErrorText, TimeAgo } from "../../components/ui"; | |
| 7 | + | import { ButtonLink, ComputeNote, EmptyState, ErrorText, SubmitButton, TimeAgo } from "../../components/ui"; | |
| 8 | 8 | import { Checkbox } from "../../components/ui/checkbox"; | |
| 9 | 9 | import { | |
| 10 | 10 | Assignee, | |
| 91 | 91 | const base = `${repo}/issues`; | |
| 92 | 92 | const stateQuery = state === "closed" ? "state=closed" : ""; | |
| 93 | 93 | const assignable = agentsEnabled && state === "open" && issues.length > 0; | |
| 94 | − | const assigning = useNavigation().state === "submitting"; | |
| 94 | + | ||
| 95 | 95 | // An archived repository's issues are locked: no new ones. | |
| 96 | 96 | const layout = useRouteLoaderData("routes/repo/layout") as { repo?: { archivedAt?: string | null } } | undefined; | |
| 97 | 97 | const archived = Boolean(layout?.repo?.archivedAt); | |
| 138 | 138 | Tick the issues to hand over. g1t takes each one in a run of its own, | |
| 139 | 139 | and they all work at once. | |
| 140 | 140 | </p> | |
| 141 | − | <Button variant="accent" type="submit" disabled={assigning}> | |
| 142 | − | {assigning ? "Starting sandboxes…" : "Assign to g1t"} | |
| 143 | − | </Button> | |
| 141 | + | <SubmitButton variant="accent" pending="Starting sandboxes…"> | |
| 142 | + | Assign to g1t | |
| 143 | + | </SubmitButton> | |
| 144 | 144 | {loaderData.computeNote && ( | |
| 145 | 145 | <div className="basis-full"> | |
| 146 | 146 | <ComputeNote note={loaderData.computeNote} /> |
| 20 | 20 | RotateCw, | |
| 21 | 21 | } from "lucide-react"; | |
| 22 | 22 | import { type ReactNode, Suspense } from "react"; | |
| 23 | − | import { Await, Form, Link, useNavigation } from "react-router"; | |
| 23 | + | import { Await, Form, Link } from "react-router"; | |
| 24 | 24 | ||
| 25 | 25 | import { | |
| 26 | 26 | type AgentRun, | |
| 46 | 46 | import { ProductionShot } from "../../components/production-shot"; | |
| 47 | 47 | import { GithubLinkStrip } from "../../components/github"; | |
| 48 | 48 | import { githubApp } from "../../lib/github.server"; | |
| 49 | − | import { Avatar, Button, ButtonLink, CopyLine, TimeAgo } from "../../components/ui"; | |
| 49 | + | import { Avatar, ButtonLink, CopyLine, SubmitButton, TimeAgo } from "../../components/ui"; | |
| 50 | 50 | import { ChangeSize } from "../../components/work"; | |
| 51 | 51 | import { | |
| 52 | 52 | type ActivityItem, | |
| 664 | 664 | const productionUrl = production ? (settings?.primaryDomain ? `https://${settings.primaryDomain}` : production.url) : null; | |
| 665 | 665 | const previews = live.filter((app) => app.kind === "preview"); | |
| 666 | 666 | const latestProduction = builds.find((build) => build.kind === "production") ?? null; | |
| 667 | − | const busy = useNavigation().state === "submitting"; | |
| 668 | 667 | const addresses = useAddresses(); | |
| 669 | 668 | const source = project?.source.kind === "hosted" ? project.source : null; | |
| 670 | 669 | const moving = agentsLive.length > 0 || columns.working.length + columns.checking.length > 0; | |
| 748 | 747 | )} | |
| 749 | 748 | {settings?.enabled ? ( | |
| 750 | 749 | loaderData.can.run && <Form method="post"> | |
| 751 | − | <Button type="submit" variant="quiet" disabled={busy} title="Build production again from the default branch"> | |
| 750 | + | <SubmitButton | |
| 751 | + | variant="quiet" | |
| 752 | + | name="intent" | |
| 753 | + | value="redeploy" | |
| 754 | + | pending="Redeploying…" | |
| 755 | + | title="Build production again from the default branch" | |
| 756 | + | > | |
| 752 | 757 | <RotateCw size={14} /> | |
| 753 | 758 | Redeploy | |
| 754 | − | </Button> | |
| 759 | + | </SubmitButton> | |
| 755 | 760 | </Form> | |
| 756 | 761 | ) : loaderData.can.manage_integrations && ( | |
| 757 | 762 | <ButtonLink to={`${base}/settings/deployments`} variant="accent"> |
| 1 | 1 | import { env } from "cloudflare:workers"; | |
| 2 | 2 | import type { G1tEvent } from "@g1t/contracts"; | |
| 3 | 3 | import { ArrowRight, CircleCheck, FileCode2, Sparkles } from "lucide-react"; | |
| 4 | − | import { Form, Link, data, useNavigation } from "react-router"; | |
| 4 | + | import { Form, Link, data } from "react-router"; | |
| 5 | 5 | ||
| 6 | 6 | import type { Route } from "./+types/plan"; | |
| 7 | 7 | import { refusal, requireRepo } from "../../lib/access.server"; | |
| 10 | 10 | import { Markdown } from "../../components/markdown"; | |
| 11 | 11 | import { Activity, Exchanges } from "../../components/activity"; | |
| 12 | 12 | import { Outcome } from "../../components/outcome"; | |
| 13 | − | import { Button, ErrorText, TimeAgo } from "../../components/ui"; | |
| 13 | + | import { ErrorText, SubmitButton, TimeAgo, usePending } from "../../components/ui"; | |
| 14 | 14 | import { Checkbox } from "../../components/ui/checkbox"; | |
| 15 | 15 | import { Label } from "../../components/work"; | |
| 16 | 16 | import { billing, events, identity, work } from "../../lib/services.server"; | |
| 91 | 91 | export default function PlanPage({ loaderData, actionData, params }: Route.ComponentProps) { | |
| 92 | 92 | const { plan, costMicros, activity } = loaderData; | |
| 93 | 93 | const base = `/${params.owner}/${params.repo}`; | |
| 94 | − | const applying = useNavigation().state === "submitting"; | |
| 94 | + | // Either way of applying it, so the other button waits too. | |
| 95 | + | const applying = usePending(); | |
| 95 | 96 | ||
| 96 | 97 | // The agent is still writing it. | |
| 97 | 98 | const planning = plan.status === "planning"; | |
| 262 | 263 | ||
| 263 | 264 | {plan.status === "ready" && ( | |
| 264 | 265 | <div className="mt-6 flex flex-wrap items-center gap-3"> | |
| 265 | − | <Button variant="accent" type="submit" name="action" value="assign" disabled={applying || !loaderData.can.run} title={whyNot(loaderData.can, "run")}> | |
| 266 | + | <SubmitButton variant="accent" name="action" value="assign" pending="Opening issues…" disabled={applying || !loaderData.can.run} title={whyNot(loaderData.can, "run")}> | |
| 266 | 267 | <Sparkles size={15} /> | |
| 267 | − | {applying ? "Opening issues…" : "Open these and assign g1t"} | |
| 268 | − | </Button> | |
| 269 | − | <Button variant="quiet" type="submit" name="action" value="open" disabled={applying || !loaderData.can.run} title={whyNot(loaderData.can, "run")}> | |
| 268 | + | Open these and assign g1t | |
| 269 | + | </SubmitButton> | |
| 270 | + | <SubmitButton variant="quiet" name="action" value="open" pending="Opening issues…" disabled={applying || !loaderData.can.run} title={whyNot(loaderData.can, "run")}> | |
| 270 | 271 | Only open the issues | |
| 271 | − | </Button> | |
| 272 | + | </SubmitButton> | |
| 272 | 273 | <span className="text-xs text-muted"> | |
| 273 | 274 | Untick any you do not want. Agents work on the independent ones at once | |
| 274 | 275 | and the rest follow as what they depend on merges. |
| 1 | 1 | import { env } from "cloudflare:workers"; | |
| 2 | 2 | import { Sparkles } from "lucide-react"; | |
| 3 | − | import { Form, Link, redirect, useNavigation } from "react-router"; | |
| 3 | + | import { Form, Link, redirect } from "react-router"; | |
| 4 | 4 | ||
| 5 | 5 | import type { Plan } from "@g1t/contracts"; | |
| 6 | 6 | ||
| 8 | 8 | import { refusal, requireRepo } from "../../lib/access.server"; | |
| 9 | 9 | import { whyNot } from "../../lib/access"; | |
| 10 | 10 | import { page } from "../../lib/meta"; | |
| 11 | − | import { Button, ComputeNote, EmptyState, ErrorText, Textarea, TimeAgo } from "../../components/ui"; | |
| 11 | + | import { ComputeNote, EmptyState, ErrorText, SubmitButton, Textarea, TimeAgo } from "../../components/ui"; | |
| 12 | 12 | import { computeNoteFor } from "../../lib/compute.server"; | |
| 13 | 13 | import { work } from "../../lib/services.server"; | |
| 14 | 14 | import { | |
| 58 | 58 | export default function Plans({ loaderData, actionData, params }: Route.ComponentProps) { | |
| 59 | 59 | const { plans } = loaderData; | |
| 60 | 60 | const base = `/${params.owner}/${params.repo}`; | |
| 61 | − | const starting = useNavigation().state === "submitting"; | |
| 61 | + | ||
| 62 | 62 | return ( | |
| 63 | 63 | <div className="grid gap-10 lg:grid-cols-[1fr_20rem]"> | |
| 64 | 64 | <div className="min-w-0"> | |
| 79 | 79 | } | |
| 80 | 80 | /> | |
| 81 | 81 | <div className="flex flex-wrap items-center gap-3"> | |
| 82 | − | <Button variant="accent" type="submit" disabled={starting || !loaderData.can.run} title={whyNot(loaderData.can, "run")}> | |
| 82 | + | <SubmitButton variant="accent" pending="Starting the planner…" disabled={!loaderData.can.run} title={whyNot(loaderData.can, "run")}> | |
| 83 | 83 | <Sparkles size={15} /> | |
| 84 | − | {starting ? "Starting the planner…" : "Plan it"} | |
| 85 | − | </Button> | |
| 84 | + | Plan it | |
| 85 | + | </SubmitButton> | |
| 86 | 86 | <span className="text-xs text-muted"> | |
| 87 | 87 | Nothing is opened until you have read the plan. | |
| 88 | 88 | </span> |
| 6 | 6 | import { cloneUrl, useAddresses } from "../../lib/addresses"; | |
| 7 | 7 | import { Combobox } from "../../components/ui/combobox"; | |
| 8 | 8 | import { | |
| 9 | − | Button, | |
| 9 | + | SubmitButton, | |
| 10 | 10 | CopyLine, | |
| 11 | 11 | EmptyState, | |
| 12 | 12 | ErrorText, | |
| 120 | 120 | <Input name="issue" type="number" min={1} defaultValue={issue} placeholder="12" /> | |
| 121 | 121 | </Field> | |
| 122 | 122 | <ErrorText>{actionData?.error}</ErrorText> | |
| 123 | − | <Button type="submit">Open pull request</Button> | |
| 123 | + | <SubmitButton pending="Opening…">Open pull request</SubmitButton> | |
| 124 | 124 | </Form> | |
| 125 | 125 | ); | |
| 126 | 126 | } |
| 24 | 24 | Wrench, | |
| 25 | 25 | } from "lucide-react"; | |
| 26 | 26 | import { Suspense } from "react"; | |
| 27 | − | import { Await, Form, Link, redirect, useNavigation } from "react-router"; | |
| 27 | + | import { Await, Form, Link, redirect } from "react-router"; | |
| 28 | 28 | ||
| 29 | 29 | import { | |
| 30 | 30 | type Capability, | |
| 48 | 48 | import { Markdown } from "../../components/markdown"; | |
| 49 | 49 | import { | |
| 50 | 50 | Avatar, | |
| 51 | − | Button, | |
| 52 | 51 | ButtonLink, | |
| 53 | 52 | CopyLine, | |
| 54 | 53 | EmptyState, | |
| 55 | 54 | ComputeNote, | |
| 56 | 55 | ErrorText, | |
| 56 | + | SubmitButton, | |
| 57 | 57 | Textarea, | |
| 58 | 58 | TimeAgo, | |
| 59 | + | usePending, | |
| 59 | 60 | } from "../../components/ui"; | |
| 60 | 61 | import { CheckboxOption } from "../../components/ui/checkbox"; | |
| 61 | 62 | import { WorkflowStatuses } from "../../components/actions"; | |
| 584 | 585 | ]; | |
| 585 | 586 | // A catch-up: the click shows at once; the answer says whether it is | |
| 586 | 587 | // done already or a sandbox is on it. | |
| 587 | − | const navigation = useNavigation(); | |
| 588 | − | const catchUpPending = navigation.state !== "idle" && navigation.formData?.get("action") === "update"; | |
| 588 | + | const catchUpPending = usePending({ action: "update" }); | |
| 589 | 589 | const catchUp = actionData?.action === "update" ? actionData : null; | |
| 590 | 590 | const caughtUp = catchUp && "updated" in catchUp ? catchUp.updated : null; | |
| 591 | 591 | const agentCatchUp = catchUp && "agent" in catchUp ? catchUp.agent : null; | |
| 770 | 770 | {canRun && | |
| 771 | 771 | pull.runtime === "hosted" && | |
| 772 | 772 | (working || ["working", "revising", "catching_up", "answering"].includes(lifecycle?.stage ?? "")) && ( | |
| 773 | − | <Form method="post" className="mt-4 rounded-2xl bg-surface p-4 ring-1 ring-merged/30"> | |
| 773 | + | // Keyed by the messages so the box empties once one shows below. | |
| 774 | + | <Form method="post" className="mt-4 rounded-2xl bg-surface p-4 ring-1 ring-merged/30" key={messages.length}> | |
| 774 | 775 | <p className="flex items-center gap-2 text-sm font-medium"> | |
| 775 | 776 | <Sparkles size={15} className="text-merged" /> | |
| 776 | 777 | Message the agent | |
| 789 | 790 | placeholder="Keep the old flag working too…" | |
| 790 | 791 | className="h-9 min-w-0 grow rounded-md bg-bg px-3 text-sm ring-1 ring-line outline-none placeholder:text-faint focus:ring-merged/60" | |
| 791 | 792 | /> | |
| 792 | − | <Button type="submit">Send</Button> | |
| 793 | + | <SubmitButton match={{ action: "message" }} pending="Sending…"> | |
| 794 | + | Send | |
| 795 | + | </SubmitButton> | |
| 793 | 796 | </div> | |
| 794 | 797 | </Form> | |
| 795 | 798 | )} | |
| 912 | 915 | Edit description | |
| 913 | 916 | </summary> | |
| 914 | 917 | <Form method="post" className="mt-3 space-y-2"> | |
| 918 | + | <input type="hidden" name="action" value="describe" /> | |
| 915 | 919 | <Textarea | |
| 916 | 920 | name="summary" | |
| 917 | 921 | rows={6} | |
| 918 | 922 | placeholder="What changed and why" | |
| 919 | 923 | defaultValue={pull.body ?? ""} | |
| 920 | 924 | /> | |
| 921 | − | <Button type="submit">Save</Button> | |
| 925 | + | <SubmitButton match={{ action: "describe" }} pending="Saving…"> | |
| 926 | + | Save | |
| 927 | + | </SubmitButton> | |
| 922 | 928 | </Form> | |
| 923 | 929 | </details> | |
| 924 | 930 | )} | |
| 936 | 942 | </StatusRow> | |
| 937 | 943 | {canManage && ( | |
| 938 | 944 | <Form method="post" className="space-y-2 px-4 py-3"> | |
| 945 | + | <input type="hidden" name="action" value="ready" /> | |
| 939 | 946 | <Textarea name="summary" rows={3} placeholder="What changed and why" /> | |
| 940 | − | <Button type="submit">Mark ready for review</Button> | |
| 947 | + | <SubmitButton match={{ action: "ready" }} pending="Marking ready…"> | |
| 948 | + | Mark ready for review | |
| 949 | + | </SubmitButton> | |
| 941 | 950 | </Form> | |
| 942 | 951 | )} | |
| 943 | 952 | </StatusBox> | |
| 1022 | 1031 | {canUpdate && ( | |
| 1023 | 1032 | <Form method="post" className="mt-2"> | |
| 1024 | 1033 | <input type="hidden" name="action" value="update" /> | |
| 1025 | − | <Button variant="quiet" type="submit" disabled={catchUpPending}> | |
| 1026 | − | {catchUpPending && <Loader size={14} className="animate-spin" />} | |
| 1027 | − | {catchUpPending ? `Merging ${defaultBranch} in…` : `Catch up with ${defaultBranch} now`} | |
| 1028 | − | </Button> | |
| 1034 | + | <SubmitButton variant="quiet" match={{ action: "update" }} pending={`Merging ${defaultBranch} in…`}> | |
| 1035 | + | Catch up with {defaultBranch} now | |
| 1036 | + | </SubmitButton> | |
| 1029 | 1037 | </Form> | |
| 1030 | 1038 | )} | |
| 1031 | 1039 | {catchUp && "error" in catchUp && <ErrorText>{catchUp.error}</ErrorText>} | |
| 1062 | 1070 | <Layers size={15} /> | |
| 1063 | 1071 | See the queue | |
| 1064 | 1072 | </ButtonLink> | |
| 1065 | − | <Button variant="quiet" type="submit" name="action" value="unqueue"> | |
| 1073 | + | <SubmitButton variant="quiet" name="action" value="unqueue" pending="Removing…"> | |
| 1066 | 1074 | Remove from the queue | |
| 1067 | − | </Button> | |
| 1075 | + | </SubmitButton> | |
| 1068 | 1076 | </Form> | |
| 1069 | 1077 | )} | |
| 1070 | 1078 | {canMerge && !landing && lifecycle?.stage !== "queued" && ( | |
| 1084 | 1092 | /> | |
| 1085 | 1093 | )} | |
| 1086 | 1094 | <div className="flex flex-wrap items-center gap-3"> | |
| 1087 | − | <Button | |
| 1095 | + | <SubmitButton | |
| 1088 | 1096 | variant="accent" | |
| 1089 | − | type="submit" | |
| 1090 | 1097 | name="action" | |
| 1091 | 1098 | value="merge" | |
| 1092 | 1099 | disabled={mergeBlocked != null} | |
| 1093 | 1100 | title={mergeBlocked ?? undefined} | |
| 1101 | + | pending={mergeQueue ? "Adding to the queue…" : "Merging…"} | |
| 1094 | 1102 | > | |
| 1095 | 1103 | {mergeQueue ? <Layers size={15} /> : <GitMerge size={15} />} | |
| 1096 | 1104 | {mergeQueue ? "Add to the merge queue" : `Merge into ${defaultBranch}`} | |
| 1097 | − | </Button> | |
| 1105 | + | </SubmitButton> | |
| 1098 | 1106 | <span className={`text-xs ${mergeBlocked ? "text-danger" : "text-muted"}`}> | |
| 1099 | 1107 | {mergeBlocked ?? (mergeQueue | |
| 1100 | 1108 | ? `Tested together with everything ahead of it, then lands on ${defaultBranch}.` | |
| 1118 | 1126 | /> | |
| 1119 | 1127 | {canManage && active && ( | |
| 1120 | 1128 | <Form method="post" className="flex justify-end"> | |
| 1121 | − | <Button variant="quiet" type="submit" name="action" value="close"> | |
| 1129 | + | <SubmitButton variant="quiet" name="action" value="close" pending="Closing…"> | |
| 1122 | 1130 | Close pull request | |
| 1123 | − | </Button> | |
| 1131 | + | </SubmitButton> | |
| 1124 | 1132 | </Form> | |
| 1125 | 1133 | )} | |
| 1126 | 1134 | {actionData && | |
| 1127 | − | !["merge", "comment", "rerun-failed", "rerun-workflow", "update", "agent-review", "reviewers", "assign"].includes( | |
| 1135 | + | !["merge", "comment", "stack", "rerun-failed", "rerun-workflow", "update", "agent-review", "reviewers", "assign"].includes( | |
| 1128 | 1136 | String(actionData.action), | |
| 1129 | 1137 | ) && <ErrorText>{actionData.error}</ErrorText>} | |
| 1130 | 1138 | </div> | |
| 1161 | 1169 | <Form method="post" className="mt-3"> | |
| 1162 | 1170 | <input type="hidden" name="action" value="stack" /> | |
| 1163 | 1171 | <input type="hidden" name="branch" value={preview.branch} /> | |
| 1164 | − | <button | |
| 1165 | − | type="submit" | |
| 1166 | − | className="w-full rounded-md border border-line px-3 py-1.5 text-xs text-muted transition-colors hover:border-line-strong hover:text-fg" | |
| 1172 | + | <SubmitButton | |
| 1173 | + | match={{ action: "stack" }} | |
| 1174 | + | pending="Starting the builds…" | |
| 1175 | + | className="flex w-full items-center justify-center gap-1.5 rounded-md border border-line px-3 py-1.5 text-xs text-muted transition-colors hover:border-line-strong hover:text-fg disabled:opacity-50" | |
| 1167 | 1176 | > | |
| 1168 | 1177 | Preview them against this change | |
| 1169 | − | </button> | |
| 1178 | + | </SubmitButton> | |
| 1170 | 1179 | </Form> | |
| 1171 | 1180 | )} | |
| 1172 | 1181 | {actionData?.action === "stack" && | |
| 1224 | 1233 | <input key={name} type="hidden" name="reviewer" value={name} /> | |
| 1225 | 1234 | ))} | |
| 1226 | 1235 | <div className="*:w-full"> | |
| 1227 | − | <Button variant="quiet" type="submit"> | |
| 1236 | + | <SubmitButton variant="quiet" match={{ action: "agent-review" }} pending="Asking g1t…"> | |
| 1228 | 1237 | <Sparkles size={14} className="text-accent" /> | |
| 1229 | 1238 | Request review from g1t | |
| 1230 | − | </Button> | |
| 1239 | + | </SubmitButton> | |
| 1231 | 1240 | </div> | |
| 1232 | 1241 | <ComputeNote note={loaderData.computeNote} /> | |
| 1233 | 1242 | </Form> | |
| 1246 | 1255 | members={members.filter((name) => name !== workOwner(pull).username)} | |
| 1247 | 1256 | chosen={pull.reviewers.filter((name) => name !== "g1t")} | |
| 1248 | 1257 | /> | |
| 1249 | − | <Button variant="quiet" type="submit"> | |
| 1258 | + | <SubmitButton variant="quiet" match={{ action: "reviewers" }} pending="Saving…"> | |
| 1250 | 1259 | Save reviewers | |
| 1251 | − | </Button> | |
| 1260 | + | </SubmitButton> | |
| 1252 | 1261 | </Form> | |
| 1253 | 1262 | </details> | |
| 1254 | 1263 | </div> | |
| 1281 | 1290 | ))} | |
| 1282 | 1291 | <input type="hidden" name="assignee" value={viewer.username} /> | |
| 1283 | 1292 | <div className="*:w-full"> | |
| 1284 | − | <Button variant="quiet" type="submit"> | |
| 1293 | + | <SubmitButton variant="quiet" name="who" value="self" pending="Assigning…"> | |
| 1285 | 1294 | Assign yourself | |
| 1286 | − | </Button> | |
| 1295 | + | </SubmitButton> | |
| 1287 | 1296 | </div> | |
| 1288 | 1297 | </Form> | |
| 1289 | 1298 | )} | |
| 1294 | 1303 | <Form method="post" className="mt-2 space-y-2" key={pull.assignees.join()}> | |
| 1295 | 1304 | <input type="hidden" name="action" value="assign" /> | |
| 1296 | 1305 | <PeoplePicker name="assignee" members={members} chosen={pull.assignees} /> | |
| 1297 | − | <Button variant="quiet" type="submit"> | |
| 1306 | + | <SubmitButton variant="quiet" name="who" value="picked" pending="Saving…"> | |
| 1298 | 1307 | Save assignees | |
| 1299 | − | </Button> | |
| 1308 | + | </SubmitButton> | |
| 1300 | 1309 | </Form> | |
| 1301 | 1310 | </details> | |
| 1302 | 1311 | </div> |
| 1 | 1 | import { Mail, UserPlus } from "lucide-react"; | |
| 2 | 2 | import { useEffect, useState } from "react"; | |
| 3 | − | import { Form, Link, useFetcher, useNavigation } from "react-router"; | |
| 3 | + | import { Form, Link, useFetcher } from "react-router"; | |
| 4 | 4 | ||
| 5 | 5 | import { | |
| 6 | 6 | BASE_PERMISSION_LABELS, | |
| 16 | 16 | import { RoleSelect, RolesTable } from "../../components/access"; | |
| 17 | 17 | import { RepoSettingsHeading } from "../../components/repo-settings-heading"; | |
| 18 | 18 | import { SettingsSection as Section } from "../../components/settings-section"; | |
| 19 | − | import { Avatar, Button, ErrorText, Field, Input } from "../../components/ui"; | |
| 19 | + | import { Avatar, Button, ErrorText, Field, Input, SubmitButton } from "../../components/ui"; | |
| 20 | 20 | import { Badge } from "../../components/ui/badge"; | |
| 21 | 21 | import { | |
| 22 | 22 | AlertDialog, | |
| 214 | 214 | function AddForm({ result }: { result: Outcome | undefined }) { | |
| 215 | 215 | const [role, setRole] = useState<RepoRole>("write"); | |
| 216 | 216 | const [key, setKey] = useState(0); | |
| 217 | − | const navigation = useNavigation(); | |
| 218 | − | const adding = navigation.state !== "idle" && navigation.formData?.get("intent") === "add"; | |
| 219 | 217 | // Cleared once someone was added, ready for the next. | |
| 220 | 218 | useEffect(() => { | |
| 221 | 219 | if (result?.ok) setKey((k) => k + 1); | |
| 231 | 229 | <span className="mb-1.5 block text-sm font-medium text-muted">Role</span> | |
| 232 | 230 | <RoleSelect name="role" label="Role" value={role} onValueChange={(value) => setRole(value as RepoRole)} /> | |
| 233 | 231 | </div> | |
| 234 | − | <Button type="submit" disabled={adding}> | |
| 232 | + | <SubmitButton match={{ intent: "add" }} pending="Adding…"> | |
| 235 | 233 | <UserPlus size={15} /> | |
| 236 | − | {adding ? "Adding…" : "Add"} | |
| 237 | − | </Button> | |
| 234 | + | Add | |
| 235 | + | </SubmitButton> | |
| 238 | 236 | </div> | |
| 239 | 237 | <p className="text-xs text-faint">{REPO_ROLE_LABELS[role]}: {roleLine(role)}</p> | |
| 240 | 238 | {result && | |
| 356 | 354 | {fetcher.data && !fetcher.data.ok && <ErrorText>{fetcher.data.error}</ErrorText>} | |
| 357 | 355 | <AlertDialogFooter> | |
| 358 | 356 | <AlertDialogCancel type="button">Cancel</AlertDialogCancel> | |
| 359 | − | <Button type="submit" variant="danger" disabled={fetcher.state !== "idle"}> | |
| 360 | − | {fetcher.state !== "idle" ? "Removing…" : "Remove"} | |
| 361 | − | </Button> | |
| 357 | + | <SubmitButton variant="danger" fetcher={fetcher} pending="Removing…"> | |
| 358 | + | Remove | |
| 359 | + | </SubmitButton> | |
| 362 | 360 | </AlertDialogFooter> | |
| 363 | 361 | </fetcher.Form> | |
| 364 | 362 | </AlertDialogContent> | |
| 396 | 394 | <fetcher.Form method="post" className="shrink-0"> | |
| 397 | 395 | <input type="hidden" name="intent" value="revoke" /> | |
| 398 | 396 | <input type="hidden" name="id" value={invitation.id} /> | |
| 399 | − | <Button type="submit" variant="quiet" disabled={fetcher.state !== "idle"}> | |
| 400 | − | {fetcher.state !== "idle" ? "Revoking…" : "Revoke"} | |
| 401 | − | </Button> | |
| 397 | + | <SubmitButton variant="quiet" fetcher={fetcher} pending="Revoking…"> | |
| 398 | + | Revoke | |
| 399 | + | </SubmitButton> | |
| 402 | 400 | </fetcher.Form> | |
| 403 | 401 | </li> | |
| 404 | 402 | ); |
| 1 | 1 | import { env } from "cloudflare:workers"; | |
| 2 | 2 | import { AtSign, Tag } from "lucide-react"; | |
| 3 | − | import { Form, useNavigation } from "react-router"; | |
| 3 | + | import { Form } from "react-router"; | |
| 4 | 4 | ||
| 5 | 5 | import { AGENT_HANDLE, mentionsClient } from "@g1t/contracts"; | |
| 6 | 6 | ||
| 7 | 7 | import type { Route } from "./+types/settings-agents"; | |
| 8 | 8 | import { page } from "../../lib/meta"; | |
| 9 | 9 | import { RepoSettingsHeading } from "../../components/repo-settings-heading"; | |
| 10 | − | import { Button, ErrorText, Field, Input, TimeAgo } from "../../components/ui"; | |
| 10 | + | import { ErrorText, Field, Input, SubmitButton, TimeAgo } from "../../components/ui"; | |
| 11 | 11 | import { work } from "../../lib/services.server"; | |
| 12 | 12 | import { instrumented } from "../../lib/perf.server"; | |
| 13 | 13 | import { assertSameOrigin, getViewer, requireUser, unwrap } from "../../lib/session.server"; | |
| 47 | 47 | ||
| 48 | 48 | export default function AgentSettings({ loaderData, actionData, params }: Route.ComponentProps) { | |
| 49 | 49 | const { rules, labels } = loaderData; | |
| 50 | − | const busy = useNavigation().state === "submitting"; | |
| 51 | 50 | const base = `/${params.owner}/${params.repo}`; | |
| 52 | 51 | return ( | |
| 53 | 52 | <div className="max-w-4xl"> | |
| 70 | 69 | </div> | |
| 71 | 70 | <Form method="post" className="space-y-3 rounded-xl border border-line bg-surface p-4"> | |
| 72 | 71 | <Field label="Label" hint="An issue that already has it is not affected; adding it again is."> | |
| 73 | − | <Input name="label" list="known-labels" defaultValue={rules.label ?? ""} placeholder="agent" maxLength={40} /> | |
| 72 | + | {/* Keyed to the saved rule, so turning it off empties the box. */} | |
| 73 | + | <Input key={rules.label ?? ""} name="label" list="known-labels" defaultValue={rules.label ?? ""} placeholder="agent" maxLength={40} /> | |
| 74 | 74 | </Field> | |
| 75 | 75 | <datalist id="known-labels"> | |
| 76 | 76 | {labels.map((label) => ( | |
| 78 | 78 | ))} | |
| 79 | 79 | </datalist> | |
| 80 | 80 | <div className="flex flex-wrap items-center gap-3"> | |
| 81 | − | <Button type="submit" name="intent" value="save" disabled={busy}> | |
| 81 | + | <SubmitButton name="intent" value="save" pending="Saving…"> | |
| 82 | 82 | Save | |
| 83 | − | </Button> | |
| 83 | + | </SubmitButton> | |
| 84 | 84 | {rules.label && ( | |
| 85 | − | <Button type="submit" variant="quiet" name="intent" value="off" disabled={busy}> | |
| 85 | + | <SubmitButton variant="quiet" name="intent" value="off" pending="Turning off…"> | |
| 86 | 86 | Turn off | |
| 87 | − | </Button> | |
| 87 | + | </SubmitButton> | |
| 88 | 88 | )} | |
| 89 | 89 | {rules.updatedBy && rules.updatedAt && ( | |
| 90 | 90 | <span className="text-xs text-faint"> |
| 1 | 1 | import { ChevronRight, ShieldCheck } from "lucide-react"; | |
| 2 | − | import { Form, Link, useNavigation } from "react-router"; | |
| 2 | + | import { Form, Link } from "react-router"; | |
| 3 | 3 | ||
| 4 | 4 | import type { Route } from "./+types/settings-branches"; | |
| 5 | 5 | import { page } from "../../lib/meta"; | |
| 7 | 7 | import { RepoSettingsHeading } from "../../components/repo-settings-heading"; | |
| 8 | 8 | import { RequiredChecksPicker } from "../../components/required-checks"; | |
| 9 | 9 | import { SettingChoice as Choice, SettingsSection as Section, SettingToggle as Toggle } from "../../components/settings-section"; | |
| 10 | − | import { Button, ErrorText, TimeAgo } from "../../components/ui"; | |
| 10 | + | import { ErrorText, SubmitButton, TimeAgo } from "../../components/ui"; | |
| 11 | 11 | import { actions, repos, work } from "../../lib/services.server"; | |
| 12 | 12 | import { assertSameOrigin, getViewer, requireUser, unwrap } from "../../lib/session.server"; | |
| 13 | 13 | import { requireCapability, requireInsider } from "../../lib/access.server"; | |
| 72 | 72 | ||
| 73 | 73 | export default function BranchSettings({ loaderData, actionData }: Route.ComponentProps) { | |
| 74 | 74 | const { repo, settings, seen, noChecks, canPush } = loaderData; | |
| 75 | − | const saving = useNavigation().state === "submitting"; | |
| 76 | 75 | const branch = repo.defaultBranch; | |
| 77 | 76 | const base = `/${repo.namespace}/${repo.name}`; | |
| 78 | 77 | const archived = Boolean(repo.archivedAt); | |
| 190 | 189 | </Section> | |
| 191 | 190 | ||
| 192 | 191 | <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"> | |
| 193 | − | <Button type="submit" disabled={saving || archived}> | |
| 194 | − | {saving ? "Saving…" : "Save settings"} | |
| 195 | − | </Button> | |
| 192 | + | <SubmitButton pending="Saving…" disabled={archived}> | |
| 193 | + | Save settings | |
| 194 | + | </SubmitButton> | |
| 196 | 195 | {actionData?.saved && <span className="text-sm text-muted">Saved.</span>} | |
| 197 | 196 | <ErrorText>{actionData?.error}</ErrorText> | |
| 198 | 197 | {settings.updatedBy && settings.updatedAt && !actionData && ( |
| 1 | 1 | import { ArrowDownLeft, ArrowUpRight, FileCode2, Network, Trash2 } from "lucide-react"; | |
| 2 | − | import { Form, Link, useNavigation } from "react-router"; | |
| 2 | + | import { Form, Link } from "react-router"; | |
| 3 | 3 | ||
| 4 | 4 | import type { DependencyLink } from "@g1t/contracts"; | |
| 5 | 5 | ||
| 6 | 6 | import type { Route } from "./+types/settings-dependencies"; | |
| 7 | 7 | import { page } from "../../lib/meta"; | |
| 8 | 8 | import { RepoSettingsHeading } from "../../components/repo-settings-heading"; | |
| 9 | − | import { Avatar, Button, EmptyState, ErrorText, Field, Input } from "../../components/ui"; | |
| 9 | + | import { Avatar, EmptyState, ErrorText, Field, Input, SubmitButton } from "../../components/ui"; | |
| 10 | 10 | import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "../../components/ui/select"; | |
| 11 | 11 | import { projects } from "../../lib/services.server"; | |
| 12 | 12 | import { assertSameOrigin, getViewer, requireUser, unwrap } from "../../lib/session.server"; | |
| 41 | 41 | return added.ok ? { notice: `Depends on ${on}.` } : { error: added.error.message }; | |
| 42 | 42 | } | |
| 43 | 43 | ||
| 44 | − | function Row({ link, base, removable, busy }: { link: DependencyLink; base: string; removable: boolean; busy: boolean }) { | |
| 44 | + | function Row({ link, base, removable }: { link: DependencyLink; base: string; removable: boolean }) { | |
| 45 | 45 | return ( | |
| 46 | 46 | <li className="flex items-center gap-3 px-4 py-3 text-sm"> | |
| 47 | 47 | <Link to={`/${base.split("/")[1]}/${link.slug}`} className="font-medium hover:underline"> | |
| 62 | 62 | <Form method="post" className="ml-auto"> | |
| 63 | 63 | <input type="hidden" name="intent" value="remove" /> | |
| 64 | 64 | <input type="hidden" name="on" value={link.slug} /> | |
| 65 | − | <button | |
| 66 | − | type="submit" | |
| 67 | − | disabled={busy} | |
| 65 | + | <SubmitButton | |
| 66 | + | icon | |
| 67 | + | match={{ intent: "remove", on: link.slug }} | |
| 68 | 68 | aria-label={`Stop depending on ${link.name}`} | |
| 69 | − | className="rounded-md p-1.5 text-faint transition-colors hover:bg-raised hover:text-danger" | |
| 69 | + | className="rounded-md p-1.5 text-faint transition-colors hover:bg-raised hover:text-danger disabled:opacity-50" | |
| 70 | 70 | > | |
| 71 | 71 | <Trash2 size={14} /> | |
| 72 | − | </button> | |
| 72 | + | </SubmitButton> | |
| 73 | 73 | </Form> | |
| 74 | 74 | )} | |
| 75 | 75 | </li> | |
| 78 | 78 | ||
| 79 | 79 | export default function DependencySettings({ loaderData, actionData, params }: Route.ComponentProps) { | |
| 80 | 80 | const { dependencies, projects: all } = loaderData; | |
| 81 | − | const busy = useNavigation().state === "submitting"; | |
| 82 | 81 | const base = `/${params.owner}/${params.repo}`; | |
| 83 | 82 | const taken = new Set([params.repo.toLowerCase(), ...dependencies.dependsOn.map((d) => d.slug)]); | |
| 84 | 83 | const choices = all.filter((project) => !taken.has(project.slug)); | |
| 116 | 115 | ) : ( | |
| 117 | 116 | <ul className="divide-y divide-line overflow-hidden rounded-xl border border-line bg-surface"> | |
| 118 | 117 | {dependencies.dependsOn.map((link) => ( | |
| 119 | − | <Row key={link.slug} link={link} base={base} removable busy={busy} /> | |
| 118 | + | <Row key={link.slug} link={link} base={base} removable /> | |
| 120 | 119 | ))} | |
| 121 | 120 | </ul> | |
| 122 | 121 | )} | |
| 123 | 122 | </div> | |
| 124 | 123 | {choices.length > 0 && ( | |
| 125 | − | <Form method="post" className="mt-4 grid items-end gap-3 rounded-xl border border-line bg-surface p-4 sm:grid-cols-[1fr_1fr_auto]"> | |
| 124 | + | // Keyed to what it depends on, so once one is added the form starts over on what is left. | |
| 125 | + | <Form | |
| 126 | + | key={dependencies.dependsOn.map((link) => link.slug).join(" ")} | |
| 127 | + | method="post" | |
| 128 | + | className="mt-4 grid items-end gap-3 rounded-xl border border-line bg-surface p-4 sm:grid-cols-[1fr_1fr_auto]" | |
| 129 | + | > | |
| 126 | 130 | <input type="hidden" name="intent" value="add" /> | |
| 127 | 131 | <Field label="Project"> | |
| 128 | 132 | <Select name="on" required defaultValue={choices[0].slug}> | |
| 146 | 150 | <Field label="Its address as"> | |
| 147 | 151 | <Input name="as" placeholder="API_URL" className="font-mono" /> | |
| 148 | 152 | </Field> | |
| 149 | − | <Button type="submit" disabled={busy}> | |
| 153 | + | <SubmitButton match={{ intent: "add" }} pending="Adding…"> | |
| 150 | 154 | Add dependency | |
| 151 | − | </Button> | |
| 155 | + | </SubmitButton> | |
| 152 | 156 | </Form> | |
| 153 | 157 | )} | |
| 154 | 158 | </section> | |
| 164 | 168 | ) : ( | |
| 165 | 169 | <ul className="divide-y divide-line overflow-hidden rounded-xl border border-line bg-surface"> | |
| 166 | 170 | {dependencies.usedBy.map((link) => ( | |
| 167 | − | <Row key={link.slug} link={link} base={base} removable={false} busy={busy} /> | |
| 171 | + | <Row key={link.slug} link={link} base={base} removable={false} /> | |
| 168 | 172 | ))} | |
| 169 | 173 | </ul> | |
| 170 | 174 | )} |
| 1 | − | import { Form, Link, useNavigation } from "react-router"; | |
| 1 | + | import { Form, Link } from "react-router"; | |
| 2 | 2 | ||
| 3 | 3 | import type { DetectedKind } from "@g1t/contracts"; | |
| 4 | 4 | ||
| 5 | 5 | import type { Route } from "./+types/settings-deployments"; | |
| 6 | 6 | import { page } from "../../lib/meta"; | |
| 7 | 7 | import { RepoSettingsHeading } from "../../components/repo-settings-heading"; | |
| 8 | − | import { Button, ErrorText, Field, Input } from "../../components/ui"; | |
| 8 | + | import { ErrorText, Field, Input, SubmitButton } from "../../components/ui"; | |
| 9 | 9 | import { SwitchCard } from "../../components/ui/switch"; | |
| 10 | 10 | import { deployments, projects } from "../../lib/services.server"; | |
| 11 | 11 | import { assertSameOrigin, getViewer, requireUser, unwrap } from "../../lib/session.server"; | |
| 96 | 96 | ||
| 97 | 97 | export default function DeploymentSettings({ loaderData, actionData, params }: Route.ComponentProps) { | |
| 98 | 98 | const { settings, notDeploying } = loaderData; | |
| 99 | − | const busy = useNavigation().state === "submitting"; | |
| 100 | 99 | const base = `/${params.owner}/${params.repo}`; | |
| 101 | 100 | return ( | |
| 102 | 101 | <div className="max-w-4xl"> | |
| 128 | 127 | </p> | |
| 129 | 128 | ) : ( | |
| 130 | 129 | <Form method="post" className="mt-4"> | |
| 131 | − | <Button variant="accent" type="submit" name="intent" value="enable" disabled={busy}> | |
| 130 | + | <SubmitButton variant="accent" name="intent" value="enable" pending="Turning on…"> | |
| 132 | 131 | Turn on deployments | |
| 133 | − | </Button> | |
| 132 | + | </SubmitButton> | |
| 134 | 133 | </Form> | |
| 135 | 134 | )} | |
| 136 | 135 | </section> | |
| 163 | 162 | </Link>{" "} | |
| 164 | 163 | available to Deployments: each row for Production or Preview, or for all environments. | |
| 165 | 164 | </p> | |
| 166 | − | <Button type="submit" disabled={busy}> | |
| 165 | + | <SubmitButton name="intent" value="save" pending="Saving…"> | |
| 167 | 166 | Save | |
| 168 | − | </Button> | |
| 167 | + | </SubmitButton> | |
| 169 | 168 | </Form> | |
| 170 | 169 | ||
| 171 | 170 | <section className="mt-10 rounded-xl border border-danger/30 p-5"> | |
| 175 | 174 | running or costing anything. The workspace's plan stays on; turn it off under Billing. | |
| 176 | 175 | </p> | |
| 177 | 176 | <Form method="post" className="mt-3"> | |
| 178 | − | <Button variant="quiet" type="submit" name="intent" value="disable" disabled={busy}> | |
| 177 | + | <SubmitButton variant="quiet" name="intent" value="disable" pending="Turning off…"> | |
| 179 | 178 | Turn off deployments | |
| 180 | − | </Button> | |
| 179 | + | </SubmitButton> | |
| 181 | 180 | </Form> | |
| 182 | 181 | </section> | |
| 183 | 182 | </> |
| 1 | − | import { CheckCircle2, Globe, Info, RotateCw, Trash2 } from "lucide-react"; | |
| 1 | + | import { CheckCircle2, Globe, Info, LoaderCircle, RotateCw, Trash2 } from "lucide-react"; | |
| 2 | 2 | import { useEffect, useState } from "react"; | |
| 3 | − | import { Form, Link, useNavigation } from "react-router"; | |
| 3 | + | import { Form, Link } from "react-router"; | |
| 4 | 4 | ||
| 5 | 5 | import type { Domain, DomainRecord, DomainStatus } from "@g1t/contracts"; | |
| 6 | 6 | ||
| 7 | 7 | import type { Route } from "./+types/settings-domains"; | |
| 8 | 8 | import { page } from "../../lib/meta"; | |
| 9 | 9 | import { RepoSettingsHeading } from "../../components/repo-settings-heading"; | |
| 10 | − | import { Button, CopyLine, EmptyState, ErrorText, Field, Input } from "../../components/ui"; | |
| 10 | + | import { Button, CopyLine, EmptyState, ErrorText, Field, Input, SubmitButton, usePending } from "../../components/ui"; | |
| 11 | 11 | import { | |
| 12 | 12 | AlertDialog, | |
| 13 | 13 | AlertDialogAction, | |
| 97 | 97 | ||
| 98 | 98 | export default function DomainSettings({ loaderData, actionData, params }: Route.ComponentProps) { | |
| 99 | 99 | const { settings, domains, target, available, notice, monthlyMicros, used } = loaderData; | |
| 100 | − | const busy = useNavigation().state === "submitting"; | |
| 101 | 100 | const base = `/${params.owner}/${params.repo}`; | |
| 102 | 101 | const [hostname, setHostname] = useState(""); | |
| 103 | 102 | const twin = twinOf(hostname); | |
| 171 | 170 | </Field> | |
| 172 | 171 | </div> | |
| 173 | 172 | <div className="sm:mb-6"> | |
| 174 | − | <Button type="submit" variant="accent" disabled={busy || !hostname.trim()}> | |
| 173 | + | <SubmitButton variant="accent" match={{ intent: "add" }} pending="Adding…" disabled={!hostname.trim()}> | |
| 175 | 174 | Add domain | |
| 176 | − | </Button> | |
| 175 | + | </SubmitButton> | |
| 177 | 176 | </div> | |
| 178 | 177 | </div> | |
| 179 | 178 | <CheckboxOption | |
| 199 | 198 | key={domain.id} | |
| 200 | 199 | domain={domain} | |
| 201 | 200 | redirects={domains.filter((d) => d.redirectTo === domain.hostname)} | |
| 202 | − | busy={busy} | |
| 203 | 201 | /> | |
| 204 | 202 | )) | |
| 205 | 203 | )} | |
| 208 | 206 | ); | |
| 209 | 207 | } | |
| 210 | 208 | ||
| 211 | − | function DomainCard({ domain, redirects, busy }: { domain: Domain; redirects: Domain[]; busy: boolean }) { | |
| 209 | + | function DomainCard({ domain, redirects }: { domain: Domain; redirects: Domain[] }) { | |
| 212 | 210 | return ( | |
| 213 | 211 | <section className="overflow-hidden rounded-xl border border-line bg-surface"> | |
| 214 | − | <DomainRow domain={domain} busy={busy} /> | |
| 212 | + | <DomainRow domain={domain} /> | |
| 215 | 213 | {redirects.map((other) => ( | |
| 216 | 214 | <div key={other.id} className="border-t border-line"> | |
| 217 | − | <DomainRow domain={other} busy={busy} /> | |
| 215 | + | <DomainRow domain={other} /> | |
| 218 | 216 | </div> | |
| 219 | 217 | ))} | |
| 220 | 218 | </section> | |
| 221 | 219 | ); | |
| 222 | 220 | } | |
| 223 | 221 | ||
| 224 | − | function DomainRow({ domain, busy }: { domain: Domain; busy: boolean }) { | |
| 222 | + | function DomainRow({ domain }: { domain: Domain }) { | |
| 225 | 223 | const active = domain.status === "active"; | |
| 226 | 224 | return ( | |
| 227 | 225 | <div className="p-5"> | |
| 240 | 238 | <Form method="post"> | |
| 241 | 239 | <input type="hidden" name="intent" value="refresh" /> | |
| 242 | 240 | <input type="hidden" name="id" value={domain.id} /> | |
| 243 | − | <Button type="submit" variant="quiet" disabled={busy} title="Ask Cloudflare to check the records again now"> | |
| 241 | + | <SubmitButton | |
| 242 | + | variant="quiet" | |
| 243 | + | match={{ intent: "refresh", id: domain.id }} | |
| 244 | + | pending="Checking…" | |
| 245 | + | title="Ask Cloudflare to check the records again now" | |
| 246 | + | > | |
| 244 | 247 | <RotateCw size={14} /> | |
| 245 | 248 | Check now | |
| 246 | − | </Button> | |
| 249 | + | </SubmitButton> | |
| 247 | 250 | </Form> | |
| 248 | 251 | )} | |
| 249 | − | <RemoveDomain domain={domain} busy={busy} /> | |
| 252 | + | <RemoveDomain domain={domain} /> | |
| 250 | 253 | </span> | |
| 251 | 254 | </div> | |
| 252 | 255 | {domain.error && !active && <p className="mt-2 text-sm text-muted">{domain.error}</p>} | |
| 307 | 310 | ); | |
| 308 | 311 | } | |
| 309 | 312 | ||
| 310 | − | function RemoveDomain({ domain, busy }: { domain: Domain; busy: boolean }) { | |
| 313 | + | function RemoveDomain({ domain }: { domain: Domain }) { | |
| 314 | + | // The dialog closes as it posts, so the row's own button says it is going. | |
| 315 | + | const removing = usePending({ intent: "remove", id: domain.id }); | |
| 311 | 316 | return ( | |
| 312 | 317 | <AlertDialog> | |
| 313 | 318 | <AlertDialogTrigger asChild> | |
| 314 | − | <Button type="button" variant="quiet" disabled={busy} aria-label={`Remove ${domain.hostname}`}> | |
| 315 | − | <Trash2 size={14} /> | |
| 316 | − | Remove | |
| 319 | + | <Button type="button" variant="quiet" disabled={removing} aria-label={`Remove ${domain.hostname}`}> | |
| 320 | + | {removing ? <LoaderCircle size={14} className="animate-spin" aria-hidden="true" /> : <Trash2 size={14} />} | |
| 321 | + | {removing ? "Removing…" : "Remove"} | |
| 317 | 322 | </Button> | |
| 318 | 323 | </AlertDialogTrigger> | |
| 319 | 324 | <AlertDialogContent> |
| 1 | − | import { Link, useNavigation } from "react-router"; | |
| 1 | + | import { Link } from "react-router"; | |
| 2 | 2 | ||
| 3 | 3 | import { RUN_KINDS } from "@g1t/contracts"; | |
| 4 | 4 | ||
| 40 | 40 | } | |
| 41 | 41 | ||
| 42 | 42 | export default function RepoGuardrails({ loaderData, actionData, params }: Route.ComponentProps) { | |
| 43 | − | const saving = useNavigation().state === "submitting"; | |
| 44 | 43 | const base = `/${params.owner}/${params.repo}`; | |
| 45 | 44 | return ( | |
| 46 | 45 | <> | |
| 57 | 56 | view={loaderData.view} | |
| 58 | 57 | level="project" | |
| 59 | 58 | editable | |
| 60 | − | saving={saving} | |
| 61 | 59 | saved={actionData?.saved ?? false} | |
| 62 | 60 | error={actionData?.error} | |
| 63 | 61 | /> |
| 11 | 11 | import type { Route } from "./+types/settings-repository"; | |
| 12 | 12 | import { page } from "../../lib/meta"; | |
| 13 | 13 | import { confirmsName, tidyName } from "../../lib/repo-lifecycle"; | |
| 14 | − | import { Button, ErrorText, Field, Input } from "../../components/ui"; | |
| 14 | + | import { Button, ErrorText, Field, Input, SubmitButton } from "../../components/ui"; | |
| 15 | 15 | import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "../../components/ui/select"; | |
| 16 | 16 | import { | |
| 17 | 17 | AlertDialog, | |
| 157 | 157 | </Section> | |
| 158 | 158 | ||
| 159 | 159 | <Section title="Details" about="What the repository is, for its page, search and Explore."> | |
| 160 | − | <Form method="post"> | |
| 160 | + | {/* Keyed to what is saved, so the fields show the topics as the service tidied them. */} | |
| 161 | + | <Form method="post" key={`${repo.description ?? ""}|${repo.website ?? ""}|${(repo.topics ?? []).join(",")}`}> | |
| 161 | 162 | <fieldset disabled={archived} className="min-w-0 space-y-3 disabled:cursor-not-allowed disabled:opacity-60"> | |
| 162 | 163 | <input type="hidden" name="intent" value="details" /> | |
| 163 | 164 | <Field label="Description"> | |
| 173 | 174 | <Input name="topics" maxLength={800} defaultValue={(repo.topics ?? []).join(", ")} placeholder="cli, rust" /> | |
| 174 | 175 | </Field> | |
| 175 | 176 | <div className="flex flex-wrap items-center gap-4 pt-1"> | |
| 176 | − | <Button type="submit" disabled={posting("details")}> | |
| 177 | − | {posting("details") ? "Saving…" : "Save"} | |
| 178 | − | </Button> | |
| 177 | + | <SubmitButton match={{ intent: "details" }} pending="Saving…"> | |
| 178 | + | Save | |
| 179 | + | </SubmitButton> | |
| 179 | 180 | <Status intent="details" data={actionData} /> | |
| 180 | 181 | </div> | |
| 181 | 182 | </fieldset> | |
| 187 | 188 | about="The branch everything lands on, and renaming branches." | |
| 188 | 189 | > | |
| 189 | 190 | <DefaultBranchForm repo={repo} branches={branches} archived={archived || !owner} busy={posting("default-branch")} result={actionData} /> | |
| 191 | + | {/* Keyed to the branches, so after a rename it starts over on the branches there are now. */} | |
| 190 | 192 | <RenameBranchForm | |
| 193 | + | key={branches.join(" ")} | |
| 191 | 194 | repo={repo} | |
| 192 | 195 | branches={branches} | |
| 193 | 196 | owner={owner} |
| 1 | 1 | import { Box, Code2, GitBranch, Rocket } from "lucide-react"; | |
| 2 | 2 | import { useState } from "react"; | |
| 3 | − | import { Form, Link, useNavigation } from "react-router"; | |
| 3 | + | import { Form, Link } from "react-router"; | |
| 4 | 4 | ||
| 5 | 5 | import type { DeploysSetting } from "@g1t/contracts"; | |
| 6 | 6 | ||
| 7 | 7 | import type { Route } from "./+types/settings"; | |
| 8 | 8 | import { page } from "../../lib/meta"; | |
| 9 | 9 | import { RepoSettingsHeading } from "../../components/repo-settings-heading"; | |
| 10 | − | import { Button, ErrorText, Field, Input, TimeAgo } from "../../components/ui"; | |
| 10 | + | import { ErrorText, Field, Input, SubmitButton, TimeAgo } from "../../components/ui"; | |
| 11 | 11 | import { RadioGroup, RadioOption } from "../../components/ui/radio-group"; | |
| 12 | 12 | import { DEPLOYS_CHOICES } from "../../lib/project-kind"; | |
| 13 | 13 | import { deployments, identity, projects } from "../../lib/services.server"; | |
| 58 | 58 | const [deploys, setDeploys] = useState<DeploysSetting>(project.deploys); | |
| 59 | 59 | // Not deploying while Deployments are on is refused: they are turned off first. | |
| 60 | 60 | const blocked = deploys === "no" && project.deploys !== "no" && deploymentsOn === true; | |
| 61 | − | const saving = useNavigation().state === "submitting"; | |
| 62 | 61 | const base = `/${params.owner}/${params.repo}`; | |
| 63 | 62 | const source = project.source.kind === "hosted" ? project.source : null; | |
| 64 | 63 | return ( | |
| 65 | 64 | <div className="max-w-4xl"> | |
| 66 | 65 | <RepoSettingsHeading base={base} /> | |
| 67 | 66 | <Form method="post" className="space-y-8"> | |
| 67 | + | {/* Enter in a field saves: the form's first submit button is the one | |
| 68 | + | Enter presses, and otherwise it would be the description reset. */} | |
| 69 | + | <button type="submit" name="intent" value="save" tabIndex={-1} aria-hidden="true" className="absolute size-0 overflow-hidden opacity-0" /> | |
| 68 | 70 | <section className="space-y-4"> | |
| 69 | 71 | <h2 className="flex items-center gap-2 text-sm font-medium"> | |
| 70 | 72 | <Box size={15} className="text-accent" /> | |
| 92 | 94 | /> | |
| 93 | 95 | </Field> | |
| 94 | 96 | {!project.descriptionInherited && ( | |
| 95 | − | <button | |
| 96 | − | type="submit" | |
| 97 | + | <SubmitButton | |
| 97 | 98 | name="inherit" | |
| 98 | 99 | value="description" | |
| 99 | − | disabled={saving} | |
| 100 | − | className="mt-1.5 text-xs text-muted hover:text-fg disabled:opacity-50" | |
| 100 | + | pending="Resetting…" | |
| 101 | + | className="mt-1.5 inline-flex items-center gap-1.5 text-xs text-muted hover:text-fg disabled:opacity-50" | |
| 101 | 102 | > | |
| 102 | 103 | Use the repository's description | |
| 103 | − | </button> | |
| 104 | + | </SubmitButton> | |
| 104 | 105 | )} | |
| 105 | 106 | </div> | |
| 106 | 107 | </div> | |
| 185 | 186 | </section> | |
| 186 | 187 | ||
| 187 | 188 | <div className="flex items-center gap-3"> | |
| 188 | − | <Button type="submit" disabled={saving || blocked}> | |
| 189 | + | <SubmitButton name="intent" value="save" pending="Saving…" disabled={blocked}> | |
| 189 | 190 | Save | |
| 190 | − | </Button> | |
| 191 | + | </SubmitButton> | |
| 191 | 192 | {actionData && "saved" in actionData && <span className="text-sm text-accent">Saved.</span>} | |
| 192 | 193 | <span className="ml-auto text-xs text-faint"> | |
| 193 | 194 | Created{" "} |
| 3 | 3 | import type { Route } from "./+types/reset"; | |
| 4 | 4 | import { page } from "../lib/meta"; | |
| 5 | 5 | import { AuthCard } from "../components/auth-card"; | |
| 6 | − | import { Button, ErrorText, Field, Input } from "../components/ui"; | |
| 6 | + | import { ErrorText, Field, Input, SubmitButton } from "../components/ui"; | |
| 7 | 7 | import { identity } from "../lib/services.server"; | |
| 8 | 8 | import { assertSameOrigin } from "../lib/session.server"; | |
| 9 | 9 | ||
| 51 | 51 | </Field> | |
| 52 | 52 | <ErrorText>{actionData?.error}</ErrorText> | |
| 53 | 53 | <div className="pt-2 *:w-full"> | |
| 54 | − | <Button type="submit">Set password</Button> | |
| 54 | + | <SubmitButton pending="Saving…">Set password</SubmitButton> | |
| 55 | 55 | </div> | |
| 56 | 56 | </Form> | |
| 57 | 57 | </AuthCard> |
| 3 | 3 | ||
| 4 | 4 | import type { Route } from "./+types/applications"; | |
| 5 | 5 | import { page } from "../../lib/meta"; | |
| 6 | − | import { Button, ButtonLink, ErrorText, TimeAgo } from "../../components/ui"; | |
| 6 | + | import { ButtonLink, ErrorText, SubmitButton, TimeAgo } from "../../components/ui"; | |
| 7 | 7 | import { DeleteButton } from "../../components/account-settings"; | |
| 8 | 8 | import { AccessSummary, ScopeChecklist } from "../../components/token-scopes"; | |
| 9 | 9 | import { grantFromForm } from "../../lib/token-scopes"; | |
| 74 | 74 | Change access | |
| 75 | 75 | </ButtonLink> | |
| 76 | 76 | )} | |
| 77 | − | <DeleteButton intent="sign-out-application" id={application.id} label="Sign out" /> | |
| 77 | + | <DeleteButton intent="sign-out-application" id={application.id} label="Sign out" pending="Signing out…" /> | |
| 78 | 78 | </div> | |
| 79 | 79 | </div> | |
| 80 | 80 | {open && ( | |
| 88 | 88 | <ScopeChecklist initial={application.scopes} /> | |
| 89 | 89 | {actionData?.editing === application.id && <ErrorText>{actionData.error}</ErrorText>} | |
| 90 | 90 | <div className="flex gap-2"> | |
| 91 | − | <Button type="submit">Save access</Button> | |
| 91 | + | <SubmitButton pending="Saving…" match={{ intent: "update-application", id: application.id }}> | |
| 92 | + | Save access | |
| 93 | + | </SubmitButton> | |
| 92 | 94 | <ButtonLink variant="quiet" to="." preventScrollReset> | |
| 93 | 95 | Cancel | |
| 94 | 96 | </ButtonLink> |
| 22 | 22 | const form = await request.formData(); | |
| 23 | 23 | // Sensitive changes may first ask for the password (lib/emails.server.ts). | |
| 24 | 24 | if ((EMAIL_INTENTS as readonly string[]).includes(String(form.get("intent")))) { | |
| 25 | − | return { emailAction: await emailAction(user, form, request) }; | |
| 25 | + | // Which form posted, so its answer shows beside it. | |
| 26 | + | return { emailAction: await emailAction(user, form, request), emailIntent: String(form.get("intent")) }; | |
| 26 | 27 | } | |
| 27 | 28 | return null; | |
| 28 | 29 | } | |
| 32 | 33 | <EmailsSection | |
| 33 | 34 | data={loaderData.emails} | |
| 34 | 35 | actionData={actionData?.emailAction} | |
| 36 | + | intent={actionData?.emailIntent} | |
| 35 | 37 | hasPassword={loaderData.hasPassword} | |
| 36 | 38 | /> | |
| 37 | 39 | ); |
| 2 | 2 | ||
| 3 | 3 | import type { Route } from "./+types/github"; | |
| 4 | 4 | import { page } from "../../lib/meta"; | |
| 5 | − | import { Button, ErrorText, TimeAgo } from "../../components/ui"; | |
| 5 | + | import { ErrorText, SubmitButton, TimeAgo } from "../../components/ui"; | |
| 6 | 6 | import { assertSameOrigin, requireUser } from "../../lib/session.server"; | |
| 7 | 7 | import { GithubMark } from "../../components/github"; | |
| 8 | 8 | import { githubSignIn } from "../../lib/github.server"; | |
| 54 | 54 | )} | |
| 55 | 55 | <Form method="post"> | |
| 56 | 56 | <input type="hidden" name="intent" value="unlink-github" /> | |
| 57 | − | <Button variant="quiet" type="submit" disabled={!github.hasPassword} title={github.hasPassword ? undefined : "GitHub is how you sign in. Set a password first."}> | |
| 57 | + | <SubmitButton variant="quiet" pending="Unlinking…" match={{ intent: "unlink-github" }} disabled={!github.hasPassword} title={github.hasPassword ? undefined : "GitHub is how you sign in. Set a password first."}> | |
| 58 | 58 | Unlink | |
| 59 | − | </Button> | |
| 59 | + | </SubmitButton> | |
| 60 | 60 | </Form> | |
| 61 | 61 | </div> | |
| 62 | 62 | </div> |
| 3 | 3 | ||
| 4 | 4 | import type { Route } from "./+types/keys"; | |
| 5 | 5 | import { page } from "../../lib/meta"; | |
| 6 | − | import { Button, ErrorText, Field, Input } from "../../components/ui"; | |
| 6 | + | import { ErrorText, Field, Input, SubmitButton } from "../../components/ui"; | |
| 7 | 7 | import { DeleteButton } from "../../components/account-settings"; | |
| 8 | 8 | import { assertSameOrigin, requireUser } from "../../lib/session.server"; | |
| 9 | 9 | ||
| 51 | 51 | </li> | |
| 52 | 52 | ))} | |
| 53 | 53 | </ul> | |
| 54 | − | <Form method="post" className={`space-y-3 ${keys.length > 0 ? "mt-4" : ""}`}> | |
| 54 | + | {/* Keyed on the list, so a key once added leaves the fields empty for the next. */} | |
| 55 | + | <Form key={keys.length} method="post" className={`space-y-3 ${keys.length > 0 ? "mt-4" : ""}`}> | |
| 55 | 56 | <input type="hidden" name="intent" value="add-key" /> | |
| 56 | 57 | <Field label="Title (optional)"> | |
| 57 | 58 | <Input name="title" maxLength={100} /> | |
| 60 | 61 | <Input name="key" placeholder="ssh-ed25519 AAAA…" required /> | |
| 61 | 62 | </Field> | |
| 62 | 63 | <ErrorText>{actionData?.keyError}</ErrorText> | |
| 63 | − | <Button type="submit">Add SSH key</Button> | |
| 64 | + | <SubmitButton pending="Adding…" match={{ intent: "add-key" }}> | |
| 65 | + | Add SSH key | |
| 66 | + | </SubmitButton> | |
| 64 | 67 | </Form> | |
| 65 | 68 | </section> | |
| 66 | 69 | ); |
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
This change is too large to show in full.