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

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