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

Merge branch 'worktree-agent-aefe9db9851cfafd8'

syntaqxcommitted Parents0a2909cec78cadBrowse 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`;