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