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