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

782 lines29,563 bytesCodeBlame
1import {
2 BarChart3,
3 BookMarked,
4 BookOpen,
5 Check,
6 ChevronsUpDown,
7 Ellipsis,
8 CircleDot,
9 Code2,
10 Compass,
11 CornerDownLeft,
12 CreditCard,
13 GitPullRequest,
14 History,
15 KeyRound,
16 Layers,
17 LayoutDashboard,
18 ListTree,
19 Lock,
20 LogOut,
21 Menu,
22 Plus,
23 Search,
24 Plug,
25 Settings,
26 Users,
27 X,
28} from "lucide-react";
29import { type ReactNode, useEffect, useMemo, useRef, useState } from "react";
30import { Form, Link, NavLink, useLocation, useNavigate, useNavigation, useSubmit } from "react-router";
31
32import type { User } from "@g1t/contracts";
33import { MICROS_PER_DOLLAR } from "@g1t/contracts";
34
35import { Mark } from "./logo";
36import { Avatar } from "./ui";
37import {
38 DropdownMenu,
39 DropdownMenuContent,
40 DropdownMenuItem,
41 DropdownMenuLabel,
42 DropdownMenuSeparator,
43 DropdownMenuTrigger,
44} from "./ui/dropdown-menu";
45
46/** What the sidebar needs, worked out by the root loader for a signed-in person. */
47export type ShellData = {
48 /** The workspace the sidebar is about: the one being looked at, or their first. */
49 workspace: { slug: string; role: "owner" | "member" } | null;
50 /** Its repositories, newest first. */
51 repos: { namespace: string; name: string; isPrivate: boolean }[];
52 /** The repository being looked at, if any, whoever owns it. */
53 repo: {
54 namespace: string;
55 name: string;
56 member: boolean;
57 issues: number;
58 pulls: number;
59 } | null;
60 /** The workspace's agent credit, if billing is on and they may see it. */
61 creditMicros: number | null;
62 /** What its agents have cost since the start of the month. */
63 monthSpentMicros: number | null;
64};
65
66function SidebarLink({
67 to,
68 icon,
69 end,
70 count,
71 also,
72 children,
73}: {
74 to: string;
75 icon: ReactNode;
76 end?: boolean;
77 count?: number;
78 /** Another path prefix under which this link is the current one. */
79 also?: string;
80 children: ReactNode;
81}) {
82 const { pathname } = useLocation();
83 return (
84 <NavLink
85 to={to}
86 end={end}
87 prefetch="intent"
88 className={({ isActive, isPending }) => {
89 const current = isActive || (also != null && pathname.startsWith(also + "/"));
90 return `group flex h-8 items-center gap-2.5 rounded-md px-2 text-[0.8125rem] transition-colors ${
91 current
92 ? "bg-raised font-medium text-fg"
93 : isPending
94 ? "bg-raised/60 text-fg"
95 : "text-muted hover:bg-raised/60 hover:text-fg"
96 }`;
97 }}
98 >
99 <span className="shrink-0 text-faint group-hover:text-muted">{icon}</span>
100 <span className="min-w-0 grow truncate">{children}</span>
101 {count != null && count > 0 && (
102 <span className="rounded bg-line px-1.5 text-[0.6875rem] tabular-nums text-muted">
103 {count}
104 </span>
105 )}
106 </NavLink>
107 );
108}
109
110function SidebarGroup({ title, action, children }: { title: string; action?: ReactNode; children: ReactNode }) {
111 return (
112 <section className="mt-6">
113 <div className="mb-1 flex h-6 items-center justify-between px-2">
114 <h2 className="text-xs font-medium text-faint">{title}</h2>
115 {action}
116 </div>
117 <div className="space-y-px">{children}</div>
118 </section>
119 );
120}
121
122function WorkspaceSwitcher({ user, shell }: { user: User; shell: ShellData }) {
123 const current = shell.workspace?.slug;
124 return (
125 <DropdownMenu>
126 {/* The name goes to the workspace; only the arrows switch it. */}
127 <Link
128 to={current ? `/${current}` : "/workspaces/new"}
129 className="flex h-8 min-w-0 items-center gap-2 rounded-md px-1.5 transition-colors hover:bg-raised"
130 >
131 {current ? (
132 <Avatar name={current} size={20} square />
133 ) : (
134 <span className="size-5 rounded-md border border-dashed border-line-strong" />
135 )}
136 <span className="min-w-0 truncate text-sm font-medium">{current ?? "Choose a workspace"}</span>
137 {shell.workspace && (
138 <span className="shrink-0 rounded-full bg-raised px-1.5 py-px text-[0.625rem] font-medium text-muted ring-1 ring-line capitalize">
139 {shell.workspace.role}
140 </span>
141 )}
142 </Link>
143 <DropdownMenuTrigger
144 aria-label="Switch workspace"
145 className="ml-auto flex h-8 w-6 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"
146 >
147 <ChevronsUpDown size={14} />
148 </DropdownMenuTrigger>
149 <DropdownMenuContent align="start" className="w-60">
150 <DropdownMenuLabel>Workspaces</DropdownMenuLabel>
151 {(user.workspaces ?? []).map((membership) => (
152 <DropdownMenuItem asChild key={membership.slug}>
153 <Link to={`/${membership.slug}`}>
154 <Avatar name={membership.slug} size={16} square />
155 <span className="grow">{membership.slug}</span>
156 {membership.slug === current && <Check className="text-accent" />}
157 </Link>
158 </DropdownMenuItem>
159 ))}
160 <DropdownMenuSeparator />
161 <DropdownMenuItem asChild>
162 <Link to="/workspaces/new">
163 <Plus />
164 New workspace
165 </Link>
166 </DropdownMenuItem>
167 </DropdownMenuContent>
168 </DropdownMenu>
169 );
170}
171
172/** This month's spend against what is left, as Vercel shows a plan's usage. */
173function UsageCard({ slug, shell }: { slug: string; shell: ShellData }) {
174 if (shell.monthSpentMicros == null) return null;
175 const spent = shell.monthSpentMicros;
176 const left = shell.creditMicros;
177 const share = left != null && spent + left > 0 ? Math.min(1, spent / (spent + Math.max(left, 0))) : 0;
178 return (
179 <Link
180 to={`/${slug}/-/usage`}
181 className="block rounded-lg bg-surface p-3 ring-1 ring-line transition-colors hover:ring-line-strong"
182 >
183 <span className="flex items-baseline justify-between text-xs">
184 <span className="font-medium text-fg">Usage</span>
185 <span className="text-faint">this month</span>
186 </span>
187 <span className="mt-2 flex items-baseline justify-between">
188 <span className="font-mono text-sm tabular-nums">${(spent / MICROS_PER_DOLLAR).toFixed(2)}</span>
189 {left != null && (
190 <span className={`text-xs ${left <= 0 ? "text-warn" : "text-faint"}`}>
191 ${(left / MICROS_PER_DOLLAR).toFixed(2)} left
192 </span>
193 )}
194 </span>
195 <span className="mt-2 block h-1 overflow-hidden rounded-full bg-raised">
196 <span
197 className={`block h-full rounded-full ${left != null && left <= 0 ? "bg-warn" : "bg-accent"}`}
198 style={{ width: `${Math.max(share * 100, spent > 0 ? 3 : 0)}%` }}
199 />
200 </span>
201 </Link>
202 );
203}
204
205function AccountMenu({ user }: { user: User }) {
206 const submit = useSubmit();
207 return (
208 <DropdownMenu>
209 <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">
210 <Avatar name={user.username} size={22} />
211 <span className="min-w-0 grow truncate text-[0.8125rem] font-medium">{user.username}</span>
212 <Ellipsis size={15} className="shrink-0 text-faint" />
213 </DropdownMenuTrigger>
214 <DropdownMenuContent align="start" side="top" className="w-56">
215 <DropdownMenuLabel>
216 Signed in as <span className="font-mono font-medium text-fg">{user.username}</span>
217 </DropdownMenuLabel>
218 <DropdownMenuSeparator />
219 <DropdownMenuItem asChild>
220 <Link to="/settings">
221 <Settings />
222 Your settings
223 </Link>
224 </DropdownMenuItem>
225 <DropdownMenuItem asChild>
226 <a href="https://docs.g1t.sh/">
227 <BookOpen />
228 Documentation
229 </a>
230 </DropdownMenuItem>
231 <DropdownMenuSeparator />
232 {/* Submitted from here: the menu closes on select, and a button
233 that has left the page cannot submit a form. */}
234 <DropdownMenuItem onSelect={() => submit(null, { method: "post", action: "/logout" })}>
235 <LogOut />
236 Sign out
237 </DropdownMenuItem>
238 </DropdownMenuContent>
239 </DropdownMenu>
240 );
241}
242
243function Sidebar({ user, shell, onFind }: { user: User; shell: ShellData; onFind: () => void }) {
244 const ws = shell.workspace;
245 const active = shell.repo;
246 const repoBase = active ? `/${active.namespace}/${active.name}` : null;
247 // The repository being looked at is listed even when it is someone else's.
248 const listed =
249 active && !shell.repos.some((repo) => repo.namespace === active.namespace && repo.name === active.name)
250 ? [{ namespace: active.namespace, name: active.name, isPrivate: false }, ...shell.repos]
251 : shell.repos;
252 return (
253 <div className="flex h-full flex-col">
254 {/* The same height and rule as the top bar, so the two read as one line. */}
255 <div className="flex h-12 shrink-0 items-center gap-1 border-b border-line px-2">
256 <Link to="/" aria-label="g1t home" className="shrink-0 rounded-md p-1.5 hover:bg-raised">
257 <Mark className="size-5" />
258 </Link>
259 <span className="shrink-0 text-line-strong" aria-hidden="true">
260 /
261 </span>
262 <WorkspaceSwitcher user={user} shell={shell} />
263 </div>
264 <div className="px-2 pt-3">
265 <button
266 type="button"
267 onClick={onFind}
268 className="flex h-8 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"
269 >
270 <Search size={14} />
271 <span className="grow text-left">Find…</span>
272 <kbd className="rounded bg-raised px-1.5 font-mono text-[0.625rem] text-muted ring-1 ring-line">⌘K</kbd>
273 </button>
274 </div>
275 <nav className="min-h-0 grow overflow-y-auto px-2 pb-4">
276 <div className="mt-3 space-y-px">
277 <SidebarLink to="/" end icon={<LayoutDashboard size={15} />}>
278 Mission control
279 </SidebarLink>
280 <SidebarLink to="/explore" icon={<Compass size={15} />}>
281 Explore
282 </SidebarLink>
283 </div>
284
285 {ws && (
286 <SidebarGroup title="Workspace">
287 <SidebarLink to={`/${ws.slug}`} end icon={<LayoutDashboard size={15} />}>
288 Overview
289 </SidebarLink>
290 <SidebarLink to={`/${ws.slug}/-/people`} icon={<Users size={15} />}>
291 People
292 </SidebarLink>
293 <SidebarLink to={`/${ws.slug}/-/tokens`} icon={<KeyRound size={15} />}>
294 Access tokens
295 </SidebarLink>
296 <SidebarLink to={`/${ws.slug}/-/usage`} icon={<BarChart3 size={15} />}>
297 Usage
298 </SidebarLink>
299 <SidebarLink to={`/${ws.slug}/-/billing`} icon={<CreditCard size={15} />}>
300 Billing
301 </SidebarLink>
302 <SidebarLink to={`/${ws.slug}/-/integrations`} icon={<Plug size={15} />}>
303 Integrations
304 </SidebarLink>
305 {ws.role === "owner" && (
306 <SidebarLink to={`/${ws.slug}/-/settings`} icon={<Settings size={15} />}>
307 Settings
308 </SidebarLink>
309 )}
310 </SidebarGroup>
311 )}
312
313 <SidebarGroup
314 title="Repositories"
315 action={
316 ws && (
317 <Link
318 to={`/new?workspace=${ws.slug}`}
319 aria-label="New repository"
320 className="rounded p-0.5 text-faint hover:bg-raised hover:text-fg"
321 >
322 <Plus size={13} />
323 </Link>
324 )
325 }
326 >
327 {listed.length === 0 && (
328 <p className="px-2 py-1 text-xs text-faint">None yet.</p>
329 )}
330 {listed.map((repo) => {
331 const open =
332 active && repo.namespace === active.namespace && repo.name === active.name;
333 const base = `/${repo.namespace}/${repo.name}`;
334 return (
335 <div key={base}>
336 <SidebarLink
337 to={base}
338 end={!open}
339 icon={repo.isPrivate ? <Lock size={15} /> : <BookMarked size={15} />}
340 >
341 <span className="font-mono text-[0.8125rem]">
342 {repo.namespace !== ws?.slug && (
343 <span className="text-faint">{repo.namespace}/</span>
344 )}
345 {repo.name}
346 </span>
347 </SidebarLink>
348 {open && repoBase && (
349 <div className="my-0.5 ml-4 space-y-px border-l border-line pl-2">
350 <SidebarLink to={repoBase} end icon={<Code2 size={14} />} also={`${repoBase}/tree`}>
351 Code
352 </SidebarLink>
353 <SidebarLink
354 to={`${repoBase}/issues`}
355 icon={<CircleDot size={14} />}
356 count={active.issues}
357 >
358 Issues
359 </SidebarLink>
360 <SidebarLink
361 to={`${repoBase}/pulls`}
362 also={`${repoBase}/pull`}
363 icon={<GitPullRequest size={14} />}
364 count={active.pulls}
365 >
366 Pull requests
367 </SidebarLink>
368 <SidebarLink to={`${repoBase}/queue`} icon={<Layers size={14} />}>
369 Merge queue
370 </SidebarLink>
371 <SidebarLink
372 to={`${repoBase}/commits`}
373 also={`${repoBase}/commit`}
374 icon={<History size={14} />}
375 >
376 Commits
377 </SidebarLink>
378 {active.member && (
379 <SidebarLink to={`${repoBase}/plans`} icon={<ListTree size={14} />}>
380 Plan
381 </SidebarLink>
382 )}
383 {active.member && (
384 <SidebarLink to={`${repoBase}/settings`} icon={<Settings size={14} />}>
385 Settings
386 </SidebarLink>
387 )}
388 </div>
389 )}
390 </div>
391 );
392 })}
393 </SidebarGroup>
394 </nav>
395
396 <div className="space-y-2 p-2">
397 {ws && <UsageCard slug={ws.slug} shell={shell} />}
398 <AccountMenu user={user} />
399 </div>
400 </div>
401 );
402}
403
404/** Words for the sections a path can end in. */
405const SECTIONS: Record<string, string> = {
406 issues: "Issues",
407 pulls: "Pull requests",
408 queue: "Merge queue",
409 commits: "Commits",
410 plans: "Plan",
411 settings: "Settings",
412 people: "People",
413 tokens: "Access tokens",
414 usage: "Usage",
415 billing: "Billing",
416 tree: "Code",
417 blob: "Code",
418};
419
420/** Where the page is, as a trail of links: workspace / repository / section. */
421function Breadcrumbs({ pathname }: { pathname: string }) {
422 const parts = pathname.split("/").filter(Boolean);
423 const reserved = ["settings", "explore", "new", "search", "workspaces"];
424 if (parts.length === 0) return <span className="text-sm font-medium">Mission control</span>;
425 if (reserved.includes(parts[0]!)) {
426 const words: Record<string, string> = {
427 settings: "Your settings",
428 explore: "Explore",
429 new: "New repository",
430 search: "Search",
431 workspaces: "New workspace",
432 };
433 return <span className="text-sm font-medium">{words[parts[0]!]}</span>;
434 }
435 const [owner, second, third, fourth] = parts;
436 const trail: { label: string; to: string; mono?: boolean }[] = [{ label: owner!, to: `/${owner}`, mono: true }];
437 if (second === "-") {
438 if (third) trail.push({ label: SECTIONS[third] ?? third, to: `/${owner}/-/${third}` });
439 } else if (second) {
440 const repo = `/${owner}/${second}`;
441 trail.push({ label: second, to: repo, mono: true });
442 if (third === "pull" && fourth) trail.push({ label: `Pull request #${fourth}`, to: `${repo}/pull/${fourth}` });
443 else if (third === "issues" && fourth && fourth !== "new") trail.push({ label: `Issue #${fourth}`, to: `${repo}/issues/${fourth}` });
444 else if (third === "commit" && fourth) trail.push({ label: fourth.slice(0, 7), to: `${repo}/commit/${fourth}`, mono: true });
445 else if (third && SECTIONS[third]) trail.push({ label: SECTIONS[third]!, to: `${repo}/${third}` });
446 }
447 return (
448 <nav aria-label="Where you are" className="flex min-w-0 items-center gap-1.5 text-sm">
449 {trail.map((crumb, index) => (
450 <span key={crumb.to} className="flex min-w-0 items-center gap-1.5">
451 {index > 0 && <span className="text-line-strong">/</span>}
452 <Link
453 to={crumb.to}
454 className={`truncate rounded px-1 py-0.5 transition-colors hover:bg-raised ${
455 index === trail.length - 1 ? "font-medium text-fg" : "text-muted hover:text-fg"
456 } ${crumb.mono ? "font-mono text-[0.8125rem]" : ""}`}
457 >
458 {crumb.label}
459 </Link>
460 </span>
461 ))}
462 </nav>
463 );
464}
465
466type Command = { label: string; hint?: string; to: string; icon: ReactNode };
467
468/** Everything the palette can jump to, from what the sidebar already knows. */
469function commandsFor(user: User, shell: ShellData): Command[] {
470 const commands: Command[] = [
471 { label: "Mission control", to: "/", icon: <LayoutDashboard size={15} /> },
472 { label: "Explore repositories", to: "/explore", icon: <Compass size={15} /> },
473 { label: "New repository", to: "/new", icon: <Plus size={15} /> },
474 { label: "New workspace", to: "/workspaces/new", icon: <Plus size={15} /> },
475 { label: "Your settings", to: "/settings", icon: <Settings size={15} /> },
476 ];
477 const repo = shell.repo;
478 if (repo) {
479 const base = `/${repo.namespace}/${repo.name}`;
480 const name = `${repo.namespace}/${repo.name}`;
481 commands.unshift(
482 { label: "Issues", hint: name, to: `${base}/issues`, icon: <CircleDot size={15} /> },
483 { label: "New issue", hint: name, to: `${base}/issues/new`, icon: <Plus size={15} /> },
484 { label: "Pull requests", hint: name, to: `${base}/pulls`, icon: <GitPullRequest size={15} /> },
485 { label: "Commits", hint: name, to: `${base}/commits`, icon: <History size={15} /> },
486 ...(repo.member
487 ? [
488 { label: "Plan work", hint: name, to: `${base}/plans`, icon: <ListTree size={15} /> },
489 { label: "Repository settings", hint: name, to: `${base}/settings`, icon: <Settings size={15} /> },
490 ]
491 : []),
492 );
493 }
494 for (const membership of user.workspaces ?? []) {
495 commands.push(
496 { label: membership.slug, hint: "Workspace", to: `/${membership.slug}`, icon: <Avatar name={membership.slug} size={15} square /> },
497 { label: "Usage", hint: membership.slug, to: `/${membership.slug}/-/usage`, icon: <BarChart3 size={15} /> },
498 { label: "Billing", hint: membership.slug, to: `/${membership.slug}/-/billing`, icon: <CreditCard size={15} /> },
499 { label: "Access tokens", hint: membership.slug, to: `/${membership.slug}/-/tokens`, icon: <KeyRound size={15} /> },
500 { label: "Integrations", hint: membership.slug, to: `/${membership.slug}/-/integrations`, icon: <Plug size={15} /> },
501 );
502 }
503 for (const listed of shell.repos) {
504 commands.push({
505 label: `${listed.namespace}/${listed.name}`,
506 hint: "Repository",
507 to: `/${listed.namespace}/${listed.name}`,
508 icon: <BookMarked size={15} />,
509 });
510 }
511 return commands;
512}
513
514function CommandPalette({
515 open,
516 onClose,
517 commands,
518}: {
519 open: boolean;
520 onClose: () => void;
521 commands: Command[];
522}) {
523 const navigate = useNavigate();
524 const [query, setQuery] = useState("");
525 const [selected, setSelected] = useState(0);
526 const input = useRef<HTMLInputElement>(null);
527 const matches = useMemo(() => {
528 const words = query.toLowerCase().split(/\s+/).filter(Boolean);
529 return commands
530 .filter((command) => {
531 const text = `${command.label} ${command.hint ?? ""}`.toLowerCase();
532 return words.every((word) => text.includes(word));
533 })
534 .slice(0, 12);
535 }, [commands, query]);
536
537 useEffect(() => {
538 if (!open) return;
539 setQuery("");
540 setSelected(0);
541 input.current?.focus();
542 }, [open]);
543 useEffect(() => setSelected(0), [query]);
544
545 if (!open) return null;
546 const go = (command: Command | undefined) => {
547 if (!command) return;
548 onClose();
549 navigate(command.to);
550 };
551 return (
552 <div className="fixed inset-0 z-50 flex items-start justify-center bg-black/50 px-4 pt-[12vh] backdrop-blur-sm" onClick={onClose}>
553 <div
554 role="dialog"
555 aria-label="Go to"
556 className="w-full max-w-xl overflow-hidden rounded-xl border border-line-strong bg-raised shadow-2xl shadow-black/60"
557 onClick={(event) => event.stopPropagation()}
558 >
559 <div className="flex items-center gap-3 border-b border-line px-4">
560 <Search size={16} className="text-faint" />
561 <input
562 ref={input}
563 value={query}
564 onChange={(event) => setQuery(event.target.value)}
565 onKeyDown={(event) => {
566 if (event.key === "ArrowDown") {
567 event.preventDefault();
568 setSelected((index) => Math.min(index + 1, matches.length - 1));
569 } else if (event.key === "ArrowUp") {
570 event.preventDefault();
571 setSelected((index) => Math.max(index - 1, 0));
572 } else if (event.key === "Enter") {
573 event.preventDefault();
574 go(matches[selected]);
575 } else if (event.key === "Escape") {
576 onClose();
577 }
578 }}
579 placeholder="Go to a repository, a page, or an action…"
580 autoComplete="off"
581 data-1p-ignore
582 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"
583 />
584 <kbd className="rounded border border-line px-1.5 font-mono text-[0.6875rem] text-faint">esc</kbd>
585 </div>
586 <ul className="max-h-[50vh] overflow-y-auto p-1.5">
587 {matches.length === 0 && (
588 <li className="px-3 py-6 text-center text-sm text-faint">Nothing matches.</li>
589 )}
590 {matches.map((command, index) => (
591 <li key={`${command.to}-${command.label}`}>
592 <button
593 type="button"
594 onMouseEnter={() => setSelected(index)}
595 onClick={() => go(command)}
596 className={`flex w-full items-center gap-3 rounded-lg px-3 py-2 text-left text-sm ${
597 index === selected ? "bg-line text-fg" : "text-muted"
598 }`}
599 >
600 <span className="shrink-0 text-faint">{command.icon}</span>
601 <span className="min-w-0 grow truncate">{command.label}</span>
602 {command.hint && <span className="shrink-0 truncate text-xs text-faint">{command.hint}</span>}
603 {index === selected && <CornerDownLeft size={13} className="shrink-0 text-faint" />}
604 </button>
605 </li>
606 ))}
607 </ul>
608 </div>
609 </div>
610 );
611}
612
613/**
614 * A bar across the top of the page while the next one loads, as GitHub
615 * has: it appears at once, creeps towards the end while waiting, then fills
616 * and fades when the page arrives.
617 */
618export function Progress() {
619 const navigation = useNavigation();
620 const busy = navigation.state !== "idle";
621 const [width, setWidth] = useState(0);
622 const [visible, setVisible] = useState(false);
623 useEffect(() => {
624 if (busy) {
625 setVisible(true);
626 setWidth(12);
627 // Slows as it goes, never quite arriving until the page does.
628 const timer = setInterval(() => setWidth((now) => now + (90 - now) * 0.12), 200);
629 return () => clearInterval(timer);
630 }
631 setWidth((now) => (now > 0 ? 100 : 0));
632 const fade = setTimeout(() => setVisible(false), 250);
633 const reset = setTimeout(() => setWidth(0), 550);
634 return () => {
635 clearTimeout(fade);
636 clearTimeout(reset);
637 };
638 }, [busy]);
639 return (
640 <div
641 aria-hidden="true"
642 className={`pointer-events-none fixed inset-x-0 top-0 z-[60] h-[3px] transition-opacity duration-300 ${
643 visible ? "opacity-100" : "opacity-0"
644 }`}
645 >
646 <div
647 className="h-full bg-accent shadow-[0_0_10px_var(--color-accent)] transition-[width] duration-200 ease-out"
648 style={{ width: `${width}%` }}
649 />
650 </div>
651 );
652}
653
654/**
655 * The signed-in app: a sidebar with the workspace, its repositories and the
656 * sections of the one being looked at; a slim bar with search and the
657 * account; and the page.
658 */
659export function AppShell({
660 user,
661 shell,
662 banner,
663 children,
664}: {
665 user: User;
666 shell: ShellData;
667 banner?: ReactNode;
668 children: ReactNode;
669}) {
670 const submit = useSubmit();
671 const { pathname } = useLocation();
672 const [drawer, setDrawer] = useState(false);
673 const [palette, setPalette] = useState(false);
674 const commands = useMemo(() => commandsFor(user, shell), [user, shell]);
675
676 // A new page closes the drawer on small screens.
677 useEffect(() => setDrawer(false), [pathname]);
678 useEffect(() => {
679 const onKey = (event: KeyboardEvent) => {
680 if ((event.metaKey || event.ctrlKey) && event.key.toLowerCase() === "k") {
681 event.preventDefault();
682 setPalette((open) => !open);
683 }
684 };
685 window.addEventListener("keydown", onKey);
686 return () => window.removeEventListener("keydown", onKey);
687 }, []);
688
689 return (
690 <div className="min-h-screen">
691 <Progress />
692 <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">
693 <Sidebar user={user} shell={shell} onFind={() => setPalette(true)} />
694 </aside>
695 {drawer && (
696 <div className="fixed inset-0 z-50 lg:hidden">
697 <button
698 type="button"
699 aria-label="Close menu"
700 className="absolute inset-0 bg-black/60"
701 onClick={() => setDrawer(false)}
702 />
703 <aside className="absolute inset-y-0 left-0 w-72 border-r border-line bg-surface">
704 <button
705 type="button"
706 aria-label="Close menu"
707 onClick={() => setDrawer(false)}
708 className="absolute top-3 right-3 rounded-md p-1 text-faint hover:bg-raised hover:text-fg"
709 >
710 <X size={16} />
711 </button>
712 <Sidebar user={user} shell={shell} onFind={() => setPalette(true)} />
713 </aside>
714 </div>
715 )}
716
717 <div className="flex min-h-screen flex-col lg:pl-64">
718 <header className="sticky top-0 z-30 flex h-12 items-center gap-2 border-b border-line bg-bg/85 px-3 backdrop-blur sm:px-4">
719 <button
720 type="button"
721 aria-label="Open menu"
722 onClick={() => setDrawer(true)}
723 className="rounded-md p-1.5 text-muted hover:bg-raised hover:text-fg lg:hidden"
724 >
725 <Menu size={18} />
726 </button>
727 <Breadcrumbs pathname={pathname} />
728 <div className="ml-auto flex items-center gap-1.5">
729 <a
730 href="https://docs.g1t.sh/"
731 className="hidden rounded-md px-2 py-1 text-[0.8125rem] text-muted transition-colors hover:bg-raised hover:text-fg sm:block"
732 >
733 Docs
734 </a>
735 <DropdownMenu>
736 <DropdownMenuTrigger
737 aria-label="Create"
738 className="flex h-8 items-center gap-1.5 rounded-md bg-fg px-2.5 text-[0.8125rem] font-medium text-bg outline-none transition-colors hover:bg-white"
739 >
740 <Plus size={14} />
741 New
742 </DropdownMenuTrigger>
743 <DropdownMenuContent align="end">
744 {shell.repo && (
745 <DropdownMenuItem asChild>
746 <Link to={`/${shell.repo.namespace}/${shell.repo.name}/issues/new`}>
747 <CircleDot />
748 New issue
749 </Link>
750 </DropdownMenuItem>
751 )}
752 {shell.repo?.member && (
753 <DropdownMenuItem asChild>
754 <Link to={`/${shell.repo.namespace}/${shell.repo.name}/plans`}>
755 <ListTree />
756 Plan work
757 </Link>
758 </DropdownMenuItem>
759 )}
760 <DropdownMenuItem asChild>
761 <Link to="/new">
762 <BookMarked />
763 New repository
764 </Link>
765 </DropdownMenuItem>
766 <DropdownMenuItem asChild>
767 <Link to="/workspaces/new">
768 <Users />
769 New workspace
770 </Link>
771 </DropdownMenuItem>
772 </DropdownMenuContent>
773 </DropdownMenu>
774 </div>
775 </header>
776 {banner}
777 <main className="grow">{children}</main>
778 </div>
779 <CommandPalette open={palette} onClose={() => setPalette(false)} commands={commands} />
780 </div>
781 );
782}