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