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.

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