Skip to content

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

148 lines5,092 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 "guardrails",
55 "secrets",
56 "runners",
57 "integrations",
58 "webhooks",
59 "billing",
60 "audit",
61] as const;
62
63/** Pages under `-/` whose sidebar row has another name. */
64const ROW_OF: Record<string, SidebarKey> = {
65 projects: "projects",
66 agents: "agents",
67 context: "context",
68 memory: "memory",
69 security: "security",
70 packages: "packages",
71 insights: "insights",
72 people: "people",
73 teams: "teams",
74 usage: "usage",
75};
76
77/**
78 * The sidebar row that is current on a path: one row at most, so the
79 * sidebar always says where you are. `slug` is the workspace the sidebar
80 * is about; another workspace's pages light nothing.
81 */
82export function sidebarCurrent(pathname: string, slug: string | null): SidebarKey | null {
83 const path = pagePath(pathname);
84 if (path === "/") return "mission";
85 if (path === "/inbox" || path.startsWith("/inbox/")) return "inbox";
86 if (path === "/support" || path.startsWith("/support/")) return "support";
87 const parts = path.split("/").filter(Boolean);
88 if (!slug || parts[0]?.toLowerCase() !== slug.toLowerCase()) return null;
89 if (parts.length === 1) return "overview";
90 if (parts[1] !== "-") return null;
91 const page = parts[2] ?? "";
92 if ((SETTINGS_PAGES as readonly string[]).includes(page)) return "settings";
93 return ROW_OF[page] ?? null;
94}
95
96/** `?tab=` as people write it, from the tabs the workspace page once had: the page it means. */
97const TAB_WORDS: Record<string, string> = {
98 overview: "",
99 projects: "-/projects",
100 repositories: "-/projects",
101 repos: "-/projects",
102 packages: "-/packages",
103 people: "-/people",
104 members: "-/people",
105 teams: "-/teams",
106 insights: "-/insights",
107 settings: "-/settings",
108};
109
110/** Workspace pages that moved, by their old name under `-/`. */
111const MOVED: Record<string, string> = {
112 members: "-/people",
113 overview: "",
114 "soon/teams": "-/teams",
115 "soon/insights": "-/insights",
116};
117
118/** The page a request is for: a click's data request ends in `.data`. */
119export function pagePath(pathname: string): string {
120 return pathname.replace(/\.data$/, "").replace(/\/+$/, "") || "/";
121}
122
123/**
124 * Where an old address of a workspace's pages is now, keeping its query;
125 * null when it has not moved. `/<workspace>?tab=projects` and the like
126 * open that page.
127 */
128export function workspaceRedirect(pathname: string, search = ""): string | null {
129 // A click asks for the page's data at `<path>.data?_routes=…`; the page
130 // is the same one a full load asks for.
131 const params = new URLSearchParams(search);
132 params.delete("_routes");
133 search = params.toString();
134 const parts = pagePath(pathname).split("/").filter(Boolean);
135 const slug = parts[0];
136 if (!slug) return null;
137 if (parts.length === 1 && params.has("tab")) {
138 const to = TAB_WORDS[(params.get("tab") ?? "").toLowerCase()];
139 if (to === undefined) return null;
140 params.delete("tab");
141 const rest = params.toString();
142 return `/${slug}${to ? `/${to}` : ""}${rest ? `?${rest}` : ""}`;
143 }
144 if (parts[1] !== "-") return null;
145 const to = MOVED[parts.slice(2).join("/")];
146 if (to === undefined) return null;
147 return `/${slug}${to ? `/${to}` : ""}${search && search !== "?" ? (search.startsWith("?") ? search : `?${search}`) : ""}`;
148}