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.

558 linesCodeBlameRaw
1import { BookOpen, Check, ChevronDown, Code2, CornerLeftUp, File, FileArchive, Folder, FolderGit2, GitBranch, History, LoaderCircle, Search, SquareTerminal, Tag as TagIcon } from "lucide-react";
2import { type ReactNode, Suspense } from "react";
3import { Await, Form, Link } from "react-router";
4
5import type { BlobView as Blob, Branch, TreeView as RawTree } from "@g1t/contracts";
6
7import type { FileCommits, ShownBlame, ShownCommit } from "../lib/commit-people";
8import { CommitAvatars, CommitNames } from "./commit-person";
9import { CommitText } from "./commit-message";
10
11import type { DeploymentEnvironments } from "@g1t/contracts";
12
13import { BlameView } from "./blame-view";
14import { DeploymentsPanel } from "./deployments-panel";
15import { CodeLines } from "./code-lines";
16
17import { useAddresses } from "../lib/addresses";
18import { type Failed, isFailed } from "../lib/partial";
19import { Boundary, Unavailable } from "./boundary";
20import { isImagePath } from "../lib/usercontent";
21import { CloneBox } from "./clone-box";
22import { type ChecksSource, CommitChecksBadge } from "./commit-checks";
23import { type AboutData, RepoAboutPanel } from "./repo-about";
24import { Markdown } from "./markdown";
25import { CopyLine, TimeAgo, notACredential } from "./ui";
26import { Card } from "./ui/card";
27import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuLabel, DropdownMenuTrigger } from "./ui/dropdown-menu";
28import { Popover, PopoverContent, PopoverTrigger } from "./ui/popover";
29import { Hint } from "./ui/hint";
30import { SkeletonLine } from "./ui/skeleton";
31
32function encodePath(path: string): string {
33 return path.split("/").map(encodeURIComponent).join("/");
34}
35
36function Breadcrumbs({
37 base,
38 repo,
39 gitRef,
40 path,
41}: {
42 base: string;
43 repo: string;
44 gitRef: string;
45 path: string;
46}) {
47 const segments = path.split("/").filter(Boolean);
48 if (segments.length === 0) return null;
49 // On a phone, one line: the folders between the repository and the last
50 // two fold into an ellipsis that opens the deepest of them.
51 const folded = segments.length - 2;
52 const slash = <span className="mx-1.5 shrink-0 text-faint">/</span>;
53 return (
54 <nav aria-label="Path" className="flex min-w-0 basis-full items-center font-mono text-sm whitespace-nowrap text-muted sm:basis-auto sm:flex-wrap sm:whitespace-normal">
55 <Link to={`${base}/tree/${gitRef}`} className="shrink-0 py-2 text-accent hover:underline sm:py-0">
56 {repo}
57 </Link>
58 {folded > 0 && (
59 <span className="flex shrink-0 items-center sm:hidden">
60 {slash}
61 <Link
62 to={`${base}/tree/${gitRef}/${encodePath(segments.slice(0, folded).join("/"))}`}
63 aria-label={`Up to ${segments.slice(0, folded).join("/")}`}
64 className="rounded px-1 py-2 text-accent hover:underline"
65 >
66 …
67 </Link>
68 </span>
69 )}
70 {segments.map((segment, i) => {
71 const to = encodePath(segments.slice(0, i + 1).join("/"));
72 const last = i === segments.length - 1;
73 return (
74 <span
75 key={to}
76 className={`${i < folded ? "hidden sm:flex" : "flex"} items-center ${last ? "min-w-0" : i === segments.length - 2 ? "min-w-0 shrink-2" : "shrink-0"}`}
77 >
78 {slash}
79 {last ? (
80 <span className="truncate font-medium text-fg">{segment}</span>
81 ) : (
82 <Link
83 to={`${base}/tree/${gitRef}/${to}`}
84 className="truncate py-2 text-accent hover:underline sm:py-0"
85 >
86 {segment}
87 </Link>
88 )}
89 </span>
90 );
91 })}
92 </nav>
93 );
94}
95
96/** The bar above a file listing: the latest commit, its checks, and the way to the history. */
97/** A tree as the page gets it: its latest commit with its people. */
98export type Tree = Omit<RawTree, "head"> & { head: ShownCommit | null };
99
100function CommitBar({ commit, base, checks }: { commit: ShownCommit; base: string; checks?: ChecksSource }) {
101 return (
102 <div className="flex items-center gap-2.5 border-b border-line bg-surface px-3 py-2.5 text-sm sm:gap-3 sm:px-4">
103 <CommitAvatars commit={commit} />
104 <span className="shrink-0 font-medium">
105 <CommitNames commit={commit} />
106 </span>
107 <span className="min-w-0 truncate text-muted">
108 <CommitText text={commit.message.split("\n")[0] ?? ""} base={base} to={`${base}/commit/${commit.hash}`} linkClassName="hover:text-fg hover:underline" />
109 </span>
110 <CommitChecksBadge checks={checks} sha={commit.hash} className="-ml-1.5" />
111 <span className="ml-auto flex shrink-0 items-center gap-3 text-xs text-faint sm:gap-4">
112 <Link to={`${base}/commit/${commit.hash}`} className="hidden font-mono hover:text-fg sm:inline">
113 {commit.hash.slice(0, 7)}
114 </Link>
115 <span className="hidden sm:inline">
116 <TimeAgo at={commit.authoredAt} />
117 </span>
118 <Link to={`${base}/commits`} aria-label="History" className="-my-2 inline-flex items-center gap-1.5 py-2 font-medium text-muted hover:text-fg">
119 <History size={14} />
120 <span className="hidden sm:inline">History</span>
121 </Link>
122 </span>
123 </div>
124 );
125}
126
127const BAR_BUTTON =
128 "inline-flex h-8 shrink-0 items-center gap-1.5 rounded-md border border-line bg-surface px-3 text-sm transition-colors hover:border-line-strong hover:bg-raised data-[state=open]:border-line-strong";
129
130/** Which branch is shown, and the others to switch to, at the same path. */
131function BranchMenu({
132 base,
133 gitRef,
134 path,
135 branches,
136 failed = null,
137 view = "tree",
138}: {
139 base: string;
140 gitRef: string;
141 path: string;
142 branches: Branch[];
143 /** The branch list could not be loaded: the menu says so under the one shown. */
144 failed?: Failed | null;
145 /** Whether `path` is a folder or a file. */
146 view?: "tree" | "blob";
147}) {
148 const rest = path ? `/${encodePath(path)}` : "";
149 return (
150 <DropdownMenu>
151 <DropdownMenuTrigger className={`${BAR_BUTTON} max-w-56 font-medium`}>
152 <GitBranch size={14} className="text-faint" />
153 <span className="truncate font-mono text-[0.8125rem]">{gitRef}</span>
154 <ChevronDown size={13} className="text-faint" />
155 </DropdownMenuTrigger>
156 <DropdownMenuContent align="start" className="max-h-80 w-64 overflow-y-auto">
157 <DropdownMenuLabel>Switch branches</DropdownMenuLabel>
158 {branches.map((branch) => (
159 <DropdownMenuItem key={branch.name} asChild>
160 <Link to={`${base}/${view}/${encodePath(branch.name)}${rest}`}>
161 <Check className={branch.name === gitRef ? "" : "invisible"} />
162 <span className="truncate font-mono text-[0.8125rem]">{branch.name}</span>
163 </Link>
164 </DropdownMenuItem>
165 ))}
166 {failed && (
167 <div className="px-2 py-1.5">
168 <Unavailable compact what={failed.what} />
169 </div>
170 )}
171 </DropdownMenuContent>
172 </DropdownMenu>
173 );
174}
175
176/** Searches this repository's code on the search page, where it can be widened to all of g1t. */
177function SearchCode({ repo }: { repo: string }) {
178 return (
179 <Form action="/search" role="search" className="relative ml-auto min-w-0 flex-1 basis-40 sm:w-64 sm:flex-none sm:basis-auto">
180 <Search size={14} className="pointer-events-none absolute top-1/2 left-2.5 -translate-y-1/2 text-faint" />
181 <input
182 name="q"
183 {...notACredential()}
184 placeholder="Search code"
185 aria-label={`Search ${repo}`}
186 className="h-9 w-full rounded-md border border-line bg-surface pr-3 pl-8 text-sm outline-none transition-colors placeholder:text-faint hover:border-line-strong focus:border-accent-dim sm:h-8"
187 />
188 <input type="hidden" name="repo" value={repo} />
189 <input type="hidden" name="type" value="code" />
190 </Form>
191 );
192}
193
194/** The one button for getting the code: clone over HTTPS or SSH, hand it to an agent, or download it. */
195function CodeButton({ path, gitRef }: { path: string; gitRef: string }) {
196 return (
197 <Popover>
198 <PopoverTrigger className="inline-flex h-8 shrink-0 items-center gap-1.5 rounded-md bg-accent px-3 text-sm font-medium text-bg transition-colors hover:bg-accent/90">
199 <Code2 size={15} />
200 Code
201 <ChevronDown size={13} />
202 </PopoverTrigger>
203 <PopoverContent align="end" className="w-[min(24rem,calc(100vw-2rem))] p-0">
204 <div className="p-4">
205 <h2 className="mb-3 flex items-center gap-2 text-sm font-medium">
206 <SquareTerminal size={15} className="text-faint" />
207 Clone
208 </h2>
209 <CloneBox path={path} />
210 </div>
211 <div className="border-t border-line p-1.5">
212 {/* A plain link: the browser downloads it, not the router. */}
213 <a
214 href={`/${path}/archive/${encodePath(gitRef)}.zip`}
215 download
216 className="flex items-center gap-2.5 rounded-md px-2.5 py-2 text-sm text-fg/90 transition-colors hover:bg-line hover:text-fg"
217 >
218 <FileArchive size={15} className="text-faint" />
219 Download ZIP
220 </a>
221 </div>
222 </PopoverContent>
223 </Popover>
224 );
225}
226
227/** Above the files: the branch and, at the root, how many there are, search and Code; below it, where you are. */
228function CodeBar({
229 base,
230 repo,
231 gitRef,
232 path,
233 branches,
234}: {
235 base: string;
236 repo: { namespace: string; name: string };
237 gitRef: string;
238 path: string;
239 branches: Branch[] | Failed | null;
240}) {
241 const full = `${repo.namespace}/${repo.name}`;
242 const failed = isFailed(branches) ? branches : null;
243 const known = isFailed(branches) ? null : branches;
244 const list = known && known.length > 0 ? known : [{ name: gitRef, hash: "" }];
245 return (
246 <div className="mb-4 flex flex-wrap items-center gap-x-3 gap-y-2">
247 <BranchMenu base={base} gitRef={gitRef} path={path} branches={list} failed={failed} />
248 {path ? (
249 <Breadcrumbs base={base} repo={repo.name} gitRef={gitRef} path={path} />
250 ) : (
251 <>
252 {known && (
253 <Link to={`${base}/branches`} className="inline-flex items-center gap-1.5 text-sm text-muted hover:text-accent">
254 <GitBranch size={14} className="text-faint" />
255 <span className="font-medium text-fg">{known.length}</span>
256 {known.length === 1 ? "branch" : "branches"}
257 </Link>
258 )}
259 <Link to={`${base}/tags`} className="inline-flex items-center gap-1.5 text-sm text-muted hover:text-accent">
260 <TagIcon size={14} className="text-faint" />
261 Tags
262 </Link>
263 <SearchCode repo={full} />
264 <CodeButton path={full} gitRef={gitRef} />
265 </>
266 )}
267 </div>
268 );
269}
270
271/**
272 * The files of a directory, each with the commit that last changed it.
273 * `last` is undefined while those are still being read (the column holds
274 * its place), and null when they could not be.
275 */
276function FileRows({
277 base,
278 gitRef,
279 prefix,
280 entries,
281 last,
282}: {
283 base: string;
284 gitRef: string;
285 prefix: string;
286 entries: Tree["entries"];
287 last: FileCommits | null | undefined;
288}) {
289 const byName = new Map((last?.entries ?? []).map((entry) => [entry.name, entry.commit]));
290 // Inside a directory, `..` leads one level up: to its parent, or the
291 // repository's top from a directory at the top.
292 const parent = prefix ? prefix.slice(0, -1).split("/").slice(0, -1).join("/") : null;
293 return (
294 // Busy while each entry's last commit is on its way; its cells keep their size.
295 <ul aria-busy={last === undefined || undefined} className="divide-y divide-line text-sm">
296 {parent !== null && (
297 <li className="px-4 transition-colors hover:bg-surface">
298 <Link
299 to={parent ? `${base}/tree/${gitRef}/${parent}` : `${base}/tree/${gitRef}`}
300 aria-label="Parent directory"
301 className="flex min-h-11 min-w-0 items-center gap-3 py-2 hover:text-accent hover:underline sm:min-h-0"
302 >
303 <CornerLeftUp size={15} className="shrink-0 text-faint" />
304 <span className="font-mono text-[0.8125rem]">..</span>
305 </Link>
306 </li>
307 )}
308 {entries.map((entry) => {
309 const isTree = entry.kind === "tree";
310 const Icon = isTree ? Folder : entry.kind === "gitlink" ? FolderGit2 : File;
311 const commit = byName.get(entry.name);
312 return (
313 <li key={entry.name} className="grid grid-cols-[minmax(0,1fr)_auto] items-center gap-x-4 px-4 transition-colors hover:bg-surface sm:grid-cols-[minmax(0,14rem)_minmax(0,1fr)_auto] lg:grid-cols-[minmax(0,18rem)_minmax(0,1fr)_auto]">
314 {/* The whole row's height is the link's: a 44px target on a phone. */}
315 <Link
316 to={`${base}/${isTree ? "tree" : "blob"}/${gitRef}/${prefix}${encodeURIComponent(entry.name)}`}
317 className="flex min-h-11 min-w-0 items-center gap-3 py-2 hover:text-accent hover:underline sm:min-h-0"
318 >
319 <Icon size={15} className={`shrink-0 ${isTree ? "text-accent-dim" : "text-faint"}`} />
320 <span className="truncate font-mono text-[0.8125rem]">{entry.name}</span>
321 </Link>
322 <span className="hidden min-w-0 sm:block">
323 {commit ? (
324 <Hint label={commit.message.split("\n")[0]}>
325 <span className="block animate-fade-in truncate text-muted">
326 <CommitText text={commit.message.split("\n")[0] ?? ""} base={base} to={`${base}/commit/${commit.hash}`} linkClassName="hover:text-fg hover:underline" />
327 </span>
328 </Hint>
329 ) : last === undefined ? (
330 <SkeletonLine width="10rem" barClassName="max-w-full" />
331 ) : null}
332 </span>
333 <span className="text-right text-xs whitespace-nowrap text-faint">
334 {commit ? <span className="animate-fade-in"><TimeAgo at={commit.authoredAt} /></span> : last === undefined ? <SkeletonLine className="justify-end" barClassName="w-12" /> : null}
335 </span>
336 </li>
337 );
338 })}
339 </ul>
340 );
341}
342
343export function TreeView({
344 tree,
345 branches = null,
346 lastCommits = null,
347 checks = null,
348 about = null,
349 canPush = false,
350 deployments = null,
351 fork = null,
352}: {
353 tree: Tree;
354 /** Above the files of a fork: where it came from, Sync fork and Contribute (components/fork-bar.tsx). */
355 fork?: ReactNode;
356 /** For the branch menu; Failed when it could not be loaded. */
357 branches?: Branch[] | Failed | null;
358 /** Each entry's last commit, streamed in after the list. */
359 lastCommits?: Promise<FileCommits | null> | null;
360 /** The latest commit's checks, streamed in. */
361 checks?: ChecksSource;
362 /** The About beside the files, at the root: streamed in after them. */
363 about?: AboutData | null;
364 /** Whether the viewer may push, for the About's "Create a new release". */
365 canPush?: boolean;
366 /** The repository's environments, for the About's Deployments section; Failed when they could not be loaded. */
367 deployments?: DeploymentEnvironments | Failed | null;
368}) {
369 const { repo, ref, path, head, entries, readme } = tree;
370 const base = `/${repo.namespace}/${repo.name}`;
371 const prefix = path ? `${encodePath(path)}/` : "";
372 const cloneUrl = `${useAddresses().site}${base}.git`;
373
374 if (!head) {
375 return (
376 <Card className="mx-auto max-w-2xl p-8">
377 <h2 className="text-lg font-semibold tracking-tight">
378 This repository is empty
379 </h2>
380 <p className="mt-1 text-sm text-muted">
381 Push an existing project to it. Use an access token as the password.
382 </p>
383 <div className="mt-5 space-y-2">
384 <CopyLine prompt text={`git remote add g1t ${cloneUrl}`} />
385 <CopyLine prompt text={`git push -u g1t ${ref}`} />
386 </div>
387 </Card>
388 );
389 }
390
391 return (
392 <div className={path ? "" : "grid gap-8 lg:grid-cols-[1fr_17rem]"}>
393 <div className="min-w-0">
394 <CodeBar base={base} repo={repo} gitRef={ref} path={path} branches={branches} />
395 {fork}
396 <Card tone="plain" className="overflow-hidden">
397 <CommitBar commit={head} base={base} checks={checks} />
398 {lastCommits ? (
399 <Suspense fallback={<FileRows base={base} gitRef={ref} prefix={prefix} entries={entries} last={undefined} />}>
400 <Await resolve={lastCommits} errorElement={<FileRows base={base} gitRef={ref} prefix={prefix} entries={entries} last={null} />}>
401 {(last) => <FileRows base={base} gitRef={ref} prefix={prefix} entries={entries} last={last} />}
402 </Await>
403 </Suspense>
404 ) : (
405 <FileRows base={base} gitRef={ref} prefix={prefix} entries={entries} last={null} />
406 )}
407 </Card>
408
409 {readme?.text != null && (
410 // A README that fails to draw says so in its place; the files stay.
411 <Boundary what="The README">
412 <Card asChild tone="plain" className="mt-6 scroll-mt-20 overflow-hidden">
413 <section id="readme">
414 <h2 className="flex items-center gap-2 border-b border-line bg-surface px-4 py-2.5 text-sm font-medium">
415 <BookOpen size={15} className="text-faint" />
416 {readme.name}
417 </h2>
418 <div className="p-4 sm:p-6">
419 {/\.(md|markdown)$/i.test(readme.name) ? (
420 <Markdown
421 source={readme.text}
422 repo={{ namespace: repo.namespace, name: repo.name }}
423 // Relative links in a README point into the repository,
424 // and its pictures at the files of the commit shown.
425 base={`/${repo.namespace}/${repo.name}/blob/${ref}${path ? `/${path}` : ""}`}
426 rawBase={`/${repo.namespace}/${repo.name}/raw/${head.hash}${path ? `/${encodePath(path)}` : ""}`}
427 />
428 ) : (
429 <pre className="whitespace-pre-wrap text-sm"><code>{readme.text}</code></pre>
430 )}
431 </div>
432 </section>
433 </Card>
434 </Boundary>
435 )}
436 </div>
437
438 {!path && (
439 <Boundary what="About" fallback={<Unavailable compact what="About" />}>
440 <RepoAboutPanel
441 repo={repo}
442 gitRef={ref}
443 readme={Boolean(readme)}
444 data={about ?? { about: null, watchers: null, packages: null, tags: null }}
445 canPush={canPush}
446 deployments={
447 isFailed(deployments) ? (
448 <section className="border-t border-line pt-4">
449 <h2 className="mb-2.5 text-sm font-semibold">Deployments</h2>
450 <Unavailable compact what={deployments.what} />
451 </section>
452 ) : (
453 <DeploymentsPanel base={base} summary={deployments} className="border-t border-line pt-4" />
454 )
455 }
456 />
457 </Boundary>
458 )}
459 </div>
460 );
461}
462
463export function BlobView({
464 blob,
465 html,
466 blame,
467 branches = null,
468 notice,
469 marked,
470}: {
471 /** For the branch menu; it shows the current branch alone without them, and says so when they failed. */
472 branches?: Branch[] | Failed | null;
473 /** Said above the file, such as what is wrong with it. */
474 notice?: ReactNode;
475 /** Lines to mark as having a problem, 1-based. */
476 marked?: readonly number[];
477 blob: Blob;
478 /** Syntax-highlighted HTML per line, when the language is known. */
479 html: string[] | null;
480 /** Shown instead of the plain file when asked for, with HTML per line;
481 * "waiting" while it streams in, "failed" when it could not be had (the
482 * plain file shows meanwhile). */
483 blame?: { blame: ShownBlame; lines: string[] | null } | "waiting" | "failed" | null;
484}) {
485 const { repo, ref, path, size, text } = blob;
486 const lines = text?.replace(/\n$/, "").split("\n");
487 const base = `/${repo.namespace}/${repo.name}`;
488 // The file as it is, on the usercontent origin (routes/repo/raw.ts).
489 const raw = `${base}/raw/${encodeURIComponent(ref)}/${encodePath(path)}`;
490 const toggle = (label: string, on: boolean, search: string) => (
491 <Link
492 to={{ search }}
493 // Blame is costly to work out and adds nothing to a search index.
494 rel={search ? "nofollow" : undefined}
495 preventScrollReset
496 className={`rounded px-2.5 py-1.5 transition-colors sm:px-2 sm:py-0.5 ${on ? "bg-raised text-fg" : "hover:text-fg"}`}
497 >
498 {label}
499 </Link>
500 );
501 return (
502 <div>
503 <div className="mb-4 flex flex-wrap items-center gap-x-3 gap-y-2">
504 <BranchMenu
505 base={base}
506 gitRef={ref}
507 path={path}
508 branches={!isFailed(branches) && branches && branches.length > 0 ? branches : [{ name: ref, hash: "" }]}
509 failed={isFailed(branches) ? branches : null}
510 view="blob"
511 />
512 <Breadcrumbs base={base} repo={repo.name} gitRef={ref} path={path} />
513 </div>
514 {notice}
515 <Card tone="plain" className="overflow-hidden">
516 <div className="flex items-center gap-3 border-b border-line bg-surface px-3 py-2 text-xs text-muted sm:px-4 sm:py-2.5">
517 {lines && <span>{lines.length.toLocaleString("en-US")} lines</span>}
518 {blame === "waiting" && (
519 <span className="flex items-center gap-1.5" role="status">
520 <LoaderCircle size={12} className="animate-spin" aria-hidden />
521 Working out who changed each line
522 </span>
523 )}
524 {blame === "failed" && <span role="status">Blame could not be worked out; try again in a moment</span>}
525 <span className="hidden min-[400px]:inline">{size.toLocaleString("en-US")} bytes</span>
526 <span className="ml-auto flex items-center gap-2">
527 {lines && (
528 <span className="flex rounded-md border border-line p-0.5">
529 {toggle("Code", !blame, "")}
530 {toggle("Blame", Boolean(blame), "?blame=1")}
531 </span>
532 )}
533 <a href={raw} className="rounded-md border border-line px-2.5 py-2 transition-colors hover:text-fg sm:px-2 sm:py-1">
534 Raw
535 </a>
536 </span>
537 </div>
538 {typeof blame === "object" && blame && lines ? (
539 <BlameView base={base} path={path} lines={lines} html={blame.lines} blame={blame.blame} />
540 ) : lines ? (
541 <CodeLines lines={lines} html={html} marked={marked} />
542 ) : isImagePath(path) ? (
543 <div className="flex justify-center bg-[repeating-conic-gradient(var(--color-raised)_0_25%,transparent_0_50%)] bg-size-[16px_16px] p-6">
544 <img src={raw} alt={path.split("/").pop() ?? path} className="max-h-[70vh] max-w-full" />
545 </div>
546 ) : (
547 <p className="p-6 text-sm text-muted">
548 This file is binary or too large to show.{" "}
549 <a href={raw} className="text-accent hover:underline">
550 View it raw
551 </a>
552 .
553 </p>
554 )}
555 </Card>
556 </div>
557 );
558}