flagon-io/g1t

public

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

g1t/apps/web/app/components/shell.tsx

1,402 lines54,486 bytesCodeBlame
1import {
2 Activity,
3 GanttChart,
4 KanbanSquare,
5 Package,
6 Sparkles,
7 BarChart3,
8 BookMarked,
9 BookOpen,
10 Check,
11 ChevronLeft,
12 ChevronRight,
13 ChevronsUpDown,
14 Ellipsis,
15 CircleDot,
16 Code2,
17 Compass,
18 CreditCard,
19 GitPullRequest,
20 History,
21 House,
22 Fingerprint,
23 Gauge,
24 KeyRound,
25 LifeBuoy,
26 Box,
27 LayoutGrid,
28 ListTree,
29 Lock,
30 LogIn,
31 LogOut,
32 Menu,
33 Plus,
34 Search,
35 Plug,
36 Settings,
37 Users,
38 Webhook,
39 Globe,
40 GitBranch,
41 PlayCircle,
42 Bot,
43 Brain,
44 Network,
45 ShieldCheck,
46 Rocket,
47 X,
48} from "lucide-react";
49import { type ReactNode, useEffect, useMemo, useRef, useState } from "react";
50import { Form, Link, NavLink, useLocation, useNavigation, useRouteLoaderData, useSubmit } from "react-router";
51
52import type { Abilities, Membership, Spike, User } from "@g1t/contracts";
53
54import { CommandPalette, type PaletteCommand, usePaletteShortcut } from "./command-palette";
55import { LegalRow } from "./footer";
56import { Logo, Mark } from "./logo";
57import { Avatar, notACredential } from "./ui";
58import {
59 DropdownMenu,
60 DropdownMenuContent,
61 DropdownMenuItem,
62 DropdownMenuLabel,
63 DropdownMenuSeparator,
64 DropdownMenuTrigger,
65} from "./ui/dropdown-menu";
66import { type RoadmapItem, roadmapIn } from "../lib/roadmap";
67import { SETTINGS_CAPABILITY, type ViewerAccess, seesSettings } from "../lib/access";
68import { VISITOR_LINKS, projectPages } from "../lib/chrome";
69import { withNext } from "../lib/next";
70import { useSignUpCopy } from "../lib/registration";
71
72/**
73 * What the sidebar needs, worked out by the root loader. For a visitor who
74 * is not signed in there is no workspace, no projects and no usage: only
75 * the project being looked at, if they can see it.
76 */
77export type ShellData = {
78 /** The workspace the sidebar is about: the one being looked at, or their first. */
79 workspace: Membership | null;
80 /** Its projects, by name: `name` is the slug in their address. */
81 repos: { namespace: string; name: string; title?: string; isPrivate: boolean }[];
82 /** Repositories shared with them in workspaces they do not belong to. */
83 shared?: { namespace: string; name: string; isPrivate: boolean }[];
84 /** The project being looked at, if any, whoever owns it. */
85 repo: {
86 namespace: string;
87 name: string;
88 member: boolean;
89 issues: number;
90 pulls: number;
91 } | null;
92 /** Where the workspace stands against its usage limit, if billing is on. */
93 limit: {
94 exposureMicros: number;
95 ceilingMicros: number | null;
96 state: "ok" | "warning" | "stopped";
97 comped: boolean;
98 } | null;
99 /** Whether g1t charges nothing for now, while it is being built out. */
100 free?: boolean;
101 /** What its agents have cost since the start of the month. */
102 /** This month's usage: charged, or at cost while g1t is free. */
103 monthUsageMicros: number | null;
104 /** Whether new compute is paused (a spend spike or a hold), and whether the viewer can answer it. */
105 compute?: { paused: string | null; spike: Spike | null; owner: boolean } | null;
106};
107
108function SidebarLink({
109 to,
110 icon,
111 end,
112 count,
113 also,
114 drill,
115 children,
116}: {
117 to: string;
118 icon: ReactNode;
119 end?: boolean;
120 count?: number;
121 /** Other path prefixes under which this link is the current one. */
122 also?: string | string[];
123 /** It opens a list of its own: a chevron says so, always or on hover. */
124 drill?: boolean | "hover";
125 children: ReactNode;
126}) {
127 const { pathname } = useLocation();
128 return (
129 <NavLink
130 to={to}
131 end={end}
132 prefetch="intent"
133 className={({ isActive, isPending }) => {
134 const current =
135 isActive || [also ?? []].flat().some((prefix) => pathname === prefix || pathname.startsWith(prefix + "/"));
136 return `group flex h-8 items-center gap-2.5 rounded-md px-2 text-[0.8125rem] transition-colors ${
137 current
138 ? "bg-raised font-medium text-fg"
139 : isPending
140 ? "bg-raised/60 text-fg"
141 : "text-muted hover:bg-raised/60 hover:text-fg"
142 }`;
143 }}
144 >
145 <span className="shrink-0 text-faint group-hover:text-muted">{icon}</span>
146 <span className="min-w-0 grow truncate">{children}</span>
147 {count != null && count > 0 && (
148 <span className="rounded bg-line px-1.5 text-[0.6875rem] tabular-nums text-muted">
149 {count}
150 </span>
151 )}
152 {drill && (
153 <ChevronRight
154 size={14}
155 aria-hidden="true"
156 className={`-mr-0.5 shrink-0 text-faint transition-opacity ${drill === "hover" ? "opacity-0 group-hover:opacity-100 group-focus-visible:opacity-100" : ""}`}
157 />
158 )}
159 </NavLink>
160 );
161}
162
163/**
164 * Something a project will have, shown where it will live so people can
165 * see where g1t is going. Not a link: nothing is behind it yet.
166 */
167function SidebarSoon({ icon, children, about }: { icon: ReactNode; children: ReactNode; about: string }) {
168 return (
169 <div
170 title={about}
171 aria-disabled="true"
172 className="flex h-8 cursor-default items-center gap-2.5 rounded-md px-2 text-[0.8125rem] text-faint"
173 >
174 <span className="shrink-0 opacity-70">{icon}</span>
175 <span className="grow truncate">{children}</span>
176 <span className="rounded-full px-1.5 py-px text-[0.625rem] font-medium tracking-wide text-muted uppercase ring-1 ring-line">
177 Soon
178 </span>
179 </div>
180 );
181}
182
183/**
184 * A page that is coming, as a link to its roadmap page: faint, with Soon,
185 * and marked current on any of its section's Soon pages.
186 */
187function SidebarSoonLink({
188 to,
189 also,
190 icon,
191 about,
192 children,
193}: {
194 to: string;
195 also?: string[];
196 icon: ReactNode;
197 about: string;
198 children: ReactNode;
199}) {
200 const { pathname } = useLocation();
201 return (
202 <NavLink
203 to={to}
204 title={about}
205 prefetch="intent"
206 className={({ isActive }) => {
207 const current = isActive || (also ?? []).some((prefix) => pathname === prefix || pathname.startsWith(prefix + "/"));
208 return `group flex h-8 items-center gap-2.5 rounded-md px-2 text-[0.8125rem] transition-colors ${
209 current ? "bg-raised font-medium text-fg" : "text-faint hover:bg-raised/60 hover:text-muted"
210 }`;
211 }}
212 >
213 <span className="shrink-0 opacity-80">{icon}</span>
214 <span className="min-w-0 grow truncate">{children}</span>
215 <span className="rounded-full px-1.5 py-px text-[0.625rem] font-medium tracking-wide text-muted uppercase ring-1 ring-line">
216 Soon
217 </span>
218 </NavLink>
219 );
220}
221
222/** Icons for what spans projects. */
223const WORKSPACE_ICONS: Record<string, ReactNode> = {
224 board: <KanbanSquare size={15} />,
225 roadmap: <GanttChart size={15} />,
226 packages: <Package size={15} />,
227 fleet: <Bot size={15} />,
228};
229
230/** The Soon pages of a section, as paths, so its link is current on them. */
231function soonPaths(base: string, section: RoadmapItem["section"]): string[] {
232 return roadmapIn(section).map((item) => `${base}/soon/${item.key}`);
233}
234
235function SidebarGroup({
236 title,
237 action,
238 className = "",
239 children,
240}: {
241 title: string;
242 action?: ReactNode;
243 className?: string;
244 children: ReactNode;
245}) {
246 return (
247 <section className={className}>
248 <div className="mb-1 flex h-6 items-center justify-between px-2">
249 <h2 className="text-xs font-medium text-faint">{title}</h2>
250 {action}
251 </div>
252 <div className="space-y-px">{children}</div>
253 </section>
254 );
255}
256
257/** What a workspace is called where people read it: its display name, else its slug. */
258function displayName(membership: Membership): string {
259 return membership.name?.trim() || membership.slug;
260}
261
262function WorkspaceSwitcher({ user, shell }: { user: User; shell: ShellData }) {
263 const workspace = shell.workspace;
264 const current = workspace?.slug;
265 const label = workspace ? displayName(workspace) : "Choose a workspace";
266 return (
267 <DropdownMenu>
268 {/* The name goes to the workspace; only the arrows switch it. The
269 name gets all the room there is, and the whole of it on hover. */}
270 <Link
271 to={current ? `/${current}` : "/workspaces/new"}
272 title={workspace ? `${label} · g1t.sh/${workspace.slug}` : undefined}
273 className="flex h-9 min-w-0 grow items-center gap-2 rounded-md px-2 transition-colors hover:bg-raised"
274 >
275 {workspace ? (
276 <Avatar name={workspace.slug} image={workspace.avatar} size={20} square />
277 ) : (
278 <span className="size-5 shrink-0 rounded-md border border-dashed border-line-strong" />
279 )}
280 <span className="min-w-0 truncate text-[0.8125rem] font-medium">{label}</span>
281 </Link>
282 <DropdownMenuTrigger
283 aria-label="Switch workspace"
284 className="flex h-9 w-7 shrink-0 items-center justify-center rounded-md text-faint outline-none transition-colors hover:bg-raised hover:text-fg data-[state=open]:bg-raised data-[state=open]:text-fg"
285 >
286 <ChevronsUpDown size={14} />
287 </DropdownMenuTrigger>
288 <DropdownMenuContent align="start" className="w-64">
289 <DropdownMenuLabel>Workspaces</DropdownMenuLabel>
290 {(user.workspaces ?? []).map((membership) => (
291 <DropdownMenuItem asChild key={membership.slug}>
292 <Link to={`/${membership.slug}`} title={displayName(membership)}>
293 <Avatar name={membership.slug} image={membership.avatar} size={24} square />
294 <span className="flex min-w-0 grow flex-col leading-tight">
295 <span className="truncate">{displayName(membership)}</span>
296 <span className="truncate font-mono text-[0.6875rem] text-faint">{membership.slug}</span>
297 </span>
298 <span className="shrink-0 text-[0.6875rem] text-faint capitalize">{membership.role}</span>
299 {membership.slug === current ? (
300 <Check className="shrink-0 text-accent" />
301 ) : (
302 <span className="size-4 shrink-0" aria-hidden="true" />
303 )}
304 </Link>
305 </DropdownMenuItem>
306 ))}
307 <DropdownMenuSeparator />
308 <DropdownMenuItem asChild>
309 <Link to="/workspaces/new">
310 <Plus />
311 New workspace
312 </Link>
313 </DropdownMenuItem>
314 </DropdownMenuContent>
315 </DropdownMenu>
316 );
317}
318
319function AccountMenu({ user }: { user: User }) {
320 const submit = useSubmit();
321 return (
322 <DropdownMenu>
323 <DropdownMenuTrigger className="flex h-10 w-full items-center gap-2.5 rounded-md px-2 text-left outline-none transition-colors hover:bg-raised data-[state=open]:bg-raised">
324 <Avatar name={user.username} image={user.avatar} size={22} />
325 <span className="min-w-0 grow truncate text-[0.8125rem] font-medium">{user.username}</span>
326 <Ellipsis size={15} className="shrink-0 text-faint" />
327 </DropdownMenuTrigger>
328 <DropdownMenuContent align="start" side="top" className="w-64">
329 <DropdownMenuLabel>
330 Signed in as <span className="font-mono font-medium text-fg">{user.username}</span>
331 </DropdownMenuLabel>
332 <DropdownMenuSeparator />
333 <DropdownMenuItem asChild>
334 <Link to="/settings">
335 <Settings />
336 Your settings
337 </Link>
338 </DropdownMenuItem>
339 <DropdownMenuItem asChild>
340 <a href="https://docs.g1t.sh/">
341 <BookOpen />
342 Documentation
343 </a>
344 </DropdownMenuItem>
345 <DropdownMenuSeparator />
346 {/* Submitted from here: the menu closes on select, and a button
347 that has left the page cannot submit a form. */}
348 <DropdownMenuItem onSelect={() => submit(null, { method: "post", action: "/logout" })}>
349 <LogOut />
350 Sign out
351 </DropdownMenuItem>
352 {/* The site footer's row, slim: the app has no footer of its own.
353 Rendered only while the menu is open, so status is fetched then. */}
354 <DropdownMenuSeparator />
355 <div className="px-1.5 pt-1 pb-1.5">
356 <LegalRow user={user} compact />
357 </div>
358 </DropdownMenuContent>
359 </DropdownMenu>
360 );
361}
362
363/**
364 * A workspace's settings pages, which the sidebar drills into: who belongs,
365 * what it pays, and its record. What it builds and runs with (secrets,
366 * integrations, webhooks, guardrails) sits in the main list.
367 */
368const SETTINGS_PAGE = /^\/([^/]+)\/-\/(settings|people|repositories|tokens|billing|audit)(\/|$)/;
369/** A project's settings pages, which the project's menu drills into. */
370const REPO_SETTINGS_PAGE = /^\/([^/]+)\/([^/-][^/]*)\/settings(\/|$)/;
371
372/**
373 * The sidebar's lists are a stack, the way a phone pushes a screen: one
374 * drilled into slides in from the right over the full width, while the one
375 * it covers drifts a quarter of the way left and fades, both on one long
376 * ease-out, the fade quicker than the move so the two never blur together.
377 * Going back reverses it.
378 */
379const LAYER =
380 "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";
381/** One list, filling its layer. */
382const PANEL = "h-full overflow-y-auto px-2 pb-4";
383
384/** One list in the stack: what it is, and its rows. */
385type Level = { key: string; node: ReactNode };
386
387/** True once the page has hydrated, so what the server drew never animates in. */
388function useHydrated(): boolean {
389 const [hydrated, setHydrated] = useState(false);
390 useEffect(() => setHydrated(true), []);
391 return hydrated;
392}
393
394/**
395 * Drilling in and out of the sidebar's lists. `trail` is the way from the
396 * main list to the one shown, worked out from the address, so a link
397 * straight to a settings page opens the sidebar already drilled in, and
398 * leaving it comes back out. Lists deeper than the one shown stay in
399 * place, off to the right, so going back slides them away rather than
400 * dropping them. Moving to a list at the same depth (a workspace's settings
401 * to a project) fades the new one in where it is.
402 */
403function Drill({ trail }: { trail: Level[] }) {
404 const depth = trail.length - 1;
405 const hydrated = useHydrated();
406 const stack = useRef<Level[]>(trail);
407 const before = stack.current;
408 const within = trail.every((level, index) => before[index]?.key === level.key);
409 stack.current = within ? [...trail, ...before.slice(trail.length)] : trail;
410 const levels = stack.current;
411 // What was showing last time, to tell drilling in from moving across.
412 const shown = useRef({ depth, keys: levels.map((level) => level.key) });
413 useEffect(() => {
414 shown.current = { depth, keys: levels.map((level) => level.key) };
415 });
416 return (
417 <div className="relative min-h-0 grow overflow-hidden">
418 {levels.map((level, index) => {
419 const current = index === depth;
420 const place =
421 index < depth
422 ? "pointer-events-none -translate-x-1/4 opacity-0"
423 : current
424 ? "translate-x-0 opacity-100"
425 : "pointer-events-none translate-x-full opacity-0";
426 // A list that was not there before arrives: from the right when
427 // drilling in, in place when moving across.
428 const arriving = hydrated && current && !shown.current.keys.includes(level.key);
429 const enter = arriving
430 ? depth > shown.current.depth
431 ? "starting:translate-x-full starting:opacity-0"
432 : "starting:opacity-0"
433 : "";
434 return (
435 <div key={level.key} className={`${LAYER} ${place} ${enter}`} inert={!current} aria-hidden={!current || undefined}>
436 {level.node}
437 </div>
438 );
439 })}
440 </div>
441 );
442}
443
444/**
445 * The row at the top of a list drilled into: the list's name, which goes
446 * back out, and what it belongs to, faint at the end.
447 */
448function BackRow({ to, label, context }: { to: string; label: ReactNode; context?: ReactNode }) {
449 return (
450 <Link
451 to={to}
452 prefetch="intent"
453 className="group mt-3 flex h-8 items-center gap-1.5 rounded-md pr-2 pl-1 text-[0.8125rem] font-medium text-fg transition-colors hover:bg-raised/60"
454 >
455 <ChevronLeft size={16} className="shrink-0 text-faint transition-transform group-hover:-translate-x-0.5 group-hover:text-muted" />
456 <span className="min-w-0 shrink-0 truncate">{label}</span>
457 {context && (
458 <span className="ml-auto min-w-0 truncate pl-2 font-mono text-[0.6875rem] font-normal text-faint">{context}</span>
459 )}
460 </Link>
461 );
462}
463
464/** A thin rule between groups of a list. */
465function Rule() {
466 return <div role="separator" className="mx-2 my-2.5 h-px bg-line" />;
467}
468
469/**
470 * The main list: where you go, the projects, what the workspace builds and
471 * runs with across them, then its usage, support and settings.
472 */
473function MainMenu({ user, shell }: { user: User | null; shell: ShellData }) {
474 const ws = shell.workspace;
475 const active = shell.repo;
476 // The repository being looked at is listed even when it is someone else's.
477 const listed =
478 active && !shell.repos.some((repo) => repo.namespace === active.namespace && repo.name === active.name)
479 ? [{ namespace: active.namespace, name: active.name, isPrivate: false }, ...shell.repos]
480 : shell.repos;
481 // A visitor browses: no workspace, no projects of their own.
482 if (!user) {
483 return (
484 <nav aria-label="g1t" className={PANEL}>
485 <div className="mt-3 space-y-px">
486 {VISITOR_LINKS.map((link) => (
487 <SidebarLink key={link.to} to={link.to} icon={link.to === "/search" ? <Search size={15} /> : <Compass size={15} />}>
488 {link.label}
489 </SidebarLink>
490 ))}
491 </div>
492 </nav>
493 );
494 }
495 return (
496 <nav aria-label="g1t" className={PANEL}>
497 <div className="mt-3 space-y-px">
498 <SidebarLink to="/" end icon={<House size={15} />}>
499 Mission control
500 </SidebarLink>
501 {ws && (
502 <SidebarLink to={`/${ws.slug}`} end icon={<LayoutGrid size={15} />}>
503 Overview
504 </SidebarLink>
505 )}
506 <SidebarLink to="/explore" icon={<Compass size={15} />}>
507 Explore
508 </SidebarLink>
509 </div>
510
511 <Rule />
512 <SidebarGroup
513 title="Projects"
514 action={
515 ws && (
516 <Link
517 to={`/new?workspace=${ws.slug}`}
518 aria-label="New project"
519 className="rounded p-0.5 text-faint hover:bg-raised hover:text-fg"
520 >
521 <Plus size={13} />
522 </Link>
523 )
524 }
525 >
526 {listed.length === 0 && <p className="px-2 py-1 text-xs text-faint">None yet.</p>}
527 {listed.map((repo) => (
528 <SidebarLink
529 key={`${repo.namespace}/${repo.name}`}
530 to={`/${repo.namespace}/${repo.name}`}
531 icon={repo.isPrivate ? <Lock size={15} /> : <Box size={15} />}
532 drill="hover"
533 >
534 {repo.namespace !== ws?.slug && <span className="font-mono text-faint">{repo.namespace}/</span>}
535 {"title" in repo && repo.title ? repo.title : repo.name}
536 </SidebarLink>
537 ))}
538 </SidebarGroup>
539 {(shell.shared ?? []).length > 0 && (
540 <SidebarGroup title="Shared with you" className="mt-3">
541 {(shell.shared ?? []).map((repo) => (
542 <SidebarLink
543 key={`${repo.namespace}/${repo.name}`}
544 to={`/${repo.namespace}/${repo.name}`}
545 icon={repo.isPrivate ? <Lock size={15} /> : <Box size={15} />}
546 drill="hover"
547 >
548 <span className="font-mono text-faint">{repo.namespace}/</span>
549 {repo.name}
550 </SidebarLink>
551 ))}
552 </SidebarGroup>
553 )}
554
555 {ws && (
556 <>
557 <Rule />
558 <div className="space-y-px">
559 <SidebarLink to={`/${ws.slug}/-/agents`} icon={<Bot size={15} />}>
560 Agent fleet
561 </SidebarLink>
562 <SidebarLink to={`/${ws.slug}/-/context`} icon={<Network size={15} />}>
563 Context
564 </SidebarLink>
565 <SidebarLink to={`/${ws.slug}/-/memory`} icon={<Brain size={15} />}>
566 Memory
567 </SidebarLink>
568 <SidebarLink to={`/${ws.slug}/-/security`} icon={<ShieldCheck size={15} />}>
569 Security
570 </SidebarLink>
571 <SidebarLink to={`/${ws.slug}/-/guardrails`} icon={<Gauge size={15} />}>
572 Guardrails
573 </SidebarLink>
574 <SidebarLink to={`/${ws.slug}/-/secrets`} icon={<Lock size={15} />}>
575 Secrets and variables
576 </SidebarLink>
577 <SidebarLink to={`/${ws.slug}/-/integrations`} icon={<Plug size={15} />}>
578 Integrations
579 </SidebarLink>
580 <SidebarLink to={`/${ws.slug}/-/webhooks`} icon={<Webhook size={15} />}>
581 Webhooks
582 </SidebarLink>
583 {roadmapIn("Workspace").map((item) => (
584 <SidebarSoonLink
585 key={item.key}
586 to={`/${ws.slug}/-/soon/${item.key}`}
587 icon={WORKSPACE_ICONS[item.key] ?? <Sparkles size={15} />}
588 about={item.summary}
589 >
590 {item.title === "Board" ? "Boards" : item.title}
591 </SidebarSoonLink>
592 ))}
593 </div>
594
595 <Rule />
596 <div className="space-y-px">
597 <SidebarLink to={`/${ws.slug}/-/usage`} icon={<BarChart3 size={15} />}>
598 Usage
599 </SidebarLink>
600 <SidebarLink to="/support" icon={<LifeBuoy size={15} />}>
601 Support
602 </SidebarLink>
603 {/* Who belongs, what it pays and its record: a list of their own. */}
604 <SidebarLink
605 to={ws.role === "owner" ? `/${ws.slug}/-/settings` : `/${ws.slug}/-/people`}
606 icon={<Settings size={15} />}
607 drill
608 >
609 Settings
610 </SidebarLink>
611 </div>
612 </>
613 )}
614 </nav>
615 );
616}
617
618/** A workspace's settings, drilled into from Settings in the main list. */
619function SettingsMenu({ slug, owner }: { slug: string; owner: boolean }) {
620 return (
621 <nav aria-label="Workspace settings" className={PANEL}>
622 <BackRow to={`/${slug}`} label="Settings" context={slug} />
623 <div className="mt-2 space-y-px">
624 {owner && (
625 <SidebarLink to={`/${slug}/-/settings`} icon={<Settings size={15} />}>
626 General
627 </SidebarLink>
628 )}
629 <SidebarLink to={`/${slug}/-/people`} icon={<Users size={15} />}>
630 Members
631 </SidebarLink>
632 <SidebarLink to={`/${slug}/-/repositories`} icon={<BookMarked size={15} />}>
633 Repositories
634 </SidebarLink>
635 <SidebarLink to={`/${slug}/-/tokens`} icon={<KeyRound size={15} />}>
636 Access tokens
637 </SidebarLink>
638 </div>
639 <Rule />
640 <div className="space-y-px">
641 <SidebarLink to={`/${slug}/-/billing`} icon={<CreditCard size={15} />}>
642 Billing and plans
643 </SidebarLink>
644 <SidebarLink to={`/${slug}/-/audit`} icon={<History size={15} />}>
645 Audit log
646 </SidebarLink>
647 </div>
648 </nav>
649 );
650}
651
652/**
653 * What the repository menu needs. Known from the address before the page's
654 * data arrives, so the menu is right from the first frame; the counts fill
655 * in once it does.
656 */
657type MenuRepo = {
658 namespace: string;
659 name: string;
660 member: boolean;
661 /** Whether they see its settings; from the repository's page once it loads. */
662 settings?: boolean;
663 /** What they may do there, once the repository's page loads. */
664 can?: Abilities;
665 issues?: number;
666 pulls?: number;
667};
668
669function sameRepo(a: { namespace: string; name: string } | null, b: { namespace: string; name: string } | null) {
670 return (
671 a != null &&
672 b != null &&
673 a.namespace.toLowerCase() === b.namespace.toLowerCase() &&
674 a.name.toLowerCase() === b.name.toLowerCase()
675 );
676}
677
678/**
679 * A project's own list, drilled into while you are in it: everything about
680 * the project, running and its code, and nothing else, with the way back
681 * out to all of them.
682 */
683function RepoMenu({
684 repo,
685 isPrivate,
686 back,
687}: {
688 repo: MenuRepo;
689 isPrivate: boolean;
690 /** Where the way back leads: the workspace's projects, mission control, or Explore. */
691 back: { to: string; label: string };
692}) {
693 const base = `/${repo.namespace}/${repo.name}`;
694 // What a member sees, and what everyone who can see the project does.
695 const shows = new Set(projectPages(repo.member, repo.can));
696 return (
697 <nav aria-label={`${repo.namespace}/${repo.name}`} className={PANEL}>
698 <BackRow to={back.to} label={back.label} />
699 <NavLink
700 to={base}
701 end
702 title="Overview"
703 className={({ isActive }) =>
704 `mt-2 flex items-center gap-2 rounded-md px-2 py-1.5 transition-colors ${isActive ? "bg-raised" : "hover:bg-raised/60"}`
705 }
706 >
707 <span className="flex size-6 shrink-0 items-center justify-center rounded-md bg-raised text-muted ring-1 ring-line">
708 {isPrivate ? <Lock size={13} /> : <Box size={13} />}
709 </span>
710 <span className="min-w-0 truncate font-mono text-[0.8125rem]">
711 <span className="text-faint">{repo.namespace}/</span>
712 <span className="font-semibold text-fg">{repo.name}</span>
713 </span>
714 </NavLink>
715 {/* A project's pages, in the order people use them. A page with more
716 than one view shows them as tabs across its top. */}
717 <Rule />
718 <div className="space-y-px">
719 <SidebarLink to={`${base}/code`} also={[`${base}/tree`, `${base}/blob`, `${base}/commits`, `${base}/commit`, ...soonPaths(base, "Code")]} icon={<Code2 size={15} />}>
720 Code
721 </SidebarLink>
722 <SidebarLink to={`${base}/issues`} also={[`${base}/plans`, ...soonPaths(base, "Issues")]} icon={<CircleDot size={15} />} count={repo.issues}>
723 Issues
724 </SidebarLink>
725 <SidebarLink to={`${base}/pulls`} also={[`${base}/pull`, `${base}/queue`]} icon={<GitPullRequest size={15} />} count={repo.pulls}>
726 Pull requests
727 </SidebarLink>
728 <SidebarLink to={`${base}/agents`} also={[`${base}/sessions`, `${base}/memory`, ...soonPaths(base, "Agents")]} icon={<Bot size={15} />}>
729 Agents
730 </SidebarLink>
731 <SidebarLink to={`${base}/actions`} icon={<PlayCircle size={15} />}>
732 Workflows
733 </SidebarLink>
734 </div>
735 <Rule />
736 <div className="space-y-px">
737 {shows.has("deployments") ? (
738 <SidebarLink to={`${base}/deployments`} also={soonPaths(base, "Deployments")} icon={<Rocket size={15} />}>
739 Deployments
740 </SidebarLink>
741 ) : null}
742 <SidebarSoonLink to={`${base}/soon/logs`} also={soonPaths(base, "Observability")} icon={<Activity size={15} />} about="Logs, errors, uptime and analytics of the project's deployed apps.">
743 Observability
744 </SidebarSoonLink>
745 {shows.has("security") ? (
746 <SidebarLink to={`${base}/security`} also={soonPaths(base, "Security")} icon={<ShieldCheck size={15} />}>
747 Security
748 </SidebarLink>
749 ) : null}
750 <SidebarSoonLink to={`${base}/soon/delivery`} also={soonPaths(base, "Insights")} icon={<BarChart3 size={15} />} about="Delivery metrics, costs and the work agents do.">
751 Insights
752 </SidebarSoonLink>
753 </div>
754 {(repo.settings ?? shows.has("settings")) && (
755 <>
756 <Rule />
757 <SidebarLink to={`${base}/settings`} icon={<Settings size={15} />} drill>
758 Settings
759 </SidebarLink>
760 </>
761 )}
762 </nav>
763 );
764}
765
766/**
767 * A project's settings, drilled into from its list: what is about running
768 * it, then its agents, then its code and who can reach it. The way back
769 * leads to the project.
770 */
771function RepoSettingsMenu({ repo }: { repo: MenuRepo }) {
772 const base = `/${repo.namespace}/${repo.name}`;
773 // Each page shows to the roles that can use it, once the role is known.
774 const shows = (page: string) => !repo.can || repo.can[SETTINGS_CAPABILITY[page] ?? "administer"];
775 const running = shows("deployments");
776 const agents = shows("agents") || shows("guardrails");
777 return (
778 <nav aria-label={`${repo.namespace}/${repo.name} settings`} className={PANEL}>
779 <BackRow to={base} label="Settings" context={`${repo.namespace}/${repo.name}`} />
780 {shows("") && (
781 <div className="mt-2 space-y-px">
782 <SidebarLink to={`${base}/settings`} end icon={<Settings size={15} />}>
783 General
784 </SidebarLink>
785 </div>
786 )}
787 {running && (
788 <>
789 {shows("") && <Rule />}
790 <div className={shows("") ? "space-y-px" : "mt-2 space-y-px"}>
791 <SidebarLink to={`${base}/settings/deployments`} icon={<Rocket size={15} />}>
792 Deployments
793 </SidebarLink>
794 <SidebarLink to={`${base}/settings/domains`} icon={<Globe size={15} />}>
795 Domains
796 </SidebarLink>
797 <SidebarLink to={`${base}/settings/dependencies`} icon={<Network size={15} />}>
798 Dependencies
799 </SidebarLink>
800 </div>
801 </>
802 )}
803 {agents && (
804 <>
805 <Rule />
806 <div className="space-y-px">
807 {shows("agents") && (
808 <SidebarLink to={`${base}/settings/agents`} icon={<Bot size={15} />}>
809 Agents
810 </SidebarLink>
811 )}
812 {shows("guardrails") && (
813 <SidebarLink to={`${base}/settings/guardrails`} icon={<ShieldCheck size={15} />}>
814 Guardrails
815 </SidebarLink>
816 )}
817 </div>
818 </>
819 )}
820 <Rule />
821 <div className="space-y-px">
822 {shows("repository") && (
823 <SidebarLink to={`${base}/settings/repository`} icon={<BookMarked size={15} />}>
824 Repository
825 </SidebarLink>
826 )}
827 {shows("access") && (
828 <SidebarLink to={`${base}/settings/access`} icon={<Users size={15} />}>
829 Access
830 </SidebarLink>
831 )}
832 {shows("branches") && (
833 <SidebarLink to={`${base}/settings/branches`} icon={<GitBranch size={15} />}>
834 Branches and merging
835 </SidebarLink>
836 )}
837 {shows("secrets") && (
838 <SidebarLink to={`${base}/settings/secrets`} icon={<Lock size={15} />}>
839 Secrets and variables
840 </SidebarLink>
841 )}
842 {shows("webhooks") && (
843 <SidebarLink to={`${base}/settings/webhooks`} icon={<Webhook size={15} />}>
844 Webhooks
845 </SidebarLink>
846 )}
847 </div>
848 </nav>
849 );
850}
851
852/** Your own settings, as the sidebar shows them on the settings page. */
853function AccountSettingsMenu() {
854 const { hash } = useLocation();
855 const item = (id: string, icon: ReactNode, label: string) => (
856 <Link
857 to={`/settings#${id}`}
858 className={`group flex h-8 items-center gap-2.5 rounded-md px-2 text-[0.8125rem] transition-colors ${
859 hash === `#${id}` ? "bg-raised font-medium text-fg" : "text-muted hover:bg-raised/60 hover:text-fg"
860 }`}
861 >
862 <span className="shrink-0 text-faint group-hover:text-muted">{icon}</span>
863 {label}
864 </Link>
865 );
866 return (
867 <nav aria-label="Your settings" className={PANEL}>
868 <BackRow to="/" label="Your settings" />
869 <div className="mt-2 space-y-px">
870 {item("ssh-keys", <Fingerprint size={15} />, "SSH keys")}
871 {item("tokens", <KeyRound size={15} />, "Access tokens")}
872 {item("applications", <Plug size={15} />, "Connected applications")}
873 </div>
874 </nav>
875 );
876}
877
878/**
879 * Signing in and signing up, in place of the account for a visitor. Signing
880 * in brings them back to the page they are on.
881 */
882function VisitorPanel() {
883 const { pathname, search } = useLocation();
884 const signUp = useSignUpCopy();
885 return (
886 <div className="space-y-2">
887 <p className="px-1 text-xs text-muted">Sign in to open issues, review pull requests and run agents.</p>
888 <div className="grid grid-cols-2 gap-2">
889 <Link
890 to={withNext("/login", pathname + search)}
891 className="flex h-9 items-center justify-center gap-1.5 rounded-md border border-line text-[0.8125rem] font-medium text-fg/90 transition-colors hover:border-line-strong hover:bg-raised hover:text-fg"
892 >
893 <LogIn size={14} />
894 Sign in
895 </Link>
896 <Link
897 to={withNext("/register", pathname + search)}
898 className="flex h-9 items-center justify-center rounded-md bg-fg text-[0.8125rem] font-medium text-bg transition-colors hover:bg-white"
899 >
900 {signUp.primary}
901 </Link>
902 </div>
903 <nav aria-label="About g1t" className="flex flex-wrap justify-center gap-x-2 gap-y-0.5 px-1 pt-1 text-[0.6875rem] text-faint">
904 <Link to="/status" className="hover:text-fg">Status</Link>
905 <Link to="/support" className="hover:text-fg">Support</Link>
906 <Link to="/policies" className="hover:text-fg">Policies</Link>
907 <Link to="/policies/privacy" className="hover:text-fg">Privacy</Link>
908 <Link to="/security" className="hover:text-fg">Security</Link>
909 </nav>
910 </div>
911 );
912}
913
914function Sidebar({
915 user,
916 shell,
917 onFind,
918 onClose,
919}: {
920 user: User | null;
921 shell: ShellData;
922 onFind: () => void;
923 /** In the sheet on a small screen: closing it, at the end of the top row. */
924 onClose?: () => void;
925}) {
926 const ws = shell.workspace;
927 const { pathname } = useLocation();
928 const going = useNavigation().location?.pathname;
929 // Where the sidebar is drilled to follows the page being gone to, so it
930 // moves as the link is followed, not once the page arrives.
931 const target = going ?? pathname;
932 const inSettings = ws != null && SETTINGS_PAGE.exec(target)?.[1]?.toLowerCase() === ws.slug;
933 const inAccount = target === "/settings";
934 const active = shell.repo;
935 // In a repository, or on the way into one, its own list.
936 const repoPath = /^\/([^/]+)\/([^/-][^/]*)(\/|$)/.exec(target);
937 const reserved = new Set(["settings", "explore", "search", "new", "u", "pricing", "avatars", "workspaces", "login", "logout", "register", "verify", "forgot", "reset", "device", "oauth", "policies", "security", "support", "status", "invite", ".well-known"]);
938 // An invitation to a repository is answered before its menu means anything.
939 const inRepo =
940 repoPath != null && !reserved.has(repoPath[1]) && repoPath[2] !== "-" && !/^\/[^/]+\/[^/]+\/invitations\/?$/.test(target);
941 // A project's settings, one level further in.
942 const inRepoSettings = inRepo && REPO_SETTINGS_PAGE.test(target);
943 // The repository the menu is for: the one loaded if it is the one being
944 // gone to, else what the address says, at once.
945 const targetRepo = inRepo && repoPath ? { namespace: repoPath[1], name: repoPath[2] } : null;
946 const guessed: MenuRepo | null = targetRepo
947 ? sameRepo(active, targetRepo)
948 ? active
949 : {
950 ...targetRepo,
951 member: (user?.workspaces ?? []).some((m) => m.slug === targetRepo.namespace.toLowerCase()),
952 }
953 : null;
954 // The viewer's role there, from the repository's page once it has loaded.
955 const page = useRouteLoaderData("routes/repo/layout") as
956 | { repo?: { namespace: string; name: string }; access?: ViewerAccess }
957 | undefined;
958 const menuRepo: MenuRepo | null =
959 guessed && page?.access && page.repo && sameRepo(page.repo, guessed)
960 ? { ...guessed, member: page.access.insider, settings: seesSettings(page.access), can: page.access.can }
961 : guessed;
962
963 // The way from the main list to the one shown.
964 const trail: Level[] = [{ key: "main", node: <MainMenu user={user} shell={shell} /> }];
965 if (user && inAccount) {
966 trail.push({ key: "account", node: <AccountSettingsMenu /> });
967 } else if (ws && inSettings) {
968 trail.push({ key: `settings:${ws.slug}`, node: <SettingsMenu slug={ws.slug} owner={ws.role === "owner"} /> });
969 } else if (menuRepo) {
970 const key = `repo:${menuRepo.namespace}/${menuRepo.name}`.toLowerCase();
971 // Out of a project: to its workspace's projects when they are yours.
972 const home = (user?.workspaces ?? []).find((m) => m.slug === menuRepo.namespace.toLowerCase());
973 const back = !user
974 ? { to: "/explore", label: "Explore" }
975 : home
976 ? { to: `/${home.slug}`, label: "All projects" }
977 : { to: "/", label: "Mission control" };
978 trail.push({
979 key,
980 node: (
981 <RepoMenu
982 repo={menuRepo}
983 isPrivate={[...shell.repos, ...(shell.shared ?? [])].some((repo) => sameRepo(repo, menuRepo) && repo.isPrivate)}
984 back={back}
985 />
986 ),
987 });
988 if (inRepoSettings) trail.push({ key: `${key}:settings`, node: <RepoSettingsMenu repo={menuRepo} /> });
989 }
990
991 return (
992 <div className="flex h-full flex-col">
993 {/* The same height and rule as the top bar, so the two read as one line. */}
994 <div className="flex h-16 shrink-0 items-center gap-1 border-b border-line pr-2 pl-2.5">
995 {user ? (
996 <>
997 <Link to="/" aria-label="g1t home" className="shrink-0 rounded-md p-1.5 hover:bg-raised">
998 <Mark className="size-6" />
999 </Link>
1000 <span className="shrink-0 text-line-strong" aria-hidden="true">
1001 /
1002 </span>
1003 <WorkspaceSwitcher user={user} shell={shell} />
1004 </>
1005 ) : (
1006 <Link to="/" aria-label="g1t home" className="mr-auto flex rounded-md px-1.5 py-1.5 hover:bg-raised">
1007 <Logo />
1008 </Link>
1009 )}
1010 {onClose && (
1011 <button
1012 type="button"
1013 aria-label="Close menu"
1014 onClick={onClose}
1015 className="flex size-9 shrink-0 items-center justify-center rounded-md text-faint hover:bg-raised hover:text-fg"
1016 >
1017 <X size={16} />
1018 </button>
1019 )}
1020 </div>
1021 <div className="px-2 pt-3">
1022 <button
1023 type="button"
1024 onClick={onFind}
1025 className="flex h-9 w-full items-center gap-2 rounded-md bg-surface px-2.5 text-[0.8125rem] text-faint ring-1 ring-line transition-colors hover:text-muted hover:ring-line-strong"
1026 >
1027 <Search size={14} />
1028 <span className="grow text-left">Find…</span>
1029 <kbd className="rounded bg-raised px-1.5 font-mono text-[0.625rem] text-muted ring-1 ring-line">⌘K</kbd>
1030 </button>
1031 </div>
1032 <Drill trail={trail} />
1033 {/* The account, or signing in, as one row at the very bottom. */}
1034 <div className="shrink-0 border-t border-line p-2">
1035 {user ? <AccountMenu user={user} /> : <VisitorPanel />}
1036 </div>
1037 </div>
1038 );
1039}
1040
1041/** Words for the sections a path can end in. */
1042const SECTIONS: Record<string, string> = {
1043 issues: "Issues",
1044 pulls: "Pull requests",
1045 queue: "Merge queue",
1046 commits: "Commits",
1047 plans: "Outcomes",
1048 actions: "Workflows",
1049 soon: "Soon",
1050 deployments: "Deployments",
1051 repository: "Repository",
1052 access: "Access",
1053 invitations: "Invitation",
1054 repositories: "Repositories",
1055 branches: "Branches and merging",
1056 dependencies: "Dependencies",
1057 code: "Files",
1058 secrets: "Secrets and variables",
1059 settings: "Settings",
1060 people: "Members",
1061 tokens: "Access tokens",
1062 usage: "Usage",
1063 billing: "Billing and plans",
1064 integrations: "Integrations",
1065 webhooks: "Webhooks",
1066 domains: "Domains",
1067 guardrails: "Guardrails",
1068 audit: "Audit log",
1069 tree: "Code",
1070 blob: "Code",
1071};
1072
1073/** Where the page is, as a trail of links: workspace / repository / section. */
1074function Breadcrumbs({ pathname }: { pathname: string }) {
1075 const parts = pathname.split("/").filter(Boolean);
1076 const reserved = ["settings", "explore", "new", "search", "workspaces", "policies", "security", "support", "status", "invite"];
1077 if (parts.length === 0) return <span className="text-sm font-medium">Mission control</span>;
1078 if (reserved.includes(parts[0]!)) {
1079 const words: Record<string, string> = {
1080 settings: "Account",
1081 explore: "Explore",
1082 new: "New project",
1083 search: "Search",
1084 workspaces: "New workspace",
1085 policies: "Policies",
1086 security: "Security",
1087 support: "Support",
1088 status: "Status",
1089 invite: "Invite",
1090 };
1091 return <span className="text-sm font-medium">{words[parts[0]!]}</span>;
1092 }
1093 // A person's profile, by their handle.
1094 if (parts[0] === "u" && parts[1]) {
1095 return <span className="truncate font-mono text-[0.8125rem] font-medium">@{parts[1]}</span>;
1096 }
1097 const [owner, second, third, fourth] = parts;
1098 const trail: { label: string; to: string; mono?: boolean }[] = [{ label: owner!, to: `/${owner}`, mono: true }];
1099 if (second === "-") {
1100 const page = `/${owner}/-/${third}`;
1101 if (third && SETTINGS_PAGE.test(page)) {
1102 trail.push({ label: "Settings", to: `/${owner}/-/settings` });
1103 trail.push({ label: third === "settings" ? "General" : (SECTIONS[third] ?? third), to: page });
1104 } else if (third) trail.push({ label: SECTIONS[third] ?? third, to: page });
1105 } else if (second) {
1106 const repo = `/${owner}/${second}`;
1107 trail.push({ label: second, to: repo, mono: true });
1108 if (third === "pull" && fourth) trail.push({ label: `Pull request #${fourth}`, to: `${repo}/pull/${fourth}` });
1109 else if (third === "issues" && fourth && fourth !== "new") trail.push({ label: `Issue #${fourth}`, to: `${repo}/issues/${fourth}` });
1110 else if (third === "commit" && fourth) trail.push({ label: fourth.slice(0, 7), to: `${repo}/commit/${fourth}`, mono: true });
1111 else if (third && SECTIONS[third]) {
1112 trail.push({ label: SECTIONS[third]!, to: `${repo}/${third}` });
1113 // A settings page names which one: Settings / Secrets and variables.
1114 if (third === "settings" && fourth && SECTIONS[fourth]) {
1115 trail.push({ label: SECTIONS[fourth]!, to: `${repo}/settings/${fourth}` });
1116 }
1117 }
1118 }
1119 return (
1120 <nav aria-label="Where you are" className="flex min-w-0 items-center gap-1.5 text-sm">
1121 {trail.map((crumb, index) => (
1122 <span key={index} className="flex min-w-0 items-center gap-1.5">
1123 {index > 0 && <span className="text-line-strong">/</span>}
1124 <Link
1125 to={crumb.to}
1126 className={`truncate rounded px-1 py-0.5 transition-colors hover:bg-raised ${
1127 index === trail.length - 1 ? "font-medium text-fg" : "text-muted hover:text-fg"
1128 } ${crumb.mono ? "font-mono text-[0.8125rem]" : ""}`}
1129 >
1130 {crumb.label}
1131 </Link>
1132 </span>
1133 ))}
1134 </nav>
1135 );
1136}
1137
1138type Command = PaletteCommand;
1139
1140/** Everything the palette can jump to, from what the sidebar already knows. */
1141function commandsFor(user: User | null, shell: ShellData, here: string, signUpLabel = "Sign up"): Command[] {
1142 if (!user) return visitorCommands(shell, here, signUpLabel);
1143 const commands: Command[] = [
1144 { label: "Mission control", to: "/", icon: <House size={15} /> },
1145 { label: "Explore repositories", to: "/explore", icon: <Compass size={15} /> },
1146 { label: "Search g1t", hint: "Repositories, code, issues, people", to: "/search", icon: <Search size={15} /> },
1147 { label: "New project", to: "/new", icon: <Plus size={15} /> },
1148 { label: "New workspace", to: "/workspaces/new", icon: <Plus size={15} /> },
1149 { label: "Your settings", to: "/settings", icon: <Settings size={15} /> },
1150 ];
1151 const repo = shell.repo;
1152 if (repo) {
1153 const base = `/${repo.namespace}/${repo.name}`;
1154 const name = `${repo.namespace}/${repo.name}`;
1155 commands.unshift(
1156 { label: "Overview", hint: name, to: base, icon: <LayoutGrid size={15} /> },
1157 { label: "Code", hint: name, to: `${base}/code`, icon: <Code2 size={15} /> },
1158 { label: "Issues", hint: name, to: `${base}/issues`, icon: <CircleDot size={15} /> },
1159 { label: "New issue", hint: name, to: `${base}/issues/new`, icon: <Plus size={15} /> },
1160 { label: "Pull requests", hint: name, to: `${base}/pulls`, icon: <GitPullRequest size={15} /> },
1161 { label: "Commits", hint: name, to: `${base}/commits`, icon: <History size={15} /> },
1162 ...(repo.member
1163 ? [
1164 { label: "Plan work", hint: name, to: `${base}/plans`, icon: <ListTree size={15} /> },
1165 { label: "Deployments", hint: name, to: `${base}/deployments`, icon: <Rocket size={15} /> },
1166 { label: "Secrets and variables", hint: name, to: `${base}/settings/secrets`, icon: <Lock size={15} /> },
1167 { label: "Project settings", hint: name, to: `${base}/settings`, icon: <Settings size={15} /> },
1168 { label: "Repository settings", hint: name, to: `${base}/settings/repository`, icon: <Settings size={15} /> },
1169 ]
1170 : []),
1171 );
1172 }
1173 for (const membership of user.workspaces ?? []) {
1174 commands.push(
1175 { label: displayName(membership), hint: `Workspace · ${membership.slug}`, to: `/${membership.slug}`, icon: <Avatar name={membership.slug} image={membership.avatar} size={15} square /> },
1176 { label: "Usage", hint: membership.slug, to: `/${membership.slug}/-/usage`, icon: <BarChart3 size={15} /> },
1177 { label: "Billing and plans", hint: `${membership.slug} · Settings`, to: `/${membership.slug}/-/billing`, icon: <CreditCard size={15} /> },
1178 { label: "Access tokens", hint: `${membership.slug} · Settings`, to: `/${membership.slug}/-/tokens`, icon: <KeyRound size={15} /> },
1179 { label: "Integrations", hint: membership.slug, to: `/${membership.slug}/-/integrations`, icon: <Plug size={15} /> },
1180 );
1181 }
1182 for (const listed of shell.repos) {
1183 commands.push({
1184 label: listed.title ?? listed.name,
1185 hint: `${listed.namespace} · Project`,
1186 to: `/${listed.namespace}/${listed.name}`,
1187 icon: <Box size={15} />,
1188 });
1189 }
1190 return commands;
1191}
1192
1193/** What the palette offers a visitor: browsing, the project they are in, and signing in. */
1194function visitorCommands(shell: ShellData, here: string, signUpLabel: string): Command[] {
1195 const commands: Command[] = [];
1196 const repo = shell.repo;
1197 if (repo) {
1198 const base = `/${repo.namespace}/${repo.name}`;
1199 const name = `${repo.namespace}/${repo.name}`;
1200 commands.push(
1201 { label: "Overview", hint: name, to: base, icon: <LayoutGrid size={15} /> },
1202 { label: "Code", hint: name, to: `${base}/code`, icon: <Code2 size={15} /> },
1203 { label: "Issues", hint: name, to: `${base}/issues`, icon: <CircleDot size={15} /> },
1204 { label: "Pull requests", hint: name, to: `${base}/pulls`, icon: <GitPullRequest size={15} /> },
1205 { label: "Commits", hint: name, to: `${base}/commits`, icon: <History size={15} /> },
1206 );
1207 }
1208 commands.push(
1209 { label: "Explore", hint: "Public projects", to: "/explore", icon: <Compass size={15} /> },
1210 { label: "Search g1t", hint: "Repositories, code, issues, people", to: "/search", icon: <Search size={15} /> },
1211 { label: "Pricing", to: "/pricing", icon: <CreditCard size={15} /> },
1212 { label: "Documentation", to: "https://docs.g1t.sh/", icon: <BookOpen size={15} /> },
1213 { label: "Sign in", to: withNext("/login", here), icon: <LogIn size={15} /> },
1214 { label: signUpLabel, to: withNext("/register", here), icon: <Plus size={15} /> },
1215 );
1216 return commands;
1217}
1218
1219/**
1220 * A bar across the top of the page while the next one loads, as GitHub
1221 * has: it appears at once, creeps towards the end while waiting, then fills
1222 * and fades when the page arrives.
1223 */
1224export function Progress() {
1225 const navigation = useNavigation();
1226 const busy = navigation.state !== "idle";
1227 const [width, setWidth] = useState(0);
1228 const [visible, setVisible] = useState(false);
1229 useEffect(() => {
1230 if (busy) {
1231 setVisible(true);
1232 setWidth(12);
1233 // Slows as it goes, never quite arriving until the page does.
1234 const timer = setInterval(() => setWidth((now) => now + (90 - now) * 0.12), 200);
1235 return () => clearInterval(timer);
1236 }
1237 setWidth((now) => (now > 0 ? 100 : 0));
1238 const fade = setTimeout(() => setVisible(false), 250);
1239 const reset = setTimeout(() => setWidth(0), 550);
1240 return () => {
1241 clearTimeout(fade);
1242 clearTimeout(reset);
1243 };
1244 }, [busy]);
1245 return (
1246 <div
1247 aria-hidden="true"
1248 className={`pointer-events-none fixed inset-x-0 top-0 z-[60] h-[3px] transition-opacity duration-300 ${
1249 visible ? "opacity-100" : "opacity-0"
1250 }`}
1251 >
1252 <div
1253 className="h-full bg-accent shadow-[0_0_10px_var(--color-accent)] transition-[width] duration-200 ease-out"
1254 style={{ width: `${width}%` }}
1255 />
1256 </div>
1257 );
1258}
1259
1260/**
1261 * The app: a sidebar with the workspace, its repositories and the sections
1262 * of the one being looked at; a slim bar with search and the account; and
1263 * the page. A visitor who is not signed in gets the same frame, with
1264 * Explore and Search in place of the workspace, and signing in in place of
1265 * the account.
1266 */
1267export function AppShell({
1268 user,
1269 shell,
1270 banner,
1271 children,
1272}: {
1273 user: User | null;
1274 shell: ShellData;
1275 banner?: ReactNode;
1276 children: ReactNode;
1277}) {
1278 const { pathname, search } = useLocation();
1279 const [drawer, setDrawer] = useState(false);
1280 const [palette, setPalette] = useState(false);
1281 const here = pathname + search;
1282 const signUpLabel = useSignUpCopy().primary;
1283 const commands = useMemo(() => commandsFor(user, shell, here, signUpLabel), [user, shell, here, signUpLabel]);
1284
1285 // A new page closes the drawer on small screens.
1286 useEffect(() => setDrawer(false), [pathname]);
1287 usePaletteShortcut(() => setPalette((open) => !open));
1288
1289 return (
1290 <div className="min-h-screen">
1291 <Progress />
1292 <aside className="fixed inset-y-0 left-0 z-40 hidden w-64 border-r border-line bg-[color-mix(in_srgb,var(--color-surface)_70%,var(--color-bg))] lg:block">
1293 <Sidebar user={user} shell={shell} onFind={() => setPalette(true)} />
1294 </aside>
1295 {drawer && (
1296 <div className="fixed inset-0 z-50 lg:hidden">
1297 <button
1298 type="button"
1299 aria-label="Close menu"
1300 className="absolute inset-0 bg-black/60"
1301 onClick={() => setDrawer(false)}
1302 />
1303 <aside className="absolute inset-y-0 left-0 w-72 border-r border-line bg-surface">
1304 <Sidebar user={user} shell={shell} onFind={() => setPalette(true)} onClose={() => setDrawer(false)} />
1305 </aside>
1306 </div>
1307 )}
1308
1309 <div className="flex min-h-screen min-w-0 flex-col lg:pl-64">
1310 <header className="sticky top-0 z-30 flex h-16 items-center gap-3 border-b border-line bg-bg/85 px-4 backdrop-blur sm:px-6">
1311 <button
1312 type="button"
1313 aria-label="Open menu"
1314 onClick={() => setDrawer(true)}
1315 className="rounded-md p-1.5 text-muted hover:bg-raised hover:text-fg lg:hidden"
1316 >
1317 <Menu size={18} />
1318 </button>
1319 <Breadcrumbs pathname={pathname} />
1320 <Form action="/search" role="search" className="relative ml-auto hidden w-full max-w-64 md:block">
1321 <Search size={14} className="pointer-events-none absolute top-1/2 left-3 -translate-y-1/2 text-faint" />
1322 <input
1323 name="q"
1324 {...notACredential()}
1325 placeholder="Search g1t"
1326 aria-label="Search g1t"
1327 className="h-9 w-full rounded-md border border-line bg-surface pr-12 pl-8 text-sm outline-none transition-colors placeholder:text-faint hover:border-line-strong focus:border-accent-dim"
1328 />
1329 <kbd className="pointer-events-none absolute top-1/2 right-2 -translate-y-1/2 rounded bg-raised px-1.5 font-mono text-[0.625rem] text-muted ring-1 ring-line">
1330 ⌘K
1331 </kbd>
1332 </Form>
1333 <div className="ml-auto flex items-center gap-1.5 md:ml-0">
1334 <a
1335 href="https://docs.g1t.sh/"
1336 className="hidden rounded-md px-2.5 py-1.5 text-sm text-muted transition-colors hover:bg-raised hover:text-fg sm:block"
1337 >
1338 Docs
1339 </a>
1340 {!user ? (
1341 // The sidebar has these too, but on a phone it is folded away.
1342 <Link
1343 to={withNext("/login", here)}
1344 className="flex h-9 items-center rounded-md bg-fg px-3 text-sm font-medium text-bg transition-colors hover:bg-white lg:hidden"
1345 >
1346 Sign in
1347 </Link>
1348 ) : (
1349 <DropdownMenu>
1350 <DropdownMenuTrigger
1351 aria-label="Create"
1352 className="flex h-9 items-center gap-1.5 rounded-md bg-fg px-3 text-sm font-medium text-bg outline-none transition-colors hover:bg-white"
1353 >
1354 <Plus size={14} />
1355 New
1356 </DropdownMenuTrigger>
1357 <DropdownMenuContent align="end">
1358 {shell.repo && (
1359 <DropdownMenuItem asChild>
1360 <Link to={`/${shell.repo.namespace}/${shell.repo.name}/issues/new`}>
1361 <CircleDot />
1362 New issue
1363 </Link>
1364 </DropdownMenuItem>
1365 )}
1366 {shell.repo?.member && (
1367 <DropdownMenuItem asChild>
1368 <Link to={`/${shell.repo.namespace}/${shell.repo.name}/plans`}>
1369 <ListTree />
1370 Plan work
1371 </Link>
1372 </DropdownMenuItem>
1373 )}
1374 <DropdownMenuItem asChild>
1375 <Link to="/new">
1376 <Box />
1377 New project
1378 </Link>
1379 </DropdownMenuItem>
1380 <DropdownMenuItem asChild>
1381 <Link to="/workspaces/new">
1382 <Users />
1383 New workspace
1384 </Link>
1385 </DropdownMenuItem>
1386 </DropdownMenuContent>
1387 </DropdownMenu>
1388 )}
1389 </div>
1390 </header>
1391 {banner}
1392 <main className="min-w-0 grow">{children}</main>
1393 </div>
1394 <CommandPalette
1395 open={palette}
1396 onOpenChange={setPalette}
1397 commands={commands}
1398 repo={shell.repo ? `${shell.repo.namespace}/${shell.repo.name}` : null}
1399 />
1400 </div>
1401 );
1402}