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.

g1t.sh

Commit

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.

syntaqxcommitted Parent38adcb1Browse files
6 files+337−340/6 viewed
+19−4
591591
592592 Signed in, g1t is always about one workspace: the one you are in. On a
593593 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
595596 [Explore](https://g1t.sh/explore), someone's profile or another
596597 workspace's public project, it stays the one you chose last.
597598
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+
598611 Each page has three parts:
599612
600613 | Part | Where | What it holds |
935948
936949 **How it is laid out.** Signed out, a profile, Explore and Search are
937950 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.
941956
942957 ### The card over a name
943958
+37−5
44 BellRing,
55 BookOpen,
66 CircleUserRound,
7+ Compass,
78 Ellipsis,
89 Keyboard,
910 LayoutGrid,
130131 export function BottomBar({
131132 user,
132133 workspace,
134+ mode: shellMode,
133135 pins,
134136 unread,
135137 onReselect,
136138 }: {
137139 user: User;
138140 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;
139143 /** The apps pinned to this person's rail here, for the More sheet. */
140144 pins: PinnableApp[];
141145 unread: { notifications: number; chat: number; mentions: number };
147151 useEffect(() => setMore(false), [pathname]);
148152 const slug = workspace.slug;
149153 const code = hasCodeAccess(workspace);
150− const mode = modeOf(pathname, slug);
154+ const mode = shellMode ?? modeOf(pathname, slug);
151155 const tabs: Tab[] = [
152156 { key: "home", label: "Home" },
153157 { key: "chat", label: "Chat", badge: { count: unread.mentions > 0 ? unread.mentions : unread.chat, loud: unread.mentions > 0 } },
202206 </li>
203207 </ul>
204208 </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} />
206210 </>
207211 );
208212 }
256260 /**
257261 * More, from the bottom bar: a flat panel standing on it, with g1t's
258262 * 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.
261267 */
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+}) {
263283 const slug = workspace.slug;
264284 const [account, setAccount] = useState(false);
265285 const shown = pins.map((key) => pinnedApp(key, slug)).filter((app) => app != null);
287307 <MoreTile to={appOf("artifacts").path(slug)} icon={appIcon("artifacts", 20)} label="Artifacts" />
288308 <MoreTile to={appOf("people").path(slug)} icon={appIcon("people", 20)} label="People" />
289309 <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+ )}
290322 <MoreTile
291323 icon={<Avatar name={user.username} image={user.avatar} size={24} />}
292324 label="You and help"
+8−4
201201 * named by the hint beside it. g1t's mark, which leads to Home; the
202202 * built-in apps (Home, Chat, Code and Artifacts) with what is
203203 * 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;
205206 * and at its foot People, Workspace and your account. The one you are in
206207 * sits on a filled square, and which that is follows the address. A
207208 * member without Code access has no Code. From 768px; a phone has the
210211 */
211212 export function Rail({
212213 workspace,
214+ mode,
213215 pins: saved,
214216 unread,
215217 account,
216218 }: {
217219 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;
218222 /** The apps pinned to this person's rail in this workspace, as saved. */
219223 pins: PinnableApp[];
220224 unread: { notifications: number; chat: number; mentions: number };
223227 const { pathname } = useLocation();
224228 const going = useNavigation().location?.pathname;
225229 const path = going ?? pathname;
226− const here = modeOf(path, workspace.slug);
230+ const here = mode ?? modeOf(path, workspace.slug);
227231 const slug = workspace.slug;
228232 const code = hasCodeAccess(workspace);
229233 const { pins, toggle } = useAppPins(slug, saved);
267271 badge={item.badge}
268272 />
269273 ))}
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")} />
272276 </SidebarMenu>
273277 {/* Your pinned apps: as many as you like, scrolling under a fade at either end. */}
274278 {shown.length > 0 && (
+166−17
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";
22 import { Fragment, type ReactNode, useCallback, useEffect, useMemo, useRef, useState } from "react";
33 import { Link, useFetcher, useLocation, useNavigation, useRouteLoaderData, useSubmit } from "react-router";
44
4646 DropdownMenuTrigger,
4747 } from "./ui/dropdown-menu";
4848 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";
5060 import { ChatSidebar } from "./chat/sidebar";
5161 import { FoliosSidebar } from "./folios/sidebar";
5262 import { Rail, ShortcutsDialog, WorkspaceSwitcher, sidebarKeyLabel } from "./rail";
773783 }: {
774784 repo: MenuRepo;
775785 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 };
778788 }) {
779789 const base = `/${repo.namespace}/${repo.name}`;
780790 // What a member sees, and what everyone who can see the project does
783793 const { current: atOverview, pending: toOverview } = useRowState(base, { end: true });
784794 return (
785795 <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} />
787797 <NavRows className="mt-1">
788798 <SidebarMenuItem>
789799 <Hint label="Overview" side="right">
10151025 user,
10161026 shell,
10171027 missing = false,
1028+ browsing = null,
10181029 }: {
10191030 user: User;
10201031 shell: ShellData;
10211032 /** The page is a 404: the address names nothing the viewer can see. */
10221033 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;
10231036 }) {
10241037 const ws = shell.workspace;
10251038 const { pathname } = useLocation();
10641077 ? { ...guessed, member: page.access.insider, settings: seesSettings(page.access), can: page.access.can }
10651078 : guessed;
10661079
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 }];
10691083 if (menuRepo) {
10701084 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.
10721087 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" };
10741093 trail.push({
10751094 key,
10761095 node: (
10861105
10871106 return (
10881107 <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"} />
10901115 <Drill trail={trail} />
10911116 </div>
10921117 );
10931118 }
10941119
10951120 /**
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+/**
10961233 * A mode's sidebar heading: its name, quiet, under the workspace's
10971234 * switcher, with what it makes at the end.
10981235 */
17441881
17451882 /**
17461883 * 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.
17501888 */
17511889 function sidebarFor(mode: ModeKey, workspace: boolean): Panel | null {
17521890 if (!workspace) return mode === "account" || mode === "notifications" ? mode : null;
17551893 }
17561894
17571895 /** 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";
17591897
17601898 /** Each sidebar's name, for the button that opens it on a phone. */
17611899 const MODE_MENU: Record<Panel, string> = {
1900+ browse: "Explore",
17621901 chat: "Chat",
17631902 artifacts: "Artifacts",
17641903 code: "Code",
18712010 useVisualViewport();
18722011 const going = useNavigation().location?.pathname;
18732012 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);
18752022 const panel = sidebarFor(mode, ws != null);
18762023 const { sidebar: chatSidebar } = useChatSidebar();
18772024 // Notify: the feed socket's live counts (lib/notify-client.ts) once it has them; the page's until then.
19062053 switch (panel) {
19072054 case "code":
19082055 return <CodeSidebar user={user} shell={shell} missing={missing} />;
2056+ case "browse":
2057+ return <CodeSidebar user={user} shell={shell} missing={missing} browsing={browsing} />;
19092058 case "chat":
19102059 return <ChatSidebar slug={ws.slug} />;
19112060 case "artifacts":
19592108 <Progress />
19602109 {ws && (
19612110 <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} />} />
19632112 </div>
19642113 )}
19652114 {panel && (
20302179 </main>
20312180 </div>
20322181 </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} />}
20342183 <CommandPalette open={palette} onOpenChange={setPalette} commands={commands} repo={shell.repo ? `${shell.repo.namespace}/${shell.repo.name}` : null} />
20352184 </SidebarProvider>
20362185 );
+37−0
160160 assert.equal(codeGate("/other/web", "", none, "acme"), null);
161161 assert.equal(codeGate("/acme/web", "", [], "acme"), null);
162162 });
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+});
+70−4
248248 * its settings area: who has access, money, policies and how each part is
249249 * set up; Apps is everything installed that you
250250 * 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.
254257 */
255258 export type ModeKey =
256259 | "home"
262265 | "workspace"
263266 | "apps"
264267 | "account"
265− | "site";
268+ | "site"
269+ | "browse";
266270
267271 /** Workspace pages under `-/`, by the mode they belong to. Anything else of the workspace's is Code's. */
268272 const PAGE_MODES: Record<string, ModeKey> = {
340344 case "account":
341345 return "/settings";
342346 case "site":
347+ case "browse":
343348 return "/explore";
344349 }
345350 }
346351
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+
347413 /** A workspace's front page: Home. */
348414 export function homePagePath(slug: string): string {
349415 return `/${slug}/-/home`;

Comments

Sign in to comment.