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