1// Commit detail page (/:repo/commit/:hash). Shows commit metadata, full
2// message, parent links, and changed files with lazy diffs.
3
4import { gql } from "@apollo/client";
5import { useQuery } from "@apollo/client/react";
6import { format } from "date-fns";
7import { ArrowLeft, GitCommit } from "lucide-react";
8import { Link, useParams, useNavigate } from "react-router";
9
10import { FileDiffView } from "@/components/code/FileDiffView";
11import { Skeleton } from "@/components/ui/skeleton";
12import { useRepo } from "@/lib/repo";
13
14const COMMIT_QUERY = gql`
15 query CommitPageDetail($repo: String, $hash: String!) {
16 repository(ref: $repo) {
17 commit(hash: $hash) {
18 hash
19 shortHash
20 message
21 fullMessage
22 authorName
23 authorEmail
24 date
25 parents
26 files {
27 nodes {
28 path
29 oldPath
30 status
31 }
32 }
33 }
34 }
35 }
36`;
37
38interface CommitQueryData {
39 repository: {
40 commit: {
41 hash: string;
42 shortHash: string;
43 message: string;
44 fullMessage: string;
45 authorName: string;
46 authorEmail: string | null;
47 date: string;
48 parents: string[];
49 files: {
50 nodes: { path: string; oldPath: string | null; status: string }[];
51 } | null;
52 } | null;
53 } | null;
54}
55
56export function CommitPage() {
57 const { hash } = useParams<{ hash: string }>();
58 const navigate = useNavigate();
59 const repo = useRepo();
60
61 const { data, loading, error } = useQuery<CommitQueryData>(COMMIT_QUERY, {
62 variables: { repo, hash },
63 skip: !hash,
64 });
65
66 if (loading) return <CommitPageSkeleton />;
67
68 if (error) {
69 return (
70 <div className="text-destructive py-16 text-center text-sm">
71 Failed to load commit: {error.message}
72 </div>
73 );
74 }
75
76 const commit = data?.repository?.commit;
77 if (!commit) return null;
78
79 const date = new Date(commit.date);
80 const files = commit.files?.nodes ?? [];
81
82 return (
83 <div>
84 <button
85 onClick={() => {
86 void navigate(-1);
87 }}
88 className="text-muted-foreground hover:text-foreground mb-6 flex items-center gap-1.5 text-sm"
89 >
90 <ArrowLeft className="size-3.5" />
91 Back
92 </button>
93
94 <div className="border-border mb-6 rounded-md border p-5">
95 <div className="mb-1 flex items-start gap-3">
96 <GitCommit className="text-muted-foreground mt-1 size-5 shrink-0" />
97 <h1 className="text-lg leading-snug font-semibold">{commit.message}</h1>
98 </div>
99
100 {commit.fullMessage.includes("\n") && (
101 <pre className="text-muted-foreground mt-3 mb-4 ml-8 font-sans text-sm whitespace-pre-wrap">
102 {commit.fullMessage.split("\n").slice(1).join("\n").trim()}
103 </pre>
104 )}
105
106 <div className="text-muted-foreground mt-3 ml-8 flex flex-wrap items-center gap-x-4 gap-y-1 text-sm">
107 <span>
108 <span className="text-foreground font-medium">{commit.authorName}</span>
109 {commit.authorEmail && <span> <{commit.authorEmail}></span>}
110 </span>
111 <span title={date.toISOString()}>{format(date, "PPP")}</span>
112 </div>
113
114 <div className="mt-3 ml-8 flex flex-wrap gap-3 text-xs">
115 <span className="text-muted-foreground">
116 commit <code className="text-foreground font-mono">{commit.hash}</code>
117 </span>
118 {commit.parents.map((p: string) => (
119 <span key={p} className="text-muted-foreground">
120 parent{" "}
121 <Link
122 to={repo ? `/${repo}/commit/${p}` : `/commit/${p}`}
123 className="text-foreground font-mono hover:underline"
124 >
125 {p.slice(0, 7)}
126 </Link>
127 </span>
128 ))}
129 </div>
130 </div>
131
132 <div>
133 <h2 className="text-muted-foreground mb-3 text-sm font-semibold">
134 {files.length} file{files.length !== 1 ? "s" : ""} changed
135 </h2>
136 <div className="divide-border border-border divide-y overflow-hidden rounded-md border">
137 {files.length === 0 && (
138 <p className="text-muted-foreground px-4 py-4 text-sm">No file changes.</p>
139 )}
140 {files.map((file: { path: string; oldPath?: string | null; status: string }) => (
141 <FileDiffView
142 key={file.path}
143 hash={commit.hash}
144 path={file.path}
145 oldPath={file.oldPath ?? undefined}
146 status={file.status}
147 />
148 ))}
149 </div>
150 </div>
151 </div>
152 );
153}
154
155function CommitPageSkeleton() {
156 return (
157 <div className="space-y-6">
158 <Skeleton className="h-4 w-24" />
159 <div className="border-border space-y-3 rounded-md border p-5">
160 <Skeleton className="h-6 w-3/4" />
161 <Skeleton className="h-4 w-1/3" />
162 <Skeleton className="h-3 w-1/2" />
163 </div>
164 <div className="divide-border border-border divide-y rounded-md border">
165 {Array.from({ length: 5 }).map((_, i) => (
166 <div key={i} className="flex items-center gap-3 px-4 py-2.5">
167 <Skeleton className="size-4" />
168 <Skeleton className="h-4 flex-1" />
169 </div>
170 ))}
171 </div>
172 </div>
173 );
174}