Skip to content

Commit

web: repository settings buttons say they are working per row and clear or re-key their forms from what was saved; confirm dialogs close when done; Follow the workspace on runners follows instead of saving; Enter on project settings saves

syntaqxcommitted Parent0ec3799Browse files
16 files+209−1830/16 viewed
+8−8
4444 type User as Actor,
4545 } from "@g1t/contracts";
4646
47−import { TimeAgo } from "./ui";
47+import { SubmitButton, TimeAgo } from "./ui";
4848 import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle, DialogTrigger } from "./ui/dialog";
4949 import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "./ui/select";
5050
157157 <textarea name="text" required rows={4} maxLength={1000} defaultValue={memory.text} className={TEXTAREA} />
158158 <div className="flex items-center justify-between gap-3">
159159 <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…">
161161 Keep
162− </button>
162+ </SubmitButton>
163163 </div>
164164 {fetcher.data?.error && <p className="text-sm text-danger">{fetcher.data.error}</p>}
165165 </fetcher.Form>
497497 <input type="hidden" name="intent" value="fix" />
498498 <input type="hidden" name="project" value={project} />
499499 <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…"
503503 title={`Opens “${rule.fix.title}” and puts an agent on it`}
504504 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"
505505 >
506506 <Bot size={12} />
507− {fetcher.state !== "idle" ? "Opening…" : "Fix with an agent"}
508− </button>
507+ Fix with an agent
508+ </SubmitButton>
509509 </fetcher.Form>
510510 )}
511511 </li>
+8−6
1818
1919 import { formatCap, tri, workflowDomainLine } from "../lib/guardrails";
2020 import { formatCost } from "./agents";
21−import { Button, ErrorText, Input, TimeAgo } from "./ui";
21+import { ErrorText, Input, SubmitButton, TimeAgo } from "./ui";
2222 import { CheckboxOption } from "./ui/checkbox";
2323 import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "./ui/select";
2424 import { Textarea } from "./ui/textarea";
9797 view: GuardrailsView;
9898 level: "workspace" | "project";
9999 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;
101102 saved: boolean;
102103 error: string | null | undefined;
103104 }) {
111112 const inheritedDeny = level === "project" ? view.inherited.deny : [];
112113 const inheritedWorkflowDomains = level === "project" ? (view.inherited.workflowDomains ?? []) : [];
113114 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">
115117 <fieldset disabled={!editable} className="min-w-0 space-y-8">
116118 <Section
117119 title="Network"
310312
311313 {editable ? (
312314 <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>
316318 {saved && <span className="text-sm text-muted">Saved. Runs that start from now on get these.</span>}
317319 <ErrorText>{error}</ErrorText>
318320 {own.updatedBy && own.updatedAt && !saved && !error && (
+8−15
99
1010 import { MEMORY_KINDS, type Memory, type MemoryKind, type MemoryScope, type Result, type User as Actor } from "@g1t/contracts";
1111
12−import { TimeAgo } from "./ui";
12+import { SubmitButton, TimeAgo } from "./ui";
1313 import {
1414 AlertDialog,
1515 AlertDialogAction,
127127 <textarea name="text" required rows={4} maxLength={1000} defaultValue={memory.text} className={TEXTAREA} />
128128 <div className="flex items-center justify-between gap-3">
129129 <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…">
135131 Save
136− </button>
132+ </SubmitButton>
137133 </div>
138134 {fetcher.data?.error && <p className="text-sm text-danger">{fetcher.data.error}</p>}
139135 </fetcher.Form>
176172 aria-label={pinned ? "Unpin" : "Pin"}
177173 title={pinned ? "Unpin" : "Pin: given to every agent first"}
178174 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"
180177 >
181178 {pinned ? <PinOff size={13} /> : <Pin size={13} />}
182179 </button>
252249 Pin it
253250 </label>
254251 <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>
262255 </div>
263256 {fetcher.data?.error && <p className="mt-2 text-sm text-danger">{fetcher.data.error}</p>}
264257 <p className="mt-2 text-xs text-faint">
+16−11
11 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";
44
5−import { Button, ErrorText } from "./ui";
5+import { ErrorText, SubmitButton, usePending } from "./ui";
66 import {
77 AlertDialog,
88 AlertDialogCancel,
5656 }) {
5757 const [open, setOpen] = useState(Boolean(error));
5858 const [typed, setTyped] = useState("");
59− const navigation = useNavigation();
6059 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]);
6570 const ready = confirm == null || confirmsName(typed, confirm);
6671 return (
6772 <>
104109 <ErrorText>{error}</ErrorText>
105110 <AlertDialogFooter>
106111 <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>
110115 </AlertDialogFooter>
111116 </Form>
112117 </AlertDialogContent>
+43−26
11 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";
44
55 import { type RegistrationToken, RUNNER_DOWNLOADS, RUNNER_FILES, RUNNER_IMAGE, type Runner, type RunnerGroup } from "@g1t/contracts";
66
77 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";
99 import { Switch } from "./ui/switch";
1010 import { Tabs, TabsList, TabsTrigger } from "./ui/tabs";
1111
3434
3535 function RunnerRow({ runner, manage, base }: { runner: Runner; manage: boolean; base: (repo: string) => string }) {
3636 const status = STATUS[runner.status];
37− const busy = useNavigation().state === "submitting";
3837 return (
3938 <li className="flex flex-wrap items-start gap-x-4 gap-y-2 px-4 py-3">
4039 <span className={`mt-1.5 size-2 shrink-0 rounded-full ${status.dot}`} aria-hidden />
8483 <input type="hidden" name="intent" value="remove" />
8584 <input type="hidden" name="id" value={runner.id} />
8685 <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 }}
9089 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"
9291 >
9392 <Trash2 size={15} />
94− </button>
93+ </SubmitButton>
9594 </Form>
9695 )}
9796 </li>
146145 function NewRunner({ token, groups, repoScoped }: { token: RegistrationToken | null; groups: RunnerGroup[]; repoScoped: boolean }) {
147146 const [platform, setPlatform] = useState<Platform>("linux");
148147 const [arch, setArch] = useState<"x64" | "arm64">("x64");
149− const busy = useNavigation().state === "submitting";
150148 return (
151149 <div className="space-y-4 rounded-xl border border-line bg-surface p-4">
152150 {!token ? (
163161 </select>
164162 </Field>
165163 )}
166− <Button type="submit" disabled={busy}>
164+ <SubmitButton match={{ intent: "token" }} pending="Making a token…">
167165 <Plus size={15} />
168166 New runner
169− </Button>
167+ </SubmitButton>
170168 <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>
171169 </Form>
172170 ) : (
239237 );
240238 }
241239
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+}) {
243251 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]);
244256 return (
245257 <div className="space-y-3">
246258 <ul className="divide-y divide-line rounded-xl border border-line bg-surface">
262274 <Form method="post" onSubmit={(event) => { if (!confirm(`Delete ${group.name}? Its runners join the default group.`)) event.preventDefault(); }}>
263275 <input type="hidden" name="intent" value="delete-group" />
264276 <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…">
266278 Delete
267− </Button>
279+ </SubmitButton>
268280 </Form>
269281 )}
270282 </span>
286298
287299 function GroupForm({ group, repositories }: { group: RunnerGroup | null; repositories: string[] }) {
288300 const [reach, setReach] = useState(group && group.repositories.length > 0 ? "some" : "all");
289− const busy = useNavigation().state === "submitting";
290301 return (
291302 <Form method="post" className="mt-3 space-y-3 rounded-lg border border-line bg-bg p-3">
292303 <input type="hidden" name="intent" value="group" />
315326 </div>
316327 )}
317328 </fieldset>
318− <Button type="submit" disabled={busy}>
329+ <SubmitButton match={{ intent: "group", id: group?.id }} pending="Saving…">
319330 Save group
320− </Button>
331+ </SubmitButton>
321332 </Form>
322333 );
323334 }
324335
325336 function Settings({ data, manage, scope }: { data: RunnersData; manage: boolean; scope: "workspace" | "project" }) {
326337 const settings = data.settings;
327− const busy = useNavigation().state === "submitting";
328338 const [agents, setAgents] = useState(settings?.agentsOnSelfHosted ?? false);
329339 const [forks, setForks] = useState(settings?.forkPullRequests ?? false);
330340 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.
331343 return (
332344 <Form method="post" className="space-y-4">
333− <input type="hidden" name="intent" value="settings" />
334345 {scope === "project" && settings.inherited && (
335346 <p className="text-sm text-muted">These are the workspace&apos;s settings. Saving here gives this project its own.</p>
336347 )}
369380 </p>
370381 {manage && (
371382 <div className="flex flex-wrap gap-3">
372− <Button type="submit" disabled={busy}>
383+ <SubmitButton name="intent" value="settings" pending="Saving…">
373384 Save
374− </Button>
385+ </SubmitButton>
375386 {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…">
377388 Follow the workspace
378− </Button>
389+ </SubmitButton>
379390 )}
380391 </div>
381392 )}
444455 title="Groups"
445456 about="Which repositories may use which runners. A runner joins the default group, every repository, unless its token names another."
446457 >
447− <Groups groups={data.groups} repositories={data.repositories} manage={manage} />
458+ <Groups groups={data.groups} repositories={data.repositories} manage={manage} action={action} />
448459 </Section>
449460 )}
450461 <Section
452463 title="Where work runs"
453464 about={scope === "workspace" ? "For every project in the workspace, unless a project says otherwise." : "For this project."}
454465 >
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+ />
456473 </Section>
457474 </div>
458475 );
+11−12
77 */
88 import { Lock, Pencil, Plus, Search, SlidersHorizontal, Trash2, X } from "lucide-react";
99 import { useMemo, useState } from "react";
10−import { Form, Link, useLocation, useNavigation } from "react-router";
10+import { Form, Link, useLocation } from "react-router";
1111
1212 import type { Setting } from "@g1t/contracts";
1313
1414 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";
1616 import { CheckboxOption } from "./ui/checkbox";
1717 import { RadioCard, RadioGroup, RadioOption } from "./ui/radio-group";
1818 import { Select, SelectContent, SelectItem, SelectSeparator, SelectTrigger, SelectValue } from "./ui/select";
150150 </div>
151151
152152 {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} />
154155 )}
155156 </div>
156157 );
157158 }
158159
159160 function Row({ row, inherited, manage }: { row: Setting; inherited: boolean; manage: boolean }) {
160− const busy = useNavigation().state === "submitting";
161161 const secret = row.kind === "secret";
162162 return (
163163 <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]">
200200 <input type="hidden" name="intent" value="delete" />
201201 <input type="hidden" name="id" value={row.id} />
202202 <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 }}
206206 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"
208208 >
209209 <Trash2 size={14} />
210− </button>
210+ </SubmitButton>
211211 </Form>
212212 </>
213213 )}
229229 projects: string[];
230230 error: string | undefined;
231231 }) {
232− const busy = useNavigation().state === "submitting";
233232 const editing = !!row;
234233 const [type, setType] = useState<"secret" | "config">(row?.kind === "variable" ? "config" : "secret");
235234 const [some, setSome] = useState(!!row && row.environments.length > 0);
403402 </div>
404403 <div className="flex items-center justify-between gap-4 border-t border-line px-6 py-4">
405404 <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…">
407406 Save
408− </Button>
407+ </SubmitButton>
409408 </div>
410409 </Form>
411410 </div>
+22−19
44 */
55 import { ChevronRight, Pause, Play, RotateCw, Send, Trash2 } from "lucide-react";
66 import { useState } from "react";
7−import { Form, Link, useNavigation } from "react-router";
7+import { Form, Link } from "react-router";
88
99 import { EVENT_TYPES, type Hook, type HookDelivery } from "@g1t/contracts";
1010
1111 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";
1313 import { CheckboxOption } from "./ui/checkbox";
1414 import { RadioGroup, RadioOption } from "./ui/radio-group";
1515
5151 }
5252
5353 function DeliveryRow({ delivery, manage }: { delivery: HookDelivery; manage: boolean }) {
54− const busy = useNavigation().state === "submitting";
5554 return (
5655 <details className="group border-t border-line first:border-t-0">
5756 <summary className="flex cursor-pointer list-none items-center gap-3 px-4 py-2.5 text-sm hover:bg-raised/40">
9089 <Form method="post" className="mt-3">
9190 <input type="hidden" name="intent" value="redeliver" />
9291 <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+ >
9497 <RotateCw size={14} />
9598 Redeliver
96− </Button>
99+ </SubmitButton>
97100 </Form>
98101 )}
99102 </div>
103106 }
104107
105108 function HookRow({ hook, open, deliveries, manage }: { hook: Hook; open: boolean; deliveries: HookDelivery[]; manage: boolean }) {
106− const busy = useNavigation().state === "submitting";
107109 return (
108110 <li className="border-t border-line first:border-t-0">
109111 <div className="flex items-center gap-3 px-4 py-3">
132134 {manage && (
133135 <Form method="post" className="flex shrink-0 gap-1">
134136 <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">
136138 <Send size={14} />
137139 </IconButton>
138140 <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"}>
140142 {hook.active ? <Pause size={14} /> : <Play size={14} />}
141143 </IconButton>
142− <IconButton intent="delete" label="Delete" disabled={busy}>
144+ <IconButton intent="delete" id={hook.id} label="Delete">
143145 <Trash2 size={14} />
144146 </IconButton>
145147 </Form>
158160 );
159161 }
160162
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 }) {
162165 return (
163− <button
164− type="submit"
166+ <SubmitButton
167+ icon
165168 name="intent"
166169 value={intent}
170+ match={{ id }}
167171 title={label}
168172 aria-label={label}
169− disabled={disabled}
170173 className="rounded-md p-2 text-muted transition-colors hover:bg-raised hover:text-fg disabled:opacity-50"
171174 >
172175 {children}
173− </button>
176+ </SubmitButton>
174177 );
175178 }
176179
177180 function AddWebhook() {
178− const busy = useNavigation().state === "submitting";
179181 const [which, setWhich] = useState<"all" | "some">("all");
180182 return (
181183 <Form method="post" className="space-y-4 rounded-xl border border-line bg-surface p-5">
223225 <Field label="Secret" hint="Optional. Deliveries are signed with it. Leave it empty and g1t makes one, shown once.">
224226 <Input name="secret" type="password" autoComplete="off" />
225227 </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>
229231 </Form>
230232 );
231233 }
291293 </ul>
292294 )}
293295
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"} />}
295298 <p className="text-xs text-faint">
296299 Each delivery is an HTTPS POST of JSON with <code>X-G1t-Event</code>, <code>X-G1t-Delivery</code> and{" "}
297300 <code>X-G1t-Signature-256</code>. One that is not answered with a 2xx is tried again after 1 minute, 5 minutes,
+11−13
11 import { Mail, UserPlus } from "lucide-react";
22 import { useEffect, useState } from "react";
3−import { Form, Link, useFetcher, useNavigation } from "react-router";
3+import { Form, Link, useFetcher } from "react-router";
44
55 import {
66 BASE_PERMISSION_LABELS,
1616 import { RoleSelect, RolesTable } from "../../components/access";
1717 import { RepoSettingsHeading } from "../../components/repo-settings-heading";
1818 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";
2020 import { Badge } from "../../components/ui/badge";
2121 import {
2222 AlertDialog,
214214 function AddForm({ result }: { result: Outcome | undefined }) {
215215 const [role, setRole] = useState<RepoRole>("write");
216216 const [key, setKey] = useState(0);
217− const navigation = useNavigation();
218− const adding = navigation.state !== "idle" && navigation.formData?.get("intent") === "add";
219217 // Cleared once someone was added, ready for the next.
220218 useEffect(() => {
221219 if (result?.ok) setKey((k) => k + 1);
231229 <span className="mb-1.5 block text-sm font-medium text-muted">Role</span>
232230 <RoleSelect name="role" label="Role" value={role} onValueChange={(value) => setRole(value as RepoRole)} />
233231 </div>
234− <Button type="submit" disabled={adding}>
232+ <SubmitButton match={{ intent: "add" }} pending="Adding…">
235233 <UserPlus size={15} />
236− {adding ? "Adding…" : "Add"}
237− </Button>
234+ Add
235+ </SubmitButton>
238236 </div>
239237 <p className="text-xs text-faint">{REPO_ROLE_LABELS[role]}: {roleLine(role)}</p>
240238 {result &&
356354 {fetcher.data && !fetcher.data.ok && <ErrorText>{fetcher.data.error}</ErrorText>}
357355 <AlertDialogFooter>
358356 <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>
362360 </AlertDialogFooter>
363361 </fetcher.Form>
364362 </AlertDialogContent>
396394 <fetcher.Form method="post" className="shrink-0">
397395 <input type="hidden" name="intent" value="revoke" />
398396 <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>
402400 </fetcher.Form>
403401 </li>
404402 );
+8−8
11 import { env } from "cloudflare:workers";
22 import { AtSign, Tag } from "lucide-react";
3−import { Form, useNavigation } from "react-router";
3+import { Form } from "react-router";
44
55 import { AGENT_HANDLE, mentionsClient } from "@g1t/contracts";
66
77 import type { Route } from "./+types/settings-agents";
88 import { page } from "../../lib/meta";
99 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";
1111 import { work } from "../../lib/services.server";
1212 import { instrumented } from "../../lib/perf.server";
1313 import { assertSameOrigin, getViewer, requireUser, unwrap } from "../../lib/session.server";
4747
4848 export default function AgentSettings({ loaderData, actionData, params }: Route.ComponentProps) {
4949 const { rules, labels } = loaderData;
50− const busy = useNavigation().state === "submitting";
5150 const base = `/${params.owner}/${params.repo}`;
5251 return (
5352 <div className="max-w-4xl">
7069 </div>
7170 <Form method="post" className="space-y-3 rounded-xl border border-line bg-surface p-4">
7271 <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} />
7474 </Field>
7575 <datalist id="known-labels">
7676 {labels.map((label) => (
7878 ))}
7979 </datalist>
8080 <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…">
8282 Save
83− </Button>
83+ </SubmitButton>
8484 {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…">
8686 Turn off
87− </Button>
87+ </SubmitButton>
8888 )}
8989 {rules.updatedBy && rules.updatedAt && (
9090 <span className="text-xs text-faint">
+5−6
11 import { ChevronRight, ShieldCheck } from "lucide-react";
2−import { Form, Link, useNavigation } from "react-router";
2+import { Form, Link } from "react-router";
33
44 import type { Route } from "./+types/settings-branches";
55 import { page } from "../../lib/meta";
77 import { RepoSettingsHeading } from "../../components/repo-settings-heading";
88 import { RequiredChecksPicker } from "../../components/required-checks";
99 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";
1111 import { actions, repos, work } from "../../lib/services.server";
1212 import { assertSameOrigin, getViewer, requireUser, unwrap } from "../../lib/session.server";
1313 import { requireCapability, requireInsider } from "../../lib/access.server";
7272
7373 export default function BranchSettings({ loaderData, actionData }: Route.ComponentProps) {
7474 const { repo, settings, seen, noChecks, canPush } = loaderData;
75− const saving = useNavigation().state === "submitting";
7675 const branch = repo.defaultBranch;
7776 const base = `/${repo.namespace}/${repo.name}`;
7877 const archived = Boolean(repo.archivedAt);
190189 </Section>
191190
192191 <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>
196195 {actionData?.saved && <span className="text-sm text-muted">Saved.</span>}
197196 <ErrorText>{actionData?.error}</ErrorText>
198197 {settings.updatedBy && settings.updatedAt && !actionData && (
+18−14
11 import { ArrowDownLeft, ArrowUpRight, FileCode2, Network, Trash2 } from "lucide-react";
2−import { Form, Link, useNavigation } from "react-router";
2+import { Form, Link } from "react-router";
33
44 import type { DependencyLink } from "@g1t/contracts";
55
66 import type { Route } from "./+types/settings-dependencies";
77 import { page } from "../../lib/meta";
88 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";
1010 import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "../../components/ui/select";
1111 import { projects } from "../../lib/services.server";
1212 import { assertSameOrigin, getViewer, requireUser, unwrap } from "../../lib/session.server";
4141 return added.ok ? { notice: `Depends on ${on}.` } : { error: added.error.message };
4242 }
4343
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 }) {
4545 return (
4646 <li className="flex items-center gap-3 px-4 py-3 text-sm">
4747 <Link to={`/${base.split("/")[1]}/${link.slug}`} className="font-medium hover:underline">
6262 <Form method="post" className="ml-auto">
6363 <input type="hidden" name="intent" value="remove" />
6464 <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 }}
6868 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"
7070 >
7171 <Trash2 size={14} />
72− </button>
72+ </SubmitButton>
7373 </Form>
7474 )}
7575 </li>
7878
7979 export default function DependencySettings({ loaderData, actionData, params }: Route.ComponentProps) {
8080 const { dependencies, projects: all } = loaderData;
81− const busy = useNavigation().state === "submitting";
8281 const base = `/${params.owner}/${params.repo}`;
8382 const taken = new Set([params.repo.toLowerCase(), ...dependencies.dependsOn.map((d) => d.slug)]);
8483 const choices = all.filter((project) => !taken.has(project.slug));
116115 ) : (
117116 <ul className="divide-y divide-line overflow-hidden rounded-xl border border-line bg-surface">
118117 {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 />
120119 ))}
121120 </ul>
122121 )}
123122 </div>
124123 {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+ >
126130 <input type="hidden" name="intent" value="add" />
127131 <Field label="Project">
128132 <Select name="on" required defaultValue={choices[0].slug}>
146150 <Field label="Its address as">
147151 <Input name="as" placeholder="API_URL" className="font-mono" />
148152 </Field>
149− <Button type="submit" disabled={busy}>
153+ <SubmitButton match={{ intent: "add" }} pending="Adding…">
150154 Add dependency
151− </Button>
155+ </SubmitButton>
152156 </Form>
153157 )}
154158 </section>
164168 ) : (
165169 <ul className="divide-y divide-line overflow-hidden rounded-xl border border-line bg-surface">
166170 {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} />
168172 ))}
169173 </ul>
170174 )}
+8−9
1−import { Form, Link, useNavigation } from "react-router";
1+import { Form, Link } from "react-router";
22
33 import type { DetectedKind } from "@g1t/contracts";
44
55 import type { Route } from "./+types/settings-deployments";
66 import { page } from "../../lib/meta";
77 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";
99 import { SwitchCard } from "../../components/ui/switch";
1010 import { deployments, projects } from "../../lib/services.server";
1111 import { assertSameOrigin, getViewer, requireUser, unwrap } from "../../lib/session.server";
9696
9797 export default function DeploymentSettings({ loaderData, actionData, params }: Route.ComponentProps) {
9898 const { settings, notDeploying } = loaderData;
99− const busy = useNavigation().state === "submitting";
10099 const base = `/${params.owner}/${params.repo}`;
101100 return (
102101 <div className="max-w-4xl">
128127 </p>
129128 ) : (
130129 <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…">
132131 Turn on deployments
133− </Button>
132+ </SubmitButton>
134133 </Form>
135134 )}
136135 </section>
163162 </Link>{" "}
164163 available to Deployments: each row for Production or Preview, or for all environments.
165164 </p>
166− <Button type="submit" disabled={busy}>
165+ <SubmitButton name="intent" value="save" pending="Saving…">
167166 Save
168− </Button>
167+ </SubmitButton>
169168 </Form>
170169
171170 <section className="mt-10 rounded-xl border border-danger/30 p-5">
175174 running or costing anything. The workspace's plan stays on; turn it off under Billing.
176175 </p>
177176 <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…">
179178 Turn off deployments
180− </Button>
179+ </SubmitButton>
181180 </Form>
182181 </section>
183182 </>
+23−18
1−import { CheckCircle2, Globe, Info, RotateCw, Trash2 } from "lucide-react";
1+import { CheckCircle2, Globe, Info, LoaderCircle, RotateCw, Trash2 } from "lucide-react";
22 import { useEffect, useState } from "react";
3−import { Form, Link, useNavigation } from "react-router";
3+import { Form, Link } from "react-router";
44
55 import type { Domain, DomainRecord, DomainStatus } from "@g1t/contracts";
66
77 import type { Route } from "./+types/settings-domains";
88 import { page } from "../../lib/meta";
99 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";
1111 import {
1212 AlertDialog,
1313 AlertDialogAction,
9797
9898 export default function DomainSettings({ loaderData, actionData, params }: Route.ComponentProps) {
9999 const { settings, domains, target, available, notice, monthlyMicros, used } = loaderData;
100− const busy = useNavigation().state === "submitting";
101100 const base = `/${params.owner}/${params.repo}`;
102101 const [hostname, setHostname] = useState("");
103102 const twin = twinOf(hostname);
171170 </Field>
172171 </div>
173172 <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()}>
175174 Add domain
176− </Button>
175+ </SubmitButton>
177176 </div>
178177 </div>
179178 <CheckboxOption
199198 key={domain.id}
200199 domain={domain}
201200 redirects={domains.filter((d) => d.redirectTo === domain.hostname)}
202− busy={busy}
203201 />
204202 ))
205203 )}
208206 );
209207 }
210208
211−function DomainCard({ domain, redirects, busy }: { domain: Domain; redirects: Domain[]; busy: boolean }) {
209+function DomainCard({ domain, redirects }: { domain: Domain; redirects: Domain[] }) {
212210 return (
213211 <section className="overflow-hidden rounded-xl border border-line bg-surface">
214− <DomainRow domain={domain} busy={busy} />
212+ <DomainRow domain={domain} />
215213 {redirects.map((other) => (
216214 <div key={other.id} className="border-t border-line">
217− <DomainRow domain={other} busy={busy} />
215+ <DomainRow domain={other} />
218216 </div>
219217 ))}
220218 </section>
221219 );
222220 }
223221
224−function DomainRow({ domain, busy }: { domain: Domain; busy: boolean }) {
222+function DomainRow({ domain }: { domain: Domain }) {
225223 const active = domain.status === "active";
226224 return (
227225 <div className="p-5">
240238 <Form method="post">
241239 <input type="hidden" name="intent" value="refresh" />
242240 <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+ >
244247 <RotateCw size={14} />
245248 Check now
246− </Button>
249+ </SubmitButton>
247250 </Form>
248251 )}
249− <RemoveDomain domain={domain} busy={busy} />
252+ <RemoveDomain domain={domain} />
250253 </span>
251254 </div>
252255 {domain.error && !active && <p className="mt-2 text-sm text-muted">{domain.error}</p>}
307310 );
308311 }
309312
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 });
311316 return (
312317 <AlertDialog>
313318 <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"}
317322 </Button>
318323 </AlertDialogTrigger>
319324 <AlertDialogContent>
+1−3
1−import { Link, useNavigation } from "react-router";
1+import { Link } from "react-router";
22
33 import { RUN_KINDS } from "@g1t/contracts";
44
4040 }
4141
4242 export default function RepoGuardrails({ loaderData, actionData, params }: Route.ComponentProps) {
43− const saving = useNavigation().state === "submitting";
4443 const base = `/${params.owner}/${params.repo}`;
4544 return (
4645 <>
5756 view={loaderData.view}
5857 level="project"
5958 editable
60− saving={saving}
6159 saved={actionData?.saved ?? false}
6260 error={actionData?.error}
6361 />
+8−5
1111 import type { Route } from "./+types/settings-repository";
1212 import { page } from "../../lib/meta";
1313 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";
1515 import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "../../components/ui/select";
1616 import {
1717 AlertDialog,
157157 </Section>
158158
159159 <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(",")}`}>
161162 <fieldset disabled={archived} className="min-w-0 space-y-3 disabled:cursor-not-allowed disabled:opacity-60">
162163 <input type="hidden" name="intent" value="details" />
163164 <Field label="Description">
173174 <Input name="topics" maxLength={800} defaultValue={(repo.topics ?? []).join(", ")} placeholder="cli, rust" />
174175 </Field>
175176 <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>
179180 <Status intent="details" data={actionData} />
180181 </div>
181182 </fieldset>
187188 about="The branch everything lands on, and renaming branches."
188189 >
189190 <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. */}
190192 <RenameBranchForm
193+ key={branches.join(" ")}
191194 repo={repo}
192195 branches={branches}
193196 owner={owner}
+11−10
11 import { Box, Code2, GitBranch, Rocket } from "lucide-react";
22 import { useState } from "react";
3−import { Form, Link, useNavigation } from "react-router";
3+import { Form, Link } from "react-router";
44
55 import type { DeploysSetting } from "@g1t/contracts";
66
77 import type { Route } from "./+types/settings";
88 import { page } from "../../lib/meta";
99 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";
1111 import { RadioGroup, RadioOption } from "../../components/ui/radio-group";
1212 import { DEPLOYS_CHOICES } from "../../lib/project-kind";
1313 import { deployments, identity, projects } from "../../lib/services.server";
5858 const [deploys, setDeploys] = useState<DeploysSetting>(project.deploys);
5959 // Not deploying while Deployments are on is refused: they are turned off first.
6060 const blocked = deploys === "no" && project.deploys !== "no" && deploymentsOn === true;
61− const saving = useNavigation().state === "submitting";
6261 const base = `/${params.owner}/${params.repo}`;
6362 const source = project.source.kind === "hosted" ? project.source : null;
6463 return (
6564 <div className="max-w-4xl">
6665 <RepoSettingsHeading base={base} />
6766 <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" />
6870 <section className="space-y-4">
6971 <h2 className="flex items-center gap-2 text-sm font-medium">
7072 <Box size={15} className="text-accent" />
9294 />
9395 </Field>
9496 {!project.descriptionInherited && (
95− <button
96− type="submit"
97+ <SubmitButton
9798 name="inherit"
9899 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"
101102 >
102103 Use the repository's description
103− </button>
104+ </SubmitButton>
104105 )}
105106 </div>
106107 </div>
185186 </section>
186187
187188 <div className="flex items-center gap-3">
188− <Button type="submit" disabled={saving || blocked}>
189+ <SubmitButton name="intent" value="save" pending="Saving…" disabled={blocked}>
189190 Save
190− </Button>
191+ </SubmitButton>
191192 {actionData && "saved" in actionData && <span className="text-sm text-accent">Saved.</span>}
192193 <span className="ml-auto text-xs text-faint">
193194 Created{" "}