Skip to content

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

126 lines4,330 bytesCodeBlame
1/**
2 * A project's pages and the tabs within them, in one place: the sidebar
3 * lists the pages, and a page with more than one view shows them as tabs
4 * across its top, the way GitHub and Vercel do. Soon tabs come from the
5 * roadmap and open its page for them, under the same tabs.
6 */
7import type { Abilities, Capability } from "@g1t/contracts";
8
9import { type RoadmapItem, roadmapIn } from "./roadmap";
10
11export type Tab = {
12 label: string;
13 /** Under the project, without a leading slash; "" is its overview. */
14 path: string;
15 /** Other paths under which this tab is the current one. */
16 also?: string[];
17 soon?: boolean;
18 about?: string;
19 /** Members only. */
20 members?: boolean;
21 /** Current on its own path only, not on the paths under it. */
22 exact?: boolean;
23 /** Only for viewers whose role has this capability, once it is known. */
24 needs?: Capability;
25};
26
27export type Section = {
28 key: RoadmapItem["section"] | "Workflows" | "Settings";
29 tabs: Tab[];
30};
31
32function soon(section: RoadmapItem["section"]): Tab[] {
33 return roadmapIn(section).map((item) => ({ label: item.title, path: `soon/${item.key}`, soon: true, about: item.summary }));
34}
35
36export const SECTIONS: Section[] = [
37 {
38 key: "Code",
39 tabs: [
40 { label: "Files", path: "code", also: ["tree", "blob"] },
41 { label: "Commits", path: "commits", also: ["commit"] },
42 { label: "Branches", path: "branches" },
43 { label: "Tags", path: "tags" },
44 { label: "Releases", path: "releases" },
45 { label: "Compare", path: "compare" },
46 ...soon("Code"),
47 ],
48 },
49 {
50 key: "Issues",
51 tabs: [
52 { label: "Issues", path: "issues" },
53 { label: "Outcomes", path: "plans" },
54 { label: "Milestones", path: "milestones" },
55 ...soon("Issues"),
56 ],
57 },
58 {
59 key: "Agents",
60 tabs: [
61 { label: "At work", path: "agents" },
62 { label: "Sessions", path: "sessions" },
63 { label: "Memory", path: "memory" },
64 ...soon("Agents"),
65 ],
66 },
67 {
68 key: "Deployments",
69 tabs: [{ label: "Deployments", path: "deployments" }, ...soon("Deployments")],
70 },
71 { key: "Observability", tabs: soon("Observability") },
72 {
73 key: "Security",
74 tabs: [
75 { label: "Overview", path: "security", members: true, needs: "push", exact: true },
76 { label: "Secret scanning", path: "security/secret-scanning", members: true, needs: "push" },
77 { label: "Code scanning", path: "security/code-scanning", members: true, needs: "push", also: ["security/pulls"] },
78 { label: "Vulnerabilities", path: "security/vulnerabilities", members: true, needs: "push" },
79 { label: "Dependency graph", path: "security/dependency-graph", members: true, needs: "push" },
80 { label: "Dependency updates", path: "security/dependency-updates", members: true, needs: "push" },
81 { label: "Settings", path: "security/settings", members: true, needs: "push" },
82 ...soon("Security"),
83 ],
84 },
85 {
86 key: "Insights",
87 tabs: [
88 { label: "Contributors", path: "contributors" },
89 { label: "Activity", path: "activity" },
90 { label: "Stargazers", path: "stargazers" },
91 ...soon("Insights"),
92 ],
93 },
94];
95
96/** Pull requests and the merge queue share a page's tabs too. */
97export const PULLS: Section = {
98 key: "Code",
99 tabs: [
100 { label: "Pull requests", path: "pulls", also: ["pull"] },
101 { label: "Merge queue", path: "queue" },
102 ],
103};
104
105function matches(rest: string, tab: Tab): boolean {
106 return [tab.path, ...(tab.also ?? [])].some((path) => path !== "" && (rest === path || rest.startsWith(`${path}/`)));
107}
108
109/**
110 * The tabs for the page at `rest` (the path under the project), if it
111 * belongs to a page with more than one view.
112 */
113export function tabsFor(rest: string, member: boolean, can?: Partial<Abilities>): Tab[] | null {
114 for (const section of [...SECTIONS, PULLS]) {
115 const visible = section.tabs.filter(
116 (tab) => (member || !tab.members) && (!tab.needs || !can || Boolean(can[tab.needs])),
117 );
118 if (visible.length > 1 && visible.some((tab) => matches(rest, tab))) return visible;
119 }
120 return null;
121}
122
123/** The first page of a section, for its sidebar link: a built one if any. */
124export function entryOf(key: Section["key"]): Tab | undefined {
125 return SECTIONS.find((section) => section.key === key)?.tabs[0];
126}