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