Skip to content

g1t/apps/web/app/routes/repo/settings-repository.tsx

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