Skip to content
446 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, 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 * Workspace mode's pages, in the groups its sidebar lists them under: the
30 * workspace's settings area, every page one click away. `owner` marks the
31 * pages only owners open; the sidebar shows members the rest. A page that
32 * is not here yet is a `soon` row: what the group will hold, not a link.
33 * `path` is under `-/`.
34 */
35export type WorkspaceSettingsPage = { key: string; label: string; path: string; owner?: boolean; soon?: string };
36
37export type WorkspaceSettingsGroup = { key: string; title: string; pages: WorkspaceSettingsPage[] };
38
39export const WORKSPACE_SETTINGS_GROUPS: readonly WorkspaceSettingsGroup[] = [
40 {
41 key: "general",
42 title: "General",
43 pages: [{ key: "settings", label: "General", path: "settings", owner: true }],
44 },
45 {
46 key: "access",
47 title: "Access",
48 pages: [
49 { key: "members", label: "Members", path: "members" },
50 { key: "invitations", label: "Invitations", path: "members/invitations", owner: true },
51 { key: "permissions", label: "Permissions", path: "permissions", owner: true },
52 { key: "tokens", label: "Access tokens", path: "tokens" },
53 { key: "personal-access-tokens", label: "Personal access tokens", path: "personal-access-tokens", owner: true },
54 ],
55 },
56 {
57 key: "money",
58 title: "Money",
59 pages: [
60 { key: "spend", label: "Spend", path: "spend" },
61 { key: "usage", label: "Usage", path: "usage" },
62 { key: "gateway", label: "AI Gateway", path: "gateway" },
63 { key: "billing", label: "Billing and plans", path: "billing" },
64 ],
65 },
66 {
67 key: "compute",
68 title: "Compute",
69 pages: [{ key: "runners", label: "Runners", path: "runners", owner: true }],
70 },
71 {
72 key: "code",
73 title: "Code",
74 pages: [
75 { key: "repositories", label: "Repositories", path: "repositories" },
76 { key: "actions", label: "Actions", path: "actions" },
77 { key: "secrets", label: "Secrets and variables", path: "secrets" },
78 { key: "webhooks", label: "Webhooks", path: "webhooks" },
79 {
80 key: "deployments",
81 label: "Deployments",
82 path: "soon/deployments",
83 soon: "Defaults for every project's deployments: environments, who approves production, and the domains the workspace owns.",
84 },
85 {
86 key: "projects",
87 label: "Project defaults",
88 path: "soon/projects",
89 soon: "What a new project starts with: its visibility, branch rules, merge method and the agents on it.",
90 },
91 ],
92 },
93 {
94 key: "agents",
95 title: "Agents",
96 pages: [
97 {
98 key: "agent-settings",
99 label: "Agent settings",
100 path: "soon/agent-settings",
101 soon: "Budgets and the models agents may use are on the Agents overview today; who may create agents is under Permissions.",
102 },
103 ],
104 },
105 {
106 key: "chat",
107 title: "Chat",
108 pages: [
109 { key: "chat-settings", label: "Chat settings", path: "settings/chat" },
110 { key: "emoji", label: "Emoji", path: "emoji" },
111 ],
112 },
113 {
114 key: "artifacts",
115 title: "Artifacts",
116 pages: [
117 {
118 key: "artifact-settings",
119 label: "Spaces and sharing",
120 path: "soon/artifact-settings",
121 soon: "Which spaces a workspace starts with, and how a new artifact is shared by default.",
122 },
123 ],
124 },
125 {
126 key: "security",
127 title: "Security",
128 pages: [
129 { key: "security-settings", label: "Security settings", path: "security/settings" },
130 { key: "guardrails", label: "Guardrails", path: "guardrails" },
131 { key: "rules", label: "Rules", path: "rules" },
132 { key: "audit", label: "Audit log", path: "audit" },
133 ],
134 },
135 {
136 key: "integrations",
137 title: "Integrations",
138 pages: [
139 { key: "integrations", label: "Integrations", path: "integrations" },
140 {
141 key: "applications",
142 label: "Applications",
143 path: "soon/applications",
144 soon: "OAuth applications the workspace registers, and the ones it lets its members authorize.",
145 },
146 ],
147 },
148];
149
150/**
151 * Which of Workspace mode's pages `pathname` is, under the workspace
152 * `slug`, as a group key and a page key; null for the overview and for
153 * anything else. A page's own subpages (one ruleset, a receipt) count as
154 * the page; the deepest match wins, so Invitations is its own page and
155 * not a subpage of Members.
156 */
157export function workspaceSettingsPage(pathname: string, slug: string): { group: string; page: string } | null {
158 const parts = pagePath(pathname).split("/").filter(Boolean);
159 if (parts[0]?.toLowerCase() !== slug.toLowerCase() || parts[1] !== "-") return null;
160 const rest = parts.slice(2).join("/");
161 let found: { group: string; page: string; depth: number } | null = null;
162 for (const group of WORKSPACE_SETTINGS_GROUPS) {
163 for (const page of group.pages) {
164 if (page.soon) continue;
165 if (rest === page.path || rest.startsWith(`${page.path}/`)) {
166 const depth = page.path.split("/").length;
167 if (!found || depth > found.depth) found = { group: group.key, page: page.key, depth };
168 }
169 }
170 }
171 return found ? { group: found.group, page: found.page } : null;
172}
173
174/** `?tab=` as people write it, from the tabs the workspace page once had: the page it means. */
175const TAB_WORDS: Record<string, string> = {
176 overview: "",
177 projects: "-/projects",
178 repositories: "-/projects",
179 repos: "-/projects",
180 packages: "-/packages",
181 people: "-/people",
182 members: "-/members",
183 teams: "-/teams",
184 insights: "-/insights",
185 settings: "-/settings",
186};
187
188/** Workspace pages that moved, by their old name under `-/`; a new address may carry a query of its own. */
189const MOVED: Record<string, string> = {
190 // Home was called Today for a while.
191 today: "-/home",
192 "soon/teams": "-/teams",
193 "soon/insights": "-/insights",
194 // The Marketplace became Customize: extensions are plugins, integrations connectors.
195 marketplace: "-/customize",
196 "marketplace/extensions": "-/customize/plugins",
197 "marketplace/integrations": "-/customize/connectors",
198 "marketplace/requests": "-/customize/requests",
199 // The skill library is Customize's Skills tab; each skill's page stays under Agents.
200 "agents/skills": "-/customize/skills?view=yours",
201};
202
203/** Workspace pages that moved with everything under them, by their old name under `-/`: the rest of the path follows. */
204const MOVED_UNDER: Record<string, string> = {
205 // Agent templates were the Marketplace's Agents tab.
206 "marketplace/agents": "-/agents/templates",
207};
208
209/** Pages whose one child moved to a new shape: an extension's page is its plugin's in g1t's marketplace, an integration's the directory entry on Connectors. */
210const MOVED_CHILD: Record<string, (id: string) => string> = {
211 "marketplace/extensions": (id) => `-/customize/plugins/${encodeURIComponent(`${id}@g1t`)}`,
212 "marketplace/integrations": (id) => `-/customize/connectors?directory=${encodeURIComponent(id)}`,
213};
214
215/** Where the page `rest` (under `-/`) is now, or null when it has not moved. */
216function movedTo(rest: string): string | null {
217 if (MOVED[rest] !== undefined) return MOVED[rest]!;
218 const under = Object.keys(MOVED_UNDER).find((from) => rest === from || rest.startsWith(`${from}/`));
219 if (under) return `${MOVED_UNDER[under]}${rest.slice(under.length)}`;
220 const parent = Object.keys(MOVED_CHILD).find((from) => rest.startsWith(`${from}/`) && !rest.slice(from.length + 1).includes("/"));
221 return parent ? MOVED_CHILD[parent]!(rest.slice(parent.length + 1)) : null;
222}
223
224/** `to` with `search` merged into any query of its own, as a path under the workspace. */
225function withQuery(to: string, search: string): string {
226 const [path = "", own = ""] = to.split("?", 2);
227 const params = new URLSearchParams(search);
228 for (const [key, value] of new URLSearchParams(own)) params.set(key, value);
229 const query = params.toString();
230 return `${path ? `/${path}` : ""}${query ? `?${query}` : ""}`;
231}
232
233/** The page a request is for: a click's data request ends in `.data`. */
234export function pagePath(pathname: string): string {
235 return pathname.replace(/\.data$/, "").replace(/\/+$/, "") || "/";
236}
237
238/**
239 * The page at `pathname` under the workspace `slug` instead of its first
240 * segment, keeping its query: where an old name or an alias leads. A click
241 * asks for the page's data at `<path>.data?_routes=…`; the address given is
242 * the page's own, which the browser then asks for as usual.
243 */
244export function underWorkspace(pathname: string, search: string, slug: string): string {
245 const params = new URLSearchParams(search);
246 params.delete("_routes");
247 const query = params.toString();
248 const segments = pagePath(pathname).split("/");
249 // segments[0] is the empty string before the leading slash.
250 segments[1] = slug;
251 return `${segments.join("/")}${query ? `?${query}` : ""}`;
252}
253
254/**
255 * Where an old address of a workspace's pages is now, keeping its query;
256 * null when it has not moved. `/<workspace>?tab=projects` and the like
257 * open that page.
258 */
259export function workspaceRedirect(pathname: string, search = ""): string | null {
260 // A click asks for the page's data at `<path>.data?_routes=…`; the page
261 // is the same one a full load asks for.
262 const params = new URLSearchParams(search);
263 params.delete("_routes");
264 search = params.toString();
265 const parts = pagePath(pathname).split("/").filter(Boolean);
266 const slug = parts[0];
267 if (!slug) return null;
268 if (parts.length === 1 && params.has("tab")) {
269 const to = TAB_WORDS[(params.get("tab") ?? "").toLowerCase()];
270 if (to === undefined) return null;
271 params.delete("tab");
272 const rest = params.toString();
273 return `/${slug}${to ? `/${to}` : ""}${rest ? `?${rest}` : ""}`;
274 }
275 if (parts[1] !== "-") return null;
276 const to = movedTo(parts.slice(2).join("/"));
277 if (to === null) return null;
278 return `/${slug}${withQuery(to, search)}`;
279}
280
281/**
282 * The rail's modes. Home is the front page;
283 * Chat, Agents, Code and Artifacts are where work happens; Notifications
284 * spans them; People is who belongs; Workspace is the workspace itself,
285 * its settings area: who has access, money, policies and how each part is
286 * set up; Apps is everything installed that you
287 * can use. `account` is your own settings, under the avatar. `site` is
288 * g1t's own public pages (a profile, Explore, Search): no workspace's, so
289 * no mode is lit and no mode's sidebar sits beside them. Each mode has a
290 * sidebar of its own, or none, and which one is lit follows the address.
291 */
292export type ModeKey =
293 | "home"
294 | "chat"
295 | "notifications"
296 | "agents"
297 | "code"
298 | "artifacts"
299 | "people"
300 | "workspace"
301 | "apps"
302 | "account"
303 | "site";
304
305/** Workspace pages under `-/`, by the mode they belong to. Anything else of the workspace's is Code's. */
306const PAGE_MODES: Record<string, ModeKey> = {
307 home: "home",
308 chat: "chat",
309 artifacts: "artifacts",
310 agents: "agents",
311 context: "agents",
312 memory: "agents",
313 apps: "apps",
314 customize: "apps",
315 marketplace: "apps",
316 people: "people",
317 members: "workspace",
318 permissions: "workspace",
319 teams: "people",
320 "org-chart": "people",
321 workspace: "workspace",
322 spend: "workspace",
323 usage: "workspace",
324 gateway: "workspace",
325 billing: "workspace",
326 integrations: "workspace",
327 guardrails: "workspace",
328 rules: "workspace",
329 audit: "workspace",
330 settings: "workspace",
331 repositories: "workspace",
332 tokens: "workspace",
333 "personal-access-tokens": "workspace",
334 secrets: "workspace",
335 actions: "workspace",
336 runners: "workspace",
337 webhooks: "workspace",
338 emoji: "workspace",
339};
340
341/** First segments that are g1t's own pages, never a workspace: none is any mode's. */
342const SITE_PAGES = new Set(["explore", "search", "support", "policies", "security", "status", "u", "invite", "workspaces"]);
343
344/**
345 * The mode a path is in, for the workspace `slug`. A repository (anyone's)
346 * and a new project are Code's; g1t's own pages (Explore, search, a
347 * profile) are `site`: public, and no workspace's mode.
348 */
349export function modeOf(pathname: string, slug: string | null): ModeKey {
350 const path = pagePath(pathname);
351 if (path === "/") return "home";
352 if (path === "/notifications" || path.startsWith("/notifications/")) return "notifications";
353 if (path === "/settings" || path.startsWith("/settings/")) return "account";
354 const parts = path.split("/").filter(Boolean);
355 if (SITE_PAGES.has(parts[0] ?? "")) return "site";
356 if (slug && parts[0]?.toLowerCase() === slug.toLowerCase()) {
357 if (parts.length === 1) return "home";
358 if (parts[1] === "-") {
359 // The workspace's security settings are a policy; its alerts are Code's.
360 if (parts[2] === "security" && parts[3] === "settings") return "workspace";
361 return PAGE_MODES[parts[2] ?? ""] ?? "code";
362 }
363 }
364 return "code";
365}
366
367/** Where each mode's button in the rail goes, in the workspace `slug`. */
368export function modeHome(mode: ModeKey, slug: string): string {
369 switch (mode) {
370 case "home":
371 return homePagePath(slug);
372 case "notifications":
373 return "/notifications";
374 case "chat":
375 return `/${slug}/-/chat`;
376 case "artifacts":
377 return `/${slug}/-/artifacts`;
378 case "agents":
379 return `/${slug}/-/agents`;
380 case "code":
381 return `/${slug}/-/overview`;
382 case "people":
383 return `/${slug}/-/people`;
384 case "workspace":
385 return `/${slug}/-/workspace`;
386 case "apps":
387 return `/${slug}/-/apps`;
388 case "account":
389 return "/settings";
390 case "site":
391 return "/explore";
392 }
393}
394
395/** A workspace's front page: Home. */
396export function homePagePath(slug: string): string {
397 return `/${slug}/-/home`;
398}
399
400/**
401 * Where g1t's front door (`/`) and a workspace's own address lead someone
402 * in the workspace `slug`: Home, or Code's Overview when the query asks
403 * for its panels (a tab, or `?agent=new` to put an agent on something).
404 */
405export function homePath(slug: string, search = ""): string {
406 const params = new URLSearchParams(search);
407 params.delete("_routes");
408 params.delete("index");
409 const query = params.toString();
410 if (params.has("agent") || params.has("tab") || params.has("sort")) return `/${slug}/-/overview?${query}`;
411 return homePagePath(slug);
412}
413
414/** The page a member without Code access sees in place of anything of Code's. */
415export function codeAccessPath(slug: string, from?: string): string {
416 return `/${slug}/-/code-access${from ? `?from=${encodeURIComponent(from)}` : ""}`;
417}
418
419/** The workspace pages under `-/` that are Code's: closed to a member without Code access. */
420const CODE_PAGES = new Set(["overview", "projects", "repositories", "packages", "security", "rules", "runners", "actions", "context", "memory", "insights", "soon"]);
421
422/**
423 * Where a member without Code access goes instead of `pathname`, or null
424 * when the page is open to them. `noCode` is the workspaces (by slug) where
425 * they lack it; `chosen` is the one they are in. g1t's front door (`/`) is
426 * Home in that workspace, and so is the workspace's own address; its
427 * Code pages and every repository page is the page that says to ask an
428 * owner.
429 */
430export function codeGate(pathname: string, search: string, noCode: readonly string[], chosen: string | null): string | null {
431 if (noCode.length === 0) return null;
432 const path = pagePath(pathname);
433 if (path === "/") {
434 const slug = chosen && noCode.includes(chosen.toLowerCase()) ? chosen.toLowerCase() : null;
435 return slug ? homePagePath(slug) : null;
436 }
437 const parts = path.split("/").filter(Boolean);
438 const slug = parts[0]?.toLowerCase();
439 if (!slug || !noCode.includes(slug)) return null;
440 const from = `${path}${search && search !== "?" ? search : ""}`;
441 // The workspace's own page is Home.
442 if (parts.length === 1) return homePagePath(slug);
443 if (parts[1] === "-") return CODE_PAGES.has(parts[2] ?? "") ? codeAccessPath(slug, from) : null;
444 // A repository, and everything in it.
445 return codeAccessPath(slug, from);
446}