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.

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