Skip to content
243 linesCodeBlameRaw
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 teams: {
64 title: "Teams",
65 about: "Groups of members, given roles on repositories together, mentioned as @workspace/team and asked to review together. Child teams inherit their parent's access.",
66 },
67 repositories: {
68 title: "Repositories",
69 about: "Every repository in the workspace: who can see it, whether it is archived, and the ones deleted recently.",
70 },
71 tokens: {
72 title: "Access tokens",
73 about: "Tokens that belong to the workspace, not a person: for CI, integrations and agents that work for the whole team.",
74 },
75 packages: {
76 title: "Packages",
77 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.",
78 },
79 usage: { title: "Usage", about: "What the workspace's agents cost, run by run, by repository, pull request and model." },
80 billing: { title: "Billing and plans", about: "The g1t plan, the trial, your spend limit and caps, prepaying, and every charge." },
81 agents: {
82 title: "Agent fleet",
83 about: "Every agent at work across the workspace's projects: what each holds, what it is doing now, and what it has cost.",
84 },
85 memory: {
86 title: "Workspace memory",
87 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.",
88 },
89 context: {
90 title: "Context",
91 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.",
92 },
93 audit: {
94 title: "Audit log",
95 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.",
96 },
97 guardrails: {
98 title: "Guardrails",
99 about: "What agents may reach, run, spend and take in every project's sandboxes. Each project can override these under its settings.",
100 },
101 webhooks: {
102 title: "Webhooks",
103 about: "Every repository's events, sent to your own addresses as they happen. A repository can also have its own, under its settings.",
104 },
105 secrets: {
106 title: "Secrets and variables",
107 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.",
108 },
109 runners: {
110 title: "Self-hosted runners",
111 about: "Your own machines, which run workflow jobs, and if you choose agents' work, for free. They connect out to g1t; nothing reaches in.",
112 },
113 security: {
114 title: "Security",
115 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.",
116 },
117 integrations: {
118 title: "Integrations",
119 about: "Model providers, alerts and trackers. Secrets are sealed when saved, and agents never see them.",
120 },
121};
122
123/**
124 * The workspace's tabs, as a project's are: a row under its header that
125 * scrolls sideways on a phone, without a scrollbar. Soon tabs say so with
126 * a dot.
127 */
128function WorkspaceTabs({ tabs, current }: { tabs: WorkspaceTab[]; current: WorkspaceTabKey | null }) {
129 return (
130 <nav aria-label="Workspace" className="relative -mb-px flex gap-1 overflow-x-auto [scrollbar-width:none] [&::-webkit-scrollbar]:hidden">
131 {tabs.map((tab) => (
132 <NavLink
133 key={tab.key}
134 to={tab.to}
135 end={tab.key === "overview"}
136 prefetch="intent"
137 aria-current={current === tab.key ? "page" : undefined}
138 className={`flex shrink-0 items-center gap-1.5 border-b-2 px-3 pt-1 pb-2.5 text-sm transition-colors ${
139 current === tab.key
140 ? "border-accent font-medium text-fg"
141 : tab.soon
142 ? "border-transparent text-faint hover:text-muted"
143 : "border-transparent text-muted hover:text-fg"
144 }`}
145 >
146 {tab.label}
147 {tab.count != null && (
148 <span className="rounded-full bg-raised px-1.5 py-px text-xs tabular-nums text-muted">{tab.count}</span>
149 )}
150 {tab.soon && (
151 <>
152 <span aria-hidden="true" className="size-1.5 rounded-full bg-accent/70" />
153 <span className="sr-only">(soon)</span>
154 </>
155 )}
156 </NavLink>
157 ))}
158 </nav>
159 );
160}
161
162export default function WorkspaceLayout({ loaderData }: Route.ComponentProps) {
163 const { workspace, role, welcome, projectCount } = loaderData;
164 const { pathname } = useLocation();
165 const going = useNavigation().location;
166 const tab = workspaceTab(pathname, workspace.slug);
167 // The sidebar finds the workspace's other pages, for everyone, so they
168 // need a title, not the workspace's whole header again.
169 const parts = (pathname.split("/-/")[1] ?? "").split("/").filter(Boolean);
170 const page = tab ? undefined : PAGES[parts[0] ?? ""];
171 // A page within one (a single package) has its own heading.
172 if (page && parts.length > 1) {
173 return (
174 <div className="mx-auto max-w-5xl px-4 py-10 sm:px-8">
175 <Outlet />
176 </div>
177 );
178 }
179 if (page) {
180 return (
181 <div className="mx-auto max-w-5xl px-4 py-10 sm:px-8">
182 <header className="mb-8 border-b border-line pb-6">
183 <h1 className="text-2xl font-semibold tracking-tight">{page.title}</h1>
184 <p className="mt-1.5 text-sm text-muted">{page.about}</p>
185 </header>
186 <Outlet />
187 </div>
188 );
189 }
190 const tabs = workspaceTabs(workspace.slug, {
191 member: role != null,
192 owner: role === "owner",
193 projects: projectCount,
194 people: role ? workspace.memberCount : null,
195 });
196 // On the way to another of its tabs: that tab's shape until it arrives.
197 const next = going && going.pathname !== pathname ? workspaceTab(going.pathname, workspace.slug) : null;
198 return (
199 <>
200 {/* The workspace's own header band, with its tabs along the bottom. */}
201 <div className="border-b border-line bg-surface/60">
202 <div className="mx-auto max-w-6xl px-4 pt-8">
203 {welcome && (
204 <div className="mb-6">
205 <WelcomeBanner title={`You're in ${workspace.name}`}>
206 You joined as a {role}. Its projects, issues and agents are all here.
207 </WelcomeBanner>
208 </div>
209 )}
210 <div className="flex flex-wrap items-center gap-4">
211 <Avatar name={workspace.slug} image={workspace.avatar} size={52} square />
212 <div className="min-w-0 grow">
213 <div className="flex flex-wrap items-center gap-2.5">
214 <h1 className="truncate text-2xl font-semibold tracking-tight">
215 {workspace.name}
216 </h1>
217 {role && <Pill>{role}</Pill>}
218 </div>
219 <p className="font-mono text-sm text-muted">g1t.sh/{workspace.slug}</p>
220 </div>
221 {role && (
222 <ButtonLink to={`/new?workspace=${workspace.slug}`}>
223 <Plus size={15} />
224 New project
225 </ButtonLink>
226 )}
227 </div>
228 {workspace.description && (
229 <p className="mt-4 max-w-2xl text-sm text-muted">{workspace.description}</p>
230 )}
231 <div className="mt-6">
232 <WorkspaceTabs tabs={tabs} current={next ?? tab} />
233 </div>
234 </div>
235 </div>
236 <div className="mx-auto max-w-6xl px-4 py-8">
237 {/* What a tab is for, said once above it. */}
238 {tab && tab !== "overview" && !next && PAGES[tab] && <p className="mb-6 max-w-3xl text-sm text-muted">{PAGES[tab].about}</p>}
239 {next ? <WorkspaceTabSkeleton tab={next} /> : <Outlet />}
240 </div>
241 </>
242 );
243}