9.0 KB269 lines
Blame
1import type { Metadata } from "next";
2import Link from "next/link";
3import { FileIcon } from "@/app/components/file-icon";
4import { Markdown } from "@/app/components/markdown";
5import { GitImportForm } from "@/app/components/git-import-form";
6import { groveApiUrl, encodePath, timeAgo } from "@/lib/utils";
7
8interface Props {
9 params: Promise<{ owner: string; repo: string }>;
10}
11
12export async function generateMetadata({ params }: Props): Promise<Metadata> {
13 const { repo } = await params;
14 return { title: `Grove · ${repo}` };
15}
16
17async function getTree(owner: string, repo: string, ref: string) {
18 const res = await fetch(`${groveApiUrl}/api/repos/${owner}/${repo}/tree/${ref}`, {
19 cache: "no-store",
20 });
21 if (!res.ok) return null;
22 return res.json();
23}
24
25async function getBlob(owner: string, repo: string, ref: string, path: string) {
26 const res = await fetch(`${groveApiUrl}/api/repos/${owner}/${repo}/blob/${ref}/${path}`, {
27 cache: "no-store",
28 });
29 if (!res.ok) return null;
30 return res.json();
31}
32
33async function getBookmarks(owner: string, repo: string) {
34 const res = await fetch(`${groveApiUrl}/api/repos/${owner}/${repo}/branches`, {
35 cache: "no-store",
36 });
37 if (!res.ok) return null;
38 return res.json();
39}
40
41async function getLatestCommit(owner: string, repo: string, ref: string) {
42 const res = await fetch(
43 `${groveApiUrl}/api/repos/${owner}/${repo}/commits/${ref}?limit=1`,
44 { cache: "no-store" }
45 );
46 if (!res.ok) return null;
47 return res.json();
48}
49
50async function findReadme(owner: string, repo: string, ref: string, entries: any[]) {
51 const readmeNames = ["README.md", "README", "readme.md", "README.txt"];
52 const readmeEntry = entries.find(
53 (e: any) => e.type !== "tree" && readmeNames.includes(e.name)
54 );
55 if (!readmeEntry) return null;
56 const blob = await getBlob(owner, repo, ref, readmeEntry.name);
57 return blob?.content ?? null;
58}
59
60function sortEntries(entries: any[]) {
61 return [...entries].sort((a, b) => {
62 if (a.type === b.type) return a.name.localeCompare(b.name);
63 return a.type === "tree" ? -1 : 1;
64 });
65}
66
67export default async function RepoPage({ params }: Props) {
68 const { owner, repo } = await params;
69
70 const bookmarks = await getBookmarks(owner, repo);
71 if (!bookmarks) {
72 return (
73 <div className="py-10">
74 <h1 className="text-lg" style={{ color: "var(--text-secondary)" }}>
75 Repository not found
76 </h1>
77 <p className="text-sm mt-1" style={{ color: "var(--text-muted)" }}>
78 {owner}/{repo} does not exist or you don&apos;t have access.
79 </p>
80 </div>
81 );
82 }
83
84 const branches = bookmarks.branches ?? bookmarks.bookmarks ?? [];
85 const mainBookmark = branches.find((b: any) => b.name === "main");
86 const ref = mainBookmark?.name ?? branches[0]?.name ?? "main";
87 const [tree, latestCommitData] = await Promise.all([
88 getTree(owner, repo, ref),
89 getLatestCommit(owner, repo, ref),
90 ]);
91 const readme = tree ? await findReadme(owner, repo, ref, tree.entries) : null;
92 const isMarkdown = readme !== null;
93 const latestCommit = latestCommitData?.commits?.[0] ?? null;
94 const sortedEntries = tree?.entries ? sortEntries(tree.entries) : [];
95 const isEmptyRepo = !latestCommit && sortedEntries.length === 0;
96 const latestCommitAuthor =
97 latestCommit?.author?.split("<")[0]?.trim() ?? latestCommit?.author ?? "";
98 const latestCommitInitial = latestCommitAuthor?.[0]?.toUpperCase() ?? "?";
99 const latestCommitSubject =
100 latestCommit?.subject ?? latestCommit?.message?.split("\n")[0] ?? "Commit";
101 const emptyRepoOptions = [
102 {
103 title: "Clone empty and start",
104 description: "Create the first commit in this repository.",
105 commands: [
106 `grove clone ${owner}/${repo}`,
107 `cd ${repo}`,
108 `echo "# ${repo}" > README.md`,
109 "sl add README.md",
110 'sl commit -m "Initial commit"',
111 `sl push --to ${ref}`,
112 ],
113 },
114 {
115 title: "Push existing Sapling repo",
116 description: "Point your current Sapling repo at this remote and push.",
117 commands: [
118 "cd ~/src/my-existing-repo",
119 `sl path default "slapi:${repo}"`,
120 `sl push --to ${ref}`,
121 ],
122 },
123 ];
124
125 return (
126 <>
127 {latestCommit && (
128 <div className="mb-3 text-sm" style={{ border: "1px solid var(--border-subtle)" }}>
129 <div className="flex items-center gap-3 py-2 pl-3 pr-3">
130 <span
131 title={latestCommit.author}
132 style={{
133 display: "inline-flex",
134 alignItems: "center",
135 justifyContent: "center",
136 width: 22,
137 height: 22,
138 borderRadius: "50%",
139 backgroundColor: "var(--bg-inset)",
140 border: "1px solid var(--border-subtle)",
141 color: "var(--text-muted)",
142 fontSize: "0.65rem",
143 cursor: "default",
144 }}
145 >
146 {latestCommitInitial}
147 </span>
148 <div className="min-w-0 flex-1">
149 <div className="truncate">
150 <Link
151 href={`/${owner}/${repo}/commit/${latestCommit.hash}`}
152 className="hover:underline"
153 style={{ color: "var(--text-primary)" }}
154 >
155 {latestCommitSubject}
156 </Link>
157 </div>
158 <div className="text-xs" style={{ color: "var(--text-faint)" }}>
159 Most recent commit {latestCommitAuthor ? `by ${latestCommitAuthor}` : ""}
160 </div>
161 </div>
162 <Link
163 href={`/${owner}/${repo}/commit/${latestCommit.hash}`}
164 className="font-mono text-xs hover:underline"
165 style={{ color: "var(--accent)" }}
166 >
167 {latestCommit.hash.slice(0, 7)}
168 </Link>
169 <span className="text-xs shrink-0" style={{ color: "var(--text-faint)" }}>
170 {timeAgo(latestCommit.timestamp)}
171 </span>
172 </div>
173 </div>
174 )}
175
176 {isEmptyRepo ? (
177 <div
178 className="px-4 py-6"
179 style={{
180 backgroundColor: "var(--bg-card)",
181 border: "1px solid var(--border-subtle)",
182 }}
183 >
184 <p className="text-sm" style={{ color: "var(--text-secondary)" }}>
185 This repository is empty
186 </p>
187 <p className="text-xs mt-1 mb-4" style={{ color: "var(--text-faint)" }}>
188 Push your first commit to <span className="font-mono">{ref}</span> to get started.
189 </p>
190 <div className="grid gap-3">
191 <GitImportForm owner={owner} repo={repo} />
192 {emptyRepoOptions.map((option) => (
193 <div
194 key={option.title}
195 className="text-left p-3 min-w-0 overflow-hidden"
196 style={{
197 backgroundColor: "var(--bg-inset)",
198 border: "1px solid var(--border-subtle)",
199 }}
200 >
201 <p className="text-xs mb-1" style={{ color: "var(--text-secondary)" }}>
202 {option.title}
203 </p>
204 <p className="text-xs mb-2" style={{ color: "var(--text-faint)" }}>
205 {option.description}
206 </p>
207 <pre
208 className="text-xs whitespace-pre overflow-x-auto"
209 style={{ color: "var(--text-muted)" }}
210 >
211{option.commands.join("\n")}
212 </pre>
213 </div>
214 ))}
215 </div>
216 </div>
217 ) : tree && sortedEntries.length > 0 ? (
218 <div>
219 <div
220 className="text-sm"
221 style={{
222 border: "1px solid var(--border-subtle)",
223 }}
224 >
225 {sortedEntries.map((entry: any, i: number) => (
226 <Link
227 key={entry.name}
228 href={
229 entry.type === "tree"
230 ? `/${owner}/${repo}/tree/${ref}/${encodePath(entry.name)}`
231 : `/${owner}/${repo}/blob/${ref}/${encodePath(entry.name)}`
232 }
233 className="flex items-center gap-2 py-1.5 pl-3 pr-3 hover-row"
234 style={{
235 borderTop:
236 i > 0 ? "1px solid var(--divide)" : undefined,
237 color: "var(--accent)",
238 }}
239 >
240 <FileIcon type={entry.type} name={entry.name} />
241 <span className="hover:underline">{entry.name}</span>
242 </Link>
243 ))}
244 </div>
245 </div>
246 ) : null}
247
248 {readme && (
249 <div className="mt-8">
250 {isMarkdown ? (
251 <Markdown content={readme} />
252 ) : (
253 <pre
254 className="whitespace-pre-wrap text-sm p-4"
255 style={{
256 color: "var(--text-secondary)",
257 backgroundColor: "var(--bg-card)",
258 border: "1px solid var(--border-subtle)",
259 }}
260 >
261 {readme}
262 </pre>
263 )}
264 </div>
265 )}
266 </>
267 );
268}
269