| 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. */ |
| 10 | export type WorkspacePageKey = "overview" | "projects" | "packages" | "teams" | "people" | "insights"; |
| 11 | |
| 12 | /** Those pages under `-/`: the overview is the workspace's own address. */ |
| 13 | export 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 | */ |
| 20 | export 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 | */ |
| 32 | export 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. */ |
| 48 | const TAB_WORDS: Record<string, string> = { |
| 49 | overview: "", |
| 50 | projects: "-/projects", |
| 51 | repositories: "-/projects", |
| 52 | repos: "-/projects", |
| 53 | packages: "-/packages", |
| 54 | people: "-/people", |
| 55 | members: "-/people", |
| 56 | teams: "-/teams", |
| 57 | insights: "-/insights", |
| 58 | settings: "-/settings", |
| 59 | }; |
| 60 | |
| 61 | /** Workspace pages that moved, by their old name under `-/`. */ |
| 62 | const MOVED: Record<string, string> = { |
| 63 | home: "-/today", |
| 64 | members: "-/people", |
| 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. */ |
| 70 | const 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`. */ |
| 76 | export 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 | */ |
| 86 | export 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 | */ |
| 101 | export 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. Today 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 | */ |
| 135 | export type ModeKey = |
| 136 | | "today" |
| 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. */ |
| 149 | const PAGE_MODES: Record<string, ModeKey> = { |
| 150 | today: "today", |
| 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 | workspace: "workspace", |
| 162 | spend: "workspace", |
| 163 | usage: "workspace", |
| 164 | gateway: "workspace", |
| 165 | billing: "workspace", |
| 166 | integrations: "workspace", |
| 167 | guardrails: "workspace", |
| 168 | rules: "workspace", |
| 169 | audit: "workspace", |
| 170 | settings: "workspace", |
| 171 | repositories: "workspace", |
| 172 | tokens: "workspace", |
| 173 | "personal-access-tokens": "workspace", |
| 174 | secrets: "workspace", |
| 175 | actions: "workspace", |
| 176 | runners: "workspace", |
| 177 | webhooks: "workspace", |
| 178 | emoji: "workspace", |
| 179 | }; |
| 180 | |
| 181 | /** First segments that are g1t's own pages, never a workspace: none is any mode's. */ |
| 182 | const SITE_PAGES = new Set(["explore", "search", "support", "policies", "security", "status", "u", "invite", "workspaces"]); |
| 183 | |
| 184 | /** |
| 185 | * The mode a path is in, for the workspace `slug`. A repository (anyone's) |
| 186 | * and a new project are Code's; g1t's own pages (Explore, search, a |
| 187 | * profile) are `site`: public, and no workspace's mode. |
| 188 | */ |
| 189 | export function modeOf(pathname: string, slug: string | null): ModeKey { |
| 190 | const path = pagePath(pathname); |
| 191 | if (path === "/") return "today"; |
| 192 | if (path === "/notifications" || path.startsWith("/notifications/")) return "notifications"; |
| 193 | if (path === "/settings" || path.startsWith("/settings/")) return "account"; |
| 194 | const parts = path.split("/").filter(Boolean); |
| 195 | if (SITE_PAGES.has(parts[0] ?? "")) return "site"; |
| 196 | if (slug && parts[0]?.toLowerCase() === slug.toLowerCase()) { |
| 197 | if (parts.length === 1) return "today"; |
| 198 | if (parts[1] === "-") { |
| 199 | // The workspace's security settings are a policy; its alerts are Code's. |
| 200 | if (parts[2] === "security" && parts[3] === "settings") return "workspace"; |
| 201 | return PAGE_MODES[parts[2] ?? ""] ?? "code"; |
| 202 | } |
| 203 | } |
| 204 | return "code"; |
| 205 | } |
| 206 | |
| 207 | /** Where each mode's button in the dock goes, in the workspace `slug`. */ |
| 208 | export function modeHome(mode: ModeKey, slug: string): string { |
| 209 | switch (mode) { |
| 210 | case "today": |
| 211 | return todayPath(slug); |
| 212 | case "notifications": |
| 213 | return "/notifications"; |
| 214 | case "chat": |
| 215 | return `/${slug}/-/chat`; |
| 216 | case "artifacts": |
| 217 | return `/${slug}/-/artifacts`; |
| 218 | case "agents": |
| 219 | return `/${slug}/-/agents`; |
| 220 | case "code": |
| 221 | return `/${slug}/-/overview`; |
| 222 | case "people": |
| 223 | return `/${slug}/-/people`; |
| 224 | case "workspace": |
| 225 | return `/${slug}/-/workspace`; |
| 226 | case "apps": |
| 227 | return `/${slug}/-/apps`; |
| 228 | case "account": |
| 229 | return "/settings"; |
| 230 | case "site": |
| 231 | return "/explore"; |
| 232 | } |
| 233 | } |
| 234 | |
| 235 | /** A workspace's front page: Today. */ |
| 236 | export function todayPath(slug: string): string { |
| 237 | return `/${slug}/-/today`; |
| 238 | } |
| 239 | |
| 240 | /** |
| 241 | * Where g1t's front door (`/`) and a workspace's own address lead someone |
| 242 | * in the workspace `slug`: Today, or Code's Overview when the query asks |
| 243 | * for its panels (a tab, or `?agent=new` to put an agent on something). |
| 244 | */ |
| 245 | export function homePath(slug: string, search = ""): string { |
| 246 | const params = new URLSearchParams(search); |
| 247 | params.delete("_routes"); |
| 248 | params.delete("index"); |
| 249 | const query = params.toString(); |
| 250 | if (params.has("agent") || params.has("tab") || params.has("sort")) return `/${slug}/-/overview?${query}`; |
| 251 | return todayPath(slug); |
| 252 | } |
| 253 | |
| 254 | /** The page a member without Code access sees in place of anything of Code's. */ |
| 255 | export function codeAccessPath(slug: string, from?: string): string { |
| 256 | return `/${slug}/-/code-access${from ? `?from=${encodeURIComponent(from)}` : ""}`; |
| 257 | } |
| 258 | |
| 259 | /** The workspace pages under `-/` that are Code's: closed to a member without Code access. */ |
| 260 | const CODE_PAGES = new Set(["overview", "projects", "repositories", "packages", "security", "rules", "runners", "actions", "context", "memory", "insights", "soon"]); |
| 261 | |
| 262 | /** |
| 263 | * Where a member without Code access goes instead of `pathname`, or null |
| 264 | * when the page is open to them. `noCode` is the workspaces (by slug) where |
| 265 | * they lack it; `chosen` is the one they are in. g1t's front door (`/`) is |
| 266 | * Today in that workspace, and so is the workspace's own address; its |
| 267 | * Code pages and every repository page is the page that says to ask an |
| 268 | * owner. |
| 269 | */ |
| 270 | export function codeGate(pathname: string, search: string, noCode: readonly string[], chosen: string | null): string | null { |
| 271 | if (noCode.length === 0) return null; |
| 272 | const path = pagePath(pathname); |
| 273 | if (path === "/") { |
| 274 | const slug = chosen && noCode.includes(chosen.toLowerCase()) ? chosen.toLowerCase() : null; |
| 275 | return slug ? todayPath(slug) : null; |
| 276 | } |
| 277 | const parts = path.split("/").filter(Boolean); |
| 278 | const slug = parts[0]?.toLowerCase(); |
| 279 | if (!slug || !noCode.includes(slug)) return null; |
| 280 | const from = `${path}${search && search !== "?" ? search : ""}`; |
| 281 | // The workspace's own page is Today. |
| 282 | if (parts.length === 1) return todayPath(slug); |
| 283 | if (parts[1] === "-") return CODE_PAGES.has(parts[2] ?? "") ? codeAccessPath(slug, from) : null; |
| 284 | // A repository, and everything in it. |
| 285 | return codeAccessPath(slug, from); |
| 286 | } |