Skip to content

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

701 lines30,029 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
Merge membership: owners, org roles, GitHub's repo roles, privileges, 2FA5import { DEFAULT_MEMBER_PRIVILEGES, 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";
Actions: OIDC tokens, the toolkit's cache and artifact services, and artifacts in R227import { actions, repos } from "../../lib/services.server";
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look28import {
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 };
Actions: OIDC tokens, the toolkit's cache and artifact services, and artifacts in R244 const [branches, retention] = await Promise.all([repos.branches(path, viewer), actions.artifactRetention(path, viewer)]);
Merge membership: owners, org roles, GitHub's repo roles, privileges, 2FA45 // 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.
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look48 const owner = access.can.administer;
Merge membership: owners, org roles, GitHub's repo roles, privileges, 2FA49 // Where it can go: a workspace where the person can create a repository like it.
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look50 const destinations = access.can.delete
51 ? (viewer?.workspaces ?? [])
Merge membership: owners, org roles, GitHub's repo roles, privileges, 2FA52 .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 })
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look58 .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,
Merge membership: owners, org roles, GitHub's repo roles, privileges, 2FA66 visibility: access.can.change_visibility,
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look67 destinations,
Actions: OIDC tokens, the toolkit's cache and artifact services, and artifacts in R268 // How long workflow runs' artifacts are kept.
69 retention: retention.ok ? retention.value : { days: 14, maximum_allowed_days: 90 },
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look70 };
Projects: what a workspace builds and runs, first on every page71}
72
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look73/** 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 };
Projects: what a workspace builds and runs, first on every page78}
79
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look80export async function action({ request, params, context }: Route.ActionArgs): Promise<Outcome> {
Projects: what a workspace builds and runs, first on every page81 assertSameOrigin(request);
82 const user = requireUser(context, request);
83 const form = await request.formData();
84 const path = { namespace: params.owner, name: params.repo };
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look85 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 =
Actions: OIDC tokens, the toolkit's cache and artifact services, and artifacts in R292 intent === "details" || intent === "artifacts"
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look93 ? "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"));
Actions: OIDC tokens, the toolkit's cache and artifact services, and artifacts in R2119 case "artifacts":
120 return outcome(intent, await actions.artifactRetention(path, user, Number(text("days"))));
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look121 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}
Projects: what a workspace builds and runs, first on every page148
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look149/** 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>;
Projects: what a workspace builds and runs, first on every page153}
154
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look155export default function RepoSettings({ loaderData, actionData }: Route.ComponentProps) {
Merge membership: owners, org roles, GitHub's repo roles, privileges, 2FA156 const { repo, branches, owner, remove, visibility, destinations, retention } = loaderData;
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look157 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.">
Merge branch 'worktree-agent-a985c01f43538af01'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(",")}`}>
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look174 <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">
Merge branch 'worktree-agent-a985c01f43538af01'189 <SubmitButton match={{ intent: "details" }} pending="Saving…">
190 Save
191 </SubmitButton>
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look192 <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} />
Merge branch 'worktree-agent-a985c01f43538af01'203 {/* 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 look204 <RenameBranchForm
Merge branch 'worktree-agent-a985c01f43538af01'205 key={branches.join(" ")}
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look206 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
Actions: OIDC tokens, the toolkit's cache and artifact services, and artifacts in R2228 <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
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look254 {owner ? (
255 <div id="danger-zone" className="scroll-mt-20 border-t border-line pt-8">
256 <DangerZone>
Merge membership: owners, org roles, GitHub's repo roles, privileges, 2FA257 {visibility && <VisibilityAction full={full} isPrivate={repo.isPrivate} error={errorFor("visibility")} />}
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look258 <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>
Merge membership: owners, org roles, GitHub's repo roles, privileges, 2FA262 {(!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 )}
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look269 </div>
270 ) : (
271 <p className="border-t border-line pt-8 text-sm text-muted">
Merge membership: owners, org roles, GitHub's repo roles, privileges, 2FA272 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.
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look274 </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,
Projects: what a workspace builds and runs, first on every page290}: {
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look291 repo: RepoLike;
292 owner: boolean;
293 archived: boolean;
294 busy: boolean;
295 result: Outcome | undefined;
Projects: what a workspace builds and runs, first on every page296}) {
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look297 const [name, setName] = useState(repo.name);
298 const wanted = tidyName(name);
299 const changed = wanted !== "" && wanted !== repo.name;
Projects: what a workspace builds and runs, first on every page300 return (
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look301 <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>
Projects: what a workspace builds and runs, first on every page342 );
343}
344
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look345/** 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,
Projects: what a workspace builds and runs, first on every page352}: {
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look353 repo: RepoLike;
354 branches: string[];
355 archived: boolean;
356 busy: boolean;
357 result: Outcome | undefined;
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 const [branch, setBranch] = useState(repo.defaultBranch);
360 const options = branches.includes(repo.defaultBranch) ? branches : [repo.defaultBranch, ...branches];
Projects: what a workspace builds and runs, first on every page361 return (
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look362 <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>
Projects: what a workspace builds and runs, first on every page394 );
395}
396
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look397/** 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,
Projects: what a workspace builds and runs, first on every page405}: {
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look406 repo: RepoLike;
407 branches: string[];
408 owner: boolean;
409 archived: boolean;
410 busy: boolean;
411 result: Outcome | undefined;
Projects: what a workspace builds and runs, first on every page412}) {
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look413 // 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;
Projects: what a workspace builds and runs, first on every page418 return (
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look419 <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>
Projects: what a workspace builds and runs, first on every page471 );
472}
473
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look474/** 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";
Projects: what a workspace builds and runs, first on every page477 return (
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look478 <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}
Projects: what a workspace builds and runs, first on every page515
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look516/** 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 }
Projects: what a workspace builds and runs, first on every page536 >
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look537 {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}
Projects: what a workspace builds and runs, first on every page560
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look561/** 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 }
Projects: what a workspace builds and runs, first on every page581 >
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look582 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}
Projects: what a workspace builds and runs, first on every page595
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look596/**
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>
Projects: what a workspace builds and runs, first on every page700 );
701}

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