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

657 lines27,714 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";
Merge branch 'worktree-agent-a985c01f43538af01'14import { Button, ErrorText, Field, Input, SubmitButton } 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.">
Merge branch 'worktree-agent-a985c01f43538af01'160 {/* Keyed to what is saved, so the fields show the topics as the service tidied them. */}
161 <Form method="post" key={`${repo.description ?? ""}|${repo.website ?? ""}|${(repo.topics ?? []).join(",")}`}>
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look162 <fieldset disabled={archived} className="min-w-0 space-y-3 disabled:cursor-not-allowed disabled:opacity-60">
163 <input type="hidden" name="intent" value="details" />
164 <Field label="Description">
165 <Input name="description" maxLength={200} defaultValue={repo.description ?? ""} />
166 </Field>
167 <Field label="Website" hint="Its home page, shown on its page. An http or https address.">
168 <Input name="website" type="url" maxLength={300} defaultValue={repo.website ?? ""} placeholder="https://" />
169 </Field>
170 <Field
171 label="Topics"
172 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."
173 >
174 <Input name="topics" maxLength={800} defaultValue={(repo.topics ?? []).join(", ")} placeholder="cli, rust" />
175 </Field>
176 <div className="flex flex-wrap items-center gap-4 pt-1">
Merge branch 'worktree-agent-a985c01f43538af01'177 <SubmitButton match={{ intent: "details" }} pending="Saving…">
178 Save
179 </SubmitButton>
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look180 <Status intent="details" data={actionData} />
181 </div>
182 </fieldset>
183 </Form>
184 </Section>
185
186 <Section
187 title="Branches"
188 about="The branch everything lands on, and renaming branches."
189 >
190 <DefaultBranchForm repo={repo} branches={branches} archived={archived || !owner} busy={posting("default-branch")} result={actionData} />
Merge branch 'worktree-agent-a985c01f43538af01'191 {/* Keyed to the branches, so after a rename it starts over on the branches there are now. */}
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look192 <RenameBranchForm
Merge branch 'worktree-agent-a985c01f43538af01'193 key={branches.join(" ")}
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look194 repo={repo}
195 branches={branches}
196 owner={owner}
197 archived={archived}
198 busy={posting("rename-branch")}
199 result={actionData}
200 />
201 <Link
202 to={`${base}/settings/branches`}
203 className="group flex items-center gap-3 rounded-xl border border-line p-4 transition-colors hover:border-line-strong hover:bg-surface"
204 >
205 <GitBranch size={16} className="shrink-0 text-accent" />
206 <span className="min-w-0 grow">
207 <span className="block text-sm font-medium">Branches and merging</span>
208 <span className="mt-0.5 block text-sm text-muted">
209 Protection for {repo.defaultBranch}, required approvals, the merge queue and what agents do.
210 </span>
211 </span>
212 <ChevronRight size={16} className="shrink-0 text-faint transition-transform group-hover:translate-x-0.5" />
213 </Link>
214 </Section>
215
216 {owner ? (
217 <div id="danger-zone" className="scroll-mt-20 border-t border-line pt-8">
218 <DangerZone>
219 <VisibilityAction full={full} isPrivate={repo.isPrivate} error={errorFor("visibility")} />
220 <ArchiveAction full={full} archived={archived} error={errorFor("archive")} />
221 {remove && <TransferAction repo={full} destinations={destinations} error={errorFor("transfer") ?? undefined} />}
222 {remove && <DeleteAction full={full} error={errorFor("delete")} />}
223 </DangerZone>
224 {!remove && <p className="mt-4 text-sm text-muted">Only an owner of the workspace can transfer or delete it.</p>}
225 </div>
226 ) : (
227 <p className="border-t border-line pt-8 text-sm text-muted">
228 Renaming it, changing who can see it and archiving it need the Admin role. Only an owner of the workspace can
229 transfer or delete it.
230 </p>
231 )}
232 </div>
233 </>
234 );
235}
236
237type RepoLike = { namespace: string; name: string; defaultBranch: string; isPrivate: boolean };
238
239/** The repository's name, which owners can change; its old address keeps redirecting. */
240function RenameForm({
241 repo,
242 owner,
243 archived,
244 busy,
245 result,
Projects: what a workspace builds and runs, first on every page246}: {
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look247 repo: RepoLike;
248 owner: boolean;
249 archived: boolean;
250 busy: boolean;
251 result: Outcome | undefined;
Projects: what a workspace builds and runs, first on every page252}) {
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look253 const [name, setName] = useState(repo.name);
254 const wanted = tidyName(name);
255 const changed = wanted !== "" && wanted !== repo.name;
Projects: what a workspace builds and runs, first on every page256 return (
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look257 <Form method="post">
258 <fieldset disabled={!owner || archived} className="min-w-0 space-y-3">
259 <input type="hidden" name="intent" value="rename" />
260 <FormField>
261 <FieldLabel htmlFor="repo-name" className="sr-only">Name</FieldLabel>
262 <div className="flex flex-col gap-3 sm:flex-row">
263 <TextInput
264 id="repo-name"
265 name="name"
266 value={name}
267 maxLength={100}
268 spellCheck={false}
269 autoCapitalize="off"
270 autoComplete="off"
271 onChange={(event) => setName(event.target.value)}
272 className="font-mono"
273 />
274 <Button type="submit" variant="quiet" disabled={!changed || busy}>
275 {busy ? "Renaming…" : "Rename"}
276 </Button>
277 </div>
278 </FormField>
279 <p className="text-xs text-faint">
280 {!owner ? (
281 needs("administer")
282 ) : changed ? (
283 <>
284 It moves to <span className="font-mono break-all text-fg">g1t.sh/{repo.namespace}/{wanted}</span>. Links, git
285 remotes and API calls to <span className="font-mono break-all">g1t.sh/{repo.namespace}/{repo.name}</span>{" "}
286 redirect there until a repository is made at the old address.
287 </>
288 ) : (
289 <>
290 At <span className="font-mono break-all">g1t.sh/{repo.namespace}/{repo.name}</span>. After a rename, the old
291 address redirects to the new one.
292 </>
293 )}
294 </p>
295 <Status intent="rename" data={result} />
296 </fieldset>
297 </Form>
Projects: what a workspace builds and runs, first on every page298 );
299}
300
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look301/** Which branch is the default: what pull requests open against and protection covers. */
302function DefaultBranchForm({
303 repo,
304 branches,
305 archived,
306 busy,
307 result,
Projects: what a workspace builds and runs, first on every page308}: {
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look309 repo: RepoLike;
310 branches: string[];
311 archived: boolean;
312 busy: boolean;
313 result: Outcome | undefined;
Projects: what a workspace builds and runs, first on every page314}) {
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look315 const [branch, setBranch] = useState(repo.defaultBranch);
316 const options = branches.includes(repo.defaultBranch) ? branches : [repo.defaultBranch, ...branches];
Projects: what a workspace builds and runs, first on every page317 return (
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look318 <Form method="post" className="rounded-xl border border-line bg-surface p-4">
319 <fieldset disabled={archived} className="min-w-0">
320 <input type="hidden" name="intent" value="default-branch" />
321 <p className="text-sm font-medium">Default branch</p>
322 <p className="mt-1 text-sm text-muted">
323 What the repository opens on, what pull requests target, and what branch protection covers.
324 </p>
325 <div className="mt-3 flex flex-col gap-3 sm:flex-row sm:items-center">
326 <Select name="branch" value={branch} onValueChange={setBranch} disabled={archived || options.length < 2}>
327 <SelectTrigger aria-label="Default branch" className="font-mono sm:max-w-xs">
328 <SelectValue />
329 </SelectTrigger>
330 <SelectContent>
331 {options.map((name) => (
332 <SelectItem key={name} value={name} className="font-mono">
333 {name}
334 </SelectItem>
335 ))}
336 </SelectContent>
337 </Select>
338 <Button type="submit" variant="quiet" disabled={busy || branch === repo.defaultBranch}>
339 {busy ? "Changing…" : "Change default branch"}
340 </Button>
341 </div>
342 {options.length < 2 && (
343 <p className="mt-2 text-xs text-faint">Push another branch to make it the default instead.</p>
344 )}
345 <div className="mt-2">
346 <Status intent="default-branch" data={result} />
347 </div>
348 </fieldset>
349 </Form>
Projects: what a workspace builds and runs, first on every page350 );
351}
352
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look353/** Renaming a branch: pull requests from it follow, and old addresses redirect. */
354function RenameBranchForm({
355 repo,
356 branches,
357 owner,
358 archived,
359 busy,
360 result,
Projects: what a workspace builds and runs, first on every page361}: {
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look362 repo: RepoLike;
363 branches: string[];
364 owner: boolean;
365 archived: boolean;
366 busy: boolean;
367 result: Outcome | undefined;
Projects: what a workspace builds and runs, first on every page368}) {
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look369 // Only Admins rename the default branch.
370 const renamable = owner ? branches : branches.filter((b) => b !== repo.defaultBranch);
371 const [from, setFrom] = useState(renamable[0] ?? "");
372 const [to, setTo] = useState("");
373 const ready = from !== "" && to.trim() !== "" && to.trim() !== from;
Projects: what a workspace builds and runs, first on every page374 return (
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look375 <Form method="post" className="rounded-xl border border-line bg-surface p-4" onSubmit={() => setTo(to.trim())}>
376 <fieldset disabled={archived || renamable.length === 0} className="min-w-0">
377 <input type="hidden" name="intent" value="rename-branch" />
378 <p className="text-sm font-medium">Rename a branch</p>
379 <p className="mt-1 text-sm text-muted">
380 Pull requests from it follow it, and addresses that name the old branch redirect to the new one.
381 {owner ? "" : ` Renaming ${repo.defaultBranch} needs the Admin role.`}
382 </p>
383 {renamable.length === 0 ? (
384 <p className="mt-3 text-sm text-faint">There is no branch you can rename yet.</p>
385 ) : (
386 <div className="mt-3 grid gap-3 sm:grid-cols-[minmax(0,1fr)_minmax(0,1fr)_auto] sm:items-end">
387 <FormField>
388 <FieldLabel htmlFor="rename-from">Branch</FieldLabel>
389 <Select name="from" value={from} onValueChange={setFrom}>
390 <SelectTrigger id="rename-from" aria-label="Branch" className="font-mono">
391 <SelectValue />
392 </SelectTrigger>
393 <SelectContent>
394 {renamable.map((name) => (
395 <SelectItem key={name} value={name} className="font-mono">
396 {name}
397 </SelectItem>
398 ))}
399 </SelectContent>
400 </Select>
401 </FormField>
402 <FormField>
403 <FieldLabel htmlFor="rename-to">New name</FieldLabel>
404 <TextInput
405 id="rename-to"
406 name="to"
407 value={to}
408 maxLength={200}
409 spellCheck={false}
410 autoCapitalize="off"
411 autoComplete="off"
412 placeholder={from === repo.defaultBranch ? "main" : "feature/new-name"}
413 onChange={(event) => setTo(event.target.value)}
414 className="font-mono"
415 />
416 </FormField>
417 <Button type="submit" variant="quiet" disabled={!ready || busy}>
418 {busy ? "Renaming…" : "Rename branch"}
419 </Button>
420 </div>
421 )}
422 <div className="mt-2">
423 <Status intent="rename-branch" data={result} saved="Renamed." />
424 </div>
425 </fieldset>
426 </Form>
Projects: what a workspace builds and runs, first on every page427 );
428}
429
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look430/** Public to private or back, with what changes, typed out to confirm. */
431function VisibilityAction({ full, isPrivate, error }: { full: string; isPrivate: boolean; error: string | null }) {
432 const next = isPrivate ? "public" : "private";
Projects: what a workspace builds and runs, first on every page433 return (
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look434 <ConfirmDialog
435 intent="visibility"
436 fields={{ visibility: next }}
437 title={`Make ${full} ${next}?`}
438 confirm={full}
439 submit={`Make ${next}`}
440 busy="Changing…"
441 error={error}
442 trigger={(open) => (
443 <DangerAction
444 title="Change visibility"
445 action={
446 <Button type="button" variant="danger" onClick={open}>
447 Make {next}
448 </Button>
449 }
450 >
451 It is {isPrivate ? "private: only people with access can see it" : "public: anyone can see and clone it"}.
452 </DangerAction>
453 )}
454 >
455 {isPrivate ? (
456 <>
457 <li>Anyone can see its code, issues and pull requests, and clone it, without signing in.</li>
458 <li>It shows in search and on Explore.</li>
459 <li>Links to it show a preview card with its name and description where they are shared.</li>
460 </>
461 ) : (
462 <>
463 <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>
464 <li>It leaves search and Explore, and links to it stop showing a preview card.</li>
465 <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>
466 </>
467 )}
468 </ConfirmDialog>
469 );
470}
Projects: what a workspace builds and runs, first on every page471
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look472/** Read-only, or back to normal. */
473function ArchiveAction({ full, archived, error }: { full: string; archived: boolean; error: string | null }) {
474 return (
475 <ConfirmDialog
476 intent="archive"
477 fields={{ archived: archived ? "false" : "true" }}
478 title={archived ? `Unarchive ${full}?` : `Archive ${full}?`}
479 description={archived ? "It goes back to working as before." : "It becomes read-only. You can unarchive it at any time."}
480 submit={archived ? "Unarchive" : "Archive"}
481 busy={archived ? "Unarchiving…" : "Archiving…"}
482 danger={!archived}
483 error={error}
484 trigger={(open) => (
485 <DangerAction
486 title={archived ? "Unarchive this repository" : "Archive this repository"}
487 action={
488 <Button type="button" variant="danger" onClick={open}>
489 {archived ? "Unarchive" : "Archive"}
490 </Button>
491 }
Projects: what a workspace builds and runs, first on every page492 >
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look493 {archived
494 ? "It is archived and read-only. Unarchiving lets pushes, merges, agents and workflows work again."
495 : "Make it read-only: everything stays where it is and can be read, and nothing changes it."}
496 </DangerAction>
497 )}
498 >
499 {archived ? (
500 <>
501 <li>Pushes and merges are accepted again.</li>
502 <li>Issues and pull requests unlock.</li>
503 <li>Agents and workflows run again.</li>
504 </>
505 ) : (
506 <>
507 <li>Pushes and merges are refused, for members and agents alike.</li>
508 <li>Issues and pull requests are locked. They stay readable.</li>
509 <li>Agents and workflows do not run.</li>
510 <li>Its deployments keep serving, and anyone who could see it still can.</li>
511 </>
512 )}
513 </ConfirmDialog>
514 );
515}
Projects: what a workspace builds and runs, first on every page516
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look517/** Deleting: hidden at once, restorable for a while, then gone. */
518function DeleteAction({ full, error }: { full: string; error: string | null }) {
519 const namespace = full.split("/")[0];
520 return (
521 <ConfirmDialog
522 intent="delete"
523 title={`Delete ${full}?`}
524 description={`You can restore it for ${RESTORE_DAYS} days.`}
525 confirm={full}
526 submit="Delete repository"
527 busy="Deleting…"
528 error={error}
529 trigger={(open) => (
530 <DangerAction
531 title="Delete this repository"
532 action={
533 <Button type="button" variant="danger" onClick={open}>
534 Delete
535 </Button>
536 }
Projects: what a workspace builds and runs, first on every page537 >
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look538 It can be restored from the workspace's repositories for {RESTORE_DAYS} days, then it is removed for good.
539 </DangerAction>
540 )}
541 >
542 <li>It disappears at once: its pages, git remote and API stop answering, for everyone.</li>
543 <li>
544 Owners can restore it, as it was, from <span className="font-mono text-fg">{namespace}</span>'s Recently deleted
545 repositories for {RESTORE_DAYS} days. After that it is removed for good, its git data with it.
546 </li>
547 <li>Its name stays taken until then, so no other repository can be made at its address.</li>
548 </ConfirmDialog>
549 );
550}
Projects: what a workspace builds and runs, first on every page551
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look552/**
553 * Moving the repository to another workspace the owner also owns. The
554 * dialog says what moves and what changes, and asks for the full name.
555 */
556function TransferAction({
557 repo,
558 destinations,
559 error,
560}: {
561 repo: string;
562 destinations: { slug: string; name: string }[];
563 error?: string;
564}) {
565 const [open, setOpen] = useState(Boolean(error));
566 const [to, setTo] = useState(destinations[0]?.slug ?? "");
567 const [confirm, setConfirm] = useState("");
568 const navigation = useNavigation();
569 const moving = navigation.state !== "idle" && navigation.formData?.get("intent") === "transfer";
570 const name = repo.split("/")[1];
571 return (
572 <DangerAction
573 title="Transfer this repository"
574 action={
575 <Button type="button" variant="danger" disabled={destinations.length === 0} onClick={() => setOpen(true)}>
576 Transfer
577 </Button>
578 }
579 >
580 {destinations.length === 0 ? (
581 <>Move it to another workspace you own. You own no other workspace yet.</>
582 ) : (
583 <>Move it to another workspace you own. Its old address keeps redirecting.</>
584 )}
585 <AlertDialog
586 open={open}
587 onOpenChange={(next) => {
588 setOpen(next);
589 setConfirm("");
590 }}
591 >
592 <AlertDialogContent>
593 <Form method="post" className="grid gap-4">
594 <input type="hidden" name="intent" value="transfer" />
595 <AlertDialogHeader>
596 <AlertDialogTitle>Transfer {repo}</AlertDialogTitle>
597 <AlertDialogDescription>
598 It keeps its name and moves with everything in it: code, issues, pull requests, workflow runs,
599 deployments, its project, and its own secrets, variables and webhooks.
600 </AlertDialogDescription>
601 </AlertDialogHeader>
602 <FormField>
603 <FieldLabel htmlFor="transfer-to">New workspace</FieldLabel>
604 <Select name="to" value={to} onValueChange={setTo}>
605 <SelectTrigger id="transfer-to" aria-label="New workspace">
606 <SelectValue />
607 </SelectTrigger>
608 <SelectContent>
609 {destinations.map((d) => (
610 <SelectItem key={d.slug} value={d.slug}>
611 {d.name === d.slug ? d.slug : `${d.name} (${d.slug})`}
612 </SelectItem>
613 ))}
614 </SelectContent>
615 </Select>
616 </FormField>
617 <ul className="list-disc space-y-1.5 pl-5 text-sm text-muted">
618 <li>
619 It moves to <span className="font-mono text-fg">g1t.sh/{to}/{name}</span>. Links, git remotes and API
620 calls to <span className="font-mono">g1t.sh/{repo}</span> redirect there until a repository is made at
621 the old address.
622 </li>
623 <li>
624 Apps it deploys move to <span className="font-mono">*.g1t.page</span> names with the new workspace; the
625 old ones redirect for 90 days. Custom domains follow.
626 </li>
627 <li>The old workspace's secrets, webhooks and integrations stop reaching it; the new one's start.</li>
628 <li>Usage from now on is charged to {to}. What it used before stays on the old workspace's bill.</li>
629 </ul>
630 <FormField>
631 <FieldLabel htmlFor="transfer-confirm">
632 Type <span className="font-mono text-fg">{repo}</span> to confirm
633 </FieldLabel>
634 <TextInput
635 id="transfer-confirm"
636 name="confirm"
637 value={confirm}
638 spellCheck={false}
639 autoCapitalize="off"
640 autoComplete="off"
641 onChange={(event) => setConfirm(event.target.value)}
642 className="font-mono"
643 />
644 </FormField>
645 <ErrorText>{error}</ErrorText>
646 <AlertDialogFooter>
647 <AlertDialogCancel type="button">Cancel</AlertDialogCancel>
648 <Button type="submit" variant="danger" disabled={!to || !confirmsName(confirm, repo) || moving}>
649 {moving ? "Transferring…" : "Transfer"}
650 </Button>
651 </AlertDialogFooter>
652 </Form>
653 </AlertDialogContent>
654 </AlertDialog>
655 </DangerAction>
Projects: what a workspace builds and runs, first on every page656 );
657}

This file's history is long; its oldest lines are credited to the oldest commit read.