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
473 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 { 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 { TimeAgo } from "./ui";
35import { Avatar } from "./ui/avatar";
36import { Badge } from "./ui/badge";
37import { SkeletonLine } from "./ui/skeleton";
38
39/** How many contributors' avatars the About shows. */
40const AVATARS = 14;
41/** Up to this many contributors are listed by name, one a row; more are shown as avatars. */
42const LISTED = 5;
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, tags }: { base: string; about: RepoAbout; canPush: boolean; tags: number | null }) {
149 const latest = about.latestRelease;
150 // Tags, when no release names them yet: what is there to release from.
151 const tagLine =
152 tags && about.releases === 0 ? (
153 <Link to={`${base}/tags`} className="flex items-center gap-1.5 text-sm font-medium text-fg hover:text-accent">
154 <TagIcon size={14} className="text-muted" />
155 {count(tags, "tag")}
156 </Link>
157 ) : null;
158 return (
159 <Section title="Releases" count={about.releases} to={`${base}/releases`}>
160 {latest ? (
161 <div className="text-sm">
162 <Link to={`${base}/releases/tag/${encodePath(latest.tagName)}`} className="flex items-start gap-2 hover:text-accent">
163 <TagIcon size={15} className="mt-0.5 shrink-0 text-success" />
164 <span className="min-w-0">
165 <span className="flex flex-wrap items-center gap-1.5">
166 <span className="truncate font-medium">{latest.name || latest.tagName}</span>
167 <Badge size="md">Latest</Badge>
168 </span>
169 <span className="text-xs text-faint">{latest.publishedAt && <TimeAgo at={latest.publishedAt} />}</span>
170 </span>
171 </Link>
172 {about.releases > 1 && (
173 <Link to={`${base}/releases`} className="mt-2 block text-xs text-muted hover:text-accent">
174 + {count(about.releases - 1, "release")}
175 </Link>
176 )}
177 </div>
178 ) : about.releases > 0 ? (
179 <Link to={`${base}/releases`} className="text-sm text-muted hover:text-accent">
180 {count(about.releases, "release")}
181 </Link>
182 ) : (
183 <div className="text-sm text-muted">
184 {tagLine ?? <p>No releases published</p>}
185 {canPush && (
186 <Link to={`${base}/releases/new`} className="mt-1 inline-flex items-center gap-1 text-accent hover:underline">
187 <Plus size={13} />
188 Create a new release
189 </Link>
190 )}
191 </div>
192 )}
193 </Section>
194 );
195}
196
197function Packages({ packages }: { packages: PackageSummary[] | null }) {
198 return (
199 <Section title="Packages" count={packages?.length}>
200 {packages && packages.length > 0 ? (
201 <ul className="space-y-2 text-sm">
202 {packages.slice(0, 5).map((pkg) => (
203 <li key={pkg.id}>
204 <Link to={`/${pkg.workspace}/-/packages/${pkg.ecosystem}/${pkg.name}`} className="flex min-w-0 items-center gap-2 hover:text-accent">
205 <EcosystemIcon ecosystem={pkg.ecosystem} size={16} />
206 <span className="truncate">{pkg.name}</span>
207 {pkg.latest && <span className="ml-auto shrink-0 font-mono text-xs text-faint">{pkg.latest}</span>}
208 </Link>
209 </li>
210 ))}
211 </ul>
212 ) : (
213 <div className="text-sm text-muted">
214 <p>No packages published</p>
215 <a href="https://docs.g1t.sh/guides/packages/" className="mt-1 inline-flex items-center gap-1 text-accent hover:underline">
216 <PackageIcon size={13} />
217 Publish your first package
218 </a>
219 </div>
220 )}
221 </Section>
222 );
223}
224
225function Contributors({ base, about }: { base: string; about: RepoAbout }) {
226 const shown = about.topContributors.slice(0, AVATARS);
227 return (
228 <Section title="Contributors" count={about.contributors} to={`${base}/contributors`}>
229 {about.pending ? (
230 <Reading />
231 ) : shown.length === 0 ? (
232 <p className="text-sm text-muted">No commits yet</p>
233 ) : about.contributors <= LISTED ? (
234 // A few people: each by name, as a row, with how much they did.
235 <ul className="space-y-2">
236 {shown.map((contributor) => {
237 const person = contributorPerson(contributor);
238 return (
239 <li key={`${contributor.kind}:${contributor.username ?? contributor.name}`} className="flex min-w-0 items-center gap-2.5 text-sm">
240 <CommitAvatar person={person} size={28} />
241 <CommitName person={person} className="truncate font-medium text-fg" />
242 <span className="ml-auto shrink-0 text-xs text-faint tabular-nums">{count(contributor.commits, "commit")}</span>
243 </li>
244 );
245 })}
246 </ul>
247 ) : (
248 <>
249 <ul className="flex flex-wrap gap-1.5">
250 {shown.map((contributor) => {
251 // The same person, avatar, link and card as on their commits.
252 const person = contributorPerson(contributor);
253 const href = profileHref(person);
254 const label = `${shownName(person)} · ${count(contributor.commits, "commit")}`;
255 const face = <Avatar name={shownName(person)} image={person.avatar} size={30} />;
256 return (
257 <li key={`${contributor.kind}:${contributor.username ?? contributor.name}`}>
258 {person.kind === "user" || person.kind === "g1t" ? (
259 <UserCard username={person.username}>
260 {href ? (
261 <Link to={href} aria-label={label} className="block rounded-full">
262 {face}
263 </Link>
264 ) : (
265 <span tabIndex={0} aria-label={label} className="block rounded-full outline-none focus-visible:ring-2 focus-visible:ring-accent">
266 {face}
267 </span>
268 )}
269 </UserCard>
270 ) : (
271 // A coding agent, or someone not on g1t: its own card, opened from the avatar.
272 <span tabIndex={0} aria-label={label} className="block rounded-full outline-none focus-visible:ring-2 focus-visible:ring-accent">
273 <CommitAvatar person={person} size={30} />
274 </span>
275 )}
276 </li>
277 );
278 })}
279 </ul>
280 {about.contributors > shown.length && (
281 <Link to={`${base}/contributors`} className="mt-2 block text-xs text-muted hover:text-accent">
282 + {count(about.contributors - shown.length, "contributor")}
283 </Link>
284 )}
285 </>
286 )}
287 </Section>
288 );
289}
290
291/** What the default branch says and the counts, once they have streamed in. */
292function Facts({ base, gitRef, about, watchers }: { base: string; gitRef: string; about: RepoAbout | null; watchers: number | null }) {
293 const activity = (
294 <Row icon={<Activity size={15} />}>
295 <Link to={`${base}/activity`} className={ROW_LINK}>
296 Activity
297 </Link>
298 </Row>
299 );
300 if (!about) return activity;
301 const blob = (path: string) => `${base}/blob/${encodePath(gitRef)}/${encodePath(path)}`;
302 return (
303 <>
304 {about.license && (
305 <Row icon={<Scale size={15} />}>
306 <Link to={blob(about.license.path)} className={ROW_LINK}>
307 {licenseLabel(about.license)}
308 </Link>
309 </Row>
310 )}
311 {about.securityPolicy && (
312 <Row icon={<ShieldCheck size={15} />}>
313 <Link to={blob(about.securityPolicy)} className={ROW_LINK}>
314 Security policy
315 </Link>
316 </Row>
317 )}
318 {activity}
319 <Row icon={<Star size={15} />}>
320 <Link to={`${base}/stargazers`} className={ROW_LINK}>
321 <span className="font-medium text-fg">{compact(about.stars)}</span> {about.stars === 1 ? "star" : "stars"}
322 </Link>
323 </Row>
324 {watchers != null && (
325 <Row icon={<Eye size={15} />}>
326 <span>
327 <span className="font-medium text-fg">{watchers.toLocaleString("en-US")}</span> watching
328 </span>
329 </Row>
330 )}
331 </>
332 );
333}
334
335function FactsSkeleton() {
336 return (
337 <>
338 {[0, 1, 2].map((key) => (
339 <li key={key} aria-hidden="true" className="flex items-center gap-2">
340 <span className="size-4 shrink-0" />
341 <SkeletonLine barClassName="w-24" />
342 </li>
343 ))}
344 </>
345 );
346}
347
348function LoadedSections({
349 base,
350 first,
351 canPush,
352 packages,
353 tags,
354 deployments,
355}: {
356 base: string;
357 first: RepoAbout;
358 canPush: boolean;
359 packages: Promise<PackageSummary[] | null> | PackageSummary[] | null;
360 tags: Promise<number | null> | number | null;
361 deployments: ReactNode;
362}) {
363 const about = useLive(base, first);
364 return (
365 <>
366 <Suspense fallback={<Releases base={base} about={about} canPush={canPush} tags={null} />}>
367 <Await resolve={tags} errorElement={<Releases base={base} about={about} canPush={canPush} tags={null} />}>
368 {(found) => <Releases base={base} about={about} canPush={canPush} tags={found} />}
369 </Await>
370 </Suspense>
371 <Suspense fallback={<SectionSkeleton title="Packages" />}>
372 <Await resolve={packages} errorElement={<Packages packages={null} />}>
373 {(found) => <Packages packages={found} />}
374 </Await>
375 </Suspense>
376 {deployments}
377 <Contributors base={base} about={about} />
378 <Section title="Languages">
379 {about.pending ? <Reading /> : about.languages.length > 0 ? <LanguageBar languages={about.languages} /> : <p className="text-sm text-muted">No code to count</p>}
380 </Section>
381 </>
382 );
383}
384
385function SectionSkeleton({ title }: { title: string }) {
386 return (
387 <section className="border-t border-line pt-4" aria-busy="true">
388 <h2 className="mb-2.5 text-sm font-semibold">{title}</h2>
389 <SkeletonLine barClassName="w-32" />
390 </section>
391 );
392}
393
394export type AboutData = {
395 /** What the default branch says, stars and releases; null when it could not be read. */
396 about: Promise<RepoAbout | null> | RepoAbout | null;
397 /** How many watch it; null when it could not be read. */
398 watchers: Promise<number | null> | number | null;
399 /** Packages published from it; null when they could not be read. */
400 packages: Promise<PackageSummary[] | null> | PackageSummary[] | null;
401 /** How many tags it has; null when they could not be counted. */
402 tags?: Promise<number | null> | number | null;
403};
404
405export function RepoAboutPanel({
406 repo,
407 gitRef,
408 readme,
409 data,
410 canPush,
411 deployments = null,
412}: {
413 repo: Repo;
414 gitRef: string;
415 readme: boolean;
416 data: AboutData;
417 canPush: boolean;
418 /** The Deployments section (deployments-panel.tsx): each environment's latest. */
419 deployments?: ReactNode;
420}) {
421 const base = `/${repo.namespace}/${repo.name}`;
422 const facts = (about: RepoAbout | null, watchers: number | null) => <Facts base={base} gitRef={gitRef} about={about} watchers={watchers} />;
423 return (
424 <aside className="space-y-4">
425 <div>
426 <ul className="space-y-2.5 text-sm text-muted">
427 {readme && (
428 <Row icon={<BookOpen size={15} />}>
429 <a href="#readme" className={ROW_LINK}>
430 Readme
431 </a>
432 </Row>
433 )}
434 <Suspense fallback={<FactsSkeleton />}>
435 <Await resolve={data.about} errorElement={facts(null, null)}>
436 {(about) => (
437 <Suspense fallback={facts(about, null)}>
438 <Await resolve={data.watchers} errorElement={facts(about, null)}>
439 {(watchers) => facts(about, watchers)}
440 </Await>
441 </Suspense>
442 )}
443 </Await>
444 </Suspense>
445 </ul>
446 <p className="mt-4 text-xs text-faint">
447 Created <TimeAgo at={repo.createdAt} />
448 </p>
449 </div>
450 <Suspense
451 fallback={
452 <>
453 <SectionSkeleton title="Releases" />
454 <SectionSkeleton title="Packages" />
455 {deployments}
456 <SectionSkeleton title="Contributors" />
457 <SectionSkeleton title="Languages" />
458 </>
459 }
460 >
461 <Await resolve={data.about} errorElement={<>{deployments}</>}>
462 {(about) =>
463 about ? (
464 <LoadedSections base={base} first={about} canPush={canPush} packages={data.packages} tags={data.tags ?? null} deployments={deployments} />
465 ) : (
466 <>{deployments}</>
467 )
468 }
469 </Await>
470 </Suspense>
471 </aside>
472 );
473}