1import { useState, useEffect } from 'react'
2import { Link, useParams, useNavigate } from 'react-router-dom'
3import { format } from 'date-fns'
4import { ArrowLeft, GitCommit } from 'lucide-react'
5import { Skeleton } from '@/components/ui/skeleton'
6import { getCommit } from '@/lib/gitApi'
7import type { GitCommitDetail } from '@/lib/gitApi'
8import { useRepo } from '@/lib/repo'
9import { FileDiffView } from '@/components/code/FileDiffView'
10
11// Commit detail page (/:repo/commit/:hash). Shows commit metadata, full message,
12// parent links, and the list of files changed with add/modify/delete/rename status.
13export function CommitPage() {
14 const { hash } = useParams<{ hash: string }>()
15 const navigate = useNavigate()
16 const repo = useRepo()
17 const [commit, setCommit] = useState<GitCommitDetail | null>(null)
18 const [loading, setLoading] = useState(true)
19 const [error, setError] = useState<string | null>(null)
20
21 useEffect(() => {
22 setLoading(true)
23 setError(null)
24 getCommit(hash!)
25 .then(setCommit)
26 .catch((e: Error) => setError(e.message))
27 .finally(() => setLoading(false))
28 }, [hash])
29
30 if (loading) return <CommitPageSkeleton />
31
32 if (error) {
33 return (
34 <div className="py-16 text-center text-sm text-destructive">
35 Failed to load commit: {error}
36 </div>
37 )
38 }
39
40 if (!commit) return null
41
42 const date = new Date(commit.date)
43
44 return (
45 <div>
46 <button
47 onClick={() => navigate(-1)}
48 className="mb-6 flex items-center gap-1.5 text-sm text-muted-foreground hover:text-foreground"
49 >
50 <ArrowLeft className="size-3.5" />
51 Back
52 </button>
53
54 {/* Header */}
55 <div className="mb-6 rounded-md border border-border p-5">
56 <div className="mb-1 flex items-start gap-3">
57 <GitCommit className="mt-1 size-5 shrink-0 text-muted-foreground" />
58 <h1 className="text-lg font-semibold leading-snug">{commit.message}</h1>
59 </div>
60
61 {/* Full message body (if multi-line) */}
62 {commit.fullMessage.includes('\n') && (
63 <pre className="mb-4 ml-8 mt-3 whitespace-pre-wrap font-sans text-sm text-muted-foreground">
64 {commit.fullMessage.split('\n').slice(1).join('\n').trim()}
65 </pre>
66 )}
67
68 <div className="ml-8 mt-3 flex flex-wrap items-center gap-x-4 gap-y-1 text-sm text-muted-foreground">
69 <span>
70 <span className="font-medium text-foreground">{commit.authorName}</span>
71 {commit.authorEmail && (
72 <span> <{commit.authorEmail}></span>
73 )}
74 </span>
75 <span title={date.toISOString()}>{format(date, 'PPP')}</span>
76 </div>
77
78 <div className="ml-8 mt-3 flex flex-wrap gap-3 text-xs">
79 <span className="text-muted-foreground">
80 commit{' '}
81 <code className="font-mono text-foreground">{commit.hash}</code>
82 </span>
83 {commit.parents.map((p) => (
84 <span key={p} className="text-muted-foreground">
85 parent{' '}
86 <Link
87 to={repo ? `/${repo}/commit/${p}` : `/commit/${p}`}
88 className="font-mono text-foreground hover:underline"
89 >
90 {p.slice(0, 7)}
91 </Link>
92 </span>
93 ))}
94 </div>
95 </div>
96
97 {/* Changed files — each row is collapsible and loads its diff lazily */}
98 <div>
99 <h2 className="mb-3 text-sm font-semibold text-muted-foreground">
100 {commit.files.length} file{commit.files.length !== 1 ? 's' : ''} changed
101 </h2>
102 <div className="overflow-hidden rounded-md border border-border divide-y divide-border">
103 {commit.files.length === 0 && (
104 <p className="px-4 py-4 text-sm text-muted-foreground">No file changes.</p>
105 )}
106 {commit.files.map((file) => (
107 <FileDiffView
108 key={file.path}
109 sha={commit.hash}
110 path={file.path}
111 oldPath={file.oldPath}
112 status={file.status}
113 />
114 ))}
115 </div>
116 </div>
117 </div>
118 )
119}
120
121function CommitPageSkeleton() {
122 return (
123 <div className="space-y-6">
124 <Skeleton className="h-4 w-24" />
125 <div className="rounded-md border border-border p-5 space-y-3">
126 <Skeleton className="h-6 w-3/4" />
127 <Skeleton className="h-4 w-1/3" />
128 <Skeleton className="h-3 w-1/2" />
129 </div>
130 <div className="rounded-md border border-border divide-y divide-border">
131 {Array.from({ length: 5 }).map((_, i) => (
132 <div key={i} className="flex items-center gap-3 px-4 py-2.5">
133 <Skeleton className="size-4" />
134 <Skeleton className="h-4 flex-1" />
135 </div>
136 ))}
137 </div>
138 </div>
139 )
140}