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
441 linesCodeBlameRaw
1import { ArrowRight, ArrowUpRight, Box, CircleDot, GitBranch, GitPullRequest, KeyRound, Lock, Pin, Plus, Rocket } from "lucide-react";
2import { Link, redirect } from "react-router";
3
4import type { PackageSummary, Project, ProjectDeploys } from "@g1t/contracts";
5
6import type { Route } from "./+types/overview";
7import { DeployLink, host, StatusDot } from "../../components/deploy";
8import { ButtonLink, EmptyState, TimeAgo } from "../../components/ui";
9import { Avatar } from "../../components/ui/avatar";
10import { Badge } from "../../components/ui/badge";
11import { Card } from "../../components/ui/card";
12import { UsageCard } from "../../components/usage-card";
13import { PullIcon } from "../../components/work-icons";
14import { openedBy } from "../../lib/opened-by";
15import { kindLabel, libraryPackages, packageLine, packagePath, primaryLink } from "../../lib/project-kind";
16import { PinButton } from "../../components/pin-button";
17import { sortProjects } from "../../lib/project-list";
18import { usageFor } from "../../lib/workspace-usage.server";
19import { deployments, identity, packages, projects as projectsApi, work } from "../../lib/services.server";
20import { getViewer, roleIn } from "../../lib/session.server";
21import { workspaceProjects } from "../../lib/workspace-projects.server";
22
23/** Projects whose open issues and pull requests are counted: the most active. */
24const MAX_COUNTED = 30;
25/** Projects shown under Recently active, after the pins. */
26const MAX_ACTIVE = 6;
27/** Projects whose pull requests are listed under "In progress". */
28const MAX_LISTED = 8;
29const MAX_PULLS = 8;
30const MAX_FACES = 8;
31export async function loader({ params, context }: Route.LoaderArgs) {
32 const viewer = getViewer(context);
33 const slug = params.owner.toLowerCase();
34 const role = roleIn(viewer, slug);
35 // A member's way in is Home; this page
36 // is the workspace as a visitor sees it.
37 if (role) throw redirect(`/${slug}/-/home`);
38 const [listed, members, deploys, usage, published, shortcuts] = await Promise.all([
39 // The listing the sidebar and the Projects tab share.
40 workspaceProjects(slug, viewer),
41 role ? identity.listMembers(slug, viewer) : null,
42 role ? deployments.overview(slug, viewer) : null,
43 role ? usageFor(slug, viewer) : null,
44 // One listing for every card: a library's card shows its package where an app's shows production.
45 packages.list(slug, viewer).catch(() => null),
46 // The viewer's pins lead the page, as they lead the sidebar.
47 role ? projectsApi.shortcuts(slug, viewer).catch(() => null) : null,
48 ]);
49 const projects = listed.ok ? listed.value : [];
50 const pinnedIds = new Set((shortcuts?.pinned ?? []).map((project) => project.id));
51 const byId = new Map(projects.map((project) => [project.id, project]));
52 const pinned = (shortcuts?.pinned ?? []).map((pin) => byId.get(pin.id)).filter((project): project is Project => project != null);
53 // However many there are: the most active first, archived ones last.
54 const byActivity = sortProjects(
55 // Deploying does not order the list, so it is not read here.
56 projects.map((project) => ({ ...project, pushedAt: project.pushedAt ?? null, activity: project.activity ?? 0, deploying: false })),
57 "active",
58 )
59 .sort((a, b) => Number(a.archived) - Number(b.archived))
60 .map((listedProject) => byId.get(listedProject.id)!);
61 const active = byActivity.filter((project) => !pinnedIds.has(project.id)).slice(0, MAX_ACTIVE);
62
63 const pathOf = (project: Project) =>
64 project.source.kind === "hosted" ? project.source.repo : { namespace: project.workspace, name: project.slug };
65 const counted = [...new Map([...pinned, ...active, ...byActivity].map((project) => [project.id, project])).values()].slice(0, Math.max(MAX_COUNTED, pinned.length + active.length));
66 const counts = await Promise.all(counted.map((project) => work.counts(pathOf(project), viewer)));
67 const open: Record<string, { issues: number; pulls: number }> = {};
68 counted.forEach((project, i) => {
69 const result = counts[i];
70 if (result.ok) open[project.id] = result.value;
71 });
72
73 const busy = counted.filter((project) => (open[project.id]?.pulls ?? 0) > 0).slice(0, MAX_LISTED);
74 const lists = await Promise.all(busy.map((project) => work.listPulls(pathOf(project), viewer, "open")));
75 const pulls = lists
76 .flatMap((result, i) => (result.ok ? result.value.map((pull) => ({ pull, project: busy[i] })) : []))
77 .sort((a, b) => b.pull.updatedAt.localeCompare(a.pull.updatedAt))
78 .slice(0, MAX_PULLS);
79
80 const bySlug: Record<string, ProjectDeploys> = {};
81 for (const entry of deploys?.ok ? deploys.value : []) bySlug[entry.slug] = entry;
82
83 const byRepo = new Map<string, PackageSummary[]>();
84 for (const pkg of published?.ok ? published.value : []) {
85 if (pkg.repo) byRepo.set(pkg.repo.id, [...(byRepo.get(pkg.repo.id) ?? []), pkg]);
86 }
87 const packageOf: Record<string, PackageSummary> = {};
88 for (const project of [...pinned, ...active]) {
89 if ((project.kind !== "library" && project.kind !== "tool") || project.source.kind !== "hosted") continue;
90 const first = libraryPackages(byRepo.get(project.source.repoId) ?? [])[0];
91 if (first) packageOf[project.id] = first;
92 }
93
94 return {
95 slug,
96 role,
97 total: projects.length,
98 elsewhere: projects.filter((project) => project.runs === "elsewhere" && project.productionUrl).length,
99 pinned,
100 active,
101 open,
102 deploys: bySlug,
103 packageOf,
104 pulls: pulls.map(({ pull, project }) => ({ pull, slug: project.slug, name: project.name })),
105 members: members?.ok ? members.value : null,
106 usage,
107 };
108}
109
110function Stat({ value, label }: { value: number; label: string }) {
111 return (
112 <Card className="px-4 py-3">
113 <p className="text-xl font-semibold tabular-nums tracking-tight">{value}</p>
114 <p className="text-xs text-muted">{label}</p>
115 </Card>
116 );
117}
118
119/** What a member sees in a workspace with no projects yet. */
120function NoProjects({ slug }: { slug: string }) {
121 return (
122 <EmptyState title="No projects yet">
123 <ButtonLink to={`/new?workspace=${slug}`} variant="accent" className="mt-3">
124 <Plus size={14} />
125 New project
126 </ButtonLink>
127 </EmptyState>
128 );
129}
130
131function ProjectCard({
132 project,
133 open,
134 deploys,
135 pkg,
136 member,
137 pinned,
138}: {
139 project: Project;
140 open: { issues: number; pulls: number } | undefined;
141 deploys: ProjectDeploys | undefined;
142 /** For a library, the package it publishes. */
143 pkg: PackageSummary | undefined;
144 member: boolean;
145 /** Whether the viewer pinned it; null when they cannot pin. */
146 pinned: boolean | null;
147}) {
148 // A library or a tool shows the package it publishes.
149 const library = project.kind === "library" || project.kind === "tool";
150 const base = `/${project.workspace}/${project.slug}`;
151 const production = deploys?.production ?? null;
152 // Production where g1t serves it or elsewhere, else its homepage or docs.
153 const link = primaryLink(project, production?.url);
154 const latest = deploys?.latest ?? null;
155 const source = project.source.kind === "hosted" ? project.source : null;
156 return (
157 <li className="group relative flex flex-col rounded-2xl border border-line bg-surface p-5 transition-colors hover:border-line-strong">
158 <div className="flex items-start gap-3">
159 <span className="flex size-9 shrink-0 items-center justify-center rounded-lg bg-raised text-muted ring-1 ring-line">
160 {project.private ? <Lock size={15} /> : <Box size={15} />}
161 </span>
162 <div className="min-w-0 grow">
163 <Link to={base} prefetch="intent" className="font-medium after:absolute after:inset-0 hover:underline">
164 {project.name}
165 </Link>
166 {link && !(library && pkg && !production) ? (
167 <DeployLink
168 href={link}
169 className="relative z-10 mt-0.5 flex items-center gap-1 truncate font-mono text-xs text-muted hover:text-accent"
170 >
171 {host(link)}
172 <ArrowUpRight size={11} className="shrink-0" />
173 </DeployLink>
174 ) : library && pkg ? (
175 <Link
176 to={packagePath(pkg)}
177 className="relative z-10 mt-0.5 block truncate font-mono text-xs text-muted hover:text-accent"
178 >
179 {packageLine(pkg)}
180 </Link>
181 ) : (
182 <p className="mt-0.5 truncate text-xs text-faint">
183 {project.description ??
184 (library
185 ? "Not published yet"
186 : project.runs === "g1t"
187 ? deploys?.enabled
188 ? "Not deployed yet"
189 : "Deployments are off"
190 : kindLabel(project))}
191 </p>
192 )}
193 </div>
194 {(project.private || project.archived) && (
195 <span className="flex shrink-0 gap-1.5">
196 {project.private && <Badge size="md">private</Badge>}
197 {project.archived && <Badge size="md">archived</Badge>}
198 </span>
199 )}
200 {pinned != null && (
201 <PinButton workspace={project.workspace} slug={project.slug} name={project.name} pinned={pinned} compact className="-mt-1.5 -mr-2" />
202 )}
203 </div>
204
205 {project.description && (link || (library && pkg)) && <p className="mt-3 line-clamp-2 text-sm text-muted">{project.description}</p>}
206
207 <div className="mt-auto pt-5">
208 {member && latest && (
209 <p className="mb-3 flex items-center gap-2 text-xs text-muted">
210 <StatusDot status={latest.status} />
211 <span className="truncate">
212 {latest.kind === "production" ? "Production" : `Preview of ${latest.branch}`}
213 </span>
214 <span className="ml-auto shrink-0 text-faint">
215 <TimeAgo at={latest.createdAt} />
216 </span>
217 </p>
218 )}
219 <div className="flex items-center gap-4 border-t border-line pt-3 text-xs text-muted">
220 {source && (
221 <span className="flex min-w-0 items-center gap-1.5 font-mono">
222 <GitBranch size={12} className="shrink-0 text-faint" />
223 <span className="truncate">{source.defaultBranch}</span>
224 </span>
225 )}
226 <span className="flex items-center gap-1">
227 <CircleDot size={12} className="text-faint" />
228 {open?.issues ?? 0}
229 </span>
230 <span className="flex items-center gap-1">
231 <GitPullRequest size={12} className="text-faint" />
232 {open?.pulls ?? 0}
233 </span>
234 {member && deploys?.enabled && (deploys.previews ?? 0) > 0 && (
235 <span className="ml-auto flex items-center gap-1 text-success">
236 <Rocket size={12} />
237 {deploys.previews} {deploys.previews === 1 ? "preview" : "previews"}
238 </span>
239 )}
240 </div>
241 </div>
242 </li>
243 );
244}
245
246export default function WorkspaceOverview({ loaderData }: Route.ComponentProps) {
247 const { slug, role, total, pinned, active, open, deploys, pulls, members, usage } = loaderData;
248 const card = (project: Project, isPinned: boolean) => (
249 <ProjectCard
250 key={project.id}
251 project={project}
252 open={open[project.id]}
253 deploys={deploys[project.slug]}
254 pkg={loaderData.packageOf[project.id]}
255 member={role != null}
256 pinned={role ? isPinned : null}
257 />
258 );
259 const totals = Object.values(open).reduce(
260 (sum, counts) => ({ issues: sum.issues + counts.issues, pulls: sum.pulls + counts.pulls }),
261 { issues: 0, pulls: 0 },
262 );
263 // Live on g1t, or deployed elsewhere with an address.
264 const liveApps =
265 Object.values(deploys).filter((entry) => entry.production).length +
266 loaderData.elsewhere;
267 return (
268 <div className="grid gap-10 lg:grid-cols-[1fr_18rem]">
269 <div className="min-w-0 space-y-10">
270 {total === 0 && role ? (
271 <NoProjects slug={slug} />
272 ) : (
273 <>
274 <div className="grid grid-cols-2 gap-3 sm:grid-cols-4">
275 <Stat value={total} label={total === 1 ? "Project" : "Projects"} />
276 <Stat value={liveApps} label="In production" />
277 <Stat value={totals.issues} label="Open issues" />
278 <Stat value={totals.pulls} label="Pull requests in progress" />
279 </div>
280
281 {role && (
282 <section aria-labelledby="pinned">
283 <h2 id="pinned" className="text-sm font-medium text-muted">
284 Pinned
285 </h2>
286 {pinned.length > 0 ? (
287 <ul className="mt-3 grid gap-4 sm:grid-cols-2">{pinned.map((project) => card(project, true))}</ul>
288 ) : (
289 <Card asChild tone="plain" className="mt-3 flex items-center gap-2 border-dashed px-4 py-3 text-sm text-muted">
290 <p>
291 <Pin size={14} className="shrink-0 text-faint" />
292 Pin the projects you use most, from their page or from Projects, to keep them here and in your sidebar.
293 </p>
294 </Card>
295 )}
296 </section>
297 )}
298
299 {active.length > 0 && (
300 <section aria-labelledby="active">
301 <div className="flex items-center justify-between">
302 <h2 id="active" className="text-sm font-medium text-muted">
303 {role ? "Recently active" : "Projects"}
304 </h2>
305 {role && (
306 <Link to={`/new?workspace=${slug}`} className="inline-flex items-center gap-1 text-xs text-muted hover:text-fg">
307 <Plus size={13} />
308 New project
309 </Link>
310 )}
311 </div>
312 <ul className="mt-3 grid gap-4 sm:grid-cols-2">{active.map((project) => card(project, false))}</ul>
313 </section>
314 )}
315
316 <Link
317 to={`/${slug}/-/projects`}
318 prefetch="intent"
319 className="flex items-center justify-between rounded-xl border border-line bg-surface px-4 py-3 text-sm transition-colors hover:border-line-strong"
320 >
321 <span>
322 All projects <span className="tabular-nums text-muted">({total})</span>
323 </span>
324 <span className="flex items-center gap-1 text-xs text-muted">
325 Search, filter and sort
326 <ArrowRight size={13} />
327 </span>
328 </Link>
329
330 {pulls.length > 0 && (
331 <section>
332 <h2 className="text-sm font-medium text-muted">In progress</h2>
333 <Card asChild divided className="mt-3 overflow-hidden">
334 <ul>
335 {pulls.map(({ pull, slug: project, name }) => (
336 <li key={pull.id}>
337 <Link
338 prefetch="intent"
339 to={`/${slug}/${project}/pull/${pull.number}`}
340 className="flex items-center gap-3 px-4 py-3 transition-colors hover:bg-raised"
341 >
342 <PullIcon status={pull.status} />
343 <span className="min-w-0 grow">
344 <span className="block truncate font-medium">{pull.title}</span>
345 <span className="text-xs text-muted">
346 {name} <span className="font-mono">#{pull.number}</span>
347 {pull.issue != null && ` · for #${pull.issue}`} · {openedBy(pull).name}
348 </span>
349 </span>
350 <span className="hidden shrink-0 text-xs text-muted sm:block">
351 {pull.status === "draft" ? "Being worked on" : "Ready for review"}
352 </span>
353 <span className="w-14 shrink-0 text-right text-xs text-faint">
354 <TimeAgo at={pull.updatedAt} />
355 </span>
356 </Link>
357 </li>
358 ))}
359 </ul>
360 </Card>
361 </section>
362 )}
363 </>
364 )}
365 </div>
366
367 <aside className="space-y-6">
368 {role && usage && <UsageCard slug={slug} glance={usage} owner={role === "owner"} />}
369
370 {members ? (
371 <section>
372 <div className="flex items-center justify-between">
373 <h2 className="text-sm font-medium">Members</h2>
374 <Link to={role === "owner" ? `/${slug}/-/members` : `/${slug}/-/people`} className="text-xs text-muted hover:text-fg">
375 {role === "owner" ? "Manage" : "See all"}
376 </Link>
377 </div>
378 <ul className="mt-3 space-y-1.5">
379 {members.slice(0, MAX_FACES).map((member) => (
380 <li key={member.username} className="flex items-center gap-2 text-sm">
381 <Avatar name={member.username} />
382 <span className="grow truncate font-mono">{member.username}</span>
383 {member.role === "owner" && <Badge size="md">owner</Badge>}
384 </li>
385 ))}
386 </ul>
387 {members.length > MAX_FACES && <p className="mt-2 text-xs text-faint">and {members.length - MAX_FACES} more</p>}
388 </section>
389 ) : (
390 <p className="text-sm text-muted">
391 Members of this workspace build its projects. Anyone can open an issue or a pull request on a public one.
392 </p>
393 )}
394
395 {role && (
396 <Card asChild className="p-5">
397 <section>
398 <h2 className="flex items-center gap-2 font-medium">
399 <Rocket size={15} className="text-faint" />
400 Deploy on g1t.page
401 </h2>
402 <p className="mt-1.5 text-sm text-muted">
403 Off until you turn them on for a project, under its Settings. Then production builds from its default
404 branch, with a live preview for every pull request. Apps cost nothing while no one visits.
405 </p>
406 <Link
407 prefetch="intent"
408 to={`/${slug}/-/billing`}
409 className="mt-3 inline-flex items-center gap-1 text-sm text-accent hover:underline"
410 >
411 Billing and plans <ArrowRight size={13} />
412 </Link>
413 </section>
414 </Card>
415 )}
416
417 {role && (
418 <Card asChild className="p-5">
419 <section>
420 <h2 className="flex items-center gap-2 font-medium">
421 <KeyRound size={15} className="text-faint" />
422 Automate without a service account
423 </h2>
424 <p className="mt-1.5 text-sm text-muted">
425 A workspace has access tokens of its own for CI, integrations and agents. They act as the workspace,
426 not as a person.
427 </p>
428 <Link
429 prefetch="intent"
430 to={`/${slug}/-/tokens`}
431 className="mt-3 inline-flex items-center gap-1 text-sm text-accent hover:underline"
432 >
433 Access tokens <ArrowRight size={13} />
434 </Link>
435 </section>
436 </Card>
437 )}
438 </aside>
439 </div>
440 );
441}