Skip to content

g1t/apps/web/app/components/repo-view.tsx

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