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