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