Skip to content

g1t/apps/web/app/routes/workspace/layout.tsx

239 lines11,043 bytesCodeBlame
1import { Plus } from "lucide-react";
2import { NavLink, Outlet, data, redirect, useLocation, useNavigation } from "react-router";
3
4import type { Route } from "./+types/layout";
5import { page } from "../../lib/meta";
6import { Avatar, ButtonLink, Pill } from "../../components/ui";
7import { WelcomeBanner } from "../../components/welcome";
8import { clearWelcome, welcomes } from "../../lib/invites";
9import { notFound } from "../../lib/not-found.server";
10import { redirectIfRenamed } from "../../lib/renamed.server";
11import { rememberWorkspace } from "../../lib/workspace-choice";
12import { type WorkspaceTab, type WorkspaceTabKey, workspaceRedirect, workspaceTab, workspaceTabs } from "../../lib/workspace-nav";
13import { workspaceProjects } from "../../lib/workspace-projects.server";
14import { WorkspaceTabSkeleton } from "../../components/workspace-skeletons";
15import { identity } from "../../lib/services.server";
16import { getViewer, roleIn } from "../../lib/session.server";
17
18export function meta({ loaderData, params, ...args }: Route.MetaArgs) {
19 return page(args, { title: `${loaderData?.workspace.name ?? params.owner} · g1t` });
20}
21
22export async function loader({ params, context, request }: Route.LoaderArgs) {
23 const url = new URL(request.url);
24 // `?tab=projects` and the like, from habit: that tab's own address.
25 const moved = url.search ? workspaceRedirect(url.pathname, url.search) : null;
26 if (moved) throw redirect(moved);
27 const viewer = getViewer(context);
28 // Under the tabs, the count of projects beside Projects: the same
29 // listing the sidebar and the Projects page read, asked at once.
30 const tabbed = workspaceTab(url.pathname, params.owner) != null;
31 const [workspace, listed] = await Promise.all([
32 identity.getWorkspace(params.owner),
33 tabbed ? workspaceProjects(params.owner, viewer).catch(() => null) : null,
34 ]);
35 if (!workspace) {
36 // A workspace's old name, after a rename: its pages are at the new one.
37 await redirectIfRenamed(request, params.owner);
38 throw notFound("workspace");
39 }
40 const role = roleIn(viewer, workspace.slug);
41 const projectCount = listed?.ok ? listed.value.length : null;
42 // Opening one of your workspaces makes it the one you are in.
43 if (!role) return { workspace, role, welcome: false, projectCount };
44 const secure = new URL(request.url).protocol === "https:";
45 const headers = new Headers({ "Set-Cookie": rememberWorkspace(workspace.slug, secure) });
46 // Just joined with an invite: welcomed once (routes/invite.tsx).
47 const welcome = welcomes(request.headers.get("cookie"), workspace.slug);
48 if (welcome) headers.append("Set-Cookie", clearWelcome(secure));
49 return data({ workspace, role, welcome, projectCount }, { headers });
50}
51
52/** A workspace's own pages, each with its title and what it is for. */
53const PAGES: Record<string, { title: string; about: string }> = {
54 settings: { title: "General", about: "The workspace's name, icon, address and description, and deleting it." },
55 people: {
56 title: "People",
57 about: "Members create repositories and have the base permission on each one. Owners are Admins on every repository, and also manage members, tokens, billing and integrations.",
58 },
59 projects: {
60 title: "Projects",
61 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.",
62 },
63 repositories: {
64 title: "Repositories",
65 about: "Every repository in the workspace: who can see it, whether it is archived, and the ones deleted recently.",
66 },
67 tokens: {
68 title: "Access tokens",
69 about: "Tokens that belong to the workspace, not a person: for CI, integrations and agents that work for the whole team.",
70 },
71 packages: {
72 title: "Packages",
73 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.",
74 },
75 usage: { title: "Usage", about: "What the workspace's agents cost, run by run, by repository, pull request and model." },
76 billing: { title: "Billing and plans", about: "The g1t plan, the trial, your spend limit and caps, prepaying, and every charge." },
77 agents: {
78 title: "Agent fleet",
79 about: "Every agent at work across the workspace's projects: what each holds, what it is doing now, and what it has cost.",
80 },
81 memory: {
82 title: "Workspace memory",
83 about: "What holds across all of the workspace's projects, given to every agent in every one of them, beside each project's own memory.",
84 },
85 context: {
86 title: "Context",
87 about: "Everything the workspace builds and runs, and what it knows: a catalog built from its projects, memory with a review queue, one search over all of it, and a scorecard for each project.",
88 },
89 audit: {
90 title: "Audit log",
91 about: "Every action agents took with their run credentials, and every change people and tokens made: by whom, on whose behalf, to what, and whether it was allowed.",
92 },
93 guardrails: {
94 title: "Guardrails",
95 about: "What agents may reach, run, spend and take in every project's sandboxes. Each project can override these under its settings.",
96 },
97 webhooks: {
98 title: "Webhooks",
99 about: "Every repository's events, sent to your own addresses as they happen. A repository can also have its own, under its settings.",
100 },
101 secrets: {
102 title: "Secrets and variables",
103 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.",
104 },
105 runners: {
106 title: "Self-hosted runners",
107 about: "Your own machines, which run workflow jobs, and if you choose agents' work, for free. They connect out to g1t; nothing reaches in.",
108 },
109 security: {
110 title: "Security",
111 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.",
112 },
113 integrations: {
114 title: "Integrations",
115 about: "Model providers, alerts and trackers. Secrets are sealed when saved, and agents never see them.",
116 },
117};
118
119/**
120 * The workspace's tabs, as a project's are: a row under its header that
121 * scrolls sideways on a phone, without a scrollbar. Soon tabs say so with
122 * a dot.
123 */
124function WorkspaceTabs({ tabs, current }: { tabs: WorkspaceTab[]; current: WorkspaceTabKey | null }) {
125 return (
126 <nav aria-label="Workspace" className="relative -mb-px flex gap-1 overflow-x-auto [scrollbar-width:none] [&::-webkit-scrollbar]:hidden">
127 {tabs.map((tab) => (
128 <NavLink
129 key={tab.key}
130 to={tab.to}
131 end={tab.key === "overview"}
132 prefetch="intent"
133 aria-current={current === tab.key ? "page" : undefined}
134 className={`flex shrink-0 items-center gap-1.5 border-b-2 px-3 pt-1 pb-2.5 text-sm transition-colors ${
135 current === tab.key
136 ? "border-accent font-medium text-fg"
137 : tab.soon
138 ? "border-transparent text-faint hover:text-muted"
139 : "border-transparent text-muted hover:text-fg"
140 }`}
141 >
142 {tab.label}
143 {tab.count != null && (
144 <span className="rounded-full bg-raised px-1.5 py-px text-xs tabular-nums text-muted">{tab.count}</span>
145 )}
146 {tab.soon && (
147 <>
148 <span aria-hidden="true" className="size-1.5 rounded-full bg-accent/70" />
149 <span className="sr-only">(soon)</span>
150 </>
151 )}
152 </NavLink>
153 ))}
154 </nav>
155 );
156}
157
158export default function WorkspaceLayout({ loaderData }: Route.ComponentProps) {
159 const { workspace, role, welcome, projectCount } = loaderData;
160 const { pathname } = useLocation();
161 const going = useNavigation().location;
162 const tab = workspaceTab(pathname, workspace.slug);
163 // The sidebar finds the workspace's other pages, for everyone, so they
164 // need a title, not the workspace's whole header again.
165 const parts = (pathname.split("/-/")[1] ?? "").split("/").filter(Boolean);
166 const page = tab ? undefined : PAGES[parts[0] ?? ""];
167 // A page within one (a single package) has its own heading.
168 if (page && parts.length > 1) {
169 return (
170 <div className="mx-auto max-w-5xl px-4 py-10 sm:px-8">
171 <Outlet />
172 </div>
173 );
174 }
175 if (page) {
176 return (
177 <div className="mx-auto max-w-5xl px-4 py-10 sm:px-8">
178 <header className="mb-8 border-b border-line pb-6">
179 <h1 className="text-2xl font-semibold tracking-tight">{page.title}</h1>
180 <p className="mt-1.5 text-sm text-muted">{page.about}</p>
181 </header>
182 <Outlet />
183 </div>
184 );
185 }
186 const tabs = workspaceTabs(workspace.slug, {
187 member: role != null,
188 owner: role === "owner",
189 projects: projectCount,
190 people: role ? workspace.memberCount : null,
191 });
192 // On the way to another of its tabs: that tab's shape until it arrives.
193 const next = going && going.pathname !== pathname ? workspaceTab(going.pathname, workspace.slug) : null;
194 return (
195 <>
196 {/* The workspace's own header band, with its tabs along the bottom. */}
197 <div className="border-b border-line bg-surface/60">
198 <div className="mx-auto max-w-6xl px-4 pt-8">
199 {welcome && (
200 <div className="mb-6">
201 <WelcomeBanner title={`You're in ${workspace.name}`}>
202 You joined as a {role}. Its projects, issues and agents are all here.
203 </WelcomeBanner>
204 </div>
205 )}
206 <div className="flex flex-wrap items-center gap-4">
207 <Avatar name={workspace.slug} image={workspace.avatar} size={52} square />
208 <div className="min-w-0 grow">
209 <div className="flex flex-wrap items-center gap-2.5">
210 <h1 className="truncate text-2xl font-semibold tracking-tight">
211 {workspace.name}
212 </h1>
213 {role && <Pill>{role}</Pill>}
214 </div>
215 <p className="font-mono text-sm text-muted">g1t.sh/{workspace.slug}</p>
216 </div>
217 {role && (
218 <ButtonLink to={`/new?workspace=${workspace.slug}`}>
219 <Plus size={15} />
220 New project
221 </ButtonLink>
222 )}
223 </div>
224 {workspace.description && (
225 <p className="mt-4 max-w-2xl text-sm text-muted">{workspace.description}</p>
226 )}
227 <div className="mt-6">
228 <WorkspaceTabs tabs={tabs} current={next ?? tab} />
229 </div>
230 </div>
231 </div>
232 <div className="mx-auto max-w-6xl px-4 py-8">
233 {/* What a tab is for, said once above it. */}
234 {tab && tab !== "overview" && !next && PAGES[tab] && <p className="mb-6 max-w-3xl text-sm text-muted">{PAGES[tab].about}</p>}
235 {next ? <WorkspaceTabSkeleton tab={next} /> : <Outlet />}
236 </div>
237 </>
238 );
239}