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