Skip to content

Where people and agents ship software together. The open-source git platform for the whole job: issues, agents, checks and deploys to the edge.

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