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.

501 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.
32 * `path` is under `-/`.
33 */
34export type WorkspaceSettingsPage = { key: string; label: string; path: string; owner?: boolean };
35
36export type WorkspaceSettingsGroup = { key: string; title: string; pages: WorkspaceSettingsPage[] };
37
38export const WORKSPACE_SETTINGS_GROUPS: readonly WorkspaceSettingsGroup[] = [
39 {
40 key: "general",
41 title: "General",
42 pages: [{ key: "settings", label: "General", path: "settings", owner: true }],
43 },
44 {
45 key: "ai",
46 title: "AI",
47 pages: [{ key: "ai", label: "Models", path: "ai" }],
48 },
49 {
50 key: "access",
51 title: "Access",
52 pages: [
53 { key: "members", label: "Members", path: "members" },
54 { key: "invitations", label: "Invitations", path: "members/invitations", owner: true },
55 { key: "permissions", label: "Permissions", path: "permissions", owner: true },
56 { key: "sso", label: "Single sign-on", path: "sso", owner: true },
57 { key: "tokens", label: "Access tokens", path: "tokens" },
58 { key: "personal-access-tokens", label: "Personal access tokens", path: "personal-access-tokens", owner: true },
59 ],
60 },
61 {
62 key: "money",
63 title: "Money",
64 pages: [
65 { key: "usage", label: "Usage", path: "usage" },
66 { key: "billing", label: "Billing and plans", path: "billing" },
67 { key: "provider-costs", label: "Provider costs", path: "provider-costs" },
68 ],
69 },
70 {
71 key: "compute",
72 title: "Compute",
73 pages: [{ key: "runners", label: "Runners", path: "runners", owner: true }],
74 },
75 {
76 key: "code",
77 title: "Code",
78 pages: [
79 { key: "repositories", label: "Repositories", path: "repositories" },
80 { key: "actions", label: "Actions", path: "actions" },
81 { key: "secrets", label: "Secrets and variables", path: "secrets" },
82 { key: "webhooks", label: "Webhooks", path: "webhooks" },
83 ],
84 },
85 {
86 key: "chat",
87 title: "Chat",
88 pages: [
89 { key: "chat-settings", label: "Chat settings", path: "settings/chat" },
90 { key: "emoji", label: "Emoji", path: "emoji" },
91 ],
92 },
93 {
94 key: "security",
95 title: "Security",
96 pages: [
97 { key: "security-settings", label: "Security settings", path: "security/settings" },
98 { key: "guardrails", label: "Guardrails", path: "guardrails" },
99 { key: "rules", label: "Rules", path: "rules" },
100 { key: "audit", label: "Audit log", path: "audit" },
101 ],
102 },
103 {
104 key: "integrations",
105 title: "Integrations",
106 pages: [{ key: "integrations", label: "Integrations", path: "integrations" }],
107 },
108];
109
110/**
111 * Which of Workspace mode's pages `pathname` is, under the workspace
112 * `slug`, as a group key and a page key; null for the overview and for
113 * anything else. A page's own subpages (one ruleset, a receipt) count as
114 * the page; the deepest match wins, so Invitations is its own page and
115 * not a subpage of Members.
116 */
117export function workspaceSettingsPage(pathname: string, slug: string): { group: string; page: string } | null {
118 const parts = pagePath(pathname).split("/").filter(Boolean);
119 if (parts[0]?.toLowerCase() !== slug.toLowerCase() || parts[1] !== "-") return null;
120 const rest = parts.slice(2).join("/");
121 let found: { group: string; page: string; depth: number } | null = null;
122 for (const group of WORKSPACE_SETTINGS_GROUPS) {
123 for (const page of group.pages) {
124 if (rest === page.path || rest.startsWith(`${page.path}/`)) {
125 const depth = page.path.split("/").length;
126 if (!found || depth > found.depth) found = { group: group.key, page: page.key, depth };
127 }
128 }
129 }
130 return found ? { group: found.group, page: found.page } : null;
131}
132
133/** `?tab=` as people write it, from the tabs the workspace page once had: the page it means. */
134const TAB_WORDS: Record<string, string> = {
135 overview: "",
136 projects: "-/projects",
137 repositories: "-/projects",
138 repos: "-/projects",
139 packages: "-/packages",
140 people: "-/people",
141 members: "-/members",
142 teams: "-/teams",
143 insights: "-/insights",
144 settings: "-/settings",
145};
146
147/** Workspace pages that moved, by their old name under `-/`; a new address may carry a query of its own. */
148const MOVED: Record<string, string> = {
149 // Home was called Today for a while.
150 today: "-/home",
151 "soon/teams": "-/teams",
152 "soon/insights": "-/insights",
153};
154
155/** Where the page `rest` (under `-/`) is now, or null when it has not moved. */
156function movedTo(rest: string): string | null {
157 return MOVED[rest] ?? null;
158}
159
160/** `to` with `search` merged into any query of its own, as a path under the workspace. */
161function withQuery(to: string, search: string): string {
162 const [path = "", own = ""] = to.split("?", 2);
163 const params = new URLSearchParams(search);
164 for (const [key, value] of new URLSearchParams(own)) params.set(key, value);
165 const query = params.toString();
166 return `${path ? `/${path}` : ""}${query ? `?${query}` : ""}`;
167}
168
169/** The page a request is for: a click's data request ends in `.data`. */
170export function pagePath(pathname: string): string {
171 return pathname.replace(/\.data$/, "").replace(/\/+$/, "") || "/";
172}
173
174/**
175 * The page at `pathname` under the workspace `slug` instead of its first
176 * segment, keeping its query: where an old name or an alias leads. A click
177 * asks for the page's data at `<path>.data?_routes=…`; the address given is
178 * the page's own, which the browser then asks for as usual.
179 */
180export function underWorkspace(pathname: string, search: string, slug: string): string {
181 const params = new URLSearchParams(search);
182 params.delete("_routes");
183 const query = params.toString();
184 const segments = pagePath(pathname).split("/");
185 // segments[0] is the empty string before the leading slash.
186 segments[1] = slug;
187 return `${segments.join("/")}${query ? `?${query}` : ""}`;
188}
189
190/**
191 * Where an old address of a workspace's pages is now, keeping its query;
192 * null when it has not moved. `/<workspace>?tab=projects` and the like
193 * open that page.
194 */
195export function workspaceRedirect(pathname: string, search = ""): string | null {
196 // A click asks for the page's data at `<path>.data?_routes=…`; the page
197 // is the same one a full load asks for.
198 const params = new URLSearchParams(search);
199 params.delete("_routes");
200 search = params.toString();
201 const parts = pagePath(pathname).split("/").filter(Boolean);
202 const slug = parts[0];
203 if (!slug) return null;
204 if (parts.length === 1 && params.has("tab")) {
205 const to = TAB_WORDS[(params.get("tab") ?? "").toLowerCase()];
206 if (to === undefined) return null;
207 params.delete("tab");
208 const rest = params.toString();
209 return `/${slug}${to ? `/${to}` : ""}${rest ? `?${rest}` : ""}`;
210 }
211 if (parts[1] !== "-") return null;
212 const to = movedTo(parts.slice(2).join("/"));
213 if (to === null) return null;
214 return `/${slug}${withQuery(to, search)}`;
215}
216
217/**
218 * The rail's modes. Home is the front page; Work is the workspace's work
219 * items and the sessions working on them, for every member;
220 * Chat, Code and Artifacts are where work happens; Notifications
221 * spans them; People is who belongs; Workspace is the workspace itself,
222 * its settings area: who has access, money, policies and how each part is
223 * set up; Apps is everything installed that you
224 * can use. `account` is your own settings, under the avatar. `site` is
225 * g1t's own pages (policies, support, a new workspace): no workspace's, so
226 * no mode is lit and no mode's sidebar sits beside them. `browse` is g1t
227 * beyond your workspaces (Explore, search, a profile, another workspace
228 * and its public projects): the rail lights Explore, and Explore's sidebar
229 * sits beside them (`browsingOf`). Each mode has a sidebar of its own, or
230 * none, and which one is lit follows the address.
231 */
232export type ModeKey =
233 | "home"
234 | "work"
235 | "chat"
236 | "notifications"
237 | "code"
238 | "artifacts"
239 | "routines"
240 | "people"
241 | "workspace"
242 | "apps"
243 | "account"
244 | "site"
245 | "browse";
246
247/** Workspace pages under `-/`, by the mode they belong to. Anything else of the workspace's is Code's. */
248const PAGE_MODES: Record<string, ModeKey> = {
249 home: "home",
250 // Work's sessions (`-/work/sessions`) are Work's too.
251 work: "work",
252 chat: "chat",
253 artifacts: "artifacts",
254 routines: "routines",
255 apps: "apps",
256 people: "people",
257 members: "workspace",
258 permissions: "workspace",
259 sso: "workspace",
260 teams: "people",
261 workspace: "workspace",
262 usage: "workspace",
263 billing: "workspace",
264 "provider-costs": "workspace",
265 integrations: "workspace",
266 ai: "workspace",
267 guardrails: "workspace",
268 rules: "workspace",
269 audit: "workspace",
270 settings: "workspace",
271 repositories: "workspace",
272 tokens: "workspace",
273 "personal-access-tokens": "workspace",
274 secrets: "workspace",
275 actions: "workspace",
276 runners: "workspace",
277 webhooks: "workspace",
278 emoji: "workspace",
279};
280
281/** First segments that are g1t's own pages, never a workspace: none is any mode's. */
282const SITE_PAGES = new Set(["explore", "search", "support", "policies", "security", "status", "u", "invite", "workspaces"]);
283
284/**
285 * The mode a path is in, for the workspace `slug`. A repository (anyone's)
286 * and a new project are Code's; g1t's own pages (Explore, search, a
287 * profile) are `site`: public, and no workspace's mode.
288 */
289export function modeOf(pathname: string, slug: string | null): ModeKey {
290 const path = pagePath(pathname);
291 if (path === "/") return "home";
292 if (path === "/notifications" || path.startsWith("/notifications/")) return "notifications";
293 if (path === "/settings" || path.startsWith("/settings/")) return "account";
294 const parts = path.split("/").filter(Boolean);
295 if (SITE_PAGES.has(parts[0] ?? "")) return "site";
296 if (slug && parts[0]?.toLowerCase() === slug.toLowerCase()) {
297 if (parts.length === 1) return "home";
298 if (parts[1] === "-") {
299 // The workspace's security settings are a policy; its alerts are Code's.
300 if (parts[2] === "security" && parts[3] === "settings") return "workspace";
301 return PAGE_MODES[parts[2] ?? ""] ?? "code";
302 }
303 }
304 // Another workspace's chat, where the viewer is a guest: Chat all the same.
305 if (parts[1] === "-" && parts[2] === "chat") return "chat";
306 return "code";
307}
308
309/** Where each mode's button in the rail goes, in the workspace `slug`. */
310export function modeHome(mode: ModeKey, slug: string): string {
311 switch (mode) {
312 case "home":
313 return homePagePath(slug);
314 case "work":
315 return `/${slug}/-/work`;
316 case "notifications":
317 return "/notifications";
318 case "chat":
319 return `/${slug}/-/chat`;
320 case "artifacts":
321 return `/${slug}/-/artifacts`;
322 case "routines":
323 return `/${slug}/-/routines`;
324 case "code":
325 return `/${slug}/-/overview`;
326 case "people":
327 return `/${slug}/-/people`;
328 case "workspace":
329 return `/${slug}/-/workspace`;
330 case "apps":
331 return `/${slug}/-/apps`;
332 case "account":
333 return "/settings";
334 case "site":
335 case "browse":
336 return "/explore";
337 }
338}
339
340/** First segments that are g1t's own pages and never anyone's name (packages/contracts names.ts). */
341const NOT_AN_OWNER = new Set([
342 "-",
343 "api",
344 "mcp",
345 "login",
346 "logout",
347 "register",
348 "new",
349 "settings",
350 "search",
351 "admin",
352 "auth",
353 "integrations",
354 "pulls",
355 "issues",
356 "verify",
357 "confirm-email",
358 "forgot",
359 "reset",
360 "device",
361 "workspaces",
362 "u",
363 "oauth",
364 "assets",
365 "avatars",
366 "docs",
367 "explore",
368 "about",
369 "pricing",
370 "policies",
371 "security",
372 "support",
373 "status",
374 "terms",
375 "privacy",
376 "help",
377 "blog",
378 "invite",
379 "invites",
380 "waitlist",
381 "invitations",
382 "v2",
383 "inbox",
384 "notifications",
385 "download",
386 "downloads",
387 "install",
388]);
389
390/**
391 * Whose g1t a browsed page is: a workspace (its overview, its pages, or
392 * one of its projects, `repo`) or a person (their profile).
393 */
394export type BrowsedOwner = { kind: "workspace"; name: string; repo: string | null } | { kind: "person"; name: string };
395
396/** What the shell knows of the viewer, to tell their own pages from the rest of g1t. */
397export type Viewer = {
398 /** The workspace the shell is about. */
399 slug: string | null;
400 /** Every workspace they belong to, by slug. */
401 memberships: readonly string[];
402 /** Repositories shared with them in workspaces they do not belong to: Code lists them as theirs. */
403 shared?: readonly { namespace: string; name: string }[];
404 /** Their own username: their profile is theirs, not someone's being browsed. */
405 username?: string | null;
406};
407
408/**
409 * Whether `pathname` is g1t beyond the viewer's own workspaces, and whose
410 * (`owner`, null for Explore, search and their own profile). Null for
411 * anything of theirs. A workspace they belong to is never browsed, even
412 * when it is not the one they are in: its pages and projects switch the
413 * shell to it (lib/workspace-choice.ts `workspaceFor`), so they are that
414 * workspace's modes. A project shared with them is Code's, whose sidebar
415 * lists it. Without a workspace there is no rail, and nothing is browsed.
416 */
417export function browsingOf(pathname: string, viewer: Viewer): { owner: BrowsedOwner | null } | null {
418 if (!viewer.slug) return null;
419 const parts = pagePath(pathname).split("/").filter(Boolean);
420 const first = parts[0]?.toLowerCase();
421 if (!first) return null;
422 if (first === "explore" || first === "search") return { owner: null };
423 if (first === "u") {
424 const name = parts[1];
425 if (!name) return null;
426 const own = viewer.username != null && name.toLowerCase() === viewer.username.toLowerCase();
427 return { owner: own ? null : { kind: "person", name } };
428 }
429 // g1t's own pages, and files such as robots.txt.
430 if (NOT_AN_OWNER.has(first) || first.includes(".")) return null;
431 if (first === viewer.slug.toLowerCase() || viewer.memberships.some((slug) => slug.toLowerCase() === first)) return null;
432 // Another workspace's channels, as a guest in them, are Chat's (`modeOf`), not Explore's.
433 if (parts[1] === "-" && parts[2] === "chat") return null;
434 const repo = parts[1] && parts[1] !== "-" ? parts[1] : null;
435 if (repo && (viewer.shared ?? []).some((r) => r.namespace.toLowerCase() === first && r.name.toLowerCase() === repo.toLowerCase())) return null;
436 return { owner: { kind: "workspace", name: parts[0]!, repo } };
437}
438
439/** The mode a path is in for this viewer: `browse` beyond their workspaces (`browsingOf`), else `modeOf`'s. */
440export function shellMode(pathname: string, viewer: Viewer): ModeKey {
441 return browsingOf(pathname, viewer) ? "browse" : modeOf(pathname, viewer.slug);
442}
443
444/** A workspace's front page: Home. */
445export function homePagePath(slug: string): string {
446 return `/${slug}/-/home`;
447}
448
449/**
450 * Where g1t's front door (`/`) and a workspace's own address lead someone
451 * in the workspace `slug`: Home, or Code's Overview when the query asks
452 * for its panels (a tab or a sort).
453 */
454export function homePath(slug: string, search = ""): string {
455 const params = new URLSearchParams(search);
456 params.delete("_routes");
457 params.delete("index");
458 const query = params.toString();
459 if (params.has("tab") || params.has("sort")) return `/${slug}/-/overview?${query}`;
460 return homePagePath(slug);
461}
462
463/** The page a member without Code access sees in place of anything of Code's. */
464export function codeAccessPath(slug: string, from?: string): string {
465 return `/${slug}/-/code-access${from ? `?from=${encodeURIComponent(from)}` : ""}`;
466}
467
468/** A repository's addresses that only send the browser on to Work or to what a reference names. */
469const WORK_REDIRECTS = new Set(["issues", "labels", "milestones", "ref"]);
470
471/** The workspace pages under `-/` that are Code's: closed to a member without Code access. */
472const CODE_PAGES = new Set(["overview", "projects", "repositories", "packages", "security", "rules", "runners", "actions", "insights", "soon"]);
473
474/**
475 * Where a member without Code access goes instead of `pathname`, or null
476 * when the page is open to them. `noCode` is the workspaces (by slug) where
477 * they lack it; `chosen` is the one they are in. g1t's front door (`/`) is
478 * Home in that workspace, and so is the workspace's own address; its
479 * Code pages and every repository page is the page that says to ask an
480 * owner.
481 */
482export function codeGate(pathname: string, search: string, noCode: readonly string[], chosen: string | null): string | null {
483 if (noCode.length === 0) return null;
484 const path = pagePath(pathname);
485 if (path === "/") {
486 const slug = chosen && noCode.includes(chosen.toLowerCase()) ? chosen.toLowerCase() : null;
487 return slug ? homePagePath(slug) : null;
488 }
489 const parts = path.split("/").filter(Boolean);
490 const slug = parts[0]?.toLowerCase();
491 if (!slug || !noCode.includes(slug)) return null;
492 const from = `${path}${search && search !== "?" ? search : ""}`;
493 // The workspace's own page is Home.
494 if (parts.length === 1) return homePagePath(slug);
495 if (parts[1] === "-") return CODE_PAGES.has(parts[2] ?? "") ? codeAccessPath(slug, from) : null;
496 // An old link to a repository's issues, labels or milestones leads to
497 // Work, which is every member's; a reference (`ref`) leads to what it names.
498 if (WORK_REDIRECTS.has(parts[2] ?? "")) return null;
499 // A repository, and everything in it.
500 return codeAccessPath(slug, from);
501}