Pick any line to see why it is the way it is: the commit, the pull request and issue it came from, and what the agent was thinking.
| Merge branch 'main' into worktree-agent-a69aeabc4b0deeb97 | 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 | */ | |
| 12 | import { | |
| 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"; | |
| 24 | import { type ReactNode, Suspense, useEffect, useState } from "react"; | |
| 25 | import { Await, Link } from "react-router"; | |
| 26 | ||
| 27 | import type { PackageSummary, Repo, RepoAbout } from "@g1t/contracts"; | |
| 28 | ||
| Merge commit authors and hovercards: commits show their accounts, every name opens a card (repos 0016) | 29 | import { compact, count, languageBar, licenseLabel } from "../lib/about"; |
| 30 | import { contributorPerson, profileHref, shownName } from "../lib/commit-people"; | |
| 31 | import { CommitAvatar } from "./commit-person"; | |
| 32 | import { UserCard } from "./user-card"; | |
| Merge branch 'main' into worktree-agent-a69aeabc4b0deeb97 | 33 | import { PackageIcon as EcosystemIcon } from "./package-icon"; |
| 34 | import { Topics } from "./topics"; | |
| 35 | import { Avatar, Pill, TimeAgo } from "./ui"; | |
| 36 | import { Hint } from "./ui/hint"; | |
| 37 | import { SkeletonLine } from "./ui/skeleton"; | |
| 38 | ||
| 39 | /** How many contributors' avatars the About shows. */ | |
| 40 | const AVATARS = 14; | |
| 41 | /** How often, and how many times, it asks again while the default branch is first read. */ | |
| 42 | const RETRY_MS = 3_000; | |
| 43 | const RETRIES = 6; | |
| 44 | ||
| 45 | function encodePath(path: string): string { | |
| 46 | return path.split("/").map(encodeURIComponent).join("/"); | |
| 47 | } | |
| 48 | ||
| 49 | function 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 | ||
| 72 | function 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 | ||
| 81 | const ROW_LINK = "hover:text-accent"; | |
| 82 | ||
| 83 | /** The colored bar and each language's share. */ | |
| 84 | export 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. */ | |
| 111 | function 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. */ | |
| 142 | function Reading() { | |
| 143 | return <p className="text-xs text-faint">Reading the default branch…</p>; | |
| 144 | } | |
| 145 | ||
| 146 | function 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 | ||
| 187 | function 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 | ||
| 215 | function 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) => { | |
| Merge commit authors and hovercards: commits show their accounts, every name opens a card (repos 0016) | 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} />; | |
| Merge branch 'main' into worktree-agent-a69aeabc4b0deeb97 | 232 | return ( |
| Merge commit authors and hovercards: commits show their accounts, every name opens a card (repos 0016) | 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} /> | |
| Merge branch 'main' into worktree-agent-a69aeabc4b0deeb97 | 251 | </span> |
| Merge commit authors and hovercards: commits show their accounts, every name opens a card (repos 0016) | 252 | </Hint> |
| 253 | )} | |
| Merge branch 'main' into worktree-agent-a69aeabc4b0deeb97 | 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. */ | |
| 270 | function 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 | ||
| 313 | function 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 | ||
| 326 | function 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 | ||
| 357 | function 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 | ||
| 366 | export 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 | ||
| 375 | export 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 | } |
This file's history is long; its oldest lines are credited to the oldest commit read.