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.

460 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 "-", "api", "mcp", "login", "logout", "register", "new", "settings", "search", "admin", "auth", "integrations",
343 "pulls", "issues", "verify", "confirm-email", "forgot", "reset", "device", "workspaces", "u", "oauth", "assets",
344 "avatars", "docs", "explore", "about", "pricing", "policies", "security", "support", "status", "terms", "privacy",
345 "help", "blog", "invite", "invites", "waitlist", "invitations", "v2", "inbox", "notifications", "download",
346 "downloads", "install",
347]);
348
349/**
350 * Whose g1t a browsed page is: a workspace (its overview, its pages, or
351 * one of its projects, `repo`) or a person (their profile).
352 */
353export type BrowsedOwner = { kind: "workspace"; name: string; repo: string | null } | { kind: "person"; name: string };
354
355/** What the shell knows of the viewer, to tell their own pages from the rest of g1t. */
356export type Viewer = {
357 /** The workspace the shell is about. */
358 slug: string | null;
359 /** Every workspace they belong to, by slug. */
360 memberships: readonly string[];
361 /** Repositories shared with them in workspaces they do not belong to: Code lists them as theirs. */
362 shared?: readonly { namespace: string; name: string }[];
363 /** Their own username: their profile is theirs, not someone's being browsed. */
364 username?: string | null;
365};
366
367/**
368 * Whether `pathname` is g1t beyond the viewer's own workspaces, and whose
369 * (`owner`, null for Explore, search and their own profile). Null for
370 * anything of theirs. A workspace they belong to is never browsed, even
371 * when it is not the one they are in: its pages and projects switch the
372 * shell to it (lib/workspace-choice.ts `workspaceFor`), so they are that
373 * workspace's modes. A project shared with them is Code's, whose sidebar
374 * lists it. Without a workspace there is no rail, and nothing is browsed.
375 */
376export function browsingOf(pathname: string, viewer: Viewer): { owner: BrowsedOwner | null } | null {
377 if (!viewer.slug) return null;
378 const parts = pagePath(pathname).split("/").filter(Boolean);
379 const first = parts[0]?.toLowerCase();
380 if (!first) return null;
381 if (first === "explore" || first === "search") return { owner: null };
382 if (first === "u") {
383 const name = parts[1];
384 if (!name) return null;
385 const own = viewer.username != null && name.toLowerCase() === viewer.username.toLowerCase();
386 return { owner: own ? null : { kind: "person", name } };
387 }
388 // g1t's own pages, and files such as robots.txt.
389 if (NOT_AN_OWNER.has(first) || first.includes(".")) return null;
390 if (first === viewer.slug.toLowerCase() || viewer.memberships.some((slug) => slug.toLowerCase() === first)) return null;
391 // Another workspace's channels, as a guest in them, are Chat's (`modeOf`), not Explore's.
392 if (parts[1] === "-" && parts[2] === "chat") return null;
393 const repo = parts[1] && parts[1] !== "-" ? parts[1] : null;
394 if (repo && (viewer.shared ?? []).some((r) => r.namespace.toLowerCase() === first && r.name.toLowerCase() === repo.toLowerCase())) return null;
395 return { owner: { kind: "workspace", name: parts[0]!, repo } };
396}
397
398/** The mode a path is in for this viewer: `browse` beyond their workspaces (`browsingOf`), else `modeOf`'s. */
399export function shellMode(pathname: string, viewer: Viewer): ModeKey {
400 return browsingOf(pathname, viewer) ? "browse" : modeOf(pathname, viewer.slug);
401}
402
403/** A workspace's front page: Home. */
404export function homePagePath(slug: string): string {
405 return `/${slug}/-/home`;
406}
407
408/**
409 * Where g1t's front door (`/`) and a workspace's own address lead someone
410 * in the workspace `slug`: Home, or Code's Overview when the query asks
411 * for its panels (a tab or a sort).
412 */
413export function homePath(slug: string, search = ""): string {
414 const params = new URLSearchParams(search);
415 params.delete("_routes");
416 params.delete("index");
417 const query = params.toString();
418 if (params.has("tab") || params.has("sort")) return `/${slug}/-/overview?${query}`;
419 return homePagePath(slug);
420}
421
422/** The page a member without Code access sees in place of anything of Code's. */
423export function codeAccessPath(slug: string, from?: string): string {
424 return `/${slug}/-/code-access${from ? `?from=${encodeURIComponent(from)}` : ""}`;
425}
426
427/** A repository's addresses that only send the browser on to Work or to what a reference names. */
428const WORK_REDIRECTS = new Set(["issues", "labels", "milestones", "ref"]);
429
430/** The workspace pages under `-/` that are Code's: closed to a member without Code access. */
431const CODE_PAGES = new Set(["overview", "projects", "repositories", "packages", "security", "rules", "runners", "actions", "insights", "soon"]);
432
433/**
434 * Where a member without Code access goes instead of `pathname`, or null
435 * when the page is open to them. `noCode` is the workspaces (by slug) where
436 * they lack it; `chosen` is the one they are in. g1t's front door (`/`) is
437 * Home in that workspace, and so is the workspace's own address; its
438 * Code pages and every repository page is the page that says to ask an
439 * owner.
440 */
441export function codeGate(pathname: string, search: string, noCode: readonly string[], chosen: string | null): string | null {
442 if (noCode.length === 0) return null;
443 const path = pagePath(pathname);
444 if (path === "/") {
445 const slug = chosen && noCode.includes(chosen.toLowerCase()) ? chosen.toLowerCase() : null;
446 return slug ? homePagePath(slug) : null;
447 }
448 const parts = path.split("/").filter(Boolean);
449 const slug = parts[0]?.toLowerCase();
450 if (!slug || !noCode.includes(slug)) return null;
451 const from = `${path}${search && search !== "?" ? search : ""}`;
452 // The workspace's own page is Home.
453 if (parts.length === 1) return homePagePath(slug);
454 if (parts[1] === "-") return CODE_PAGES.has(parts[2] ?? "") ? codeAccessPath(slug, from) : null;
455 // An old link to a repository's issues, labels or milestones leads to
456 // Work, which is every member's; a reference (`ref`) leads to what it names.
457 if (WORK_REDIRECTS.has(parts[2] ?? "")) return null;
458 // A repository, and everything in it.
459 return codeAccessPath(slug, from);
460}