CommitPage.tsx

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