Skip to content
302 linesCodeBlameRaw
1/**
2 * A workspace's pages and where the sidebar finds them. `g1t.sh/<workspace>`
3 * is the workspace's own page, its header and overview; Projects, Packages,
4 * Teams, People, Insights and Settings are pages of their own under `-/`,
5 * each reached from the sidebar and each with its own heading. The sidebar
6 * is always the current workspace's.
7 */
8
9/** The workspace's pages a person moves between, and which the sidebar lists. */
10export type WorkspacePageKey = "overview" | "projects" | "packages" | "teams" | "people" | "insights";
11
12/** Those pages under `-/`: the overview is the workspace's own address. */
13export const WORKSPACE_PAGES = ["projects", "packages", "teams", "people", "insights"] as const;
14
15/**
16 * Which of those pages a path is, under `/<workspace>`: null for the
17 * workspace's other pages (settings, Agent fleet, Usage and the rest), for
18 * one package's or one team's page, and for anything else.
19 */
20export function workspacePage(pathname: string, slug: string): WorkspacePageKey | null {
21 const parts = pagePath(pathname).split("/").filter(Boolean);
22 if (parts[0]?.toLowerCase() !== slug.toLowerCase()) return null;
23 if (parts.length === 1) return "overview";
24 if (parts[1] !== "-" || parts.length !== 3) return null;
25 return (WORKSPACE_PAGES as readonly string[]).includes(parts[2]!) ? (parts[2] as WorkspacePageKey) : null;
26}
27
28/**
29 * The sidebar's rows. The first two are the person's own, whichever
30 * workspace they are in; the rest are the workspace's.
31 */
32export type SidebarKey =
33 | "mission"
34 | "inbox"
35 | "overview"
36 | "projects"
37 | "agents"
38 | "context"
39 | "memory"
40 | "security"
41 | "packages"
42 | "insights"
43 | "people"
44 | "teams"
45 | "usage"
46 | "support"
47 | "settings";
48
49/** The workspace's pages that its Settings row drills into. */
50export const SETTINGS_PAGES = [
51 "settings",
52 "repositories",
53 "tokens",
54 "rules",
55 "guardrails",
56 "secrets",
57 "actions",
58 "runners",
59 "integrations",
60 "webhooks",
61 "emoji",
62 "billing",
63 "audit",
64] as const;
65
66/** Pages under `-/` whose sidebar row has another name. */
67const ROW_OF: Record<string, SidebarKey> = {
68 projects: "projects",
69 agents: "agents",
70 context: "context",
71 memory: "memory",
72 security: "security",
73 packages: "packages",
74 insights: "insights",
75 people: "people",
76 teams: "teams",
77 usage: "usage",
78 // The AI Gateway's requests are usage.
79 gateway: "usage",
80};
81
82/**
83 * The sidebar row that is current on a path: one row at most, so the
84 * sidebar always says where you are. `slug` is the workspace the sidebar
85 * is about; another workspace's pages light nothing.
86 */
87export function sidebarCurrent(pathname: string, slug: string | null): SidebarKey | null {
88 const path = pagePath(pathname);
89 if (path === "/") return "mission";
90 if (path === "/inbox" || path.startsWith("/inbox/")) return "inbox";
91 if (path === "/support" || path.startsWith("/support/")) return "support";
92 const parts = path.split("/").filter(Boolean);
93 if (!slug || parts[0]?.toLowerCase() !== slug.toLowerCase()) return null;
94 if (parts.length === 1) return "overview";
95 if (parts[1] !== "-") return null;
96 const page = parts[2] ?? "";
97 if ((SETTINGS_PAGES as readonly string[]).includes(page)) return "settings";
98 return ROW_OF[page] ?? null;
99}
100
101/** `?tab=` as people write it, from the tabs the workspace page once had: the page it means. */
102const TAB_WORDS: Record<string, string> = {
103 overview: "",
104 projects: "-/projects",
105 repositories: "-/projects",
106 repos: "-/projects",
107 packages: "-/packages",
108 people: "-/people",
109 members: "-/people",
110 teams: "-/teams",
111 insights: "-/insights",
112 settings: "-/settings",
113};
114
115/** Workspace pages that moved, by their old name under `-/`. */
116const MOVED: Record<string, string> = {
117 members: "-/people",
118 "soon/teams": "-/teams",
119 "soon/insights": "-/insights",
120};
121
122/** The page a request is for: a click's data request ends in `.data`. */
123export function pagePath(pathname: string): string {
124 return pathname.replace(/\.data$/, "").replace(/\/+$/, "") || "/";
125}
126
127/**
128 * The page at `pathname` under the workspace `slug` instead of its first
129 * segment, keeping its query: where an old name or an alias leads. A click
130 * asks for the page's data at `<path>.data?_routes=…`; the address given is
131 * the page's own, which the browser then asks for as usual.
132 */
133export function underWorkspace(pathname: string, search: string, slug: string): string {
134 const params = new URLSearchParams(search);
135 params.delete("_routes");
136 const query = params.toString();
137 const segments = pagePath(pathname).split("/");
138 // segments[0] is the empty string before the leading slash.
139 segments[1] = slug;
140 return `${segments.join("/")}${query ? `?${query}` : ""}`;
141}
142
143/**
144 * Where an old address of a workspace's pages is now, keeping its query;
145 * null when it has not moved. `/<workspace>?tab=projects` and the like
146 * open that page.
147 */
148export function workspaceRedirect(pathname: string, search = ""): string | null {
149 // A click asks for the page's data at `<path>.data?_routes=…`; the page
150 // is the same one a full load asks for.
151 const params = new URLSearchParams(search);
152 params.delete("_routes");
153 search = params.toString();
154 const parts = pagePath(pathname).split("/").filter(Boolean);
155 const slug = parts[0];
156 if (!slug) return null;
157 if (parts.length === 1 && params.has("tab")) {
158 const to = TAB_WORDS[(params.get("tab") ?? "").toLowerCase()];
159 if (to === undefined) return null;
160 params.delete("tab");
161 const rest = params.toString();
162 return `/${slug}${to ? `/${to}` : ""}${rest ? `?${rest}` : ""}`;
163 }
164 if (parts[1] !== "-") return null;
165 const to = MOVED[parts.slice(2).join("/")];
166 if (to === undefined) return null;
167 return `/${slug}${to ? `/${to}` : ""}${search && search !== "?" ? (search.startsWith("?") ? search : `?${search}`) : ""}`;
168}
169
170/**
171 * The rail's modes (docs/WORKSPACE.md, "Shell"). Home, Chat, Docs, Agents
172 * and Code are where work happens; the Inbox spans them; Workspace is the
173 * workspace itself: its people, money, policies and settings. `account` is
174 * your own settings, under the avatar. Each has a sidebar of its own, or
175 * none, and which one is lit follows the address.
176 */
177export type ModeKey = "home" | "chat" | "docs" | "agents" | "code" | "inbox" | "workspace" | "account";
178
179/** Workspace pages under `-/`, by the mode they belong to. Anything else of the workspace's is Code's. */
180const PAGE_MODES: Record<string, ModeKey> = {
181 home: "home",
182 chat: "chat",
183 docs: "docs",
184 agents: "agents",
185 context: "agents",
186 memory: "agents",
187 workspace: "workspace",
188 people: "workspace",
189 members: "workspace",
190 teams: "workspace",
191 usage: "workspace",
192 gateway: "workspace",
193 billing: "workspace",
194 integrations: "workspace",
195 guardrails: "workspace",
196 rules: "workspace",
197 audit: "workspace",
198 settings: "workspace",
199 repositories: "workspace",
200 tokens: "workspace",
201 "personal-access-tokens": "workspace",
202 secrets: "workspace",
203 actions: "workspace",
204 runners: "workspace",
205 webhooks: "workspace",
206 emoji: "workspace",
207};
208
209/** First segments that are g1t's own pages, never a workspace: none is Code's. */
210const SITE_PAGES = new Set(["explore", "search", "support", "policies", "security", "status", "u", "invite", "workspaces"]);
211
212/**
213 * The mode a path is in, for the workspace `slug`. A repository (anyone's)
214 * and a new project are Code's; g1t's own pages (Explore, search, a
215 * profile) sit under Home, which is never Code.
216 */
217export function modeOf(pathname: string, slug: string | null): ModeKey {
218 const path = pagePath(pathname);
219 if (path === "/") return "home";
220 if (path === "/inbox" || path.startsWith("/inbox/")) return "inbox";
221 if (path === "/settings" || path.startsWith("/settings/")) return "account";
222 const parts = path.split("/").filter(Boolean);
223 if (SITE_PAGES.has(parts[0] ?? "")) return "home";
224 if (slug && parts[0]?.toLowerCase() === slug.toLowerCase()) {
225 if (parts.length === 1) return "home";
226 if (parts[1] === "-") {
227 // The workspace's security settings are a policy; its alerts are Code's.
228 if (parts[2] === "security" && parts[3] === "settings") return "workspace";
229 return PAGE_MODES[parts[2] ?? ""] ?? "code";
230 }
231 }
232 return "code";
233}
234
235/** Where each mode's rail button goes, in the workspace `slug`. */
236export function modeHome(mode: ModeKey, slug: string): string {
237 switch (mode) {
238 case "home":
239 return `/${slug}/-/home`;
240 case "inbox":
241 return "/inbox";
242 case "chat":
243 return `/${slug}/-/chat`;
244 case "docs":
245 return `/${slug}/-/docs`;
246 case "agents":
247 return `/${slug}/-/agents`;
248 case "code":
249 return `/${slug}/-/overview`;
250 case "workspace":
251 return `/${slug}/-/workspace`;
252 case "account":
253 return "/settings";
254 }
255}
256
257/**
258 * Where Mission control's old addresses (`/`, `/<workspace>`) lead in the
259 * workspace `slug`: Home, or Code's Overview when the query asks for its
260 * panels (a tab, or `?agent=new` to put an agent on something).
261 */
262export function homePath(slug: string, search = ""): string {
263 const params = new URLSearchParams(search);
264 params.delete("_routes");
265 params.delete("index");
266 const query = params.toString();
267 if (params.has("agent") || params.has("tab") || params.has("sort")) return `/${slug}/-/overview?${query}`;
268 return `/${slug}/-/home`;
269}
270
271/** The page a member without Code access sees in place of anything of Code's. */
272export function codeAccessPath(slug: string, from?: string): string {
273 return `/${slug}/-/code-access${from ? `?from=${encodeURIComponent(from)}` : ""}`;
274}
275
276/** The workspace pages under `-/` that are Code's: closed to a member without Code access. */
277const CODE_PAGES = new Set(["overview", "projects", "repositories", "packages", "security", "rules", "runners", "actions", "context", "memory", "insights", "soon"]);
278
279/**
280 * Where a member without Code access goes instead of `pathname`, or null
281 * when the page is open to them. `noCode` is the workspaces (by slug) where
282 * they lack it; `chosen` is the one they are in. Mission control (`/`) is
283 * their Home in that workspace; its overview, its projects and every
284 * repository page is the page that says to ask an owner.
285 */
286export function codeGate(pathname: string, search: string, noCode: readonly string[], chosen: string | null): string | null {
287 if (noCode.length === 0) return null;
288 const path = pagePath(pathname);
289 if (path === "/") {
290 const slug = chosen && noCode.includes(chosen.toLowerCase()) ? chosen.toLowerCase() : null;
291 return slug ? `/${slug}/-/home` : null;
292 }
293 const parts = path.split("/").filter(Boolean);
294 const slug = parts[0]?.toLowerCase();
295 if (!slug || !noCode.includes(slug)) return null;
296 const from = `${path}${search && search !== "?" ? search : ""}`;
297 // The workspace's own page is their Home.
298 if (parts.length === 1) return `/${slug}/-/home`;
299 if (parts[1] === "-") return CODE_PAGES.has(parts[2] ?? "") ? codeAccessPath(slug, from) : null;
300 // A repository, and everything in it.
301 return codeAccessPath(slug, from);
302}