Merge branch 'worktree-agent-aefe9db9851cfafd8'
6 files+337−340/6 viewed
| 591 | 591 | ||
| 592 | 592 | Signed in, g1t is always about one workspace: the one you are in. On a | |
| 593 | 593 | workspace's pages, and on a project in one of your workspaces, that is the | |
| 594 | − | workspace the page belongs to; anywhere else, even on public pages such as | |
| 594 | + | workspace the page belongs to, so opening a project in another of your | |
| 595 | + | workspaces switches you to it; anywhere else, even on public pages such as | |
| 595 | 596 | [Explore](https://g1t.sh/explore), someone's profile or another | |
| 596 | 597 | workspace's public project, it stays the one you chose last. | |
| 597 | 598 | ||
| 599 | + | Those pages beyond your workspaces are browsing g1t: the rail lights | |
| 600 | + | **Explore**, and Explore's sidebar sits beside them. It has **Back to** | |
| 601 | + | your workspace at the top, which goes to its [Home](/guides/home/); then | |
| 602 | + | **Recently active**, **New projects** and **Search**; **Your stars** and | |
| 603 | + | **Your profile**; and under **You are viewing**, whose page this is: a | |
| 604 | + | workspace with its overview and **Projects**, or a person with their | |
| 605 | + | profile and **Stars**. In one of their public projects, the sidebar is | |
| 606 | + | that project's own list, as in Code, with **Projects** at its top to go | |
| 607 | + | back to the rest of theirs. A project shared with you stays in Code, under | |
| 608 | + | **Shared with you**. On a phone, the button at the top left opens the same | |
| 609 | + | sidebar, and **More** has **Explore**. | |
| 610 | + | ||
| 598 | 611 | Each page has three parts: | |
| 599 | 612 | ||
| 600 | 613 | | Part | Where | What it holds | | |
| ⋯ | |||
| 935 | 948 | ||
| 936 | 949 | **How it is laid out.** Signed out, a profile, Explore and Search are | |
| 937 | 950 | shown with g1t's public header (search, Explore, Docs, signing in) and the page | |
| 938 | − | at full width, with no workspace sidebar. Signed in, the rail stays, but | |
| 939 | − | no mode is lit and no mode's sidebar opens beside these pages: they are | |
| 940 | − | nobody's workspace, and the profile's own left column says whose it is. | |
| 951 | + | at full width, with no workspace sidebar. Signed in, the rail stays with | |
| 952 | + | **Explore** lit, and Explore's sidebar opens beside these pages, with the | |
| 953 | + | person you are looking at under **You are viewing** (see | |
| 954 | + | [The rail, the sidebar and the page](#the-rail-the-sidebar-and-the-page)). | |
| 955 | + | Your own profile is browsing too, with **Your profile** lit. | |
| 941 | 956 | ||
| 942 | 957 | ### The card over a name | |
| 943 | 958 | ||
| 4 | 4 | BellRing, | |
| 5 | 5 | BookOpen, | |
| 6 | 6 | CircleUserRound, | |
| 7 | + | Compass, | |
| 7 | 8 | Ellipsis, | |
| 8 | 9 | Keyboard, | |
| 9 | 10 | LayoutGrid, | |
| ⋯ | |||
| 130 | 131 | export function BottomBar({ | |
| 131 | 132 | user, | |
| 132 | 133 | workspace, | |
| 134 | + | mode: shellMode, | |
| 133 | 135 | pins, | |
| 134 | 136 | unread, | |
| 135 | 137 | onReselect, | |
| 136 | 138 | }: { | |
| 137 | 139 | user: User; | |
| 138 | 140 | workspace: Membership; | |
| 141 | + | /** The mode the shell is in, when it knows more than the address (`browse`, lib/workspace-nav.ts `shellMode`). */ | |
| 142 | + | mode?: ModeKey; | |
| 139 | 143 | /** The apps pinned to this person's rail here, for the More sheet. */ | |
| 140 | 144 | pins: PinnableApp[]; | |
| 141 | 145 | unread: { notifications: number; chat: number; mentions: number }; | |
| ⋯ | |||
| 147 | 151 | useEffect(() => setMore(false), [pathname]); | |
| 148 | 152 | const slug = workspace.slug; | |
| 149 | 153 | const code = hasCodeAccess(workspace); | |
| 150 | − | const mode = modeOf(pathname, slug); | |
| 154 | + | const mode = shellMode ?? modeOf(pathname, slug); | |
| 151 | 155 | const tabs: Tab[] = [ | |
| 152 | 156 | { key: "home", label: "Home" }, | |
| 153 | 157 | { key: "chat", label: "Chat", badge: { count: unread.mentions > 0 ? unread.mentions : unread.chat, loud: unread.mentions > 0 } }, | |
| ⋯ | |||
| 202 | 206 | </li> | |
| 203 | 207 | </ul> | |
| 204 | 208 | </nav> | |
| 205 | − | <MoreSheet open={more} onOpenChange={setMore} user={user} workspace={workspace} pins={pins} /> | |
| 209 | + | <MoreSheet open={more} onOpenChange={setMore} user={user} workspace={workspace} pins={pins} onExplore={mode === "browse" ? onReselect : undefined} /> | |
| 206 | 210 | </> | |
| 207 | 211 | ); | |
| 208 | 212 | } | |
| ⋯ | |||
| 256 | 260 | /** | |
| 257 | 261 | * More, from the bottom bar: a flat panel standing on it, with g1t's | |
| 258 | 262 | * mark at its top and a grid of big tiles: all apps, | |
| 259 | − | * your pinned apps, Artifacts, People, Workspace, and "You and | |
| 260 | − | * help", which opens your account's sheet. | |
| 263 | + | * your pinned apps, Artifacts, People, Workspace, Explore, and "You and | |
| 264 | + | * help", which opens your account's sheet. Browsing beyond your | |
| 265 | + | * workspaces, Explore opens Explore's menu (`onExplore`) rather than the | |
| 266 | + | * page. | |
| 261 | 267 | */ | |
| 262 | − | function MoreSheet({ open, onOpenChange, user, workspace, pins }: { open: boolean; onOpenChange: (open: boolean) => void; user: User; workspace: Membership; pins: PinnableApp[] }) { | |
| 268 | + | function MoreSheet({ | |
| 269 | + | open, | |
| 270 | + | onOpenChange, | |
| 271 | + | user, | |
| 272 | + | workspace, | |
| 273 | + | pins, | |
| 274 | + | onExplore, | |
| 275 | + | }: { | |
| 276 | + | open: boolean; | |
| 277 | + | onOpenChange: (open: boolean) => void; | |
| 278 | + | user: User; | |
| 279 | + | workspace: Membership; | |
| 280 | + | pins: PinnableApp[]; | |
| 281 | + | onExplore?: () => void; | |
| 282 | + | }) { | |
| 263 | 283 | const slug = workspace.slug; | |
| 264 | 284 | const [account, setAccount] = useState(false); | |
| 265 | 285 | const shown = pins.map((key) => pinnedApp(key, slug)).filter((app) => app != null); | |
| ⋯ | |||
| 287 | 307 | <MoreTile to={appOf("artifacts").path(slug)} icon={appIcon("artifacts", 20)} label="Artifacts" /> | |
| 288 | 308 | <MoreTile to={appOf("people").path(slug)} icon={appIcon("people", 20)} label="People" /> | |
| 289 | 309 | <MoreTile to={appOf("workspace").path(slug)} icon={appIcon("workspace", 20)} label="Workspace" /> | |
| 310 | + | {onExplore ? ( | |
| 311 | + | <MoreTile | |
| 312 | + | icon={<Compass size={20} />} | |
| 313 | + | label="Explore" | |
| 314 | + | onClick={() => { | |
| 315 | + | onOpenChange(false); | |
| 316 | + | onExplore(); | |
| 317 | + | }} | |
| 318 | + | /> | |
| 319 | + | ) : ( | |
| 320 | + | <MoreTile to="/explore" icon={<Compass size={20} />} label="Explore" /> | |
| 321 | + | )} | |
| 290 | 322 | <MoreTile | |
| 291 | 323 | icon={<Avatar name={user.username} image={user.avatar} size={24} />} | |
| 292 | 324 | label="You and help" | |
| 201 | 201 | * named by the hint beside it. g1t's mark, which leads to Home; the | |
| 202 | 202 | * built-in apps (Home, Chat, Code and Artifacts) with what is | |
| 203 | 203 | * unread on them (Notifications is the bell in the top bar, not a rail | |
| 204 | − | * app); the apps you pinned; the Apps launcher; | |
| 204 | + | * app); Explore, lit anywhere beyond your workspaces; the apps you | |
| 205 | + | * pinned; the Apps launcher; | |
| 205 | 206 | * and at its foot People, Workspace and your account. The one you are in | |
| 206 | 207 | * sits on a filled square, and which that is follows the address. A | |
| 207 | 208 | * member without Code access has no Code. From 768px; a phone has the | |
| ⋯ | |||
| 210 | 211 | */ | |
| 211 | 212 | export function Rail({ | |
| 212 | 213 | workspace, | |
| 214 | + | mode, | |
| 213 | 215 | pins: saved, | |
| 214 | 216 | unread, | |
| 215 | 217 | account, | |
| 216 | 218 | }: { | |
| 217 | 219 | workspace: Membership; | |
| 220 | + | /** The mode the shell is in, when it knows more than the address (`browse`, lib/workspace-nav.ts `shellMode`). */ | |
| 221 | + | mode?: ModeKey; | |
| 218 | 222 | /** The apps pinned to this person's rail in this workspace, as saved. */ | |
| 219 | 223 | pins: PinnableApp[]; | |
| 220 | 224 | unread: { notifications: number; chat: number; mentions: number }; | |
| ⋯ | |||
| 223 | 227 | const { pathname } = useLocation(); | |
| 224 | 228 | const going = useNavigation().location?.pathname; | |
| 225 | 229 | const path = going ?? pathname; | |
| 226 | − | const here = modeOf(path, workspace.slug); | |
| 230 | + | const here = mode ?? modeOf(path, workspace.slug); | |
| 227 | 231 | const slug = workspace.slug; | |
| 228 | 232 | const code = hasCodeAccess(workspace); | |
| 229 | 233 | const { pins, toggle } = useAppPins(slug, saved); | |
| ⋯ | |||
| 267 | 271 | badge={item.badge} | |
| 268 | 272 | /> | |
| 269 | 273 | ))} | |
| 270 | − | {/* Public repositories across g1t, beyond this workspace. */} | |
| 271 | − | <RailLink to="/explore" label="Explore" icon={<Compass size={20} />} current={at("/explore")} /> | |
| 274 | + | {/* g1t beyond your workspaces: Explore, search, profiles, other workspaces and their public projects. */} | |
| 275 | + | <RailLink to="/explore" label="Explore" icon={<Compass size={20} />} current={here === "browse" || at("/explore")} /> | |
| 272 | 276 | </SidebarMenu> | |
| 273 | 277 | {/* Your pinned apps: as many as you like, scrolling under a fade at either end. */} | |
| 274 | 278 | {shown.length > 0 && ( | |
| 1 | − | import { Activity, BarChart3, Building2, MessagesSquare, Bell, BookMarked, BookOpen, Bookmark, Blocks, Box, Brain, Check, ChevronDown, ChevronLeft, ChevronRight, CircleDot, GripVertical, CircleUserRound, Code2, Compass, CreditCard, Fingerprint, Gauge, GitBranch, GitPullRequest, Globe, History, Keyboard, KeyRound, Layers, LayoutDashboard, LayoutGrid, LifeBuoy, Lock, LogOut, Mail, MonitorCog, MonitorDown, Package, PlayCircle, Plug, Plus, Rocket, Search, ServerCog, Settings, Shapes, Shield, ShieldCheck, Scale, Smile, Sparkles, House, TrendingUp, UserRoundKey, Users, UsersRound, Webhook, X } from "lucide-react"; | |
| 1 | + | import { Activity, BarChart3, Building2, MessagesSquare, Bell, BookMarked, BookOpen, Bookmark, Blocks, Box, Brain, Check, ChevronDown, ChevronLeft, ChevronRight, CircleDot, GripVertical, CircleUserRound, Code2, Compass, CreditCard, Fingerprint, Gauge, GitBranch, GitPullRequest, Globe, History, Keyboard, KeyRound, Layers, LayoutDashboard, LayoutGrid, LifeBuoy, Lock, LogOut, Mail, MonitorCog, MonitorDown, Package, PlayCircle, Plug, Plus, Rocket, Search, ServerCog, Settings, Shapes, Shield, ShieldCheck, Scale, Smile, Sparkles, Star, House, TrendingUp, UserRoundKey, Users, UsersRound, Webhook, X } from "lucide-react"; | |
| 2 | 2 | import { Fragment, type ReactNode, useCallback, useEffect, useMemo, useRef, useState } from "react"; | |
| 3 | 3 | import { Link, useFetcher, useLocation, useNavigation, useRouteLoaderData, useSubmit } from "react-router"; | |
| 4 | 4 | ||
| ⋯ | |||
| 46 | 46 | DropdownMenuTrigger, | |
| 47 | 47 | } from "./ui/dropdown-menu"; | |
| 48 | 48 | import { type RoadmapItem, roadmapIn, roadmapItem } from "../lib/roadmap"; | |
| 49 | − | import { type ModeKey, WORKSPACE_SETTINGS_GROUPS, type WorkspaceSettingsPage, homePagePath, modeOf, workspaceSettingsPage } from "../lib/workspace-nav"; | |
| 49 | + | import { | |
| 50 | + | type BrowsedOwner, | |
| 51 | + | type ModeKey, | |
| 52 | + | WORKSPACE_SETTINGS_GROUPS, | |
| 53 | + | type WorkspaceSettingsPage, | |
| 54 | + | browsingOf, | |
| 55 | + | homePagePath, | |
| 56 | + | modeOf, | |
| 57 | + | pagePath, | |
| 58 | + | workspaceSettingsPage, | |
| 59 | + | } from "../lib/workspace-nav"; | |
| 50 | 60 | import { ChatSidebar } from "./chat/sidebar"; | |
| 51 | 61 | import { FoliosSidebar } from "./folios/sidebar"; | |
| 52 | 62 | import { Rail, ShortcutsDialog, WorkspaceSwitcher, sidebarKeyLabel } from "./rail"; | |
| ⋯ | |||
| 773 | 783 | }: { | |
| 774 | 784 | repo: MenuRepo; | |
| 775 | 785 | isPrivate: boolean; | |
| 776 | − | /** Where the way back leads: the workspace's projects, mission control, or Explore. */ | |
| 777 | − | back: { to: string; label: string }; | |
| 786 | + | /** Where the way back leads: the workspace's projects, mission control, or the projects of the workspace being browsed. */ | |
| 787 | + | back: { to: string; label: string; context?: string }; | |
| 778 | 788 | }) { | |
| 779 | 789 | const base = `/${repo.namespace}/${repo.name}`; | |
| 780 | 790 | // What a member sees, and what everyone who can see the project does | |
| ⋯ | |||
| 783 | 793 | const { current: atOverview, pending: toOverview } = useRowState(base, { end: true }); | |
| 784 | 794 | return ( | |
| 785 | 795 | <nav aria-label={`${repo.namespace}/${repo.name}`} className={PANEL}> | |
| 786 | − | <BackRow to={back.to} label={back.label} /> | |
| 796 | + | <BackRow to={back.to} label={back.label} context={back.context} /> | |
| 787 | 797 | <NavRows className="mt-1"> | |
| 788 | 798 | <SidebarMenuItem> | |
| 789 | 799 | <Hint label="Overview" side="right"> | |
| ⋯ | |||
| 1015 | 1025 | user, | |
| 1016 | 1026 | shell, | |
| 1017 | 1027 | missing = false, | |
| 1028 | + | browsing = null, | |
| 1018 | 1029 | }: { | |
| 1019 | 1030 | user: User; | |
| 1020 | 1031 | shell: ShellData; | |
| 1021 | 1032 | /** The page is a 404: the address names nothing the viewer can see. */ | |
| 1022 | 1033 | missing?: boolean; | |
| 1034 | + | /** g1t beyond the viewer's workspaces (lib/workspace-nav.ts `browsingOf`): Explore's list in place of Code's. */ | |
| 1035 | + | browsing?: { owner: BrowsedOwner | null } | null; | |
| 1023 | 1036 | }) { | |
| 1024 | 1037 | const ws = shell.workspace; | |
| 1025 | 1038 | const { pathname } = useLocation(); | |
| ⋯ | |||
| 1064 | 1077 | ? { ...guessed, member: page.access.insider, settings: seesSettings(page.access), can: page.access.can } | |
| 1065 | 1078 | : guessed; | |
| 1066 | 1079 | ||
| 1067 | − | // The way from Code's list to the one shown. | |
| 1068 | − | const trail: Level[] = [{ key: "main", node: ws ? <CodeMenu shell={shell} slug={ws.slug} /> : null }]; | |
| 1080 | + | // The way from Code's list (or Explore's, browsing) to the one shown. | |
| 1081 | + | const main = browsing ? <BrowseMenu user={user} owner={browsing.owner} /> : ws ? <CodeMenu shell={shell} slug={ws.slug} /> : null; | |
| 1082 | + | const trail: Level[] = [{ key: browsing ? "browse" : "main", node: main }]; | |
| 1069 | 1083 | if (menuRepo) { | |
| 1070 | 1084 | const key = `repo:${menuRepo.namespace}/${menuRepo.name}`.toLowerCase(); | |
| 1071 | − | // Out of a project: to its workspace's projects when they are yours. | |
| 1085 | + | // Out of a project: to its workspace's projects when they are yours, or | |
| 1086 | + | // when browsing, to the projects of the workspace it is in. | |
| 1072 | 1087 | const home = (user.workspaces ?? []).find((m) => m.slug === menuRepo.namespace.toLowerCase()); | |
| 1073 | − | const back = home ? { to: `/${home.slug}/-/projects`, label: "All projects" } : { to: ws ? `/${ws.slug}/-/overview` : "/explore", label: "Code" }; | |
| 1088 | + | const back = home | |
| 1089 | + | ? { to: `/${home.slug}/-/projects`, label: "All projects" } | |
| 1090 | + | : browsing | |
| 1091 | + | ? { to: `/${menuRepo.namespace}/-/projects`, label: "Projects", context: menuRepo.namespace } | |
| 1092 | + | : { to: ws ? `/${ws.slug}/-/overview` : "/explore", label: "Code" }; | |
| 1074 | 1093 | trail.push({ | |
| 1075 | 1094 | key, | |
| 1076 | 1095 | node: ( | |
| ⋯ | |||
| 1086 | 1105 | ||
| 1087 | 1106 | return ( | |
| 1088 | 1107 | <div className="flex h-full flex-col"> | |
| 1089 | − | <ModeHeader title="Code" /> | |
| 1108 | + | {browsing && ws && ( | |
| 1109 | + | <> | |
| 1110 | + | <BackToWorkspace workspace={ws} /> | |
| 1111 | + | <Rule /> | |
| 1112 | + | </> | |
| 1113 | + | )} | |
| 1114 | + | <ModeHeader title={browsing ? "Explore" : "Code"} /> | |
| 1090 | 1115 | <Drill trail={trail} /> | |
| 1091 | 1116 | </div> | |
| 1092 | 1117 | ); | |
| 1093 | 1118 | } | |
| 1094 | 1119 | ||
| 1095 | 1120 | /** | |
| 1121 | + | * Browsing, the way back to the workspace you are in: its Home, at the top | |
| 1122 | + | * of Explore's sidebar, at every level of it. | |
| 1123 | + | */ | |
| 1124 | + | function BackToWorkspace({ workspace }: { workspace: Membership }) { | |
| 1125 | + | const name = displayName(workspace); | |
| 1126 | + | return ( | |
| 1127 | + | <NavRows className="pt-0"> | |
| 1128 | + | <SidebarMenuItem> | |
| 1129 | + | <SidebarMenuButton asChild className="gap-2 pl-1 font-medium text-fg"> | |
| 1130 | + | <Link to={homePagePath(workspace.slug)} prefetch="intent" aria-label={`Back to ${name}: Home`}> | |
| 1131 | + | <ChevronLeft className="text-faint transition-transform group-hover/menu-button:-translate-x-0.5 group-hover/menu-button:text-muted" /> | |
| 1132 | + | <Avatar name={workspace.slug} image={workspace.avatar} size={18} square /> | |
| 1133 | + | <span className="min-w-0 truncate">Back to {name}</span> | |
| 1134 | + | </Link> | |
| 1135 | + | </SidebarMenuButton> | |
| 1136 | + | </SidebarMenuItem> | |
| 1137 | + | </NavRows> | |
| 1138 | + | ); | |
| 1139 | + | } | |
| 1140 | + | ||
| 1141 | + | /** Whose page is being browsed, as the page itself names them once it has loaded: a workspace's or a person's. */ | |
| 1142 | + | function useBrowsedOwner(owner: BrowsedOwner | null): { name: string; avatar: string | null } | null { | |
| 1143 | + | const workspace = useRouteLoaderData("routes/workspace/layout") as { workspace?: { slug: string; name?: string | null; avatar?: string | null } } | undefined; | |
| 1144 | + | const person = useRouteLoaderData("routes/user") as { profile?: { username: string; displayUsername?: string | null; avatar: string | null } } | undefined; | |
| 1145 | + | if (!owner) return null; | |
| 1146 | + | if (owner.kind === "person") { | |
| 1147 | + | const profile = person?.profile?.username.toLowerCase() === owner.name.toLowerCase() ? person.profile : null; | |
| 1148 | + | return { name: `@${profile ? shownUsername(profile) : owner.name}`, avatar: profile?.avatar ?? null }; | |
| 1149 | + | } | |
| 1150 | + | const page = workspace?.workspace?.slug.toLowerCase() === owner.name.toLowerCase() ? workspace.workspace : null; | |
| 1151 | + | return { name: page?.name?.trim() || owner.name, avatar: page?.avatar ?? null }; | |
| 1152 | + | } | |
| 1153 | + | ||
| 1154 | + | /** | |
| 1155 | + | * Explore's list, beside g1t beyond your workspaces (lib/workspace-nav.ts | |
| 1156 | + | * `browsingOf`): public projects across g1t, recently active and new, | |
| 1157 | + | * search, and what you starred; then whose page this is, with their own | |
| 1158 | + | * pages. A project of theirs drills into its own list, as in Code. | |
| 1159 | + | */ | |
| 1160 | + | function BrowseMenu({ user, owner }: { user: User; owner: BrowsedOwner | null }) { | |
| 1161 | + | const location = useLocation(); | |
| 1162 | + | const going = useNavigation().location; | |
| 1163 | + | const path = pagePath((going ?? location).pathname); | |
| 1164 | + | const query = new URLSearchParams((going ?? location).search); | |
| 1165 | + | const atExplore = path === "/explore"; | |
| 1166 | + | const fresh = atExplore && query.get("sort") === "new"; | |
| 1167 | + | const mine = `/u/${user.username}`; | |
| 1168 | + | const atMine = path.toLowerCase() === mine.toLowerCase(); | |
| 1169 | + | const stars = atMine && query.get("tab") === "stars"; | |
| 1170 | + | const shown = useBrowsedOwner(owner); | |
| 1171 | + | const profileTab = query.get("tab"); | |
| 1172 | + | return ( | |
| 1173 | + | <nav aria-label="Explore" className={PANEL}> | |
| 1174 | + | <NavRows> | |
| 1175 | + | <SidebarLink to="/explore" icon={<Compass size={15} />} current={atExplore && !fresh}> | |
| 1176 | + | Recently active | |
| 1177 | + | </SidebarLink> | |
| 1178 | + | <SidebarLink to="/explore?sort=new" icon={<Sparkles size={15} />} current={fresh}> | |
| 1179 | + | New projects | |
| 1180 | + | </SidebarLink> | |
| 1181 | + | <SidebarLink to="/search" icon={<Search size={15} />} current={path === "/search"}> | |
| 1182 | + | Search | |
| 1183 | + | </SidebarLink> | |
| 1184 | + | </NavRows> | |
| 1185 | + | <Rule /> | |
| 1186 | + | <NavRows> | |
| 1187 | + | <SidebarLink to={`${mine}?tab=stars`} icon={<Star size={15} />} current={stars}> | |
| 1188 | + | Your stars | |
| 1189 | + | </SidebarLink> | |
| 1190 | + | <SidebarLink to={mine} icon={<CircleUserRound size={15} />} current={atMine && !stars}> | |
| 1191 | + | Your profile | |
| 1192 | + | </SidebarLink> | |
| 1193 | + | </NavRows> | |
| 1194 | + | {owner && shown && ( | |
| 1195 | + | <> | |
| 1196 | + | <Rule /> | |
| 1197 | + | <NavGroup title="You are viewing"> | |
| 1198 | + | {owner.kind === "workspace" ? ( | |
| 1199 | + | <> | |
| 1200 | + | <SidebarLink | |
| 1201 | + | to={`/${owner.name}`} | |
| 1202 | + | icon={<Avatar name={owner.name} image={shown.avatar} size={16} square />} | |
| 1203 | + | current={path.toLowerCase() === `/${owner.name}`.toLowerCase()} | |
| 1204 | + | > | |
| 1205 | + | {shown.name} | |
| 1206 | + | </SidebarLink> | |
| 1207 | + | <SidebarLink to={`/${owner.name}/-/projects`} icon={<LayoutGrid size={15} />}> | |
| 1208 | + | Projects | |
| 1209 | + | </SidebarLink> | |
| 1210 | + | </> | |
| 1211 | + | ) : ( | |
| 1212 | + | <> | |
| 1213 | + | <SidebarLink | |
| 1214 | + | to={`/u/${owner.name}`} | |
| 1215 | + | icon={<Avatar name={owner.name} image={shown.avatar} size={16} />} | |
| 1216 | + | current={profileTab !== "stars"} | |
| 1217 | + | > | |
| 1218 | + | {shown.name} | |
| 1219 | + | </SidebarLink> | |
| 1220 | + | <SidebarLink to={`/u/${owner.name}?tab=stars`} icon={<Star size={15} />} current={profileTab === "stars"}> | |
| 1221 | + | Stars | |
| 1222 | + | </SidebarLink> | |
| 1223 | + | </> | |
| 1224 | + | )} | |
| 1225 | + | </NavGroup> | |
| 1226 | + | </> | |
| 1227 | + | )} | |
| 1228 | + | </nav> | |
| 1229 | + | ); | |
| 1230 | + | } | |
| 1231 | + | ||
| 1232 | + | /** | |
| 1096 | 1233 | * A mode's sidebar heading: its name, quiet, under the workspace's | |
| 1097 | 1234 | * switcher, with what it makes at the end. | |
| 1098 | 1235 | */ | |
| ⋯ | |||
| 1744 | 1881 | ||
| 1745 | 1882 | /** | |
| 1746 | 1883 | * Which sidebar sits beside the page: each mode's own, or none. Home and | |
| 1747 | − | * Apps are pages at full width, and so are g1t's public pages, such as a | |
| 1748 | − | * profile, which are no workspace's. Without a workspace, only your own | |
| 1749 | − | * settings and Notifications have one. | |
| 1884 | + | * Apps are pages at full width, and so are g1t's own pages, such as its | |
| 1885 | + | * policies, which are no workspace's. g1t beyond your workspaces (Explore, | |
| 1886 | + | * a profile, another workspace's projects) has Explore's. Without a | |
| 1887 | + | * workspace, only your own settings and Notifications have one. | |
| 1750 | 1888 | */ | |
| 1751 | 1889 | function sidebarFor(mode: ModeKey, workspace: boolean): Panel | null { | |
| 1752 | 1890 | if (!workspace) return mode === "account" || mode === "notifications" ? mode : null; | |
| ⋯ | |||
| 1755 | 1893 | } | |
| 1756 | 1894 | ||
| 1757 | 1895 | /** The sidebars that sit beside the page, one per mode that has one. */ | |
| 1758 | − | type Panel = "chat" | "artifacts" | "code" | "notifications" | "people" | "workspace" | "account"; | |
| 1896 | + | type Panel = "chat" | "artifacts" | "code" | "notifications" | "people" | "workspace" | "account" | "browse"; | |
| 1759 | 1897 | ||
| 1760 | 1898 | /** Each sidebar's name, for the button that opens it on a phone. */ | |
| 1761 | 1899 | const MODE_MENU: Record<Panel, string> = { | |
| 1900 | + | browse: "Explore", | |
| 1762 | 1901 | chat: "Chat", | |
| 1763 | 1902 | artifacts: "Artifacts", | |
| 1764 | 1903 | code: "Code", | |
| ⋯ | |||
| 1871 | 2010 | useVisualViewport(); | |
| 1872 | 2011 | const going = useNavigation().location?.pathname; | |
| 1873 | 2012 | const ws = shell.workspace; | |
| 1874 | − | const mode = modeOf(going ?? pathname, ws?.slug ?? null); | |
| 2013 | + | // Beyond your workspaces (Explore, a profile, another workspace's | |
| 2014 | + | // projects), the shell is Explore's; anything of yours is its mode's. | |
| 2015 | + | const browsing = browsingOf(going ?? pathname, { | |
| 2016 | + | slug: ws?.slug ?? null, | |
| 2017 | + | memberships: (user.workspaces ?? []).map((membership) => membership.slug), | |
| 2018 | + | shared: shell.shared, | |
| 2019 | + | username: user.username, | |
| 2020 | + | }); | |
| 2021 | + | const mode: ModeKey = browsing ? "browse" : modeOf(going ?? pathname, ws?.slug ?? null); | |
| 1875 | 2022 | const panel = sidebarFor(mode, ws != null); | |
| 1876 | 2023 | const { sidebar: chatSidebar } = useChatSidebar(); | |
| 1877 | 2024 | // Notify: the feed socket's live counts (lib/notify-client.ts) once it has them; the page's until then. | |
| ⋯ | |||
| 1906 | 2053 | switch (panel) { | |
| 1907 | 2054 | case "code": | |
| 1908 | 2055 | return <CodeSidebar user={user} shell={shell} missing={missing} />; | |
| 2056 | + | case "browse": | |
| 2057 | + | return <CodeSidebar user={user} shell={shell} missing={missing} browsing={browsing} />; | |
| 1909 | 2058 | case "chat": | |
| 1910 | 2059 | return <ChatSidebar slug={ws.slug} />; | |
| 1911 | 2060 | case "artifacts": | |
| ⋯ | |||
| 1959 | 2108 | <Progress /> | |
| 1960 | 2109 | {ws && ( | |
| 1961 | 2110 | <div className="fixed top-(--frame-top) bottom-0 left-0 z-40 hidden md:block"> | |
| 1962 | − | <Rail workspace={ws} pins={shell.pins ?? []} unread={unread} account={<AccountMenu user={user} />} /> | |
| 2111 | + | <Rail workspace={ws} mode={mode} pins={shell.pins ?? []} unread={unread} account={<AccountMenu user={user} />} /> | |
| 1963 | 2112 | </div> | |
| 1964 | 2113 | )} | |
| 1965 | 2114 | {panel && ( | |
| ⋯ | |||
| 2030 | 2179 | </main> | |
| 2031 | 2180 | </div> | |
| 2032 | 2181 | </div> | |
| 2033 | − | {ws && <BottomBar user={user} workspace={ws} pins={shell.pins ?? []} unread={unread} onReselect={panel ? () => setDrawer(true) : undefined} />} | |
| 2182 | + | {ws && <BottomBar user={user} workspace={ws} mode={mode} pins={shell.pins ?? []} unread={unread} onReselect={panel ? () => setDrawer(true) : undefined} />} | |
| 2034 | 2183 | <CommandPalette open={palette} onOpenChange={setPalette} commands={commands} repo={shell.repo ? `${shell.repo.namespace}/${shell.repo.name}` : null} /> | |
| 2035 | 2184 | </SidebarProvider> | |
| 2036 | 2185 | ); | |
| 160 | 160 | assert.equal(codeGate("/other/web", "", none, "acme"), null); | |
| 161 | 161 | assert.equal(codeGate("/acme/web", "", [], "acme"), null); | |
| 162 | 162 | }); | |
| 163 | + | ||
| 164 | + | test("g1t beyond your workspaces is browsed, and whose it is", async () => { | |
| 165 | + | const { browsingOf, shellMode } = await import("./workspace-nav.ts"); | |
| 166 | + | const viewer = { slug: "acme", memberships: ["acme", "Side-Co"], shared: [{ namespace: "friend", name: "notes" }], username: "ada" }; | |
| 167 | + | // Explore and search are nobody's. | |
| 168 | + | assert.deepEqual(browsingOf("/explore", viewer), { owner: null }); | |
| 169 | + | assert.deepEqual(browsingOf("/explore.data", viewer), { owner: null }); | |
| 170 | + | assert.deepEqual(browsingOf("/search", viewer), { owner: null }); | |
| 171 | + | // Someone's profile is theirs; your own is no one's being browsed. | |
| 172 | + | assert.deepEqual(browsingOf("/u/grace", viewer), { owner: { kind: "person", name: "grace" } }); | |
| 173 | + | assert.deepEqual(browsingOf("/u/Ada", viewer), { owner: null }); | |
| 174 | + | // Another workspace, its pages and its projects. | |
| 175 | + | assert.deepEqual(browsingOf("/other", viewer), { owner: { kind: "workspace", name: "other", repo: null } }); | |
| 176 | + | assert.deepEqual(browsingOf("/other/-/projects", viewer), { owner: { kind: "workspace", name: "other", repo: null } }); | |
| 177 | + | assert.deepEqual(browsingOf("/other/site/pulls/3", viewer), { owner: { kind: "workspace", name: "other", repo: "site" } }); | |
| 178 | + | // Your workspaces are never browsed, the one you are in or another (the shell switches to it). | |
| 179 | + | assert.equal(browsingOf("/acme/web", viewer), null); | |
| 180 | + | assert.equal(browsingOf("/acme/-/projects", viewer), null); | |
| 181 | + | assert.equal(browsingOf("/side-co/api", viewer), null); | |
| 182 | + | // A project shared with you is Code's; the rest of its workspace is browsed. | |
| 183 | + | assert.equal(browsingOf("/friend/notes/issues", viewer), null); | |
| 184 | + | assert.deepEqual(browsingOf("/friend/other", viewer), { owner: { kind: "workspace", name: "friend", repo: "other" } }); | |
| 185 | + | // g1t's own pages and files are no one's. | |
| 186 | + | for (const path of ["/", "/settings/emails", "/notifications", "/new", "/policies/terms", "/workspaces/new", "/invitations", "/robots.txt", "/.well-known/g1t.json", "/u"]) { | |
| 187 | + | assert.equal(browsingOf(path, viewer), null, path); | |
| 188 | + | } | |
| 189 | + | // Without a workspace there is no rail, and nothing is browsed. | |
| 190 | + | assert.equal(browsingOf("/explore", { slug: null, memberships: [] }), null); | |
| 191 | + | // The shell's mode: browse beyond your workspaces, the address's mode otherwise. | |
| 192 | + | assert.equal(shellMode("/explore", viewer), "browse"); | |
| 193 | + | assert.equal(shellMode("/u/grace", viewer), "browse"); | |
| 194 | + | assert.equal(shellMode("/other/site", viewer), "browse"); | |
| 195 | + | assert.equal(shellMode("/acme/web", viewer), "code"); | |
| 196 | + | assert.equal(shellMode("/friend/notes", viewer), "code"); | |
| 197 | + | assert.equal(shellMode("/acme/-/chat", viewer), "chat"); | |
| 198 | + | assert.equal(shellMode("/policies", viewer), "site"); | |
| 199 | + | }); |
| 248 | 248 | * its settings area: who has access, money, policies and how each part is | |
| 249 | 249 | * set up; Apps is everything installed that you | |
| 250 | 250 | * can use. `account` is your own settings, under the avatar. `site` is | |
| 251 | − | * g1t's own public pages (a profile, Explore, Search): no workspace's, so | |
| 252 | − | * no mode is lit and no mode's sidebar sits beside them. Each mode has a | |
| 253 | − | * sidebar of its own, or none, and which one is lit follows the address. | |
| 251 | + | * g1t's own pages (policies, support, a new workspace): no workspace's, so | |
| 252 | + | * no mode is lit and no mode's sidebar sits beside them. `browse` is g1t | |
| 253 | + | * beyond your workspaces (Explore, search, a profile, another workspace | |
| 254 | + | * and its public projects): the rail lights Explore, and Explore's sidebar | |
| 255 | + | * sits beside them (`browsingOf`). Each mode has a sidebar of its own, or | |
| 256 | + | * none, and which one is lit follows the address. | |
| 254 | 257 | */ | |
| 255 | 258 | export type ModeKey = | |
| 256 | 259 | | "home" | |
| ⋯ | |||
| 262 | 265 | | "workspace" | |
| 263 | 266 | | "apps" | |
| 264 | 267 | | "account" | |
| 265 | − | | "site"; | |
| 268 | + | | "site" | |
| 269 | + | | "browse"; | |
| 266 | 270 | ||
| 267 | 271 | /** Workspace pages under `-/`, by the mode they belong to. Anything else of the workspace's is Code's. */ | |
| 268 | 272 | const PAGE_MODES: Record<string, ModeKey> = { | |
| ⋯ | |||
| 340 | 344 | case "account": | |
| 341 | 345 | return "/settings"; | |
| 342 | 346 | case "site": | |
| 347 | + | case "browse": | |
| 343 | 348 | return "/explore"; | |
| 344 | 349 | } | |
| 345 | 350 | } | |
| 346 | 351 | ||
| 352 | + | /** First segments that are g1t's own pages and never anyone's name (packages/contracts names.ts). */ | |
| 353 | + | const NOT_AN_OWNER = new Set([ | |
| 354 | + | "-", "api", "mcp", "login", "logout", "register", "new", "settings", "search", "admin", "auth", "integrations", | |
| 355 | + | "pulls", "issues", "verify", "confirm-email", "forgot", "reset", "device", "workspaces", "u", "oauth", "assets", | |
| 356 | + | "avatars", "docs", "explore", "about", "pricing", "policies", "security", "support", "status", "terms", "privacy", | |
| 357 | + | "help", "blog", "invite", "invites", "waitlist", "invitations", "v2", "inbox", "notifications", "download", | |
| 358 | + | "downloads", "install", | |
| 359 | + | ]); | |
| 360 | + | ||
| 361 | + | /** | |
| 362 | + | * Whose g1t a browsed page is: a workspace (its overview, its pages, or | |
| 363 | + | * one of its projects, `repo`) or a person (their profile). | |
| 364 | + | */ | |
| 365 | + | export type BrowsedOwner = { kind: "workspace"; name: string; repo: string | null } | { kind: "person"; name: string }; | |
| 366 | + | ||
| 367 | + | /** What the shell knows of the viewer, to tell their own pages from the rest of g1t. */ | |
| 368 | + | export type Viewer = { | |
| 369 | + | /** The workspace the shell is about. */ | |
| 370 | + | slug: string | null; | |
| 371 | + | /** Every workspace they belong to, by slug. */ | |
| 372 | + | memberships: readonly string[]; | |
| 373 | + | /** Repositories shared with them in workspaces they do not belong to: Code lists them as theirs. */ | |
| 374 | + | shared?: readonly { namespace: string; name: string }[]; | |
| 375 | + | /** Their own username: their profile is theirs, not someone's being browsed. */ | |
| 376 | + | username?: string | null; | |
| 377 | + | }; | |
| 378 | + | ||
| 379 | + | /** | |
| 380 | + | * Whether `pathname` is g1t beyond the viewer's own workspaces, and whose | |
| 381 | + | * (`owner`, null for Explore, search and their own profile). Null for | |
| 382 | + | * anything of theirs. A workspace they belong to is never browsed, even | |
| 383 | + | * when it is not the one they are in: its pages and projects switch the | |
| 384 | + | * shell to it (lib/workspace-choice.ts `workspaceFor`), so they are that | |
| 385 | + | * workspace's modes. A project shared with them is Code's, whose sidebar | |
| 386 | + | * lists it. Without a workspace there is no rail, and nothing is browsed. | |
| 387 | + | */ | |
| 388 | + | export function browsingOf(pathname: string, viewer: Viewer): { owner: BrowsedOwner | null } | null { | |
| 389 | + | if (!viewer.slug) return null; | |
| 390 | + | const parts = pagePath(pathname).split("/").filter(Boolean); | |
| 391 | + | const first = parts[0]?.toLowerCase(); | |
| 392 | + | if (!first) return null; | |
| 393 | + | if (first === "explore" || first === "search") return { owner: null }; | |
| 394 | + | if (first === "u") { | |
| 395 | + | const name = parts[1]; | |
| 396 | + | if (!name) return null; | |
| 397 | + | const own = viewer.username != null && name.toLowerCase() === viewer.username.toLowerCase(); | |
| 398 | + | return { owner: own ? null : { kind: "person", name } }; | |
| 399 | + | } | |
| 400 | + | // g1t's own pages, and files such as robots.txt. | |
| 401 | + | if (NOT_AN_OWNER.has(first) || first.includes(".")) return null; | |
| 402 | + | if (first === viewer.slug.toLowerCase() || viewer.memberships.some((slug) => slug.toLowerCase() === first)) return null; | |
| 403 | + | const repo = parts[1] && parts[1] !== "-" ? parts[1] : null; | |
| 404 | + | if (repo && (viewer.shared ?? []).some((r) => r.namespace.toLowerCase() === first && r.name.toLowerCase() === repo.toLowerCase())) return null; | |
| 405 | + | return { owner: { kind: "workspace", name: parts[0]!, repo } }; | |
| 406 | + | } | |
| 407 | + | ||
| 408 | + | /** The mode a path is in for this viewer: `browse` beyond their workspaces (`browsingOf`), else `modeOf`'s. */ | |
| 409 | + | export function shellMode(pathname: string, viewer: Viewer): ModeKey { | |
| 410 | + | return browsingOf(pathname, viewer) ? "browse" : modeOf(pathname, viewer.slug); | |
| 411 | + | } | |
| 412 | + | ||
| 347 | 413 | /** A workspace's front page: Home. */ | |
| 348 | 414 | export function homePagePath(slug: string): string { | |
| 349 | 415 | return `/${slug}/-/home`; | |