Skip to content
287 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 * The workspace's pages that its Settings row drills into. Runners is not
30 * one: it is a page of Workspace mode's own, at `-/runners`.
31 */
32export const SETTINGS_PAGES = [
33 "settings",
34 "repositories",
35 "tokens",
36 "rules",
37 "guardrails",
38 "secrets",
39 "actions",
40 "integrations",
41 "webhooks",
42 "emoji",
43 "billing",
44 "audit",
45] as const;
46
47/** `?tab=` as people write it, from the tabs the workspace page once had: the page it means. */
48const TAB_WORDS: Record<string, string> = {
49 overview: "",
50 projects: "-/projects",
51 repositories: "-/projects",
52 repos: "-/projects",
53 packages: "-/packages",
54 people: "-/people",
55 members: "-/members",
56 teams: "-/teams",
57 insights: "-/insights",
58 settings: "-/settings",
59};
60
61/** Workspace pages that moved, by their old name under `-/`. */
62const MOVED: Record<string, string> = {
63 // Home was called Today for a while.
64 today: "-/home",
65 "soon/teams": "-/teams",
66 "soon/insights": "-/insights",
67};
68
69/** Workspace pages that moved with everything under them, by their old name under `-/`: the rest of the path follows. */
70const MOVED_UNDER: Record<string, string> = {
71 // Agent templates were the Marketplace's Agents tab.
72 "marketplace/agents": "-/agents/templates",
73};
74
75/** The page a request is for: a click's data request ends in `.data`. */
76export function pagePath(pathname: string): string {
77 return pathname.replace(/\.data$/, "").replace(/\/+$/, "") || "/";
78}
79
80/**
81 * The page at `pathname` under the workspace `slug` instead of its first
82 * segment, keeping its query: where an old name or an alias leads. A click
83 * asks for the page's data at `<path>.data?_routes=…`; the address given is
84 * the page's own, which the browser then asks for as usual.
85 */
86export function underWorkspace(pathname: string, search: string, slug: string): string {
87 const params = new URLSearchParams(search);
88 params.delete("_routes");
89 const query = params.toString();
90 const segments = pagePath(pathname).split("/");
91 // segments[0] is the empty string before the leading slash.
92 segments[1] = slug;
93 return `${segments.join("/")}${query ? `?${query}` : ""}`;
94}
95
96/**
97 * Where an old address of a workspace's pages is now, keeping its query;
98 * null when it has not moved. `/<workspace>?tab=projects` and the like
99 * open that page.
100 */
101export function workspaceRedirect(pathname: string, search = ""): string | null {
102 // A click asks for the page's data at `<path>.data?_routes=…`; the page
103 // is the same one a full load asks for.
104 const params = new URLSearchParams(search);
105 params.delete("_routes");
106 search = params.toString();
107 const parts = pagePath(pathname).split("/").filter(Boolean);
108 const slug = parts[0];
109 if (!slug) return null;
110 if (parts.length === 1 && params.has("tab")) {
111 const to = TAB_WORDS[(params.get("tab") ?? "").toLowerCase()];
112 if (to === undefined) return null;
113 params.delete("tab");
114 const rest = params.toString();
115 return `/${slug}${to ? `/${to}` : ""}${rest ? `?${rest}` : ""}`;
116 }
117 if (parts[1] !== "-") return null;
118 const rest = parts.slice(2).join("/");
119 const under = Object.keys(MOVED_UNDER).find((from) => rest === from || rest.startsWith(`${from}/`));
120 const to = under ? `${MOVED_UNDER[under]}${rest.slice(under.length)}` : MOVED[rest];
121 if (to === undefined) return null;
122 return `/${slug}${to ? `/${to}` : ""}${search && search !== "?" ? (search.startsWith("?") ? search : `?${search}`) : ""}`;
123}
124
125/**
126 * The dock's modes. Home is the front page;
127 * Chat, Agents, Code and Artifacts are where work happens; Notifications
128 * spans them; People is who belongs; Workspace is the workspace itself:
129 * its money, policies and settings; Apps is everything installed that you
130 * can use. `account` is your own settings, under the avatar. `site` is
131 * g1t's own public pages (a profile, Explore, Search): no workspace's, so
132 * no mode is lit and no mode's sidebar sits beside them. Each mode has a
133 * sidebar of its own, or none, and which one is lit follows the address.
134 */
135export type ModeKey =
136 | "home"
137 | "chat"
138 | "notifications"
139 | "agents"
140 | "code"
141 | "artifacts"
142 | "people"
143 | "workspace"
144 | "apps"
145 | "account"
146 | "site";
147
148/** Workspace pages under `-/`, by the mode they belong to. Anything else of the workspace's is Code's. */
149const PAGE_MODES: Record<string, ModeKey> = {
150 home: "home",
151 chat: "chat",
152 artifacts: "artifacts",
153 agents: "agents",
154 context: "agents",
155 memory: "agents",
156 apps: "apps",
157 marketplace: "apps",
158 people: "people",
159 members: "people",
160 teams: "people",
161 "org-chart": "people",
162 workspace: "workspace",
163 spend: "workspace",
164 usage: "workspace",
165 gateway: "workspace",
166 billing: "workspace",
167 integrations: "workspace",
168 guardrails: "workspace",
169 rules: "workspace",
170 audit: "workspace",
171 settings: "workspace",
172 repositories: "workspace",
173 tokens: "workspace",
174 "personal-access-tokens": "workspace",
175 secrets: "workspace",
176 actions: "workspace",
177 runners: "workspace",
178 webhooks: "workspace",
179 emoji: "workspace",
180};
181
182/** First segments that are g1t's own pages, never a workspace: none is any mode's. */
183const SITE_PAGES = new Set(["explore", "search", "support", "policies", "security", "status", "u", "invite", "workspaces"]);
184
185/**
186 * The mode a path is in, for the workspace `slug`. A repository (anyone's)
187 * and a new project are Code's; g1t's own pages (Explore, search, a
188 * profile) are `site`: public, and no workspace's mode.
189 */
190export function modeOf(pathname: string, slug: string | null): ModeKey {
191 const path = pagePath(pathname);
192 if (path === "/") return "home";
193 if (path === "/notifications" || path.startsWith("/notifications/")) return "notifications";
194 if (path === "/settings" || path.startsWith("/settings/")) return "account";
195 const parts = path.split("/").filter(Boolean);
196 if (SITE_PAGES.has(parts[0] ?? "")) return "site";
197 if (slug && parts[0]?.toLowerCase() === slug.toLowerCase()) {
198 if (parts.length === 1) return "home";
199 if (parts[1] === "-") {
200 // The workspace's security settings are a policy; its alerts are Code's.
201 if (parts[2] === "security" && parts[3] === "settings") return "workspace";
202 return PAGE_MODES[parts[2] ?? ""] ?? "code";
203 }
204 }
205 return "code";
206}
207
208/** Where each mode's button in the dock goes, in the workspace `slug`. */
209export function modeHome(mode: ModeKey, slug: string): string {
210 switch (mode) {
211 case "home":
212 return homePagePath(slug);
213 case "notifications":
214 return "/notifications";
215 case "chat":
216 return `/${slug}/-/chat`;
217 case "artifacts":
218 return `/${slug}/-/artifacts`;
219 case "agents":
220 return `/${slug}/-/agents`;
221 case "code":
222 return `/${slug}/-/overview`;
223 case "people":
224 return `/${slug}/-/people`;
225 case "workspace":
226 return `/${slug}/-/workspace`;
227 case "apps":
228 return `/${slug}/-/apps`;
229 case "account":
230 return "/settings";
231 case "site":
232 return "/explore";
233 }
234}
235
236/** A workspace's front page: Home. */
237export function homePagePath(slug: string): string {
238 return `/${slug}/-/home`;
239}
240
241/**
242 * Where g1t's front door (`/`) and a workspace's own address lead someone
243 * in the workspace `slug`: Home, or Code's Overview when the query asks
244 * for its panels (a tab, or `?agent=new` to put an agent on something).
245 */
246export function homePath(slug: string, search = ""): string {
247 const params = new URLSearchParams(search);
248 params.delete("_routes");
249 params.delete("index");
250 const query = params.toString();
251 if (params.has("agent") || params.has("tab") || params.has("sort")) return `/${slug}/-/overview?${query}`;
252 return homePagePath(slug);
253}
254
255/** The page a member without Code access sees in place of anything of Code's. */
256export function codeAccessPath(slug: string, from?: string): string {
257 return `/${slug}/-/code-access${from ? `?from=${encodeURIComponent(from)}` : ""}`;
258}
259
260/** The workspace pages under `-/` that are Code's: closed to a member without Code access. */
261const CODE_PAGES = new Set(["overview", "projects", "repositories", "packages", "security", "rules", "runners", "actions", "context", "memory", "insights", "soon"]);
262
263/**
264 * Where a member without Code access goes instead of `pathname`, or null
265 * when the page is open to them. `noCode` is the workspaces (by slug) where
266 * they lack it; `chosen` is the one they are in. g1t's front door (`/`) is
267 * Home in that workspace, and so is the workspace's own address; its
268 * Code pages and every repository page is the page that says to ask an
269 * owner.
270 */
271export function codeGate(pathname: string, search: string, noCode: readonly string[], chosen: string | null): string | null {
272 if (noCode.length === 0) return null;
273 const path = pagePath(pathname);
274 if (path === "/") {
275 const slug = chosen && noCode.includes(chosen.toLowerCase()) ? chosen.toLowerCase() : null;
276 return slug ? homePagePath(slug) : null;
277 }
278 const parts = path.split("/").filter(Boolean);
279 const slug = parts[0]?.toLowerCase();
280 if (!slug || !noCode.includes(slug)) return null;
281 const from = `${path}${search && search !== "?" ? search : ""}`;
282 // The workspace's own page is Home.
283 if (parts.length === 1) return homePagePath(slug);
284 if (parts[1] === "-") return CODE_PAGES.has(parts[2] ?? "") ? codeAccessPath(slug, from) : null;
285 // A repository, and everything in it.
286 return codeAccessPath(slug, from);
287}