web/app/%5Bowner%5D/%5Brepo%5D/(tabs)/page.tsxblame
View source
e4a233c1import type { Metadata } from "next";
3e3af552import Link from "next/link";
bf5fc333import { FileIcon } from "@/app/components/file-icon";
bf5fc334import { Markdown } from "@/app/components/markdown";
59a80f95import { GitImportForm } from "@/app/components/git-import-form";
2dc32fd6import { groveApiUrl, encodePath, timeAgo } from "@/lib/utils";
3e3af557
3e3af558interface Props {
3e3af559 params: Promise<{ owner: string; repo: string }>;
3e3af5510}
3e3af5511
e4a233c12export async function generateMetadata({ params }: Props): Promise<Metadata> {
86450dc13 const { repo } = await params;
86450dc14 return { title: `Grove · ${repo}` };
e4a233c15}
e4a233c16
80fafdf17async function getTree(owner: string, repo: string, ref: string) {
f0bb19218 const res = await fetch(`${groveApiUrl}/api/repos/${owner}/${repo}/tree/${ref}`, {
3e3af5519 cache: "no-store",
3e3af5520 });
3e3af5521 if (!res.ok) return null;
3e3af5522 return res.json();
3e3af5523}
3e3af5524
80fafdf25async function getBlob(owner: string, repo: string, ref: string, path: string) {
f0bb19226 const res = await fetch(`${groveApiUrl}/api/repos/${owner}/${repo}/blob/${ref}/${path}`, {
3e3af5527 cache: "no-store",
3e3af5528 });
3e3af5529 if (!res.ok) return null;
3e3af5530 return res.json();
3e3af5531}
3e3af5532
80fafdf33async function getBookmarks(owner: string, repo: string) {
f0bb19234 const res = await fetch(`${groveApiUrl}/api/repos/${owner}/${repo}/branches`, {
bf5fc3335 cache: "no-store",
bf5fc3336 });
bf5fc3337 if (!res.ok) return null;
bf5fc3338 return res.json();
bf5fc3339}
bf5fc3340
2dc32fd41async function getLatestCommit(owner: string, repo: string, ref: string) {
2dc32fd42 const res = await fetch(
2dc32fd43 `${groveApiUrl}/api/repos/${owner}/${repo}/commits/${ref}?limit=1`,
2dc32fd44 { cache: "no-store" }
2dc32fd45 );
2dc32fd46 if (!res.ok) return null;
2dc32fd47 return res.json();
2dc32fd48}
2dc32fd49
80fafdf50async function findReadme(owner: string, repo: string, ref: string, entries: any[]) {
bf5fc3351 const readmeNames = ["README.md", "README", "readme.md", "README.txt"];
bf5fc3352 const readmeEntry = entries.find(
f0bb19253 (e: any) => e.type !== "tree" && readmeNames.includes(e.name)
bf5fc3354 );
bf5fc3355 if (!readmeEntry) return null;
80fafdf56 const blob = await getBlob(owner, repo, ref, readmeEntry.name);
bf5fc3357 return blob?.content ?? null;
bf5fc3358}
bf5fc3359
bf5fc3360function sortEntries(entries: any[]) {
bf5fc3361 return [...entries].sort((a, b) => {
bf5fc3362 if (a.type === b.type) return a.name.localeCompare(b.name);
bf5fc3363 return a.type === "tree" ? -1 : 1;
bf5fc3364 });
bf5fc3365}
bf5fc3366
3e3af5567export default async function RepoPage({ params }: Props) {
3e3af5568 const { owner, repo } = await params;
3e3af5569
80fafdf70 const bookmarks = await getBookmarks(owner, repo);
bf5fc3371 if (!bookmarks) {
3e3af5572 return (
530592e73 <div className="py-10">
cf89d3c74 <h1 className="text-lg" style={{ color: "var(--text-secondary)" }}>
135dfe575 Repository not found
135dfe576 </h1>
135dfe577 <p className="text-sm mt-1" style={{ color: "var(--text-muted)" }}>
3e3af5578 {owner}/{repo} does not exist or you don&apos;t have access.
3e3af5579 </p>
3e3af5580 </div>
3e3af5581 );
3e3af5582 }
3e3af5583
f0bb19284 const branches = bookmarks.branches ?? bookmarks.bookmarks ?? [];
f0bb19285 const mainBookmark = branches.find((b: any) => b.name === "main");
f0bb19286 const ref = mainBookmark?.name ?? branches[0]?.name ?? "main";
2dc32fd87 const [tree, latestCommitData] = await Promise.all([
2dc32fd88 getTree(owner, repo, ref),
2dc32fd89 getLatestCommit(owner, repo, ref),
2dc32fd90 ]);
80fafdf91 const readme = tree ? await findReadme(owner, repo, ref, tree.entries) : null;
bf5fc3392 const isMarkdown = readme !== null;
2dc32fd93 const latestCommit = latestCommitData?.commits?.[0] ?? null;
bc2f20594 const sortedEntries = tree?.entries ? sortEntries(tree.entries) : [];
bc2f20595 const isEmptyRepo = !latestCommit && sortedEntries.length === 0;
2dc32fd96 const latestCommitAuthor =
2dc32fd97 latestCommit?.author?.split("<")[0]?.trim() ?? latestCommit?.author ?? "";
2dc32fd98 const latestCommitInitial = latestCommitAuthor?.[0]?.toUpperCase() ?? "?";
2dc32fd99 const latestCommitSubject =
2dc32fd100 latestCommit?.subject ?? latestCommit?.message?.split("\n")[0] ?? "Commit";
bc2f205101 const emptyRepoOptions = [
bc2f205102 {
bc2f205103 title: "Clone empty and start",
bc2f205104 description: "Create the first commit in this repository.",
bc2f205105 commands: [
8d8e815106 `grove clone ${owner}/${repo}`,
bc2f205107 `cd ${repo}`,
bc2f205108 `echo "# ${repo}" > README.md`,
bc2f205109 "sl add README.md",
bc2f205110 'sl commit -m "Initial commit"',
bc2f205111 `sl push --to ${ref}`,
bc2f205112 ],
bc2f205113 },
bc2f205114 {
bc2f205115 title: "Push existing Sapling repo",
bc2f205116 description: "Point your current Sapling repo at this remote and push.",
bc2f205117 commands: [
bc2f205118 "cd ~/src/my-existing-repo",
4ab1317119 `sl path default "slapi:${repo}"`,
bc2f205120 `sl push --to ${ref}`,
bc2f205121 ],
bc2f205122 },
bc2f205123 ];
3e3af55124
3e3af55125 return (
530592e126 <>
2dc32fd127 {latestCommit && (
2dc32fd128 <div className="mb-3 text-sm" style={{ border: "1px solid var(--border-subtle)" }}>
2dc32fd129 <div className="flex items-center gap-3 py-2 pl-3 pr-3">
2dc32fd130 <span
2dc32fd131 title={latestCommit.author}
2dc32fd132 style={{
2dc32fd133 display: "inline-flex",
2dc32fd134 alignItems: "center",
2dc32fd135 justifyContent: "center",
2dc32fd136 width: 22,
2dc32fd137 height: 22,
2dc32fd138 borderRadius: "50%",
2dc32fd139 backgroundColor: "var(--bg-inset)",
2dc32fd140 border: "1px solid var(--border-subtle)",
2dc32fd141 color: "var(--text-muted)",
2dc32fd142 fontSize: "0.65rem",
2dc32fd143 cursor: "default",
2dc32fd144 }}
2dc32fd145 >
2dc32fd146 {latestCommitInitial}
2dc32fd147 </span>
2dc32fd148 <div className="min-w-0 flex-1">
2dc32fd149 <div className="truncate">
2dc32fd150 <Link
2dc32fd151 href={`/${owner}/${repo}/commit/${latestCommit.hash}`}
2dc32fd152 className="hover:underline"
2dc32fd153 style={{ color: "var(--text-primary)" }}
2dc32fd154 >
2dc32fd155 {latestCommitSubject}
2dc32fd156 </Link>
2dc32fd157 </div>
2dc32fd158 <div className="text-xs" style={{ color: "var(--text-faint)" }}>
2dc32fd159 Most recent commit {latestCommitAuthor ? `by ${latestCommitAuthor}` : ""}
2dc32fd160 </div>
2dc32fd161 </div>
2dc32fd162 <Link
2dc32fd163 href={`/${owner}/${repo}/commit/${latestCommit.hash}`}
2dc32fd164 className="font-mono text-xs hover:underline"
2dc32fd165 style={{ color: "var(--accent)" }}
2dc32fd166 >
2dc32fd167 {latestCommit.hash.slice(0, 7)}
2dc32fd168 </Link>
2dc32fd169 <span className="text-xs shrink-0" style={{ color: "var(--text-faint)" }}>
2dc32fd170 {timeAgo(latestCommit.timestamp)}
2dc32fd171 </span>
2dc32fd172 </div>
2dc32fd173 </div>
2dc32fd174 )}
2dc32fd175
bc2f205176 {isEmptyRepo ? (
bc2f205177 <div
bc2f205178 className="px-4 py-6"
bc2f205179 style={{
bc2f205180 backgroundColor: "var(--bg-card)",
bc2f205181 border: "1px solid var(--border-subtle)",
bc2f205182 }}
bc2f205183 >
bc2f205184 <p className="text-sm" style={{ color: "var(--text-secondary)" }}>
bc2f205185 This repository is empty
bc2f205186 </p>
bc2f205187 <p className="text-xs mt-1 mb-4" style={{ color: "var(--text-faint)" }}>
bc2f205188 Push your first commit to <span className="font-mono">{ref}</span> to get started.
bc2f205189 </p>
bc2f205190 <div className="grid gap-3">
59a80f9191 <GitImportForm owner={owner} repo={repo} />
bc2f205192 {emptyRepoOptions.map((option) => (
bc2f205193 <div
bc2f205194 key={option.title}
bdf6540195 className="text-left p-3 min-w-0 overflow-hidden"
bc2f205196 style={{
bc2f205197 backgroundColor: "var(--bg-inset)",
bc2f205198 border: "1px solid var(--border-subtle)",
bc2f205199 }}
bc2f205200 >
bc2f205201 <p className="text-xs mb-1" style={{ color: "var(--text-secondary)" }}>
bc2f205202 {option.title}
bc2f205203 </p>
bc2f205204 <p className="text-xs mb-2" style={{ color: "var(--text-faint)" }}>
bc2f205205 {option.description}
bc2f205206 </p>
bc2f205207 <pre
bc2f205208 className="text-xs whitespace-pre overflow-x-auto"
bc2f205209 style={{ color: "var(--text-muted)" }}
bc2f205210 >
bc2f205211{option.commands.join("\n")}
bc2f205212 </pre>
bc2f205213 </div>
bc2f205214 ))}
bc2f205215 </div>
bc2f205216 </div>
bc2f205217 ) : tree && sortedEntries.length > 0 ? (
135dfe5218 <div>
bf5fc33219 <div
bf5fc33220 className="text-sm"
bf5fc33221 style={{
bf5fc33222 border: "1px solid var(--border-subtle)",
bf5fc33223 }}
bf5fc33224 >
bc2f205225 {sortedEntries.map((entry: any, i: number) => (
bf5fc33226 <Link
bf5fc33227 key={entry.name}
bf5fc33228 href={
bf5fc33229 entry.type === "tree"
d744b82230 ? `/${owner}/${repo}/tree/${ref}/${encodePath(entry.name)}`
d744b82231 : `/${owner}/${repo}/blob/${ref}/${encodePath(entry.name)}`
bf5fc33232 }
bf5fc33233 className="flex items-center gap-2 py-1.5 pl-3 pr-3 hover-row"
bf5fc33234 style={{
bf5fc33235 borderTop:
bf5fc33236 i > 0 ? "1px solid var(--divide)" : undefined,
bf5fc33237 color: "var(--accent)",
bf5fc33238 }}
bf5fc33239 >
bf5fc33240 <FileIcon type={entry.type} name={entry.name} />
bf5fc33241 <span className="hover:underline">{entry.name}</span>
bf5fc33242 </Link>
bf5fc33243 ))}
bf5fc33244 </div>
3e3af55245 </div>
bc2f205246 ) : null}
3e3af55247
bf5fc33248 {readme && (
135dfe5249 <div className="mt-8">
bf5fc33250 {isMarkdown ? (
bf5fc33251 <Markdown content={readme} />
bf5fc33252 ) : (
bf5fc33253 <pre
bf5fc33254 className="whitespace-pre-wrap text-sm p-4"
bf5fc33255 style={{
bf5fc33256 color: "var(--text-secondary)",
bf5fc33257 backgroundColor: "var(--bg-card)",
bf5fc33258 border: "1px solid var(--border-subtle)",
bf5fc33259 }}
bf5fc33260 >
bf5fc33261 {readme}
bf5fc33262 </pre>
bf5fc33263 )}
3e3af55264 </div>
3e3af55265 )}
530592e266 </>
3e3af55267 );
3e3af55268}