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.

256 linesCodeBlameRaw
1import { Package, Plus } from "lucide-react";
2import { Outlet, data, redirect, useLocation, useNavigation } from "react-router";
3
4import type { Route } from "./+types/layout";
5import { page } from "../../lib/meta";
6import { ButtonLink } from "../../components/ui";
7import { Avatar } from "../../components/ui/avatar";
8import { Badge } from "../../components/ui/badge";
9import { WelcomeBanner } from "../../components/welcome";
10import { clearWelcome, welcomes } from "../../lib/invites";
11import { notFound } from "../../lib/not-found.server";
12import { redirectIfRenamed } from "../../lib/renamed.server";
13import { rememberWorkspace } from "../../lib/workspace-choice";
14import { workspacePage, workspaceRedirect } from "../../lib/workspace-nav";
15import { shortcutOf, workspaceProjects } from "../../lib/workspace-projects.server";
16import { WorkspacePageSkeleton } from "../../components/workspace-skeletons";
17import { identity } from "../../lib/services.server";
18import { getViewer, roleIn } from "../../lib/session.server";
19import { isFailed, part } from "../../lib/partial";
20
21export function meta({ loaderData, params, ...args }: Route.MetaArgs) {
22 return page(args, { title: `${loaderData?.workspace.name ?? params.owner} · g1t` });
23}
24
25export async function loader({ params, context, request }: Route.LoaderArgs) {
26 const url = new URL(request.url);
27 // `?tab=projects` and the like, from the tabs this page once had: that
28 // page's own address.
29 const moved = url.search ? workspaceRedirect(url.pathname, url.search) : null;
30 if (moved) throw redirect(moved);
31 const viewer = getViewer(context);
32 const workspace = await identity.getWorkspace(params.owner);
33 if (!workspace) {
34 // A workspace's old name, after a rename: its pages are at the new one.
35 await redirectIfRenamed(request, params.owner);
36 throw notFound("workspace");
37 }
38 const role = roleIn(viewer, workspace.slug);
39 // Opening one of your workspaces makes it the one you are in.
40 if (!role) {
41 // The workspace is what the frame is about; its projects only fill the
42 // sidebar, which goes without them rather than take every page down.
43 const listed = await part(workspaceProjects(workspace.slug, viewer), "Projects");
44 return { workspace, role, welcome: false, projects: !isFailed(listed) && listed.ok ? listed.value.map(shortcutOf) : [] };
45 }
46 const secure = new URL(request.url).protocol === "https:";
47 const headers = new Headers({ "Set-Cookie": rememberWorkspace(workspace.slug, secure) });
48 // Just joined with an invite: welcomed once (routes/invite.tsx).
49 const welcome = welcomes(request.headers.get("cookie"), workspace.slug);
50 if (welcome) headers.append("Set-Cookie", clearWelcome(secure));
51 return data({ workspace, role, welcome }, { headers });
52}
53
54/** A workspace's own pages, each with its title and what it is for. */
55const PAGES: Record<string, { title: string; about: string }> = {
56 workspace: {
57 title: "Workspace",
58 about: "Who is in it, what it is on, and what it has spent this month. Everything about the workspace itself is in this sidebar.",
59 },
60 settings: { title: "General", about: "The workspace's name, icon, address and description, two-factor authentication, where its data lives, and deleting it." },
61 people: {
62 title: "People",
63 about: "Everyone in the workspace, in one directory: who they are, what they own, the teams they're on and who's around.",
64 },
65 members: {
66 title: "Members",
67 about: "Who is in the workspace and their roles. Owners invite people, manage members and owners, and see the outside collaborators; what members may do is under Permissions.",
68 },
69 "members/invitations": {
70 title: "Invitations",
71 about: "Every invitation to join the workspace: who it is for, who sent it, the role, and where it stands. Send one again, revoke it, or copy its link.",
72 },
73 permissions: {
74 title: "Permissions",
75 about: "Who may do what in the workspace, part by part: repositories, teams, chat, artifacts, deployments and packages. Owners only.",
76 },
77 projects: {
78 title: "Projects",
79 about: "Everything the workspace builds and runs. Search, filter and sort them, and pin the ones you use most to keep them in your sidebar.",
80 },
81 teams: {
82 title: "Teams",
83 about: "People with a lead, a channel and roles on repositories. Everyone on a team knows who their teammates are.",
84 },
85 repositories: {
86 title: "Repositories",
87 about: "Every repository in the workspace: who can see it, whether it is archived, and the ones deleted recently.",
88 },
89 tokens: {
90 title: "Access tokens",
91 about: "Tokens that belong to the workspace, not a person: for CI, integrations and coding agents that work for the whole team. Each has the permissions an owner gives it, in all of the workspace's repositories or the ones chosen.",
92 },
93 "personal-access-tokens": {
94 title: "Personal access tokens",
95 about: "Which of your members' own tokens may reach the workspace and for how long, the tokens made for it that wait for an owner's approval, and every token that can reach it.",
96 },
97 packages: {
98 title: "Packages",
99 about: "What the workspace publishes and installs, beside its code: container images, npm, Composer, Go, Cargo, Maven, NuGet and RubyGems, with the same members, roles and tokens.",
100 },
101 work: {
102 title: "Work",
103 about: "Everything the workspace means to do, features, bugs and chores, numbered once for the whole workspace and linked to the repositories it lands in.",
104 },
105 "work/new": {
106 title: "New work item",
107 about: "Say what should change, and pick the repositories it should land in. Whoever works on it, person or agent, works from this text.",
108 },
109 "work/labels": {
110 title: "Labels",
111 about: "The workspace's labels, shared by every work item and pull request in it.",
112 },
113 "work/milestones": {
114 title: "Milestones",
115 about: "Goals with a due date, that work items and pull requests from every repository of the workspace are gathered under.",
116 },
117 insights: { title: "Insights", about: "Delivery, review times, work in progress and costs across your workspace." },
118 usage: { title: "Usage", about: "What the workspace used and what it cost, by product, project and day." },
119 billing: { title: "Billing and plans", about: "The g1t plan, the trial, your spend limit, prepaying, and every charge." },
120 audit: {
121 title: "Audit log",
122 about: "Every change people, tokens and g1t's own runs made: by whom, on whose behalf, to what, and whether it was allowed.",
123 },
124 guardrails: {
125 title: "Guardrails",
126 about: "How long every project's runs may take. Each project can override these under its settings.",
127 },
128 webhooks: {
129 title: "Webhooks",
130 about: "Every repository's events, sent to your own addresses as they happen. A repository can also have its own, under its settings.",
131 },
132 secrets: {
133 title: "Secrets and variables",
134 about: "Shared with every repository, or the ones you link: read by workflows, deployments, or both. A repository's own row of the same key wins.",
135 },
136 security: {
137 title: "Security",
138 about: "Open alerts in every project: secrets found in pushes and history, and vulnerable dependencies. Each project's Security page has the details and the security update g1t opened for each.",
139 },
140 integrations: {
141 title: "Integrations",
142 about:
143 "The tools your team works with, connected once for everyone in the workspace. Secrets are sealed when saved, and never shown again.",
144 },
145};
146
147/** A workspace page's heading: its title and what it is for. */
148function PageHeader({ title, about }: { title: string; about: string }) {
149 return (
150 <header className="mb-8 border-b border-line pb-6">
151 <h1 className="text-2xl font-semibold tracking-tight">{title}</h1>
152 <p className="mt-1.5 text-sm text-muted">{about}</p>
153 </header>
154 );
155}
156
157/** Artifacts' pages that are lists, not one artifact. */
158const ARTIFACT_LISTS = new Set(["new", "templates", "trash", "stale", "spaces"]);
159
160/** Whether the page (its path after `/-/`, in parts) is one artifact or a project's doc file. */
161function isArtifactPage(parts: string[]): boolean {
162 if (parts[0] !== "artifacts" || parts.length < 2) return false;
163 if (parts[1] === "repo") return true;
164 return !ARTIFACT_LISTS.has(parts[1]!) && parts.length === 2;
165}
166
167export default function WorkspaceLayout({ loaderData }: Route.ComponentProps) {
168 const { workspace, role, welcome } = loaderData;
169 const { pathname } = useLocation();
170 const going = useNavigation().location;
171 // The workspace's own page is its overview. Its other pages are found
172 // from the sidebar, which lights the one you are on, and the top bar's
173 // trail leads back here; each has a heading of its own.
174 const here = workspacePage(pathname, workspace.slug);
175 // On the way to another of its pages: that page's heading and shape
176 // until it arrives.
177 const next = going && going.pathname !== pathname ? workspacePage(going.pathname, workspace.slug) : null;
178 const shown = next ?? here;
179 const parts = (pathname.split("/-/")[1] ?? "").split("/").filter(Boolean);
180 // Chat fills the page edge to edge: its conversation, thread and details
181 // are columns of their own (components/chat/channel.tsx).
182 if (parts[0] === "chat" && !next) return <Outlet />;
183 // Code's Overview lays itself out, as Mission control did.
184 if (parts[0] === "overview" && parts.length === 1 && !next) return <Outlet />;
185 // An open artifact, or a project's doc, fills the panel: its header runs edge to edge right under
186 // the top bar (components/folios/shell.tsx), and its page sets its own width.
187 if (isArtifactPage(parts) && !next) {
188 return (
189 <div className="px-4 pb-10 sm:px-6 lg:px-8">
190 <Outlet />
191 </div>
192 );
193 }
194 if (shown !== "overview") {
195 // A page of its own under another's address (Invitations under Members) has its own heading.
196 const whole = parts.join("/");
197 const key = shown ?? (PAGES[whole] ? whole : (parts[0] ?? ""));
198 const heading = PAGES[key];
199 // Pages within one (a single package or team) have headings of their own.
200 const titled = heading && (shown != null || parts.length === 1 || key === whole);
201 return (
202 <div className="mx-auto max-w-5xl px-4 py-10 sm:px-8">
203 {titled && (
204 <PageHeader title={heading.title} about={heading.about} />
205 )}
206 {next ? <WorkspacePageSkeleton page={next} /> : <Outlet />}
207 </div>
208 );
209 }
210 return (
211 <>
212 {/* The workspace's own header band, above its overview. */}
213 <div className="border-b border-line bg-surface/60">
214 <div className="mx-auto max-w-6xl px-4 py-8">
215 {welcome && (
216 <div className="mb-6">
217 <WelcomeBanner title={`You're in ${workspace.name}`}>
218 You joined as a {role}. Its projects, work and people are all here.
219 </WelcomeBanner>
220 </div>
221 )}
222 <div className="flex flex-wrap items-center gap-4">
223 <Avatar name={workspace.slug} image={workspace.avatar} size={52} square />
224 <div className="min-w-0 grow">
225 <div className="flex flex-wrap items-center gap-2.5">
226 <h1 className="truncate text-2xl font-semibold tracking-tight">
227 {workspace.name}
228 </h1>
229 <Badge size="md">Workspace</Badge>
230 {role && <Badge size="md">{role}</Badge>}
231 </div>
232 <p className="font-mono text-sm text-muted">g1t.sh/{workspace.slug}</p>
233 </div>
234 {role ? (
235 <ButtonLink to={`/new?workspace=${workspace.slug}`}>
236 <Plus size={15} />
237 New project
238 </ButtonLink>
239 ) : (
240 <ButtonLink to={`/${workspace.slug}/-/packages`} variant="outline" prefetch="intent">
241 <Package size={15} />
242 Packages
243 </ButtonLink>
244 )}
245 </div>
246 {workspace.description && (
247 <p className="mt-4 max-w-2xl text-sm text-muted">{workspace.description}</p>
248 )}
249 </div>
250 </div>
251 <div className="mx-auto max-w-6xl px-4 py-8">
252 {next ? <WorkspacePageSkeleton page="overview" /> : <Outlet />}
253 </div>
254 </>
255 );
256}