| 4 | 4 | | BookOpen, |
| 5 | 5 | | Check, |
| 6 | 6 | | ChevronsUpDown, |
| 7 | + | Ellipsis, |
| 7 | 8 | | CircleDot, |
| 8 | 9 | | Code2, |
| 9 | 10 | | Compass, |
| ⋯ |
| 30 | 31 | | import type { User } from "@g1t/contracts"; |
| 31 | 32 | | import { MICROS_PER_DOLLAR } from "@g1t/contracts"; |
| 32 | 33 | | |
| 33 | | − | import { Logo } from "./logo"; |
| 34 | + | import { Mark } from "./logo"; |
| 34 | 35 | | import { Avatar } from "./ui"; |
| 35 | 36 | | import { |
| 36 | 37 | | DropdownMenu, |
| ⋯ |
| 85 | 86 | | prefetch="intent" |
| 86 | 87 | | className={({ isActive, isPending }) => { |
| 87 | 88 | | const current = isActive || (also != null && pathname.startsWith(also + "/")); |
| 88 | | − | return `group flex items-center gap-2.5 rounded-md px-2 py-1.5 text-sm transition-colors ${ |
| 89 | + | return `group flex h-8 items-center gap-2.5 rounded-md px-2 text-[0.8125rem] transition-colors ${ |
| 89 | 90 | | current |
| 90 | 91 | | ? "bg-raised font-medium text-fg" |
| 91 | 92 | | : isPending |
| ⋯ |
| 107 | 108 | | |
| 108 | 109 | | function SidebarGroup({ title, action, children }: { title: string; action?: ReactNode; children: ReactNode }) { |
| 109 | 110 | | return ( |
| 110 | | − | <section className="mt-5"> |
| 111 | | − | <div className="mb-1 flex items-center justify-between px-2"> |
| 112 | | − | <h2 className="text-[0.6875rem] font-medium tracking-wider text-faint uppercase"> |
| 113 | | − | {title} |
| 114 | | − | </h2> |
| 111 | + | <section className="mt-6"> |
| 112 | + | <div className="mb-1 flex h-6 items-center justify-between px-2"> |
| 113 | + | <h2 className="text-xs font-medium text-faint">{title}</h2> |
| 115 | 114 | | {action} |
| 116 | 115 | | </div> |
| 117 | 116 | | <div className="space-y-px">{children}</div> |
| ⋯ |
| 123 | 122 | | const current = shell.workspace?.slug; |
| 124 | 123 | | return ( |
| 125 | 124 | | <DropdownMenu> |
| 126 | | − | <DropdownMenuTrigger className="flex w-full items-center gap-2.5 rounded-lg px-2 py-2 text-left outline-none transition-colors hover:bg-raised data-[state=open]:bg-raised"> |
| 125 | + | {/* The name goes to the workspace; only the arrows switch it. */} |
| 126 | + | <Link |
| 127 | + | to={current ? `/${current}` : "/workspaces/new"} |
| 128 | + | className="flex h-8 min-w-0 items-center gap-2 rounded-md px-1.5 transition-colors hover:bg-raised" |
| 129 | + | > |
| 127 | 130 | | {current ? ( |
| 128 | | − | <Avatar name={current} size={26} square /> |
| 131 | + | <Avatar name={current} size={20} square /> |
| 129 | 132 | | ) : ( |
| 130 | | − | <span className="size-6.5 rounded-md border border-dashed border-line-strong" /> |
| 133 | + | <span className="size-5 rounded-md border border-dashed border-line-strong" /> |
| 131 | 134 | | )} |
| 132 | | − | <span className="min-w-0 grow"> |
| 133 | | − | <span className="block truncate text-sm font-semibold">{current ?? "No workspace"}</span> |
| 134 | | − | <span className="block truncate text-xs text-faint"> |
| 135 | | − | {shell.workspace?.role ?? "Create one to start"} |
| 135 | + | <span className="min-w-0 truncate text-sm font-medium">{current ?? "Choose a workspace"}</span> |
| 136 | + | {shell.workspace && ( |
| 137 | + | <span className="shrink-0 rounded-full bg-raised px-1.5 py-px text-[0.625rem] font-medium text-muted ring-1 ring-line capitalize"> |
| 138 | + | {shell.workspace.role} |
| 136 | 139 | | </span> |
| 137 | | − | </span> |
| 138 | | − | <ChevronsUpDown size={14} className="shrink-0 text-faint" /> |
| 140 | + | )} |
| 141 | + | </Link> |
| 142 | + | <DropdownMenuTrigger |
| 143 | + | aria-label="Switch workspace" |
| 144 | + | className="ml-auto flex h-8 w-6 shrink-0 items-center justify-center rounded-md text-faint outline-none transition-colors hover:bg-raised hover:text-fg data-[state=open]:bg-raised data-[state=open]:text-fg" |
| 145 | + | > |
| 146 | + | <ChevronsUpDown size={14} /> |
| 139 | 147 | | </DropdownMenuTrigger> |
| 140 | 148 | | <DropdownMenuContent align="start" className="w-60"> |
| 141 | 149 | | <DropdownMenuLabel>Workspaces</DropdownMenuLabel> |
| ⋯ |
| 160 | 168 | | ); |
| 161 | 169 | | } |
| 162 | 170 | | |
| 163 | | − | function Sidebar({ user, shell }: { user: User; shell: ShellData }) { |
| 171 | + | /** This month's spend against what is left, as Vercel shows a plan's usage. */ |
| 172 | + | function UsageCard({ slug, shell }: { slug: string; shell: ShellData }) { |
| 173 | + | if (shell.monthSpentMicros == null) return null; |
| 174 | + | const spent = shell.monthSpentMicros; |
| 175 | + | const left = shell.creditMicros; |
| 176 | + | const share = left != null && spent + left > 0 ? Math.min(1, spent / (spent + Math.max(left, 0))) : 0; |
| 177 | + | return ( |
| 178 | + | <Link |
| 179 | + | to={`/${slug}/-/usage`} |
| 180 | + | className="block rounded-lg bg-surface p-3 ring-1 ring-line transition-colors hover:ring-line-strong" |
| 181 | + | > |
| 182 | + | <span className="flex items-baseline justify-between text-xs"> |
| 183 | + | <span className="font-medium text-fg">Usage</span> |
| 184 | + | <span className="text-faint">this month</span> |
| 185 | + | </span> |
| 186 | + | <span className="mt-2 flex items-baseline justify-between"> |
| 187 | + | <span className="font-mono text-sm tabular-nums">${(spent / MICROS_PER_DOLLAR).toFixed(2)}</span> |
| 188 | + | {left != null && ( |
| 189 | + | <span className={`text-xs ${left <= 0 ? "text-warn" : "text-faint"}`}> |
| 190 | + | ${(left / MICROS_PER_DOLLAR).toFixed(2)} left |
| 191 | + | </span> |
| 192 | + | )} |
| 193 | + | </span> |
| 194 | + | <span className="mt-2 block h-1 overflow-hidden rounded-full bg-raised"> |
| 195 | + | <span |
| 196 | + | className={`block h-full rounded-full ${left != null && left <= 0 ? "bg-warn" : "bg-accent"}`} |
| 197 | + | style={{ width: `${Math.max(share * 100, spent > 0 ? 3 : 0)}%` }} |
| 198 | + | /> |
| 199 | + | </span> |
| 200 | + | </Link> |
| 201 | + | ); |
| 202 | + | } |
| 203 | + | |
| 204 | + | function AccountMenu({ user }: { user: User }) { |
| 205 | + | const submit = useSubmit(); |
| 206 | + | return ( |
| 207 | + | <DropdownMenu> |
| 208 | + | <DropdownMenuTrigger className="flex h-10 w-full items-center gap-2.5 rounded-md px-2 text-left outline-none transition-colors hover:bg-raised data-[state=open]:bg-raised"> |
| 209 | + | <Avatar name={user.username} size={22} /> |
| 210 | + | <span className="min-w-0 grow truncate text-[0.8125rem] font-medium">{user.username}</span> |
| 211 | + | <Ellipsis size={15} className="shrink-0 text-faint" /> |
| 212 | + | </DropdownMenuTrigger> |
| 213 | + | <DropdownMenuContent align="start" side="top" className="w-56"> |
| 214 | + | <DropdownMenuLabel> |
| 215 | + | Signed in as <span className="font-mono font-medium text-fg">{user.username}</span> |
| 216 | + | </DropdownMenuLabel> |
| 217 | + | <DropdownMenuSeparator /> |
| 218 | + | <DropdownMenuItem asChild> |
| 219 | + | <Link to="/settings"> |
| 220 | + | <Settings /> |
| 221 | + | Your settings |
| 222 | + | </Link> |
| 223 | + | </DropdownMenuItem> |
| 224 | + | <DropdownMenuItem asChild> |
| 225 | + | <a href="https://docs.g1t.sh/"> |
| 226 | + | <BookOpen /> |
| 227 | + | Documentation |
| 228 | + | </a> |
| 229 | + | </DropdownMenuItem> |
| 230 | + | <DropdownMenuSeparator /> |
| 231 | + | {/* Submitted from here: the menu closes on select, and a button |
| 232 | + | that has left the page cannot submit a form. */} |
| 233 | + | <DropdownMenuItem onSelect={() => submit(null, { method: "post", action: "/logout" })}> |
| 234 | + | <LogOut /> |
| 235 | + | Sign out |
| 236 | + | </DropdownMenuItem> |
| 237 | + | </DropdownMenuContent> |
| 238 | + | </DropdownMenu> |
| 239 | + | ); |
| 240 | + | } |
| 241 | + | |
| 242 | + | function Sidebar({ user, shell, onFind }: { user: User; shell: ShellData; onFind: () => void }) { |
| 164 | 243 | | const ws = shell.workspace; |
| 165 | 244 | | const active = shell.repo; |
| 166 | 245 | | const repoBase = active ? `/${active.namespace}/${active.name}` : null; |
| ⋯ |
| 171 | 250 | | : shell.repos; |
| 172 | 251 | | return ( |
| 173 | 252 | | <div className="flex h-full flex-col"> |
| 174 | | − | <div className="flex h-12 items-center gap-2 px-4"> |
| 175 | | − | <Link to="/" aria-label="g1t home"> |
| 176 | | − | <Logo /> |
| 253 | + | {/* The same height and rule as the top bar, so the two read as one line. */} |
| 254 | + | <div className="flex h-12 shrink-0 items-center gap-1 border-b border-line px-2"> |
| 255 | + | <Link to="/" aria-label="g1t home" className="shrink-0 rounded-md p-1.5 hover:bg-raised"> |
| 256 | + | <Mark className="size-5" /> |
| 177 | 257 | | </Link> |
| 258 | + | <span className="shrink-0 text-line-strong" aria-hidden="true"> |
| 259 | + | / |
| 260 | + | </span> |
| 261 | + | <WorkspaceSwitcher user={user} shell={shell} /> |
| 178 | 262 | | </div> |
| 179 | | − | <div className="px-2"> |
| 180 | | − | <WorkspaceSwitcher user={user} shell={shell} /> |
| 263 | + | <div className="px-2 pt-3"> |
| 264 | + | <button |
| 265 | + | type="button" |
| 266 | + | onClick={onFind} |
| 267 | + | className="flex h-8 w-full items-center gap-2 rounded-md bg-surface px-2.5 text-[0.8125rem] text-faint ring-1 ring-line transition-colors hover:text-muted hover:ring-line-strong" |
| 268 | + | > |
| 269 | + | <Search size={14} /> |
| 270 | + | <span className="grow text-left">Find…</span> |
| 271 | + | <kbd className="rounded bg-raised px-1.5 font-mono text-[0.625rem] text-muted ring-1 ring-line">⌘K</kbd> |
| 272 | + | </button> |
| 181 | 273 | | </div> |
| 182 | 274 | | <nav className="min-h-0 grow overflow-y-auto px-2 pb-4"> |
| 183 | 275 | | <div className="mt-3 space-y-px"> |
| ⋯ |
| 297 | 389 | | </SidebarGroup> |
| 298 | 390 | | </nav> |
| 299 | 391 | | |
| 300 | | − | <div className="border-t border-line p-2"> |
| 301 | | − | {ws && shell.monthSpentMicros != null && ( |
| 392 | + | <div className="space-y-2 p-2"> |
| 393 | + | {ws && <UsageCard slug={ws.slug} shell={shell} />} |
| 394 | + | <AccountMenu user={user} /> |
| 395 | + | </div> |
| 396 | + | </div> |
| 397 | + | ); |
| 398 | + | } |
| 399 | + | |
| 400 | + | /** Words for the sections a path can end in. */ |
| 401 | + | const SECTIONS: Record<string, string> = { |
| 402 | + | issues: "Issues", |
| 403 | + | pulls: "Pull requests", |
| 404 | + | queue: "Merge queue", |
| 405 | + | commits: "Commits", |
| 406 | + | plans: "Plan", |
| 407 | + | settings: "Settings", |
| 408 | + | people: "People", |
| 409 | + | tokens: "Access tokens", |
| 410 | + | usage: "Usage", |
| 411 | + | billing: "Billing", |
| 412 | + | tree: "Code", |
| 413 | + | blob: "Code", |
| 414 | + | }; |
| 415 | + | |
| 416 | + | /** Where the page is, as a trail of links: workspace / repository / section. */ |
| 417 | + | function Breadcrumbs({ pathname }: { pathname: string }) { |
| 418 | + | const parts = pathname.split("/").filter(Boolean); |
| 419 | + | const reserved = ["settings", "explore", "new", "search", "workspaces"]; |
| 420 | + | if (parts.length === 0) return <span className="text-sm font-medium">Mission control</span>; |
| 421 | + | if (reserved.includes(parts[0]!)) { |
| 422 | + | const words: Record<string, string> = { |
| 423 | + | settings: "Your settings", |
| 424 | + | explore: "Explore", |
| 425 | + | new: "New repository", |
| 426 | + | search: "Search", |
| 427 | + | workspaces: "New workspace", |
| 428 | + | }; |
| 429 | + | return <span className="text-sm font-medium">{words[parts[0]!]}</span>; |
| 430 | + | } |
| 431 | + | const [owner, second, third, fourth] = parts; |
| 432 | + | const trail: { label: string; to: string; mono?: boolean }[] = [{ label: owner!, to: `/${owner}`, mono: true }]; |
| 433 | + | if (second === "-") { |
| 434 | + | if (third) trail.push({ label: SECTIONS[third] ?? third, to: `/${owner}/-/${third}` }); |
| 435 | + | } else if (second) { |
| 436 | + | const repo = `/${owner}/${second}`; |
| 437 | + | trail.push({ label: second, to: repo, mono: true }); |
| 438 | + | if (third === "pull" && fourth) trail.push({ label: `Pull request #${fourth}`, to: `${repo}/pull/${fourth}` }); |
| 439 | + | else if (third === "issues" && fourth && fourth !== "new") trail.push({ label: `Issue #${fourth}`, to: `${repo}/issues/${fourth}` }); |
| 440 | + | else if (third === "commit" && fourth) trail.push({ label: fourth.slice(0, 7), to: `${repo}/commit/${fourth}`, mono: true }); |
| 441 | + | else if (third && SECTIONS[third]) trail.push({ label: SECTIONS[third]!, to: `${repo}/${third}` }); |
| 442 | + | } |
| 443 | + | return ( |
| 444 | + | <nav aria-label="Where you are" className="flex min-w-0 items-center gap-1.5 text-sm"> |
| 445 | + | {trail.map((crumb, index) => ( |
| 446 | + | <span key={crumb.to} className="flex min-w-0 items-center gap-1.5"> |
| 447 | + | {index > 0 && <span className="text-line-strong">/</span>} |
| 302 | 448 | | <Link |
| 303 | | − | to={`/${ws.slug}/-/usage`} |
| 304 | | − | className="mb-1 block rounded-md px-2 py-1.5 text-sm text-muted transition-colors hover:bg-raised hover:text-fg" |
| 449 | + | to={crumb.to} |
| 450 | + | className={`truncate rounded px-1 py-0.5 transition-colors hover:bg-raised ${ |
| 451 | + | index === trail.length - 1 ? "font-medium text-fg" : "text-muted hover:text-fg" |
| 452 | + | } ${crumb.mono ? "font-mono text-[0.8125rem]" : ""}`} |
| 305 | 453 | | > |
| 306 | | − | <span className="flex items-center gap-2.5"> |
| 307 | | − | <BarChart3 size={15} className="shrink-0 text-faint" /> |
| 308 | | − | <span className="grow">Usage this month</span> |
| 309 | | − | <span className="font-mono text-xs text-fg tabular-nums"> |
| 310 | | − | ${(shell.monthSpentMicros / MICROS_PER_DOLLAR).toFixed(2)} |
| 311 | | − | </span> |
| 312 | | − | </span> |
| 313 | | − | {shell.creditMicros != null && ( |
| 314 | | − | <span |
| 315 | | − | className={`mt-0.5 block pl-6.5 text-xs ${shell.creditMicros <= 0 ? "text-warn" : "text-faint"}`} |
| 316 | | − | > |
| 317 | | − | ${(shell.creditMicros / MICROS_PER_DOLLAR).toFixed(2)} of credit left |
| 318 | | − | </span> |
| 319 | | − | )} |
| 454 | + | {crumb.label} |
| 320 | 455 | | </Link> |
| 321 | | − | )} |
| 322 | | − | <a |
| 323 | | − | href="https://docs.g1t.sh/" |
| 324 | | − | className="flex items-center gap-2.5 rounded-md px-2 py-1.5 text-sm text-muted transition-colors hover:bg-raised hover:text-fg" |
| 325 | | − | > |
| 326 | | − | <BookOpen size={15} className="shrink-0 text-faint" /> |
| 327 | | − | Documentation |
| 328 | | − | </a> |
| 329 | | − | </div> |
| 330 | | − | </div> |
| 456 | + | </span> |
| 457 | + | ))} |
| 458 | + | </nav> |
| 331 | 459 | | ); |
| 332 | 460 | | } |
| 333 | 461 | | |
| ⋯ |
| 557 | 685 | | <div className="min-h-screen"> |
| 558 | 686 | | <Progress /> |
| 559 | 687 | | <aside className="fixed inset-y-0 left-0 z-40 hidden w-64 border-r border-line bg-[color-mix(in_srgb,var(--color-surface)_70%,var(--color-bg))] lg:block"> |
| 560 | | − | <Sidebar user={user} shell={shell} /> |
| 688 | + | <Sidebar user={user} shell={shell} onFind={() => setPalette(true)} /> |
| 561 | 689 | | </aside> |
| 562 | 690 | | {drawer && ( |
| 563 | 691 | | <div className="fixed inset-0 z-50 lg:hidden"> |
| ⋯ |
| 576 | 704 | | > |
| 577 | 705 | | <X size={16} /> |
| 578 | 706 | | </button> |
| 579 | | − | <Sidebar user={user} shell={shell} /> |
| 707 | + | <Sidebar user={user} shell={shell} onFind={() => setPalette(true)} /> |
| 580 | 708 | | </aside> |
| 581 | 709 | | </div> |
| 582 | 710 | | )} |
| ⋯ |
| 591 | 719 | | > |
| 592 | 720 | | <Menu size={18} /> |
| 593 | 721 | | </button> |
| 594 | | − | <button |
| 595 | | − | type="button" |
| 596 | | − | onClick={() => setPalette(true)} |
| 597 | | − | className="flex h-8 w-full max-w-md items-center gap-2 rounded-md border border-line bg-surface/60 px-2.5 text-sm text-faint transition-colors hover:border-line-strong hover:text-muted" |
| 598 | | − | > |
| 599 | | − | <Search size={14} /> |
| 600 | | − | <span className="grow text-left">Search or jump to…</span> |
| 601 | | − | <kbd className="hidden rounded border border-line px-1.5 font-mono text-[0.6875rem] sm:inline">⌘K</kbd> |
| 602 | | − | </button> |
| 603 | | − | <Form action="/search" className="hidden" /> |
| 722 | + | <Breadcrumbs pathname={pathname} /> |
| 604 | 723 | | <div className="ml-auto flex items-center gap-1.5"> |
| 724 | + | <a |
| 725 | + | href="https://docs.g1t.sh/" |
| 726 | + | className="hidden rounded-md px-2 py-1 text-[0.8125rem] text-muted transition-colors hover:bg-raised hover:text-fg sm:block" |
| 727 | + | > |
| 728 | + | Docs |
| 729 | + | </a> |
| 605 | 730 | | <DropdownMenu> |
| 606 | 731 | | <DropdownMenuTrigger |
| 607 | 732 | | aria-label="Create" |
| 608 | | − | className="rounded-md border border-line p-1.5 text-muted outline-none transition-colors hover:border-line-strong hover:text-fg data-[state=open]:bg-raised" |
| 733 | + | className="flex h-8 items-center gap-1.5 rounded-md bg-fg px-2.5 text-[0.8125rem] font-medium text-bg outline-none transition-colors hover:bg-white" |
| 609 | 734 | | > |
| 610 | | − | <Plus size={15} /> |
| 735 | + | <Plus size={14} /> |
| 736 | + | New |
| 611 | 737 | | </DropdownMenuTrigger> |
| 612 | 738 | | <DropdownMenuContent align="end"> |
| 613 | 739 | | {shell.repo && ( |
| ⋯ |
| 637 | 763 | | <Users /> |
| 638 | 764 | | New workspace |
| 639 | 765 | | </Link> |
| 640 | | − | </DropdownMenuItem> |
| 641 | | − | </DropdownMenuContent> |
| 642 | | − | </DropdownMenu> |
| 643 | | − | <DropdownMenu> |
| 644 | | − | <DropdownMenuTrigger |
| 645 | | − | aria-label="Account menu" |
| 646 | | − | className="rounded-md p-1 outline-none transition-colors hover:bg-raised data-[state=open]:bg-raised" |
| 647 | | − | > |
| 648 | | − | <Avatar name={user.username} size={24} /> |
| 649 | | − | </DropdownMenuTrigger> |
| 650 | | − | <DropdownMenuContent align="end" className="w-56"> |
| 651 | | − | <DropdownMenuLabel> |
| 652 | | − | Signed in as <span className="font-mono font-medium text-fg">{user.username}</span> |
| 653 | | − | </DropdownMenuLabel> |
| 654 | | − | <DropdownMenuSeparator /> |
| 655 | | − | <DropdownMenuItem asChild> |
| 656 | | − | <Link to="/settings"> |
| 657 | | − | <Settings /> |
| 658 | | − | Your settings |
| 659 | | − | </Link> |
| 660 | | − | </DropdownMenuItem> |
| 661 | | − | <DropdownMenuItem asChild> |
| 662 | | − | <a href="https://docs.g1t.sh/"> |
| 663 | | − | <BookOpen /> |
| 664 | | − | Documentation |
| 665 | | − | </a> |
| 666 | | − | </DropdownMenuItem> |
| 667 | | − | <DropdownMenuSeparator /> |
| 668 | | − | {/* Submitted from here: the menu closes on select, and a button |
| 669 | | − | that has left the page cannot submit a form. */} |
| 670 | | − | <DropdownMenuItem onSelect={() => submit(null, { method: "post", action: "/logout" })}> |
| 671 | | − | <LogOut /> |
| 672 | | − | Sign out |
| 673 | 766 | | </DropdownMenuItem> |
| 674 | 767 | | </DropdownMenuContent> |
| 675 | 768 | | </DropdownMenu> |