| 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, Agent fleet, 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 sidebar's rows. The first two are the person's own, whichever |
| 30 | * workspace they are in; the rest are the workspace's. |
| 31 | */ |
| 32 | export type SidebarKey = |
| 33 | | "mission" |
| 34 | | "inbox" |
| 35 | | "overview" |
| 36 | | "projects" |
| 37 | | "agents" |
| 38 | | "context" |
| 39 | | "memory" |
| 40 | | "security" |
| 41 | | "packages" |
| 42 | | "insights" |
| 43 | | "people" |
| 44 | | "teams" |
| 45 | | "usage" |
| 46 | | "support" |
| 47 | | "settings"; |
| 48 | |
| 49 | /** The workspace's pages that its Settings row drills into. */ |
| 50 | export const SETTINGS_PAGES = [ |
| 51 | "settings", |
| 52 | "repositories", |
| 53 | "tokens", |
| 54 | "rules", |
| 55 | "guardrails", |
| 56 | "secrets", |
| 57 | "runners", |
| 58 | "integrations", |
| 59 | "webhooks", |
| 60 | "billing", |
| 61 | "audit", |
| 62 | ] as const; |
| 63 | |
| 64 | /** Pages under `-/` whose sidebar row has another name. */ |
| 65 | const ROW_OF: Record<string, SidebarKey> = { |
| 66 | projects: "projects", |
| 67 | agents: "agents", |
| 68 | context: "context", |
| 69 | memory: "memory", |
| 70 | security: "security", |
| 71 | packages: "packages", |
| 72 | insights: "insights", |
| 73 | people: "people", |
| 74 | teams: "teams", |
| 75 | usage: "usage", |
| 76 | }; |
| 77 | |
| 78 | /** |
| 79 | * The sidebar row that is current on a path: one row at most, so the |
| 80 | * sidebar always says where you are. `slug` is the workspace the sidebar |
| 81 | * is about; another workspace's pages light nothing. |
| 82 | */ |
| 83 | export function sidebarCurrent(pathname: string, slug: string | null): SidebarKey | null { |
| 84 | const path = pagePath(pathname); |
| 85 | if (path === "/") return "mission"; |
| 86 | if (path === "/inbox" || path.startsWith("/inbox/")) return "inbox"; |
| 87 | if (path === "/support" || path.startsWith("/support/")) return "support"; |
| 88 | const parts = path.split("/").filter(Boolean); |
| 89 | if (!slug || parts[0]?.toLowerCase() !== slug.toLowerCase()) return null; |
| 90 | if (parts.length === 1) return "overview"; |
| 91 | if (parts[1] !== "-") return null; |
| 92 | const page = parts[2] ?? ""; |
| 93 | if ((SETTINGS_PAGES as readonly string[]).includes(page)) return "settings"; |
| 94 | return ROW_OF[page] ?? null; |
| 95 | } |
| 96 | |
| 97 | /** `?tab=` as people write it, from the tabs the workspace page once had: the page it means. */ |
| 98 | const TAB_WORDS: Record<string, string> = { |
| 99 | overview: "", |
| 100 | projects: "-/projects", |
| 101 | repositories: "-/projects", |
| 102 | repos: "-/projects", |
| 103 | packages: "-/packages", |
| 104 | people: "-/people", |
| 105 | members: "-/people", |
| 106 | teams: "-/teams", |
| 107 | insights: "-/insights", |
| 108 | settings: "-/settings", |
| 109 | }; |
| 110 | |
| 111 | /** Workspace pages that moved, by their old name under `-/`. */ |
| 112 | const MOVED: Record<string, string> = { |
| 113 | members: "-/people", |
| 114 | overview: "", |
| 115 | "soon/teams": "-/teams", |
| 116 | "soon/insights": "-/insights", |
| 117 | }; |
| 118 | |
| 119 | /** The page a request is for: a click's data request ends in `.data`. */ |
| 120 | export function pagePath(pathname: string): string { |
| 121 | return pathname.replace(/\.data$/, "").replace(/\/+$/, "") || "/"; |
| 122 | } |
| 123 | |
| 124 | /** |
| 125 | * The page at `pathname` under the workspace `slug` instead of its first |
| 126 | * segment, keeping its query: where an old name or an alias leads. A click |
| 127 | * asks for the page's data at `<path>.data?_routes=…`; the address given is |
| 128 | * the page's own, which the browser then asks for as usual. |
| 129 | */ |
| 130 | export function underWorkspace(pathname: string, search: string, slug: string): string { |
| 131 | const params = new URLSearchParams(search); |
| 132 | params.delete("_routes"); |
| 133 | const query = params.toString(); |
| 134 | const segments = pagePath(pathname).split("/"); |
| 135 | // segments[0] is the empty string before the leading slash. |
| 136 | segments[1] = slug; |
| 137 | return `${segments.join("/")}${query ? `?${query}` : ""}`; |
| 138 | } |
| 139 | |
| 140 | /** |
| 141 | * Where an old address of a workspace's pages is now, keeping its query; |
| 142 | * null when it has not moved. `/<workspace>?tab=projects` and the like |
| 143 | * open that page. |
| 144 | */ |
| 145 | export function workspaceRedirect(pathname: string, search = ""): string | null { |
| 146 | // A click asks for the page's data at `<path>.data?_routes=…`; the page |
| 147 | // is the same one a full load asks for. |
| 148 | const params = new URLSearchParams(search); |
| 149 | params.delete("_routes"); |
| 150 | search = params.toString(); |
| 151 | const parts = pagePath(pathname).split("/").filter(Boolean); |
| 152 | const slug = parts[0]; |
| 153 | if (!slug) return null; |
| 154 | if (parts.length === 1 && params.has("tab")) { |
| 155 | const to = TAB_WORDS[(params.get("tab") ?? "").toLowerCase()]; |
| 156 | if (to === undefined) return null; |
| 157 | params.delete("tab"); |
| 158 | const rest = params.toString(); |
| 159 | return `/${slug}${to ? `/${to}` : ""}${rest ? `?${rest}` : ""}`; |
| 160 | } |
| 161 | if (parts[1] !== "-") return null; |
| 162 | const to = MOVED[parts.slice(2).join("/")]; |
| 163 | if (to === undefined) return null; |
| 164 | return `/${slug}${to ? `/${to}` : ""}${search && search !== "?" ? (search.startsWith("?") ? search : `?${search}`) : ""}`; |
| 165 | } |