Skip to content
687 linesCodeBlameRaw
1import { ChevronRight, GitBranch } from "lucide-react";
2import { useState } from "react";
3import { Form, Link, redirect, useNavigation } from "react-router";
4
5import { RESTORE_DAYS, type Result, needs } from "@g1t/contracts";
6
7import { DangerAction, DangerZone } from "../../components/danger-zone";
8import { ConfirmDialog } from "../../components/repo-lifecycle";
9import { RepoSettingsHeading } from "../../components/repo-settings-heading";
10import { SettingsSection as Section } from "../../components/settings-section";
11import type { Route } from "./+types/settings-repository";
12import { page } from "../../lib/meta";
13import { confirmsName, tidyName } from "../../lib/repo-lifecycle";
14import { Button, ErrorText, Field, Input, SubmitButton } from "../../components/ui";
15import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "../../components/ui/select";
16import {
17 AlertDialog,
18 AlertDialogCancel,
19 AlertDialogContent,
20 AlertDialogDescription,
21 AlertDialogFooter,
22 AlertDialogHeader,
23 AlertDialogTitle,
24} from "../../components/ui/alert-dialog";
25import { FieldLabel, Field as FormField } from "../../components/ui/field";
26import { Input as TextInput } from "../../components/ui/input";
27import { actions, repos } from "../../lib/services.server";
28import {
29 assertSameOrigin,
30 getViewer,
31 requireUser,
32} from "../../lib/session.server";
33import { refusal, requireInsider } from "../../lib/access.server";
34
35export function meta({ params, ...args }: Route.MetaArgs) {
36 return page(args, { title: `Repository settings · ${params.owner}/${params.repo} · g1t` });
37}
38
39export async function loader({ params, context }: Route.LoaderArgs) {
40 const viewer = getViewer(context);
41 // Maintain and up; to anyone without a role here the page does not exist.
42 const { repo, access } = await requireInsider(context, params, "manage_settings");
43 const path = { namespace: params.owner, name: params.repo };
44 const [branches, retention] = await Promise.all([repos.branches(path, viewer), actions.artifactRetention(path, viewer)]);
45 // Renaming, visibility, archiving and the default branch are for Admins;
46 // moving and deleting, for owners of the workspace.
47 const owner = access.can.administer;
48 const destinations = access.can.delete
49 ? (viewer?.workspaces ?? [])
50 .filter((m) => m.role === "owner" && m.slug !== params.owner.toLowerCase())
51 .map((m) => ({ slug: m.slug, name: m.name ?? m.slug }))
52 : [];
53 return {
54 repo,
55 // An empty repository has no branches yet; the page still works.
56 branches: branches.ok ? branches.value.map((b) => b.name) : [],
57 owner,
58 remove: access.can.delete,
59 destinations,
60 // How long workflow runs' artifacts are kept.
61 retention: retention.ok ? retention.value : { days: 14, maximum_allowed_days: 90 },
62 };
63}
64
65/** What a form on this page came back with: which one, and how it went. */
66type Outcome = { intent: string; saved: boolean; error: string | null };
67
68function outcome(intent: string, result: Result<unknown>): Outcome {
69 return result.ok ? { intent, saved: true, error: null } : { intent, saved: false, error: result.error.message };
70}
71
72export async function action({ request, params, context }: Route.ActionArgs): Promise<Outcome> {
73 assertSameOrigin(request);
74 const user = requireUser(context, request);
75 const form = await request.formData();
76 const path = { namespace: params.owner, name: params.repo };
77 const full = `${params.owner}/${params.repo}`;
78 const intent = String(form.get("intent") ?? "details");
79 const text = (name: string) => String(form.get(name) ?? "").trim();
80 const typed = text("confirm");
81 // What each form needs: the details are settings, renaming a branch other
82 // than the default is pushing, transfer and delete are for owners.
83 const capability =
84 intent === "details" || intent === "artifacts"
85 ? "manage_settings"
86 : intent === "rename-branch"
87 ? "push"
88 : intent === "transfer" || intent === "delete"
89 ? "delete"
90 : "administer";
91 const refused = await refusal(context, params, capability);
92 if (refused) return { intent, saved: false, error: refused };
93
94 switch (intent) {
95 case "rename": {
96 const renamed = await repos.rename(user, path, tidyName(text("name")));
97 if (!renamed.ok) return outcome(intent, renamed);
98 // The old address redirects, but the page moves with it now.
99 throw redirect(`/${renamed.value.namespace}/${renamed.value.name}/settings/repository`);
100 }
101 case "default-branch":
102 return outcome(intent, await repos.setDefaultBranch(user, path, text("branch")));
103 case "rename-branch":
104 return outcome(intent, await repos.renameBranch(user, path, text("from"), text("to")));
105 case "visibility": {
106 if (!confirmsName(typed, full)) return { intent, saved: false, error: `Type ${full} to confirm.` };
107 return outcome(intent, await repos.setVisibility(user, path, text("visibility") === "private", typed));
108 }
109 case "archive":
110 return outcome(intent, await repos.archive(user, path, text("archived") === "true"));
111 case "artifacts":
112 return outcome(intent, await actions.artifactRetention(path, user, Number(text("days"))));
113 case "transfer": {
114 // repos checks both workspaces' owners, the name and storage, and
115 // keeps the old address as a redirect.
116 const to = text("to").toLowerCase();
117 if (!confirmsName(typed, full)) return { intent, saved: false, error: `Type ${full} to confirm.` };
118 const moved = await repos.transfer(user, path, to);
119 if (!moved.ok) return outcome(intent, moved);
120 throw redirect(`/${moved.value.namespace}/${moved.value.name}/settings/repository`);
121 }
122 case "delete": {
123 if (!confirmsName(typed, full)) return { intent, saved: false, error: `Type ${full} to confirm.` };
124 const deleted = await repos.delete(user, path, typed);
125 if (!deleted.ok) return outcome(intent, deleted);
126 throw redirect(`/${params.owner}/-/repositories?deleted=${encodeURIComponent(deleted.value.name)}`);
127 }
128 default:
129 return outcome(
130 "details",
131 await repos.update(user, path, {
132 description: text("description"),
133 website: text("website"),
134 // Typed with commas or spaces between them; the service tidies the rest.
135 topics: text("topics").split(/[\s,]+/).filter(Boolean),
136 }),
137 );
138 }
139}
140
141/** Saved, or the error, for the form that posted `intent`. */
142function Status({ intent, data: result, saved = "Saved." }: { intent: string; data: Outcome | undefined; saved?: string }) {
143 if (result?.intent !== intent) return null;
144 return result.saved ? <span className="text-sm text-muted">{saved}</span> : <ErrorText>{result.error}</ErrorText>;
145}
146
147export default function RepoSettings({ loaderData, actionData }: Route.ComponentProps) {
148 const { repo, branches, owner, remove, destinations, retention } = loaderData;
149 const navigation = useNavigation();
150 const posting = (intent: string) => navigation.state !== "idle" && navigation.formData?.get("intent") === intent;
151 const base = `/${repo.namespace}/${repo.name}`;
152 const full = `${repo.namespace}/${repo.name}`;
153 const archived = Boolean(repo.archivedAt);
154 const errorFor = (intent: string) => (actionData?.intent === intent ? actionData.error : null);
155 return (
156 <>
157 <RepoSettingsHeading base={base} />
158 <div className="max-w-4xl space-y-8">
159 <Section title="Name" about="Its address on g1t, in git remotes and in the API.">
160 <RenameForm repo={repo} owner={owner} archived={archived} busy={posting("rename")} result={actionData} />
161 </Section>
162
163 <Section title="Details" about="What the repository is, for its page, search and Explore.">
164 {/* Keyed to what is saved, so the fields show the topics as the service tidied them. */}
165 <Form method="post" key={`${repo.description ?? ""}|${repo.website ?? ""}|${(repo.topics ?? []).join(",")}`}>
166 <fieldset disabled={archived} className="min-w-0 space-y-3 disabled:cursor-not-allowed disabled:opacity-60">
167 <input type="hidden" name="intent" value="details" />
168 <Field label="Description">
169 <Input name="description" maxLength={200} defaultValue={repo.description ?? ""} />
170 </Field>
171 <Field label="Website" hint="Its home page, shown on its page. An http or https address.">
172 <Input name="website" type="url" maxLength={300} defaultValue={repo.website ?? ""} placeholder="https://" />
173 </Field>
174 <Field
175 label="Topics"
176 hint="What it is about, for search and Explore: words such as cli, rust or design-system, separated by commas or spaces. Up to 20."
177 >
178 <Input name="topics" maxLength={800} defaultValue={(repo.topics ?? []).join(", ")} placeholder="cli, rust" />
179 </Field>
180 <div className="flex flex-wrap items-center gap-4 pt-1">
181 <SubmitButton match={{ intent: "details" }} pending="Saving…">
182 Save
183 </SubmitButton>
184 <Status intent="details" data={actionData} />
185 </div>
186 </fieldset>
187 </Form>
188 </Section>
189
190 <Section
191 title="Branches"
192 about="The branch everything lands on, and renaming branches."
193 >
194 <DefaultBranchForm repo={repo} branches={branches} archived={archived || !owner} busy={posting("default-branch")} result={actionData} />
195 {/* Keyed to the branches, so after a rename it starts over on the branches there are now. */}
196 <RenameBranchForm
197 key={branches.join(" ")}
198 repo={repo}
199 branches={branches}
200 owner={owner}
201 archived={archived}
202 busy={posting("rename-branch")}
203 result={actionData}
204 />
205 <Link
206 to={`${base}/settings/branches`}
207 className="group flex items-center gap-3 rounded-xl border border-line p-4 transition-colors hover:border-line-strong hover:bg-surface"
208 >
209 <GitBranch size={16} className="shrink-0 text-accent" />
210 <span className="min-w-0 grow">
211 <span className="block text-sm font-medium">Branches and merging</span>
212 <span className="mt-0.5 block text-sm text-muted">
213 Protection for {repo.defaultBranch}, required approvals, the merge queue and what agents do.
214 </span>
215 </span>
216 <ChevronRight size={16} className="shrink-0 text-faint transition-transform group-hover:translate-x-0.5" />
217 </Link>
218 </Section>
219
220 <Section title="Artifacts" about="How long the files workflow runs upload are kept.">
221 <Form method="post" key={retention.days} className="space-y-3">
222 <input type="hidden" name="intent" value="artifacts" />
223 <Field
224 label="Days to keep artifacts"
225 hint={`1 to ${retention.maximum_allowed_days}. A workflow's retention-days can ask for fewer, never more. Artifacts already uploaded keep their expiry.`}
226 >
227 <Input
228 name="days"
229 type="number"
230 min={1}
231 max={retention.maximum_allowed_days}
232 required
233 defaultValue={retention.days}
234 className="w-28"
235 />
236 </Field>
237 <div className="flex flex-wrap items-center gap-4 pt-1">
238 <SubmitButton match={{ intent: "artifacts" }} pending="Saving…">
239 Save
240 </SubmitButton>
241 <Status intent="artifacts" data={actionData} />
242 </div>
243 </Form>
244 </Section>
245
246 {owner ? (
247 <div id="danger-zone" className="scroll-mt-20 border-t border-line pt-8">
248 <DangerZone>
249 <VisibilityAction full={full} isPrivate={repo.isPrivate} error={errorFor("visibility")} />
250 <ArchiveAction full={full} archived={archived} error={errorFor("archive")} />
251 {remove && <TransferAction repo={full} destinations={destinations} error={errorFor("transfer") ?? undefined} />}
252 {remove && <DeleteAction full={full} error={errorFor("delete")} />}
253 </DangerZone>
254 {!remove && <p className="mt-4 text-sm text-muted">Only an owner of the workspace can transfer or delete it.</p>}
255 </div>
256 ) : (
257 <p className="border-t border-line pt-8 text-sm text-muted">
258 Renaming it, changing who can see it and archiving it need the Admin role. Only an owner of the workspace can
259 transfer or delete it.
260 </p>
261 )}
262 </div>
263 </>
264 );
265}
266
267type RepoLike = { namespace: string; name: string; defaultBranch: string; isPrivate: boolean };
268
269/** The repository's name, which owners can change; its old address keeps redirecting. */
270function RenameForm({
271 repo,
272 owner,
273 archived,
274 busy,
275 result,
276}: {
277 repo: RepoLike;
278 owner: boolean;
279 archived: boolean;
280 busy: boolean;
281 result: Outcome | undefined;
282}) {
283 const [name, setName] = useState(repo.name);
284 const wanted = tidyName(name);
285 const changed = wanted !== "" && wanted !== repo.name;
286 return (
287 <Form method="post">
288 <fieldset disabled={!owner || archived} className="min-w-0 space-y-3">
289 <input type="hidden" name="intent" value="rename" />
290 <FormField>
291 <FieldLabel htmlFor="repo-name" className="sr-only">Name</FieldLabel>
292 <div className="flex flex-col gap-3 sm:flex-row">
293 <TextInput
294 id="repo-name"
295 name="name"
296 value={name}
297 maxLength={100}
298 spellCheck={false}
299 autoCapitalize="off"
300 autoComplete="off"
301 onChange={(event) => setName(event.target.value)}
302 className="font-mono"
303 />
304 <Button type="submit" variant="quiet" disabled={!changed || busy}>
305 {busy ? "Renaming…" : "Rename"}
306 </Button>
307 </div>
308 </FormField>
309 <p className="text-xs text-faint">
310 {!owner ? (
311 needs("administer")
312 ) : changed ? (
313 <>
314 It moves to <span className="font-mono break-all text-fg">g1t.sh/{repo.namespace}/{wanted}</span>. Links, git
315 remotes and API calls to <span className="font-mono break-all">g1t.sh/{repo.namespace}/{repo.name}</span>{" "}
316 redirect there until a repository is made at the old address.
317 </>
318 ) : (
319 <>
320 At <span className="font-mono break-all">g1t.sh/{repo.namespace}/{repo.name}</span>. After a rename, the old
321 address redirects to the new one.
322 </>
323 )}
324 </p>
325 <Status intent="rename" data={result} />
326 </fieldset>
327 </Form>
328 );
329}
330
331/** Which branch is the default: what pull requests open against and protection covers. */
332function DefaultBranchForm({
333 repo,
334 branches,
335 archived,
336 busy,
337 result,
338}: {
339 repo: RepoLike;
340 branches: string[];
341 archived: boolean;
342 busy: boolean;
343 result: Outcome | undefined;
344}) {
345 const [branch, setBranch] = useState(repo.defaultBranch);
346 const options = branches.includes(repo.defaultBranch) ? branches : [repo.defaultBranch, ...branches];
347 return (
348 <Form method="post" className="rounded-xl border border-line bg-surface p-4">
349 <fieldset disabled={archived} className="min-w-0">
350 <input type="hidden" name="intent" value="default-branch" />
351 <p className="text-sm font-medium">Default branch</p>
352 <p className="mt-1 text-sm text-muted">
353 What the repository opens on, what pull requests target, and what branch protection covers.
354 </p>
355 <div className="mt-3 flex flex-col gap-3 sm:flex-row sm:items-center">
356 <Select name="branch" value={branch} onValueChange={setBranch} disabled={archived || options.length < 2}>
357 <SelectTrigger aria-label="Default branch" className="font-mono sm:max-w-xs">
358 <SelectValue />
359 </SelectTrigger>
360 <SelectContent>
361 {options.map((name) => (
362 <SelectItem key={name} value={name} className="font-mono">
363 {name}
364 </SelectItem>
365 ))}
366 </SelectContent>
367 </Select>
368 <Button type="submit" variant="quiet" disabled={busy || branch === repo.defaultBranch}>
369 {busy ? "Changing…" : "Change default branch"}
370 </Button>
371 </div>
372 {options.length < 2 && (
373 <p className="mt-2 text-xs text-faint">Push another branch to make it the default instead.</p>
374 )}
375 <div className="mt-2">
376 <Status intent="default-branch" data={result} />
377 </div>
378 </fieldset>
379 </Form>
380 );
381}
382
383/** Renaming a branch: pull requests from it follow, and old addresses redirect. */
384function RenameBranchForm({
385 repo,
386 branches,
387 owner,
388 archived,
389 busy,
390 result,
391}: {
392 repo: RepoLike;
393 branches: string[];
394 owner: boolean;
395 archived: boolean;
396 busy: boolean;
397 result: Outcome | undefined;
398}) {
399 // Only Admins rename the default branch.
400 const renamable = owner ? branches : branches.filter((b) => b !== repo.defaultBranch);
401 const [from, setFrom] = useState(renamable[0] ?? "");
402 const [to, setTo] = useState("");
403 const ready = from !== "" && to.trim() !== "" && to.trim() !== from;
404 return (
405 <Form method="post" className="rounded-xl border border-line bg-surface p-4" onSubmit={() => setTo(to.trim())}>
406 <fieldset disabled={archived || renamable.length === 0} className="min-w-0">
407 <input type="hidden" name="intent" value="rename-branch" />
408 <p className="text-sm font-medium">Rename a branch</p>
409 <p className="mt-1 text-sm text-muted">
410 Pull requests from it follow it, and addresses that name the old branch redirect to the new one.
411 {owner ? "" : ` Renaming ${repo.defaultBranch} needs the Admin role.`}
412 </p>
413 {renamable.length === 0 ? (
414 <p className="mt-3 text-sm text-faint">There is no branch you can rename yet.</p>
415 ) : (
416 <div className="mt-3 grid gap-3 sm:grid-cols-[minmax(0,1fr)_minmax(0,1fr)_auto] sm:items-end">
417 <FormField>
418 <FieldLabel htmlFor="rename-from">Branch</FieldLabel>
419 <Select name="from" value={from} onValueChange={setFrom}>
420 <SelectTrigger id="rename-from" aria-label="Branch" className="font-mono">
421 <SelectValue />
422 </SelectTrigger>
423 <SelectContent>
424 {renamable.map((name) => (
425 <SelectItem key={name} value={name} className="font-mono">
426 {name}
427 </SelectItem>
428 ))}
429 </SelectContent>
430 </Select>
431 </FormField>
432 <FormField>
433 <FieldLabel htmlFor="rename-to">New name</FieldLabel>
434 <TextInput
435 id="rename-to"
436 name="to"
437 value={to}
438 maxLength={200}
439 spellCheck={false}
440 autoCapitalize="off"
441 autoComplete="off"
442 placeholder={from === repo.defaultBranch ? "main" : "feature/new-name"}
443 onChange={(event) => setTo(event.target.value)}
444 className="font-mono"
445 />
446 </FormField>
447 <Button type="submit" variant="quiet" disabled={!ready || busy}>
448 {busy ? "Renaming…" : "Rename branch"}
449 </Button>
450 </div>
451 )}
452 <div className="mt-2">
453 <Status intent="rename-branch" data={result} saved="Renamed." />
454 </div>
455 </fieldset>
456 </Form>
457 );
458}
459
460/** Public to private or back, with what changes, typed out to confirm. */
461function VisibilityAction({ full, isPrivate, error }: { full: string; isPrivate: boolean; error: string | null }) {
462 const next = isPrivate ? "public" : "private";
463 return (
464 <ConfirmDialog
465 intent="visibility"
466 fields={{ visibility: next }}
467 title={`Make ${full} ${next}?`}
468 confirm={full}
469 submit={`Make ${next}`}
470 busy="Changing…"
471 error={error}
472 trigger={(open) => (
473 <DangerAction
474 title="Change visibility"
475 action={
476 <Button type="button" variant="danger" onClick={open}>
477 Make {next}
478 </Button>
479 }
480 >
481 It is {isPrivate ? "private: only people with access can see it" : "public: anyone can see and clone it"}.
482 </DangerAction>
483 )}
484 >
485 {isPrivate ? (
486 <>
487 <li>Anyone can see its code, issues and pull requests, and clone it, without signing in.</li>
488 <li>It shows in search and on Explore.</li>
489 <li>Links to it show a preview card with its name and description where they are shared.</li>
490 </>
491 ) : (
492 <>
493 <li>Only people with access can see it: owners, members by the workspace's base permission, and people you add. Anyone else gets a page that says it does not exist.</li>
494 <li>It leaves search and Explore, and links to it stop showing a preview card.</li>
495 <li>Its storage counts toward the workspace's private storage. A free workspace has 1 GB, and making it private is refused when that would go over.</li>
496 </>
497 )}
498 </ConfirmDialog>
499 );
500}
501
502/** Read-only, or back to normal. */
503function ArchiveAction({ full, archived, error }: { full: string; archived: boolean; error: string | null }) {
504 return (
505 <ConfirmDialog
506 intent="archive"
507 fields={{ archived: archived ? "false" : "true" }}
508 title={archived ? `Unarchive ${full}?` : `Archive ${full}?`}
509 description={archived ? "It goes back to working as before." : "It becomes read-only. You can unarchive it at any time."}
510 submit={archived ? "Unarchive" : "Archive"}
511 busy={archived ? "Unarchiving…" : "Archiving…"}
512 danger={!archived}
513 error={error}
514 trigger={(open) => (
515 <DangerAction
516 title={archived ? "Unarchive this repository" : "Archive this repository"}
517 action={
518 <Button type="button" variant="danger" onClick={open}>
519 {archived ? "Unarchive" : "Archive"}
520 </Button>
521 }
522 >
523 {archived
524 ? "It is archived and read-only. Unarchiving lets pushes, merges, agents and workflows work again."
525 : "Make it read-only: everything stays where it is and can be read, and nothing changes it."}
526 </DangerAction>
527 )}
528 >
529 {archived ? (
530 <>
531 <li>Pushes and merges are accepted again.</li>
532 <li>Issues and pull requests unlock.</li>
533 <li>Agents and workflows run again.</li>
534 </>
535 ) : (
536 <>
537 <li>Pushes and merges are refused, for members and agents alike.</li>
538 <li>Issues and pull requests are locked. They stay readable.</li>
539 <li>Agents and workflows do not run.</li>
540 <li>Its deployments keep serving, and anyone who could see it still can.</li>
541 </>
542 )}
543 </ConfirmDialog>
544 );
545}
546
547/** Deleting: hidden at once, restorable for a while, then gone. */
548function DeleteAction({ full, error }: { full: string; error: string | null }) {
549 const namespace = full.split("/")[0];
550 return (
551 <ConfirmDialog
552 intent="delete"
553 title={`Delete ${full}?`}
554 description={`You can restore it for ${RESTORE_DAYS} days.`}
555 confirm={full}
556 submit="Delete repository"
557 busy="Deleting…"
558 error={error}
559 trigger={(open) => (
560 <DangerAction
561 title="Delete this repository"
562 action={
563 <Button type="button" variant="danger" onClick={open}>
564 Delete
565 </Button>
566 }
567 >
568 It can be restored from the workspace's repositories for {RESTORE_DAYS} days, then it is removed for good.
569 </DangerAction>
570 )}
571 >
572 <li>It disappears at once: its pages, git remote and API stop answering, for everyone.</li>
573 <li>
574 Owners can restore it, as it was, from <span className="font-mono text-fg">{namespace}</span>'s Recently deleted
575 repositories for {RESTORE_DAYS} days. After that it is removed for good, its git data with it.
576 </li>
577 <li>Its name stays taken until then, so no other repository can be made at its address.</li>
578 </ConfirmDialog>
579 );
580}
581
582/**
583 * Moving the repository to another workspace the owner also owns. The
584 * dialog says what moves and what changes, and asks for the full name.
585 */
586function TransferAction({
587 repo,
588 destinations,
589 error,
590}: {
591 repo: string;
592 destinations: { slug: string; name: string }[];
593 error?: string;
594}) {
595 const [open, setOpen] = useState(Boolean(error));
596 const [to, setTo] = useState(destinations[0]?.slug ?? "");
597 const [confirm, setConfirm] = useState("");
598 const navigation = useNavigation();
599 const moving = navigation.state !== "idle" && navigation.formData?.get("intent") === "transfer";
600 const name = repo.split("/")[1];
601 return (
602 <DangerAction
603 title="Transfer this repository"
604 action={
605 <Button type="button" variant="danger" disabled={destinations.length === 0} onClick={() => setOpen(true)}>
606 Transfer
607 </Button>
608 }
609 >
610 {destinations.length === 0 ? (
611 <>Move it to another workspace you own. You own no other workspace yet.</>
612 ) : (
613 <>Move it to another workspace you own. Its old address keeps redirecting.</>
614 )}
615 <AlertDialog
616 open={open}
617 onOpenChange={(next) => {
618 setOpen(next);
619 setConfirm("");
620 }}
621 >
622 <AlertDialogContent>
623 <Form method="post" className="grid gap-4">
624 <input type="hidden" name="intent" value="transfer" />
625 <AlertDialogHeader>
626 <AlertDialogTitle>Transfer {repo}</AlertDialogTitle>
627 <AlertDialogDescription>
628 It keeps its name and moves with everything in it: code, issues, pull requests, workflow runs,
629 deployments, its project, and its own secrets, variables and webhooks.
630 </AlertDialogDescription>
631 </AlertDialogHeader>
632 <FormField>
633 <FieldLabel htmlFor="transfer-to">New workspace</FieldLabel>
634 <Select name="to" value={to} onValueChange={setTo}>
635 <SelectTrigger id="transfer-to" aria-label="New workspace">
636 <SelectValue />
637 </SelectTrigger>
638 <SelectContent>
639 {destinations.map((d) => (
640 <SelectItem key={d.slug} value={d.slug}>
641 {d.name === d.slug ? d.slug : `${d.name} (${d.slug})`}
642 </SelectItem>
643 ))}
644 </SelectContent>
645 </Select>
646 </FormField>
647 <ul className="list-disc space-y-1.5 pl-5 text-sm text-muted">
648 <li>
649 It moves to <span className="font-mono text-fg">g1t.sh/{to}/{name}</span>. Links, git remotes and API
650 calls to <span className="font-mono">g1t.sh/{repo}</span> redirect there until a repository is made at
651 the old address.
652 </li>
653 <li>
654 Apps it deploys move to <span className="font-mono">*.g1t.page</span> names with the new workspace; the
655 old ones redirect for 90 days. Custom domains follow.
656 </li>
657 <li>The old workspace's secrets, webhooks and integrations stop reaching it; the new one's start.</li>
658 <li>Usage from now on is charged to {to}. What it used before stays on the old workspace's bill.</li>
659 </ul>
660 <FormField>
661 <FieldLabel htmlFor="transfer-confirm">
662 Type <span className="font-mono text-fg">{repo}</span> to confirm
663 </FieldLabel>
664 <TextInput
665 id="transfer-confirm"
666 name="confirm"
667 value={confirm}
668 spellCheck={false}
669 autoCapitalize="off"
670 autoComplete="off"
671 onChange={(event) => setConfirm(event.target.value)}
672 className="font-mono"
673 />
674 </FormField>
675 <ErrorText>{error}</ErrorText>
676 <AlertDialogFooter>
677 <AlertDialogCancel type="button">Cancel</AlertDialogCancel>
678 <Button type="submit" variant="danger" disabled={!to || !confirmsName(confirm, repo) || moving}>
679 {moving ? "Transferring…" : "Transfer"}
680 </Button>
681 </AlertDialogFooter>
682 </Form>
683 </AlertDialogContent>
684 </AlertDialog>
685 </DangerAction>
686 );
687}