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 { Link, useParams } from "@tanstack/react-router";
7import { format } from "date-fns";
8import { ArrowLeft, GitCommit } from "lucide-react";
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({ strict: false });
58 const repo = useRepo();
59
60 const { data, loading, error } = useQuery<CommitQueryData>(COMMIT_QUERY, {
61 variables: { repo, hash },
62 skip: !hash,
63 });
64
65 if (loading) return <CommitPageSkeleton />;
66
67 if (error) {
68 return (
69 <div className="text-destructive py-16 text-center text-sm">
70 Failed to load commit: {error.message}
71 </div>
72 );
73 }
74
75 const commit = data?.repository?.commit;
76 if (!commit) return null;
77
78 const date = new Date(commit.date);
79 const files = commit.files?.nodes ?? [];
80
81 return (
82 <div>
83 <button
84 onClick={() => {
85 window.history.back();
86 }}
87 className="text-muted-foreground hover:text-foreground mb-6 flex items-center gap-1.5 text-sm"
88 >
89 <ArrowLeft className="size-3.5" />
90 Back
91 </button>
92
93 <div className="border-border mb-6 rounded-md border p-5">
94 <div className="mb-1 flex items-start gap-3">
95 <GitCommit className="text-muted-foreground mt-1 size-5 shrink-0" />
96 <h1 className="text-lg leading-snug font-semibold">{commit.message}</h1>
97 </div>
98
99 {commit.fullMessage.includes("\n") && (
100 <pre className="text-muted-foreground mt-3 mb-4 ml-8 font-sans text-sm whitespace-pre-wrap">
101 {commit.fullMessage.split("\n").slice(1).join("\n").trim()}
102 </pre>
103 )}
104
105 <div className="text-muted-foreground mt-3 ml-8 flex flex-wrap items-center gap-x-4 gap-y-1 text-sm">
106 <span>
107 <span className="text-foreground font-medium">{commit.authorName}</span>
108 {commit.authorEmail && <span> <{commit.authorEmail}></span>}
109 </span>
110 <span title={date.toISOString()}>{format(date, "PPP")}</span>
111 </div>
112
113 <div className="mt-3 ml-8 flex flex-wrap gap-3 text-xs">
114 <span className="text-muted-foreground">
115 commit <code className="text-foreground font-mono">{commit.hash}</code>
116 </span>
117 {commit.parents.map((p: string) => (
118 <span key={p} className="text-muted-foreground">
119 parent{" "}
120 <Link
121 to="/$repo/commit/$hash"
122 params={{ repo: repo!, hash: 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}