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.

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