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.

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