Project settings move into the sidebar, grouped, with a heading per page
12 files+122−630/12 viewed
| 1 | + | import { useLocation } from "react-router"; | |
| 2 | + | ||
| 3 | + | /** Each of a project's settings pages: its name and what it is for. */ | |
| 4 | + | const PAGES: Record<string, { title: string; about: string }> = { | |
| 5 | + | "": { title: "General", about: "The project's name, where its code lives, and what it is for." }, | |
| 6 | + | deployments: { title: "Deployments", about: "How the project builds, where it runs, and its previews." }, | |
| 7 | + | domains: { title: "Domains", about: "Addresses of your own for the production deployment." }, | |
| 8 | + | dependencies: { title: "Dependencies", about: "What the project relies on and what relies on it." }, | |
| 9 | + | agents: { title: "Agents", about: "How g1t's agents pick up work here, and what they read first." }, | |
| 10 | + | guardrails: { title: "Guardrails", about: "What agents may reach, run and spend while they work here." }, | |
| 11 | + | repository: { title: "Repository", about: "Branches, protection, merging and who can see the code." }, | |
| 12 | + | secrets: { title: "Secrets and variables", about: "Values workflows, builds and deployments read at run time." }, | |
| 13 | + | webhooks: { title: "Webhooks", about: "Addresses g1t calls when something happens in the project." }, | |
| 14 | + | }; | |
| 15 | + | ||
| 16 | + | /** | |
| 17 | + | * The heading of a project's settings page, named from the address. The | |
| 18 | + | * pages themselves are listed in the sidebar while you are in settings. | |
| 19 | + | */ | |
| 20 | + | export function RepoSettingsHeading({ base }: { base: string }) { | |
| 21 | + | const { pathname } = useLocation(); | |
| 22 | + | const section = pathname.slice(`${base}/settings`.length).replace(/^\//, "").split("/")[0] ?? ""; | |
| 23 | + | const page = PAGES[section] ?? PAGES[""]!; | |
| 24 | + | return ( | |
| 25 | + | <header className="mb-8 border-b border-line pb-5"> | |
| 26 | + | <h1 className="text-lg font-semibold tracking-tight">{page.title}</h1> | |
| 27 | + | <p className="mt-1 text-sm text-muted">{page.about}</p> | |
| 28 | + | </header> | |
| 29 | + | ); | |
| 30 | + | } |
| 1 | − | import { Bot, GitBranch, Globe, Lock, Network, Rocket, Settings, ShieldCheck, Webhook } from "lucide-react"; | |
| 2 | − | ||
| 3 | − | import { TabLink } from "./ui"; | |
| 4 | − | ||
| 5 | − | /** | |
| 6 | − | * A project's settings, and its repository's: what is about running it | |
| 7 | − | * first, then what is about the code. | |
| 8 | − | */ | |
| 9 | − | export function RepoSettingsTabs({ base }: { base: string }) { | |
| 10 | − | return ( | |
| 11 | − | <nav className="mb-8 flex gap-x-6 border-b border-line"> | |
| 12 | − | <TabLink to={`${base}/settings`} end icon={<Settings size={15} />}> | |
| 13 | − | General | |
| 14 | − | </TabLink> | |
| 15 | − | <TabLink to={`${base}/settings/deployments`} icon={<Rocket size={15} />}> | |
| 16 | − | Deployments | |
| 17 | − | </TabLink> | |
| 18 | − | <TabLink to={`${base}/settings/domains`} icon={<Globe size={15} />}> | |
| 19 | − | Domains | |
| 20 | − | </TabLink> | |
| 21 | − | <TabLink to={`${base}/settings/dependencies`} icon={<Network size={15} />}> | |
| 22 | − | Dependencies | |
| 23 | − | </TabLink> | |
| 24 | − | <TabLink to={`${base}/settings/agents`} icon={<Bot size={15} />}> | |
| 25 | − | Agents | |
| 26 | − | </TabLink> | |
| 27 | − | <TabLink to={`${base}/settings/guardrails`} icon={<ShieldCheck size={15} />}> | |
| 28 | − | Guardrails | |
| 29 | − | </TabLink> | |
| 30 | − | <TabLink to={`${base}/settings/secrets`} icon={<Lock size={15} />}> | |
| 31 | − | Secrets and variables | |
| 32 | − | </TabLink> | |
| 33 | − | <TabLink to={`${base}/settings/repository`} icon={<GitBranch size={15} />}> | |
| 34 | − | Repository | |
| 35 | − | </TabLink> | |
| 36 | − | <TabLink to={`${base}/settings/webhooks`} icon={<Webhook size={15} />}> | |
| 37 | − | Webhooks | |
| 38 | − | </TabLink> | |
| 39 | − | </nav> | |
| 40 | − | ); | |
| 41 | − | } |
| 32 | 32 | Settings, | |
| 33 | 33 | Users, | |
| 34 | 34 | Webhook, | |
| 35 | + | Globe, | |
| 36 | + | GitBranch, | |
| 35 | 37 | PlayCircle, | |
| 36 | 38 | Bot, | |
| 37 | 39 | Brain, | |
| 364 | 366 | } | |
| 365 | 367 | ||
| 366 | 368 | /** A workspace's settings pages, which the sidebar slides over to. */ | |
| 367 | − | const SETTINGS_PAGE = /^\/([^/]+)\/-\/(settings|people|tokens|billing|integrations|webhooks|secrets|audit)(\/|$)/; | |
| 369 | + | const SETTINGS_PAGE = /^\/([^/]+)\/-\/(settings|people|tokens|billing|integrations|webhooks|secrets|guardrails|audit)(\/|$)/; | |
| 370 | + | /** A project's settings pages, which the project's menu gives way to. */ | |
| 371 | + | const REPO_SETTINGS_PAGE = /^\/([^/]+)\/([^/-][^/]*)\/settings(\/|$)/; | |
| 368 | 372 | ||
| 369 | 373 | /** | |
| 370 | 374 | * The sidebar's menus are two layers, the way a phone pushes a screen: the | |
| 526 | 530 | ); | |
| 527 | 531 | } | |
| 528 | 532 | ||
| 533 | + | /** | |
| 534 | + | * A project's settings, as the sidebar shows them in place of the project's | |
| 535 | + | * menu: what is about running it, then its agents, then its code and who | |
| 536 | + | * can reach it. The way back leads to the project. | |
| 537 | + | */ | |
| 538 | + | function RepoSettingsMenu({ repo, open }: { repo: MenuRepo; open: boolean }) { | |
| 539 | + | const base = `/${repo.namespace}/${repo.name}`; | |
| 540 | + | return ( | |
| 541 | + | <nav aria-label={`${repo.namespace}/${repo.name} settings`} inert={!open} className={PANEL}> | |
| 542 | + | <Link | |
| 543 | + | to={base} | |
| 544 | + | className="group mt-3 flex h-8 items-center gap-2 rounded-md px-2 text-[0.8125rem] text-muted transition-colors hover:bg-raised/60 hover:text-fg" | |
| 545 | + | > | |
| 546 | + | <ArrowLeft size={15} className="text-faint transition-transform group-hover:-translate-x-0.5 group-hover:text-muted" /> | |
| 547 | + | <span className="min-w-0 truncate font-mono"> | |
| 548 | + | <span className="text-faint">{repo.namespace}/</span> | |
| 549 | + | {repo.name} | |
| 550 | + | </span> | |
| 551 | + | </Link> | |
| 552 | + | <SidebarGroup title="Settings"> | |
| 553 | + | <SidebarLink to={`${base}/settings`} end icon={<Settings size={15} />}> | |
| 554 | + | General | |
| 555 | + | </SidebarLink> | |
| 556 | + | </SidebarGroup> | |
| 557 | + | <SidebarGroup title="Running"> | |
| 558 | + | <SidebarLink to={`${base}/settings/deployments`} icon={<Rocket size={15} />}> | |
| 559 | + | Deployments | |
| 560 | + | </SidebarLink> | |
| 561 | + | <SidebarLink to={`${base}/settings/domains`} icon={<Globe size={15} />}> | |
| 562 | + | Domains | |
| 563 | + | </SidebarLink> | |
| 564 | + | <SidebarLink to={`${base}/settings/dependencies`} icon={<Network size={15} />}> | |
| 565 | + | Dependencies | |
| 566 | + | </SidebarLink> | |
| 567 | + | </SidebarGroup> | |
| 568 | + | <SidebarGroup title="Agents"> | |
| 569 | + | <SidebarLink to={`${base}/settings/agents`} icon={<Bot size={15} />}> | |
| 570 | + | Agents | |
| 571 | + | </SidebarLink> | |
| 572 | + | <SidebarLink to={`${base}/settings/guardrails`} icon={<ShieldCheck size={15} />}> | |
| 573 | + | Guardrails | |
| 574 | + | </SidebarLink> | |
| 575 | + | </SidebarGroup> | |
| 576 | + | <SidebarGroup title="Code and access"> | |
| 577 | + | <SidebarLink to={`${base}/settings/repository`} icon={<GitBranch size={15} />}> | |
| 578 | + | Repository | |
| 579 | + | </SidebarLink> | |
| 580 | + | <SidebarLink to={`${base}/settings/secrets`} icon={<Lock size={15} />}> | |
| 581 | + | Secrets and variables | |
| 582 | + | </SidebarLink> | |
| 583 | + | <SidebarLink to={`${base}/settings/webhooks`} icon={<Webhook size={15} />}> | |
| 584 | + | Webhooks | |
| 585 | + | </SidebarLink> | |
| 586 | + | </SidebarGroup> | |
| 587 | + | </nav> | |
| 588 | + | ); | |
| 589 | + | } | |
| 590 | + | ||
| 529 | 591 | /** Your own settings, as the sidebar shows them on the settings page. */ | |
| 530 | 592 | function AccountSettingsMenu({ open }: { open: boolean }) { | |
| 531 | 593 | const { hash } = useLocation(); | |
| 576 | 638 | const repoPath = /^\/([^/]+)\/([^/-][^/]*)(\/|$)/.exec(target); | |
| 577 | 639 | const reserved = new Set(["settings", "explore", "search", "new", "u", "pricing", "avatars", "workspaces", "login", "logout", "register", "verify", "forgot", "reset", "device", "oauth"]); | |
| 578 | 640 | const inRepo = repoPath != null && !reserved.has(repoPath[1]) && repoPath[2] !== "-"; | |
| 641 | + | // A project's settings take its menu over in turn. | |
| 642 | + | const inRepoSettings = inRepo && REPO_SETTINGS_PAGE.test(target); | |
| 579 | 643 | const away = inSettings || inAccount || inRepo; | |
| 580 | 644 | // The repository the menu is for: the one loaded if it is the one being | |
| 581 | 645 | // gone to, else what the address says, at once. | |
| 590 | 654 | : null; | |
| 591 | 655 | // What sits on the far side of the track. Kept while sliding back, so it | |
| 592 | 656 | // does not vanish on the way out. | |
| 593 | − | const side = useRef<"workspace" | "account" | "repo">("workspace"); | |
| 657 | + | const side = useRef<"workspace" | "account" | "repo" | "repo-settings">("workspace"); | |
| 594 | 658 | if (inAccount) side.current = "account"; | |
| 595 | 659 | else if (inSettings) side.current = "workspace"; | |
| 660 | + | else if (inRepoSettings) side.current = "repo-settings"; | |
| 596 | 661 | else if (inRepo) side.current = "repo"; | |
| 597 | 662 | // The repository last shown, kept for the slide back. | |
| 598 | 663 | const shown = useRef<MenuRepo | null>(menuRepo); | |
| 599 | 664 | if (menuRepo) shown.current = menuRepo; | |
| 600 | 665 | // Moving between two of the far-side menus (settings to a repository, | |
| 601 | 666 | // one repository to another) crossfades in place instead of sliding. | |
| 602 | − | const detailKey = side.current === "repo" ? `repo:${shown.current?.namespace}/${shown.current?.name}` : side.current; | |
| 667 | + | const detailKey = | |
| 668 | + | side.current === "repo" || side.current === "repo-settings" | |
| 669 | + | ? `${side.current}:${shown.current?.namespace}/${shown.current?.name}` | |
| 670 | + | : side.current; | |
| 603 | 671 | const lastAway = useRef(away); | |
| 604 | 672 | const lastKey = useRef(detailKey); | |
| 605 | 673 | const swapped = lastAway.current && away && lastKey.current !== detailKey; | |
| 713 | 781 | </div> | |
| 714 | 782 | <div className={`${LAYER} ${away ? "translate-x-0 opacity-100" : "pointer-events-none translate-x-full opacity-0"}`}> | |
| 715 | 783 | <div key={detailKey} className={`h-full ${swapped ? "animate-[g1t-swap_220ms_ease-out]" : ""}`}> | |
| 716 | − | {side.current === "repo" && shown.current ? ( | |
| 784 | + | {side.current === "repo-settings" && shown.current ? ( | |
| 785 | + | <RepoSettingsMenu repo={shown.current} open={inRepoSettings} /> | |
| 786 | + | ) : side.current === "repo" && shown.current ? ( | |
| 717 | 787 | <RepoMenu | |
| 718 | 788 | repo={shown.current} | |
| 719 | 789 | isPrivate={shell.repos.some((repo) => sameRepo(repo, shown.current) && repo.isPrivate)} |
| 1 | 1 | import type { Route } from "./+types/secrets"; | |
| 2 | 2 | import { page } from "../../lib/meta"; | |
| 3 | − | import { RepoSettingsTabs } from "../../components/repo-settings-tabs"; | |
| 3 | + | import { RepoSettingsHeading } from "../../components/repo-settings-heading"; | |
| 4 | 4 | import { SecretsPanel } from "../../components/secrets"; | |
| 5 | 5 | import { actOnSecrets, loadSecrets } from "../../lib/secrets.server"; | |
| 6 | 6 | import { assertSameOrigin, getViewer, requireUser, roleIn } from "../../lib/session.server"; | |
| 27 | 27 | export default function RepoSecrets({ loaderData, actionData, params }: Route.ComponentProps) { | |
| 28 | 28 | return ( | |
| 29 | 29 | <div> | |
| 30 | − | <RepoSettingsTabs base={`/${params.owner}/${params.repo}`} /> | |
| 30 | + | <RepoSettingsHeading base={`/${params.owner}/${params.repo}`} /> | |
| 31 | 31 | <SecretsPanel data={loaderData} action={actionData} scope="project" manage /> | |
| 32 | 32 | </div> | |
| 33 | 33 | ); |
| 6 | 6 | ||
| 7 | 7 | import type { Route } from "./+types/settings-agents"; | |
| 8 | 8 | import { page } from "../../lib/meta"; | |
| 9 | − | import { RepoSettingsTabs } from "../../components/repo-settings-tabs"; | |
| 9 | + | import { RepoSettingsHeading } from "../../components/repo-settings-heading"; | |
| 10 | 10 | import { Button, ErrorText, Field, Input, TimeAgo } from "../../components/ui"; | |
| 11 | 11 | import { work } from "../../lib/services.server"; | |
| 12 | 12 | import { assertSameOrigin, getViewer, requireUser, roleIn, unwrap } from "../../lib/session.server"; | |
| 48 | 48 | const base = `/${params.owner}/${params.repo}`; | |
| 49 | 49 | return ( | |
| 50 | 50 | <div className="max-w-4xl"> | |
| 51 | − | <RepoSettingsTabs base={base} /> | |
| 51 | + | <RepoSettingsHeading base={base} /> | |
| 52 | 52 | <div className="min-h-6"> | |
| 53 | 53 | {actionData?.notice && <p className="text-sm text-accent">{actionData.notice}</p>} | |
| 54 | 54 | <ErrorText>{actionData?.error ?? null}</ErrorText> |
| 5 | 5 | ||
| 6 | 6 | import type { Route } from "./+types/settings-dependencies"; | |
| 7 | 7 | import { page } from "../../lib/meta"; | |
| 8 | − | import { RepoSettingsTabs } from "../../components/repo-settings-tabs"; | |
| 8 | + | import { RepoSettingsHeading } from "../../components/repo-settings-heading"; | |
| 9 | 9 | import { Avatar, Button, EmptyState, ErrorText, Field, Input } from "../../components/ui"; | |
| 10 | 10 | import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "../../components/ui/select"; | |
| 11 | 11 | import { projects } from "../../lib/services.server"; | |
| 81 | 81 | const choices = all.filter((project) => !taken.has(project.slug)); | |
| 82 | 82 | return ( | |
| 83 | 83 | <div className="max-w-4xl"> | |
| 84 | − | <RepoSettingsTabs base={base} /> | |
| 84 | + | <RepoSettingsHeading base={base} /> | |
| 85 | 85 | <header> | |
| 86 | 86 | <h2 className="flex items-center gap-2 text-sm font-medium"> | |
| 87 | 87 | <Network size={15} className="text-accent" /> |
| 2 | 2 | ||
| 3 | 3 | import type { Route } from "./+types/settings-deployments"; | |
| 4 | 4 | import { page } from "../../lib/meta"; | |
| 5 | − | import { RepoSettingsTabs } from "../../components/repo-settings-tabs"; | |
| 5 | + | import { RepoSettingsHeading } from "../../components/repo-settings-heading"; | |
| 6 | 6 | import { Button, ErrorText, Field, Input } from "../../components/ui"; | |
| 7 | 7 | import { SwitchCard } from "../../components/ui/switch"; | |
| 8 | 8 | import { deployments } from "../../lib/services.server"; | |
| 56 | 56 | const base = `/${params.owner}/${params.repo}`; | |
| 57 | 57 | return ( | |
| 58 | 58 | <div className="max-w-4xl"> | |
| 59 | − | <RepoSettingsTabs base={base} /> | |
| 59 | + | <RepoSettingsHeading base={base} /> | |
| 60 | 60 | <div className="min-h-6"> | |
| 61 | 61 | {actionData && "notice" in actionData && <p className="text-sm text-accent">{actionData.notice}</p>} | |
| 62 | 62 | <ErrorText>{actionData && "error" in actionData ? actionData.error : null}</ErrorText> |
| 6 | 6 | ||
| 7 | 7 | import type { Route } from "./+types/settings-domains"; | |
| 8 | 8 | import { page } from "../../lib/meta"; | |
| 9 | − | import { RepoSettingsTabs } from "../../components/repo-settings-tabs"; | |
| 9 | + | import { RepoSettingsHeading } from "../../components/repo-settings-heading"; | |
| 10 | 10 | import { Button, CopyLine, EmptyState, ErrorText, Field, Input } from "../../components/ui"; | |
| 11 | 11 | import { | |
| 12 | 12 | AlertDialog, | |
| 113 | 113 | ||
| 114 | 114 | return ( | |
| 115 | 115 | <div className="max-w-4xl"> | |
| 116 | − | <RepoSettingsTabs base={base} /> | |
| 116 | + | <RepoSettingsHeading base={base} /> | |
| 117 | 117 | <div className="min-h-6"> | |
| 118 | 118 | {actionData && "notice" in actionData && <p className="text-sm text-accent">{actionData.notice}</p>} | |
| 119 | 119 | <ErrorText>{actionData && "error" in actionData ? actionData.error : null}</ErrorText> |
| 4 | 4 | ||
| 5 | 5 | import type { Route } from "./+types/settings-guardrails"; | |
| 6 | 6 | import { GuardrailsForm } from "../../components/guardrails"; | |
| 7 | − | import { RepoSettingsTabs } from "../../components/repo-settings-tabs"; | |
| 7 | + | import { RepoSettingsHeading } from "../../components/repo-settings-heading"; | |
| 8 | 8 | import { settingsFromForm } from "../../lib/guardrails"; | |
| 9 | 9 | import { page } from "../../lib/meta"; | |
| 10 | 10 | import { guardrails } from "../../lib/services.server"; | |
| 42 | 42 | const base = `/${params.owner}/${params.repo}`; | |
| 43 | 43 | return ( | |
| 44 | 44 | <> | |
| 45 | − | <RepoSettingsTabs base={base} /> | |
| 45 | + | <RepoSettingsHeading base={base} /> | |
| 46 | 46 | <p className="mb-8 max-w-3xl text-sm text-muted"> | |
| 47 | 47 | What g1t's agents, checks and merge queue may do in this project's sandboxes. Anything left as the | |
| 48 | 48 | workspace's follows the{" "} |
| 2 | 2 | import type { ReactNode } from "react"; | |
| 3 | 3 | import { Form, data, useNavigation } from "react-router"; | |
| 4 | 4 | ||
| 5 | − | import { RepoSettingsTabs } from "../../components/repo-settings-tabs"; | |
| 5 | + | import { RepoSettingsHeading } from "../../components/repo-settings-heading"; | |
| 6 | 6 | import type { Route } from "./+types/settings-repository"; | |
| 7 | 7 | import { page } from "../../lib/meta"; | |
| 8 | 8 | import { Button, ErrorText, Field, Input, TimeAgo } from "../../components/ui"; | |
| 155 | 155 | const branch = repo.defaultBranch; | |
| 156 | 156 | return ( | |
| 157 | 157 | <> | |
| 158 | − | <RepoSettingsTabs base={`/${repo.namespace}/${repo.name}`} /> | |
| 158 | + | <RepoSettingsHeading base={`/${repo.namespace}/${repo.name}`} /> | |
| 159 | 159 | <Form method="post" className="max-w-4xl space-y-8"> | |
| 160 | 160 | <Section title="General" about="What the repository is and who can see it."> | |
| 161 | 161 | <Field label="Description"> |
| 3 | 3 | ||
| 4 | 4 | import type { Route } from "./+types/settings"; | |
| 5 | 5 | import { page } from "../../lib/meta"; | |
| 6 | − | import { RepoSettingsTabs } from "../../components/repo-settings-tabs"; | |
| 6 | + | import { RepoSettingsHeading } from "../../components/repo-settings-heading"; | |
| 7 | 7 | import { Button, ErrorText, Field, Input, TimeAgo } from "../../components/ui"; | |
| 8 | 8 | import { projects } from "../../lib/services.server"; | |
| 9 | 9 | import { assertSameOrigin, getViewer, requireUser, roleIn, unwrap } from "../../lib/session.server"; | |
| 38 | 38 | const source = project.source.kind === "hosted" ? project.source : null; | |
| 39 | 39 | return ( | |
| 40 | 40 | <div className="max-w-4xl"> | |
| 41 | − | <RepoSettingsTabs base={base} /> | |
| 41 | + | <RepoSettingsHeading base={base} /> | |
| 42 | 42 | <Form method="post" className="space-y-8"> | |
| 43 | 43 | <section className="space-y-4"> | |
| 44 | 44 | <h2 className="flex items-center gap-2 text-sm font-medium"> |
| 1 | 1 | import type { Route } from "./+types/webhooks"; | |
| 2 | 2 | import { page } from "../../lib/meta"; | |
| 3 | − | import { RepoSettingsTabs } from "../../components/repo-settings-tabs"; | |
| 3 | + | import { RepoSettingsHeading } from "../../components/repo-settings-heading"; | |
| 4 | 4 | import { WebhooksPanel } from "../../components/webhooks"; | |
| 5 | 5 | import { actOnWebhooks, loadWebhooks } from "../../lib/webhooks.server"; | |
| 6 | 6 | import { assertSameOrigin, getViewer, requireUser, roleIn } from "../../lib/session.server"; | |
| 29 | 29 | export default function RepoWebhooks({ loaderData, actionData, params }: Route.ComponentProps) { | |
| 30 | 30 | return ( | |
| 31 | 31 | <div> | |
| 32 | − | <RepoSettingsTabs base={`/${params.owner}/${params.repo}`} /> | |
| 32 | + | <RepoSettingsHeading base={`/${params.owner}/${params.repo}`} /> | |
| 33 | 33 | <WebhooksPanel data={loaderData} action={actionData} manage scope={`${params.owner}/${params.repo}`} /> | |
| 34 | 34 | </div> | |
| 35 | 35 | ); |