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

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