CommitPage.tsx

  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> &lt;{commit.authorEmail}&gt;</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}