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