web/app/%5Bowner%5D/%5Brepo%5D/commit/%5Bsha%5D/page.tsxblame
View source
1da98741import type { Metadata } from "next";
60644e72import Link from "next/link";
62019fc3import { createHighlighter, type Highlighter } from "shiki";
f0bb1924import { groveApiUrl, timeAgo } from "@/lib/utils";
62019fc5import { DiffViewer } from "./diff-viewer";
62019fc6
62019fc7let highlighter: Highlighter | null = null;
62019fc8
62019fc9async function getHL() {
62019fc10 if (!highlighter) {
62019fc11 highlighter = await createHighlighter({
62019fc12 themes: ["vitesse-light", "vitesse-dark"],
62019fc13 langs: [
62019fc14 "typescript", "tsx", "javascript", "jsx", "json", "markdown",
62019fc15 "css", "html", "python", "rust", "go", "ruby", "yaml", "toml",
62019fc16 "bash", "sql", "graphql", "xml", "c", "cpp", "java", "kotlin",
62019fc17 "swift", "lua", "diff", "dockerfile", "makefile", "ini",
62019fc18 ],
62019fc19 });
62019fc20 }
62019fc21 return highlighter;
62019fc22}
62019fc23
62019fc24function getLang(filepath: string): string {
62019fc25 const filename = filepath.split("/").pop() ?? "";
62019fc26 const ext = filename.split(".").pop()?.toLowerCase() ?? "";
62019fc27 const map: Record<string, string> = {
62019fc28 ts: "typescript", tsx: "tsx", js: "javascript", jsx: "jsx",
62019fc29 json: "json", md: "markdown", css: "css", html: "html",
62019fc30 py: "python", rs: "rust", go: "go", rb: "ruby",
62019fc31 yml: "yaml", yaml: "yaml", toml: "toml",
62019fc32 sh: "bash", bash: "bash", zsh: "bash",
62019fc33 sql: "sql", graphql: "graphql", svg: "xml", xml: "xml",
62019fc34 c: "c", cpp: "cpp", h: "c", hpp: "cpp",
62019fc35 java: "java", kt: "kotlin", swift: "swift", lua: "lua", diff: "diff",
62019fc36 };
62019fc37 const nameMap: Record<string, string> = {
62019fc38 Dockerfile: "dockerfile", Makefile: "makefile",
62019fc39 ".gitignore": "gitignore", ".gitmodules": "ini",
62019fc40 };
818dc9041 if (filename.startsWith("Dockerfile")) return "dockerfile";
818dc9042 if (filename.startsWith("Makefile")) return "makefile";
62019fc43 return nameMap[filename] ?? map[ext] ?? "text";
62019fc44}
60644e745
60644e746interface Props {
60644e747 params: Promise<{ owner: string; repo: string; sha: string }>;
60644e748}
60644e749
1da987450export async function generateMetadata({ params }: Props): Promise<Metadata> {
86450dc51 const { repo, sha } = await params;
86450dc52 return { title: `${sha.slice(0, 7)} · ${repo}` };
1da987453}
1da987454
62019fc55export interface DiffLine {
62019fc56 type: "add" | "del" | "context";
62019fc57 content: string;
62019fc58 oldNum: number | null;
62019fc59 newNum: number | null;
62019fc60 html?: string;
62019fc61}
62019fc62
62019fc63export interface DiffHunk {
60644e764 header: string;
62019fc65 lines: DiffLine[];
60644e766}
60644e767
62019fc68export interface ParsedFile {
60644e769 path: string;
60644e770 hunks: DiffHunk[];
60644e771}
60644e772
60644e773function parseUnifiedDiff(raw: string): DiffHunk[] {
60644e774 const hunks: DiffHunk[] = [];
60644e775 let currentHunk: DiffHunk | null = null;
60644e776 let oldLine = 0;
60644e777 let newLine = 0;
60644e778
60644e779 for (const line of raw.split("\n")) {
60644e780 const hunkMatch = line.match(/^@@ -(\d+)(?:,\d+)? \+(\d+)(?:,\d+)? @@(.*)/);
60644e781 if (hunkMatch) {
60644e782 currentHunk = { header: line, lines: [] };
60644e783 hunks.push(currentHunk);
60644e784 oldLine = parseInt(hunkMatch[1]);
60644e785 newLine = parseInt(hunkMatch[2]);
60644e786 continue;
60644e787 }
60644e788 if (!currentHunk) continue;
60644e789 if (line.startsWith("+")) {
60644e790 currentHunk.lines.push({ type: "add", content: line.slice(1), oldNum: null, newNum: newLine++ });
60644e791 } else if (line.startsWith("-")) {
60644e792 currentHunk.lines.push({ type: "del", content: line.slice(1), oldNum: oldLine++, newNum: null });
60644e793 } else if (line.startsWith(" ") || line === "") {
60644e794 currentHunk.lines.push({ type: "context", content: line.slice(1), oldNum: oldLine++, newNum: newLine++ });
60644e795 }
60644e796 }
60644e797 return hunks;
60644e798}
60644e799
80fafdf100async function getCommit(owner: string, repo: string, sha: string) {
60644e7101 try {
60644e7102 const res = await fetch(
f0bb192103 `${groveApiUrl}/api/repos/${owner}/${repo}/commits/${sha}?limit=1`,
60644e7104 { cache: "no-store" }
60644e7105 );
60644e7106 if (!res.ok) return null;
60644e7107 const data = await res.json();
60644e7108 return data.commits?.[0] ?? null;
60644e7109 } catch {
60644e7110 return null;
60644e7111 }
60644e7112}
60644e7113
60644e7114
80fafdf115async function getDiff(owner: string, repo: string, base: string, head: string) {
60644e7116 try {
60644e7117 const res = await fetch(
f0bb192118 `${groveApiUrl}/api/repos/${owner}/${repo}/diff?base=${base}&head=${head}`,
60644e7119 { cache: "no-store" }
60644e7120 );
60644e7121 if (!res.ok) return null;
60644e7122 return res.json();
60644e7123 } catch {
60644e7124 return null;
60644e7125 }
60644e7126}
60644e7127
60644e7128export default async function CommitPage({ params }: Props) {
60644e7129 const { owner, repo, sha } = await params;
60644e7130
80fafdf131 const commit = await getCommit(owner, repo, sha);
60644e7132
60644e7133 if (!commit) {
60644e7134 return (
60644e7135 <div className="max-w-3xl mx-auto px-4 py-16">
60644e7136 <h1 className="text-lg" style={{ color: "var(--text-secondary)" }}>
60644e7137 Commit not found
60644e7138 </h1>
60644e7139 </div>
60644e7140 );
60644e7141 }
60644e7142
530592e143 const gitSha = commit.hash ?? sha;
530592e144 const parentSha = commit.parents?.[0] ?? null;
80fafdf145 const diffData = parentSha ? await getDiff(owner, repo, parentSha, gitSha) : null;
60644e7146
60644e7147 const files: ParsedFile[] = [];
60644e7148 if (diffData?.diffs) {
60644e7149 for (const file of diffData.diffs) {
60644e7150 if (file.is_binary) {
60644e7151 files.push({ path: file.path, hunks: [] });
60644e7152 } else {
60644e7153 const hunks = parseUnifiedDiff(file.diff);
60644e7154 files.push({ path: file.path, hunks });
60644e7155 }
60644e7156 }
60644e7157 }
60644e7158
62019fc159 // Syntax-highlight diff lines using shiki
62019fc160 try {
62019fc161 const hl = await getHL();
62019fc162 const loadedLangs = hl.getLoadedLanguages();
62019fc163 for (const file of files) {
62019fc164 if (file.hunks.length === 0) continue;
62019fc165 const lang = getLang(file.path);
62019fc166 const effectiveLang = loadedLangs.includes(lang) ? lang : "text";
62019fc167 // Collect all content lines in diff order
62019fc168 const allLines: DiffLine[] = [];
62019fc169 for (const hunk of file.hunks) {
62019fc170 for (const line of hunk.lines) {
62019fc171 allLines.push(line);
62019fc172 }
62019fc173 }
62019fc174 const code = allLines.map((l) => l.content).join("\n");
62019fc175 const highlighted = hl.codeToHtml(code, {
62019fc176 lang: effectiveLang,
62019fc177 themes: { light: "vitesse-light", dark: "vitesse-dark" },
62019fc178 defaultColor: false,
62019fc179 });
62019fc180 const codeMatch = highlighted.match(/<code[^>]*>([\s\S]*)<\/code>/);
62019fc181 if (codeMatch) {
62019fc182 const htmlLines = codeMatch[1].split("\n");
62019fc183 for (let i = 0; i < allLines.length && i < htmlLines.length; i++) {
62019fc184 allLines[i].html = htmlLines[i];
62019fc185 }
60644e7186 }
60644e7187 }
62019fc188 } catch (e) {
62019fc189 console.error("[shiki] Failed to highlight diff:", e);
60644e7190 }
60644e7191
62019fc192 const subject = commit.subject ?? "";
62019fc193 const body = commit.body ?? "";
62019fc194 const authorName = commit.author?.split("<")[0]?.trim() ?? commit.author;
62019fc195
60644e7196 return (
62019fc197 <div className="px-4 py-6 mx-auto" style={{ maxWidth: "90rem" }}>
60644e7198 <div
60644e7199 className="mb-6 px-4 py-4"
60644e7200 style={{
60644e7201 backgroundColor: "var(--bg-card)",
60644e7202 border: "1px solid var(--border-subtle)",
60644e7203 }}
60644e7204 >
60644e7205 <h1 className="text-lg mb-1">{subject}</h1>
60644e7206 {body && (
60644e7207 <pre
60644e7208 className="text-sm whitespace-pre-wrap mt-3 mb-3"
60644e7209 style={{ color: "var(--text-muted)" }}
60644e7210 >
60644e7211 {body}
60644e7212 </pre>
60644e7213 )}
60644e7214 <div className="flex flex-wrap items-center gap-4 text-xs" style={{ color: "var(--text-muted)" }}>
60644e7215 <span>{authorName}</span>
60644e7216 <span>{timeAgo(commit.timestamp)}</span>
60644e7217 <span className="font-mono" style={{ color: "var(--text-faint)" }}>
60644e7218 {gitSha.slice(0, 12)}
60644e7219 </span>
60644e7220 {parentSha && (
60644e7221 <span>
60644e7222 parent{" "}
60644e7223 <Link
60644e7224 href={`/${owner}/${repo}/commit/${parentSha}`}
60644e7225 className="font-mono hover:underline"
60644e7226 style={{ color: "var(--accent)" }}
60644e7227 >
60644e7228 {parentSha.slice(0, 7)}
60644e7229 </Link>
60644e7230 </span>
60644e7231 )}
60644e7232 </div>
60644e7233 </div>
60644e7234
60644e7235 {files.length > 0 && (
62019fc236 <DiffViewer files={files} owner={owner} repo={repo} gitSha={gitSha} />
60644e7237 )}
60644e7238
60644e7239 {!diffData && !parentSha && (
60644e7240 <p className="text-sm" style={{ color: "var(--text-faint)" }}>
60644e7241 This is the initial commit — no diff available.
60644e7242 </p>
60644e7243 )}
60644e7244 </div>
60644e7245 );
60644e7246}