| 1 | import { ChevronRight, GitBranch } from "lucide-react"; |
| 2 | import { useState } from "react"; |
| 3 | import { Form, Link, redirect, useNavigation } from "react-router"; |
| 4 | |
| 5 | import { type Result, needs } from "@g1t/contracts"; |
| 6 | |
| 7 | import { DangerAction, DangerZone } from "../../components/danger-zone"; |
| 8 | import { ConfirmDialog } from "../../components/repo-lifecycle"; |
| 9 | import { RepoSettingsHeading } from "../../components/repo-settings-heading"; |
| 10 | import { SettingsSection as Section, SettingToggle } from "../../components/settings-section"; |
| 11 | import { SwitchCard } from "../../components/ui/switch"; |
| 12 | import type { Route } from "./+types/settings-repository"; |
| 13 | import { page } from "../../lib/meta"; |
| 14 | import { confirmsName, tidyName } from "../../lib/repo-lifecycle"; |
| 15 | import { ErrorText, Field, Input, SubmitButton } from "../../components/ui"; |
| 16 | import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "../../components/ui/select"; |
| 17 | import { Button } from "../../components/ui/button"; |
| 18 | import { Badge } from "../../components/ui/badge"; |
| 19 | import { Card } from "../../components/ui/card"; |
| 20 | import { FieldLabel, Field as FormField } from "../../components/ui/field"; |
| 21 | import { Input as TextInput } from "../../components/ui/input"; |
| 22 | import { actions, repos, work } from "../../lib/services.server"; |
| 23 | import { assertSameOrigin, getViewer, requireUser } from "../../lib/session.server"; |
| 24 | import { refusal, requireInsider } from "../../lib/access.server"; |
| 25 | import { Unavailable } from "../../components/boundary"; |
| 26 | import { isFailed, part } from "../../lib/partial"; |
| 27 | |
| 28 | export function meta({ params, ...args }: Route.MetaArgs) { |
| 29 | return page(args, { title: `Repository settings · ${params.owner}/${params.repo} · g1t` }); |
| 30 | } |
| 31 | |
| 32 | export 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. */ |
| 72 | type Outcome = { intent: string; saved: boolean; error: string | null }; |
| 73 | |
| 74 | function 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 | |
| 78 | export 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`. */ |
| 168 | function 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 | |
| 173 | export 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. */ |
| 430 | function 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. */ |
| 443 | function 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 | |
| 486 | type RepoLike = { namespace: string; name: string; defaultBranch: string; isPrivate: boolean }; |
| 487 | |
| 488 | /** The repository's name, which owners can change; its old address keeps redirecting. */ |
| 489 | function 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. */ |
| 562 | function 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. */ |
| 612 | function 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. */ |
| 691 | function 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. */ |
| 752 | function 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 | } |