Skip to content

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

164 lines5,787 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 * The page at `pathname` under the workspace `slug` instead of its first
125 * segment, keeping its query: where an old name or an alias leads. A click
126 * asks for the page's data at `<path>.data?_routes=…`; the address given is
127 * the page's own, which the browser then asks for as usual.
128 */
129export function underWorkspace(pathname: string, search: string, slug: string): string {
130 const params = new URLSearchParams(search);
131 params.delete("_routes");
132 const query = params.toString();
133 const segments = pagePath(pathname).split("/");
134 // segments[0] is the empty string before the leading slash.
135 segments[1] = slug;
136 return `${segments.join("/")}${query ? `?${query}` : ""}`;
137}
138
139/**
140 * Where an old address of a workspace's pages is now, keeping its query;
141 * null when it has not moved. `/<workspace>?tab=projects` and the like
142 * open that page.
143 */
144export function workspaceRedirect(pathname: string, search = ""): string | null {
145 // A click asks for the page's data at `<path>.data?_routes=…`; the page
146 // is the same one a full load asks for.
147 const params = new URLSearchParams(search);
148 params.delete("_routes");
149 search = params.toString();
150 const parts = pagePath(pathname).split("/").filter(Boolean);
151 const slug = parts[0];
152 if (!slug) return null;
153 if (parts.length === 1 && params.has("tab")) {
154 const to = TAB_WORDS[(params.get("tab") ?? "").toLowerCase()];
155 if (to === undefined) return null;
156 params.delete("tab");
157 const rest = params.toString();
158 return `/${slug}${to ? `/${to}` : ""}${rest ? `?${rest}` : ""}`;
159 }
160 if (parts[1] !== "-") return null;
161 const to = MOVED[parts.slice(2).join("/")];
162 if (to === undefined) return null;
163 return `/${slug}${to ? `/${to}` : ""}${search && search !== "?" ? (search.startsWith("?") ? search : `?${search}`) : ""}`;
164}