Skip to content

Where people and agents ship software together. The open-source git platform for the whole job: issues, agents, checks and deploys to the edge.

2,656 linesCodeBlameRaw
1import {
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";
80import { Fragment, type ReactNode, useCallback, useEffect, useMemo, useRef, useState, useSyncExternalStore } from "react";
81import { Link, useFetcher, useLocation, useNavigation, useRouteLoaderData, useSubmit } from "react-router";
82
83import {
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
96import { InMain } from "./landmark";
97import { NeedsAttention } from "./needs-attention";
98import { cn } from "../lib/cn";
99import { CommandPalette, type PaletteCommand, PaletteKey, ShortcutsKey, usePaletteShortcut, useShortcutsKey } from "./command-palette";
100import { AskButton } from "./ask-button";
101import { NotificationsBell } from "./inbox";
102import { desktopBridge, useDesktopApp, useTitleBarSearch } from "../lib/notify-client";
103import { PinButton } from "./pin-button";
104import { Hint } from "./ui/hint";
105import {
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";
124import { StatusDot, useSiteStatus } from "./footer";
125import { Logo } from "./logo";
126import { SoonPill } from "./ui";
127import { Avatar } from "./ui/avatar";
128import { Skeleton } from "./ui/skeleton";
129import { Badge } from "./ui/badge";
130import { Button } from "./ui/button";
131import { Separator } from "./ui/separator";
132import { Breadcrumb, BreadcrumbItem, BreadcrumbLink, BreadcrumbList, BreadcrumbPage, BreadcrumbSeparator } from "./ui/breadcrumb";
133import {
134 DropdownMenu,
135 DropdownMenuContent,
136 DropdownMenuGroup,
137 DropdownMenuItem,
138 DropdownMenuLabel,
139 DropdownMenuSeparator,
140 DropdownMenuTrigger,
141} from "./ui/dropdown-menu";
142import { type RoadmapItem, roadmapIn, roadmapItem } from "../lib/roadmap";
143import {
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";
156import { useSidebarLocation } from "../lib/use-sidebar-location";
157import { setupPath } from "../lib/onboarding";
158import { ChatSidebar } from "./chat/sidebar";
159import { FoliosSidebar } from "./folios/sidebar";
160import { AppBar, ShortcutsDialog, WorkspaceSwitcher, sidebarKeyLabel } from "./app-bar";
161import { BottomBar, isConversation, useVisualViewport } from "./mobile";
162import { useChatSidebar } from "./chat/actions";
163import { unreadTotals } from "../lib/chat";
164import { SETTINGS_CAPABILITY, type ViewerAccess, seesSettings } from "../lib/access";
165import { PROJECT_OPERATIONS, PROJECT_PAGE_LINKS, projectPageAt, projectPages } from "../lib/chrome";
166import { ACCOUNT_SETTINGS, type AccountSettingsPage, FIRST_SETTINGS_PAGE, accountSettingsPage } from "../lib/account-settings";
167import { type PinnableApp, sidebarCookie } from "../lib/apps";
168import { REASON_FILTERS, inboxReason } from "../lib/inbox";
169import { notificationViewAt, notificationsAddress, profilePath } from "../lib/tab-paths";
170import { GithubMark } from "./github";
171import { STATUS_URL, statusTitle } from "../lib/status";
172import { G1T_INVITES, leftBadge } from "../lib/invites";
173import { type ShortcutProject, movedPin, recentWith } from "../lib/pins";
174import { ProjectIcon } from "./project-icon";
175import type { AccountMenuData } from "../routes/settings-menu-json";
176import { useLiveBadges } from "../lib/notify-client";
177import { OwnPresenceDot, OwnPresenceItems, StatusDialog } from "./presence";
178import { THEME_COMMANDS, ThemeMenuSwitch } from "./theme-switch";
179import { newItemPath, repoWorkPath, sessionsPath, workPath } from "../lib/work-items";
180import { HEALTH_LABELS, projectPath, projectsJsonPath, projectsPath } from "../lib/projects";
181import { type SidebarCycle, cycleJsonPath, cyclePath } from "../lib/cycle-paths";
182import { 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 */
189export 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. */
234function 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 */
246function 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 */
260function 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 */
314function 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 */
337function 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. */
370function 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. */
375function 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. */
403function 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. */
414function 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 */
423const LEGAL_LINKS: [string, string][] = [
424 ["Policies", "/policies"],
425 ["Privacy", "/policies/privacy"],
426 ["Security", "/security"],
427];
428const LEGAL_LINK = "flex h-7 items-center rounded px-1.5 text-xs text-faint outline-none transition-colors";
429
430function 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. */
450function 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. */
465const 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 */
474function 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. */
626const 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 */
635const 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. */
638const 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. */
641type Level = { key: string; node: ReactNode };
642
643/** True once the page has hydrated, so what the server drew never animates in. */
644function 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 */
659function 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 */
700function 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. */
717function Rule() {
718 return <SidebarSeparator className="my-1.5" />;
719}
720
721/** A quiet heading inside a group: Pinned, Recent. */
722function 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 */
736function 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 */
861function 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 */
876type 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
889function 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 */
898function 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 */
1034function 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. */
1100function 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 */
1140function 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 */
1263function 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. */
1281function 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 */
1299function 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 */
1406export 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 */
1422function 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). */
1477const 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 */
1513export 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 */
1574function 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 */
1633function 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). */
1655type 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 */
1663function 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 */
1723function 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 */
1747function 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. */
1797export 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. */
1809const 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. */
1867const 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. */
1870function 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. */
1971function 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. */
1991function 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. */
2004type 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 */
2010function 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
2062type Command = PaletteCommand;
2063
2064/** Everything the palette can jump to, from what the sidebar already knows. */
2065function 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. */
2156export 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 */
2166export 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 */
2190export 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 */
2228function 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. */
2235type 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. */
2238const 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. */
2251function 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. */
2257function 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 */
2274function 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. */
2310function 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. */
2326function 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 */
2343export 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. */
2588function 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}