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";
bc1b2ba4import { timeAgo } from "@/lib/utils";
bc1b2ba5import { getRepoCommits, getRepoDiff } from "@/lib/grove-api";
62019fc6import { DiffViewer } from "./diff-viewer";
62019fc7
62019fc8let highlighter: Highlighter | null = null;
62019fc9
62019fc10async function getHL() {
62019fc11 if (!highlighter) {
62019fc12 highlighter = await createHighlighter({
62019fc13 themes: ["vitesse-light", "vitesse-dark"],
62019fc14 langs: [
62019fc15 "typescript", "tsx", "javascript", "jsx", "json", "markdown",
62019fc16 "css", "html", "python", "rust", "go", "ruby", "yaml", "toml",
62019fc17 "bash", "sql", "graphql", "xml", "c", "cpp", "java", "kotlin",
62019fc18 "swift", "lua", "diff", "dockerfile", "makefile", "ini",
62019fc19 ],
62019fc20 });
62019fc21 }
62019fc22 return highlighter;
62019fc23}
62019fc24
62019fc25function getLang(filepath: string): string {
62019fc26 const filename = filepath.split("/").pop() ?? "";
62019fc27 const ext = filename.split(".").pop()?.toLowerCase() ?? "";
62019fc28 const map: Record<string, string> = {
62019fc29 ts: "typescript", tsx: "tsx", js: "javascript", jsx: "jsx",
62019fc30 json: "json", md: "markdown", css: "css", html: "html",
62019fc31 py: "python", rs: "rust", go: "go", rb: "ruby",
62019fc32 yml: "yaml", yaml: "yaml", toml: "toml",
62019fc33 sh: "bash", bash: "bash", zsh: "bash",
62019fc34 sql: "sql", graphql: "graphql", svg: "xml", xml: "xml",
62019fc35 c: "c", cpp: "cpp", h: "c", hpp: "cpp",
62019fc36 java: "java", kt: "kotlin", swift: "swift", lua: "lua", diff: "diff",
62019fc37 };
62019fc38 const nameMap: Record<string, string> = {
62019fc39 Dockerfile: "dockerfile", Makefile: "makefile",
62019fc40 ".gitignore": "gitignore", ".gitmodules": "ini",
62019fc41 };
818dc9042 if (filename.startsWith("Dockerfile")) return "dockerfile";
818dc9043 if (filename.startsWith("Makefile")) return "makefile";
62019fc44 return nameMap[filename] ?? map[ext] ?? "text";
62019fc45}
60644e746
60644e747interface Props {
60644e748 params: Promise<{ owner: string; repo: string; sha: string }>;
60644e749}
60644e750
1da987451export async function generateMetadata({ params }: Props): Promise<Metadata> {
86450dc52 const { repo, sha } = await params;
86450dc53 return { title: `${sha.slice(0, 7)} · ${repo}` };
1da987454}
1da987455
62019fc56export interface DiffLine {
62019fc57 type: "add" | "del" | "context";
62019fc58 content: string;
62019fc59 oldNum: number | null;
62019fc60 newNum: number | null;
62019fc61 html?: string;
62019fc62}
62019fc63
62019fc64export interface DiffHunk {
60644e765 header: string;
62019fc66 lines: DiffLine[];
60644e767}
60644e768
62019fc69export interface ParsedFile {
60644e770 path: string;
60644e771 hunks: DiffHunk[];
60644e772}
60644e773
60644e774function parseUnifiedDiff(raw: string): DiffHunk[] {
60644e775 const hunks: DiffHunk[] = [];
60644e776 let currentHunk: DiffHunk | null = null;
60644e777 let oldLine = 0;
60644e778 let newLine = 0;
60644e779
60644e780 for (const line of raw.split("\n")) {
60644e781 const hunkMatch = line.match(/^@@ -(\d+)(?:,\d+)? \+(\d+)(?:,\d+)? @@(.*)/);
60644e782 if (hunkMatch) {
60644e783 currentHunk = { header: line, lines: [] };
60644e784 hunks.push(currentHunk);
60644e785 oldLine = parseInt(hunkMatch[1]);
60644e786 newLine = parseInt(hunkMatch[2]);
60644e787 continue;
60644e788 }
60644e789 if (!currentHunk) continue;
60644e790 if (line.startsWith("+")) {
60644e791 currentHunk.lines.push({ type: "add", content: line.slice(1), oldNum: null, newNum: newLine++ });
60644e792 } else if (line.startsWith("-")) {
60644e793 currentHunk.lines.push({ type: "del", content: line.slice(1), oldNum: oldLine++, newNum: null });
60644e794 } else if (line.startsWith(" ") || line === "") {
60644e795 currentHunk.lines.push({ type: "context", content: line.slice(1), oldNum: oldLine++, newNum: newLine++ });
60644e796 }
60644e797 }
60644e798 return hunks;
60644e799}
60644e7100
80fafdf101async function getCommit(owner: string, repo: string, sha: string) {
bc1b2ba102 const data = await getRepoCommits(owner, repo, sha, { limit: 1 });
bc1b2ba103 return data?.commits?.[0] ?? null;
60644e7104}
60644e7105
60644e7106export default async function CommitPage({ params }: Props) {
60644e7107 const { owner, repo, sha } = await params;
60644e7108
80fafdf109 const commit = await getCommit(owner, repo, sha);
60644e7110
60644e7111 if (!commit) {
60644e7112 return (
60644e7113 <div className="max-w-3xl mx-auto px-4 py-16">
60644e7114 <h1 className="text-lg" style={{ color: "var(--text-secondary)" }}>
60644e7115 Commit not found
60644e7116 </h1>
60644e7117 </div>
60644e7118 );
60644e7119 }
60644e7120
530592e121 const gitSha = commit.hash ?? sha;
530592e122 const parentSha = commit.parents?.[0] ?? null;
bc1b2ba123 const diffData = parentSha ? await getRepoDiff(owner, repo, parentSha, gitSha) : null;
60644e7124
60644e7125 const files: ParsedFile[] = [];
60644e7126 if (diffData?.diffs) {
60644e7127 for (const file of diffData.diffs) {
60644e7128 if (file.is_binary) {
60644e7129 files.push({ path: file.path, hunks: [] });
60644e7130 } else {
60644e7131 const hunks = parseUnifiedDiff(file.diff);
60644e7132 files.push({ path: file.path, hunks });
60644e7133 }
60644e7134 }
60644e7135 }
60644e7136
62019fc137 // Syntax-highlight diff lines using shiki
62019fc138 try {
62019fc139 const hl = await getHL();
62019fc140 const loadedLangs = hl.getLoadedLanguages();
62019fc141 for (const file of files) {
62019fc142 if (file.hunks.length === 0) continue;
62019fc143 const lang = getLang(file.path);
62019fc144 const effectiveLang = loadedLangs.includes(lang) ? lang : "text";
62019fc145 // Collect all content lines in diff order
62019fc146 const allLines: DiffLine[] = [];
62019fc147 for (const hunk of file.hunks) {
62019fc148 for (const line of hunk.lines) {
62019fc149 allLines.push(line);
62019fc150 }
62019fc151 }
62019fc152 const code = allLines.map((l) => l.content).join("\n");
62019fc153 const highlighted = hl.codeToHtml(code, {
62019fc154 lang: effectiveLang,
62019fc155 themes: { light: "vitesse-light", dark: "vitesse-dark" },
62019fc156 defaultColor: false,
62019fc157 });
62019fc158 const codeMatch = highlighted.match(/<code[^>]*>([\s\S]*)<\/code>/);
62019fc159 if (codeMatch) {
62019fc160 const htmlLines = codeMatch[1].split("\n");
62019fc161 for (let i = 0; i < allLines.length && i < htmlLines.length; i++) {
62019fc162 allLines[i].html = htmlLines[i];
62019fc163 }
60644e7164 }
60644e7165 }
62019fc166 } catch (e) {
62019fc167 console.error("[shiki] Failed to highlight diff:", e);
60644e7168 }
60644e7169
62019fc170 const subject = commit.subject ?? "";
62019fc171 const body = commit.body ?? "";
62019fc172 const authorName = commit.author?.split("<")[0]?.trim() ?? commit.author;
62019fc173
60644e7174 return (
62019fc175 <div className="px-4 py-6 mx-auto" style={{ maxWidth: "90rem" }}>
60644e7176 <div
60644e7177 className="mb-6 px-4 py-4"
60644e7178 style={{
60644e7179 backgroundColor: "var(--bg-card)",
60644e7180 border: "1px solid var(--border-subtle)",
60644e7181 }}
60644e7182 >
60644e7183 <h1 className="text-lg mb-1">{subject}</h1>
60644e7184 {body && (
60644e7185 <pre
60644e7186 className="text-sm whitespace-pre-wrap mt-3 mb-3"
60644e7187 style={{ color: "var(--text-muted)" }}
60644e7188 >
60644e7189 {body}
60644e7190 </pre>
60644e7191 )}
60644e7192 <div className="flex flex-wrap items-center gap-4 text-xs" style={{ color: "var(--text-muted)" }}>
60644e7193 <span>{authorName}</span>
60644e7194 <span>{timeAgo(commit.timestamp)}</span>
60644e7195 <span className="font-mono" style={{ color: "var(--text-faint)" }}>
60644e7196 {gitSha.slice(0, 12)}
60644e7197 </span>
60644e7198 {parentSha && (
60644e7199 <span>
60644e7200 parent{" "}
60644e7201 <Link
60644e7202 href={`/${owner}/${repo}/commit/${parentSha}`}
60644e7203 className="font-mono hover:underline"
60644e7204 style={{ color: "var(--accent)" }}
60644e7205 >
60644e7206 {parentSha.slice(0, 7)}
60644e7207 </Link>
60644e7208 </span>
60644e7209 )}
60644e7210 </div>
60644e7211 </div>
60644e7212
60644e7213 {files.length > 0 && (
62019fc214 <DiffViewer files={files} owner={owner} repo={repo} gitSha={gitSha} />
60644e7215 )}
60644e7216
60644e7217 {!diffData && !parentSha && (
60644e7218 <p className="text-sm" style={{ color: "var(--text-faint)" }}>
60644e7219 This is the initial commit — no diff available.
60644e7220 </p>
60644e7221 )}
60644e7222 </div>
60644e7223 );
60644e7224}