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