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

654 lines27,390 bytesCodeBlame

Pick any line to see why it is the way it is: the commit, the pull request and issue it came from, and what the agent was thinking.

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