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