Skip to content

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

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