Skip to content

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

165 lines5,798 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};
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 */
83export 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. */
98const 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 `-/`. */
112const 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`. */
120export 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 */
130export 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 */
145export 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}