| 1da9874 | | | 1 | import type { Metadata } from "next"; |
| 60644e7 | | | 2 | import Link from "next/link"; |
| 62019fc | | | 3 | import { createHighlighter, type Highlighter } from "shiki"; |
| f0bb192 | | | 4 | import { groveApiUrl, timeAgo } from "@/lib/utils"; |
| 62019fc | | | 5 | import { DiffViewer } from "./diff-viewer"; |
| 62019fc | | | 6 | |
| 62019fc | | | 7 | let highlighter: Highlighter | null = null; |
| 62019fc | | | 8 | |
| 62019fc | | | 9 | async function getHL() { |
| 62019fc | | | 10 | if (!highlighter) { |
| 62019fc | | | 11 | highlighter = await createHighlighter({ |
| 62019fc | | | 12 | themes: ["vitesse-light", "vitesse-dark"], |
| 62019fc | | | 13 | langs: [ |
| 62019fc | | | 14 | "typescript", "tsx", "javascript", "jsx", "json", "markdown", |
| 62019fc | | | 15 | "css", "html", "python", "rust", "go", "ruby", "yaml", "toml", |
| 62019fc | | | 16 | "bash", "sql", "graphql", "xml", "c", "cpp", "java", "kotlin", |
| 62019fc | | | 17 | "swift", "lua", "diff", "dockerfile", "makefile", "ini", |
| 62019fc | | | 18 | ], |
| 62019fc | | | 19 | }); |
| 62019fc | | | 20 | } |
| 62019fc | | | 21 | return highlighter; |
| 62019fc | | | 22 | } |
| 62019fc | | | 23 | |
| 62019fc | | | 24 | function getLang(filepath: string): string { |
| 62019fc | | | 25 | const filename = filepath.split("/").pop() ?? ""; |
| 62019fc | | | 26 | const ext = filename.split(".").pop()?.toLowerCase() ?? ""; |
| 62019fc | | | 27 | const map: Record<string, string> = { |
| 62019fc | | | 28 | ts: "typescript", tsx: "tsx", js: "javascript", jsx: "jsx", |
| 62019fc | | | 29 | json: "json", md: "markdown", css: "css", html: "html", |
| 62019fc | | | 30 | py: "python", rs: "rust", go: "go", rb: "ruby", |
| 62019fc | | | 31 | yml: "yaml", yaml: "yaml", toml: "toml", |
| 62019fc | | | 32 | sh: "bash", bash: "bash", zsh: "bash", |
| 62019fc | | | 33 | sql: "sql", graphql: "graphql", svg: "xml", xml: "xml", |
| 62019fc | | | 34 | c: "c", cpp: "cpp", h: "c", hpp: "cpp", |
| 62019fc | | | 35 | java: "java", kt: "kotlin", swift: "swift", lua: "lua", diff: "diff", |
| 62019fc | | | 36 | }; |
| 62019fc | | | 37 | const nameMap: Record<string, string> = { |
| 62019fc | | | 38 | Dockerfile: "dockerfile", Makefile: "makefile", |
| 62019fc | | | 39 | ".gitignore": "gitignore", ".gitmodules": "ini", |
| 62019fc | | | 40 | }; |
| 818dc90 | | | 41 | if (filename.startsWith("Dockerfile")) return "dockerfile"; |
| 818dc90 | | | 42 | if (filename.startsWith("Makefile")) return "makefile"; |
| 62019fc | | | 43 | return nameMap[filename] ?? map[ext] ?? "text"; |
| 62019fc | | | 44 | } |
| 60644e7 | | | 45 | |
| 60644e7 | | | 46 | interface Props { |
| 60644e7 | | | 47 | params: Promise<{ owner: string; repo: string; sha: string }>; |
| 60644e7 | | | 48 | } |
| 60644e7 | | | 49 | |
| 1da9874 | | | 50 | export async function generateMetadata({ params }: Props): Promise<Metadata> { |
| 86450dc | | | 51 | const { repo, sha } = await params; |
| 86450dc | | | 52 | return { title: `${sha.slice(0, 7)} · ${repo}` }; |
| 1da9874 | | | 53 | } |
| 1da9874 | | | 54 | |
| 62019fc | | | 55 | export interface DiffLine { |
| 62019fc | | | 56 | type: "add" | "del" | "context"; |
| 62019fc | | | 57 | content: string; |
| 62019fc | | | 58 | oldNum: number | null; |
| 62019fc | | | 59 | newNum: number | null; |
| 62019fc | | | 60 | html?: string; |
| 62019fc | | | 61 | } |
| 62019fc | | | 62 | |
| 62019fc | | | 63 | export interface DiffHunk { |
| 60644e7 | | | 64 | header: string; |
| 62019fc | | | 65 | lines: DiffLine[]; |
| 60644e7 | | | 66 | } |
| 60644e7 | | | 67 | |
| 62019fc | | | 68 | export interface ParsedFile { |
| 60644e7 | | | 69 | path: string; |
| 60644e7 | | | 70 | hunks: DiffHunk[]; |
| 60644e7 | | | 71 | } |
| 60644e7 | | | 72 | |
| 60644e7 | | | 73 | function parseUnifiedDiff(raw: string): DiffHunk[] { |
| 60644e7 | | | 74 | const hunks: DiffHunk[] = []; |
| 60644e7 | | | 75 | let currentHunk: DiffHunk | null = null; |
| 60644e7 | | | 76 | let oldLine = 0; |
| 60644e7 | | | 77 | let newLine = 0; |
| 60644e7 | | | 78 | |
| 60644e7 | | | 79 | for (const line of raw.split("\n")) { |
| 60644e7 | | | 80 | const hunkMatch = line.match(/^@@ -(\d+)(?:,\d+)? \+(\d+)(?:,\d+)? @@(.*)/); |
| 60644e7 | | | 81 | if (hunkMatch) { |
| 60644e7 | | | 82 | currentHunk = { header: line, lines: [] }; |
| 60644e7 | | | 83 | hunks.push(currentHunk); |
| 60644e7 | | | 84 | oldLine = parseInt(hunkMatch[1]); |
| 60644e7 | | | 85 | newLine = parseInt(hunkMatch[2]); |
| 60644e7 | | | 86 | continue; |
| 60644e7 | | | 87 | } |
| 60644e7 | | | 88 | if (!currentHunk) continue; |
| 60644e7 | | | 89 | if (line.startsWith("+")) { |
| 60644e7 | | | 90 | currentHunk.lines.push({ type: "add", content: line.slice(1), oldNum: null, newNum: newLine++ }); |
| 60644e7 | | | 91 | } else if (line.startsWith("-")) { |
| 60644e7 | | | 92 | currentHunk.lines.push({ type: "del", content: line.slice(1), oldNum: oldLine++, newNum: null }); |
| 60644e7 | | | 93 | } else if (line.startsWith(" ") || line === "") { |
| 60644e7 | | | 94 | currentHunk.lines.push({ type: "context", content: line.slice(1), oldNum: oldLine++, newNum: newLine++ }); |
| 60644e7 | | | 95 | } |
| 60644e7 | | | 96 | } |
| 60644e7 | | | 97 | return hunks; |
| 60644e7 | | | 98 | } |
| 60644e7 | | | 99 | |
| 80fafdf | | | 100 | async function getCommit(owner: string, repo: string, sha: string) { |
| 60644e7 | | | 101 | try { |
| 60644e7 | | | 102 | const res = await fetch( |
| f0bb192 | | | 103 | `${groveApiUrl}/api/repos/${owner}/${repo}/commits/${sha}?limit=1`, |
| 60644e7 | | | 104 | { cache: "no-store" } |
| 60644e7 | | | 105 | ); |
| 60644e7 | | | 106 | if (!res.ok) return null; |
| 60644e7 | | | 107 | const data = await res.json(); |
| 60644e7 | | | 108 | return data.commits?.[0] ?? null; |
| 60644e7 | | | 109 | } catch { |
| 60644e7 | | | 110 | return null; |
| 60644e7 | | | 111 | } |
| 60644e7 | | | 112 | } |
| 60644e7 | | | 113 | |
| 60644e7 | | | 114 | |
| 80fafdf | | | 115 | async function getDiff(owner: string, repo: string, base: string, head: string) { |
| 60644e7 | | | 116 | try { |
| 60644e7 | | | 117 | const res = await fetch( |
| f0bb192 | | | 118 | `${groveApiUrl}/api/repos/${owner}/${repo}/diff?base=${base}&head=${head}`, |
| 60644e7 | | | 119 | { cache: "no-store" } |
| 60644e7 | | | 120 | ); |
| 60644e7 | | | 121 | if (!res.ok) return null; |
| 60644e7 | | | 122 | return res.json(); |
| 60644e7 | | | 123 | } catch { |
| 60644e7 | | | 124 | return null; |
| 60644e7 | | | 125 | } |
| 60644e7 | | | 126 | } |
| 60644e7 | | | 127 | |
| 60644e7 | | | 128 | export default async function CommitPage({ params }: Props) { |
| 60644e7 | | | 129 | const { owner, repo, sha } = await params; |
| 60644e7 | | | 130 | |
| 80fafdf | | | 131 | const commit = await getCommit(owner, repo, sha); |
| 60644e7 | | | 132 | |
| 60644e7 | | | 133 | if (!commit) { |
| 60644e7 | | | 134 | return ( |
| 60644e7 | | | 135 | <div className="max-w-3xl mx-auto px-4 py-16"> |
| 60644e7 | | | 136 | <h1 className="text-lg" style={{ color: "var(--text-secondary)" }}> |
| 60644e7 | | | 137 | Commit not found |
| 60644e7 | | | 138 | </h1> |
| 60644e7 | | | 139 | </div> |
| 60644e7 | | | 140 | ); |
| 60644e7 | | | 141 | } |
| 60644e7 | | | 142 | |
| 530592e | | | 143 | const gitSha = commit.hash ?? sha; |
| 530592e | | | 144 | const parentSha = commit.parents?.[0] ?? null; |
| 80fafdf | | | 145 | const diffData = parentSha ? await getDiff(owner, repo, parentSha, gitSha) : null; |
| 60644e7 | | | 146 | |
| 60644e7 | | | 147 | const files: ParsedFile[] = []; |
| 60644e7 | | | 148 | if (diffData?.diffs) { |
| 60644e7 | | | 149 | for (const file of diffData.diffs) { |
| 60644e7 | | | 150 | if (file.is_binary) { |
| 60644e7 | | | 151 | files.push({ path: file.path, hunks: [] }); |
| 60644e7 | | | 152 | } else { |
| 60644e7 | | | 153 | const hunks = parseUnifiedDiff(file.diff); |
| 60644e7 | | | 154 | files.push({ path: file.path, hunks }); |
| 60644e7 | | | 155 | } |
| 60644e7 | | | 156 | } |
| 60644e7 | | | 157 | } |
| 60644e7 | | | 158 | |
| 62019fc | | | 159 | // Syntax-highlight diff lines using shiki |
| 62019fc | | | 160 | try { |
| 62019fc | | | 161 | const hl = await getHL(); |
| 62019fc | | | 162 | const loadedLangs = hl.getLoadedLanguages(); |
| 62019fc | | | 163 | for (const file of files) { |
| 62019fc | | | 164 | if (file.hunks.length === 0) continue; |
| 62019fc | | | 165 | const lang = getLang(file.path); |
| 62019fc | | | 166 | const effectiveLang = loadedLangs.includes(lang) ? lang : "text"; |
| 62019fc | | | 167 | // Collect all content lines in diff order |
| 62019fc | | | 168 | const allLines: DiffLine[] = []; |
| 62019fc | | | 169 | for (const hunk of file.hunks) { |
| 62019fc | | | 170 | for (const line of hunk.lines) { |
| 62019fc | | | 171 | allLines.push(line); |
| 62019fc | | | 172 | } |
| 62019fc | | | 173 | } |
| 62019fc | | | 174 | const code = allLines.map((l) => l.content).join("\n"); |
| 62019fc | | | 175 | const highlighted = hl.codeToHtml(code, { |
| 62019fc | | | 176 | lang: effectiveLang, |
| 62019fc | | | 177 | themes: { light: "vitesse-light", dark: "vitesse-dark" }, |
| 62019fc | | | 178 | defaultColor: false, |
| 62019fc | | | 179 | }); |
| 62019fc | | | 180 | const codeMatch = highlighted.match(/<code[^>]*>([\s\S]*)<\/code>/); |
| 62019fc | | | 181 | if (codeMatch) { |
| 62019fc | | | 182 | const htmlLines = codeMatch[1].split("\n"); |
| 62019fc | | | 183 | for (let i = 0; i < allLines.length && i < htmlLines.length; i++) { |
| 62019fc | | | 184 | allLines[i].html = htmlLines[i]; |
| 62019fc | | | 185 | } |
| 60644e7 | | | 186 | } |
| 60644e7 | | | 187 | } |
| 62019fc | | | 188 | } catch (e) { |
| 62019fc | | | 189 | console.error("[shiki] Failed to highlight diff:", e); |
| 60644e7 | | | 190 | } |
| 60644e7 | | | 191 | |
| 62019fc | | | 192 | const subject = commit.subject ?? ""; |
| 62019fc | | | 193 | const body = commit.body ?? ""; |
| 62019fc | | | 194 | const authorName = commit.author?.split("<")[0]?.trim() ?? commit.author; |
| 62019fc | | | 195 | |
| 60644e7 | | | 196 | return ( |
| 62019fc | | | 197 | <div className="px-4 py-6 mx-auto" style={{ maxWidth: "90rem" }}> |
| 60644e7 | | | 198 | <div |
| 60644e7 | | | 199 | className="mb-6 px-4 py-4" |
| 60644e7 | | | 200 | style={{ |
| 60644e7 | | | 201 | backgroundColor: "var(--bg-card)", |
| 60644e7 | | | 202 | border: "1px solid var(--border-subtle)", |
| 60644e7 | | | 203 | }} |
| 60644e7 | | | 204 | > |
| 60644e7 | | | 205 | <h1 className="text-lg mb-1">{subject}</h1> |
| 60644e7 | | | 206 | {body && ( |
| 60644e7 | | | 207 | <pre |
| 60644e7 | | | 208 | className="text-sm whitespace-pre-wrap mt-3 mb-3" |
| 60644e7 | | | 209 | style={{ color: "var(--text-muted)" }} |
| 60644e7 | | | 210 | > |
| 60644e7 | | | 211 | {body} |
| 60644e7 | | | 212 | </pre> |
| 60644e7 | | | 213 | )} |
| 60644e7 | | | 214 | <div className="flex flex-wrap items-center gap-4 text-xs" style={{ color: "var(--text-muted)" }}> |
| 60644e7 | | | 215 | <span>{authorName}</span> |
| 60644e7 | | | 216 | <span>{timeAgo(commit.timestamp)}</span> |
| 60644e7 | | | 217 | <span className="font-mono" style={{ color: "var(--text-faint)" }}> |
| 60644e7 | | | 218 | {gitSha.slice(0, 12)} |
| 60644e7 | | | 219 | </span> |
| 60644e7 | | | 220 | {parentSha && ( |
| 60644e7 | | | 221 | <span> |
| 60644e7 | | | 222 | parent{" "} |
| 60644e7 | | | 223 | <Link |
| 60644e7 | | | 224 | href={`/${owner}/${repo}/commit/${parentSha}`} |
| 60644e7 | | | 225 | className="font-mono hover:underline" |
| 60644e7 | | | 226 | style={{ color: "var(--accent)" }} |
| 60644e7 | | | 227 | > |
| 60644e7 | | | 228 | {parentSha.slice(0, 7)} |
| 60644e7 | | | 229 | </Link> |
| 60644e7 | | | 230 | </span> |
| 60644e7 | | | 231 | )} |
| 60644e7 | | | 232 | </div> |
| 60644e7 | | | 233 | </div> |
| 60644e7 | | | 234 | |
| 60644e7 | | | 235 | {files.length > 0 && ( |
| 62019fc | | | 236 | <DiffViewer files={files} owner={owner} repo={repo} gitSha={gitSha} /> |
| 60644e7 | | | 237 | )} |
| 60644e7 | | | 238 | |
| 60644e7 | | | 239 | {!diffData && !parentSha && ( |
| 60644e7 | | | 240 | <p className="text-sm" style={{ color: "var(--text-faint)" }}> |
| 60644e7 | | | 241 | This is the initial commit — no diff available. |
| 60644e7 | | | 242 | </p> |
| 60644e7 | | | 243 | )} |
| 60644e7 | | | 244 | </div> |
| 60644e7 | | | 245 | ); |
| 60644e7 | | | 246 | } |