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,140 lines45,071 bytesCodeBlame
1import {
2 Activity,
3 ArrowLeft,
4 GanttChart,
5 KanbanSquare,
6 Package,
7 Sparkles,
8 BarChart3,
9 BookMarked,
10 BookOpen,
11 Check,
12 ChevronsUpDown,
13 Ellipsis,
14 CircleDot,
15 Code2,
16 Compass,
17 CornerDownLeft,
18 CreditCard,
19 GitPullRequest,
20 History,
21 Fingerprint,
22 KeyRound,
23 Box,
24 LayoutDashboard,
25 LayoutGrid,
26 ListTree,
27 Lock,
28 LogOut,
29 Menu,
30 Plus,
31 Search,
32 Plug,
33 Settings,
34 Users,
35 Webhook,
36 PlayCircle,
37 Bot,
38 ShieldCheck,
39 Rocket,
40 X,
41} from "lucide-react";
42import { type ReactNode, useEffect, useMemo, useRef, useState } from "react";
43import { Form, Link, NavLink, useLocation, useNavigate, useNavigation, useSubmit } from "react-router";
44
45import type { Membership, User } from "@g1t/contracts";
46import { MICROS_PER_DOLLAR } from "@g1t/contracts";
47
48import { Mark } from "./logo";
49import { Avatar } from "./ui";
50import {
51 DropdownMenu,
52 DropdownMenuContent,
53 DropdownMenuItem,
54 DropdownMenuLabel,
55 DropdownMenuSeparator,
56 DropdownMenuTrigger,
57} from "./ui/dropdown-menu";
58import { type RoadmapItem, roadmapIn } from "../lib/roadmap";
59
60/** What the sidebar needs, worked out by the root loader for a signed-in person. */
61export type ShellData = {
62 /** The workspace the sidebar is about: the one being looked at, or their first. */
63 workspace: Membership | null;
64 /** Its projects, by name: `name` is the slug in their address. */
65 repos: { namespace: string; name: string; title?: string; isPrivate: boolean }[];
66 /** The project being looked at, if any, whoever owns it. */
67 repo: {
68 namespace: string;
69 name: string;
70 member: boolean;
71 issues: number;
72 pulls: number;
73 } | null;
74 /** Where the workspace stands against its usage limit, if billing is on. */
75 limit: {
76 exposureMicros: number;
77 ceilingMicros: number | null;
78 state: "ok" | "warning" | "stopped";
79 comped: boolean;
80 } | null;
81 /** Whether g1t charges nothing for now, while it is being built out. */
82 free?: boolean;
83 /** What its agents have cost since the start of the month. */
84 /** This month's usage: charged, or at cost while g1t is free. */
85 monthUsageMicros: number | null;
86};
87
88function SidebarLink({
89 to,
90 icon,
91 end,
92 count,
93 also,
94 children,
95}: {
96 to: string;
97 icon: ReactNode;
98 end?: boolean;
99 count?: number;
100 /** Other path prefixes under which this link is the current one. */
101 also?: string | string[];
102 children: ReactNode;
103}) {
104 const { pathname } = useLocation();
105 return (
106 <NavLink
107 to={to}
108 end={end}
109 prefetch="intent"
110 className={({ isActive, isPending }) => {
111 const current =
112 isActive || [also ?? []].flat().some((prefix) => pathname === prefix || pathname.startsWith(prefix + "/"));
113 return `group flex h-8 items-center gap-2.5 rounded-md px-2 text-[0.8125rem] transition-colors ${
114 current
115 ? "bg-raised font-medium text-fg"
116 : isPending
117 ? "bg-raised/60 text-fg"
118 : "text-muted hover:bg-raised/60 hover:text-fg"
119 }`;
120 }}
121 >
122 <span className="shrink-0 text-faint group-hover:text-muted">{icon}</span>
123 <span className="min-w-0 grow truncate">{children}</span>
124 {count != null && count > 0 && (
125 <span className="rounded bg-line px-1.5 text-[0.6875rem] tabular-nums text-muted">
126 {count}
127 </span>
128 )}
129 </NavLink>
130 );
131}
132
133/**
134 * Something a project will have, shown where it will live so people can
135 * see where g1t is going. Not a link: nothing is behind it yet.
136 */
137function SidebarSoon({ icon, children, about }: { icon: ReactNode; children: ReactNode; about: string }) {
138 return (
139 <div
140 title={about}
141 aria-disabled="true"
142 className="flex h-8 cursor-default items-center gap-2.5 rounded-md px-2 text-[0.8125rem] text-faint"
143 >
144 <span className="shrink-0 opacity-70">{icon}</span>
145 <span className="grow truncate">{children}</span>
146 <span className="rounded-full px-1.5 py-px text-[0.625rem] font-medium tracking-wide text-muted uppercase ring-1 ring-line">
147 Soon
148 </span>
149 </div>
150 );
151}
152
153/**
154 * A page that is coming, as a link to its roadmap page: faint, with Soon,
155 * and marked current on any of its section's Soon pages.
156 */
157function SidebarSoonLink({
158 to,
159 also,
160 icon,
161 about,
162 children,
163}: {
164 to: string;
165 also?: string[];
166 icon: ReactNode;
167 about: string;
168 children: ReactNode;
169}) {
170 const { pathname } = useLocation();
171 return (
172 <NavLink
173 to={to}
174 title={about}
175 prefetch="intent"
176 className={({ isActive }) => {
177 const current = isActive || (also ?? []).some((prefix) => pathname === prefix || pathname.startsWith(prefix + "/"));
178 return `group flex h-8 items-center gap-2.5 rounded-md px-2 text-[0.8125rem] transition-colors ${
179 current ? "bg-raised font-medium text-fg" : "text-faint hover:bg-raised/60 hover:text-muted"
180 }`;
181 }}
182 >
183 <span className="shrink-0 opacity-80">{icon}</span>
184 <span className="min-w-0 grow truncate">{children}</span>
185 <span className="rounded-full px-1.5 py-px text-[0.625rem] font-medium tracking-wide text-muted uppercase ring-1 ring-line">
186 Soon
187 </span>
188 </NavLink>
189 );
190}
191
192/** Icons for what spans projects. */
193const WORKSPACE_ICONS: Record<string, ReactNode> = {
194 board: <KanbanSquare size={15} />,
195 roadmap: <GanttChart size={15} />,
196 packages: <Package size={15} />,
197 fleet: <Bot size={15} />,
198};
199
200/** The Soon pages of a section, as paths, so its link is current on them. */
201function soonPaths(base: string, section: RoadmapItem["section"]): string[] {
202 return roadmapIn(section).map((item) => `${base}/soon/${item.key}`);
203}
204
205function SidebarGroup({ title, action, children }: { title: string; action?: ReactNode; children: ReactNode }) {
206 return (
207 <section className="mt-6">
208 <div className="mb-1 flex h-6 items-center justify-between px-2">
209 <h2 className="text-xs font-medium text-faint">{title}</h2>
210 {action}
211 </div>
212 <div className="space-y-px">{children}</div>
213 </section>
214 );
215}
216
217/** What a workspace is called where people read it: its display name, else its slug. */
218function displayName(membership: Membership): string {
219 return membership.name?.trim() || membership.slug;
220}
221
222function WorkspaceSwitcher({ user, shell }: { user: User; shell: ShellData }) {
223 const workspace = shell.workspace;
224 const current = workspace?.slug;
225 const label = workspace ? displayName(workspace) : "Choose a workspace";
226 return (
227 <DropdownMenu>
228 {/* The name goes to the workspace; only the arrows switch it. The
229 name gets all the room there is, and the whole of it on hover. */}
230 <Link
231 to={current ? `/${current}` : "/workspaces/new"}
232 title={workspace ? `${label} · g1t.sh/${workspace.slug}` : undefined}
233 className="flex h-8 min-w-0 grow items-center gap-1.5 rounded-md px-1 transition-colors hover:bg-raised"
234 >
235 {workspace ? (
236 <Avatar name={workspace.slug} image={workspace.avatar} size={20} square />
237 ) : (
238 <span className="size-5 shrink-0 rounded-md border border-dashed border-line-strong" />
239 )}
240 <span className="min-w-0 truncate text-[0.8125rem] font-medium">{label}</span>
241 </Link>
242 <DropdownMenuTrigger
243 aria-label="Switch workspace"
244 className="flex h-8 w-5 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"
245 >
246 <ChevronsUpDown size={14} />
247 </DropdownMenuTrigger>
248 <DropdownMenuContent align="start" className="w-64">
249 <DropdownMenuLabel>Workspaces</DropdownMenuLabel>
250 {(user.workspaces ?? []).map((membership) => (
251 <DropdownMenuItem asChild key={membership.slug}>
252 <Link to={`/${membership.slug}`} title={displayName(membership)}>
253 <Avatar name={membership.slug} image={membership.avatar} size={24} square />
254 <span className="flex min-w-0 grow flex-col leading-tight">
255 <span className="truncate">{displayName(membership)}</span>
256 <span className="truncate font-mono text-[0.6875rem] text-faint">{membership.slug}</span>
257 </span>
258 <span className="shrink-0 text-[0.6875rem] text-faint capitalize">{membership.role}</span>
259 {membership.slug === current ? (
260 <Check className="shrink-0 text-accent" />
261 ) : (
262 <span className="size-4 shrink-0" aria-hidden="true" />
263 )}
264 </Link>
265 </DropdownMenuItem>
266 ))}
267 <DropdownMenuSeparator />
268 <DropdownMenuItem asChild>
269 <Link to="/workspaces/new">
270 <Plus />
271 New workspace
272 </Link>
273 </DropdownMenuItem>
274 </DropdownMenuContent>
275 </DropdownMenu>
276 );
277}
278
279/**
280 * This month's usage, and how close the workspace is to its usage limit,
281 * as Vercel shows a plan's usage.
282 */
283function UsageCard({ slug, shell }: { slug: string; shell: ShellData }) {
284 if (shell.monthUsageMicros == null) return null;
285 const spent = shell.monthUsageMicros;
286 const limit = shell.limit;
287 const ceiling = limit?.ceilingMicros ?? null;
288 const share = limit && ceiling ? Math.min(1, limit.exposureMicros / Math.max(ceiling, 1)) : 0;
289 const tone = limit?.state === "stopped" ? "text-danger" : limit?.state === "warning" ? "text-warn" : "text-faint";
290 const bar = limit?.state === "stopped" ? "bg-danger" : limit?.state === "warning" ? "bg-warn" : "bg-accent";
291 return (
292 <Link
293 to={`/${slug}/-/usage`}
294 className="block rounded-lg bg-surface p-3 ring-1 ring-line transition-colors hover:ring-line-strong"
295 >
296 <span className="flex items-baseline justify-between text-xs">
297 <span className="font-medium text-fg">Usage</span>
298 <span className="text-faint">this month</span>
299 </span>
300 <span className="mt-2 flex items-baseline justify-between">
301 <span className="font-mono text-sm tabular-nums">${(spent / MICROS_PER_DOLLAR).toFixed(2)}</span>
302 {shell.free ? (
303 <span className="text-xs text-accent">Free for now</span>
304 ) : limit?.comped ? (
305 <span className="text-xs text-accent">Comped</span>
306 ) : (
307 ceiling != null && (
308 <span className={`text-xs ${tone}`}>
309 {limit?.state === "stopped" ? "Limit reached" : `$${(ceiling / MICROS_PER_DOLLAR).toFixed(2)} limit`}
310 </span>
311 )
312 )}
313 </span>
314 {ceiling != null && !limit?.comped && (
315 <span className="mt-2 block h-1 overflow-hidden rounded-full bg-raised">
316 <span
317 className={`block h-full rounded-full ${bar}`}
318 style={{ width: `${Math.max(share * 100, share > 0 ? 3 : 0)}%` }}
319 />
320 </span>
321 )}
322 </Link>
323 );
324}
325
326function AccountMenu({ user }: { user: User }) {
327 const submit = useSubmit();
328 return (
329 <DropdownMenu>
330 <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">
331 <Avatar name={user.username} image={user.avatar} size={22} />
332 <span className="min-w-0 grow truncate text-[0.8125rem] font-medium">{user.username}</span>
333 <Ellipsis size={15} className="shrink-0 text-faint" />
334 </DropdownMenuTrigger>
335 <DropdownMenuContent align="start" side="top" className="w-56">
336 <DropdownMenuLabel>
337 Signed in as <span className="font-mono font-medium text-fg">{user.username}</span>
338 </DropdownMenuLabel>
339 <DropdownMenuSeparator />
340 <DropdownMenuItem asChild>
341 <Link to="/settings">
342 <Settings />
343 Your settings
344 </Link>
345 </DropdownMenuItem>
346 <DropdownMenuItem asChild>
347 <a href="https://docs.g1t.sh/">
348 <BookOpen />
349 Documentation
350 </a>
351 </DropdownMenuItem>
352 <DropdownMenuSeparator />
353 {/* Submitted from here: the menu closes on select, and a button
354 that has left the page cannot submit a form. */}
355 <DropdownMenuItem onSelect={() => submit(null, { method: "post", action: "/logout" })}>
356 <LogOut />
357 Sign out
358 </DropdownMenuItem>
359 </DropdownMenuContent>
360 </DropdownMenu>
361 );
362}
363
364/** A workspace's settings pages, which the sidebar slides over to. */
365const SETTINGS_PAGE = /^\/([^/]+)\/-\/(settings|people|tokens|billing|integrations|webhooks|secrets)(\/|$)/;
366
367/**
368 * The sidebar's menus are two layers, the way a phone pushes a screen: the
369 * one arriving slides in from the right over the full width, while the one
370 * leaving drifts a quarter of the way left and fades, both on one long
371 * ease-out, the fade quicker than the move so the two never blur together.
372 * Going back reverses it.
373 */
374const LAYER =
375 "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";
376/** One menu, filling its layer. */
377const PANEL = "h-full overflow-y-auto px-2 pb-4";
378
379/** A workspace's settings, as the sidebar shows them in place of everything else. */
380function SettingsMenu({ slug, owner, open }: { slug: string; owner: boolean; open: boolean }) {
381 return (
382 <nav
383 aria-label="Workspace settings"
384 inert={!open}
385 className={PANEL}
386 >
387 <Link
388 to={`/${slug}`}
389 className="group mt-3 flex h-8 items-center gap-2 rounded-md px-2 text-[0.8125rem] text-muted transition-colors hover:bg-raised/60 hover:text-fg"
390 >
391 <ArrowLeft size={15} className="text-faint transition-transform group-hover:-translate-x-0.5 group-hover:text-muted" />
392 <span className="truncate">{slug}</span>
393 </Link>
394 <SidebarGroup title="Settings">
395 {owner && (
396 <SidebarLink to={`/${slug}/-/settings`} icon={<Settings size={15} />}>
397 General
398 </SidebarLink>
399 )}
400 <SidebarLink to={`/${slug}/-/people`} icon={<Users size={15} />}>
401 Members
402 </SidebarLink>
403 <SidebarLink to={`/${slug}/-/tokens`} icon={<KeyRound size={15} />}>
404 Access tokens
405 </SidebarLink>
406 <SidebarLink to={`/${slug}/-/billing`} icon={<CreditCard size={15} />}>
407 Billing and plans
408 </SidebarLink>
409 <SidebarLink to={`/${slug}/-/integrations`} icon={<Plug size={15} />}>
410 Integrations
411 </SidebarLink>
412 <SidebarLink to={`/${slug}/-/secrets`} icon={<Lock size={15} />}>
413 Secrets and variables
414 </SidebarLink>
415 <SidebarLink to={`/${slug}/-/webhooks`} icon={<Webhook size={15} />}>
416 Webhooks
417 </SidebarLink>
418 </SidebarGroup>
419 </nav>
420 );
421}
422
423/**
424 * What the repository menu needs. Known from the address before the page's
425 * data arrives, so the menu is right from the first frame; the counts fill
426 * in once it does.
427 */
428type MenuRepo = {
429 namespace: string;
430 name: string;
431 member: boolean;
432 issues?: number;
433 pulls?: number;
434};
435
436function sameRepo(a: { namespace: string; name: string } | null, b: { namespace: string; name: string } | null) {
437 return (
438 a != null &&
439 b != null &&
440 a.namespace.toLowerCase() === b.namespace.toLowerCase() &&
441 a.name.toLowerCase() === b.name.toLowerCase()
442 );
443}
444
445/**
446 * A project's own menu, which the sidebar slides to while you are in it,
447 * as it does for settings: everything about the project, running and its
448 * code, and nothing else, with the way back to everything.
449 */
450function RepoMenu({ repo, isPrivate, open }: { repo: MenuRepo; isPrivate: boolean; open: boolean }) {
451 const base = `/${repo.namespace}/${repo.name}`;
452 return (
453 <nav aria-label={`${repo.namespace}/${repo.name}`} inert={!open} className={PANEL}>
454 <Link
455 to="/"
456 className="group mt-3 flex h-8 items-center gap-2 rounded-md px-2 text-[0.8125rem] text-muted transition-colors hover:bg-raised/60 hover:text-fg"
457 >
458 <ArrowLeft size={15} className="text-faint transition-transform group-hover:-translate-x-0.5 group-hover:text-muted" />
459 Mission control
460 </Link>
461 <NavLink
462 to={base}
463 end
464 title="Overview"
465 className={({ isActive }) =>
466 `mt-3 flex items-center gap-2 rounded-md px-2 py-1.5 transition-colors ${isActive ? "bg-raised" : "hover:bg-raised/60"}`
467 }
468 >
469 <span className="flex size-6 shrink-0 items-center justify-center rounded-md bg-raised text-muted ring-1 ring-line">
470 {isPrivate ? <Lock size={13} /> : <Box size={13} />}
471 </span>
472 <span className="min-w-0 truncate font-mono text-[0.8125rem]">
473 <span className="text-faint">{repo.namespace}/</span>
474 <span className="font-semibold text-fg">{repo.name}</span>
475 </span>
476 </NavLink>
477 {/* A project's pages, flat and in the order people use them. A page
478 with more than one view shows them as tabs across its top. */}
479 <div className="mt-3 space-y-px">
480 <SidebarLink to={`${base}/code`} also={[`${base}/tree`, `${base}/blob`, `${base}/commits`, `${base}/commit`, ...soonPaths(base, "Code")]} icon={<Code2 size={15} />}>
481 Code
482 </SidebarLink>
483 <SidebarLink to={`${base}/issues`} also={[`${base}/plans`, ...soonPaths(base, "Issues")]} icon={<CircleDot size={15} />} count={repo.issues}>
484 Issues
485 </SidebarLink>
486 <SidebarLink to={`${base}/pulls`} also={[`${base}/pull`, `${base}/queue`]} icon={<GitPullRequest size={15} />} count={repo.pulls}>
487 Pull requests
488 </SidebarLink>
489 <SidebarSoonLink to={`${base}/soon/agents`} also={soonPaths(base, "Agents")} icon={<Bot size={15} />} about="Every agent at work on this project, its sessions, playbooks and memory.">
490 Agents
491 </SidebarSoonLink>
492 <SidebarLink to={`${base}/actions`} icon={<PlayCircle size={15} />}>
493 Workflows
494 </SidebarLink>
495 {repo.member ? (
496 <SidebarLink to={`${base}/deployments`} also={soonPaths(base, "Deployments")} icon={<Rocket size={15} />}>
497 Deployments
498 </SidebarLink>
499 ) : null}
500 <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.">
501 Observability
502 </SidebarSoonLink>
503 <SidebarSoonLink to={`${base}/soon/security`} also={soonPaths(base, "Security")} icon={<ShieldCheck size={15} />} about="Findings, secret scanning, dependency updates and code scanning, each fixed by an agent.">
504 Security
505 </SidebarSoonLink>
506 <SidebarSoonLink to={`${base}/soon/delivery`} also={soonPaths(base, "Insights")} icon={<BarChart3 size={15} />} about="Delivery metrics, costs and the work agents do.">
507 Insights
508 </SidebarSoonLink>
509 {repo.member && (
510 <SidebarLink to={`${base}/settings`} icon={<Settings size={15} />}>
511 Settings
512 </SidebarLink>
513 )}
514 </div>
515 </nav>
516 );
517}
518
519/** Your own settings, as the sidebar shows them on the settings page. */
520function AccountSettingsMenu({ open }: { open: boolean }) {
521 const { hash } = useLocation();
522 const item = (id: string, icon: ReactNode, label: string) => (
523 <Link
524 to={`/settings#${id}`}
525 className={`group flex h-8 items-center gap-2.5 rounded-md px-2 text-[0.8125rem] transition-colors ${
526 hash === `#${id}` ? "bg-raised font-medium text-fg" : "text-muted hover:bg-raised/60 hover:text-fg"
527 }`}
528 >
529 <span className="shrink-0 text-faint group-hover:text-muted">{icon}</span>
530 {label}
531 </Link>
532 );
533 return (
534 <nav
535 aria-label="Your settings"
536 inert={!open}
537 className={PANEL}
538 >
539 <Link
540 to="/"
541 className="group mt-3 flex h-8 items-center gap-2 rounded-md px-2 text-[0.8125rem] text-muted transition-colors hover:bg-raised/60 hover:text-fg"
542 >
543 <ArrowLeft size={15} className="text-faint transition-transform group-hover:-translate-x-0.5 group-hover:text-muted" />
544 Mission control
545 </Link>
546 <SidebarGroup title="Account">
547 {item("ssh-keys", <Fingerprint size={15} />, "SSH keys")}
548 {item("tokens", <KeyRound size={15} />, "Access tokens")}
549 {item("applications", <Plug size={15} />, "Connected applications")}
550 </SidebarGroup>
551 </nav>
552 );
553}
554
555function Sidebar({ user, shell, onFind }: { user: User; shell: ShellData; onFind: () => void }) {
556 const ws = shell.workspace;
557 const { pathname } = useLocation();
558 const going = useNavigation().location?.pathname;
559 // On a workspace's settings page, or on the way to one, its settings take
560 // the sidebar over.
561 const inSettings = ws != null && SETTINGS_PAGE.exec(going ?? pathname)?.[1]?.toLowerCase() === ws.slug;
562 const inAccount = (going ?? pathname) === "/settings";
563 const active = shell.repo;
564 // In a repository, or on the way into one, its own menu takes the sidebar.
565 const target = going ?? pathname;
566 const repoPath = /^\/([^/]+)\/([^/-][^/]*)(\/|$)/.exec(target);
567 const reserved = new Set(["settings", "explore", "search", "new", "workspaces", "login", "logout", "register", "verify", "forgot", "reset", "device", "oauth"]);
568 const inRepo = repoPath != null && !reserved.has(repoPath[1]) && repoPath[2] !== "-";
569 const away = inSettings || inAccount || inRepo;
570 // The repository the menu is for: the one loaded if it is the one being
571 // gone to, else what the address says, at once.
572 const targetRepo = inRepo && repoPath ? { namespace: repoPath[1], name: repoPath[2] } : null;
573 const menuRepo: MenuRepo | null = targetRepo
574 ? sameRepo(active, targetRepo)
575 ? active
576 : {
577 ...targetRepo,
578 member: (user.workspaces ?? []).some((m) => m.slug === targetRepo.namespace.toLowerCase()),
579 }
580 : null;
581 // What sits on the far side of the track. Kept while sliding back, so it
582 // does not vanish on the way out.
583 const side = useRef<"workspace" | "account" | "repo">("workspace");
584 if (inAccount) side.current = "account";
585 else if (inSettings) side.current = "workspace";
586 else if (inRepo) side.current = "repo";
587 // The repository last shown, kept for the slide back.
588 const shown = useRef<MenuRepo | null>(menuRepo);
589 if (menuRepo) shown.current = menuRepo;
590 // Moving between two of the far-side menus (settings to a repository,
591 // one repository to another) crossfades in place instead of sliding.
592 const detailKey = side.current === "repo" ? `repo:${shown.current?.namespace}/${shown.current?.name}` : side.current;
593 const lastAway = useRef(away);
594 const lastKey = useRef(detailKey);
595 const swapped = lastAway.current && away && lastKey.current !== detailKey;
596 useEffect(() => {
597 lastAway.current = away;
598 lastKey.current = detailKey;
599 });
600 // The repository being looked at is listed even when it is someone else's.
601 const listed =
602 active && !shell.repos.some((repo) => repo.namespace === active.namespace && repo.name === active.name)
603 ? [{ namespace: active.namespace, name: active.name, isPrivate: false }, ...shell.repos]
604 : shell.repos;
605 return (
606 <div className="flex h-full flex-col">
607 {/* The same height and rule as the top bar, so the two read as one line. */}
608 <div className="flex h-16 shrink-0 items-center gap-1 border-b border-line pr-2 pl-2.5">
609 <Link to="/" aria-label="g1t home" className="shrink-0 rounded-md p-1.5 hover:bg-raised">
610 <Mark className="size-6" />
611 </Link>
612 <span className="shrink-0 text-line-strong" aria-hidden="true">
613 /
614 </span>
615 <WorkspaceSwitcher user={user} shell={shell} />
616 </div>
617 <div className="px-2 pt-3">
618 <button
619 type="button"
620 onClick={onFind}
621 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"
622 >
623 <Search size={14} />
624 <span className="grow text-left">Find…</span>
625 <kbd className="rounded bg-raised px-1.5 font-mono text-[0.625rem] text-muted ring-1 ring-line">⌘K</kbd>
626 </button>
627 </div>
628 <div className="relative min-h-0 grow overflow-hidden">
629 <div className={`${LAYER} ${away ? "pointer-events-none -translate-x-1/4 opacity-0" : "translate-x-0 opacity-100"}`}>
630 <nav aria-label="g1t" inert={away} className={PANEL}>
631 <div className="mt-3 space-y-px">
632 <SidebarLink to="/" end icon={<LayoutDashboard size={15} />}>
633 Mission control
634 </SidebarLink>
635 <SidebarLink to="/explore" icon={<Compass size={15} />}>
636 Explore
637 </SidebarLink>
638 </div>
639
640 <SidebarGroup
641 title="Projects"
642 action={
643 ws && (
644 <Link
645 to={`/new?workspace=${ws.slug}`}
646 aria-label="New project"
647 className="rounded p-0.5 text-faint hover:bg-raised hover:text-fg"
648 >
649 <Plus size={13} />
650 </Link>
651 )
652 }
653 >
654 {listed.length === 0 && (
655 <p className="px-2 py-1 text-xs text-faint">None yet.</p>
656 )}
657 {listed.map((repo) => {
658 const base = `/${repo.namespace}/${repo.name}`;
659 return (
660 <div key={base}>
661 <SidebarLink
662 to={base}
663 icon={repo.isPrivate ? <Lock size={15} /> : <Box size={15} />}
664 >
665 <span className="text-[0.8125rem]">
666 {repo.namespace !== ws?.slug && (
667 <span className="font-mono text-faint">{repo.namespace}/</span>
668 )}
669 {"title" in repo && repo.title ? repo.title : repo.name}
670 </span>
671 </SidebarLink>
672 </div>
673 );
674 })}
675 </SidebarGroup>
676 {ws && (
677 <SidebarGroup title="Across projects">
678 {roadmapIn("Workspace").map((item) => (
679 <SidebarSoonLink
680 key={item.key}
681 to={`/${ws.slug}/-/soon/${item.key}`}
682 icon={WORKSPACE_ICONS[item.key] ?? <Sparkles size={15} />}
683 about={item.summary}
684 >
685 {item.title === "Board" ? "Boards" : item.title}
686 </SidebarSoonLink>
687 ))}
688 </SidebarGroup>
689 )}
690 </nav>
691 </div>
692 <div className={`${LAYER} ${away ? "translate-x-0 opacity-100" : "pointer-events-none translate-x-full opacity-0"}`}>
693 <div key={detailKey} className={`h-full ${swapped ? "animate-[g1t-swap_220ms_ease-out]" : ""}`}>
694 {side.current === "repo" && shown.current ? (
695 <RepoMenu
696 repo={shown.current}
697 isPrivate={shell.repos.some((repo) => sameRepo(repo, shown.current) && repo.isPrivate)}
698 open={inRepo}
699 />
700 ) : side.current === "account" || !ws ? (
701 <AccountSettingsMenu open={inAccount} />
702 ) : (
703 <SettingsMenu slug={ws.slug} owner={ws.role === "owner"} open={inSettings} />
704 )}
705 </div>
706 </div>
707 </div>
708
709 {/* The workspace's own things sit at the bottom, by its usage and the
710 account: a panel of their own, ruled off from whatever menu is above,
711 however long it grows. */}
712 <div className="shrink-0 space-y-2 border-t border-line bg-surface/50 p-2 pt-3">
713 {ws && (
714 <div className="space-y-px">
715 <p className="flex items-center gap-2 px-2 pb-1 text-[0.6875rem] font-medium uppercase tracking-wider text-faint">
716 <span>Workspace</span>
717 <span className="min-w-0 truncate font-mono normal-case tracking-normal text-muted">{ws.slug}</span>
718 </p>
719 <SidebarLink to={`/${ws.slug}/-/usage`} icon={<BarChart3 size={15} />}>
720 Usage
721 </SidebarLink>
722 {/* Everything else about the workspace lives in its settings, and only there. */}
723 <SidebarLink
724 to={ws.role === "owner" ? `/${ws.slug}/-/settings` : `/${ws.slug}/-/people`}
725 icon={<Settings size={15} />}
726 >
727 Settings
728 </SidebarLink>
729 </div>
730 )}
731 {ws && <UsageCard slug={ws.slug} shell={shell} />}
732 <AccountMenu user={user} />
733 </div>
734 </div>
735 );
736}
737
738/** Words for the sections a path can end in. */
739const SECTIONS: Record<string, string> = {
740 issues: "Issues",
741 pulls: "Pull requests",
742 queue: "Merge queue",
743 commits: "Commits",
744 plans: "Outcomes",
745 actions: "Workflows",
746 soon: "Soon",
747 deployments: "Deployments",
748 repository: "Repository",
749 dependencies: "Dependencies",
750 code: "Files",
751 secrets: "Secrets and variables",
752 settings: "Settings",
753 people: "Members",
754 tokens: "Access tokens",
755 usage: "Usage",
756 billing: "Billing and plans",
757 integrations: "Integrations",
758 webhooks: "Webhooks",
759 tree: "Code",
760 blob: "Code",
761};
762
763/** Where the page is, as a trail of links: workspace / repository / section. */
764function Breadcrumbs({ pathname }: { pathname: string }) {
765 const parts = pathname.split("/").filter(Boolean);
766 const reserved = ["settings", "explore", "new", "search", "workspaces"];
767 if (parts.length === 0) return <span className="text-sm font-medium">Mission control</span>;
768 if (reserved.includes(parts[0]!)) {
769 const words: Record<string, string> = {
770 settings: "Account",
771 explore: "Explore",
772 new: "New project",
773 search: "Search",
774 workspaces: "New workspace",
775 };
776 return <span className="text-sm font-medium">{words[parts[0]!]}</span>;
777 }
778 const [owner, second, third, fourth] = parts;
779 const trail: { label: string; to: string; mono?: boolean }[] = [{ label: owner!, to: `/${owner}`, mono: true }];
780 if (second === "-") {
781 const page = `/${owner}/-/${third}`;
782 if (third && SETTINGS_PAGE.test(page)) {
783 trail.push({ label: "Settings", to: `/${owner}/-/settings` });
784 trail.push({ label: third === "settings" ? "General" : (SECTIONS[third] ?? third), to: page });
785 } else if (third) trail.push({ label: SECTIONS[third] ?? third, to: page });
786 } else if (second) {
787 const repo = `/${owner}/${second}`;
788 trail.push({ label: second, to: repo, mono: true });
789 if (third === "pull" && fourth) trail.push({ label: `Pull request #${fourth}`, to: `${repo}/pull/${fourth}` });
790 else if (third === "issues" && fourth && fourth !== "new") trail.push({ label: `Issue #${fourth}`, to: `${repo}/issues/${fourth}` });
791 else if (third === "commit" && fourth) trail.push({ label: fourth.slice(0, 7), to: `${repo}/commit/${fourth}`, mono: true });
792 else if (third && SECTIONS[third]) {
793 trail.push({ label: SECTIONS[third]!, to: `${repo}/${third}` });
794 // A settings page names which one: Settings / Secrets and variables.
795 if (third === "settings" && fourth && SECTIONS[fourth]) {
796 trail.push({ label: SECTIONS[fourth]!, to: `${repo}/settings/${fourth}` });
797 }
798 }
799 }
800 return (
801 <nav aria-label="Where you are" className="flex min-w-0 items-center gap-1.5 text-sm">
802 {trail.map((crumb, index) => (
803 <span key={crumb.to} className="flex min-w-0 items-center gap-1.5">
804 {index > 0 && <span className="text-line-strong">/</span>}
805 <Link
806 to={crumb.to}
807 className={`truncate rounded px-1 py-0.5 transition-colors hover:bg-raised ${
808 index === trail.length - 1 ? "font-medium text-fg" : "text-muted hover:text-fg"
809 } ${crumb.mono ? "font-mono text-[0.8125rem]" : ""}`}
810 >
811 {crumb.label}
812 </Link>
813 </span>
814 ))}
815 </nav>
816 );
817}
818
819type Command = { label: string; hint?: string; to: string; icon: ReactNode };
820
821/** Everything the palette can jump to, from what the sidebar already knows. */
822function commandsFor(user: User, shell: ShellData): Command[] {
823 const commands: Command[] = [
824 { label: "Mission control", to: "/", icon: <LayoutDashboard size={15} /> },
825 { label: "Explore repositories", to: "/explore", icon: <Compass size={15} /> },
826 { label: "New project", to: "/new", icon: <Plus size={15} /> },
827 { label: "New workspace", to: "/workspaces/new", icon: <Plus size={15} /> },
828 { label: "Your settings", to: "/settings", icon: <Settings size={15} /> },
829 ];
830 const repo = shell.repo;
831 if (repo) {
832 const base = `/${repo.namespace}/${repo.name}`;
833 const name = `${repo.namespace}/${repo.name}`;
834 commands.unshift(
835 { label: "Overview", hint: name, to: base, icon: <LayoutGrid size={15} /> },
836 { label: "Code", hint: name, to: `${base}/code`, icon: <Code2 size={15} /> },
837 { label: "Issues", hint: name, to: `${base}/issues`, icon: <CircleDot size={15} /> },
838 { label: "New issue", hint: name, to: `${base}/issues/new`, icon: <Plus size={15} /> },
839 { label: "Pull requests", hint: name, to: `${base}/pulls`, icon: <GitPullRequest size={15} /> },
840 { label: "Commits", hint: name, to: `${base}/commits`, icon: <History size={15} /> },
841 ...(repo.member
842 ? [
843 { label: "Plan work", hint: name, to: `${base}/plans`, icon: <ListTree size={15} /> },
844 { label: "Deployments", hint: name, to: `${base}/deployments`, icon: <Rocket size={15} /> },
845 { label: "Secrets and variables", hint: name, to: `${base}/settings/secrets`, icon: <Lock size={15} /> },
846 { label: "Project settings", hint: name, to: `${base}/settings`, icon: <Settings size={15} /> },
847 { label: "Repository settings", hint: name, to: `${base}/settings/repository`, icon: <Settings size={15} /> },
848 ]
849 : []),
850 );
851 }
852 for (const membership of user.workspaces ?? []) {
853 commands.push(
854 { label: displayName(membership), hint: `Workspace · ${membership.slug}`, to: `/${membership.slug}`, icon: <Avatar name={membership.slug} image={membership.avatar} size={15} square /> },
855 { label: "Usage", hint: membership.slug, to: `/${membership.slug}/-/usage`, icon: <BarChart3 size={15} /> },
856 { label: "Billing and plans", hint: `${membership.slug} · Settings`, to: `/${membership.slug}/-/billing`, icon: <CreditCard size={15} /> },
857 { label: "Access tokens", hint: `${membership.slug} · Settings`, to: `/${membership.slug}/-/tokens`, icon: <KeyRound size={15} /> },
858 { label: "Integrations", hint: `${membership.slug} · Settings`, to: `/${membership.slug}/-/integrations`, icon: <Plug size={15} /> },
859 );
860 }
861 for (const listed of shell.repos) {
862 commands.push({
863 label: listed.title ?? listed.name,
864 hint: `${listed.namespace} · Project`,
865 to: `/${listed.namespace}/${listed.name}`,
866 icon: <Box size={15} />,
867 });
868 }
869 return commands;
870}
871
872function CommandPalette({
873 open,
874 onClose,
875 commands,
876}: {
877 open: boolean;
878 onClose: () => void;
879 commands: Command[];
880}) {
881 const navigate = useNavigate();
882 const [query, setQuery] = useState("");
883 const [selected, setSelected] = useState(0);
884 const input = useRef<HTMLInputElement>(null);
885 const matches = useMemo(() => {
886 const words = query.toLowerCase().split(/\s+/).filter(Boolean);
887 return commands
888 .filter((command) => {
889 const text = `${command.label} ${command.hint ?? ""}`.toLowerCase();
890 return words.every((word) => text.includes(word));
891 })
892 .slice(0, 12);
893 }, [commands, query]);
894
895 useEffect(() => {
896 if (!open) return;
897 setQuery("");
898 setSelected(0);
899 input.current?.focus();
900 }, [open]);
901 useEffect(() => setSelected(0), [query]);
902
903 if (!open) return null;
904 const go = (command: Command | undefined) => {
905 if (!command) return;
906 onClose();
907 navigate(command.to);
908 };
909 return (
910 <div className="fixed inset-0 z-50 flex items-start justify-center bg-black/50 px-4 pt-[12vh] backdrop-blur-sm" onClick={onClose}>
911 <div
912 role="dialog"
913 aria-label="Go to"
914 className="w-full max-w-xl overflow-hidden rounded-xl border border-line-strong bg-raised shadow-2xl shadow-black/60"
915 onClick={(event) => event.stopPropagation()}
916 >
917 <div className="flex items-center gap-3 border-b border-line px-4">
918 <Search size={16} className="text-faint" />
919 <input
920 ref={input}
921 value={query}
922 onChange={(event) => setQuery(event.target.value)}
923 onKeyDown={(event) => {
924 if (event.key === "ArrowDown") {
925 event.preventDefault();
926 setSelected((index) => Math.min(index + 1, matches.length - 1));
927 } else if (event.key === "ArrowUp") {
928 event.preventDefault();
929 setSelected((index) => Math.max(index - 1, 0));
930 } else if (event.key === "Enter") {
931 event.preventDefault();
932 go(matches[selected]);
933 } else if (event.key === "Escape") {
934 onClose();
935 }
936 }}
937 placeholder="Go to a repository, a page, or an action…"
938 autoComplete="off"
939 data-1p-ignore
940 className="h-12 grow border-0 bg-transparent text-sm shadow-none outline-none ring-0 placeholder:text-faint focus:outline-none focus-visible:outline-none"
941 />
942 <kbd className="rounded border border-line px-1.5 font-mono text-[0.6875rem] text-faint">esc</kbd>
943 </div>
944 <ul className="max-h-[50vh] overflow-y-auto p-1.5">
945 {matches.length === 0 && (
946 <li className="px-3 py-6 text-center text-sm text-faint">Nothing matches.</li>
947 )}
948 {matches.map((command, index) => (
949 <li key={`${command.to}-${command.label}`}>
950 <button
951 type="button"
952 onMouseEnter={() => setSelected(index)}
953 onClick={() => go(command)}
954 className={`flex w-full items-center gap-3 rounded-lg px-3 py-2 text-left text-sm ${
955 index === selected ? "bg-line text-fg" : "text-muted"
956 }`}
957 >
958 <span className="shrink-0 text-faint">{command.icon}</span>
959 <span className="min-w-0 grow truncate">{command.label}</span>
960 {command.hint && <span className="shrink-0 truncate text-xs text-faint">{command.hint}</span>}
961 {index === selected && <CornerDownLeft size={13} className="shrink-0 text-faint" />}
962 </button>
963 </li>
964 ))}
965 </ul>
966 </div>
967 </div>
968 );
969}
970
971/**
972 * A bar across the top of the page while the next one loads, as GitHub
973 * has: it appears at once, creeps towards the end while waiting, then fills
974 * and fades when the page arrives.
975 */
976export function Progress() {
977 const navigation = useNavigation();
978 const busy = navigation.state !== "idle";
979 const [width, setWidth] = useState(0);
980 const [visible, setVisible] = useState(false);
981 useEffect(() => {
982 if (busy) {
983 setVisible(true);
984 setWidth(12);
985 // Slows as it goes, never quite arriving until the page does.
986 const timer = setInterval(() => setWidth((now) => now + (90 - now) * 0.12), 200);
987 return () => clearInterval(timer);
988 }
989 setWidth((now) => (now > 0 ? 100 : 0));
990 const fade = setTimeout(() => setVisible(false), 250);
991 const reset = setTimeout(() => setWidth(0), 550);
992 return () => {
993 clearTimeout(fade);
994 clearTimeout(reset);
995 };
996 }, [busy]);
997 return (
998 <div
999 aria-hidden="true"
1000 className={`pointer-events-none fixed inset-x-0 top-0 z-[60] h-[3px] transition-opacity duration-300 ${
1001 visible ? "opacity-100" : "opacity-0"
1002 }`}
1003 >
1004 <div
1005 className="h-full bg-accent shadow-[0_0_10px_var(--color-accent)] transition-[width] duration-200 ease-out"
1006 style={{ width: `${width}%` }}
1007 />
1008 </div>
1009 );
1010}
1011
1012/**
1013 * The signed-in app: a sidebar with the workspace, its repositories and the
1014 * sections of the one being looked at; a slim bar with search and the
1015 * account; and the page.
1016 */
1017export function AppShell({
1018 user,
1019 shell,
1020 banner,
1021 children,
1022}: {
1023 user: User;
1024 shell: ShellData;
1025 banner?: ReactNode;
1026 children: ReactNode;
1027}) {
1028 const submit = useSubmit();
1029 const { pathname } = useLocation();
1030 const [drawer, setDrawer] = useState(false);
1031 const [palette, setPalette] = useState(false);
1032 const commands = useMemo(() => commandsFor(user, shell), [user, shell]);
1033
1034 // A new page closes the drawer on small screens.
1035 useEffect(() => setDrawer(false), [pathname]);
1036 useEffect(() => {
1037 const onKey = (event: KeyboardEvent) => {
1038 if ((event.metaKey || event.ctrlKey) && event.key.toLowerCase() === "k") {
1039 event.preventDefault();
1040 setPalette((open) => !open);
1041 }
1042 };
1043 window.addEventListener("keydown", onKey);
1044 return () => window.removeEventListener("keydown", onKey);
1045 }, []);
1046
1047 return (
1048 <div className="min-h-screen">
1049 <Progress />
1050 <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">
1051 <Sidebar user={user} shell={shell} onFind={() => setPalette(true)} />
1052 </aside>
1053 {drawer && (
1054 <div className="fixed inset-0 z-50 lg:hidden">
1055 <button
1056 type="button"
1057 aria-label="Close menu"
1058 className="absolute inset-0 bg-black/60"
1059 onClick={() => setDrawer(false)}
1060 />
1061 <aside className="absolute inset-y-0 left-0 w-72 border-r border-line bg-surface">
1062 <button
1063 type="button"
1064 aria-label="Close menu"
1065 onClick={() => setDrawer(false)}
1066 className="absolute top-3 right-3 rounded-md p-1 text-faint hover:bg-raised hover:text-fg"
1067 >
1068 <X size={16} />
1069 </button>
1070 <Sidebar user={user} shell={shell} onFind={() => setPalette(true)} />
1071 </aside>
1072 </div>
1073 )}
1074
1075 <div className="flex min-h-screen min-w-0 flex-col lg:pl-64">
1076 <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">
1077 <button
1078 type="button"
1079 aria-label="Open menu"
1080 onClick={() => setDrawer(true)}
1081 className="rounded-md p-1.5 text-muted hover:bg-raised hover:text-fg lg:hidden"
1082 >
1083 <Menu size={18} />
1084 </button>
1085 <Breadcrumbs pathname={pathname} />
1086 <div className="ml-auto flex items-center gap-1.5">
1087 <a
1088 href="https://docs.g1t.sh/"
1089 className="hidden rounded-md px-2.5 py-1.5 text-sm text-muted transition-colors hover:bg-raised hover:text-fg sm:block"
1090 >
1091 Docs
1092 </a>
1093 <DropdownMenu>
1094 <DropdownMenuTrigger
1095 aria-label="Create"
1096 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"
1097 >
1098 <Plus size={14} />
1099 New
1100 </DropdownMenuTrigger>
1101 <DropdownMenuContent align="end">
1102 {shell.repo && (
1103 <DropdownMenuItem asChild>
1104 <Link to={`/${shell.repo.namespace}/${shell.repo.name}/issues/new`}>
1105 <CircleDot />
1106 New issue
1107 </Link>
1108 </DropdownMenuItem>
1109 )}
1110 {shell.repo?.member && (
1111 <DropdownMenuItem asChild>
1112 <Link to={`/${shell.repo.namespace}/${shell.repo.name}/plans`}>
1113 <ListTree />
1114 Plan work
1115 </Link>
1116 </DropdownMenuItem>
1117 )}
1118 <DropdownMenuItem asChild>
1119 <Link to="/new">
1120 <Box />
1121 New project
1122 </Link>
1123 </DropdownMenuItem>
1124 <DropdownMenuItem asChild>
1125 <Link to="/workspaces/new">
1126 <Users />
1127 New workspace
1128 </Link>
1129 </DropdownMenuItem>
1130 </DropdownMenuContent>
1131 </DropdownMenu>
1132 </div>
1133 </header>
1134 {banner}
1135 <main className="min-w-0 grow">{children}</main>
1136 </div>
1137 <CommandPalette open={palette} onClose={() => setPalette(false)} commands={commands} />
1138 </div>
1139 );
1140}