Skip to content

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

112 lines4,512 bytesCodeBlame

Pick any line to see why it is the way it is: the commit, the pull request and issue it came from, and what the agent was thinking.

Navigation: the workspace's own page with tabs, pins and recent in the sidebar1/**
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. */
Teams and CODEOWNERS, labels and milestones, dependency updates, the security suite, and a clearer top bar25export const SOON_TABS = new Set<WorkspaceTabKey>(["insights"]);
Navigation: the workspace's own page with tabs, pins and recent in the sidebar26
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` },
Teams and CODEOWNERS, labels and milestones, dependency updates, the security suite, and a clearer top bar40 options.member && { key: "teams", label: "Teams", to: `${base}/-/teams` },
Navigation: the workspace's own page with tabs, pins and recent in the sidebar41 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
Billing: credits with a kind and expiry, discounts instead of comped, and safer charging82/** The page a request is for: a click's data request ends in `.data`. */
83export function pagePath(pathname: string): string {
84 return pathname.replace(/\.data$/, "").replace(/\/+$/, "") || "/";
85}
86
Navigation: the workspace's own page with tabs, pins and recent in the sidebar87/**
88 * Where an old address of a workspace's pages is now, keeping its query;
89 * null when it has not moved. `/<workspace>?tab=projects` and the like
90 * open that tab.
91 */
92export function workspaceRedirect(pathname: string, search = ""): string | null {
Billing: credits with a kind and expiry, discounts instead of comped, and safer charging93 // A click asks for the page's data at `<path>.data?_routes=…`; the page
94 // is the same one a full load asks for.
95 const params = new URLSearchParams(search);
96 params.delete("_routes");
97 search = params.toString();
98 const parts = pagePath(pathname).split("/").filter(Boolean);
Navigation: the workspace's own page with tabs, pins and recent in the sidebar99 const slug = parts[0];
100 if (!slug) return null;
101 if (parts.length === 1 && params.has("tab")) {
102 const to = TAB_WORDS[(params.get("tab") ?? "").toLowerCase()];
103 if (to === undefined) return null;
104 params.delete("tab");
105 const rest = params.toString();
106 return `/${slug}${to ? `/${to}` : ""}${rest ? `?${rest}` : ""}`;
107 }
108 if (parts[1] !== "-") return null;
109 const to = MOVED[parts.slice(2).join("/")];
110 if (to === undefined) return null;
111 return `/${slug}${to ? `/${to}` : ""}${search && search !== "?" ? (search.startsWith("?") ? search : `?${search}`) : ""}`;
112}