19.8 KB613 lines
Blame
1"use client";
2
3import { useState, useEffect } from "react";
4import Link from "next/link";
5import { useAuth } from "@/lib/auth";
6import {
7 diffs,
8 repos,
9 type Diff,
10 type Comment,
11 type Review,
12} from "@/lib/api";
13import { Skeleton } from "@/app/components/skeleton";
14
15interface Props {
16 params: Promise<{ owner: string; repo: string; number: string }>;
17}
18
19function timeAgo(dateStr: string): string {
20 const seconds = Math.floor(
21 (Date.now() - new Date(dateStr).getTime()) / 1000
22 );
23 if (seconds < 60) return "just now";
24 if (seconds < 3600) return `${Math.floor(seconds / 60)}m ago`;
25 if (seconds < 86400) return `${Math.floor(seconds / 3600)}h ago`;
26 if (seconds < 2592000) return `${Math.floor(seconds / 86400)}d ago`;
27 return new Date(dateStr).toLocaleDateString();
28}
29
30interface DiffFile {
31 path: string;
32 hunks: DiffHunk[];
33}
34
35interface DiffHunk {
36 header: string;
37 lines: DiffLine[];
38}
39
40interface DiffLine {
41 type: "add" | "del" | "context";
42 content: string;
43 oldNum: number | null;
44 newNum: number | null;
45}
46
47/** Parse unified diff hunks from a diff string (single file or full) */
48function parseUnifiedDiff(raw: string): DiffHunk[] {
49 const hunks: DiffHunk[] = [];
50 let currentHunk: DiffHunk | null = null;
51 let oldLine = 0;
52 let newLine = 0;
53
54 for (const line of raw.split("\n")) {
55 const hunkMatch = line.match(/^@@ -(\d+)(?:,\d+)? \+(\d+)(?:,\d+)? @@(.*)/);
56 if (hunkMatch) {
57 currentHunk = { header: line, lines: [] };
58 hunks.push(currentHunk);
59 oldLine = parseInt(hunkMatch[1]);
60 newLine = parseInt(hunkMatch[2]);
61 continue;
62 }
63 if (!currentHunk) continue;
64 if (line.startsWith("+")) {
65 currentHunk.lines.push({ type: "add", content: line.slice(1), oldNum: null, newNum: newLine++ });
66 } else if (line.startsWith("-")) {
67 currentHunk.lines.push({ type: "del", content: line.slice(1), oldNum: oldLine++, newNum: null });
68 } else if (line.startsWith(" ") || line === "") {
69 currentHunk.lines.push({ type: "context", content: line.slice(1), oldNum: oldLine++, newNum: newLine++ });
70 }
71 }
72 return hunks;
73}
74
75/** Parse a full multi-file git diff string */
76function parseDiff(raw: string): DiffFile[] {
77 const files: DiffFile[] = [];
78 const fileChunks = raw.split(/^diff --git /m).filter(Boolean);
79
80 for (const chunk of fileChunks) {
81 const lines = chunk.split("\n");
82 const headerMatch = lines[0]?.match(/a\/(.+?) b\/(.+)/);
83 const path = headerMatch ? headerMatch[2] : "unknown";
84 const hunks = parseUnifiedDiff(chunk);
85 if (hunks.length > 0) {
86 files.push({ path, hunks });
87 }
88 }
89 return files;
90}
91
92export default function DiffDetailPage({ params }: Props) {
93 const { user } = useAuth();
94 const [owner, setOwner] = useState("");
95 const [repo, setRepo] = useState("");
96 const [diffNumber, setDiffNumber] = useState(0);
97 const [diff, setDiff] = useState<Diff | null>(null);
98 const [comments, setComments] = useState<Comment[]>([]);
99 const [reviews, setReviews] = useState<Review[]>([]);
100 const [diffFiles, setDiffFiles] = useState<DiffFile[]>([]);
101 const [loading, setLoading] = useState(true);
102 const [activeTab, setActiveTab] = useState<"conversation" | "changes">("conversation");
103
104 // Comment form
105 const [commentBody, setCommentBody] = useState("");
106 const [commenting, setCommenting] = useState(false);
107
108 // Action states
109 const [landing, setLanding] = useState(false);
110 const [closing, setClosing] = useState(false);
111 const [actionError, setActionError] = useState("");
112
113 useEffect(() => {
114 params.then(({ owner, repo, number }) => {
115 setOwner(owner);
116 setRepo(repo);
117 setDiffNumber(parseInt(number));
118 document.title = `D${number} · ${repo}`;
119 });
120 }, [params]);
121
122 useEffect(() => {
123 if (diff) {
124 document.title = `${diff.title} D${diff.number} · ${repo}`;
125 }
126 }, [diff, owner, repo]);
127
128 useEffect(() => {
129 if (!owner || !diffNumber) return;
130 loadData();
131 }, [owner, repo, diffNumber]);
132
133 async function loadData() {
134 setLoading(true);
135 try {
136 const data = await diffs.get(owner, repo, diffNumber);
137 setDiff(data.diff);
138 setComments(data.comments);
139 setReviews(data.reviews);
140
141 // Fetch diff
142 if (data.diff.status === "open") {
143 try {
144 const base = data.diff.base_commit ?? data.diff.head_commit + '^';
145 const diffData = await repos.diff(
146 owner,
147 repo,
148 base,
149 data.diff.head_commit
150 );
151 if (diffData.diffs) {
152 const parsed: DiffFile[] = [];
153 for (const d of diffData.diffs) {
154 if (d.is_binary) continue;
155 const hunks = parseUnifiedDiff(d.diff);
156 if (hunks.length > 0) {
157 parsed.push({ path: d.path, hunks });
158 }
159 }
160 setDiffFiles(parsed);
161 }
162 } catch {
163 // Diff may not be available
164 }
165 }
166 } catch {
167 // Diff not found
168 } finally {
169 setLoading(false);
170 }
171 }
172
173 async function handleLand() {
174 setActionError("");
175 setLanding(true);
176 try {
177 const { diff: updated } = await diffs.land(owner, repo, diffNumber);
178 setDiff(updated);
179 } catch (err: unknown) {
180 setActionError(err instanceof Error ? err.message : "Land failed");
181 } finally {
182 setLanding(false);
183 }
184 }
185
186 async function handleClose() {
187 setActionError("");
188 setClosing(true);
189 try {
190 const { diff: updated } = await diffs.update(owner, repo, diffNumber, { status: "closed" });
191 setDiff(updated);
192 } catch (err: unknown) {
193 setActionError(err instanceof Error ? err.message : "Failed to close");
194 } finally {
195 setClosing(false);
196 }
197 }
198
199 async function handleReopen() {
200 setActionError("");
201 try {
202 const { diff: updated } = await diffs.update(owner, repo, diffNumber, { status: "open" });
203 setDiff(updated);
204 } catch (err: unknown) {
205 setActionError(err instanceof Error ? err.message : "Failed to reopen");
206 }
207 }
208
209 async function handleComment(e: React.FormEvent) {
210 e.preventDefault();
211 if (!commentBody.trim()) return;
212 setCommenting(true);
213 try {
214 const { comment } = await diffs.comment(owner, repo, diffNumber, {
215 body: commentBody,
216 });
217 setComments((prev) => [...prev, comment]);
218 setCommentBody("");
219 } catch {
220 // silently fail
221 } finally {
222 setCommenting(false);
223 }
224 }
225
226 if (loading || !diff) {
227 return (
228 <div className="max-w-3xl mx-auto px-4 py-6">
229 {loading ? (
230 <div className="space-y-4">
231 <Skeleton width="200px" height="1.25rem" />
232 <Skeleton width="100%" height="4rem" />
233 <div className="flex gap-3">
234 <Skeleton width="80px" height="2rem" />
235 <Skeleton width="80px" height="2rem" />
236 </div>
237 <Skeleton width="100%" height="6rem" />
238 </div>
239 ) : (
240 <p className="text-sm" style={{ color: "var(--text-faint)" }}>
241 Diff not found
242 </p>
243 )}
244 </div>
245 );
246 }
247
248 // Merge activity into a single timeline sorted by date
249 const activity = [
250 ...comments.map((c) => ({ ...c, kind: "comment" as const })),
251 ...reviews.map((r) => ({ ...r, kind: "review" as const })),
252 ].sort(
253 (a, b) =>
254 new Date(a.created_at).getTime() - new Date(b.created_at).getTime()
255 );
256
257 return (
258 <div className="max-w-3xl mx-auto px-4 py-6">
259 <div className="text-sm mb-1">
260 <Link
261 href={`/${owner}/${repo}/diffs`}
262 style={{ color: "var(--text-muted)" }}
263 className="hover:underline"
264 >
265 Diffs
266 </Link>
267 </div>
268 <h1 className="text-lg mb-1">
269 {diff.title}
270 <span className="font-normal" style={{ color: "var(--text-muted)" }}>
271 {" "}D{diff.number}
272 </span>
273 </h1>
274 <div className="text-sm mb-6" style={{ color: "var(--text-muted)" }}>
275 <span
276 className="capitalize"
277 style={{
278 color:
279 diff.status === "open"
280 ? "var(--status-open-text)"
281 : diff.status === "landed"
282 ? "var(--status-merged-text)"
283 : "var(--status-closed-text)",
284 }}
285 >
286 {diff.status}
287 </span>
288 {" \u2014 "}
289 {diff.author_name} submitted{" "}
290 <code className="text-xs font-mono">{diff.head_commit?.slice(0, 12)}</code>
291 </div>
292
293 {diff.description && (
294 <div
295 className="text-sm mb-6 pb-6"
296 style={{
297 color: "var(--text-secondary)",
298 borderBottom: "1px solid var(--border)",
299 }}
300 >
301 <p className="whitespace-pre-wrap">{diff.description}</p>
302 </div>
303 )}
304
305 {/* Land / Close actions */}
306 {user && diff.status === "open" && (
307 <div
308 className="flex items-center gap-3 mb-6 pb-6"
309 style={{ borderBottom: "1px solid var(--border)" }}
310 >
311 <button
312 onClick={handleLand}
313 disabled={landing}
314 className="text-sm px-3 py-1"
315 style={{
316 backgroundColor: "var(--status-merged-bg)",
317 color: "var(--status-merged-text)",
318 border: "none",
319 cursor: landing ? "default" : "pointer",
320 opacity: landing ? 0.5 : 1,
321 font: "inherit",
322 fontSize: "inherit",
323 }}
324 >
325 {landing ? "Landing..." : "Land"}
326 </button>
327 <button
328 onClick={handleClose}
329 disabled={closing}
330 className="text-sm px-3 py-1"
331 style={{
332 backgroundColor: "var(--bg-inset)",
333 border: "1px solid var(--border)",
334 color: "var(--text-muted)",
335 cursor: closing ? "default" : "pointer",
336 font: "inherit",
337 fontSize: "inherit",
338 }}
339 >
340 {closing ? "Closing..." : "Close"}
341 </button>
342 {actionError && (
343 <span className="text-xs" style={{ color: "var(--error-text)" }}>
344 {actionError}
345 </span>
346 )}
347 </div>
348 )}
349
350 {user && diff.status === "closed" && (
351 <div
352 className="mb-6 pb-6"
353 style={{ borderBottom: "1px solid var(--border)" }}
354 >
355 <button
356 onClick={handleReopen}
357 className="text-sm px-3 py-1"
358 style={{
359 backgroundColor: "var(--bg-inset)",
360 border: "1px solid var(--border)",
361 color: "var(--text-muted)",
362 cursor: "pointer",
363 font: "inherit",
364 fontSize: "inherit",
365 }}
366 >
367 Reopen
368 </button>
369 </div>
370 )}
371
372 {/* Tabs */}
373 <div className="flex gap-4 text-sm mb-6">
374 <button
375 onClick={() => setActiveTab("conversation")}
376 className="hover:underline"
377 style={{
378 color: activeTab === "conversation" ? "var(--text-primary)" : "var(--text-muted)",
379 fontWeight: activeTab === "conversation" ? 600 : 400,
380 background: "none",
381 border: "none",
382 cursor: "pointer",
383 font: "inherit",
384 fontSize: "inherit",
385 }}
386 >
387 Conversation
388 </button>
389 <button
390 onClick={() => setActiveTab("changes")}
391 className="hover:underline"
392 style={{
393 color: activeTab === "changes" ? "var(--text-primary)" : "var(--text-muted)",
394 fontWeight: activeTab === "changes" ? 600 : 400,
395 background: "none",
396 border: "none",
397 cursor: "pointer",
398 font: "inherit",
399 fontSize: "inherit",
400 }}
401 >
402 Changes{diffFiles.length > 0 && ` (${diffFiles.length})`}
403 </button>
404 </div>
405
406 {activeTab === "conversation" && (
407 <>
408 {activity.map((item) =>
409 item.kind === "comment" ? (
410 <div
411 key={`c-${item.id}`}
412 className="mb-4 pb-4"
413 style={{ borderBottom: "1px solid var(--divide)" }}
414 >
415 <div className="flex items-center justify-between text-xs mb-1">
416 <span style={{ color: "var(--text-secondary)" }}>
417 {item.author_name}
418 </span>
419 <span style={{ color: "var(--text-faint)" }}>
420 {timeAgo(item.created_at)}
421 </span>
422 </div>
423 {item.file_path && (
424 <div
425 className="text-xs font-mono mb-1"
426 style={{ color: "var(--text-muted)" }}
427 >
428 {item.file_path}
429 {item.line_number ? `:${item.line_number}` : ""}
430 </div>
431 )}
432 <p
433 className="text-sm whitespace-pre-wrap"
434 style={{ color: "var(--text-secondary)" }}
435 >
436 {item.body}
437 </p>
438 </div>
439 ) : (
440 <div
441 key={`r-${item.id}`}
442 className="mb-4 pb-4 text-sm"
443 style={{ borderBottom: "1px solid var(--divide)" }}
444 >
445 <span style={{ color: "var(--text-secondary)" }}>
446 {item.reviewer_name}
447 </span>{" "}
448 <span
449 style={{
450 color:
451 item.status === "approved"
452 ? "var(--status-open-text)"
453 : "var(--status-closed-text)",
454 }}
455 >
456 {item.status === "approved" ? "approved" : "requested changes"}
457 </span>
458 {item.body && (
459 <p className="mt-1" style={{ color: "var(--text-muted)" }}>
460 {item.body}
461 </p>
462 )}
463 <span className="text-xs" style={{ color: "var(--text-faint)" }}>
464 {" "}{timeAgo(item.created_at)}
465 </span>
466 </div>
467 )
468 )}
469
470 {activity.length === 0 && (
471 <p className="text-sm mb-6" style={{ color: "var(--text-faint)" }}>
472 No activity yet.
473 </p>
474 )}
475
476 {/* Comment form */}
477 {user && diff.status === "open" && (
478 <form onSubmit={handleComment} className="mt-4">
479 <textarea
480 value={commentBody}
481 onChange={(e) => setCommentBody(e.target.value)}
482 rows={3}
483 placeholder="Leave a comment..."
484 className="w-full px-2 py-1 text-sm focus:outline-none resize-y mb-2"
485 style={{
486 backgroundColor: "var(--bg-input)",
487 border: "1px solid var(--border)",
488 color: "var(--text-primary)",
489 }}
490 />
491 <button
492 type="submit"
493 disabled={commenting || !commentBody.trim()}
494 className="text-sm px-3 py-1"
495 style={{
496 backgroundColor: "var(--accent)",
497 color: "var(--accent-text)",
498 opacity: commenting || !commentBody.trim() ? 0.5 : 1,
499 border: "none",
500 cursor: commenting ? "default" : "pointer",
501 font: "inherit",
502 fontSize: "inherit",
503 }}
504 >
505 {commenting ? "Commenting..." : "Comment"}
506 </button>
507 </form>
508 )}
509 </>
510 )}
511
512 {activeTab === "changes" && (
513 <div>
514 {diffFiles.length === 0 ? (
515 <p className="text-sm" style={{ color: "var(--text-faint)" }}>
516 No changes to display.
517 </p>
518 ) : (
519 diffFiles.map((file) => (
520 <div key={file.path} className="mb-6">
521 <div
522 className="text-sm font-mono py-2 px-3"
523 style={{
524 backgroundColor: "var(--bg-inset)",
525 border: "1px solid var(--border)",
526 borderBottom: "none",
527 color: "var(--text-secondary)",
528 }}
529 >
530 {file.path}
531 </div>
532 <div
533 className="overflow-x-auto"
534 style={{ border: "1px solid var(--border)" }}
535 >
536 <table className="w-full text-sm font-mono" style={{ borderCollapse: "collapse" }}>
537 <tbody>
538 {file.hunks.map((hunk, hi) => (
539 <>
540 <tr key={`h-${hi}`}>
541 <td
542 colSpan={3}
543 className="text-xs py-1 px-3"
544 style={{
545 backgroundColor: "var(--bg-inset)",
546 color: "var(--text-faint)",
547 }}
548 >
549 {hunk.header}
550 </td>
551 </tr>
552 {hunk.lines.map((line, li) => (
553 <tr
554 key={`${hi}-${li}`}
555 style={{
556 backgroundColor:
557 line.type === "add"
558 ? "var(--diff-add-bg, rgba(46,160,67,0.1))"
559 : line.type === "del"
560 ? "var(--diff-del-bg, rgba(248,81,73,0.1))"
561 : "transparent",
562 }}
563 >
564 <td
565 className="text-right select-none px-2 py-0 w-10"
566 style={{
567 color: "var(--text-faint)",
568 borderRight: "1px solid var(--divide)",
569 fontSize: "0.75rem",
570 }}
571 >
572 {line.oldNum ?? ""}
573 </td>
574 <td
575 className="text-right select-none px-2 py-0 w-10"
576 style={{
577 color: "var(--text-faint)",
578 borderRight: "1px solid var(--divide)",
579 fontSize: "0.75rem",
580 }}
581 >
582 {line.newNum ?? ""}
583 </td>
584 <td
585 className="pl-3 py-0 whitespace-pre"
586 style={{
587 color:
588 line.type === "add"
589 ? "var(--diff-add-text, #3fb950)"
590 : line.type === "del"
591 ? "var(--diff-del-text, #f85149)"
592 : "var(--text-secondary)",
593 }}
594 >
595 {line.type === "add" ? "+" : line.type === "del" ? "-" : " "}
596 {line.content}
597 </td>
598 </tr>
599 ))}
600 </>
601 ))}
602 </tbody>
603 </table>
604 </div>
605 </div>
606 ))
607 )}
608 </div>
609 )}
610 </div>
611 );
612}
613