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

358 lines13,699 bytesCodeBlame
1import { BookOpen, Check, ChevronDown, Code2, File, 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, or hand it to an agent. */
132function CodeButton({ path }: { path: 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-4">
141 <h2 className="mb-3 flex items-center gap-2 text-sm font-medium">
142 <SquareTerminal size={15} className="text-faint" />
143 Clone
144 </h2>
145 <CloneBox path={path} />
146 </PopoverContent>
147 </Popover>
148 );
149}
150
151/** Above the files: the branch and, at the root, how many there are, search and Code; below it, where you are. */
152function CodeBar({
153 base,
154 repo,
155 gitRef,
156 path,
157 branches,
158}: {
159 base: string;
160 repo: { namespace: string; name: string };
161 gitRef: string;
162 path: string;
163 branches: Branch[] | null;
164}) {
165 const full = `${repo.namespace}/${repo.name}`;
166 const list = branches && branches.length > 0 ? branches : [{ name: gitRef, hash: "" }];
167 return (
168 <div className="mb-4 flex flex-wrap items-center gap-x-3 gap-y-2">
169 <BranchMenu base={base} gitRef={gitRef} path={path} branches={list} />
170 {path ? (
171 <Breadcrumbs base={base} repo={repo.name} gitRef={gitRef} path={path} />
172 ) : (
173 <>
174 {branches && (
175 <span className="inline-flex items-center gap-1.5 text-sm text-muted">
176 <GitBranch size={14} className="text-faint" />
177 <span className="font-medium text-fg">{branches.length}</span>
178 {branches.length === 1 ? "branch" : "branches"}
179 </span>
180 )}
181 <SearchCode repo={full} />
182 <CodeButton path={full} />
183 </>
184 )}
185 </div>
186 );
187}
188
189export function TreeView({ tree, branches = null }: { tree: Tree; branches?: Branch[] | null }) {
190 const { repo, ref, path, head, entries, readme } = tree;
191 const base = `/${repo.namespace}/${repo.name}`;
192 const prefix = path ? `${encodePath(path)}/` : "";
193 const cloneUrl = `https://g1t.sh${base}.git`;
194
195 if (!head) {
196 return (
197 <div className="mx-auto max-w-2xl rounded-xl border border-line bg-surface p-8">
198 <h2 className="text-lg font-semibold tracking-tight">
199 This repository is empty
200 </h2>
201 <p className="mt-1 text-sm text-muted">
202 Push an existing project to it. Use an access token as the password.
203 </p>
204 <div className="mt-5 space-y-2">
205 <CopyLine prompt text={`git remote add g1t ${cloneUrl}`} />
206 <CopyLine prompt text={`git push -u g1t ${ref}`} />
207 </div>
208 <h3 className="mt-7 text-sm font-medium">Or have your coding agent start it</h3>
209 <p className="mt-1 text-sm text-muted">
210 Connect it to g1t, then ask it to build the first version of{" "}
211 <span className="font-mono text-fg">{repo.name}</span> and push it here.
212 </p>
213 <AgentSetup className="mt-3" />
214 </div>
215 );
216 }
217
218 return (
219 <div className={path ? "" : "grid gap-8 lg:grid-cols-[1fr_17rem]"}>
220 <div className="min-w-0">
221 <CodeBar base={base} repo={repo} gitRef={ref} path={path} branches={branches} />
222 <div className="overflow-hidden rounded-xl border border-line">
223 <CommitBar commit={head} base={base} />
224 <ul className="divide-y divide-line text-sm">
225 {entries.map((entry) => {
226 const isTree = entry.kind === "tree";
227 const Icon = isTree ? Folder : entry.kind === "gitlink" ? FolderGit2 : File;
228 return (
229 <li key={entry.name}>
230 <Link
231 to={`${base}/${isTree ? "tree" : "blob"}/${ref}/${prefix}${encodeURIComponent(entry.name)}`}
232 className="flex items-center gap-3 px-4 py-2 transition-colors hover:bg-surface"
233 >
234 <Icon
235 size={15}
236 className={isTree ? "text-accent-dim" : "text-faint"}
237 />
238 <span className="font-mono text-[0.8125rem]">{entry.name}</span>
239 </Link>
240 </li>
241 );
242 })}
243 </ul>
244 </div>
245
246 {readme?.text != null && (
247 <section id="readme" className="mt-6 scroll-mt-20 overflow-hidden rounded-xl border border-line">
248 <h2 className="flex items-center gap-2 border-b border-line bg-surface px-4 py-2.5 text-sm font-medium">
249 <BookOpen size={15} className="text-faint" />
250 {readme.name}
251 </h2>
252 <div className="p-6">
253 {/\.(md|markdown)$/i.test(readme.name) ? (
254 <Markdown
255 source={readme.text}
256 repo={{ namespace: repo.namespace, name: repo.name }}
257 // Relative links in a README point into the repository.
258 base={`/${repo.namespace}/${repo.name}/blob/${ref}${path ? `/${path}` : ""}`}
259 />
260 ) : (
261 <pre className="whitespace-pre-wrap text-sm"><code>{readme.text}</code></pre>
262 )}
263 </div>
264 </section>
265 )}
266 </div>
267
268 {!path && (
269 <aside>
270 <h2 className="text-base font-semibold">About</h2>
271 <p className="mt-2.5 text-sm text-fg-soft">{repo.description ?? "No description."}</p>
272 <Topics topics={repo.topics} className="mt-3" />
273 <ul className="mt-4 space-y-2.5 text-sm text-muted">
274 {readme && (
275 <li>
276 <a href="#readme" className="inline-flex items-center gap-2 hover:text-fg">
277 <BookOpen size={15} className="text-faint" />
278 Readme
279 </a>
280 </li>
281 )}
282 <li>
283 <Link to={`${base}/commits`} className="inline-flex items-center gap-2 hover:text-fg">
284 <History size={15} className="text-faint" />
285 Commits
286 </Link>
287 </li>
288 {branches && (
289 <li className="flex items-center gap-2">
290 <GitBranch size={15} className="text-faint" />
291 {branches.length} {branches.length === 1 ? "branch" : "branches"}
292 </li>
293 )}
294 </ul>
295 <p className="mt-4 text-xs text-faint">
296 Created <TimeAgo at={repo.createdAt} />
297 </p>
298 </aside>
299 )}
300 </div>
301 );
302}
303
304export function BlobView({
305 blob,
306 html,
307 blame,
308}: {
309 blob: Blob;
310 /** Syntax-highlighted HTML per line, when the language is known. */
311 html: string[] | null;
312 /** Shown instead of the plain file when asked for, with HTML per line. */
313 blame?: { blame: Blame; lines: string[] | null } | null;
314}) {
315 const { repo, ref, path, size, text } = blob;
316 const lines = text?.replace(/\n$/, "").split("\n");
317 const base = `/${repo.namespace}/${repo.name}`;
318 const toggle = (label: string, on: boolean, search: string) => (
319 <Link
320 to={{ search }}
321 preventScrollReset
322 className={`rounded px-2 py-0.5 transition-colors ${on ? "bg-raised text-fg" : "hover:text-fg"}`}
323 >
324 {label}
325 </Link>
326 );
327 return (
328 <div>
329 <Breadcrumbs
330 base={`/${repo.namespace}/${repo.name}`}
331 repo={repo.name}
332 gitRef={ref}
333 path={path}
334 />
335 <div className="overflow-hidden rounded-xl border border-line">
336 <div className="flex items-center gap-3 border-b border-line bg-surface px-4 py-2.5 text-xs text-muted">
337 {lines && <span>{lines.length.toLocaleString("en-US")} lines</span>}
338 <span>{size.toLocaleString("en-US")} bytes</span>
339 {lines && (
340 <span className="ml-auto flex rounded-md border border-line p-0.5">
341 {toggle("Code", !blame, "")}
342 {toggle("Blame", Boolean(blame), "?blame=1")}
343 </span>
344 )}
345 </div>
346 {blame && lines ? (
347 <BlameView base={base} path={path} lines={lines} html={blame.lines} blame={blame.blame} />
348 ) : lines ? (
349 <CodeLines lines={lines} html={html} />
350 ) : (
351 <p className="p-6 text-sm text-muted">
352 This file is binary or too large to show.
353 </p>
354 )}
355 </div>
356 </div>
357 );
358}