Skip to content

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

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