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