Skip to content

Where people and agents ship software together. The open-source git platform for the whole job: issues, agents, checks and deploys to the edge.

g1t.sh
175 linesCodeBlameRaw
1/**
2 * Which frame a page is drawn in.
3 *
4 * - `standalone`: signing in, signing up and choosing a workspace. The
5 * page alone, centred, with the logo and a quiet row of links at the
6 * foot: no header, no sidebar, no rail.
7 * - `app`: someone signed in, everywhere else. The rail, the mode's
8 * sidebar and the page in its panel, always about their current
9 * workspace, even on public pages such as Explore, a profile or another
10 * workspace's public project.
11 * - `public`: a visitor who is not signed in (or has not confirmed their
12 * address yet), on every other page: a plain header with the logo,
13 * Explore, Docs and signing in, the page at full width, and the footer.
14 */
15
16import type { Abilities, Capability, RepoMirror } from "@g1t/contracts";
17
18import { type RepoArea, offers, writable } from "./mirror.ts";
19
20export type Frame = "standalone" | "public" | "app";
21
22/** Pages that are drawn on their own, whoever opens them. */
23const STANDALONE = new Set([
24 "/login",
25 "/login/two-factor",
26 "/register",
27 "/logout",
28 "/verify",
29 "/confirm-email",
30 "/forgot",
31 "/reset",
32 "/device",
33 "/oauth/authorize",
34 "/workspaces/new",
35 "/integrations/github/setup",
36]);
37
38/** Marketing pages: the public header and footer, even for someone signed in. */
39const MARKETING = new Set(["/download"]);
40
41/** The page's path without a trailing slash or a click's `.data`. */
42function framePath(pathname: string): string {
43 const path = pathname.replace(/\.data$/, "");
44 return path.length > 1 ? path.replace(/\/+$/, "") : path;
45}
46
47/**
48 * The frame for `pathname`. `viewer` says whether someone is signed in and
49 * confirmed (`signedIn`), and whether they belong to a workspace yet: until
50 * they do, answering an invitation is part of getting started too.
51 */
52export function frameOf(pathname: string, viewer: { signedIn: boolean; workspace: boolean }): Frame {
53 const path = framePath(pathname);
54 if (STANDALONE.has(path)) return "standalone";
55 // Marketing pages are the site's front, whoever reads them: never inside the app's rail and sidebar.
56 if (MARKETING.has(path)) return "public";
57 // An invite link, and signing in with GitHub, are the front door.
58 if (path.startsWith("/invite/") || path === "/auth/github" || path.startsWith("/auth/github/")) return "standalone";
59 if (path === "/invitations" && viewer.signedIn && !viewer.workspace) return "standalone";
60 return viewer.signedIn ? "app" : "public";
61}
62
63/** The project pages the sidebar lists, by key, for a member or not. */
64export type ProjectPage =
65 | "code"
66 | "issues"
67 | "pulls"
68 | "actions"
69 | "deployments"
70 | "observability"
71 | "security"
72 | "insights"
73 | "settings";
74
75/**
76 * What a page needs beyond a role on the repository, once what the viewer
77 * may do is known: Security's findings are for people who can push.
78 */
79export const PAGE_NEEDS: Partial<Record<ProjectPage, Capability>> = { security: "push" };
80
81/**
82 * The part of the repository each page is about (`RepoArea` in the
83 * contracts): what a mirror offers decides which pages it lists.
84 */
85export const PAGE_AREA: Record<ProjectPage, RepoArea> = {
86 code: "code",
87 issues: "issues",
88 pulls: "pulls",
89 actions: "workflows",
90 deployments: "deployments",
91 // Logs and traces are of something running here.
92 observability: "deployments",
93 security: "security",
94 insights: "insights",
95 settings: "settings",
96};
97
98/**
99 * A project's menu, in order. Security and Settings are for people with a
100 * role on its repository, and a page in `PAGE_NEEDS` only for those whose
101 * role has its capability (`can`, once the repository's page has loaded);
102 * everyone who can see the project sees the rest. A mirror lists only what
103 * it offers now (`mirror`, the repository's, once its page has loaded): a
104 * standby mirror is a read-only copy, so Code, Security, Insights and
105 * Settings, no Issues, Pull requests or Deployments, and Workflows
106 * only when they run here.
107 */
108export function projectPages(member: boolean, can?: Partial<Abilities>, mirror?: RepoMirror | null): ProjectPage[] {
109 const pages: (ProjectPage | false)[] = [
110 "code",
111 "issues",
112 "pulls",
113 "actions",
114 // A public repository's deployments and build logs are for anyone, as its code is.
115 "deployments",
116 // Logs and requests are not: they can hold what only the people running it should read.
117 member && "observability",
118 member && "security",
119 "insights",
120 member && "settings",
121 ];
122 return pages.filter(
123 (page): page is ProjectPage =>
124 page !== false && (!can || !PAGE_NEEDS[page] || Boolean(can[PAGE_NEEDS[page]!])) && offers(mirror, PAGE_AREA[page]),
125 );
126}
127
128/**
129 * The page under a project that a mirror does not offer now and `rest`
130 * (the path under the project) is on, with what to call it, or null when
131 * the page is there. An issue or a pull request by number stays readable,
132 * as does a release; making a release is a write.
133 */
134export function hiddenOnMirror(rest: string, mirror: RepoMirror | null | undefined): { page: ProjectPage; what: string } | null {
135 if (!mirror || writable(mirror)) return null;
136 const path = rest.replace(/^\/+|\/+$/g, "");
137 if (path === "releases/new") return { page: "code", what: "releases" };
138 if (/^(issues|pull)\/\d+(\/|$)/.test(path)) return null;
139 const page = projectPageAt(path);
140 if (!page || page === "overview" || offers(mirror, PAGE_AREA[page])) return null;
141 return { page, what: PROJECT_PAGE_LINKS[page].label.toLowerCase() };
142}
143
144/**
145 * A project's pages as the phone's strip of tabs under its name names them
146 * (routes/repo/layout.tsx): the sidebar's words, and the paths under the
147 * project at which each is the current one.
148 */
149export const PROJECT_PAGE_LINKS: Record<ProjectPage, { label: string; path: string; also: string[]; soon?: boolean }> = {
150 code: { label: "Code", path: "code", also: ["tree", "blob", "commits", "commit", "branches", "tags", "releases", "compare", "memory"] },
151 issues: { label: "Issues", path: "issues", also: ["milestones", "labels"] },
152 pulls: { label: "Pull requests", path: "pulls", also: ["pull", "queue", "sessions"] },
153 actions: { label: "Workflows", path: "actions", also: [] },
154 deployments: { label: "Deployments", path: "deployments", also: [] },
155 observability: { label: "Observability", path: "observability", also: [] },
156 security: { label: "Security", path: "security", also: [] },
157 insights: { label: "Insights", path: "contributors", also: ["activity", "stargazers"] },
158 settings: { label: "Settings", path: "settings", also: [] },
159};
160
161/**
162 * Which of a project's pages `rest` (the path under the project, no
163 * leading slash) is on: "overview" for the project itself. `soon` says
164 * which page a roadmap page (`soon/<key>`) sits under.
165 */
166export function projectPageAt(rest: string, soon: Record<string, ProjectPage> = {}): ProjectPage | "overview" | null {
167 const path = rest.replace(/^\/+|\/+$/g, "");
168 if (path === "") return "overview";
169 const soonKey = /^soon\/([^/]+)/.exec(path)?.[1];
170 if (soonKey && soon[soonKey]) return soon[soonKey]!;
171 for (const [page, link] of Object.entries(PROJECT_PAGE_LINKS) as [ProjectPage, (typeof PROJECT_PAGE_LINKS)[ProjectPage]][]) {
172 if ([link.path, ...link.also].some((prefix) => path === prefix || path.startsWith(`${prefix}/`))) return page;
173 }
174 return null;
175}