Pick any line to see why it is the way it is: the commit, the pull request and issue it came from, and what the agent was thinking.
| Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look | 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"; | |
| Projects: what a workspace builds and runs, first on every page | 6 | |
| Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look | 7 | import { DangerAction, DangerZone } from "../../components/danger-zone"; |
| 8 | import { ConfirmDialog } from "../../components/repo-lifecycle"; | |
| Project settings move into the sidebar, grouped, with a heading per page | 9 | import { RepoSettingsHeading } from "../../components/repo-settings-heading"; |
| Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look | 10 | import { SettingsSection as Section } from "../../components/settings-section"; |
| Projects: what a workspace builds and runs, first on every page | 11 | import type { Route } from "./+types/settings-repository"; |
| Social cards for every page: og.g1t.sh | 12 | import { page } from "../../lib/meta"; |
| Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look | 13 | import { confirmsName, tidyName } from "../../lib/repo-lifecycle"; |
| Merge branch 'worktree-agent-a985c01f43538af01' | 14 | import { Button, ErrorText, Field, Input, SubmitButton } from "../../components/ui"; |
| Workspace names and icons, and a component kit for every control | 15 | import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "../../components/ui/select"; |
| Projects: what a workspace builds and runs, first on every page | 16 | import { |
| Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look | 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 { | |
| Projects: what a workspace builds and runs, first on every page | 29 | assertSameOrigin, |
| 30 | getViewer, | |
| 31 | requireUser, | |
| 32 | } from "../../lib/session.server"; | |
| Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look | 33 | import { refusal, requireInsider } from "../../lib/access.server"; |
| Projects: what a workspace builds and runs, first on every page | 34 | |
| Social cards for every page: og.g1t.sh | 35 | export function meta({ params, ...args }: Route.MetaArgs) { |
| 36 | return page(args, { title: `Repository settings · ${params.owner}/${params.repo} · g1t` }); | |
| Projects: what a workspace builds and runs, first on every page | 37 | } |
| 38 | ||
| 39 | export async function loader({ params, context }: Route.LoaderArgs) { | |
| 40 | const viewer = getViewer(context); | |
| Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look | 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"); | |
| Projects: what a workspace builds and runs, first on every page | 43 | const path = { namespace: params.owner, name: params.repo }; |
| Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look | 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 | }; | |
| Projects: what a workspace builds and runs, first on every page | 61 | } |
| 62 | ||
| Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look | 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 }; | |
| Projects: what a workspace builds and runs, first on every page | 68 | } |
| 69 | ||
| Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look | 70 | export async function action({ request, params, context }: Route.ActionArgs): Promise<Outcome> { |
| Projects: what a workspace builds and runs, first on every page | 71 | assertSameOrigin(request); |
| 72 | const user = requireUser(context, request); | |
| 73 | const form = await request.formData(); | |
| 74 | const path = { namespace: params.owner, name: params.repo }; | |
| Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look | 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 | } | |
| Projects: what a workspace builds and runs, first on every page | 136 | |
| Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look | 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>; | |
| Projects: what a workspace builds and runs, first on every page | 141 | } |
| 142 | ||
| Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look | 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."> | |
| Merge branch 'worktree-agent-a985c01f43538af01' | 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(",")}`}> | |
| Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look | 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"> | |
| Merge branch 'worktree-agent-a985c01f43538af01' | 177 | <SubmitButton match={{ intent: "details" }} pending="Saving…"> |
| 178 | Save | |
| 179 | </SubmitButton> | |
| Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look | 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} /> | |
| Merge branch 'worktree-agent-a985c01f43538af01' | 191 | {/* Keyed to the branches, so after a rename it starts over on the branches there are now. */} |
| Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look | 192 | <RenameBranchForm |
| Merge branch 'worktree-agent-a985c01f43538af01' | 193 | key={branches.join(" ")} |
| Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look | 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, | |
| Projects: what a workspace builds and runs, first on every page | 246 | }: { |
| Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look | 247 | repo: RepoLike; |
| 248 | owner: boolean; | |
| 249 | archived: boolean; | |
| 250 | busy: boolean; | |
| 251 | result: Outcome | undefined; | |
| Projects: what a workspace builds and runs, first on every page | 252 | }) { |
| Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look | 253 | const [name, setName] = useState(repo.name); |
| 254 | const wanted = tidyName(name); | |
| 255 | const changed = wanted !== "" && wanted !== repo.name; | |
| Projects: what a workspace builds and runs, first on every page | 256 | return ( |
| Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look | 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> | |
| Projects: what a workspace builds and runs, first on every page | 298 | ); |
| 299 | } | |
| 300 | ||
| Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look | 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, | |
| Projects: what a workspace builds and runs, first on every page | 308 | }: { |
| Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look | 309 | repo: RepoLike; |
| 310 | branches: string[]; | |
| 311 | archived: boolean; | |
| 312 | busy: boolean; | |
| 313 | result: Outcome | undefined; | |
| Projects: what a workspace builds and runs, first on every page | 314 | }) { |
| Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look | 315 | const [branch, setBranch] = useState(repo.defaultBranch); |
| 316 | const options = branches.includes(repo.defaultBranch) ? branches : [repo.defaultBranch, ...branches]; | |
| Projects: what a workspace builds and runs, first on every page | 317 | return ( |
| Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look | 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> | |
| Projects: what a workspace builds and runs, first on every page | 350 | ); |
| 351 | } | |
| 352 | ||
| Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look | 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, | |
| Projects: what a workspace builds and runs, first on every page | 361 | }: { |
| Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look | 362 | repo: RepoLike; |
| 363 | branches: string[]; | |
| 364 | owner: boolean; | |
| 365 | archived: boolean; | |
| 366 | busy: boolean; | |
| 367 | result: Outcome | undefined; | |
| Projects: what a workspace builds and runs, first on every page | 368 | }) { |
| Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look | 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; | |
| Projects: what a workspace builds and runs, first on every page | 374 | return ( |
| Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look | 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> | |
| Projects: what a workspace builds and runs, first on every page | 427 | ); |
| 428 | } | |
| 429 | ||
| Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look | 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"; | |
| Projects: what a workspace builds and runs, first on every page | 433 | return ( |
| Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look | 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 | } | |
| Projects: what a workspace builds and runs, first on every page | 471 | |
| Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look | 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 | } | |
| Projects: what a workspace builds and runs, first on every page | 492 | > |
| Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look | 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 | } | |
| Projects: what a workspace builds and runs, first on every page | 516 | |
| Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look | 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 | } | |
| Projects: what a workspace builds and runs, first on every page | 537 | > |
| Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look | 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 | } | |
| Projects: what a workspace builds and runs, first on every page | 551 | |
| Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look | 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> | |
| Projects: what a workspace builds and runs, first on every page | 656 | ); |
| 657 | } |
This file's history is long; its oldest lines are credited to the oldest commit read.