Signed in, g1t beyond your workspaces has a place in the shell: on Explore, search, a profile, or another workspace and its public projects, the rail lights Explore and Explore's sidebar sits beside the page, with the way back to your workspace's Home at its top, Recently active, New projects, Search, Your stars and Your profile, and under You are viewing whose page it is; a project of theirs drills into its own list as in Code. A project in another of your workspaces still switches the shell to that workspace, and one shared with you stays in Code. On a phone the same sidebar opens from the top bar, and More has Explore.
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`; | |
Comments
Sign in to comment.