Skip to content

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

295 linesCodeBlameRaw
1import { useSidebarLocation } from "../lib/use-sidebar-location";
2import { Check, ChevronDown, CircleUserRound, Compass, LayoutGrid, Plus, Settings } from "lucide-react";
3import { type ReactNode, useState } from "react";
4import { Link } from "react-router";
5
6import { type Membership, type User, hasCodeAccess, shownUsername } from "@g1t/contracts";
7
8import { AppsLauncher, appIcon, pinnedApp, useAppPins } from "./apps";
9import { Mark } from "./logo";
10
11import { Avatar } from "./ui/avatar";
12import { Card } from "./ui/card";
13import { Kbd } from "./ui/kbd";
14import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from "./ui/dialog";
15import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuLabel, DropdownMenuSeparator, DropdownMenuTrigger } from "./ui/dropdown-menu";
16import { Popover, PopoverContent, PopoverTrigger } from "./ui/popover";
17import { Sidebar, SidebarContent, SidebarFooter, SidebarHeader, SidebarMenu, SidebarMenuButton, SidebarMenuItem, SidebarSeparator } from "./ui/sidebar";
18import { type PinnableApp, appOf, railApps } from "../lib/apps";
19import { cn } from "../lib/cn";
20import { paletteKeyLabel } from "../lib/shortcut";
21import { type ModeKey, homePagePath, modeHome, modeOf } from "../lib/workspace-nav";
22
23type Item = { badge?: { count: number; loud: boolean } | null };
24
25/** A count on an icon: loud (the accent) for what waits on you, quiet for the rest. */
26export function CountBadge({ count, loud, className }: { count: number; loud: boolean; className?: string }) {
27 if (count <= 0) return null;
28 return (
29 <span
30 className={cn(
31 "absolute -top-1 -right-1.5 min-w-4.5 rounded-full px-1 text-center text-[0.625rem] leading-4.5 font-bold tabular-nums ring-2 ring-rail",
32 loud ? "bg-accent text-bg" : "bg-line-strong text-fg",
33 className,
34 )}
35 >
36 {count > 99 ? "99+" : count}
37 </span>
38 );
39}
40
41/** One icon of the rail: a link to an app, its name as the hint beside it, and what is unread on it. */
42function RailLink({ to, label, icon, current, badge }: { to: string; label: string; icon: ReactNode; current: boolean; badge?: Item["badge"] }) {
43 return (
44 <SidebarMenuItem>
45 <SidebarMenuButton asChild size="icon" isActive={current} tooltip={label}>
46 <Link
47 to={to}
48 prefetch="intent"
49 aria-current={current ? "page" : undefined}
50 aria-label={badge && badge.count > 0 ? `${label}, ${badge.count} unread` : label}
51 >
52 {icon}
53 {badge && <CountBadge count={badge.count} loud={badge.loud} />}
54 </Link>
55 </SidebarMenuButton>
56 </SidebarMenuItem>
57 );
58}
59
60function displayName(membership: Membership): string {
61 return membership.name?.trim() || membership.slug;
62}
63
64/**
65 * The workspace you are in and the way to another: its avatar, its name
66 * and a chevron, opening your workspaces, a new one, and your own account.
67 * `row` heads the sidebar; `compact` starts the page header's trail when
68 * no sidebar is open.
69 */
70export function WorkspaceSwitcher({ user, workspace, compact = false }: { user: User; workspace: Membership; compact?: boolean }) {
71 return (
72 <DropdownMenu>
73 <DropdownMenuTrigger
74 aria-label={`${displayName(workspace)}: switch workspace`}
75 className={cn(
76 "flex min-w-0 items-center rounded-lg outline-none transition-colors hover:bg-sidebar-accent focus-visible:ring-2 focus-visible:ring-accent data-[state=open]:bg-sidebar-accent",
77 compact ? "h-8 shrink-0 gap-1.5 px-1 max-md:h-11 max-sm:gap-1 hover:bg-raised data-[state=open]:bg-raised" : "h-9 grow gap-2 px-1.5",
78 )}
79 >
80 <Avatar name={workspace.slug} image={workspace.avatar} size={compact ? 22 : 26} square />
81 <span className={cn("min-w-0 truncate font-semibold", compact ? "text-sm max-sm:hidden" : "text-[0.875rem]")}>{displayName(workspace)}</span>
82 <ChevronDown size={14} className="shrink-0 text-faint" />
83 </DropdownMenuTrigger>
84 <DropdownMenuContent align="start" className="w-72 p-1.5">
85 <DropdownMenuItem asChild className="gap-3 px-2 py-2">
86 <Link to={`/${workspace.slug}/-/workspace`}>
87 <Avatar name={workspace.slug} image={workspace.avatar} size={36} square />
88 <span className="flex min-w-0 grow flex-col leading-tight">
89 <span className="truncate text-sm font-medium text-fg">{displayName(workspace)}</span>
90 <span className="truncate font-mono text-xs text-muted">g1t.sh/{workspace.slug}</span>
91 <span className="mt-0.5 text-xs text-faint capitalize">{workspace.role}</span>
92 </span>
93 </Link>
94 </DropdownMenuItem>
95 <DropdownMenuSeparator className="my-1.5" />
96 <DropdownMenuLabel>Switch workspace</DropdownMenuLabel>
97 {(user.workspaces ?? []).map((membership) => (
98 <DropdownMenuItem key={membership.slug} asChild>
99 <Link to={homePagePath(membership.slug)}>
100 <Avatar name={membership.slug} image={membership.avatar} size={24} square />
101 <span className="flex min-w-0 grow flex-col leading-tight">
102 <span className="truncate">{displayName(membership)}</span>
103 <span className="truncate font-mono text-[0.6875rem] text-faint">{membership.slug}</span>
104 </span>
105 {membership.slug === workspace.slug ? <Check className="shrink-0 text-accent" /> : <span className="size-4 shrink-0" aria-hidden="true" />}
106 </Link>
107 </DropdownMenuItem>
108 ))}
109 <DropdownMenuItem asChild>
110 <Link to="/workspaces/new">
111 <Plus />
112 New workspace
113 </Link>
114 </DropdownMenuItem>
115 <DropdownMenuSeparator className="my-1.5" />
116 <DropdownMenuLabel>Personal account</DropdownMenuLabel>
117 <DropdownMenuItem asChild>
118 <Link to={`/u/${user.username}`}>
119 <CircleUserRound />
120 <span className="grow">Your profile</span>
121 <span className="font-mono text-xs text-faint">@{shownUsername(user)}</span>
122 </Link>
123 </DropdownMenuItem>
124 <DropdownMenuItem asChild>
125 <Link to="/settings">
126 <Settings />
127 Your settings
128 </Link>
129 </DropdownMenuItem>
130 </DropdownMenuContent>
131 </DropdownMenu>
132 );
133}
134
135/** Keyboard shortcuts, as the account menu lists them. */
136function shortcuts(palette: string, sidebar: string): [string, string][] {
137 return [
138 [palette, "Search, jump anywhere, or run a command"],
139 [sidebar, "Show or hide the sidebar"],
140 ["Esc", "Close a menu, dialog or panel"],
141 ["Enter", "Send a message in Chat"],
142 ["Shift Enter", "A new line in a message"],
143 ["@", "Mention a person or a team"],
144 ["↑ ↓ then Tab", "Choose a suggestion"],
145 ["Alt ↑ ↓", "Move a pinned project in Code's sidebar"],
146 ];
147}
148
149/** The sidebar's shortcut, as this computer writes it. */
150export function sidebarKeyLabel(platform: string | null): string {
151 return paletteKeyLabel(platform).replace(/K$/, "B");
152}
153
154/** The keyboard's shortcuts, opened from the account menu. */
155export function ShortcutsDialog({ open, onOpenChange }: { open: boolean; onOpenChange: (open: boolean) => void }) {
156 const platform = typeof navigator === "undefined" ? null : navigator.platform;
157 return (
158 <Dialog open={open} onOpenChange={onOpenChange}>
159 <DialogContent className="max-w-md">
160 <DialogHeader>
161 <DialogTitle>Keyboard shortcuts</DialogTitle>
162 <DialogDescription>Everywhere in g1t, and in Chat.</DialogDescription>
163 </DialogHeader>
164 <Card asChild tone="plain" radius="lg" divided>
165 <dl>
166 {shortcuts(paletteKeyLabel(platform), sidebarKeyLabel(platform)).map(([key, what]) => (
167 <div key={key} className="flex items-center justify-between gap-4 px-3.5 py-2.5 text-sm">
168 <dt className="text-fg-soft">{what}</dt>
169 <dd>
170 <Kbd className="px-1.5 py-0.5 font-sans text-xs whitespace-nowrap text-fg ring-line-strong">{key}</Kbd>
171 </dd>
172 </div>
173 ))}
174 </dl>
175 </Card>
176 </DialogContent>
177 </Dialog>
178 );
179}
180
181/** The Apps icon at the foot of the rail's apps, and the launcher it opens beside it. */
182function AppsButton({ slug, pins, onToggle, current }: { slug: string; pins: PinnableApp[]; onToggle: (app: PinnableApp) => void; current: boolean }) {
183 const [open, setOpen] = useState(false);
184 return (
185 <Popover open={open} onOpenChange={setOpen}>
186 <SidebarMenuButton asChild size="icon" isActive={current} tooltip="Apps">
187 <PopoverTrigger aria-label="Apps">
188 <LayoutGrid />
189 </PopoverTrigger>
190 </SidebarMenuButton>
191 <PopoverContent side="right" align="end" sideOffset={12} aria-label="Apps" className="w-92 p-2.5">
192 <AppsLauncher slug={slug} pins={pins} onToggle={onToggle} onClose={() => setOpen(false)} />
193 </PopoverContent>
194 </Popover>
195 );
196}
197
198/**
199 * The rail down the window's left edge: a narrow column of icons, each
200 * named by the hint beside it. g1t's mark, which leads to Home; Home;
201 * Explore, lit anywhere beyond your workspaces; then the other built-in
202 * apps (Work, with its sessions, Chat, Code, Artifacts and Routines) with
203 * what is unread on them (Notifications is the bell in the top bar, not a
204 * rail app); the apps you pinned; the Apps launcher;
205 * and at its foot People, Workspace and your account. The one you are in
206 * sits on a filled square, and which that is follows the address. A
207 * member without Code access has no Code. From 768px; a phone has the
208 * bottom bar (components/mobile.tsx). Drawn from the sidebar kit
209 * (components/ui/sidebar.tsx), as the mode sidebar beside it is.
210 */
211export function Rail({
212 workspace,
213 mode,
214 pins: saved,
215 unread,
216 account,
217}: {
218 workspace: Membership;
219 /** The mode the shell is in, when it knows more than the address (`browse`, lib/workspace-nav.ts `shellMode`). */
220 mode?: ModeKey;
221 /** The apps pinned to this person's rail in this workspace, as saved. */
222 pins: PinnableApp[];
223 unread: { notifications: number; chat: number; mentions: number };
224 account: ReactNode;
225}) {
226 const { pathname: path } = useSidebarLocation();
227 const here = mode ?? modeOf(path, workspace.slug);
228 const slug = workspace.slug;
229 // A member without Code access has no Code; Work is every member's, its sessions in it.
230 const code = hasCodeAccess(workspace);
231 const { pins, toggle } = useAppPins(slug, saved);
232 const badges: Partial<Record<ModeKey, Item["badge"]>> = {
233 chat: { count: unread.mentions > 0 ? unread.mentions : unread.chat, loud: unread.mentions > 0 },
234 };
235 const shown = pins.map((key) => pinnedApp(key, slug)).filter((app) => app != null);
236 // A pinned app is current on its own page and the pages under it.
237 const at = (to: string) => path === to || path.startsWith(`${to}/`);
238 return (
239 <Sidebar variant="rail" collapsible="none" role="navigation" aria-label="Apps">
240 <SidebarHeader className="h-14 items-center justify-center p-0">
241 <SidebarMenu className="w-auto">
242 <SidebarMenuItem>
243 <SidebarMenuButton asChild size="icon" tooltip="Home">
244 <Link to={homePagePath(slug)} aria-label="g1t: Home">
245 <span className="flex">
246 <Mark tight className="h-4.5 w-auto" />
247 </span>
248 </Link>
249 </SidebarMenuButton>
250 </SidebarMenuItem>
251 </SidebarMenu>
252 </SidebarHeader>
253 <SidebarContent className="items-center gap-0">
254 <SidebarMenu className="w-auto shrink-0 gap-2">
255 {railApps(code).map((key) =>
256 // g1t beyond your workspaces: Explore, search, profiles, other workspaces and their public projects.
257 key === "explore" ? (
258 <RailLink key={key} to="/explore" label="Explore" icon={<Compass size={20} />} current={here === "browse" || at("/explore")} />
259 ) : (
260 <RailLink
261 key={key}
262 to={modeHome(key, slug)}
263 label={appOf(key).name}
264 icon={appIcon(key, 20)}
265 current={here === key && !shown.some((app) => at(app.to))}
266 badge={badges[key]}
267 />
268 ),
269 )}
270 </SidebarMenu>
271 {/* Your pinned apps: as many as you like, scrolling under a fade at either end. */}
272 {shown.length > 0 && (
273 <SidebarMenu className="mt-2 w-auto min-h-0 grow gap-2 overflow-y-auto py-2 mask-[linear-gradient(to_bottom,transparent,#000_10px,#000_calc(100%-14px),transparent)] scrollbar-none">
274 {shown.map((app) => (
275 <RailLink key={app.key} to={app.to} label={app.name} icon={app.icon(20)} current={at(app.to)} />
276 ))}
277 </SidebarMenu>
278 )}
279 </SidebarContent>
280 <SidebarFooter className="items-center gap-0 p-0 pb-3">
281 <SidebarMenu className="w-auto gap-2">
282 <SidebarMenuItem>
283 <AppsButton slug={slug} pins={pins} onToggle={toggle} current={here === "apps"} />
284 </SidebarMenuItem>
285 </SidebarMenu>
286 <SidebarSeparator className="my-2.5 w-7" />
287 <SidebarMenu className="w-auto gap-2">
288 <RailLink to={modeHome("people", slug)} label="People" icon={appIcon("people", 20)} current={here === "people"} />
289 <RailLink to={modeHome("workspace", slug)} label="Workspace" icon={appIcon("workspace", 20)} current={here === "workspace"} />
290 <SidebarMenuItem className="mt-1 flex justify-center">{account}</SidebarMenuItem>
291 </SidebarMenu>
292 </SidebarFooter>
293 </Sidebar>
294 );
295}