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.

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