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.

508 linesCodeBlameRaw
1/**
2 * The facts beside a repository's files (what it is, its home page and
3 * topics are the header's, on every page of it): what its files say
4 * (readme, license, security policy), how people follow it (activity,
5 * stars, watching), then its releases,
6 * deployments, packages, contributors and languages, each a section.
7 *
8 * What is read from the default branch (license, languages, contributors)
9 * is worked out in the background and kept by commit; it streams in after
10 * the files, and while it is first being worked out the panel asks for it
11 * again by itself (`about.json`).
12 */
13import { Activity, BookOpen, Eye, Package as PackageIcon, Plus, Scale, ShieldCheck, Star, Tag as TagIcon } from "lucide-react";
14import { type ReactNode, Suspense, useEffect, useState } from "react";
15import { Await, Link } from "react-router";
16
17import type { PackageSummary, Repo, RepoAbout } from "@g1t/contracts";
18
19import { compact, count, languageBar, licenseLabel } from "../lib/about";
20import { type Failed, isFailed } from "../lib/partial";
21import { Unavailable } from "./boundary";
22import { contributorPerson, profileHref, shownName } from "../lib/commit-people";
23import { CommitAvatar, CommitName } from "./commit-person";
24import { UserCard } from "./user-card";
25import { PackageIcon as EcosystemIcon } from "./package-icon";
26import { TimeAgo } from "./ui";
27import { Avatar } from "./ui/avatar";
28import { Badge } from "./ui/badge";
29import { SkeletonLine } from "./ui/skeleton";
30
31/** How many contributors' avatars the About shows. */
32const AVATARS = 14;
33/** Up to this many contributors are listed by name, one a row; more are shown as avatars. */
34const LISTED = 5;
35/** How often, and how many times, it asks again while the default branch is first read. */
36const RETRY_MS = 3_000;
37const RETRIES = 6;
38
39function encodePath(path: string): string {
40 return path.split("/").map(encodeURIComponent).join("/");
41}
42
43function Section({ title, count: n, to, children }: { title: string; count?: number; to?: string; children: ReactNode }) {
44 const heading = (
45 <>
46 {title}
47 {n != null && n > 0 && <span className="rounded-full bg-raised px-1.5 text-xs font-medium text-muted tabular-nums">{n.toLocaleString("en-US")}</span>}
48 </>
49 );
50 return (
51 <section className="border-t border-line pt-4">
52 <h2 className="mb-2.5 text-sm font-semibold">
53 {to ? (
54 <Link to={to} className="inline-flex items-center gap-1.5 hover:text-accent">
55 {heading}
56 </Link>
57 ) : (
58 <span className="inline-flex items-center gap-1.5">{heading}</span>
59 )}
60 </h2>
61 {children}
62 </section>
63 );
64}
65
66function Row({ icon, children }: { icon: ReactNode; children: ReactNode }) {
67 return (
68 <li className="flex min-w-0 items-center gap-2">
69 <span className="flex size-4 shrink-0 items-center justify-center text-faint">{icon}</span>
70 <span className="min-w-0 truncate">{children}</span>
71 </li>
72 );
73}
74
75const ROW_LINK = "hover:text-accent";
76
77/** The colored bar and each language's share. */
78export function LanguageBar({ languages }: { languages: RepoAbout["languages"] }) {
79 const bar = languageBar(languages);
80 return (
81 <div>
82 <div className="flex h-2 overflow-hidden rounded-full bg-raised" role="img" aria-label={bar.map((l) => `${l.name} ${l.percent}%`).join(", ")}>
83 {bar.map((language) => (
84 <span
85 key={language.name}
86 className="h-full border-r border-bg last:border-r-0"
87 style={{ width: `${Math.max(language.percent, 0.6)}%`, background: language.color ?? "var(--color-faint)" }}
88 />
89 ))}
90 </div>
91 <ul className="mt-2.5 flex flex-wrap gap-x-4 gap-y-1.5 text-xs">
92 {bar.map((language) => (
93 <li key={language.name} className="inline-flex items-center gap-1.5">
94 <span className="size-2 shrink-0 rounded-full" style={{ background: language.color ?? "var(--color-faint)" }} />
95 <span className="font-medium text-fg">{language.name}</span>
96 <span className="text-faint tabular-nums">{language.percent.toFixed(1)}%</span>
97 </li>
98 ))}
99 </ul>
100 </div>
101 );
102}
103
104/** The About of a repository that is read from its default branch: it asks again while that is pending. */
105function useLive(base: string, first: RepoAbout): RepoAbout {
106 const [about, setAbout] = useState(first);
107 useEffect(() => setAbout(first), [first]);
108 useEffect(() => {
109 if (!about.pending) return;
110 let tries = 0;
111 let stopped = false;
112 const timer = setInterval(async () => {
113 tries += 1;
114 if (tries > RETRIES) return clearInterval(timer);
115 try {
116 const response = await fetch(`${base}/about.json`, { headers: { accept: "application/json" } });
117 if (!response.ok || stopped) return;
118 const next = (await response.json()) as RepoAbout;
119 if (!next.pending) {
120 clearInterval(timer);
121 setAbout(next);
122 }
123 } catch {
124 // Asked again on the next tick.
125 }
126 }, RETRY_MS);
127 return () => {
128 stopped = true;
129 clearInterval(timer);
130 };
131 }, [base, about.pending]);
132 return about;
133}
134
135/** Reading the default branch for the first time. */
136function Reading() {
137 return <p className="text-xs text-faint">Reading the default branch…</p>;
138}
139
140function Releases({ base, about, canPush, tags }: { base: string; about: RepoAbout; canPush: boolean; tags: number | null }) {
141 const latest = about.latestRelease;
142 // Tags, when no release names them yet: what is there to release from.
143 const tagLine =
144 tags && about.releases === 0 ? (
145 <Link to={`${base}/tags`} className="flex items-center gap-1.5 text-sm font-medium text-fg hover:text-accent">
146 <TagIcon size={14} className="text-muted" />
147 {count(tags, "tag")}
148 </Link>
149 ) : null;
150 return (
151 <Section title="Releases" count={about.releases} to={`${base}/releases`}>
152 {latest ? (
153 <div className="text-sm">
154 <Link to={`${base}/releases/tag/${encodePath(latest.tagName)}`} className="flex items-start gap-2 hover:text-accent">
155 <TagIcon size={15} className="mt-0.5 shrink-0 text-success" />
156 <span className="min-w-0">
157 <span className="flex flex-wrap items-center gap-1.5">
158 <span className="truncate font-medium">{latest.name || latest.tagName}</span>
159 <Badge size="md">Latest</Badge>
160 </span>
161 <span className="text-xs text-faint">{latest.publishedAt && <TimeAgo at={latest.publishedAt} />}</span>
162 </span>
163 </Link>
164 {about.releases > 1 && (
165 <Link to={`${base}/releases`} className="mt-2 block text-xs text-muted hover:text-accent">
166 + {count(about.releases - 1, "release")}
167 </Link>
168 )}
169 </div>
170 ) : about.releases > 0 ? (
171 <Link to={`${base}/releases`} className="text-sm text-muted hover:text-accent">
172 {count(about.releases, "release")}
173 </Link>
174 ) : (
175 <div className="text-sm text-muted">
176 {tagLine ?? <p>No releases published</p>}
177 {canPush && (
178 <Link to={`${base}/releases/new`} className="mt-1 inline-flex items-center gap-1 text-accent hover:underline">
179 <Plus size={13} />
180 Create a new release
181 </Link>
182 )}
183 </div>
184 )}
185 </Section>
186 );
187}
188
189function Packages({ packages: found }: { packages: PackageSummary[] | Failed | null }) {
190 if (isFailed(found)) {
191 return (
192 <Section title="Packages">
193 <Unavailable compact what={found.what} />
194 </Section>
195 );
196 }
197 const packages = found;
198 return (
199 <Section title="Packages" count={packages?.length}>
200 {packages && packages.length > 0 ? (
201 <ul className="space-y-2 text-sm">
202 {packages.slice(0, 5).map((pkg) => (
203 <li key={pkg.id}>
204 <Link to={`/${pkg.workspace}/-/packages/${pkg.ecosystem}/${pkg.name}`} className="flex min-w-0 items-center gap-2 hover:text-accent">
205 <EcosystemIcon ecosystem={pkg.ecosystem} size={16} />
206 <span className="truncate">{pkg.name}</span>
207 {pkg.latest && <span className="ml-auto shrink-0 font-mono text-xs text-faint">{pkg.latest}</span>}
208 </Link>
209 </li>
210 ))}
211 </ul>
212 ) : (
213 <div className="text-sm text-muted">
214 <p>No packages published</p>
215 <a href="https://docs.g1t.sh/guides/packages/" className="mt-1 inline-flex items-center gap-1 text-accent hover:underline">
216 <PackageIcon size={13} />
217 Publish your first package
218 </a>
219 </div>
220 )}
221 </Section>
222 );
223}
224
225function Contributors({ base, about }: { base: string; about: RepoAbout }) {
226 const shown = about.topContributors.slice(0, AVATARS);
227 return (
228 <Section title="Contributors" count={about.contributors} to={`${base}/contributors`}>
229 {about.pending ? (
230 <Reading />
231 ) : shown.length === 0 ? (
232 <p className="text-sm text-muted">No commits yet</p>
233 ) : about.contributors <= LISTED ? (
234 // A few people: each by name, as a row, with how much they did.
235 <ul className="space-y-2">
236 {shown.map((contributor) => {
237 const person = contributorPerson(contributor);
238 return (
239 <li key={`${contributor.kind}:${contributor.username ?? contributor.name}`} className="flex min-w-0 items-center gap-2.5 text-sm">
240 <CommitAvatar person={person} size={28} />
241 <CommitName person={person} className="truncate font-medium text-fg" />
242 <span className="ml-auto shrink-0 text-xs text-faint tabular-nums">{count(contributor.commits, "commit")}</span>
243 </li>
244 );
245 })}
246 </ul>
247 ) : (
248 <>
249 <ul className="flex flex-wrap gap-1.5">
250 {shown.map((contributor) => {
251 // The same person, avatar, link and card as on their commits.
252 const person = contributorPerson(contributor);
253 const href = profileHref(person);
254 const label = `${shownName(person)} · ${count(contributor.commits, "commit")}`;
255 const face = <Avatar name={shownName(person)} image={person.avatar} size={30} />;
256 return (
257 <li key={`${contributor.kind}:${contributor.username ?? contributor.name}`}>
258 {person.kind === "user" || person.kind === "g1t" ? (
259 <UserCard username={person.username}>
260 {href ? (
261 <Link to={href} aria-label={label} className="block rounded-full">
262 {face}
263 </Link>
264 ) : (
265 <span tabIndex={0} aria-label={label} className="block rounded-full outline-none focus-visible:ring-2 focus-visible:ring-accent">
266 {face}
267 </span>
268 )}
269 </UserCard>
270 ) : (
271 // A coding agent, or someone not on g1t: its own card, opened from the avatar.
272 <span tabIndex={0} aria-label={label} className="block rounded-full outline-none focus-visible:ring-2 focus-visible:ring-accent">
273 <CommitAvatar person={person} size={30} />
274 </span>
275 )}
276 </li>
277 );
278 })}
279 </ul>
280 {about.contributors > shown.length && (
281 <Link to={`${base}/contributors`} className="mt-2 block text-xs text-muted hover:text-accent">
282 + {count(about.contributors - shown.length, "contributor")}
283 </Link>
284 )}
285 </>
286 )}
287 </Section>
288 );
289}
290
291/** What the default branch says and the counts, once they have streamed in. */
292function Facts({ base, gitRef, about, watchers }: { base: string; gitRef: string; about: RepoAbout | null; watchers: number | null }) {
293 const activity = (
294 <Row icon={<Activity size={15} />}>
295 <Link to={`${base}/activity`} className={ROW_LINK}>
296 Activity
297 </Link>
298 </Row>
299 );
300 if (!about) return activity;
301 const blob = (path: string) => `${base}/blob/${encodePath(gitRef)}/${encodePath(path)}`;
302 return (
303 <>
304 {about.license && (
305 <Row icon={<Scale size={15} />}>
306 <Link to={blob(about.license.path)} className={ROW_LINK}>
307 {licenseLabel(about.license)}
308 </Link>
309 </Row>
310 )}
311 {about.securityPolicy && (
312 <Row icon={<ShieldCheck size={15} />}>
313 <Link to={blob(about.securityPolicy)} className={ROW_LINK}>
314 Security policy
315 </Link>
316 </Row>
317 )}
318 {activity}
319 <Row icon={<Star size={15} />}>
320 <Link to={`${base}/stargazers`} className={ROW_LINK}>
321 <span className="font-medium text-fg">{compact(about.stars)}</span> {about.stars === 1 ? "star" : "stars"}
322 </Link>
323 </Row>
324 {watchers != null && (
325 <Row icon={<Eye size={15} />}>
326 <span>
327 <span className="font-medium text-fg">{watchers.toLocaleString("en-US")}</span> watching
328 </span>
329 </Row>
330 )}
331 </>
332 );
333}
334
335function FactsSkeleton() {
336 return (
337 <>
338 {[0, 1, 2].map((key) => (
339 <li key={key} aria-hidden="true" className="flex items-center gap-2">
340 <span className="size-4 shrink-0" />
341 <SkeletonLine barClassName="w-24" />
342 </li>
343 ))}
344 </>
345 );
346}
347
348function LoadedSections({
349 base,
350 first,
351 canPush,
352 packages,
353 tags,
354 deployments,
355}: {
356 base: string;
357 first: RepoAbout;
358 canPush: boolean;
359 packages: Promise<PackageSummary[] | Failed | null> | PackageSummary[] | Failed | null;
360 tags: Promise<number | null> | number | null;
361 deployments: ReactNode;
362}) {
363 const about = useLive(base, first);
364 return (
365 <>
366 <Suspense fallback={<Releases base={base} about={about} canPush={canPush} tags={null} />}>
367 <Await resolve={tags} errorElement={<Releases base={base} about={about} canPush={canPush} tags={null} />}>
368 {(found) => <Releases base={base} about={about} canPush={canPush} tags={found} />}
369 </Await>
370 </Suspense>
371 <Suspense fallback={<SectionSkeleton title="Packages" />}>
372 <Await resolve={packages} errorElement={<Packages packages={{ failed: true, what: "Packages" }} />}>
373 {(found) => <Packages packages={found} />}
374 </Await>
375 </Suspense>
376 {deployments}
377 <Contributors base={base} about={about} />
378 <Section title="Languages">
379 {about.pending ? (
380 <Reading />
381 ) : about.languages.length > 0 ? (
382 <LanguageBar languages={about.languages} />
383 ) : (
384 <p className="text-sm text-muted">No code to count</p>
385 )}
386 </Section>
387 </>
388 );
389}
390
391/** In place of the sections that come from the About, when it could not be read. */
392function AboutUnavailable({ what }: { what: string }) {
393 return (
394 <div className="border-t border-line pt-4">
395 <Unavailable compact what={what} />
396 </div>
397 );
398}
399
400function SectionSkeleton({ title }: { title: string }) {
401 return (
402 <section className="border-t border-line pt-4" aria-busy="true">
403 <h2 className="mb-2.5 text-sm font-semibold">{title}</h2>
404 <SkeletonLine barClassName="w-32" />
405 </section>
406 );
407}
408
409export type AboutData = {
410 /** What the default branch says, stars and releases; null when there is none to read, Failed when it could not be read. */
411 about: Promise<RepoAbout | Failed | null> | RepoAbout | Failed | null;
412 /** How many watch it; null when it could not be read. */
413 watchers: Promise<number | null> | number | null;
414 /** Packages published from it; null when there are none to read, Failed when they could not be read. */
415 packages: Promise<PackageSummary[] | Failed | null> | PackageSummary[] | Failed | null;
416 /** How many tags it has; null when they could not be counted. */
417 tags?: Promise<number | null> | number | null;
418};
419
420export function RepoAboutPanel({
421 repo,
422 gitRef,
423 readme,
424 data,
425 canPush,
426 deployments = null,
427}: {
428 repo: Repo;
429 gitRef: string;
430 readme: boolean;
431 data: AboutData;
432 canPush: boolean;
433 /** The Deployments section (deployments-panel.tsx): each environment's latest. */
434 deployments?: ReactNode;
435}) {
436 const base = `/${repo.namespace}/${repo.name}`;
437 const facts = (about: RepoAbout | null, watchers: number | null) => <Facts base={base} gitRef={gitRef} about={about} watchers={watchers} />;
438 return (
439 <aside className="space-y-4">
440 <div>
441 <ul className="space-y-2.5 text-sm text-muted">
442 {readme && (
443 <Row icon={<BookOpen size={15} />}>
444 <a href="#readme" className={ROW_LINK}>
445 Readme
446 </a>
447 </Row>
448 )}
449 <Suspense fallback={<FactsSkeleton />}>
450 <Await resolve={data.about} errorElement={facts(null, null)}>
451 {(found) => {
452 // Without it, the lines it gives (license, stars) are left
453 // out: the sections below say it could not be loaded.
454 const about = isFailed(found) ? null : found;
455 return (
456 <Suspense fallback={facts(about, null)}>
457 <Await resolve={data.watchers} errorElement={facts(about, null)}>
458 {(watchers) => facts(about, watchers)}
459 </Await>
460 </Suspense>
461 );
462 }}
463 </Await>
464 </Suspense>
465 </ul>
466 <p className="mt-4 text-xs text-faint">
467 Created <TimeAgo at={repo.createdAt} />
468 </p>
469 </div>
470 <Suspense
471 fallback={
472 <>
473 <SectionSkeleton title="Releases" />
474 <SectionSkeleton title="Packages" />
475 {deployments}
476 <SectionSkeleton title="Contributors" />
477 <SectionSkeleton title="Languages" />
478 </>
479 }
480 >
481 <Await
482 resolve={data.about}
483 errorElement={
484 <>
485 <AboutUnavailable what="Releases, contributors and languages" />
486 {deployments}
487 </>
488 }
489 >
490 {(about) =>
491 isFailed(about) ? (
492 // Releases, contributors and languages all come from it: one
493 // line in their place says so, and the rest still shows.
494 <>
495 <AboutUnavailable what={about.what} />
496 {deployments}
497 </>
498 ) : about ? (
499 <LoadedSections base={base} first={about} canPush={canPush} packages={data.packages} tags={data.tags ?? null} deployments={deployments} />
500 ) : (
501 <>{deployments}</>
502 )
503 }
504 </Await>
505 </Suspense>
506 </aside>
507 );
508}