Skip to content

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

783 linesCodeBlameRaw
1import { ChevronRight, GitBranch } from "lucide-react";
2import { useState } from "react";
3import { Form, Link, redirect, useNavigation } from "react-router";
4
5import { type Result, needs } from "@g1t/contracts";
6
7import { DangerAction, DangerZone } from "../../components/danger-zone";
8import { ConfirmDialog } from "../../components/repo-lifecycle";
9import { RepoSettingsHeading } from "../../components/repo-settings-heading";
10import { SettingsSection as Section, SettingToggle } from "../../components/settings-section";
11import { SwitchCard } from "../../components/ui/switch";
12import type { Route } from "./+types/settings-repository";
13import { page } from "../../lib/meta";
14import { confirmsName, tidyName } from "../../lib/repo-lifecycle";
15import { ErrorText, Field, Input, SubmitButton } from "../../components/ui";
16import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "../../components/ui/select";
17import { Button } from "../../components/ui/button";
18import { Badge } from "../../components/ui/badge";
19import { Card } from "../../components/ui/card";
20import { FieldLabel, Field as FormField } from "../../components/ui/field";
21import { Input as TextInput } from "../../components/ui/input";
22import { actions, repos, work } from "../../lib/services.server";
23import { assertSameOrigin, getViewer, requireUser } from "../../lib/session.server";
24import { refusal, requireInsider } from "../../lib/access.server";
25import { Unavailable } from "../../components/boundary";
26import { isFailed, part } from "../../lib/partial";
27
28export function meta({ params, ...args }: Route.MetaArgs) {
29 return page(args, { title: `Repository settings · ${params.owner}/${params.repo} · g1t` });
30}
31
32export async function loader({ params, context }: Route.LoaderArgs) {
33 const viewer = getViewer(context);
34 // Maintain and up; to anyone without a role here the page does not exist.
35 const { repo, access } = await requireInsider(context, params, "manage_settings");
36 const path = { namespace: params.owner, name: params.repo };
37 // Each of these is one section's: when one cannot be read, that section
38 // says so and has no form to save over what it could not show.
39 const [branches, retention, settings] = await Promise.all([
40 part(repos.branches(path, viewer), "Its branches"),
41 part(actions.artifactRetention(path, viewer), "How long artifacts are kept"),
42 part(work.getSettings(path, viewer), "These settings"),
43 ]);
44 // Renaming and the default branch are for Admins; visibility, for owners
45 // of the workspace unless its member privileges let its members with
46 // Admin. Archiving, moving and deleting are the project's, on General.
47 const owner = access.can.administer;
48 return {
49 repo,
50 // An empty repository has no branches yet; the page still works.
51 branches: isFailed(branches) ? branches : branches.ok ? branches.value.map((b) => b.name) : [],
52 owner,
53 visibility: access.can.change_visibility,
54 // How long workflow runs' artifacts are kept.
55 retention: isFailed(retention) ? retention : retention.ok ? retention.value : { days: 14, maximum_allowed_days: 90 },
56 // Whether work items can be filed from it; null when it was not answered.
57 issues: isFailed(settings) ? settings : settings.ok ? { on: settings.value.workItemsEnabled ?? true, chosen: settings.value.hasWorkItems != null } : null,
58 // How commits and work items are handled; null when it was not answered.
59 pullSettings: isFailed(settings)
60 ? settings
61 : settings.ok
62 ? {
63 autoCloseItems: settings.value.autoCloseItems,
64 requireWebCommitSignOff: settings.value.requireWebCommitSignOff,
65 allowCommitComments: settings.value.allowCommitComments,
66 }
67 : null,
68 };
69}
70
71/** What a form on this page came back with: which one, and how it went. */
72type Outcome = { intent: string; saved: boolean; error: string | null };
73
74function outcome(intent: string, result: Result<unknown>): Outcome {
75 return result.ok ? { intent, saved: true, error: null } : { intent, saved: false, error: result.error.message };
76}
77
78export async function action({ request, params, context }: Route.ActionArgs): Promise<Outcome> {
79 assertSameOrigin(request);
80 const user = requireUser(context, request);
81 const form = await request.formData();
82 const path = { namespace: params.owner, name: params.repo };
83 const full = `${params.owner}/${params.repo}`;
84 const intent = String(form.get("intent") ?? "details");
85 const text = (name: string) => String(form.get(name) ?? "").trim();
86 const typed = text("confirm");
87 // What each form needs: the details are settings, renaming a branch other
88 // than the default is pushing.
89 const capability =
90 intent === "details" || intent === "artifacts" || intent === "features" || intent === "commits"
91 ? "manage_settings"
92 : intent === "rename-branch"
93 ? "push"
94 : "administer";
95 const refused = await refusal(context, params, capability);
96 if (refused) return { intent, saved: false, error: refused };
97
98 switch (intent) {
99 case "rename": {
100 const renamed = await repos.rename(user, path, tidyName(text("name")));
101 if (!renamed.ok) return outcome(intent, renamed);
102 // The old address redirects, but the page moves with it now.
103 throw redirect(`/${renamed.value.namespace}/${renamed.value.name}/settings/repository`);
104 }
105 case "default-branch":
106 return outcome(intent, await repos.setDefaultBranch(user, path, text("branch")));
107 case "rename-branch":
108 return outcome(intent, await repos.renameBranch(user, path, text("from"), text("to")));
109 case "visibility": {
110 if (!confirmsName(typed, full)) return { intent, saved: false, error: `Type ${full} to confirm.` };
111 return outcome(intent, await repos.setVisibility(user, path, text("visibility") === "private", typed));
112 }
113 case "features": {
114 // The other settings are sent back as they are: only work items change here.
115 const current = await work.getSettings(path, user);
116 if (!current.ok) return outcome(intent, current);
117 return outcome(
118 intent,
119 await work.updateSettings(user, path, {
120 ...current.value,
121 hasWorkItems: form.get("hasWorkItems") === "on",
122 autoCloseItems: form.get("autoCloseItems") === "on",
123 }),
124 );
125 }
126 case "commits": {
127 // The other settings are sent back as they are.
128 const current = await work.getSettings(path, user);
129 if (!current.ok) return outcome(intent, current);
130 return outcome(
131 intent,
132 await work.updateSettings(user, path, {
133 ...current.value,
134 requireWebCommitSignOff: form.get("requireWebCommitSignOff") === "on",
135 allowCommitComments: form.get("allowCommitComments") === "on",
136 }),
137 );
138 }
139 case "pushes": {
140 // Off is 0: no limit.
141 const limit = form.get("limitRefUpdates") === "on" ? Math.trunc(Number(text("maxRefUpdates"))) : 0;
142 if (!Number.isFinite(limit) || limit < 0 || limit > 1000 || (form.get("limitRefUpdates") === "on" && limit < 1)) {
143 return { intent, saved: false, error: "Enter a number of branches and tags from 1 to 1000." };
144 }
145 return outcome(intent, await repos.update(user, path, { maxRefUpdates: limit }));
146 }
147 case "forking":
148 return outcome(intent, await repos.update(user, path, { allowForking: form.get("allowForking") === "on" }));
149 case "detach":
150 return outcome(intent, await repos.detachFork(user, path));
151 case "artifacts":
152 return outcome(intent, await actions.artifactRetention(path, user, Number(text("days"))));
153 default:
154 return outcome(
155 "details",
156 await repos.update(user, path, {
157 website: text("website"),
158 // Typed with commas or spaces between them; the service tidies the rest.
159 topics: text("topics")
160 .split(/[\s,]+/)
161 .filter(Boolean),
162 }),
163 );
164 }
165}
166
167/** Saved, or the error, for the form that posted `intent`. */
168function Status({ intent, data: result, saved = "Saved." }: { intent: string; data: Outcome | undefined; saved?: string }) {
169 if (result?.intent !== intent) return null;
170 return result.saved ? <span className="text-sm text-muted">{saved}</span> : <ErrorText>{result.error}</ErrorText>;
171}
172
173export default function RepoSettings({ loaderData, actionData }: Route.ComponentProps) {
174 const { repo, branches, owner, visibility, retention, issues: features, pullSettings: commits } = loaderData;
175 // Settings that could not be read are said to be, in their sections.
176 const issues = isFailed(features) ? null : features;
177 const pullSettings = isFailed(commits) ? null : commits;
178 const navigation = useNavigation();
179 const posting = (intent: string) => navigation.state !== "idle" && navigation.formData?.get("intent") === intent;
180 const base = `/${repo.namespace}/${repo.name}`;
181 const full = `${repo.namespace}/${repo.name}`;
182 const archived = Boolean(repo.archivedAt);
183 const errorFor = (intent: string) => (actionData?.intent === intent ? actionData.error : null);
184 return (
185 <>
186 <RepoSettingsHeading base={base} />
187 <div className="max-w-4xl space-y-8">
188 <Section title="Name" about="Its address on g1t, in git remotes and in the API.">
189 <RenameForm repo={repo} owner={owner} archived={archived} busy={posting("rename")} result={actionData} />
190 </Section>
191
192 <Section
193 title="Details"
194 about={
195 <>
196 Its website and topics, for its page, search and Explore. Its description is its project's, on{" "}
197 <Link to={`${base}/settings`} className="text-accent hover:underline">
198 General
199 </Link>
200 .
201 </>
202 }
203 >
204 {/* Keyed to what is saved, so the fields show the topics as the service tidied them. */}
205 <Form method="post" key={`${repo.website ?? ""}|${(repo.topics ?? []).join(",")}`}>
206 <fieldset disabled={archived} className="min-w-0 space-y-3 disabled:cursor-not-allowed disabled:opacity-60">
207 <input type="hidden" name="intent" value="details" />
208 <Field label="Website" hint="Its home page, shown on its page. An http or https address.">
209 <Input name="website" type="url" maxLength={300} defaultValue={repo.website ?? ""} placeholder="https://" />
210 </Field>
211 <Field
212 label="Topics"
213 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."
214 >
215 <Input name="topics" maxLength={800} defaultValue={(repo.topics ?? []).join(", ")} placeholder="cli, rust" />
216 </Field>
217 <div className="flex flex-wrap items-center gap-4 pt-1">
218 <SubmitButton match={{ intent: "details" }} pending="Saving…">
219 Save
220 </SubmitButton>
221 <Status intent="details" data={actionData} />
222 </div>
223 </fieldset>
224 </Form>
225 </Section>
226
227 <Section title="Branches" about="The branch everything lands on, and renaming branches.">
228 {isFailed(branches) ? (
229 <Unavailable what={branches.what} />
230 ) : (
231 <>
232 <DefaultBranchForm repo={repo} branches={branches} archived={archived || !owner} busy={posting("default-branch")} result={actionData} />
233 {/* Keyed to the branches, so after a rename it starts over on the branches there are now. */}
234 <RenameBranchForm
235 key={branches.join(" ")}
236 repo={repo}
237 branches={branches}
238 owner={owner}
239 archived={archived}
240 busy={posting("rename-branch")}
241 result={actionData}
242 />
243 </>
244 )}
245 <Link
246 to={`${base}/settings/branches`}
247 className="group flex items-center gap-3 rounded-xl border border-line p-4 transition-colors hover:border-line-strong hover:bg-surface"
248 >
249 <GitBranch size={16} className="shrink-0 text-accent" />
250 <span className="min-w-0 grow">
251 <span className="block text-sm font-medium">Branches and merging</span>
252 <span className="mt-0.5 block text-sm text-muted">
253 Merge methods and commit messages, auto-merge, deleting branches after merging, and the rules for {repo.defaultBranch}.
254 </span>
255 </span>
256 <ChevronRight size={16} className="shrink-0 text-faint transition-transform group-hover:translate-x-0.5" />
257 </Link>
258 </Section>
259
260 {isFailed(features) && (
261 <Section title="Features" about="What the repository has besides its code.">
262 <Unavailable what="Its features" />
263 </Section>
264 )}
265 {issues && (
266 <Section title="Features" about="What the repository has besides its code.">
267 <Form method="post" key={`${issues.on}|${pullSettings?.autoCloseItems}`} className="space-y-3">
268 <fieldset disabled={archived} className="min-w-0 space-y-3">
269 <input type="hidden" name="intent" value="features" />
270 <SettingToggle name="hasWorkItems" on={issues.on} title="Work items" disabled={archived}>
271 File the workspace's work items from it: bugs, ideas and work to do. Turned off, no new work item can be filed from it; those filed already
272 stay, and work items can still land in it as a target.
273 {repo.forkedFrom && !issues.chosen && " Off by default, as it was made as a fork."}
274 </SettingToggle>
275 {pullSettings && (
276 <SettingToggle
277 name="autoCloseItems"
278 on={pullSettings.autoCloseItems}
279 title="Land work items with merged linked pull requests"
280 disabled={archived}
281 >
282 When a pull request merges into {repo.defaultBranch}, the work items it is for or names after a closing keyword (
283 <span className="font-mono text-xs">Fixes #12</span>, <span className="font-mono text-xs">Closes #12</span>) land here, and close once
284 nothing else is left to land. Off, they stay open to be closed by hand.
285 </SettingToggle>
286 )}
287 <div className="flex flex-wrap items-center gap-4 pt-1">
288 <SubmitButton variant="outline" match={{ intent: "features" }} pending="Saving…">
289 Save
290 </SubmitButton>
291 <Status intent="features" data={actionData} />
292 </div>
293 </fieldset>
294 </Form>
295 </Section>
296 )}
297
298 {isFailed(commits) && (
299 <Section id="commits" title="Commits" about="Commits made on g1t, and talking about commits.">
300 <Unavailable what="Its commit settings" />
301 </Section>
302 )}
303 {pullSettings && (
304 <Section id="commits" title="Commits" about="Commits made on g1t, and talking about commits.">
305 <Form method="post" key={`${pullSettings.requireWebCommitSignOff}|${pullSettings.allowCommitComments}`} className="space-y-3">
306 <fieldset disabled={archived} className="min-w-0 space-y-3">
307 <input type="hidden" name="intent" value="commits" />
308 <SettingToggle
309 name="requireWebCommitSignOff"
310 on={pullSettings.requireWebCommitSignOff}
311 title="Require contributors to sign off on web-based commits"
312 disabled={archived}
313 >
314 Every commit made on g1t.sh, from editing a file to merging a pull request, carries a{" "}
315 <span className="font-mono text-xs">Signed-off-by:</span> line for whoever made it: their statement that they may contribute it under the
316 project's license, as the{" "}
317 <a
318 href="https://developercertificate.org/"
319 target="_blank"
320 rel="noreferrer"
321 className="text-fg underline underline-offset-2 hover:text-accent"
322 onClick={(event) => event.stopPropagation()}
323 >
324 Developer Certificate of Origin
325 </a>{" "}
326 asks.
327 </SettingToggle>
328 <SettingToggle
329 name="allowCommitComments"
330 on={pullSettings.allowCommitComments}
331 title="Allow comments on individual commits"
332 disabled={archived}
333 >
334 Anyone who can read it may comment on a commit, or on one line of a file a commit changed, from the commit's page. Off, comments already made
335 stay readable and no new ones can be added.
336 </SettingToggle>
337 <div className="flex flex-wrap items-center gap-4 pt-1">
338 <SubmitButton variant="outline" match={{ intent: "commits" }} pending="Saving…">
339 Save
340 </SubmitButton>
341 <Status intent="commits" data={actionData} />
342 </div>
343 </fieldset>
344 </Form>
345 </Section>
346 )}
347
348 <Section id="pushes" title="Pushes" about="What a single push may change.">
349 <PushLimitForm limit={repo.maxRefUpdates ?? 0} disabled={archived || !owner} owner={owner} result={actionData} />
350 </Section>
351
352 <Section id="archives" title="Archives" about="What the .zip and .tar.gz downloads of its code hold.">
353 {/* Large file storage is not built yet: shown so the choice has its place. */}
354 <SwitchCard name="includeLfsInArchives" defaultChecked={false} disabled title="Include Git LFS objects in archives">
355 <Badge tone="neutral" size="sm" className="mr-1.5 align-middle">
356 Coming soon
357 </Badge>
358 Archives would hold the files Git LFS stores, not only their pointers. g1t does not store Git LFS files yet.
359 </SwitchCard>
360 </Section>
361
362 <Section title="Artifacts" about="How long the files workflow runs upload are kept.">
363 {isFailed(retention) ? (
364 <Unavailable what={retention.what} />
365 ) : (
366 <Form method="post" key={retention.days} className="space-y-3">
367 <input type="hidden" name="intent" value="artifacts" />
368 <Field
369 label="Days to keep artifacts"
370 hint={`1 to ${retention.maximum_allowed_days}. A workflow's retention-days can ask for fewer, never more. Artifacts already uploaded keep their expiry.`}
371 >
372 <Input name="days" type="number" min={1} max={retention.maximum_allowed_days} required defaultValue={retention.days} className="w-28" />
373 </Field>
374 <div className="flex flex-wrap items-center gap-4 pt-1">
375 <SubmitButton match={{ intent: "artifacts" }} pending="Saving…">
376 Save
377 </SubmitButton>
378 <Status intent="artifacts" data={actionData} />
379 </div>
380 </Form>
381 )}
382 </Section>
383
384 {repo.isPrivate && (
385 <Section id="forking" title="Forking" about="Whether people who can read it may copy it into their own workspaces.">
386 <Form method="post" key={String(Boolean(repo.allowForking))} className="space-y-3">
387 <fieldset disabled={!owner || archived} className="min-w-0 space-y-3">
388 <input type="hidden" name="intent" value="forking" />
389 <SettingToggle name="allowForking" on={Boolean(repo.allowForking)} title="Allow forking" disabled={!owner || archived}>
390 People who can read it may fork it into their own workspaces. Forks stay private.
391 </SettingToggle>
392 <div className="flex flex-wrap items-center gap-4 pt-1">
393 <SubmitButton variant="outline" match={{ intent: "forking" }} pending="Saving…">
394 Save
395 </SubmitButton>
396 <Status intent="forking" data={actionData} />
397 {!owner && <span className="text-xs text-faint">{needs("administer")}</span>}
398 </div>
399 </fieldset>
400 </Form>
401 </Section>
402 )}
403
404 {owner && (visibility || repo.parentId) ? (
405 <div id="danger-zone" className="scroll-mt-20 border-t border-line pt-8">
406 <DangerZone>
407 {visibility && <VisibilityAction full={full} isPrivate={repo.isPrivate} fork={Boolean(repo.parentId)} error={errorFor("visibility")} />}
408 {repo.parentId && <DetachAction full={full} parent={repo.parent ?? null} error={errorFor("detach")} />}
409 </DangerZone>
410 <LifecycleNote base={base} />
411 </div>
412 ) : (
413 <div className="border-t border-line pt-8 text-sm text-muted">
414 {owner ? (
415 <p>Only an owner of the workspace can change who can see it: its member privileges keep that to owners.</p>
416 ) : (
417 <p>
418 Renaming it needs the Admin role, and changing who can see it an owner of the workspace, unless its member privileges let repository admins.
419 </p>
420 )}
421 <LifecycleNote base={base} />
422 </div>
423 )}
424 </div>
425 </>
426 );
427}
428
429/** Where archiving, transferring and deleting went: they are the project's. */
430function LifecycleNote({ base }: { base: string }) {
431 return (
432 <p className="mt-4 text-sm text-muted">
433 Archiving, transferring and deleting it are the project's, at the end of{" "}
434 <Link to={`${base}/settings#danger-zone`} className="text-accent hover:underline">
435 General
436 </Link>
437 .
438 </p>
439 );
440}
441
442/** The most branches and tags one push may update: off (0), or a number. */
443function PushLimitForm({ limit, disabled, owner, result }: { limit: number; disabled: boolean; owner: boolean; result: Outcome | undefined }) {
444 const [on, setOn] = useState(limit > 0);
445 return (
446 <Form method="post" key={limit} className="space-y-3">
447 <fieldset disabled={disabled} className="min-w-0 space-y-3">
448 <input type="hidden" name="intent" value="pushes" />
449 <SwitchCard
450 name="limitRefUpdates"
451 checked={on}
452 onCheckedChange={setOn}
453 disabled={disabled}
454 title="Limit how many branches and tags can be updated in a single push"
455 >
456 A push that creates, moves or deletes more than this many branches and tags at once is refused whole, so a mistaken{" "}
457 <span className="font-mono text-xs">git push --mirror</span> or <span className="font-mono text-xs">--all</span> cannot rewrite the repository.
458 </SwitchCard>
459 {on && (
460 <FormField className="pl-4">
461 <FieldLabel htmlFor="max-ref-updates">Most branches and tags per push</FieldLabel>
462 <TextInput
463 id="max-ref-updates"
464 name="maxRefUpdates"
465 type="number"
466 min={1}
467 max={1000}
468 required
469 defaultValue={limit > 0 ? limit : 5}
470 className="w-28"
471 />
472 </FormField>
473 )}
474 <div className="flex flex-wrap items-center gap-4 pt-1">
475 <SubmitButton variant="outline" match={{ intent: "pushes" }} pending="Saving…">
476 Save
477 </SubmitButton>
478 <Status intent="pushes" data={result} />
479 {!owner && <span className="text-xs text-faint">{needs("administer")}</span>}
480 </div>
481 </fieldset>
482 </Form>
483 );
484}
485
486type RepoLike = { namespace: string; name: string; defaultBranch: string; isPrivate: boolean };
487
488/** The repository's name, which owners can change; its old address keeps redirecting. */
489function RenameForm({
490 repo,
491 owner,
492 archived,
493 busy,
494 result,
495}: {
496 repo: RepoLike;
497 owner: boolean;
498 archived: boolean;
499 busy: boolean;
500 result: Outcome | undefined;
501}) {
502 const [name, setName] = useState(repo.name);
503 const wanted = tidyName(name);
504 const changed = wanted !== "" && wanted !== repo.name;
505 return (
506 <Form method="post">
507 <fieldset disabled={!owner || archived} className="min-w-0 space-y-3">
508 <input type="hidden" name="intent" value="rename" />
509 <FormField>
510 <FieldLabel htmlFor="repo-name" className="sr-only">
511 Name
512 </FieldLabel>
513 <div className="flex flex-col gap-3 sm:flex-row">
514 <TextInput
515 id="repo-name"
516 name="name"
517 value={name}
518 maxLength={100}
519 spellCheck={false}
520 autoCapitalize="off"
521 autoComplete="off"
522 onChange={(event) => setName(event.target.value)}
523 className="font-mono"
524 />
525 <Button type="submit" variant="outline" disabled={!changed || busy}>
526 {busy ? "Renaming…" : "Rename"}
527 </Button>
528 </div>
529 </FormField>
530 <p className="text-xs text-faint">
531 {!owner ? (
532 needs("administer")
533 ) : changed ? (
534 <>
535 It moves to{" "}
536 <span className="font-mono break-all text-fg">
537 g1t.sh/{repo.namespace}/{wanted}
538 </span>
539 . Links, git remotes and API calls to{" "}
540 <span className="font-mono break-all">
541 g1t.sh/{repo.namespace}/{repo.name}
542 </span>{" "}
543 redirect there until a repository is made at the old address.
544 </>
545 ) : (
546 <>
547 At{" "}
548 <span className="font-mono break-all">
549 g1t.sh/{repo.namespace}/{repo.name}
550 </span>
551 . After a rename, the old address redirects to the new one.
552 </>
553 )}
554 </p>
555 <Status intent="rename" data={result} />
556 </fieldset>
557 </Form>
558 );
559}
560
561/** Which branch is the default: what pull requests open against and protection covers. */
562function DefaultBranchForm({
563 repo,
564 branches,
565 archived,
566 busy,
567 result,
568}: {
569 repo: RepoLike;
570 branches: string[];
571 archived: boolean;
572 busy: boolean;
573 result: Outcome | undefined;
574}) {
575 const [branch, setBranch] = useState(repo.defaultBranch);
576 const options = branches.includes(repo.defaultBranch) ? branches : [repo.defaultBranch, ...branches];
577 return (
578 <Card asChild className="p-4">
579 <Form method="post">
580 <fieldset disabled={archived} className="min-w-0">
581 <input type="hidden" name="intent" value="default-branch" />
582 <p className="text-sm font-medium">Default branch</p>
583 <p className="mt-1 text-sm text-muted">What the repository opens on, what pull requests target, and what branch protection covers.</p>
584 <div className="mt-3 flex flex-col gap-3 sm:flex-row sm:items-center">
585 <Select name="branch" value={branch} onValueChange={setBranch} disabled={archived || options.length < 2}>
586 <SelectTrigger aria-label="Default branch" className="font-mono sm:max-w-xs">
587 <SelectValue />
588 </SelectTrigger>
589 <SelectContent>
590 {options.map((name) => (
591 <SelectItem key={name} value={name} className="font-mono">
592 {name}
593 </SelectItem>
594 ))}
595 </SelectContent>
596 </Select>
597 <Button type="submit" variant="outline" disabled={busy || branch === repo.defaultBranch}>
598 {busy ? "Changing…" : "Change default branch"}
599 </Button>
600 </div>
601 {options.length < 2 && <p className="mt-2 text-xs text-faint">Push another branch to make it the default instead.</p>}
602 <div className="mt-2">
603 <Status intent="default-branch" data={result} />
604 </div>
605 </fieldset>
606 </Form>
607 </Card>
608 );
609}
610
611/** Renaming a branch: pull requests from it follow, and old addresses redirect. */
612function RenameBranchForm({
613 repo,
614 branches,
615 owner,
616 archived,
617 busy,
618 result,
619}: {
620 repo: RepoLike;
621 branches: string[];
622 owner: boolean;
623 archived: boolean;
624 busy: boolean;
625 result: Outcome | undefined;
626}) {
627 // Only Admins rename the default branch.
628 const renamable = owner ? branches : branches.filter((b) => b !== repo.defaultBranch);
629 const [from, setFrom] = useState(renamable[0] ?? "");
630 const [to, setTo] = useState("");
631 const ready = from !== "" && to.trim() !== "" && to.trim() !== from;
632 return (
633 <Card asChild className="p-4">
634 <Form method="post" onSubmit={() => setTo(to.trim())}>
635 <fieldset disabled={archived || renamable.length === 0} className="min-w-0">
636 <input type="hidden" name="intent" value="rename-branch" />
637 <p className="text-sm font-medium">Rename a branch</p>
638 <p className="mt-1 text-sm text-muted">
639 Pull requests from it follow it, and addresses that name the old branch redirect to the new one.
640 {owner ? "" : ` Renaming ${repo.defaultBranch} needs the Admin role.`}
641 </p>
642 {renamable.length === 0 ? (
643 <p className="mt-3 text-sm text-faint">There is no branch you can rename yet.</p>
644 ) : (
645 <div className="mt-3 grid gap-3 sm:grid-cols-[minmax(0,1fr)_minmax(0,1fr)_auto] sm:items-end">
646 <FormField>
647 <FieldLabel htmlFor="rename-from">Branch</FieldLabel>
648 <Select name="from" value={from} onValueChange={setFrom}>
649 <SelectTrigger id="rename-from" aria-label="Branch" className="font-mono">
650 <SelectValue />
651 </SelectTrigger>
652 <SelectContent>
653 {renamable.map((name) => (
654 <SelectItem key={name} value={name} className="font-mono">
655 {name}
656 </SelectItem>
657 ))}
658 </SelectContent>
659 </Select>
660 </FormField>
661 <FormField>
662 <FieldLabel htmlFor="rename-to">New name</FieldLabel>
663 <TextInput
664 id="rename-to"
665 name="to"
666 value={to}
667 maxLength={200}
668 spellCheck={false}
669 autoCapitalize="off"
670 autoComplete="off"
671 placeholder={from === repo.defaultBranch ? "main" : "feature/new-name"}
672 onChange={(event) => setTo(event.target.value)}
673 className="font-mono"
674 />
675 </FormField>
676 <Button type="submit" variant="outline" disabled={!ready || busy}>
677 {busy ? "Renaming…" : "Rename branch"}
678 </Button>
679 </div>
680 )}
681 <div className="mt-2">
682 <Status intent="rename-branch" data={result} saved="Renamed." />
683 </div>
684 </fieldset>
685 </Form>
686 </Card>
687 );
688}
689
690/** Public to private or back, with what changes, typed out to confirm. */
691function VisibilityAction({ full, isPrivate, fork, error }: { full: string; isPrivate: boolean; fork: boolean; error: string | null }) {
692 const next = isPrivate ? "public" : "private";
693 if (fork) {
694 return (
695 <DangerAction
696 title="Change visibility"
697 action={
698 <Button type="button" variant="destructive" disabled>
699 Make {next}
700 </Button>
701 }
702 >
703 It is {isPrivate ? "private" : "public"}. A fork is as visible as the repository it came from. Detach it to change this.
704 </DangerAction>
705 );
706 }
707 return (
708 <ConfirmDialog
709 intent="visibility"
710 fields={{ visibility: next }}
711 title={`Make ${full} ${next}?`}
712 confirm={full}
713 submit={`Make ${next}`}
714 busy="Changing…"
715 error={error}
716 trigger={(open) => (
717 <DangerAction
718 title="Change visibility"
719 action={
720 <Button type="button" variant="destructive" onClick={open}>
721 Make {next}
722 </Button>
723 }
724 >
725 It is {isPrivate ? "private: only people with access can see it" : "public: anyone can see and clone it"}.
726 </DangerAction>
727 )}
728 >
729 {isPrivate ? (
730 <>
731 <li>Anyone can see its code, work items and pull requests, and clone it, without signing in.</li>
732 <li>It shows in search and on Explore.</li>
733 <li>Links to it show a preview card with its name and its project's description where they are shared.</li>
734 </>
735 ) : (
736 <>
737 <li>
738 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
739 does not exist.
740 </li>
741 <li>It leaves search and Explore, and links to it stop showing a preview card.</li>
742 <li>
743 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.
744 </li>
745 </>
746 )}
747 </ConfirmDialog>
748 );
749}
750
751/** A fork made an ordinary repository, with no parent. */
752function DetachAction({ full, parent, error }: { full: string; parent: { namespace: string; name: string } | null; error: string | null }) {
753 const from = parent ? `${parent.namespace}/${parent.name}` : "the repository it came from";
754 return (
755 <ConfirmDialog
756 intent="detach"
757 title={`Detach ${full} from ${from}?`}
758 description="It becomes an ordinary repository. This can't be undone."
759 confirm={full}
760 submit="Detach fork"
761 busy="Detaching…"
762 error={error}
763 trigger={(open) => (
764 <DangerAction
765 title="Detach fork"
766 action={
767 <Button type="button" variant="destructive" onClick={open}>
768 Detach
769 </Button>
770 }
771 >
772 It is a fork of <span className="font-mono text-fg/85">{from}</span>. Detaching makes it a repository of its own.
773 </DangerAction>
774 )}
775 >
776 <li>Sync fork stops: it no longer brings in what lands on {from}.</li>
777 <li>Opening pull requests from it to {from} is no longer offered.</li>
778 <li>It stops showing among {from}'s forks, and its page no longer says where it came from.</li>
779 <li>Its visibility can then be changed on its own.</li>
780 <li>It can't be attached again.</li>
781 </ConfirmDialog>
782 );
783}