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.

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