Skip to content

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

103 lines4,146 bytesCodeBlame
1/**
2 * A workspace's own page at `g1t.sh/<workspace>`: its header, and tabs for
3 * what it has (Overview, Projects, Packages, Teams, People, Insights and,
4 * for owners, Settings). The sidebar is always the current workspace's;
5 * this page is where the workspace itself is shown, to members and to
6 * everyone else.
7 */
8
9export type WorkspaceTabKey = "overview" | "projects" | "packages" | "teams" | "people" | "insights" | "settings";
10
11export type WorkspaceTab = {
12 key: WorkspaceTabKey;
13 label: string;
14 to: string;
15 /** Shown beside the label; left out when unknown. */
16 count?: number | null;
17 /** Not built yet: its tab opens what it will be. */
18 soon?: boolean;
19};
20
21/** The workspace's pages under `-/` that are drawn under its tabs. */
22export const TAB_PAGES = ["projects", "packages", "teams", "people", "insights"] as const;
23
24/** Tabs whose pages say what they will be, until they are built. */
25export const SOON_TABS = new Set<WorkspaceTabKey>(["teams", "insights"]);
26
27/**
28 * The tabs, in order. People, Teams and Insights are for members, and
29 * Settings for owners; everyone sees the rest.
30 */
31export function workspaceTabs(
32 slug: string,
33 options: { member: boolean; owner: boolean; projects?: number | null; people?: number | null },
34): WorkspaceTab[] {
35 const base = `/${slug}`;
36 const tabs: (WorkspaceTab | false)[] = [
37 { key: "overview", label: "Overview", to: base },
38 { key: "projects", label: "Projects", to: `${base}/-/projects`, count: options.projects ?? null },
39 { key: "packages", label: "Packages", to: `${base}/-/packages` },
40 options.member && { key: "teams", label: "Teams", to: `${base}/-/teams`, soon: true },
41 options.member && { key: "people", label: "People", to: `${base}/-/people`, count: options.people ?? null },
42 options.member && { key: "insights", label: "Insights", to: `${base}/-/insights`, soon: true },
43 options.owner && { key: "settings", label: "Settings", to: `${base}/-/settings` },
44 ];
45 return tabs.filter((tab): tab is WorkspaceTab => tab !== false);
46}
47
48/**
49 * Which tab a path is, under `/<workspace>`: null for the workspace's other
50 * pages (its settings, Agent fleet, Usage and the rest), which have a page
51 * heading of their own, and for one package's page.
52 */
53export function workspaceTab(pathname: string, slug: string): WorkspaceTabKey | null {
54 const parts = pathname.split("/").filter(Boolean);
55 if (parts[0]?.toLowerCase() !== slug.toLowerCase()) return null;
56 if (parts.length === 1) return "overview";
57 if (parts[1] !== "-" || parts.length !== 3) return null;
58 return (TAB_PAGES as readonly string[]).includes(parts[2]!) ? (parts[2] as WorkspaceTabKey) : null;
59}
60
61/** `?tab=` as people write it, from habit: the tab it means. */
62const TAB_WORDS: Record<string, string> = {
63 overview: "",
64 projects: "-/projects",
65 repositories: "-/projects",
66 repos: "-/projects",
67 packages: "-/packages",
68 people: "-/people",
69 members: "-/people",
70 teams: "-/teams",
71 insights: "-/insights",
72};
73
74/** Workspace pages that moved, by their old name under `-/`. */
75const MOVED: Record<string, string> = {
76 members: "-/people",
77 overview: "",
78 "soon/teams": "-/teams",
79 "soon/insights": "-/insights",
80};
81
82/**
83 * Where an old address of a workspace's pages is now, keeping its query;
84 * null when it has not moved. `/<workspace>?tab=projects` and the like
85 * open that tab.
86 */
87export function workspaceRedirect(pathname: string, search = ""): string | null {
88 const parts = pathname.split("/").filter(Boolean);
89 const slug = parts[0];
90 if (!slug) return null;
91 const params = new URLSearchParams(search);
92 if (parts.length === 1 && params.has("tab")) {
93 const to = TAB_WORDS[(params.get("tab") ?? "").toLowerCase()];
94 if (to === undefined) return null;
95 params.delete("tab");
96 const rest = params.toString();
97 return `/${slug}${to ? `/${to}` : ""}${rest ? `?${rest}` : ""}`;
98 }
99 if (parts[1] !== "-") return null;
100 const to = MOVED[parts.slice(2).join("/")];
101 if (to === undefined) return null;
102 return `/${slug}${to ? `/${to}` : ""}${search && search !== "?" ? (search.startsWith("?") ? search : `?${search}`) : ""}`;
103}