| 1 | import { |
| 2 | Database, |
| 3 | Activity, |
| 4 | BarChart3, |
| 5 | Bot, |
| 6 | Cpu, |
| 7 | Columns3, |
| 8 | Radio, |
| 9 | ScrollText, |
| 10 | BrickWall, |
| 11 | Building2, |
| 12 | ChartLine, |
| 13 | Flag, |
| 14 | FolderKanban, |
| 15 | MessagesSquare, |
| 16 | Bell, |
| 17 | BookMarked, |
| 18 | BookOpen, |
| 19 | Bookmark, |
| 20 | Blocks, |
| 21 | Box, |
| 22 | Check, |
| 23 | ChevronDown, |
| 24 | ChevronLeft, |
| 25 | ChevronRight, |
| 26 | CircleDot, |
| 27 | GripVertical, |
| 28 | CircleUserRound, |
| 29 | Code2, |
| 30 | Compass, |
| 31 | CreditCard, |
| 32 | Fingerprint, |
| 33 | Gauge, |
| 34 | GitBranch, |
| 35 | GitPullRequest, |
| 36 | Globe, |
| 37 | History, |
| 38 | Keyboard, |
| 39 | KeyRound, |
| 40 | Layers, |
| 41 | LayoutDashboard, |
| 42 | LayoutGrid, |
| 43 | LifeBuoy, |
| 44 | Inbox, |
| 45 | ListOrdered, |
| 46 | ListTodo, |
| 47 | Lock, |
| 48 | LogOut, |
| 49 | Mail, |
| 50 | MonitorCog, |
| 51 | MonitorDown, |
| 52 | Package, |
| 53 | PlayCircle, |
| 54 | Plug, |
| 55 | Plus, |
| 56 | ReceiptText, |
| 57 | Repeat, |
| 58 | Rocket, |
| 59 | Search, |
| 60 | ServerCog, |
| 61 | Settings, |
| 62 | LibraryBig, |
| 63 | Shield, |
| 64 | ShieldCheck, |
| 65 | Scale, |
| 66 | Smile, |
| 67 | Sparkles, |
| 68 | Star, |
| 69 | House, |
| 70 | Ticket, |
| 71 | Timer, |
| 72 | TrendingUp, |
| 73 | UserPlus, |
| 74 | UserRoundKey, |
| 75 | Users, |
| 76 | UsersRound, |
| 77 | Webhook, |
| 78 | X, |
| 79 | } from "lucide-react"; |
| 80 | import { Fragment, type ReactNode, useCallback, useEffect, useMemo, useRef, useState, useSyncExternalStore } from "react"; |
| 81 | import { Link, useFetcher, useLocation, useNavigation, useRouteLoaderData, useSubmit } from "react-router"; |
| 82 | |
| 83 | import { |
| 84 | type Abilities, |
| 85 | type ChatSidebarEntry, |
| 86 | type InboxCounts, |
| 87 | type Membership, |
| 88 | type Spike, |
| 89 | type User, |
| 90 | type WorkProject, |
| 91 | hasCodeAccess, |
| 92 | mayCreateTeams, |
| 93 | shownUsername, |
| 94 | } from "@g1t/contracts"; |
| 95 | |
| 96 | import { InMain } from "./landmark"; |
| 97 | import { NeedsAttention } from "./needs-attention"; |
| 98 | import { cn } from "../lib/cn"; |
| 99 | import { CommandPalette, type PaletteCommand, PaletteKey, ShortcutsKey, usePaletteShortcut, useShortcutsKey } from "./command-palette"; |
| 100 | import { AskButton } from "./ask-button"; |
| 101 | import { NotificationsBell } from "./inbox"; |
| 102 | import { desktopBridge, useDesktopApp, useTitleBarSearch } from "../lib/notify-client"; |
| 103 | import { PinButton } from "./pin-button"; |
| 104 | import { Hint } from "./ui/hint"; |
| 105 | import { |
| 106 | Sidebar, |
| 107 | SidebarGroup, |
| 108 | SidebarGroupAction, |
| 109 | SidebarGroupContent, |
| 110 | SidebarGroupLabel, |
| 111 | SidebarHeader, |
| 112 | SidebarMenu, |
| 113 | SidebarMenuBadge, |
| 114 | SidebarMenuButton, |
| 115 | SidebarMenuItem, |
| 116 | SidebarMenuSub, |
| 117 | SidebarMenuSubButton, |
| 118 | SidebarMenuSubItem, |
| 119 | SidebarMobile, |
| 120 | SidebarProvider, |
| 121 | SidebarSeparator, |
| 122 | SidebarTrigger, |
| 123 | } from "./ui/sidebar"; |
| 124 | import { StatusDot, useSiteStatus } from "./footer"; |
| 125 | import { Logo } from "./logo"; |
| 126 | import { SoonPill } from "./ui"; |
| 127 | import { Avatar } from "./ui/avatar"; |
| 128 | import { Skeleton } from "./ui/skeleton"; |
| 129 | import { Badge } from "./ui/badge"; |
| 130 | import { Button } from "./ui/button"; |
| 131 | import { Separator } from "./ui/separator"; |
| 132 | import { Breadcrumb, BreadcrumbItem, BreadcrumbLink, BreadcrumbList, BreadcrumbPage, BreadcrumbSeparator } from "./ui/breadcrumb"; |
| 133 | import { |
| 134 | DropdownMenu, |
| 135 | DropdownMenuContent, |
| 136 | DropdownMenuGroup, |
| 137 | DropdownMenuItem, |
| 138 | DropdownMenuLabel, |
| 139 | DropdownMenuSeparator, |
| 140 | DropdownMenuTrigger, |
| 141 | } from "./ui/dropdown-menu"; |
| 142 | import { type RoadmapItem, roadmapIn, roadmapItem } from "../lib/roadmap"; |
| 143 | import { |
| 144 | type BrowsedOwner, |
| 145 | type ModeKey, |
| 146 | WORKSPACE_SETTINGS_GROUPS, |
| 147 | type WorkspaceSettingsPage, |
| 148 | browsingOf, |
| 149 | homePagePath, |
| 150 | modeOf, |
| 151 | pagePath, |
| 152 | repositoriesPath, |
| 153 | workspaceSettingsPage, |
| 154 | isIntegrationsPath, |
| 155 | } from "../lib/workspace-nav"; |
| 156 | import { useSidebarLocation } from "../lib/use-sidebar-location"; |
| 157 | import { setupPath } from "../lib/onboarding"; |
| 158 | import { ChatSidebar } from "./chat/sidebar"; |
| 159 | import { FoliosSidebar } from "./folios/sidebar"; |
| 160 | import { AppBar, ShortcutsDialog, WorkspaceSwitcher, sidebarKeyLabel } from "./app-bar"; |
| 161 | import { BottomBar, isConversation, useVisualViewport } from "./mobile"; |
| 162 | import { useChatSidebar } from "./chat/actions"; |
| 163 | import { unreadTotals } from "../lib/chat"; |
| 164 | import { SETTINGS_CAPABILITY, type ViewerAccess, seesSettings } from "../lib/access"; |
| 165 | import { PROJECT_OPERATIONS, PROJECT_PAGE_LINKS, projectPageAt, projectPages } from "../lib/chrome"; |
| 166 | import { ACCOUNT_SETTINGS, type AccountSettingsPage, FIRST_SETTINGS_PAGE, accountSettingsPage } from "../lib/account-settings"; |
| 167 | import { type PinnableApp, sidebarCookie } from "../lib/apps"; |
| 168 | import { REASON_FILTERS, inboxReason } from "../lib/inbox"; |
| 169 | import { notificationViewAt, notificationsAddress, profilePath } from "../lib/tab-paths"; |
| 170 | import { GithubMark } from "./github"; |
| 171 | import { STATUS_URL, statusTitle } from "../lib/status"; |
| 172 | import { G1T_INVITES, leftBadge } from "../lib/invites"; |
| 173 | import { type ShortcutProject, movedPin, recentWith } from "../lib/pins"; |
| 174 | import { ProjectIcon } from "./project-icon"; |
| 175 | import type { AccountMenuData } from "../routes/settings-menu-json"; |
| 176 | import { useLiveBadges } from "../lib/notify-client"; |
| 177 | import { OwnPresenceDot, OwnPresenceItems, StatusDialog } from "./presence"; |
| 178 | import { THEME_COMMANDS, ThemeMenuSwitch } from "./theme-switch"; |
| 179 | import { newItemPath, repoWorkPath, sessionsPath, workPath } from "../lib/work-items"; |
| 180 | import { HEALTH_LABELS, projectPath, projectsJsonPath, projectsPath } from "../lib/projects"; |
| 181 | import { type SidebarCycle, cycleJsonPath, cyclePath } from "../lib/cycle-paths"; |
| 182 | import { HealthDot, ProjectMark } from "./projects"; |
| 183 | |
| 184 | /** |
| 185 | * What the sidebar needs, worked out by the root loader. For a visitor who |
| 186 | * is not signed in there is no workspace, no repositories and no usage: |
| 187 | * only the repository being looked at, if they can see it. |
| 188 | */ |
| 189 | export type ShellData = { |
| 190 | /** The workspace the sidebar is about: the one being looked at, or their first. */ |
| 191 | workspace: Membership | null; |
| 192 | /** All its repositories, by name, for the palette: `name` is the slug in their address. */ |
| 193 | repos: ShortcutProject[]; |
| 194 | /** The person's pinned repositories in it, in their order (lib/pins.ts). */ |
| 195 | pinned?: ShortcutProject[]; |
| 196 | /** What they opened there last, latest first, leaving out the pinned. */ |
| 197 | recent?: ShortcutProject[]; |
| 198 | /** Repositories shared with them in workspaces they do not belong to. */ |
| 199 | shared?: { namespace: string; name: string; isPrivate: boolean }[]; |
| 200 | /** The repository being looked at, if any, whoever owns it. */ |
| 201 | repo: { |
| 202 | namespace: string; |
| 203 | name: string; |
| 204 | member: boolean; |
| 205 | /** Open work items linked to it. */ |
| 206 | items: number; |
| 207 | pulls: number; |
| 208 | } | null; |
| 209 | /** Where the workspace stands against its usage limit, if billing is on. */ |
| 210 | limit: { |
| 211 | exposureMicros: number; |
| 212 | ceilingMicros: number | null; |
| 213 | state: "ok" | "warning" | "stopped"; |
| 214 | comped: boolean; |
| 215 | } | null; |
| 216 | /** Whether g1t charges nothing for now, while it is being built out. */ |
| 217 | free?: boolean; |
| 218 | /** This month's usage: charged, or at cost while g1t is free. */ |
| 219 | monthUsageMicros: number | null; |
| 220 | /** Whether new compute is paused (a spend spike or a hold), and whether the viewer can answer it. */ |
| 221 | compute?: { paused: string | null; spike: Spike | null; owner: boolean } | null; |
| 222 | /** What is unread in their notifications, for the rail and the bell. Absent for a visitor. */ |
| 223 | inbox?: InboxCounts | null; |
| 224 | /** |
| 225 | * Chat, for the rail's badge: what is unread, and the |
| 226 | * starred and latest conversations. Null when chat did not answer in time. |
| 227 | */ |
| 228 | chat?: { unread: number; mentions: number; starred?: ChatSidebarEntry[]; recent?: ChatSidebarEntry[] } | null; |
| 229 | /** The apps this person pinned to their rail in the workspace (lib/apps.ts). */ |
| 230 | pins?: PinnableApp[]; |
| 231 | }; |
| 232 | |
| 233 | /** Whether `pathname` is `to` or a page under it (`end`: only `to` itself), the query left aside. */ |
| 234 | function under(pathname: string, to: string, end = false): boolean { |
| 235 | const path = to.split("?")[0]!; |
| 236 | return pathname === path || (!end && pathname.startsWith(path.endsWith("/") ? path : `${path}/`)); |
| 237 | } |
| 238 | |
| 239 | /** |
| 240 | * Where a row is current and where it is on its way to. A row's page is |
| 241 | * current under its own address and any `also` prefix, unless the list |
| 242 | * says itself (`current`). Rows that differ by query alone (Notifications' |
| 243 | * views and reasons) are pending only when the page on its way is that |
| 244 | * row's, query and all. |
| 245 | */ |
| 246 | function useRowState(to: string, { end, also, current: lit }: { end?: boolean; also?: string | string[]; current?: boolean }) { |
| 247 | const { pathname, key } = useLocation(); |
| 248 | const destination = useSidebarLocation(); |
| 249 | const going = destination.key !== key ? destination : undefined; |
| 250 | const current = lit ?? (under(pathname, to, end) || [also ?? []].flat().some((prefix) => under(pathname, prefix))); |
| 251 | const pending = going != null && !current && (lit === undefined ? under(going.pathname, to, end) : `${going.pathname}${going.search}` === to); |
| 252 | return { current, pending }; |
| 253 | } |
| 254 | |
| 255 | /** |
| 256 | * One row of a mode's sidebar: an icon, its words, how many it lists |
| 257 | * (`count`) and, for a row that opens a list of its own (`drill`), a |
| 258 | * chevron, always or on hover. |
| 259 | */ |
| 260 | function SidebarLink({ |
| 261 | to, |
| 262 | icon, |
| 263 | end, |
| 264 | count, |
| 265 | also, |
| 266 | drill, |
| 267 | current: lit, |
| 268 | action, |
| 269 | bare = false, |
| 270 | children, |
| 271 | }: { |
| 272 | to: string; |
| 273 | icon: ReactNode; |
| 274 | end?: boolean; |
| 275 | count?: number; |
| 276 | /** Other path prefixes under which this link is the current one. */ |
| 277 | also?: string | string[]; |
| 278 | /** It opens a list of its own: a chevron says so, always or on hover. */ |
| 279 | drill?: boolean | "hover"; |
| 280 | /** Whether it is the current row, when the list works that out itself. */ |
| 281 | current?: boolean; |
| 282 | /** A small button at the row's end, beside the words (a pin). */ |
| 283 | action?: ReactNode; |
| 284 | /** The row alone, for a list item that is drawn by the list (a pinned repository, which drags). */ |
| 285 | bare?: boolean; |
| 286 | children: ReactNode; |
| 287 | }) { |
| 288 | const { current, pending } = useRowState(to, { end, also, current: lit }); |
| 289 | const Item = bare ? Fragment : SidebarMenuItem; |
| 290 | return ( |
| 291 | <Item> |
| 292 | <SidebarMenuButton asChild isActive={current} isPending={pending} className={action ? "pr-7" : undefined}> |
| 293 | <Link to={to} prefetch="intent" aria-current={current ? "page" : undefined}> |
| 294 | {icon} |
| 295 | <span className="min-w-0 grow truncate">{children}</span> |
| 296 | {count != null && count > 0 && <SidebarMenuBadge>{count}</SidebarMenuBadge>} |
| 297 | {drill && ( |
| 298 | <ChevronRight |
| 299 | aria-hidden="true" |
| 300 | className={`-mr-0.5 size-3.5! shrink-0 text-faint transition-opacity ${drill === "hover" ? "opacity-0 group-hover/menu-button:opacity-100 group-focus-visible/menu-button:opacity-100" : ""}`} |
| 301 | /> |
| 302 | )} |
| 303 | </Link> |
| 304 | </SidebarMenuButton> |
| 305 | {action} |
| 306 | </Item> |
| 307 | ); |
| 308 | } |
| 309 | |
| 310 | /** |
| 311 | * What a mode's sidebar makes first (New work item): a white pill on the |
| 312 | * sidebar's grey, above its lists, lit like any row on its own page. |
| 313 | */ |
| 314 | function PrimaryLink({ to, icon, current, children }: { to: string; icon: ReactNode; current: boolean; children: ReactNode }) { |
| 315 | const { pending } = useRowState(to, { current }); |
| 316 | return ( |
| 317 | <SidebarMenuItem> |
| 318 | <SidebarMenuButton |
| 319 | asChild |
| 320 | isActive={current} |
| 321 | isPending={pending} |
| 322 | className="rounded-full bg-bg font-medium text-fg shadow-xs ring-1 ring-sidebar-border hover:bg-bg hover:ring-line-strong data-[active=true]:bg-bg data-[active=true]:ring-line-strong [&>svg]:text-fg" |
| 323 | > |
| 324 | <Link to={to} prefetch="intent" aria-current={current ? "page" : undefined}> |
| 325 | {icon} |
| 326 | <span className="min-w-0 grow truncate">{children}</span> |
| 327 | </Link> |
| 328 | </SidebarMenuButton> |
| 329 | </SidebarMenuItem> |
| 330 | ); |
| 331 | } |
| 332 | |
| 333 | /** |
| 334 | * A page that is coming, as a link to its roadmap page: faint, with Soon, |
| 335 | * and marked current on any of its section's Soon pages. |
| 336 | */ |
| 337 | function SidebarSoonLink({ |
| 338 | to, |
| 339 | also, |
| 340 | icon, |
| 341 | about, |
| 342 | current: lit, |
| 343 | children, |
| 344 | }: { |
| 345 | to: string; |
| 346 | also?: string[]; |
| 347 | icon: ReactNode; |
| 348 | about: string; |
| 349 | /** Whether it is the current row, when the list works that out itself. */ |
| 350 | current?: boolean; |
| 351 | children: ReactNode; |
| 352 | }) { |
| 353 | const { current, pending } = useRowState(to, { also, current: lit }); |
| 354 | return ( |
| 355 | <SidebarMenuItem> |
| 356 | <Hint label={about} side="right"> |
| 357 | <SidebarMenuButton asChild variant="faint" isActive={current} isPending={pending}> |
| 358 | <Link to={to} prefetch="intent" aria-current={current ? "page" : undefined}> |
| 359 | {icon} |
| 360 | <span className="min-w-0 grow truncate">{children}</span> |
| 361 | <SoonPill /> |
| 362 | </Link> |
| 363 | </SidebarMenuButton> |
| 364 | </Hint> |
| 365 | </SidebarMenuItem> |
| 366 | ); |
| 367 | } |
| 368 | |
| 369 | /** The Soon pages of a section, as paths, so its link is current on them. */ |
| 370 | function soonPaths(base: string, section: RoadmapItem["section"]): string[] { |
| 371 | return roadmapIn(section).map((item) => `${base}/soon/${item.key}`); |
| 372 | } |
| 373 | |
| 374 | /** A titled group of rows: its name in small capitals, what it makes at the end, and its menu. */ |
| 375 | function NavGroup({ |
| 376 | title, |
| 377 | action, |
| 378 | className, |
| 379 | children, |
| 380 | }: { |
| 381 | title: string; |
| 382 | /** A small button at the label's end, in its row: what the group makes (a `SidebarGroupAction` with `relative top-auto right-auto`). */ |
| 383 | action?: ReactNode; |
| 384 | className?: string; |
| 385 | children: ReactNode; |
| 386 | }) { |
| 387 | return ( |
| 388 | <SidebarGroup className={className}> |
| 389 | <div className="flex items-center justify-between gap-2 pr-1"> |
| 390 | <SidebarGroupLabel asChild> |
| 391 | <h2>{title}</h2> |
| 392 | </SidebarGroupLabel> |
| 393 | {action} |
| 394 | </div> |
| 395 | <SidebarGroupContent> |
| 396 | <SidebarMenu>{children}</SidebarMenu> |
| 397 | </SidebarGroupContent> |
| 398 | </SidebarGroup> |
| 399 | ); |
| 400 | } |
| 401 | |
| 402 | /** Rows with no name over them: the first of a list, or a run between rules. */ |
| 403 | function NavRows({ className, children }: { className?: string; children: ReactNode }) { |
| 404 | return ( |
| 405 | <SidebarGroup className={className}> |
| 406 | <SidebarGroupContent> |
| 407 | <SidebarMenu>{children}</SidebarMenu> |
| 408 | </SidebarGroupContent> |
| 409 | </SidebarGroup> |
| 410 | ); |
| 411 | } |
| 412 | |
| 413 | /** What a workspace is called where people read it: its display name, else its slug. */ |
| 414 | function displayName(membership: Membership): string { |
| 415 | return membership.name?.trim() || membership.slug; |
| 416 | } |
| 417 | |
| 418 | /** |
| 419 | * The fine print, as one quiet row at the foot of the account menu and the |
| 420 | * visitor's panel: each link a full-height target, left-aligned. In the |
| 421 | * menu each is a menu item, so the arrow keys reach it. |
| 422 | */ |
| 423 | const LEGAL_LINKS: [string, string][] = [ |
| 424 | ["Policies", "/policies"], |
| 425 | ["Privacy", "/policies/privacy"], |
| 426 | ["Security", "/security"], |
| 427 | ]; |
| 428 | const LEGAL_LINK = "flex h-7 items-center rounded px-1.5 text-xs text-faint outline-none transition-colors"; |
| 429 | |
| 430 | function MenuLegalRow() { |
| 431 | return ( |
| 432 | <div role="group" aria-label="Legal" className="flex items-center gap-0.5 px-0.5 pt-0.5"> |
| 433 | {LEGAL_LINKS.map(([label, to], index) => ( |
| 434 | <span key={to} className="flex items-center gap-0.5"> |
| 435 | {index > 0 && ( |
| 436 | <span aria-hidden="true" className="text-[0.625rem] text-line-strong"> |
| 437 | · |
| 438 | </span> |
| 439 | )} |
| 440 | <DropdownMenuItem asChild className={`${LEGAL_LINK} py-0 data-highlighted:bg-line data-highlighted:text-fg`}> |
| 441 | <Link to={to}>{label}</Link> |
| 442 | </DropdownMenuItem> |
| 443 | </span> |
| 444 | ))} |
| 445 | </div> |
| 446 | ); |
| 447 | } |
| 448 | |
| 449 | /** Status's row: the live dot and two or three words, fetched when the menu opens. */ |
| 450 | function StatusSummary({ open }: { open: boolean }) { |
| 451 | const status = useSiteStatus(open); |
| 452 | const words = statusTitle(status); |
| 453 | return ( |
| 454 | <span className="ml-auto flex min-w-0 items-center gap-1.5 pl-2 text-xs text-faint"> |
| 455 | {/* The words say it; the dot's own words for screen readers would repeat them. */} |
| 456 | <span aria-hidden="true" className="flex"> |
| 457 | <StatusDot state={status?.overall.state ?? null} /> |
| 458 | </span> |
| 459 | {words ? <span className="truncate">{words}</span> : <Skeleton className="h-3 w-20" />} |
| 460 | </span> |
| 461 | ); |
| 462 | } |
| 463 | |
| 464 | /** A menu row: an icon, words, and whatever sits at its end. */ |
| 465 | const MENU_ROW = "h-9 gap-2.5 px-2.5 text-[0.8125rem]"; |
| 466 | |
| 467 | /** |
| 468 | * Your account, at the end of the app bar (or of the page's top bar, before |
| 469 | * you have a workspace): who you are and your status, your profile and |
| 470 | * settings, and help: the documentation, support, status and the |
| 471 | * keyboard's shortcuts; then signing out and the fine print. `on` is the |
| 472 | * surface it sits on, which rings its presence dot. |
| 473 | */ |
| 474 | function AccountMenu({ user, on = "page" }: { user: User; on?: "bar" | "page" }) { |
| 475 | const submit = useSubmit(); |
| 476 | const [open, setOpen] = useState(false); |
| 477 | const [keys, setKeys] = useState(false); |
| 478 | // ⌘K or Ctrl K (or ?) shows the keyboard's shortcuts from anywhere but a text field; / is search. |
| 479 | useShortcutsKey(() => setKeys(true)); |
| 480 | // Inside the desktop app there is nothing to get. |
| 481 | const inApp = useDesktopApp(); |
| 482 | // Your status's dialog: outside the menu, so the menu closes behind it. |
| 483 | const [editing, setEditing] = useState(false); |
| 484 | // Name, primary address and invites left: asked for once, as soon as the |
| 485 | // pointer or focus reaches the button, so they are there when it opens. |
| 486 | const details = useFetcher<AccountMenuData | null>({ key: "account-menu" }); |
| 487 | const prefetch = () => { |
| 488 | if (details.state === "idle" && details.data === undefined) details.load("/settings/menu.json"); |
| 489 | }; |
| 490 | useEffect(() => { |
| 491 | if (open) prefetch(); |
| 492 | // eslint-disable-next-line react-hooks/exhaustive-deps |
| 493 | }, [open]); |
| 494 | const me = details.data ?? null; |
| 495 | // Still on its way: shapes where the words will be, the same size. |
| 496 | const loading = details.data === undefined && details.state !== "idle"; |
| 497 | const profile = `/u/${user.username}`; |
| 498 | return ( |
| 499 | <> |
| 500 | <DropdownMenu open={open} onOpenChange={setOpen}> |
| 501 | {/* The avatar alone, with a dot that says how others see you (components/presence.tsx). */} |
| 502 | <Hint label={on === "bar" ? "Your account" : undefined} side="bottom"> |
| 503 | <DropdownMenuTrigger |
| 504 | aria-label={`Account menu for ${shownUsername(user)}`} |
| 505 | onPointerEnter={prefetch} |
| 506 | onFocus={prefetch} |
| 507 | className="relative ml-0.5 flex rounded-full outline-none transition-transform hover:scale-[1.04] focus-visible:ring-2 focus-visible:ring-accent data-[state=open]:ring-2 data-[state=open]:ring-line-strong" |
| 508 | > |
| 509 | <Avatar name={user.username} image={user.avatar} size={28} /> |
| 510 | <OwnPresenceDot ring={on === "bar" ? "var(--color-sidebar)" : "var(--color-bg)"} className="absolute -right-0.5 -bottom-0.5" /> |
| 511 | </DropdownMenuTrigger> |
| 512 | </Hint> |
| 513 | <DropdownMenuContent align="end" side="bottom" sideOffset={8} collisionPadding={8} className="w-70 p-1.5"> |
| 514 | {/* Who is signed in, and a way to their profile. */} |
| 515 | <DropdownMenuItem asChild className="gap-3 px-2 py-2"> |
| 516 | <Link to={profile} aria-label={`${me?.name ?? shownUsername(user)} (@${shownUsername(user)}), your profile`}> |
| 517 | <Avatar name={user.username} image={user.avatar} size={36} /> |
| 518 | <span className="flex min-w-0 grow flex-col leading-tight" aria-busy={loading}> |
| 519 | {loading ? ( |
| 520 | <Skeleton className="my-0.75 h-3.5 w-28" /> |
| 521 | ) : ( |
| 522 | <span className="truncate text-sm font-medium text-fg">{me?.name ?? shownUsername(user)}</span> |
| 523 | )} |
| 524 | <span className="truncate font-mono text-xs text-muted">@{shownUsername(user)}</span> |
| 525 | {loading ? <Skeleton className="mt-1 h-3 w-40" /> : me?.email && <span className="mt-0.5 truncate text-xs text-faint">{me.email}</span>} |
| 526 | </span> |
| 527 | </Link> |
| 528 | </DropdownMenuItem> |
| 529 | <DropdownMenuSeparator className="my-1.5" /> |
| 530 | {/* Your status, away, and pausing notifications. */} |
| 531 | <DropdownMenuGroup> |
| 532 | <OwnPresenceItems className="min-h-9 py-1.5 text-[0.8125rem]" onEdit={() => setEditing(true)} /> |
| 533 | </DropdownMenuGroup> |
| 534 | <DropdownMenuSeparator className="my-1.5" /> |
| 535 | <DropdownMenuGroup> |
| 536 | <DropdownMenuItem asChild className={MENU_ROW}> |
| 537 | <Link to={profile}> |
| 538 | <CircleUserRound /> |
| 539 | Your profile |
| 540 | </Link> |
| 541 | </DropdownMenuItem> |
| 542 | <DropdownMenuItem asChild className={MENU_ROW}> |
| 543 | <Link to={FIRST_SETTINGS_PAGE}> |
| 544 | <Settings /> |
| 545 | Your settings |
| 546 | </Link> |
| 547 | </DropdownMenuItem> |
| 548 | {/* Invite a friend to g1t: word of mouth, never needed to sign up (routes/settings/invites.tsx). */} |
| 549 | <DropdownMenuItem asChild className={MENU_ROW}> |
| 550 | <Link to="/settings/invites?new"> |
| 551 | <UserPlus /> |
| 552 | {G1T_INVITES.action} |
| 553 | {loading && <Skeleton className="ml-auto h-4 w-12 rounded-full" />} |
| 554 | {leftBadge(me?.invites_left) && ( |
| 555 | <Badge size="sm" className="ml-auto tabular-nums"> |
| 556 | {leftBadge(me?.invites_left)} |
| 557 | </Badge> |
| 558 | )} |
| 559 | </Link> |
| 560 | </DropdownMenuItem> |
| 561 | {/* The app's own: starting with the computer, closing to the tray, the badge (apps/desktop). */} |
| 562 | {inApp && desktopBridge()?.openSettings && ( |
| 563 | <DropdownMenuItem className={MENU_ROW} onSelect={() => desktopBridge()?.openSettings?.()}> |
| 564 | <MonitorCog /> |
| 565 | Desktop app settings |
| 566 | </DropdownMenuItem> |
| 567 | )} |
| 568 | </DropdownMenuGroup> |
| 569 | <DropdownMenuSeparator className="my-1.5" /> |
| 570 | {/* Auto, Light or Dark, switched in place (lib/theme.ts). */} |
| 571 | <ThemeMenuSwitch /> |
| 572 | <DropdownMenuSeparator className="my-1.5" /> |
| 573 | <DropdownMenuGroup> |
| 574 | <DropdownMenuItem asChild className={MENU_ROW}> |
| 575 | <a href="https://docs.g1t.sh/"> |
| 576 | <BookOpen /> |
| 577 | Documentation |
| 578 | </a> |
| 579 | </DropdownMenuItem> |
| 580 | <DropdownMenuItem asChild className={MENU_ROW}> |
| 581 | <Link to="/support"> |
| 582 | <LifeBuoy /> |
| 583 | Support |
| 584 | </Link> |
| 585 | </DropdownMenuItem> |
| 586 | <DropdownMenuItem asChild className={MENU_ROW}> |
| 587 | <a href={STATUS_URL}> |
| 588 | <Activity /> |
| 589 | Status |
| 590 | <StatusSummary open={open} /> |
| 591 | </a> |
| 592 | </DropdownMenuItem> |
| 593 | <DropdownMenuItem className={MENU_ROW} onSelect={() => setKeys(true)}> |
| 594 | <Keyboard /> |
| 595 | Keyboard shortcuts |
| 596 | {/* Ctrl K (⌘K on a Mac) opens them anywhere; / is search's. */} |
| 597 | <ShortcutsKey className="ml-auto font-sans text-xs text-faint" /> |
| 598 | </DropdownMenuItem> |
| 599 | {!inApp && ( |
| 600 | <DropdownMenuItem asChild className={MENU_ROW}> |
| 601 | <Link to="/download"> |
| 602 | <MonitorDown /> |
| 603 | Get the desktop app |
| 604 | </Link> |
| 605 | </DropdownMenuItem> |
| 606 | )} |
| 607 | </DropdownMenuGroup> |
| 608 | <DropdownMenuSeparator className="my-1.5" /> |
| 609 | {/* Submitted from here: the menu closes on select, and a button |
| 610 | that has left the page cannot submit a form. */} |
| 611 | <DropdownMenuItem className={MENU_ROW} onSelect={() => submit(null, { method: "post", action: "/logout" })}> |
| 612 | <LogOut /> |
| 613 | Sign out |
| 614 | </DropdownMenuItem> |
| 615 | <DropdownMenuSeparator className="my-1.5" /> |
| 616 | <MenuLegalRow /> |
| 617 | </DropdownMenuContent> |
| 618 | </DropdownMenu> |
| 619 | <StatusDialog open={editing} onOpenChange={setEditing} /> |
| 620 | <ShortcutsDialog open={keys} onOpenChange={setKeys} /> |
| 621 | </> |
| 622 | ); |
| 623 | } |
| 624 | |
| 625 | /** A repository's settings pages, which the repository's menu drills into. */ |
| 626 | const REPO_SETTINGS_PAGE = /^\/([^/]+)\/([^/-][^/]*)\/settings(\/|$)/; |
| 627 | |
| 628 | /** |
| 629 | * The sidebar's lists are a stack, the way a phone pushes a screen: one |
| 630 | * drilled into slides in from the right over the full width, while the one |
| 631 | * it covers drifts a quarter of the way left and fades, both on one long |
| 632 | * ease-out, the fade quicker than the move so the two never blur together. |
| 633 | * Going back reverses it. |
| 634 | */ |
| 635 | const LAYER = |
| 636 | "absolute inset-0 flex flex-col [transition:translate_380ms_cubic-bezier(0.32,0.72,0,1),opacity_220ms_ease-out] will-change-[translate,opacity] motion-reduce:transition-none"; |
| 637 | /** One list, filling its layer. */ |
| 638 | const PANEL = "min-h-0 flex-1 overflow-y-auto px-1 pt-1 pb-4 scrollbar-thin"; |
| 639 | |
| 640 | /** One list in the stack: what it is, and its rows. */ |
| 641 | type Level = { key: string; node: ReactNode }; |
| 642 | |
| 643 | /** True once the page has hydrated, so what the server drew never animates in. */ |
| 644 | function useHydrated(): boolean { |
| 645 | const [hydrated, setHydrated] = useState(false); |
| 646 | useEffect(() => setHydrated(true), []); |
| 647 | return hydrated; |
| 648 | } |
| 649 | |
| 650 | /** |
| 651 | * Drilling in and out of the sidebar's lists. `trail` is the way from the |
| 652 | * main list to the one shown, worked out from the address, so a link |
| 653 | * straight to a settings page opens the sidebar already drilled in, and |
| 654 | * leaving it comes back out. Lists deeper than the one shown stay in |
| 655 | * place, off to the right, so going back slides them away rather than |
| 656 | * dropping them. Moving to a list at the same depth (a workspace's settings |
| 657 | * to a project) fades the new one in where it is. |
| 658 | */ |
| 659 | function Drill({ trail }: { trail: Level[] }) { |
| 660 | const depth = trail.length - 1; |
| 661 | const hydrated = useHydrated(); |
| 662 | const stack = useRef<Level[]>(trail); |
| 663 | const before = stack.current; |
| 664 | const within = trail.every((level, index) => before[index]?.key === level.key); |
| 665 | stack.current = within ? [...trail, ...before.slice(trail.length)] : trail; |
| 666 | const levels = stack.current; |
| 667 | // What was showing last time, to tell drilling in from moving across. |
| 668 | const shown = useRef({ depth, keys: levels.map((level) => level.key) }); |
| 669 | useEffect(() => { |
| 670 | shown.current = { depth, keys: levels.map((level) => level.key) }; |
| 671 | }); |
| 672 | return ( |
| 673 | <div className="relative min-h-0 grow overflow-hidden"> |
| 674 | {levels.map((level, index) => { |
| 675 | const current = index === depth; |
| 676 | const place = |
| 677 | index < depth |
| 678 | ? "pointer-events-none -translate-x-1/4 opacity-0" |
| 679 | : current |
| 680 | ? "translate-x-0 opacity-100" |
| 681 | : "pointer-events-none translate-x-full opacity-0"; |
| 682 | // A list that was not there before arrives: from the right when |
| 683 | // drilling in, in place when moving across. |
| 684 | const arriving = hydrated && current && !shown.current.keys.includes(level.key); |
| 685 | const enter = arriving ? (depth > shown.current.depth ? "starting:translate-x-full starting:opacity-0" : "starting:opacity-0") : ""; |
| 686 | return ( |
| 687 | <div key={level.key} className={`${LAYER} ${place} ${enter}`} inert={!current} aria-hidden={!current || undefined}> |
| 688 | {level.node} |
| 689 | </div> |
| 690 | ); |
| 691 | })} |
| 692 | </div> |
| 693 | ); |
| 694 | } |
| 695 | |
| 696 | /** |
| 697 | * The row at the top of a list drilled into: the list's name, which goes |
| 698 | * back out, and what it belongs to, faint at the end. |
| 699 | */ |
| 700 | function BackRow({ to, label, context }: { to: string; label: ReactNode; context?: ReactNode }) { |
| 701 | return ( |
| 702 | <NavRows className="mt-1"> |
| 703 | <SidebarMenuItem> |
| 704 | <SidebarMenuButton asChild className="gap-1.5 pl-1 font-medium text-fg"> |
| 705 | <Link to={to} prefetch="intent"> |
| 706 | <ChevronLeft className="text-faint transition-transform group-hover/menu-button:-translate-x-0.5 group-hover/menu-button:text-muted" /> |
| 707 | <span className="min-w-0 shrink-0 truncate">{label}</span> |
| 708 | {context && <span className="ml-auto min-w-0 truncate pl-2 font-mono text-[0.6875rem] font-normal text-faint">{context}</span>} |
| 709 | </Link> |
| 710 | </SidebarMenuButton> |
| 711 | </SidebarMenuItem> |
| 712 | </NavRows> |
| 713 | ); |
| 714 | } |
| 715 | |
| 716 | /** A thin rule between groups of a list. */ |
| 717 | function Rule() { |
| 718 | return <SidebarSeparator className="my-1.5" />; |
| 719 | } |
| 720 | |
| 721 | /** A quiet heading inside a group: Pinned, Recent. */ |
| 722 | function SidebarSubhead({ children }: { children: ReactNode }) { |
| 723 | return ( |
| 724 | <SidebarGroupLabel asChild className="h-6 pt-0.5"> |
| 725 | <h3>{children}</h3> |
| 726 | </SidebarGroupLabel> |
| 727 | ); |
| 728 | } |
| 729 | |
| 730 | /** |
| 731 | * The workspace's repositories as the sidebar keeps them, however many there |
| 732 | * are: the person's pins in their order, what they opened last, and the |
| 733 | * way to all of them. Pins move by dragging, or with Alt and the arrow |
| 734 | * keys; g1t keeps the order. |
| 735 | */ |
| 736 | function SidebarProjects({ slug, shell, current }: { slug: string; shell: ShellData; current: boolean }) { |
| 737 | const reorder = useFetcher({ key: `pins:${slug}` }); |
| 738 | const saved = shell.pinned ?? []; |
| 739 | // While a new order is on its way, it shows as made. |
| 740 | const asked = reorder.formData?.get("intent") === "reorder" ? reorder.formData.getAll("slug").map(String) : null; |
| 741 | const pinned = asked |
| 742 | ? asked.map((name) => saved.find((project) => project.name === name)).filter((project): project is ShortcutProject => project != null) |
| 743 | : saved; |
| 744 | const active = shell.repo ? { namespace: shell.repo.namespace, name: shell.repo.name, isPrivate: false } : null; |
| 745 | const recent = recentWith(shell.recent ?? [], pinned, active, slug); |
| 746 | const [dragging, setDragging] = useState<number | null>(null); |
| 747 | const move = (from: number, by: number) => { |
| 748 | const next = movedPin(pinned, from, by); |
| 749 | if (!next) return; |
| 750 | const form = new FormData(); |
| 751 | form.set("intent", "reorder"); |
| 752 | for (const project of next) form.append("slug", project.name); |
| 753 | reorder.submit(form, { method: "post", action: `/${slug}/-/pins` }); |
| 754 | }; |
| 755 | // Each row pins or unpins in place: the pin shows on hover or focus, and |
| 756 | // stays shown on a pinned row's hover so it reads as "unpin". |
| 757 | const row = (project: ShortcutProject, isPinned: boolean) => ( |
| 758 | <SidebarLink |
| 759 | to={`/${project.namespace}/${project.name}`} |
| 760 | icon={<ProjectIcon kind={project.kind} isPrivate={project.isPrivate} lockPrivate />} |
| 761 | bare={isPinned} |
| 762 | action={ |
| 763 | // At the row's end, where the row's other actions sit, and only on |
| 764 | // hover or keyboard focus: a pin clicked keeps the mouse's focus, |
| 765 | // and showing it on any focus left it lit on a row no one was over. |
| 766 | <PinButton |
| 767 | workspace={slug} |
| 768 | slug={project.name} |
| 769 | name={project.title ?? project.name} |
| 770 | pinned={isPinned} |
| 771 | small |
| 772 | className="absolute top-1/2 right-1 -translate-y-1/2 opacity-0 transition-opacity group-hover/menu-item:opacity-100 has-focus-visible:opacity-100 [@media(hover:none)]:opacity-100" |
| 773 | /> |
| 774 | } |
| 775 | > |
| 776 | {project.title ?? project.name} |
| 777 | </SidebarLink> |
| 778 | ); |
| 779 | return ( |
| 780 | <SidebarGroup> |
| 781 | {/* The label and its button share a row, so the button stays level with the words at any text size. */} |
| 782 | <div className="flex items-center justify-between gap-2 pr-1"> |
| 783 | <SidebarGroupLabel asChild> |
| 784 | <h2>Repositories</h2> |
| 785 | </SidebarGroupLabel> |
| 786 | <Hint label="New repository"> |
| 787 | <SidebarGroupAction asChild className="relative top-auto right-auto"> |
| 788 | <Link to="/new" aria-label="New repository"> |
| 789 | <Plus /> |
| 790 | </Link> |
| 791 | </SidebarGroupAction> |
| 792 | </Hint> |
| 793 | </div> |
| 794 | <SidebarGroupContent> |
| 795 | {/* Only there once something is pinned: each row below has its own pin. */} |
| 796 | {pinned.length > 0 && ( |
| 797 | <> |
| 798 | <SidebarSubhead>Pinned</SidebarSubhead> |
| 799 | <SidebarMenu aria-label="Pinned repositories"> |
| 800 | {pinned.map((project, index) => ( |
| 801 | <SidebarMenuItem |
| 802 | key={project.name} |
| 803 | draggable={pinned.length > 1} |
| 804 | onDragStart={(event) => { |
| 805 | setDragging(index); |
| 806 | event.dataTransfer.effectAllowed = "move"; |
| 807 | }} |
| 808 | onDragOver={(event) => { |
| 809 | if (dragging != null) event.preventDefault(); |
| 810 | }} |
| 811 | onDrop={(event) => { |
| 812 | event.preventDefault(); |
| 813 | if (dragging != null && dragging !== index) move(dragging, index - dragging); |
| 814 | setDragging(null); |
| 815 | }} |
| 816 | onDragEnd={() => setDragging(null)} |
| 817 | onKeyDown={(event) => { |
| 818 | if (!event.altKey || (event.key !== "ArrowUp" && event.key !== "ArrowDown")) return; |
| 819 | event.preventDefault(); |
| 820 | move(index, event.key === "ArrowUp" ? -1 : 1); |
| 821 | }} |
| 822 | className={`group/pin ${dragging === index ? "opacity-50" : ""}`} |
| 823 | > |
| 824 | {row(project, true)} |
| 825 | {pinned.length > 1 && ( |
| 826 | <Hint label="Drag, or Alt and an arrow key, to reorder" side="right"> |
| 827 | <span className="absolute top-1/2 -left-2 -translate-y-1/2 cursor-grab text-faint opacity-0 transition-opacity group-hover/pin:opacity-100"> |
| 828 | <GripVertical size={12} aria-hidden="true" /> |
| 829 | </span> |
| 830 | </Hint> |
| 831 | )} |
| 832 | </SidebarMenuItem> |
| 833 | ))} |
| 834 | </SidebarMenu> |
| 835 | </> |
| 836 | )} |
| 837 | {recent.length > 0 && ( |
| 838 | <> |
| 839 | <SidebarSubhead>Recent</SidebarSubhead> |
| 840 | <SidebarMenu aria-label="Recent repositories"> |
| 841 | {recent.map((project) => ( |
| 842 | <Fragment key={`${project.namespace}/${project.name}`}>{row(project, false)}</Fragment> |
| 843 | ))} |
| 844 | </SidebarMenu> |
| 845 | </> |
| 846 | )} |
| 847 | <SidebarMenu className="pt-1"> |
| 848 | <SidebarLink to={repositoriesPath(slug)} icon={<LayoutGrid />} count={shell.repos.length} current={current}> |
| 849 | All repositories |
| 850 | </SidebarLink> |
| 851 | </SidebarMenu> |
| 852 | </SidebarGroupContent> |
| 853 | </SidebarGroup> |
| 854 | ); |
| 855 | } |
| 856 | |
| 857 | /** |
| 858 | * A page a repository will have that is a page of its own, not a tab: its |
| 859 | * own faint row in the repository's list, leading to what it will be. |
| 860 | */ |
| 861 | function ProjectSoonRow({ base, feature, icon }: { base: string; feature: string; icon: ReactNode }) { |
| 862 | const item = roadmapItem(feature); |
| 863 | if (!item) return null; |
| 864 | return ( |
| 865 | <SidebarSoonLink to={`${base}/soon/${item.key}`} icon={icon} about={item.summary}> |
| 866 | {item.title} |
| 867 | </SidebarSoonLink> |
| 868 | ); |
| 869 | } |
| 870 | |
| 871 | /** |
| 872 | * What the repository menu needs. Known from the address before the page's |
| 873 | * data arrives, so the menu is right from the first frame; the counts fill |
| 874 | * in once it does. |
| 875 | */ |
| 876 | type MenuRepo = { |
| 877 | namespace: string; |
| 878 | name: string; |
| 879 | member: boolean; |
| 880 | /** Whether they see its settings; from the repository's page once it loads. */ |
| 881 | settings?: boolean; |
| 882 | /** What they may do there, once the repository's page loads. */ |
| 883 | can?: Abilities; |
| 884 | /** Open work items linked to it. */ |
| 885 | items?: number; |
| 886 | pulls?: number; |
| 887 | }; |
| 888 | |
| 889 | function sameRepo(a: { namespace: string; name: string } | null, b: { namespace: string; name: string } | null) { |
| 890 | return a != null && b != null && a.namespace.toLowerCase() === b.namespace.toLowerCase() && a.name.toLowerCase() === b.name.toLowerCase(); |
| 891 | } |
| 892 | |
| 893 | /** |
| 894 | * A repository's own list, drilled into while you are in it: everything |
| 895 | * about the repository, running and its code, and nothing else, with the way back |
| 896 | * out to all of them. |
| 897 | */ |
| 898 | function RepoMenu({ |
| 899 | repo, |
| 900 | isPrivate, |
| 901 | back, |
| 902 | }: { |
| 903 | repo: MenuRepo; |
| 904 | isPrivate: boolean; |
| 905 | /** Where the way back leads: the workspace's repositories, mission control, or the repositories of the workspace being browsed. */ |
| 906 | back: { to: string; label: string; context?: string }; |
| 907 | }) { |
| 908 | const base = `/${repo.namespace}/${repo.name}`; |
| 909 | // What a member sees, and what everyone who can see the repository does |
| 910 | // (lib/chrome.ts). |
| 911 | const shows = new Set(projectPages(repo.member, repo.can)); |
| 912 | const { current: atOverview, pending: toOverview } = useRowState(base, { end: true }); |
| 913 | return ( |
| 914 | <nav aria-label={`${repo.namespace}/${repo.name}`} className={PANEL}> |
| 915 | <BackRow to={back.to} label={back.label} context={back.context} /> |
| 916 | <NavRows className="mt-1"> |
| 917 | <SidebarMenuItem> |
| 918 | <Hint label="Overview" side="right"> |
| 919 | <SidebarMenuButton asChild size="lg" isActive={atOverview} isPending={toOverview} className="gap-2 font-normal"> |
| 920 | <Link to={base} prefetch="intent" aria-current={atOverview ? "page" : undefined}> |
| 921 | <span className="flex size-6 shrink-0 items-center justify-center rounded-md bg-raised text-muted ring-1 ring-line [&>svg]:size-3.5"> |
| 922 | {isPrivate ? <Lock /> : <Box />} |
| 923 | </span> |
| 924 | <span className="min-w-0 truncate font-mono text-[0.8125rem]"> |
| 925 | <span className="text-faint">{repo.namespace}/</span> |
| 926 | <span className="font-semibold text-fg">{repo.name}</span> |
| 927 | </span> |
| 928 | </Link> |
| 929 | </SidebarMenuButton> |
| 930 | </Hint> |
| 931 | </SidebarMenuItem> |
| 932 | </NavRows> |
| 933 | {/* A repository's pages, in the order people use them. A page with more |
| 934 | than one view shows them as tabs across its top. */} |
| 935 | <Rule /> |
| 936 | <NavRows> |
| 937 | <SidebarLink |
| 938 | to={`${base}/code`} |
| 939 | also={[ |
| 940 | `${base}/tree`, |
| 941 | `${base}/blob`, |
| 942 | `${base}/commits`, |
| 943 | `${base}/commit`, |
| 944 | `${base}/branches`, |
| 945 | `${base}/tags`, |
| 946 | `${base}/compare`, |
| 947 | ...soonPaths(base, "Code"), |
| 948 | ]} |
| 949 | icon={<Code2 size={15} />} |
| 950 | > |
| 951 | Code |
| 952 | </SidebarLink> |
| 953 | {shows.has("pulls") && ( |
| 954 | <SidebarLink to={`${base}/pulls`} also={[`${base}/pull`, `${base}/queue`]} icon={<GitPullRequest size={15} />} count={repo.pulls}> |
| 955 | Pull requests |
| 956 | </SidebarLink> |
| 957 | )} |
| 958 | {shows.has("actions") && ( |
| 959 | <SidebarLink to={`${base}/actions`} icon={<PlayCircle size={15} />}> |
| 960 | Workflows |
| 961 | </SidebarLink> |
| 962 | )} |
| 963 | </NavRows> |
| 964 | <Rule /> |
| 965 | <NavRows> |
| 966 | {shows.has("deployments") ? ( |
| 967 | <SidebarLink to={`${base}/deployments`} also={[`${base}/settings/deployments`, ...soonPaths(base, "Deployments")]} icon={<Rocket size={15} />}> |
| 968 | Deployments |
| 969 | </SidebarLink> |
| 970 | ) : null} |
| 971 | {repo.member && <ProjectSoonRow base={base} feature="analytics" icon={<ChartLine size={15} />} />} |
| 972 | {shows.has("observability") && ( |
| 973 | <SidebarLink to={`${base}/observability`} also={soonPaths(base, "Observability")} icon={<Activity size={15} />}> |
| 974 | Observability |
| 975 | </SidebarLink> |
| 976 | )} |
| 977 | {repo.member && <ProjectSoonRow base={base} feature="firewall" icon={<BrickWall size={15} />} />} |
| 978 | {repo.member && <ProjectSoonRow base={base} feature="flags" icon={<Flag size={15} />} />} |
| 979 | {shows.has("security") ? ( |
| 980 | <SidebarLink to={`${base}/security`} also={soonPaths(base, "Security")} icon={<ShieldCheck size={15} />}> |
| 981 | Security |
| 982 | </SidebarLink> |
| 983 | ) : null} |
| 984 | <SidebarLink |
| 985 | to={`${base}/contributors`} |
| 986 | also={[`${base}/activity`, `${base}/stargazers`, ...soonPaths(base, "Insights")]} |
| 987 | icon={<BarChart3 size={15} />} |
| 988 | > |
| 989 | Insights |
| 990 | </SidebarLink> |
| 991 | </NavRows> |
| 992 | {repo.member && ( |
| 993 | <> |
| 994 | <Rule /> |
| 995 | <NavRows> |
| 996 | {PROJECT_OPERATIONS.filter((page) => shows.has(page)).map((page) => { |
| 997 | const link = PROJECT_PAGE_LINKS[page]; |
| 998 | const icons = { |
| 999 | secrets: <KeyRound size={15} />, |
| 1000 | environments: <Layers size={15} />, |
| 1001 | domains: <Globe size={15} />, |
| 1002 | runners: <ServerCog size={15} />, |
| 1003 | webhooks: <Webhook size={15} />, |
| 1004 | }; |
| 1005 | return ( |
| 1006 | <SidebarLink key={page} to={`${base}/${link.path}`} icon={icons[page]}> |
| 1007 | {link.label} |
| 1008 | </SidebarLink> |
| 1009 | ); |
| 1010 | })} |
| 1011 | <ProjectSoonRow base={base} feature="storage" icon={<Database size={15} />} /> |
| 1012 | <ProjectSoonRow base={base} feature="daemons" icon={<Repeat size={15} />} /> |
| 1013 | </NavRows> |
| 1014 | </> |
| 1015 | )} |
| 1016 | {(repo.settings ?? shows.has("settings")) && ( |
| 1017 | <> |
| 1018 | <Rule /> |
| 1019 | <NavRows> |
| 1020 | <SidebarLink to={`${base}/settings`} end icon={<Settings size={15} />} drill> |
| 1021 | Settings |
| 1022 | </SidebarLink> |
| 1023 | </NavRows> |
| 1024 | </> |
| 1025 | )} |
| 1026 | </nav> |
| 1027 | ); |
| 1028 | } |
| 1029 | |
| 1030 | /** |
| 1031 | * Administrative settings: repository policy, access and workflow permissions. |
| 1032 | * Day-to-day operations stay in the main repository menu. |
| 1033 | */ |
| 1034 | function RepoSettingsMenu({ repo }: { repo: MenuRepo }) { |
| 1035 | const base = `/${repo.namespace}/${repo.name}`; |
| 1036 | // Each page shows to the roles that can use it, once the role is known. |
| 1037 | const shows = (page: string) => !repo.can || repo.can[SETTINGS_CAPABILITY[page] ?? "administer"]; |
| 1038 | const guarded = shows("guardrails"); |
| 1039 | return ( |
| 1040 | <nav aria-label={`${repo.namespace}/${repo.name} settings`} className={PANEL}> |
| 1041 | <BackRow to={base} label="Settings" context={`${repo.namespace}/${repo.name}`} /> |
| 1042 | {shows("") && ( |
| 1043 | <NavRows className="mt-2"> |
| 1044 | <SidebarLink to={`${base}/settings`} end icon={<Settings size={15} />}> |
| 1045 | General |
| 1046 | </SidebarLink> |
| 1047 | </NavRows> |
| 1048 | )} |
| 1049 | {guarded && ( |
| 1050 | <> |
| 1051 | <Rule /> |
| 1052 | <NavRows> |
| 1053 | {shows("guardrails") && ( |
| 1054 | <SidebarLink to={`${base}/settings/guardrails`} icon={<ShieldCheck size={15} />}> |
| 1055 | Guardrails |
| 1056 | </SidebarLink> |
| 1057 | )} |
| 1058 | </NavRows> |
| 1059 | </> |
| 1060 | )} |
| 1061 | <Rule /> |
| 1062 | <NavRows> |
| 1063 | {shows("repository") && ( |
| 1064 | <SidebarLink to={`${base}/settings/repository`} icon={<BookMarked size={15} />}> |
| 1065 | Repository |
| 1066 | </SidebarLink> |
| 1067 | )} |
| 1068 | {shows("access") && ( |
| 1069 | <SidebarLink to={`${base}/settings/access`} icon={<Users size={15} />}> |
| 1070 | Access |
| 1071 | </SidebarLink> |
| 1072 | )} |
| 1073 | {shows("keys") && ( |
| 1074 | <SidebarLink to={`${base}/settings/keys`} icon={<KeyRound size={15} />}> |
| 1075 | Deploy keys |
| 1076 | </SidebarLink> |
| 1077 | )} |
| 1078 | {shows("branches") && ( |
| 1079 | <SidebarLink to={`${base}/settings/branches`} icon={<GitBranch size={15} />}> |
| 1080 | Branches and merging |
| 1081 | </SidebarLink> |
| 1082 | )} |
| 1083 | {shows("rules") && ( |
| 1084 | <SidebarLink to={`${base}/settings/rules`} icon={<Scale size={15} />}> |
| 1085 | Rules |
| 1086 | </SidebarLink> |
| 1087 | )} |
| 1088 | |
| 1089 | {shows("actions") && ( |
| 1090 | <SidebarLink to={`${base}/settings/actions`} icon={<PlayCircle size={15} />}> |
| 1091 | Actions |
| 1092 | </SidebarLink> |
| 1093 | )} |
| 1094 | </NavRows> |
| 1095 | </nav> |
| 1096 | ); |
| 1097 | } |
| 1098 | |
| 1099 | /** Your own settings, drilled into from Your settings: one page each. */ |
| 1100 | function AccountSettingsMenu({ username }: { username: string }) { |
| 1101 | const link = (page: AccountSettingsPage, icon: ReactNode) => ( |
| 1102 | <SidebarLink to={`/settings/${page}`} icon={icon}> |
| 1103 | {ACCOUNT_SETTINGS[page].title} |
| 1104 | </SidebarLink> |
| 1105 | ); |
| 1106 | return ( |
| 1107 | <nav aria-label="Your settings" className={PANEL}> |
| 1108 | <BackRow to="/" label="Your settings" context={username} /> |
| 1109 | <NavRows className="mt-2"> |
| 1110 | {link("profile", <CircleUserRound size={15} />)} |
| 1111 | {link("emails", <Mail size={15} />)} |
| 1112 | {link("invites", <Ticket size={15} />)} |
| 1113 | </NavRows> |
| 1114 | <Rule /> |
| 1115 | <NavRows> |
| 1116 | {link("keys", <Fingerprint size={15} />)} |
| 1117 | {link("tokens", <KeyRound size={15} />)} |
| 1118 | </NavRows> |
| 1119 | <Rule /> |
| 1120 | <NavRows> |
| 1121 | {link("integrations", <Blocks size={15} />)} |
| 1122 | {link("github", <GithubMark className="size-3.75" />)} |
| 1123 | {link("applications", <Plug size={15} />)} |
| 1124 | </NavRows> |
| 1125 | <Rule /> |
| 1126 | <NavRows> |
| 1127 | {link("two-factor", <ShieldCheck size={15} />)} |
| 1128 | {link("security-log", <History size={15} />)} |
| 1129 | </NavRows> |
| 1130 | </nav> |
| 1131 | ); |
| 1132 | } |
| 1133 | |
| 1134 | /** |
| 1135 | * Code's sidebar: its Overview, the repositories, what spans them and what |
| 1136 | * is coming; in a repository, drilled into the repository's own list, and |
| 1137 | * its settings one level further in. The address says which, so a link |
| 1138 | * straight to a repository opens the sidebar already drilled in. |
| 1139 | */ |
| 1140 | function CodeSidebar({ |
| 1141 | user, |
| 1142 | shell, |
| 1143 | missing = false, |
| 1144 | browsing = null, |
| 1145 | }: { |
| 1146 | user: User; |
| 1147 | shell: ShellData; |
| 1148 | /** The page is a 404: the address names nothing the viewer can see. */ |
| 1149 | missing?: boolean; |
| 1150 | /** g1t beyond the viewer's workspaces (lib/workspace-nav.ts `browsingOf`): Explore's list in place of Code's. */ |
| 1151 | browsing?: { owner: BrowsedOwner | null } | null; |
| 1152 | }) { |
| 1153 | const ws = shell.workspace; |
| 1154 | const location = useLocation(); |
| 1155 | const destination = useSidebarLocation(); |
| 1156 | // Where the sidebar is drilled to follows the page being gone to, so it |
| 1157 | // moves as the link is followed, not once the page arrives. |
| 1158 | const target = destination.pathname; |
| 1159 | // On a 404, the address is not a place: no list is drilled into from it, |
| 1160 | // except the repository's own when the repository is real and visible and only |
| 1161 | // something inside it is missing (a file, a commit, a work item). |
| 1162 | const lost = missing && destination.key === location.key; |
| 1163 | const active = shell.repo; |
| 1164 | // In a repository, or on the way into one, its own list. |
| 1165 | const repoPath = /^\/([^/]+)\/([^/-][^/]*)(\/|$)/.exec(target); |
| 1166 | const reserved = new Set([ |
| 1167 | "settings", |
| 1168 | "explore", |
| 1169 | "search", |
| 1170 | "new", |
| 1171 | "u", |
| 1172 | "pricing", |
| 1173 | "avatars", |
| 1174 | "workspaces", |
| 1175 | "login", |
| 1176 | "logout", |
| 1177 | "register", |
| 1178 | "verify", |
| 1179 | "forgot", |
| 1180 | "reset", |
| 1181 | "device", |
| 1182 | "oauth", |
| 1183 | "policies", |
| 1184 | "security", |
| 1185 | "support", |
| 1186 | "status", |
| 1187 | "invite", |
| 1188 | "notifications", |
| 1189 | "inbox", |
| 1190 | ".well-known", |
| 1191 | ]); |
| 1192 | // An invitation to a repository is answered before its menu means anything. |
| 1193 | const inRepo = |
| 1194 | repoPath != null && |
| 1195 | !reserved.has(repoPath[1]) && |
| 1196 | repoPath[2] !== "-" && |
| 1197 | !/^\/[^/]+\/[^/]+\/invitations\/?$/.test(target) && |
| 1198 | (!lost || sameRepo(active, { namespace: repoPath[1]!, name: repoPath[2]! })); |
| 1199 | // A repository's settings, one level further in. |
| 1200 | const inRepoSettings = inRepo && REPO_SETTINGS_PAGE.test(target) && projectPageAt(target.split("/").slice(3).join("/")) === "settings"; |
| 1201 | // The repository the menu is for: the one loaded if it is the one being |
| 1202 | // gone to, else what the address says, at once. |
| 1203 | const targetRepo = inRepo && repoPath ? { namespace: repoPath[1], name: repoPath[2] } : null; |
| 1204 | const guessed: MenuRepo | null = targetRepo |
| 1205 | ? sameRepo(active, targetRepo) |
| 1206 | ? active |
| 1207 | : { |
| 1208 | ...targetRepo, |
| 1209 | member: (user.workspaces ?? []).some((m) => m.slug === targetRepo.namespace.toLowerCase()), |
| 1210 | } |
| 1211 | : null; |
| 1212 | // The viewer's role there, from the repository's page once it has loaded. |
| 1213 | const page = useRouteLoaderData("routes/repo/layout") as { repo?: { namespace: string; name: string }; access?: ViewerAccess } | undefined; |
| 1214 | const menuRepo: MenuRepo | null = |
| 1215 | guessed && page?.access && page.repo && sameRepo(page.repo, guessed) |
| 1216 | ? { ...guessed, member: page.access.insider, settings: seesSettings(page.access), can: page.access.can } |
| 1217 | : guessed; |
| 1218 | |
| 1219 | // The way from Code's list (or Explore's, browsing) to the one shown. |
| 1220 | const main = browsing ? <BrowseMenu user={user} owner={browsing.owner} /> : ws ? <CodeMenu shell={shell} slug={ws.slug} /> : null; |
| 1221 | const trail: Level[] = [{ key: browsing ? "browse" : "main", node: main }]; |
| 1222 | if (menuRepo) { |
| 1223 | const key = `repo:${menuRepo.namespace}/${menuRepo.name}`.toLowerCase(); |
| 1224 | // Out of a repository: to its workspace's repositories when they are |
| 1225 | // yours, or when browsing, to the repositories of the workspace it is in. |
| 1226 | const home = (user.workspaces ?? []).find((m) => m.slug === menuRepo.namespace.toLowerCase()); |
| 1227 | const back = home |
| 1228 | ? { to: repositoriesPath(home.slug), label: "All repositories" } |
| 1229 | : browsing |
| 1230 | ? { to: repositoriesPath(menuRepo.namespace), label: "Repositories", context: menuRepo.namespace } |
| 1231 | : { to: ws ? `/${ws.slug}` : "/explore", label: "Code" }; |
| 1232 | trail.push({ |
| 1233 | key, |
| 1234 | node: ( |
| 1235 | <RepoMenu |
| 1236 | repo={menuRepo} |
| 1237 | isPrivate={[...shell.repos, ...(shell.shared ?? [])].some((repo) => sameRepo(repo, menuRepo) && repo.isPrivate)} |
| 1238 | back={back} |
| 1239 | /> |
| 1240 | ), |
| 1241 | }); |
| 1242 | if (inRepoSettings) trail.push({ key: `${key}:settings`, node: <RepoSettingsMenu repo={menuRepo} /> }); |
| 1243 | } |
| 1244 | |
| 1245 | return ( |
| 1246 | <div className="flex h-full flex-col"> |
| 1247 | {browsing && ws && ( |
| 1248 | <> |
| 1249 | <BackToWorkspace workspace={ws} /> |
| 1250 | <Rule /> |
| 1251 | </> |
| 1252 | )} |
| 1253 | <ModeHeader title={browsing?.owner?.kind === "workspace" ? "Workspace" : browsing ? "Explore" : "Code"} tabbed={!browsing} /> |
| 1254 | <Drill trail={trail} /> |
| 1255 | </div> |
| 1256 | ); |
| 1257 | } |
| 1258 | |
| 1259 | /** |
| 1260 | * Browsing, the way back to the workspace you are in: its Home, at the top |
| 1261 | * of Explore's sidebar, at every level of it. |
| 1262 | */ |
| 1263 | function BackToWorkspace({ workspace }: { workspace: Membership }) { |
| 1264 | const name = displayName(workspace); |
| 1265 | return ( |
| 1266 | <NavRows className="pt-0"> |
| 1267 | <SidebarMenuItem> |
| 1268 | <SidebarMenuButton asChild className="gap-2 pl-1 font-medium text-fg"> |
| 1269 | <Link to={homePagePath(workspace.slug)} prefetch="intent" aria-label={`Back to ${name}: Home`}> |
| 1270 | <ChevronLeft className="text-faint transition-transform group-hover/menu-button:-translate-x-0.5 group-hover/menu-button:text-muted" /> |
| 1271 | <Avatar name={workspace.slug} image={workspace.avatar} size={18} square /> |
| 1272 | <span className="min-w-0 truncate">Back to {name}</span> |
| 1273 | </Link> |
| 1274 | </SidebarMenuButton> |
| 1275 | </SidebarMenuItem> |
| 1276 | </NavRows> |
| 1277 | ); |
| 1278 | } |
| 1279 | |
| 1280 | /** Whose page is being browsed, as the page itself names them once it has loaded: a workspace's or a person's. */ |
| 1281 | function useBrowsedOwner(owner: BrowsedOwner | null): { name: string; avatar: string | null } | null { |
| 1282 | const workspace = useRouteLoaderData("routes/workspace/layout") as { workspace?: { slug: string; name?: string | null; avatar?: string | null } } | undefined; |
| 1283 | const person = useRouteLoaderData("routes/user") as { profile?: { username: string; displayUsername?: string | null; avatar: string | null } } | undefined; |
| 1284 | if (!owner) return null; |
| 1285 | if (owner.kind === "person") { |
| 1286 | const profile = person?.profile?.username.toLowerCase() === owner.name.toLowerCase() ? person.profile : null; |
| 1287 | return { name: `@${profile ? shownUsername(profile) : owner.name}`, avatar: profile?.avatar ?? null }; |
| 1288 | } |
| 1289 | const page = workspace?.workspace?.slug.toLowerCase() === owner.name.toLowerCase() ? workspace.workspace : null; |
| 1290 | return { name: page?.name?.trim() || owner.name, avatar: page?.avatar ?? null }; |
| 1291 | } |
| 1292 | |
| 1293 | /** |
| 1294 | * Explore's list, beside g1t beyond your workspaces (lib/workspace-nav.ts |
| 1295 | * `browsingOf`): public repositories across g1t, recently active and new, |
| 1296 | * search, and what you starred; then whose page this is, with their own |
| 1297 | * pages. A repository of theirs drills into its own list, as in Code. |
| 1298 | */ |
| 1299 | function BrowseMenu({ user, owner }: { user: User; owner: BrowsedOwner | null }) { |
| 1300 | const location = useSidebarLocation(); |
| 1301 | const path = pagePath(location.pathname); |
| 1302 | const query = new URLSearchParams(location.search); |
| 1303 | const atExplore = path === "/explore"; |
| 1304 | const fresh = atExplore && query.get("sort") === "new"; |
| 1305 | const mine = profilePath(user.username); |
| 1306 | // A profile's tab is its third segment: `/u/ada/stars`. |
| 1307 | const [, profileOf, profileTab] = path.toLowerCase().split("/").filter(Boolean); |
| 1308 | const atMine = path.toLowerCase().startsWith("/u/") && profileOf === user.username.toLowerCase(); |
| 1309 | const stars = atMine && profileTab === "stars"; |
| 1310 | const shown = useBrowsedOwner(owner); |
| 1311 | const workspace = useRouteLoaderData("routes/workspace/layout") as |
| 1312 | | { workspace: { slug: string }; projects?: (ShortcutProject & { title: string })[] } |
| 1313 | | undefined; |
| 1314 | if (owner?.kind === "workspace" && shown) { |
| 1315 | const base = `/${owner.name}`; |
| 1316 | const projects = workspace?.workspace.slug.toLowerCase() === owner.name.toLowerCase() ? (workspace.projects ?? []) : []; |
| 1317 | return ( |
| 1318 | <nav aria-label={`${owner.name} workspace`} className={PANEL}> |
| 1319 | <NavRows> |
| 1320 | <SidebarLink to={base} icon={<Avatar name={owner.name} image={shown.avatar} size={16} square />} current={false}> |
| 1321 | {shown.name} |
| 1322 | </SidebarLink> |
| 1323 | <SidebarLink to={base} icon={<LayoutDashboard size={15} />} current={path.toLowerCase() === base.toLowerCase()}> |
| 1324 | Overview |
| 1325 | </SidebarLink> |
| 1326 | </NavRows> |
| 1327 | <Rule /> |
| 1328 | <NavGroup title="Repositories"> |
| 1329 | {projects.slice(0, 8).map((project) => ( |
| 1330 | <SidebarLink |
| 1331 | key={project.name} |
| 1332 | to={`/${project.namespace}/${project.name}`} |
| 1333 | icon={<ProjectIcon kind={project.kind} isPrivate={project.isPrivate} lockPrivate size={15} />} |
| 1334 | > |
| 1335 | {project.title} |
| 1336 | </SidebarLink> |
| 1337 | ))} |
| 1338 | <SidebarLink to={repositoriesPath(owner.name)} icon={<LayoutGrid size={15} />} count={projects.length || undefined}> |
| 1339 | All repositories |
| 1340 | </SidebarLink> |
| 1341 | </NavGroup> |
| 1342 | <Rule /> |
| 1343 | <NavRows> |
| 1344 | <SidebarLink to={`${base}/-/packages`} icon={<Package size={15} />}> |
| 1345 | Packages |
| 1346 | </SidebarLink> |
| 1347 | </NavRows> |
| 1348 | <Rule /> |
| 1349 | <NavRows> |
| 1350 | <SidebarLink to="/explore" icon={<Compass size={15} />}> |
| 1351 | Explore |
| 1352 | </SidebarLink> |
| 1353 | </NavRows> |
| 1354 | </nav> |
| 1355 | ); |
| 1356 | } |
| 1357 | return ( |
| 1358 | <nav aria-label="Explore" className={PANEL}> |
| 1359 | <NavRows> |
| 1360 | <SidebarLink to="/explore" icon={<Compass size={15} />} current={atExplore && !fresh}> |
| 1361 | Recently active |
| 1362 | </SidebarLink> |
| 1363 | <SidebarLink to="/explore?sort=new" icon={<Sparkles size={15} />} current={fresh}> |
| 1364 | New repositories |
| 1365 | </SidebarLink> |
| 1366 | <SidebarLink to="/search" icon={<Search size={15} />} current={path === "/search"}> |
| 1367 | Search |
| 1368 | </SidebarLink> |
| 1369 | </NavRows> |
| 1370 | <Rule /> |
| 1371 | <NavRows> |
| 1372 | <SidebarLink to={profilePath(user.username, "stars")} icon={<Star size={15} />} current={stars}> |
| 1373 | Your stars |
| 1374 | </SidebarLink> |
| 1375 | <SidebarLink to={mine} icon={<CircleUserRound size={15} />} current={atMine && !stars}> |
| 1376 | Your profile |
| 1377 | </SidebarLink> |
| 1378 | </NavRows> |
| 1379 | {owner && shown && ( |
| 1380 | <> |
| 1381 | <Rule /> |
| 1382 | <NavGroup title="You are viewing"> |
| 1383 | {owner.kind === "person" && ( |
| 1384 | <> |
| 1385 | <SidebarLink to={`/u/${owner.name}`} icon={<Avatar name={owner.name} image={shown.avatar} size={16} />} current={profileTab !== "stars"}> |
| 1386 | {shown.name} |
| 1387 | </SidebarLink> |
| 1388 | <SidebarLink to={profilePath(owner.name, "stars")} icon={<Star size={15} />} current={profileTab === "stars"}> |
| 1389 | Stars |
| 1390 | </SidebarLink> |
| 1391 | </> |
| 1392 | )} |
| 1393 | </NavGroup> |
| 1394 | </> |
| 1395 | )} |
| 1396 | </nav> |
| 1397 | ); |
| 1398 | } |
| 1399 | |
| 1400 | /** |
| 1401 | * A mode's sidebar heading: its name, quiet, with what it makes at the end. |
| 1402 | * A mode that is a tab of the app bar (`tabbed`: Work, Code) has its name |
| 1403 | * lit there already, so beside the page it says nothing; in the drawer, |
| 1404 | * which covers the bar, it still does. |
| 1405 | */ |
| 1406 | export function ModeHeader({ title, action, tabbed = false }: { title: ReactNode; action?: ReactNode; tabbed?: boolean }) { |
| 1407 | return ( |
| 1408 | <div className={cn("flex h-8 shrink-0 items-center gap-1 pr-2 pl-3", tabbed && !action && "[[data-slot=app-bar]~[data-slot=sidebar]_&]:hidden")}> |
| 1409 | <SidebarGroupLabel asChild className="min-w-0 grow truncate px-1"> |
| 1410 | <h2>{title}</h2> |
| 1411 | </SidebarGroupLabel> |
| 1412 | {action} |
| 1413 | </div> |
| 1414 | ); |
| 1415 | } |
| 1416 | |
| 1417 | /** |
| 1418 | * Code's own list (beside the rail): only code. Its Overview (Mission |
| 1419 | * control's code panels), the repositories, what spans them, and what is |
| 1420 | * coming. The workspace's people, money and settings are elsewhere. |
| 1421 | */ |
| 1422 | function CodeMenu({ shell, slug }: { shell: ShellData; slug: string }) { |
| 1423 | const { pathname: path } = useSidebarLocation(); |
| 1424 | const at = (page: string) => path === `/${slug}/-/${page}` || path.startsWith(`/${slug}/-/${page}/`); |
| 1425 | const shared = shell.shared ?? []; |
| 1426 | return ( |
| 1427 | <nav aria-label="Code" className={PANEL}> |
| 1428 | <NavRows> |
| 1429 | <SidebarLink to={`/${slug}`} icon={<LayoutDashboard size={15} />} current={path === `/${slug}`}> |
| 1430 | Overview |
| 1431 | </SidebarLink> |
| 1432 | </NavRows> |
| 1433 | <Rule /> |
| 1434 | <SidebarProjects slug={slug} shell={shell} current={at("code/repositories")} /> |
| 1435 | <Rule /> |
| 1436 | <NavRows> |
| 1437 | <SidebarLink to={`/${slug}/-/security`} icon={<ShieldCheck size={15} />} current={at("security") && !at("security/settings")}> |
| 1438 | Security |
| 1439 | </SidebarLink> |
| 1440 | <SidebarLink to={`/${slug}/-/packages`} icon={<Package size={15} />} current={at("packages")}> |
| 1441 | Packages |
| 1442 | </SidebarLink> |
| 1443 | <SidebarLink to={`/${slug}/-/insights`} icon={<TrendingUp size={15} />} current={at("insights")}> |
| 1444 | Insights |
| 1445 | </SidebarLink> |
| 1446 | </NavRows> |
| 1447 | <Rule /> |
| 1448 | <NavRows> |
| 1449 | {/* Public repositories beyond this workspace's own. */} |
| 1450 | <SidebarLink to="/explore" icon={<Compass size={15} />} current={path === "/explore" || path.startsWith("/explore/")}> |
| 1451 | Explore |
| 1452 | </SidebarLink> |
| 1453 | </NavRows> |
| 1454 | {shared.length > 0 && ( |
| 1455 | <> |
| 1456 | <Rule /> |
| 1457 | <NavGroup title="Shared with you"> |
| 1458 | {shared.map((repo) => ( |
| 1459 | <SidebarLink |
| 1460 | key={`${repo.namespace}/${repo.name}`} |
| 1461 | to={`/${repo.namespace}/${repo.name}`} |
| 1462 | icon={repo.isPrivate ? <Lock size={15} /> : <Box size={15} />} |
| 1463 | drill="hover" |
| 1464 | > |
| 1465 | <span className="font-mono text-faint">{repo.namespace}/</span> |
| 1466 | {repo.name} |
| 1467 | </SidebarLink> |
| 1468 | ))} |
| 1469 | </NavGroup> |
| 1470 | </> |
| 1471 | )} |
| 1472 | </nav> |
| 1473 | ); |
| 1474 | } |
| 1475 | |
| 1476 | /** The icon of each of Workspace mode's pages, by its key (lib/workspace-nav.ts). */ |
| 1477 | const WORKSPACE_PAGE_ICONS: Record<string, ReactNode> = { |
| 1478 | profile: <Building2 size={15} />, |
| 1479 | authentication: <Fingerprint size={15} />, |
| 1480 | sso: <KeyRound size={15} />, |
| 1481 | members: <Users size={15} />, |
| 1482 | invitations: <Mail size={15} />, |
| 1483 | permissions: <ShieldCheck size={15} />, |
| 1484 | tokens: <KeyRound size={15} />, |
| 1485 | "personal-access-tokens": <UserRoundKey size={15} />, |
| 1486 | usage: <BarChart3 size={15} />, |
| 1487 | billing: <CreditCard size={15} />, |
| 1488 | "provider-costs": <ReceiptText size={15} />, |
| 1489 | runners: <ServerCog size={15} />, |
| 1490 | repositories: <BookMarked size={15} />, |
| 1491 | actions: <PlayCircle size={15} />, |
| 1492 | secrets: <Lock size={15} />, |
| 1493 | webhooks: <Webhook size={15} />, |
| 1494 | "chat-settings": <MessagesSquare size={15} />, |
| 1495 | "work-settings": <Timer size={15} />, |
| 1496 | emoji: <Smile size={15} />, |
| 1497 | "security-settings": <Shield size={15} />, |
| 1498 | guardrails: <Gauge size={15} />, |
| 1499 | rules: <Scale size={15} />, |
| 1500 | audit: <History size={15} />, |
| 1501 | integrations: <Plug size={15} />, |
| 1502 | ai: <Sparkles size={15} />, |
| 1503 | }; |
| 1504 | |
| 1505 | /** |
| 1506 | * The Workspace mode's sidebar: the workspace's settings, laid out as |
| 1507 | * GitHub lays out an organization's (lib/workspace-nav.ts). The Profile on |
| 1508 | * top, then each section under its heading, between rules. A page with |
| 1509 | * pages of its own (Billing's Usage, Members' Invitations) shows them |
| 1510 | * under it while you are on it or one of them. Pages only owners open are |
| 1511 | * left out for members, and a section with none left goes with them. |
| 1512 | */ |
| 1513 | export function WorkspaceSidebar({ slug, owner }: { slug: string; owner: boolean }) { |
| 1514 | const { pathname: path } = useSidebarLocation(); |
| 1515 | const here = workspaceSettingsPage(path, slug); |
| 1516 | const shown = (page: WorkspaceSettingsPage) => owner || !page.owner; |
| 1517 | const rows = (pages: readonly WorkspaceSettingsPage[]) => |
| 1518 | pages.filter(shown).map((page) => { |
| 1519 | const subpages = (page.pages ?? []).filter(shown); |
| 1520 | const open = subpages.length > 0 && here?.parent === page.key; |
| 1521 | return ( |
| 1522 | <SidebarMenuItem key={page.key}> |
| 1523 | <SidebarLink |
| 1524 | bare |
| 1525 | to={`/${slug}/-/${page.path}`} |
| 1526 | icon={WORKSPACE_PAGE_ICONS[page.key] ?? <Settings size={15} />} |
| 1527 | current={here?.page === page.key} |
| 1528 | drill={subpages.length > 0 && !open ? "hover" : undefined} |
| 1529 | > |
| 1530 | {page.label} |
| 1531 | </SidebarLink> |
| 1532 | {open && ( |
| 1533 | <SidebarMenuSub> |
| 1534 | {subpages.map((sub) => ( |
| 1535 | <SidebarMenuSubItem key={sub.key}> |
| 1536 | <SidebarMenuSubButton asChild isActive={here?.page === sub.key}> |
| 1537 | <Link to={`/${slug}/-/${sub.path}`} prefetch="intent" aria-current={here?.page === sub.key ? "page" : undefined}> |
| 1538 | <span>{sub.label}</span> |
| 1539 | </Link> |
| 1540 | </SidebarMenuSubButton> |
| 1541 | </SidebarMenuSubItem> |
| 1542 | ))} |
| 1543 | </SidebarMenuSub> |
| 1544 | )} |
| 1545 | </SidebarMenuItem> |
| 1546 | ); |
| 1547 | }); |
| 1548 | return ( |
| 1549 | <div className="flex h-full flex-col"> |
| 1550 | <ModeHeader title="Workspace" /> |
| 1551 | <nav aria-label="Workspace settings" className={PANEL}> |
| 1552 | {WORKSPACE_SETTINGS_GROUPS.filter((group) => group.pages.some(shown)).map((group) => |
| 1553 | group.title == null ? ( |
| 1554 | <NavRows key={group.key}>{rows(group.pages)}</NavRows> |
| 1555 | ) : ( |
| 1556 | <Fragment key={group.key}> |
| 1557 | <Rule /> |
| 1558 | <NavGroup title={group.title}>{rows(group.pages)}</NavGroup> |
| 1559 | </Fragment> |
| 1560 | ), |
| 1561 | )} |
| 1562 | </nav> |
| 1563 | </div> |
| 1564 | ); |
| 1565 | } |
| 1566 | /** |
| 1567 | * Work's sidebar, and its menu on a phone (the Work tab tapped again): a new |
| 1568 | * work item first, then its work items: the plan, the board of them as they |
| 1569 | * move, Triage, the views of all of them people keep coming back to, saved |
| 1570 | * in the address (open, yours, closed), and the workspace's labels and |
| 1571 | * milestones; then its live projects. Sessions, the work agents do on |
| 1572 | * them, are Chat's. |
| 1573 | */ |
| 1574 | function WorkSidebar({ slug, username }: { slug: string; username: string }) { |
| 1575 | const { pathname: path, search } = useSidebarLocation(); |
| 1576 | const list = workPath(slug, "all"); |
| 1577 | const query = new URLSearchParams(search); |
| 1578 | const viewOf = (page: string) => (want: Record<string, string>) => |
| 1579 | path === page && [...query.keys()].every((key) => key in want) && Object.entries(want).every(([key, value]) => query.get(key) === value); |
| 1580 | const view = viewOf(list); |
| 1581 | const at = (page: string) => path === workPath(slug, page) || path.startsWith(`${workPath(slug, page)}/`); |
| 1582 | return ( |
| 1583 | <div className="flex h-full flex-col"> |
| 1584 | <ModeHeader title="Work" tabbed /> |
| 1585 | <nav aria-label="Work" className={PANEL}> |
| 1586 | <NavRows> |
| 1587 | <PrimaryLink to={newItemPath(slug)} icon={<Plus size={15} />} current={at("new")}> |
| 1588 | New work item |
| 1589 | </PrimaryLink> |
| 1590 | </NavRows> |
| 1591 | {/* Room between what the sidebar makes and where it goes. */} |
| 1592 | <NavRows className="mt-3"> |
| 1593 | <SidebarLink to={workPath(slug)} icon={<ListOrdered size={15} />} current={path === workPath(slug)}> |
| 1594 | Plan |
| 1595 | </SidebarLink> |
| 1596 | <WorkCycleRow slug={slug} current={at("cycle") || at("cycles")} /> |
| 1597 | <SidebarLink to={workPath(slug, "board")} icon={<Columns3 size={15} />} current={at("board")}> |
| 1598 | Board |
| 1599 | </SidebarLink> |
| 1600 | <SidebarLink to={workPath(slug, "triage")} icon={<Inbox size={15} />} current={at("triage")}> |
| 1601 | Triage |
| 1602 | </SidebarLink> |
| 1603 | <SidebarLink to={list} icon={<ListTodo size={15} />} current={view({})}> |
| 1604 | All open |
| 1605 | </SidebarLink> |
| 1606 | <SidebarLink to={`${list}?assignee=${encodeURIComponent(username)}`} icon={<CircleUserRound size={15} />} current={view({ assignee: username })}> |
| 1607 | Assigned to you |
| 1608 | </SidebarLink> |
| 1609 | <SidebarLink to={`${list}?state=closed`} icon={<Check size={15} />} current={view({ state: "closed" })}> |
| 1610 | Closed |
| 1611 | </SidebarLink> |
| 1612 | <SidebarLink to={workPath(slug, "labels")} icon={<Ticket size={15} />} current={at("labels")}> |
| 1613 | Labels |
| 1614 | </SidebarLink> |
| 1615 | <SidebarLink to={workPath(slug, "milestones")} icon={<Flag size={15} />} current={at("milestones")}> |
| 1616 | Milestones |
| 1617 | </SidebarLink> |
| 1618 | </NavRows> |
| 1619 | <Rule /> |
| 1620 | <WorkProjectsGroup slug={slug} /> |
| 1621 | </nav> |
| 1622 | </div> |
| 1623 | ); |
| 1624 | } |
| 1625 | |
| 1626 | /** |
| 1627 | * Work's current cycle, while the workspace has cycles on: "Cycle 14", |
| 1628 | * the days left and how much is done, to its page. Read by the sidebar |
| 1629 | * itself (routes/workspace/work/cycle-json.ts), once per workspace and |
| 1630 | * again after any form on the site is sent; left out while cycles are off |
| 1631 | * or when it could not be read. |
| 1632 | */ |
| 1633 | function WorkCycleRow({ slug, current }: { slug: string; current: boolean }) { |
| 1634 | const fetcher = useFetcher<{ cycle: SidebarCycle | null }>({ key: `work-cycle:${slug}` }); |
| 1635 | useEffect(() => { |
| 1636 | if (fetcher.state === "idle" && fetcher.data === undefined) fetcher.load(cycleJsonPath(slug)); |
| 1637 | // eslint-disable-next-line react-hooks/exhaustive-deps |
| 1638 | }, [slug]); |
| 1639 | const cycle = fetcher.data?.cycle; |
| 1640 | if (!cycle) return null; |
| 1641 | return ( |
| 1642 | <SidebarLink to={cyclePath(slug)} icon={<Timer size={15} />} current={current}> |
| 1643 | <span className="flex min-w-0 items-center gap-2"> |
| 1644 | <span className="shrink-0">Cycle {cycle.number}</span> |
| 1645 | <span className="min-w-0 truncate text-xs text-faint">{cycle.left}</span> |
| 1646 | <span className="ml-auto shrink-0 font-mono text-xs tabular-nums text-muted" aria-label={`${cycle.done} of ${cycle.scope} done`}> |
| 1647 | {cycle.done}/{cycle.scope} |
| 1648 | </span> |
| 1649 | </span> |
| 1650 | </SidebarLink> |
| 1651 | ); |
| 1652 | } |
| 1653 | |
| 1654 | /** A project as Work's sidebar lists it (routes/workspace/work/projects-json.ts). */ |
| 1655 | type SidebarProject = Pick<WorkProject, "slug" | "name" | "color" | "icon" | "health" | "state">; |
| 1656 | |
| 1657 | /** |
| 1658 | * Work's Projects: the workspace's live projects, active first, each with |
| 1659 | * its health as a dot, then New project. Read by the sidebar itself, once |
| 1660 | * per workspace, and again after any form on the site is sent (a project |
| 1661 | * made or changed), so no page has to carry it. |
| 1662 | */ |
| 1663 | function WorkProjectsGroup({ slug }: { slug: string }) { |
| 1664 | const { pathname: path } = useSidebarLocation(); |
| 1665 | const fetcher = useFetcher<{ projects: SidebarProject[] | null }>({ key: `work-projects:${slug}` }); |
| 1666 | useEffect(() => { |
| 1667 | if (fetcher.state === "idle" && fetcher.data === undefined) fetcher.load(projectsJsonPath(slug)); |
| 1668 | // eslint-disable-next-line react-hooks/exhaustive-deps |
| 1669 | }, [slug]); |
| 1670 | const projects = fetcher.data?.projects; |
| 1671 | const all = projectsPath(slug); |
| 1672 | return ( |
| 1673 | <NavGroup |
| 1674 | title="Projects" |
| 1675 | action={ |
| 1676 | <Hint label="New project"> |
| 1677 | <SidebarGroupAction asChild className="relative top-auto right-auto"> |
| 1678 | <Link to={projectsPath(slug, "new=1")} aria-label="New project"> |
| 1679 | <Plus /> |
| 1680 | </Link> |
| 1681 | </SidebarGroupAction> |
| 1682 | </Hint> |
| 1683 | } |
| 1684 | > |
| 1685 | <SidebarLink to={all} end icon={<FolderKanban size={15} />} current={path === all}> |
| 1686 | All projects |
| 1687 | </SidebarLink> |
| 1688 | {projects === undefined ? ( |
| 1689 | <SidebarMenuItem> |
| 1690 | <Skeleton className="mx-2 my-1.5 h-4 w-28" /> |
| 1691 | </SidebarMenuItem> |
| 1692 | ) : projects === null ? ( |
| 1693 | <SidebarMenuItem> |
| 1694 | <p className="px-2 py-1.5 text-xs text-faint">Projects could not be loaded just now.</p> |
| 1695 | </SidebarMenuItem> |
| 1696 | ) : ( |
| 1697 | projects.map((project) => ( |
| 1698 | <SidebarLink |
| 1699 | key={project.slug} |
| 1700 | to={projectPath(slug, project.slug)} |
| 1701 | icon={<ProjectMark project={project} size={16} className="rounded text-[0.625rem]" />} |
| 1702 | > |
| 1703 | <span className="flex min-w-0 items-center justify-between gap-2"> |
| 1704 | <span className="truncate">{project.name}</span> |
| 1705 | <span className="mr-0.5 inline-flex" aria-label={project.health ? HEALTH_LABELS[project.health] : undefined}> |
| 1706 | <HealthDot health={project.health} className={project.health ? undefined : "opacity-0"} /> |
| 1707 | </span> |
| 1708 | </span> |
| 1709 | </SidebarLink> |
| 1710 | )) |
| 1711 | )} |
| 1712 | <SidebarLink to={projectsPath(slug, "new=1")} icon={<Plus size={15} />} current={false}> |
| 1713 | <span className="text-muted">New project</span> |
| 1714 | </SidebarLink> |
| 1715 | </NavGroup> |
| 1716 | ); |
| 1717 | } |
| 1718 | |
| 1719 | /** |
| 1720 | * People's sidebar: everyone in the workspace and its teams. Who belongs, and |
| 1721 | * inviting people, is the workspace's: Workspace mode's Access group. |
| 1722 | */ |
| 1723 | function PeopleSidebar({ slug }: { slug: string }) { |
| 1724 | const { pathname: path } = useSidebarLocation(); |
| 1725 | const at = (page: string) => path === `/${slug}/-/${page}` || path.startsWith(`/${slug}/-/${page}/`); |
| 1726 | return ( |
| 1727 | <div className="flex h-full flex-col"> |
| 1728 | <ModeHeader title="People" /> |
| 1729 | <nav aria-label="People" className={PANEL}> |
| 1730 | <NavRows> |
| 1731 | <SidebarLink to={`/${slug}/-/people`} icon={<Users size={15} />} current={at("people")}> |
| 1732 | Everyone |
| 1733 | </SidebarLink> |
| 1734 | <SidebarLink to={`/${slug}/-/teams`} icon={<UsersRound size={15} />} current={at("teams")}> |
| 1735 | Teams |
| 1736 | </SidebarLink> |
| 1737 | </NavRows> |
| 1738 | </nav> |
| 1739 | </div> |
| 1740 | ); |
| 1741 | } |
| 1742 | |
| 1743 | /** |
| 1744 | * Notifications' sidebar: what you have not finished with, what you saved |
| 1745 | * and what is done; then why you were told, which narrows any of them. |
| 1746 | */ |
| 1747 | function NotificationsSidebar({ counts }: { counts: InboxCounts | null }) { |
| 1748 | const { pathname, search } = useSidebarLocation(); |
| 1749 | const params = new URLSearchParams(search); |
| 1750 | // Saved and Done are paths (`/notifications/saved`); the rest narrows them. |
| 1751 | const view = notificationViewAt(pathname); |
| 1752 | const reason = inboxReason(params.get("reason")); |
| 1753 | const tab = params.get("tab"); |
| 1754 | const address = (changes: { view?: string | null; reason?: string | null; before?: string | null }) => |
| 1755 | notificationsAddress({ tab, view, reason, ...changes }); |
| 1756 | const views = [ |
| 1757 | { view: "inbox", label: "Everything", icon: <Bell size={15} />, count: counts?.unread }, |
| 1758 | { view: "saved", label: "Saved", icon: <Bookmark size={15} /> }, |
| 1759 | { view: "done", label: "Done", icon: <Check size={15} /> }, |
| 1760 | ] as const; |
| 1761 | return ( |
| 1762 | <div className="flex h-full flex-col"> |
| 1763 | <ModeHeader title="Notifications" /> |
| 1764 | <nav aria-label="Notifications" className={PANEL}> |
| 1765 | <NavRows> |
| 1766 | {views.map((entry) => ( |
| 1767 | <SidebarLink |
| 1768 | key={entry.view} |
| 1769 | to={address({ view: entry.view === "inbox" ? null : entry.view, before: null })} |
| 1770 | icon={entry.icon} |
| 1771 | count={"count" in entry ? (entry.count ?? undefined) : undefined} |
| 1772 | current={view === entry.view} |
| 1773 | > |
| 1774 | {entry.label} |
| 1775 | </SidebarLink> |
| 1776 | ))} |
| 1777 | </NavRows> |
| 1778 | <Rule /> |
| 1779 | <NavGroup title="Why you were told"> |
| 1780 | {REASON_FILTERS.map((entry) => ( |
| 1781 | <SidebarLink |
| 1782 | key={entry.reason ?? "any"} |
| 1783 | to={address({ reason: entry.reason, before: null })} |
| 1784 | icon={<span className="block size-3.75" />} |
| 1785 | current={reason === entry.reason} |
| 1786 | > |
| 1787 | {entry.reason == null ? "Any reason" : entry.label} |
| 1788 | </SidebarLink> |
| 1789 | ))} |
| 1790 | </NavGroup> |
| 1791 | </nav> |
| 1792 | </div> |
| 1793 | ); |
| 1794 | } |
| 1795 | |
| 1796 | /** Your own settings, beside the page: under the account menu, not any workspace's. */ |
| 1797 | export function AccountSidebar({ username }: { username: string }) { |
| 1798 | return ( |
| 1799 | <div className="flex h-full flex-col"> |
| 1800 | <ModeHeader title="Your account" /> |
| 1801 | <div className="min-h-0 grow"> |
| 1802 | <AccountSettingsMenu username={username} /> |
| 1803 | </div> |
| 1804 | </div> |
| 1805 | ); |
| 1806 | } |
| 1807 | |
| 1808 | /** Words for the sections a path can end in. */ |
| 1809 | const SECTIONS: Record<string, string> = { |
| 1810 | milestones: "Milestones", |
| 1811 | labels: "Labels", |
| 1812 | pulls: "Pull requests", |
| 1813 | queue: "Merge queue", |
| 1814 | commits: "Commits", |
| 1815 | actions: "Workflows", |
| 1816 | soon: "Soon", |
| 1817 | deployments: "Deployments", |
| 1818 | repository: "Repository", |
| 1819 | access: "Access", |
| 1820 | invitations: "Invitation", |
| 1821 | repositories: "Repositories", |
| 1822 | branches: "Branches", |
| 1823 | tags: "Tags", |
| 1824 | compare: "Compare", |
| 1825 | code: "Files", |
| 1826 | secrets: "Secrets and variables", |
| 1827 | settings: "Settings", |
| 1828 | people: "People", |
| 1829 | members: "Members", |
| 1830 | permissions: "Permissions", |
| 1831 | teams: "Teams", |
| 1832 | tokens: "Access tokens", |
| 1833 | "personal-access-tokens": "Personal access tokens", |
| 1834 | usage: "Usage", |
| 1835 | billing: "Billing and plans", |
| 1836 | "provider-costs": "Provider costs", |
| 1837 | integrations: "Integrations", |
| 1838 | ai: "AI", |
| 1839 | webhooks: "Webhooks", |
| 1840 | emoji: "Emoji", |
| 1841 | domains: "Domains", |
| 1842 | guardrails: "Guardrails", |
| 1843 | rules: "Rules", |
| 1844 | audit: "Audit log", |
| 1845 | tree: "Files", |
| 1846 | blob: "Files", |
| 1847 | security: "Security", |
| 1848 | packages: "Packages", |
| 1849 | runners: "Runners", |
| 1850 | environments: "Environments", |
| 1851 | workflows: "Workflows", |
| 1852 | observability: "Observability", |
| 1853 | insights: "Insights", |
| 1854 | work: "Work", |
| 1855 | chat: "Chat", |
| 1856 | artifacts: "Artifacts", |
| 1857 | library: "Library", |
| 1858 | routines: "Routines", |
| 1859 | home: "Home", |
| 1860 | apps: "Apps", |
| 1861 | "code-access": "Code access", |
| 1862 | overview: "Overview", |
| 1863 | workspace: "Workspace", |
| 1864 | }; |
| 1865 | |
| 1866 | /** Settings pages whose name differs from the section's of the same word. */ |
| 1867 | const SETTINGS_SECTIONS: Record<string, string> = { branches: "Branches and merging", actions: "Actions" }; |
| 1868 | |
| 1869 | /** Where the page is, as a trail of links: workspace / repository / section. */ |
| 1870 | function Breadcrumbs({ |
| 1871 | pathname, |
| 1872 | missing, |
| 1873 | repo, |
| 1874 | workspace, |
| 1875 | }: { |
| 1876 | pathname: string; |
| 1877 | missing?: boolean; |
| 1878 | repo?: ShellData["repo"]; |
| 1879 | /** The workspace you are in: the switcher names it, so the trail starts after it. */ |
| 1880 | workspace: string | null; |
| 1881 | }) { |
| 1882 | // A 404 names nothing from the address, unless the repository is real and |
| 1883 | // visible and only something inside it is missing. |
| 1884 | const visibleRepo = /^\/([^/]+)\/([^/-][^/]*)(\/|$)/.exec(pathname); |
| 1885 | if (missing && !(visibleRepo && sameRepo(repo ?? null, { namespace: visibleRepo[1]!, name: visibleRepo[2]! }))) { |
| 1886 | return <Here>Not found</Here>; |
| 1887 | } |
| 1888 | const parts = pathname.split("/").filter(Boolean); |
| 1889 | const reserved = ["settings", "explore", "new", "search", "workspaces", "policies", "security", "support", "status", "invite", "notifications"]; |
| 1890 | if (parts.length === 0) return <Here>Home</Here>; |
| 1891 | // Your settings: Settings / Emails. |
| 1892 | if (parts[0] === "settings") { |
| 1893 | const page = accountSettingsPage(pathname); |
| 1894 | return ( |
| 1895 | <Trail trail={[{ label: "Settings", to: FIRST_SETTINGS_PAGE }, ...(page ? [{ label: ACCOUNT_SETTINGS[page].title, to: `/settings/${page}` }] : [])]} /> |
| 1896 | ); |
| 1897 | } |
| 1898 | if (reserved.includes(parts[0]!)) { |
| 1899 | const words: Record<string, string> = { |
| 1900 | settings: "Account", |
| 1901 | explore: "Explore", |
| 1902 | new: "New repository", |
| 1903 | search: "Search", |
| 1904 | workspaces: "New workspace", |
| 1905 | policies: "Policies", |
| 1906 | security: "Security", |
| 1907 | support: "Support", |
| 1908 | status: "Status", |
| 1909 | invite: "Invite", |
| 1910 | notifications: "Notifications", |
| 1911 | }; |
| 1912 | return <Here>{words[parts[0]!]}</Here>; |
| 1913 | } |
| 1914 | // A person's profile, by their handle. |
| 1915 | if (parts[0] === "u" && parts[1]) return <ProfileCrumb username={parts[1]} />; |
| 1916 | const [owner, second, third, fourth] = parts; |
| 1917 | const ours = workspace != null && owner!.toLowerCase() === workspace.toLowerCase(); |
| 1918 | const trail: Crumb[] = ours ? [] : [{ label: owner!, to: `/${owner}`, mono: true }]; |
| 1919 | if (second === "-") { |
| 1920 | const page = `/${owner}/-/${third}`; |
| 1921 | if (third === "settings" && fourth === "chat") { |
| 1922 | // Chat settings is a page of its own, not one of General's. |
| 1923 | trail.push({ label: "Chat settings", to: `${page}/chat` }); |
| 1924 | } else if (third === "code" && fourth === "repositories") { |
| 1925 | // Code's Repositories, one of Code's pages like Security or Packages. |
| 1926 | trail.push({ label: "Repositories", to: `${page}/repositories` }); |
| 1927 | } else if (third === "chat" && fourth === "sessions") { |
| 1928 | // Chat's sessions: Chat / Sessions / Session. |
| 1929 | trail.push({ label: "Chat", to: page }, { label: "Sessions", to: `${page}/sessions` }); |
| 1930 | if (parts[4]) trail.push({ label: "Session", to: `${page}/sessions/${parts[4]}` }); |
| 1931 | } else if (third === "work") { |
| 1932 | // Work: Work / #12, Work / Labels, Work / Milestones / #3. |
| 1933 | trail.push({ label: "Work", to: page }); |
| 1934 | const words: Record<string, string> = { new: "New work item", board: "Board", labels: "Labels", milestones: "Milestones", sessions: "Sessions" }; |
| 1935 | if (fourth && /^\d+$/.test(fourth)) trail.push({ label: `Work item #${fourth}`, short: `#${fourth}`, to: `${page}/${fourth}` }); |
| 1936 | else if (fourth && words[fourth]) trail.push({ label: words[fourth]!, to: `${page}/${fourth}` }); |
| 1937 | const fifth = parts[4]; |
| 1938 | if (fourth === "milestones" && fifth) trail.push({ label: `#${fifth}`, to: `${page}/milestones/${fifth}` }); |
| 1939 | if (fourth === "sessions" && fifth) trail.push({ label: "Session", to: `${page}/sessions/${fifth}` }); |
| 1940 | } else if (third) { |
| 1941 | // The workspace's settings pages each stand alone: General, Members, Permissions. |
| 1942 | trail.push({ label: third === "settings" ? "General" : (SECTIONS[third] ?? third), to: page }); |
| 1943 | // Members names its invitations. |
| 1944 | const tabs: Record<string, string> = third === "members" ? { invitations: "Invitations" } : {}; |
| 1945 | const tab = fourth ? tabs[fourth] : undefined; |
| 1946 | if (tab) trail.push({ label: tab, to: `${page}/${fourth}` }); |
| 1947 | } |
| 1948 | } else if (second) { |
| 1949 | const repo = `/${owner}/${second}`; |
| 1950 | trail.push({ label: second, to: repo, mono: true }); |
| 1951 | if (third === "pull" && fourth) trail.push({ label: `Pull request #${fourth}`, short: `#${fourth}`, to: `${repo}/pull/${fourth}` }); |
| 1952 | else if (third === "commit" && fourth) trail.push({ label: fourth.slice(0, 7), to: `${repo}/commit/${fourth}`, mono: true }); |
| 1953 | // A Soon page names what is coming. |
| 1954 | else if (third === "soon" && fourth && roadmapItem(fourth)) trail.push({ label: roadmapItem(fourth)!.title, to: `${repo}/soon/${fourth}` }); |
| 1955 | else if (third === "settings" && fourth && projectPageAt(`settings/${fourth}`) !== "settings" && projectPageAt(`settings/${fourth}`) != null) { |
| 1956 | const page = projectPageAt(`settings/${fourth}`) as keyof typeof PROJECT_PAGE_LINKS; |
| 1957 | trail.push({ label: fourth === "deployments" ? "Build configuration" : PROJECT_PAGE_LINKS[page].label, to: `${repo}/settings/${fourth}` }); |
| 1958 | } else if (third && SECTIONS[third]) { |
| 1959 | trail.push({ label: SECTIONS[third]!, to: `${repo}/${third}` }); |
| 1960 | // A settings page names which one: Settings / Secrets and variables. |
| 1961 | if (third === "settings" && fourth && SECTIONS[fourth]) { |
| 1962 | trail.push({ label: SETTINGS_SECTIONS[fourth] ?? SECTIONS[fourth]!, to: `${repo}/settings/${fourth}` }); |
| 1963 | } |
| 1964 | } |
| 1965 | } |
| 1966 | if (trail.length === 0) trail.push({ label: "Overview", to: `/${owner}` }); |
| 1967 | return <Trail trail={trail} />; |
| 1968 | } |
| 1969 | |
| 1970 | /** A profile's place in the top bar: the person's avatar and @handle, from the page once it has loaded. */ |
| 1971 | function ProfileCrumb({ username }: { username: string }) { |
| 1972 | const page = useRouteLoaderData("routes/user") as { profile?: { username: string; displayUsername?: string | null; avatar: string | null } } | undefined; |
| 1973 | const profile = page?.profile?.username.toLowerCase() === username.toLowerCase() ? page.profile : null; |
| 1974 | return ( |
| 1975 | <Breadcrumb className="min-w-0"> |
| 1976 | <BreadcrumbList> |
| 1977 | <BreadcrumbItem> |
| 1978 | <BreadcrumbPage asChild className="flex items-center gap-2 hover:bg-raised"> |
| 1979 | <Link to={`/u/${profile?.username ?? username}`}> |
| 1980 | <Avatar name={profile?.username ?? username} image={profile?.avatar ?? null} size={20} /> |
| 1981 | <span className="truncate font-mono text-[0.8125rem]">@{profile ? shownUsername(profile) : username}</span> |
| 1982 | </Link> |
| 1983 | </BreadcrumbPage> |
| 1984 | </BreadcrumbItem> |
| 1985 | </BreadcrumbList> |
| 1986 | </Breadcrumb> |
| 1987 | ); |
| 1988 | } |
| 1989 | |
| 1990 | /** A page with no trail to it, such as Today or Search: its name alone. */ |
| 1991 | function Here({ children }: { children: ReactNode }) { |
| 1992 | return ( |
| 1993 | <Breadcrumb className="min-w-0"> |
| 1994 | <BreadcrumbList> |
| 1995 | <BreadcrumbItem> |
| 1996 | <BreadcrumbPage>{children}</BreadcrumbPage> |
| 1997 | </BreadcrumbItem> |
| 1998 | </BreadcrumbList> |
| 1999 | </Breadcrumb> |
| 2000 | ); |
| 2001 | } |
| 2002 | |
| 2003 | /** One link of the trail; `short` is what a phone shows when it is the page itself. */ |
| 2004 | type Crumb = { label: string; to: string; mono?: boolean; short?: string }; |
| 2005 | |
| 2006 | /** |
| 2007 | * The trail of links. On a phone there is room for one: the page itself, |
| 2008 | * with a way back to where it sits; the whole trail from a wider screen up. |
| 2009 | */ |
| 2010 | function Trail({ trail }: { trail: Crumb[] }) { |
| 2011 | // Up on a phone, unless up is the workspace itself: its pages are the tabs. |
| 2012 | const up = trail.length > 1 && !/^\/[^/]+$/.test(trail[trail.length - 2]!.to) ? trail[trail.length - 2]! : null; |
| 2013 | return ( |
| 2014 | <Breadcrumb className="flex min-w-0 items-center"> |
| 2015 | {up && ( |
| 2016 | <Button asChild variant="ghost" size="icon-bar" className="-ml-1 sm:hidden"> |
| 2017 | <Link to={up.to} prefetch="intent" aria-label={`Back to ${up.label}`}> |
| 2018 | <ChevronLeft size={16} /> |
| 2019 | </Link> |
| 2020 | </Button> |
| 2021 | )} |
| 2022 | <BreadcrumbList> |
| 2023 | {trail.map((crumb, index) => { |
| 2024 | const last = index === trail.length - 1; |
| 2025 | const label = |
| 2026 | last && crumb.short ? ( |
| 2027 | <> |
| 2028 | <span className="sm:hidden">{crumb.short}</span> |
| 2029 | <span className="hidden sm:inline">{crumb.label}</span> |
| 2030 | </> |
| 2031 | ) : ( |
| 2032 | crumb.label |
| 2033 | ); |
| 2034 | const mono = crumb.mono ? "font-mono text-[0.8125rem]" : ""; |
| 2035 | return ( |
| 2036 | <Fragment key={index}> |
| 2037 | {index > 0 && <BreadcrumbSeparator className="max-sm:hidden" />} |
| 2038 | <BreadcrumbItem className={last ? "" : "max-sm:hidden"}> |
| 2039 | {last ? ( |
| 2040 | // The page you are on is still a link: to its own top, as the trail always was. |
| 2041 | <BreadcrumbPage asChild className={`hover:bg-raised ${mono}`}> |
| 2042 | <Link to={crumb.to} prefetch="intent"> |
| 2043 | {label} |
| 2044 | </Link> |
| 2045 | </BreadcrumbPage> |
| 2046 | ) : ( |
| 2047 | <BreadcrumbLink asChild className={mono}> |
| 2048 | <Link to={crumb.to} prefetch="intent"> |
| 2049 | {label} |
| 2050 | </Link> |
| 2051 | </BreadcrumbLink> |
| 2052 | )} |
| 2053 | </BreadcrumbItem> |
| 2054 | </Fragment> |
| 2055 | ); |
| 2056 | })} |
| 2057 | </BreadcrumbList> |
| 2058 | </Breadcrumb> |
| 2059 | ); |
| 2060 | } |
| 2061 | |
| 2062 | type Command = PaletteCommand; |
| 2063 | |
| 2064 | /** Everything the palette can jump to, from what the sidebar already knows. */ |
| 2065 | function commandsFor(user: User, shell: ShellData): Command[] { |
| 2066 | const ws = shell.workspace; |
| 2067 | const commands: Command[] = [ |
| 2068 | ...(ws |
| 2069 | ? [ |
| 2070 | { label: "Home", to: homePagePath(ws.slug), icon: <House size={15} /> }, |
| 2071 | { label: "Work", hint: "The plan: Now, Next and Later", to: workPath(ws.slug), icon: <ListTodo size={15} /> }, |
| 2072 | { label: "New work item", to: newItemPath(ws.slug), icon: <Plus size={15} /> }, |
| 2073 | { label: "Board", hint: "Work items as they move", to: workPath(ws.slug, "board"), icon: <Columns3 size={15} /> }, |
| 2074 | { label: "Triage", hint: "Work: new items nobody has placed yet", to: workPath(ws.slug, "triage"), icon: <Inbox size={15} /> }, |
| 2075 | { label: "All work items", hint: "Work, with filters", to: workPath(ws.slug, "all"), icon: <ListTodo size={15} /> }, |
| 2076 | { label: "Sessions", hint: "Work: what is cooking across the workspace", to: sessionsPath(ws.slug), icon: <Activity size={15} /> }, |
| 2077 | { label: "Labels", hint: "Work", to: workPath(ws.slug, "labels"), icon: <ListTodo size={15} /> }, |
| 2078 | { label: "Milestones", hint: "Work", to: workPath(ws.slug, "milestones"), icon: <ListTodo size={15} /> }, |
| 2079 | { label: "Chat", to: `/${ws.slug}/-/chat`, icon: <MessagesSquare size={15} /> }, |
| 2080 | ...(hasCodeAccess(ws) ? [{ label: "Code overview", to: `/${ws.slug}`, icon: <Code2 size={15} /> }] : []), |
| 2081 | { label: "Library", to: `/${ws.slug}/-/library/artifacts`, icon: <LibraryBig size={15} /> }, |
| 2082 | { label: "Routines", hint: "Standing work for @g1t (soon)", to: `/${ws.slug}/-/routines`, icon: <Repeat size={15} /> }, |
| 2083 | { label: "Apps", hint: "Everything installed that you can use", to: `/${ws.slug}/-/apps`, icon: <LayoutGrid size={15} /> }, |
| 2084 | { label: "Workspace settings", hint: "Profile, billing, members, policies", to: `/${ws.slug}/-/settings/profile`, icon: <Building2 size={15} /> }, |
| 2085 | ] |
| 2086 | : []), |
| 2087 | { label: "Explore repositories", to: "/explore", icon: <Compass size={15} /> }, |
| 2088 | { label: "Search g1t", hint: "Repositories, code, work items, people", to: "/search", icon: <Search size={15} /> }, |
| 2089 | { label: "Notifications", hint: "What needs you", to: "/notifications", icon: <Bell size={15} /> }, |
| 2090 | { label: "New repository", to: "/new", icon: <Plus size={15} /> }, |
| 2091 | { label: "Your settings", to: FIRST_SETTINGS_PAGE, icon: <Settings size={15} /> }, |
| 2092 | ...(Object.keys(ACCOUNT_SETTINGS) as AccountSettingsPage[]) |
| 2093 | .filter((page) => page !== "profile") |
| 2094 | .map((page) => ({ label: ACCOUNT_SETTINGS[page].title, hint: "Your settings", to: `/settings/${page}`, icon: <Settings size={15} /> })), |
| 2095 | ...THEME_COMMANDS, |
| 2096 | ]; |
| 2097 | const repo = shell.repo; |
| 2098 | if (repo) { |
| 2099 | const base = `/${repo.namespace}/${repo.name}`; |
| 2100 | const name = `${repo.namespace}/${repo.name}`; |
| 2101 | commands.unshift( |
| 2102 | { label: "Overview", hint: name, to: base, icon: <LayoutGrid size={15} /> }, |
| 2103 | { label: "Code", hint: name, to: `${base}/code`, icon: <Code2 size={15} /> }, |
| 2104 | { label: "Work", hint: name, to: repoWorkPath(repo.namespace, repo.name), icon: <ListTodo size={15} /> }, |
| 2105 | { label: "Sessions", hint: name, to: sessionsPath(repo.namespace, { repo: repo.name }), icon: <Activity size={15} /> }, |
| 2106 | { label: "New work item", hint: name, to: newItemPath(repo.namespace, repo.name), icon: <Plus size={15} /> }, |
| 2107 | { label: "Pull requests", hint: name, to: `${base}/pulls`, icon: <GitPullRequest size={15} /> }, |
| 2108 | { label: "Commits", hint: name, to: `${base}/commits`, icon: <History size={15} /> }, |
| 2109 | ...(repo.member |
| 2110 | ? [ |
| 2111 | { label: "Deployments", hint: name, to: `${base}/deployments`, icon: <Rocket size={15} /> }, |
| 2112 | { label: "Environment variables", hint: name, to: `${base}/settings/secrets`, icon: <Lock size={15} /> }, |
| 2113 | { label: "General settings", hint: name, to: `${base}/settings`, icon: <Settings size={15} /> }, |
| 2114 | { label: "Repository settings", hint: name, to: `${base}/settings/repository`, icon: <Settings size={15} /> }, |
| 2115 | ] |
| 2116 | : []), |
| 2117 | ); |
| 2118 | } |
| 2119 | for (const membership of user.workspaces ?? []) { |
| 2120 | commands.push( |
| 2121 | { |
| 2122 | label: displayName(membership), |
| 2123 | hint: `Workspace · ${membership.slug}`, |
| 2124 | to: `/${membership.slug}`, |
| 2125 | icon: <Avatar name={membership.slug} image={membership.avatar} size={15} square />, |
| 2126 | }, |
| 2127 | { label: "All repositories", hint: membership.slug, to: repositoriesPath(membership.slug), icon: <LayoutGrid size={15} /> }, |
| 2128 | { label: "People", hint: membership.slug, to: `/${membership.slug}/-/people`, icon: <Users size={15} /> }, |
| 2129 | { label: "Usage", hint: membership.slug, to: `/${membership.slug}/-/settings/usage`, icon: <BarChart3 size={15} /> }, |
| 2130 | { label: "Billing and plans", hint: `${membership.slug} · Settings`, to: `/${membership.slug}/-/settings/billing`, icon: <CreditCard size={15} /> }, |
| 2131 | { label: "Access tokens", hint: `${membership.slug} · Settings`, to: `/${membership.slug}/-/settings/tokens`, icon: <KeyRound size={15} /> }, |
| 2132 | { label: "Integrations", hint: membership.slug, to: `/${membership.slug}/-/settings/integrations`, icon: <Plug size={15} /> }, |
| 2133 | { label: "AI models", hint: `${membership.slug} · Settings`, to: `/${membership.slug}/-/settings/ai`, icon: <Sparkles size={15} /> }, |
| 2134 | { label: "Members", hint: `${membership.slug} · Access`, to: `/${membership.slug}/-/settings/members`, icon: <Users size={15} /> }, |
| 2135 | ...(membership.role === "owner" |
| 2136 | ? [ |
| 2137 | { label: "Getting started", hint: `${membership.slug} · Set up the workspace`, to: setupPath(membership.slug), icon: <Rocket size={15} /> }, |
| 2138 | { label: "Invitations", hint: `${membership.slug} · Access`, to: `/${membership.slug}/-/settings/members/invitations`, icon: <Mail size={15} /> }, |
| 2139 | { label: "Permissions", hint: `${membership.slug} · Access`, to: `/${membership.slug}/-/settings/permissions`, icon: <ShieldCheck size={15} /> }, |
| 2140 | ] |
| 2141 | : []), |
| 2142 | ); |
| 2143 | } |
| 2144 | for (const listed of shell.repos) { |
| 2145 | commands.push({ |
| 2146 | label: listed.title ?? listed.name, |
| 2147 | hint: `${listed.namespace} · Repository`, |
| 2148 | to: `/${listed.namespace}/${listed.name}`, |
| 2149 | icon: <Box size={15} />, |
| 2150 | }); |
| 2151 | } |
| 2152 | return commands; |
| 2153 | } |
| 2154 | |
| 2155 | /** How long the next page may take before the one leaving dims: most arrive sooner, and then nothing flickers. */ |
| 2156 | export const LEAVING_DIM_MS = 500; |
| 2157 | |
| 2158 | /** |
| 2159 | * The page while the next one loads: the bar below says so at once, and |
| 2160 | * only if it takes a while does the page dim, so it is plainly on its way |
| 2161 | * out rather than frozen. The next page is simply there, at full strength: |
| 2162 | * no fade back up. Sending a form does not dim it: the form's button says |
| 2163 | * it is working. Spread on the element holding the page (the content, |
| 2164 | * never the chrome around it). |
| 2165 | */ |
| 2166 | export function useLeaving() { |
| 2167 | const navigation = useNavigation(); |
| 2168 | const method = navigation.formMethod?.toUpperCase(); |
| 2169 | const leaving = navigation.state === "loading" && (method == null || method === "GET"); |
| 2170 | const [dim, setDim] = useState(false); |
| 2171 | useEffect(() => { |
| 2172 | if (!leaving) { |
| 2173 | setDim(false); |
| 2174 | return; |
| 2175 | } |
| 2176 | const timer = setTimeout(() => setDim(true), LEAVING_DIM_MS); |
| 2177 | return () => clearTimeout(timer); |
| 2178 | }, [leaving]); |
| 2179 | return { |
| 2180 | "aria-busy": leaving || undefined, |
| 2181 | className: dim && leaving ? "opacity-60 transition-opacity duration-200 motion-reduce:transition-none" : "", |
| 2182 | }; |
| 2183 | } |
| 2184 | |
| 2185 | /** |
| 2186 | * A bar across the top of the page while the next one loads, as GitHub |
| 2187 | * has: it appears at once, creeps towards the end while waiting, then fills |
| 2188 | * and fades when the page arrives. |
| 2189 | */ |
| 2190 | export function Progress() { |
| 2191 | const navigation = useNavigation(); |
| 2192 | const busy = navigation.state !== "idle"; |
| 2193 | const [width, setWidth] = useState(0); |
| 2194 | const [visible, setVisible] = useState(false); |
| 2195 | useEffect(() => { |
| 2196 | if (busy) { |
| 2197 | setVisible(true); |
| 2198 | setWidth(12); |
| 2199 | // Slows as it goes, never quite arriving until the page does. |
| 2200 | const timer = setInterval(() => setWidth((now) => now + (90 - now) * 0.12), 200); |
| 2201 | return () => clearInterval(timer); |
| 2202 | } |
| 2203 | setWidth((now) => (now > 0 ? 100 : 0)); |
| 2204 | const fade = setTimeout(() => setVisible(false), 250); |
| 2205 | const reset = setTimeout(() => setWidth(0), 550); |
| 2206 | return () => { |
| 2207 | clearTimeout(fade); |
| 2208 | clearTimeout(reset); |
| 2209 | }; |
| 2210 | }, [busy]); |
| 2211 | return ( |
| 2212 | <div |
| 2213 | aria-hidden="true" |
| 2214 | className={`pointer-events-none fixed inset-x-0 top-0 z-60 h-0.75 transition-opacity duration-300 ${visible ? "opacity-100" : "opacity-0"}`} |
| 2215 | > |
| 2216 | <div className="h-full bg-accent shadow-[0_0_10px_var(--color-accent)] transition-[width] duration-200 ease-out" style={{ width: `${width}%` }} /> |
| 2217 | </div> |
| 2218 | ); |
| 2219 | } |
| 2220 | |
| 2221 | /** |
| 2222 | * Which sidebar sits beside the page: each mode's own, or none. Home, |
| 2223 | * Apps and Routines are pages at full width, and so are g1t's own pages, such as its |
| 2224 | * policies, which are no workspace's. g1t beyond your workspaces (Explore, |
| 2225 | * a profile, another workspace's projects) has Explore's. Without a |
| 2226 | * workspace, only your own settings and Notifications have one. |
| 2227 | */ |
| 2228 | function sidebarFor(mode: ModeKey, workspace: boolean): Panel | null { |
| 2229 | if (!workspace) return mode === "account" || mode === "notifications" ? mode : null; |
| 2230 | if (mode === "home" || mode === "apps" || mode === "routines" || mode === "site") return null; |
| 2231 | return mode; |
| 2232 | } |
| 2233 | |
| 2234 | /** The sidebars that sit beside the page, one per mode that has one. */ |
| 2235 | type Panel = "work" | "chat" | "artifacts" | "code" | "notifications" | "people" | "workspace" | "account" | "browse"; |
| 2236 | |
| 2237 | /** Each sidebar's name, for the button that opens it on a phone. */ |
| 2238 | const MODE_MENU: Record<Panel, string> = { |
| 2239 | browse: "Explore", |
| 2240 | work: "Work", |
| 2241 | chat: "Chat", |
| 2242 | artifacts: "Library", |
| 2243 | code: "Code", |
| 2244 | notifications: "Notifications", |
| 2245 | people: "People", |
| 2246 | workspace: "Workspace", |
| 2247 | account: "Your account", |
| 2248 | }; |
| 2249 | |
| 2250 | /** Whether a key press happened in something you type into, where Ctrl B is the editor's. */ |
| 2251 | function typing(target: EventTarget | null): boolean { |
| 2252 | if (!(target instanceof HTMLElement)) return false; |
| 2253 | return target.isContentEditable || target.closest("input, textarea, select, [contenteditable=''], [contenteditable='true'], [role='textbox']") != null; |
| 2254 | } |
| 2255 | |
| 2256 | /** Ctrl B (⌘B on a Mac) shows or hides the sidebar, except while typing. */ |
| 2257 | function useSidebarShortcut(toggle: () => void) { |
| 2258 | useEffect(() => { |
| 2259 | const onKey = (event: KeyboardEvent) => { |
| 2260 | if (event.key.toLowerCase() !== "b" || event.altKey || event.shiftKey || !(event.ctrlKey || event.metaKey)) return; |
| 2261 | if (event.defaultPrevented || typing(event.target)) return; |
| 2262 | event.preventDefault(); |
| 2263 | toggle(); |
| 2264 | }; |
| 2265 | window.addEventListener("keydown", onKey); |
| 2266 | return () => window.removeEventListener("keydown", onKey); |
| 2267 | }, [toggle]); |
| 2268 | } |
| 2269 | |
| 2270 | /** |
| 2271 | * Search or jump to: the command palette. On a computer it looks like the |
| 2272 | * field it opens, with its shortcut; on a phone, a magnifier. |
| 2273 | */ |
| 2274 | function SearchButton({ onClick, place }: { onClick: () => void; place: "bar" | "sidebar" | "appbar" }) { |
| 2275 | const field = ( |
| 2276 | <Button |
| 2277 | variant="outline" |
| 2278 | size="sm" |
| 2279 | aria-label="Search or jump to" |
| 2280 | onClick={onClick} |
| 2281 | className={ |
| 2282 | place === "sidebar" |
| 2283 | ? "w-full justify-start bg-bg font-normal text-faint hover:bg-bg hover:text-muted" |
| 2284 | : place === "appbar" |
| 2285 | ? // The field from 1024px, the magnifier before; it narrows to its shortcut before the tabs give way. |
| 2286 | "mr-1 w-64 min-w-34 shrink justify-start bg-bg font-normal text-faint @container hover:bg-bg hover:text-muted max-lg:hidden" |
| 2287 | : "mr-1 w-[min(18rem,26vw)] justify-start bg-surface font-normal text-faint hover:bg-surface hover:text-muted max-md:hidden" |
| 2288 | } |
| 2289 | > |
| 2290 | <Search size={15} /> |
| 2291 | {/* Narrowed in the app bar, it says Search alone rather than half of it. */} |
| 2292 | <span className="grow truncate text-left"> |
| 2293 | Search<span className="@max-[13rem]:hidden"> or jump to</span> |
| 2294 | </span> |
| 2295 | <PaletteKey className="px-1.5 text-[0.625rem] leading-4 text-muted" /> |
| 2296 | </Button> |
| 2297 | ); |
| 2298 | if (place === "sidebar") return field; |
| 2299 | return ( |
| 2300 | <> |
| 2301 | <Button variant="ghost" size="icon-bar" aria-label="Search or jump to" onClick={onClick} className={place === "appbar" ? "lg:hidden" : "md:hidden"}> |
| 2302 | <Search size={18} /> |
| 2303 | </Button> |
| 2304 | {field} |
| 2305 | </> |
| 2306 | ); |
| 2307 | } |
| 2308 | |
| 2309 | /** Whether the window matches `query`: null on the server and until the page hydrates, so both draw the same. */ |
| 2310 | function useMedia(query: string): boolean | null { |
| 2311 | return useSyncExternalStore( |
| 2312 | useCallback( |
| 2313 | (change: () => void) => { |
| 2314 | const media = window.matchMedia(query); |
| 2315 | media.addEventListener("change", change); |
| 2316 | return () => media.removeEventListener("change", change); |
| 2317 | }, |
| 2318 | [query], |
| 2319 | ), |
| 2320 | () => window.matchMedia(query).matches, |
| 2321 | () => null, |
| 2322 | ); |
| 2323 | } |
| 2324 | |
| 2325 | /** The sidebar's shortcut, for the hint on its toggle. */ |
| 2326 | function useSidebarKey(): string { |
| 2327 | return sidebarKeyLabel(typeof navigator === "undefined" ? null : navigator.platform); |
| 2328 | } |
| 2329 | |
| 2330 | /** |
| 2331 | * The app: three surfaces that never blur together. The rail, a narrow |
| 2332 | * column of icons down the window's left edge; the mode's sidebar, flat |
| 2333 | * against it with a hairline between, which folds away with Ctrl B and is |
| 2334 | * a drawer below 1024px; and the page, flat against the sidebar and |
| 2335 | * filling the rest of the window, with its top bar across the top and a |
| 2336 | * line under it: the sidebar's toggle, where you are, search, Ask, the bell and |
| 2337 | * Create new. On a phone the rail is a bar along the bottom and the page |
| 2338 | * fills the window above it, flat as well. Only for someone signed in; without a workspace yet, there |
| 2339 | * is no rail. The rail and the sidebar are drawn from the sidebar kit |
| 2340 | * (components/ui/sidebar.tsx): one provider holds whether the sidebar is |
| 2341 | * open on a computer and whether the drawer is. |
| 2342 | */ |
| 2343 | export function AppShell({ |
| 2344 | user, |
| 2345 | shell, |
| 2346 | missing = false, |
| 2347 | banner, |
| 2348 | sidebarClosed = false, |
| 2349 | children, |
| 2350 | }: { |
| 2351 | user: User; |
| 2352 | shell: ShellData; |
| 2353 | /** The page is a 404 (see CodeSidebar). */ |
| 2354 | missing?: boolean; |
| 2355 | banner?: ReactNode; |
| 2356 | /** Whether the person folded the sidebar away, as their cookie says. */ |
| 2357 | sidebarClosed?: boolean; |
| 2358 | children: ReactNode; |
| 2359 | }) { |
| 2360 | const { pathname } = useLocation(); |
| 2361 | const [drawer, setDrawer] = useState(false); |
| 2362 | const [closed, setClosed] = useState(sidebarClosed); |
| 2363 | const [palette, setPalette] = useState(false); |
| 2364 | const commands = useMemo(() => commandsFor(user, shell), [user, shell]); |
| 2365 | |
| 2366 | // A new page closes the drawer. |
| 2367 | useEffect(() => setDrawer(false), [pathname]); |
| 2368 | usePaletteShortcut(() => setPalette((open) => !open)); |
| 2369 | // In the desktop app, search is in the middle of its title bar, as in Slack, and not in the top bar too. |
| 2370 | const titleSearch = useTitleBarSearch(`Search ${shell.workspace?.name ?? shell.workspace?.slug ?? "g1t"}`, () => setPalette(true)); |
| 2371 | const leaving = useLeaving(); |
| 2372 | useVisualViewport(); |
| 2373 | const target = useSidebarLocation().pathname; |
| 2374 | const ws = shell.workspace; |
| 2375 | // Beyond your workspaces (Explore, a profile, another workspace's |
| 2376 | // repositories), the shell is Explore's; anything of yours is its mode's. |
| 2377 | const browsing = browsingOf(target, { |
| 2378 | slug: ws?.slug ?? null, |
| 2379 | memberships: (user.workspaces ?? []).map((membership) => membership.slug), |
| 2380 | shared: shell.shared, |
| 2381 | username: user.username, |
| 2382 | }); |
| 2383 | const mode: ModeKey = browsing ? "browse" : modeOf(target, ws?.slug ?? null); |
| 2384 | // The workspace's integrations are Apps's (lit in the app bar), kept beside the settings they are part of. |
| 2385 | const panel = ws && isIntegrationsPath(target, ws.slug) ? "workspace" : sidebarFor(mode, ws != null); |
| 2386 | const { sidebar: chatSidebar } = useChatSidebar(); |
| 2387 | // Notify: the feed socket's live counts (lib/notify-client.ts) once it has them; the page's until then. |
| 2388 | const live = useLiveBadges(ws?.slug); |
| 2389 | const chatUnread = chatSidebar |
| 2390 | ? unreadTotals(chatSidebar.entries) |
| 2391 | : live?.chat != null |
| 2392 | ? { unread: live.chat, mentions: live.mentions ?? 0 } |
| 2393 | : (shell.chat ?? { unread: 0, mentions: 0 }); |
| 2394 | const notificationsUnread = live?.inbox ?? shell.inbox?.unread ?? 0; |
| 2395 | const unread = { notifications: notificationsUnread, chat: chatUnread.unread, mentions: chatUnread.mentions }; |
| 2396 | |
| 2397 | // Folding the sidebar away on a computer is remembered, so the next page is drawn the same from the server. |
| 2398 | const toggleSidebar = useCallback(() => { |
| 2399 | if (!window.matchMedia("(min-width: 1024px)").matches) { |
| 2400 | setDrawer((open) => !open); |
| 2401 | return; |
| 2402 | } |
| 2403 | setClosed((was) => { |
| 2404 | document.cookie = sidebarCookie(!was, window.location.protocol === "https:"); |
| 2405 | return !was; |
| 2406 | }); |
| 2407 | }, []); |
| 2408 | useSidebarShortcut( |
| 2409 | useCallback(() => { |
| 2410 | if (panel) toggleSidebar(); |
| 2411 | }, [panel, toggleSidebar]), |
| 2412 | ); |
| 2413 | |
| 2414 | const inline = panel != null && !closed; |
| 2415 | const sidebarNode = () => { |
| 2416 | if (!panel) return null; |
| 2417 | if (!ws) return panel === "notifications" ? <NotificationsSidebar counts={shell.inbox ?? null} /> : <AccountSidebar username={user.username} />; |
| 2418 | switch (panel) { |
| 2419 | case "code": |
| 2420 | return <CodeSidebar user={user} shell={shell} missing={missing} />; |
| 2421 | case "browse": |
| 2422 | return <CodeSidebar user={user} shell={shell} missing={missing} browsing={browsing} />; |
| 2423 | case "work": |
| 2424 | return <WorkSidebar slug={ws.slug} username={user.username} />; |
| 2425 | case "chat": |
| 2426 | return <ChatSidebar slug={ws.slug} />; |
| 2427 | case "artifacts": |
| 2428 | return <FoliosSidebar slug={ws.slug} />; |
| 2429 | case "notifications": |
| 2430 | return <NotificationsSidebar counts={shell.inbox ?? null} />; |
| 2431 | case "people": |
| 2432 | return <PeopleSidebar slug={ws.slug} />; |
| 2433 | case "workspace": |
| 2434 | return <WorkspaceSidebar slug={ws.slug} owner={ws.role === "owner"} />; |
| 2435 | case "account": |
| 2436 | return <AccountSidebar username={user.username} />; |
| 2437 | } |
| 2438 | }; |
| 2439 | const sidebarKey = useSidebarKey(); |
| 2440 | const wide = useMedia("(min-width: 768px)"); |
| 2441 | // The sidebar beside the page from 1024px, the drawer before: what the page's toggle says it does. |
| 2442 | const desk = useMedia("(min-width: 1024px)"); |
| 2443 | const shownSidebar = desk === false ? drawer : inline; |
| 2444 | // Without a workspace there is no app bar, and search or jump to sits at the |
| 2445 | // top of the sidebar (Notifications', your account's). |
| 2446 | const sidebarSearch = panel != null && panel !== "chat" && panel !== "artifacts"; |
| 2447 | // The drawer's head, and the sidebar's without a workspace: the workspace |
| 2448 | // and its switcher, the way to fold the sidebar or close the drawer, and |
| 2449 | // search, which opens the palette. Under the app bar the sidebar has no |
| 2450 | // head: the workspace and search are in the bar, its toggle in the page's. |
| 2451 | const sidebarTop = (inDrawer: boolean) => ( |
| 2452 | <SidebarHeader className="gap-1 px-2 pt-0 pb-2"> |
| 2453 | <div className="flex h-14 shrink-0 items-center gap-1"> |
| 2454 | {ws ? ( |
| 2455 | <WorkspaceSwitcher user={user} workspace={ws} /> |
| 2456 | ) : ( |
| 2457 | <Link to="/" aria-label="g1t" className="mr-auto flex h-9 items-center rounded-md px-2"> |
| 2458 | <Logo className="text-[1.125rem]" /> |
| 2459 | </Link> |
| 2460 | )} |
| 2461 | {inDrawer ? ( |
| 2462 | <Button type="button" aria-label="Close menu" onClick={() => setDrawer(false)} variant="ghost" size="icon-sm" className="text-faint"> |
| 2463 | <X size={16} /> |
| 2464 | </Button> |
| 2465 | ) : ( |
| 2466 | <SidebarTrigger hint={`Hide the sidebar (${sidebarKey})`} className="text-faint" /> |
| 2467 | )} |
| 2468 | </div> |
| 2469 | {sidebarSearch && <SearchButton place="sidebar" onClick={() => setPalette(true)} />} |
| 2470 | </SidebarHeader> |
| 2471 | ); |
| 2472 | |
| 2473 | // On a phone (below 768px): the bottom bar, and a conversation full screen. |
| 2474 | const conversation = isConversation(target); |
| 2475 | // The page starts where the sidebar (15rem), when it is open, ends. |
| 2476 | const pad = inline ? "lg:pl-60" : ""; |
| 2477 | // Search, Ask, the bell and Create new: at the app bar's end from 768px, |
| 2478 | // and in the page's top bar on a phone or without a workspace. Ask and the |
| 2479 | // bell hold state of their own, so each is drawn in one place only: until |
| 2480 | // the page knows the window's width, both are drawn and CSS shows one. |
| 2481 | const barred = ws != null; |
| 2482 | // Without a workspace there is no app bar: the page's bar is their only place. |
| 2483 | const here = (place: "appbar" | "bar") => (!barred ? place === "bar" : wide == null || (place === "appbar") === wide); |
| 2484 | const actions = (place: "appbar" | "bar") => ( |
| 2485 | <> |
| 2486 | {!titleSearch && ( |
| 2487 | <span className={`flex ${place === "appbar" ? "min-w-0 shrink" : ""} ${place === "bar" && !barred && inline && sidebarSearch ? "lg:hidden" : ""}`}> |
| 2488 | <SearchButton place={place} onClick={() => setPalette(true)} /> |
| 2489 | </span> |
| 2490 | )} |
| 2491 | {ws && here(place) && <AskButton key={ws.slug} slug={ws.slug} />} |
| 2492 | <Separator orientation="vertical" className="mx-1 data-[orientation=vertical]:h-5 max-md:hidden" /> |
| 2493 | {/* On a phone, Notifications is in the bottom bar. */} |
| 2494 | {here(place) && ( |
| 2495 | <span className="flex max-md:hidden"> |
| 2496 | <NotificationsBell counts={shell.inbox ? { ...shell.inbox, unread: notificationsUnread } : null} /> |
| 2497 | </span> |
| 2498 | )} |
| 2499 | <CreateMenu shell={shell} /> |
| 2500 | </> |
| 2501 | ); |
| 2502 | |
| 2503 | return ( |
| 2504 | <SidebarProvider open={!closed} onOpenChange={(open) => setClosed(!open)} openMobile={drawer} onOpenMobileChange={setDrawer} toggleSidebar={toggleSidebar}> |
| 2505 | <Progress /> |
| 2506 | {ws && ( |
| 2507 | <AppBar |
| 2508 | user={user} |
| 2509 | workspace={ws} |
| 2510 | mode={mode} |
| 2511 | pins={shell.pins ?? []} |
| 2512 | unread={unread} |
| 2513 | end={ |
| 2514 | <> |
| 2515 | {actions("appbar")} |
| 2516 | <AccountMenu user={user} on="bar" /> |
| 2517 | </> |
| 2518 | } |
| 2519 | /> |
| 2520 | )} |
| 2521 | {panel && ( |
| 2522 | <Sidebar |
| 2523 | role="complementary" |
| 2524 | aria-label={`${MODE_MENU[panel]} sidebar`} |
| 2525 | className="fixed top-[calc(var(--frame-top)+var(--appbar-h))] bottom-0 left-0 z-30 h-auto max-lg:hidden" |
| 2526 | > |
| 2527 | {!barred && sidebarTop(false)} |
| 2528 | <div className={`flex min-h-0 grow flex-col ${barred ? "pt-2" : ""}`}>{sidebarNode()}</div> |
| 2529 | </Sidebar> |
| 2530 | )} |
| 2531 | {/* The drawer is a sheet: a dialog that holds focus, closes on Escape or |
| 2532 | a tap outside, and gives focus back to the button that opened it. */} |
| 2533 | {panel && ( |
| 2534 | <SidebarMobile title={`${MODE_MENU[panel]} menu`} className="pb-[env(safe-area-inset-bottom)] max-md:[&_nav_a]:min-h-10"> |
| 2535 | {sidebarTop(true)} |
| 2536 | <div className="flex min-h-0 grow flex-col">{sidebarNode()}</div> |
| 2537 | </SidebarMobile> |
| 2538 | )} |
| 2539 | |
| 2540 | <div className={`min-h-dvh pt-[calc(var(--frame-top)+var(--appbar-h))] pb-(--tabbar-h) ${pad}`}> |
| 2541 | {/* The top bar always has its line, so a page's own header (data-page-head: a conversation's, a repository's, |
| 2542 | a sticky one under an open artifact) sits under it as its own band. */} |
| 2543 | <div className="flex min-h-[calc(100dvh-var(--frame-top)-var(--appbar-h)-var(--tabbar-h))] min-w-0 flex-col bg-bg"> |
| 2544 | <header |
| 2545 | className={`sticky top-[calc(var(--frame-top)+var(--appbar-h))] z-30 flex h-14 items-center gap-1 border-b border-line bg-bg pr-2 pl-2 sm:pr-3 ${barred ? "md:h-12 md:pl-3" : inline ? "lg:pl-3.5" : ""} ${conversation ? "max-md:hidden" : ""}`} |
| 2546 | > |
| 2547 | {panel && ( |
| 2548 | <SidebarTrigger |
| 2549 | hint={`${shownSidebar ? "Hide" : "Show"} the sidebar (${sidebarKey})`} |
| 2550 | aria-label={shownSidebar ? "Hide the sidebar" : "Show the sidebar"} |
| 2551 | aria-expanded={shownSidebar} |
| 2552 | className={`text-faint max-md:size-11 ${inline && !barred ? "lg:hidden" : ""}`} |
| 2553 | /> |
| 2554 | )} |
| 2555 | {/* The workspace is the app bar's from 768px; on a phone it starts the trail. */} |
| 2556 | {ws && ( |
| 2557 | <span className="flex min-w-0 shrink items-center gap-1.5 md:hidden"> |
| 2558 | <WorkspaceSwitcher user={user} workspace={ws} variant="compact" /> |
| 2559 | <span aria-hidden="true" className="text-line-strong"> |
| 2560 | / |
| 2561 | </span> |
| 2562 | </span> |
| 2563 | )} |
| 2564 | {panel && barred && <span aria-hidden="true" className="mx-1 h-4 w-px shrink-0 bg-line max-md:hidden" />} |
| 2565 | <Breadcrumbs pathname={pathname} missing={missing} repo={shell.repo} workspace={ws?.slug ?? null} /> |
| 2566 | {/* Search, Ask, then the bell and Create new: one height, one rhythm. In a workspace, the app bar's from 768px. */} |
| 2567 | <div className={`ml-auto flex shrink-0 items-center gap-1 md:gap-1.5 ${barred ? "md:hidden" : ""}`}> |
| 2568 | {actions("bar")} |
| 2569 | {/* Without a workspace there is no app bar: the account is here. */} |
| 2570 | {!ws && <AccountMenu user={user} />} |
| 2571 | </div> |
| 2572 | </header> |
| 2573 | {banner} |
| 2574 | <main id="content" tabIndex={-1} {...leaving} className={`min-w-0 grow outline-none ${leaving.className}`}> |
| 2575 | <InMain.Provider value={true}>{children}</InMain.Provider> |
| 2576 | </main> |
| 2577 | </div> |
| 2578 | </div> |
| 2579 | {ws && ( |
| 2580 | <BottomBar user={user} workspace={ws} mode={mode} pins={shell.pins ?? []} unread={unread} onReselect={panel ? () => setDrawer(true) : undefined} /> |
| 2581 | )} |
| 2582 | <CommandPalette open={palette} onOpenChange={setPalette} commands={commands} repo={shell.repo ? `${shell.repo.namespace}/${shell.repo.name}` : null} /> |
| 2583 | </SidebarProvider> |
| 2584 | ); |
| 2585 | } |
| 2586 | |
| 2587 | /** Create new: a work item (from the repository you are in), a repository, a workspace or a team. */ |
| 2588 | function CreateMenu({ shell }: { shell: ShellData }) { |
| 2589 | return ( |
| 2590 | <DropdownMenu> |
| 2591 | <Hint label="Create new"> |
| 2592 | <DropdownMenuTrigger asChild> |
| 2593 | <Button variant="ghost" size="bar" aria-label="Create new" className="gap-0.5 px-1.5 text-fg/90"> |
| 2594 | <Plus size={17} className="max-md:size-5" /> |
| 2595 | <ChevronDown size={12} className="text-faint max-md:hidden" /> |
| 2596 | </Button> |
| 2597 | </DropdownMenuTrigger> |
| 2598 | </Hint> |
| 2599 | <DropdownMenuContent align="end" className="min-w-52"> |
| 2600 | {shell.repo ? ( |
| 2601 | <> |
| 2602 | <DropdownMenuLabel className="truncate">In {shell.repo.name}</DropdownMenuLabel> |
| 2603 | <DropdownMenuItem asChild> |
| 2604 | <Link to={newItemPath(shell.repo.namespace, shell.repo.name)}> |
| 2605 | <ListTodo /> |
| 2606 | New work item |
| 2607 | </Link> |
| 2608 | </DropdownMenuItem> |
| 2609 | <DropdownMenuSeparator /> |
| 2610 | </> |
| 2611 | ) : ( |
| 2612 | shell.workspace && ( |
| 2613 | <> |
| 2614 | <DropdownMenuItem asChild> |
| 2615 | <Link to={newItemPath(shell.workspace.slug)}> |
| 2616 | <ListTodo /> |
| 2617 | New work item |
| 2618 | </Link> |
| 2619 | </DropdownMenuItem> |
| 2620 | <DropdownMenuSeparator /> |
| 2621 | </> |
| 2622 | ) |
| 2623 | )} |
| 2624 | <DropdownMenuItem asChild> |
| 2625 | <Link to="/new"> |
| 2626 | <Box /> |
| 2627 | New repository |
| 2628 | </Link> |
| 2629 | </DropdownMenuItem> |
| 2630 | <DropdownMenuItem asChild> |
| 2631 | <Link to="/new/github"> |
| 2632 | <GitBranch /> |
| 2633 | Import from GitHub |
| 2634 | </Link> |
| 2635 | </DropdownMenuItem> |
| 2636 | {/* A team in the workspace you are in, for whoever it lets create one. A new workspace is made from the workspace switcher in the rail. */} |
| 2637 | {shell.workspace && <DropdownMenuSeparator />} |
| 2638 | {shell.workspace && |
| 2639 | (mayCreateTeams(shell.workspace.team_creation, shell.workspace.role) ? ( |
| 2640 | <DropdownMenuItem asChild> |
| 2641 | <Link to={`/${shell.workspace.slug}/-/teams/new`}> |
| 2642 | <UsersRound /> |
| 2643 | New team |
| 2644 | </Link> |
| 2645 | </DropdownMenuItem> |
| 2646 | ) : ( |
| 2647 | <DropdownMenuItem disabled> |
| 2648 | <UsersRound /> |
| 2649 | New team |
| 2650 | <span className="ml-auto pl-3 text-xs text-faint">Owners only</span> |
| 2651 | </DropdownMenuItem> |
| 2652 | ))} |
| 2653 | </DropdownMenuContent> |
| 2654 | </DropdownMenu> |
| 2655 | ); |
| 2656 | } |