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