246 lines8,775 bytesCodeBlame
1import { BookOpen, File, Folder, FolderGit2 } from "lucide-react";
2import { Link } from "react-router";
3
4import type { Blame, BlobView as Blob, Commit, TreeView as Tree } from "@g1t/contracts";
5
6import { BlameView } from "./blame-view";
7
8import { AgentSetup } from "./agent-setup";
9import { CloneBox } from "./clone-box";
10import { Markdown } from "./markdown";
11import { Avatar, CopyLine, TimeAgo } from "./ui";
12
13function encodePath(path: string): string {
14 return path.split("/").map(encodeURIComponent).join("/");
15}
16
17function Breadcrumbs({
18 base,
19 repo,
20 gitRef,
21 path,
22}: {
23 base: string;
24 repo: string;
25 gitRef: string;
26 path: string;
27}) {
28 const segments = path.split("/").filter(Boolean);
29 if (segments.length === 0) return null;
30 return (
31 <p className="mb-4 font-mono text-sm text-muted">
32 <Link to={`${base}/tree/${gitRef}`} className="text-accent hover:underline">
33 {repo}
34 </Link>
35 {segments.map((segment, i) => {
36 const to = encodePath(segments.slice(0, i + 1).join("/"));
37 const last = i === segments.length - 1;
38 return (
39 <span key={to}>
40 <span className="mx-1.5 text-faint">/</span>
41 {last ? (
42 <span className="font-medium text-fg">{segment}</span>
43 ) : (
44 <Link
45 to={`${base}/tree/${gitRef}/${to}`}
46 className="text-accent hover:underline"
47 >
48 {segment}
49 </Link>
50 )}
51 </span>
52 );
53 })}
54 </p>
55 );
56}
57
58/** The bar above a file listing describing the latest commit. */
59function CommitBar({ commit, gitRef }: { commit: Commit; gitRef: string }) {
60 return (
61 <div className="flex items-center gap-3 border-b border-line bg-surface px-4 py-2.5 text-sm">
62 <Avatar name={commit.author.name} />
63 <span className="shrink-0 font-medium">{commit.author.name}</span>
64 <span className="truncate text-muted">{commit.message.split("\n")[0]}</span>
65 <span className="ml-auto flex shrink-0 items-center gap-3 text-xs text-faint">
66 <span className="rounded-full border border-line px-2 py-0.5 font-mono text-accent">
67 {gitRef}
68 </span>
69 <span className="font-mono">{commit.hash.slice(0, 7)}</span>
70 <TimeAgo at={commit.authoredAt} />
71 </span>
72 </div>
73 );
74}
75
76export function TreeView({ tree }: { tree: Tree }) {
77 const { repo, ref, path, head, entries, readme } = tree;
78 const base = `/${repo.namespace}/${repo.name}`;
79 const prefix = path ? `${encodePath(path)}/` : "";
80 const cloneUrl = `https://g1t.sh${base}.git`;
81
82 if (!head) {
83 return (
84 <div className="mx-auto max-w-2xl rounded-xl border border-line bg-surface p-8">
85 <h2 className="text-lg font-semibold tracking-tight">
86 This repository is empty
87 </h2>
88 <p className="mt-1 text-sm text-muted">
89 Push an existing project to it. Use an access token as the password.
90 </p>
91 <div className="mt-5 space-y-2">
92 <CopyLine prompt text={`git remote add g1t ${cloneUrl}`} />
93 <CopyLine prompt text={`git push -u g1t ${ref}`} />
94 </div>
95 <h3 className="mt-7 text-sm font-medium">Or have your coding agent start it</h3>
96 <p className="mt-1 text-sm text-muted">
97 Connect it to g1t, then ask it to build the first version of{" "}
98 <span className="font-mono text-fg">{repo.name}</span> and push it here.
99 </p>
100 <AgentSetup className="mt-3" />
101 </div>
102 );
103 }
104
105 return (
106 <div className="grid gap-6 lg:grid-cols-[1fr_18rem]">
107 <div className="min-w-0">
108 <Breadcrumbs base={base} repo={repo.name} gitRef={ref} path={path} />
109 <div className="overflow-hidden rounded-xl border border-line">
110 <CommitBar commit={head} gitRef={ref} />
111 <ul className="divide-y divide-line text-sm">
112 {entries.map((entry) => {
113 const isTree = entry.kind === "tree";
114 const Icon = isTree ? Folder : entry.kind === "gitlink" ? FolderGit2 : File;
115 return (
116 <li key={entry.name}>
117 <Link
118 to={`${base}/${isTree ? "tree" : "blob"}/${ref}/${prefix}${encodeURIComponent(entry.name)}`}
119 className="flex items-center gap-3 px-4 py-2 transition-colors hover:bg-surface"
120 >
121 <Icon
122 size={15}
123 className={isTree ? "text-accent-dim" : "text-faint"}
124 />
125 <span className="font-mono text-[0.8125rem]">{entry.name}</span>
126 </Link>
127 </li>
128 );
129 })}
130 </ul>
131 </div>
132
133 {readme?.text != null && (
134 <section className="mt-6 overflow-hidden rounded-xl border border-line">
135 <h2 className="flex items-center gap-2 border-b border-line bg-surface px-4 py-2.5 text-sm font-medium">
136 <BookOpen size={15} className="text-faint" />
137 {readme.name}
138 </h2>
139 <div className="p-6">
140 {/\.(md|markdown)$/i.test(readme.name) ? (
141 <Markdown
142 source={readme.text}
143 repo={{ namespace: repo.namespace, name: repo.name }}
144 // Relative links in a README point into the repository.
145 base={`/${repo.namespace}/${repo.name}/blob/${ref}${path ? `/${path}` : ""}`}
146 />
147 ) : (
148 <pre className="whitespace-pre-wrap text-sm"><code>{readme.text}</code></pre>
149 )}
150 </div>
151 </section>
152 )}
153 </div>
154
155 {!path && (
156 <aside className="space-y-6">
157 <section>
158 <h2 className="text-sm font-medium">Clone</h2>
159 <div className="mt-2">
160 <CloneBox path={`${repo.namespace}/${repo.name}`} />
161 </div>
162 </section>
163 <section>
164 <h2 className="text-sm font-medium">About</h2>
165 <p className="mt-2 text-sm text-muted">
166 {repo.description ?? "No description."}
167 </p>
168 <p className="mt-3 text-xs text-faint">
169 Created <TimeAgo at={repo.createdAt} />
170 </p>
171 </section>
172 </aside>
173 )}
174 </div>
175 );
176}
177
178export function BlobView({
179 blob,
180 html,
181 blame,
182}: {
183 blob: Blob;
184 /** Syntax-highlighted HTML, when the language is known. */
185 html: string | null;
186 /** Shown instead of the plain file when asked for, with HTML per line. */
187 blame?: { blame: Blame; lines: string[] | null } | null;
188}) {
189 const { repo, ref, path, size, text } = blob;
190 const lines = text?.replace(/\n$/, "").split("\n");
191 const base = `/${repo.namespace}/${repo.name}`;
192 const toggle = (label: string, on: boolean, search: string) => (
193 <Link
194 to={{ search }}
195 preventScrollReset
196 className={`rounded px-2 py-0.5 transition-colors ${on ? "bg-raised text-fg" : "hover:text-fg"}`}
197 >
198 {label}
199 </Link>
200 );
201 return (
202 <div>
203 <Breadcrumbs
204 base={`/${repo.namespace}/${repo.name}`}
205 repo={repo.name}
206 gitRef={ref}
207 path={path}
208 />
209 <div className="overflow-hidden rounded-xl border border-line">
210 <div className="flex items-center gap-3 border-b border-line bg-surface px-4 py-2.5 text-xs text-muted">
211 {lines && <span>{lines.length.toLocaleString("en-US")} lines</span>}
212 <span>{size.toLocaleString("en-US")} bytes</span>
213 {lines && (
214 <span className="ml-auto flex rounded-md border border-line p-0.5">
215 {toggle("Code", !blame, "")}
216 {toggle("Blame", Boolean(blame), "?blame=1")}
217 </span>
218 )}
219 </div>
220 {blame && lines ? (
221 <BlameView base={base} path={path} lines={lines} html={blame.lines} blame={blame.blame} />
222 ) : html ? (
223 <div
224 className="overflow-x-auto py-3 pr-4"
225 // Shiki escapes the source; this is its generated markup.
226 dangerouslySetInnerHTML={{ __html: html }}
227 />
228 ) : lines ? (
229 <div className="flex overflow-x-auto py-3 font-mono text-sm leading-6">
230 <pre
231 aria-hidden="true"
232 className="w-10 shrink-0 text-right text-faint select-none"
233 >
234 {lines.map((_, i) => i + 1).join("\n")}
235 </pre>
236 <pre className="pr-4 pl-5"><code>{lines.join("\n")}</code></pre>
237 </div>
238 ) : (
239 <p className="p-6 text-sm text-muted">
240 This file is binary or too large to show.
241 </p>
242 )}
243 </div>
244 </div>
245 );
246}