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