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.

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