Skip to content

g1t/apps/web/app/lib/workspace-nav.ts

167 lines5,860 bytesCodeBlame
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, Agent fleet, 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 sidebar's rows. The first two are the person's own, whichever
30 * workspace they are in; the rest are the workspace's.
31 */
32export 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. */
50export 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. */
65const 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 // The AI Gateway's requests are usage.
77 gateway: "usage",
78};
79
80/**
81 * The sidebar row that is current on a path: one row at most, so the
82 * sidebar always says where you are. `slug` is the workspace the sidebar
83 * is about; another workspace's pages light nothing.
84 */
85export function sidebarCurrent(pathname: string, slug: string | null): SidebarKey | null {
86 const path = pagePath(pathname);
87 if (path === "/") return "mission";
88 if (path === "/inbox" || path.startsWith("/inbox/")) return "inbox";
89 if (path === "/support" || path.startsWith("/support/")) return "support";
90 const parts = path.split("/").filter(Boolean);
91 if (!slug || parts[0]?.toLowerCase() !== slug.toLowerCase()) return null;
92 if (parts.length === 1) return "overview";
93 if (parts[1] !== "-") return null;
94 const page = parts[2] ?? "";
95 if ((SETTINGS_PAGES as readonly string[]).includes(page)) return "settings";
96 return ROW_OF[page] ?? null;
97}
98
99/** `?tab=` as people write it, from the tabs the workspace page once had: the page it means. */
100const TAB_WORDS: Record<string, string> = {
101 overview: "",
102 projects: "-/projects",
103 repositories: "-/projects",
104 repos: "-/projects",
105 packages: "-/packages",
106 people: "-/people",
107 members: "-/people",
108 teams: "-/teams",
109 insights: "-/insights",
110 settings: "-/settings",
111};
112
113/** Workspace pages that moved, by their old name under `-/`. */
114const MOVED: Record<string, string> = {
115 members: "-/people",
116 overview: "",
117 "soon/teams": "-/teams",
118 "soon/insights": "-/insights",
119};
120
121/** The page a request is for: a click's data request ends in `.data`. */
122export function pagePath(pathname: string): string {
123 return pathname.replace(/\.data$/, "").replace(/\/+$/, "") || "/";
124}
125
126/**
127 * The page at `pathname` under the workspace `slug` instead of its first
128 * segment, keeping its query: where an old name or an alias leads. A click
129 * asks for the page's data at `<path>.data?_routes=…`; the address given is
130 * the page's own, which the browser then asks for as usual.
131 */
132export function underWorkspace(pathname: string, search: string, slug: string): string {
133 const params = new URLSearchParams(search);
134 params.delete("_routes");
135 const query = params.toString();
136 const segments = pagePath(pathname).split("/");
137 // segments[0] is the empty string before the leading slash.
138 segments[1] = slug;
139 return `${segments.join("/")}${query ? `?${query}` : ""}`;
140}
141
142/**
143 * Where an old address of a workspace's pages is now, keeping its query;
144 * null when it has not moved. `/<workspace>?tab=projects` and the like
145 * open that page.
146 */
147export function workspaceRedirect(pathname: string, search = ""): string | null {
148 // A click asks for the page's data at `<path>.data?_routes=…`; the page
149 // is the same one a full load asks for.
150 const params = new URLSearchParams(search);
151 params.delete("_routes");
152 search = params.toString();
153 const parts = pagePath(pathname).split("/").filter(Boolean);
154 const slug = parts[0];
155 if (!slug) return null;
156 if (parts.length === 1 && params.has("tab")) {
157 const to = TAB_WORDS[(params.get("tab") ?? "").toLowerCase()];
158 if (to === undefined) return null;
159 params.delete("tab");
160 const rest = params.toString();
161 return `/${slug}${to ? `/${to}` : ""}${rest ? `?${rest}` : ""}`;
162 }
163 if (parts[1] !== "-") return null;
164 const to = MOVED[parts.slice(2).join("/")];
165 if (to === undefined) return null;
166 return `/${slug}${to ? `/${to}` : ""}${search && search !== "?" ? (search.startsWith("?") ? search : `?${search}`) : ""}`;
167}