1// Paginated commit history grouped by calendar date. Each row links to the
2// commit detail page. Used in CodePage's "History" view.
3
4import { gql, useQuery } from "@apollo/client";
5import { formatDistanceToNow } from "date-fns";
6import { GitCommit } from "lucide-react";
7import { useState } from "react";
8import { Link } from "react-router";
9
10import { Button } from "@/components/ui/button";
11import { Skeleton } from "@/components/ui/skeleton";
12import { useRepo } from "@/lib/repo";
13
14const COMMITS_QUERY = gql`
15 query CommitList($repo: String, $ref: String!, $path: String, $after: String, $first: Int) {
16 repository(ref: $repo) {
17 commits(ref: $ref, path: $path, after: $after, first: $first) {
18 nodes {
19 hash
20 shortHash
21 message
22 authorName
23 date
24 }
25 pageInfo {
26 hasNextPage
27 endCursor
28 }
29 }
30 }
31 }
32`;
33
34const PAGE_SIZE = 30;
35
36interface CommitListProps {
37 ref_: string;
38 path?: string;
39}
40
41type CommitNode = {
42 hash: string;
43 shortHash: string;
44 message: string;
45 authorName: string;
46 date: string;
47};
48
49export function CommitList({ ref_, path }: CommitListProps) {
50 const repo = useRepo();
51 const [cursor, setCursor] = useState<string | null>(null);
52 const [allCommits, setAllCommits] = useState<CommitNode[]>([]);
53
54 const { loading, error, fetchMore } = useQuery(COMMITS_QUERY, {
55 variables: { repo, ref: ref_, path: path ?? null, after: null, first: PAGE_SIZE },
56 skip: !ref_,
57 onCompleted(data) {
58 const nodes = data?.repository?.commits?.nodes ?? [];
59 setAllCommits(nodes);
60 setCursor(data?.repository?.commits?.pageInfo?.endCursor ?? null);
61 },
62 });
63
64 const hasMore = !!cursor && allCommits.length > 0 && allCommits.length % PAGE_SIZE === 0;
65 const [loadingMore, setLoadingMore] = useState(false);
66
67 function loadMore() {
68 if (!cursor) return;
69 setLoadingMore(true);
70 void fetchMore({
71 variables: { after: cursor },
72 })
73 .then((result) => {
74 const newNodes = result.data?.repository?.commits?.nodes ?? [];
75 setAllCommits((prev) => [...prev, ...newNodes]);
76 setCursor(result.data?.repository?.commits?.pageInfo?.endCursor ?? null);
77 })
78 .finally(() => setLoadingMore(false));
79 }
80
81 if (loading) return <CommitListSkeleton />;
82
83 if (error) {
84 return (
85 <div className="rounded-md border border-border px-4 py-8 text-center text-sm text-destructive">
86 {error.message}
87 </div>
88 );
89 }
90
91 const groups = groupByDate(allCommits);
92
93 return (
94 <div className="space-y-6">
95 {groups.map(([date, group]) => (
96 <div key={date}>
97 <h3 className="mb-2 text-xs font-semibold uppercase tracking-wider text-muted-foreground">
98 Commits on {date}
99 </h3>
100 <div className="divide-y divide-border overflow-hidden rounded-md border border-border">
101 {group.map((commit) => (
102 <CommitRow key={commit.hash} commit={commit} repo={repo} />
103 ))}
104 </div>
105 </div>
106 ))}
107
108 {hasMore && (
109 <div className="text-center">
110 <Button variant="outline" size="sm" onClick={loadMore} disabled={loadingMore}>
111 {loadingMore ? "Loading…" : "Load more commits"}
112 </Button>
113 </div>
114 )}
115 </div>
116 );
117}
118
119function CommitRow({ commit, repo }: { commit: CommitNode; repo: string | null }) {
120 const commitPath = repo ? `/${repo}/commit/${commit.hash}` : `/commit/${commit.hash}`;
121 return (
122 <div className="flex items-center gap-3 bg-background px-4 py-3 hover:bg-muted/30">
123 <GitCommit className="size-4 shrink-0 text-muted-foreground" />
124 <div className="min-w-0 flex-1">
125 <Link
126 to={commitPath}
127 className="block truncate font-medium text-foreground hover:text-primary hover:underline"
128 >
129 {commit.message}
130 </Link>
131 <p className="mt-0.5 text-xs text-muted-foreground">
132 {commit.authorName} ·{" "}
133 {formatDistanceToNow(new Date(commit.date), { addSuffix: true })}
134 </p>
135 </div>
136 <Link
137 to={commitPath}
138 className="shrink-0 font-mono text-xs text-muted-foreground hover:text-foreground hover:underline"
139 title={commit.hash}
140 >
141 {commit.shortHash}
142 </Link>
143 </div>
144 );
145}
146
147function groupByDate(commits: CommitNode[]): [string, CommitNode[]][] {
148 const map = new Map<string, CommitNode[]>();
149 for (const c of commits) {
150 const date = new Date(c.date).toLocaleDateString("en-US", {
151 year: "numeric",
152 month: "long",
153 day: "numeric",
154 });
155 const group = map.get(date) ?? [];
156 group.push(c);
157 map.set(date, group);
158 }
159 return Array.from(map.entries());
160}
161
162function CommitListSkeleton() {
163 return (
164 <div className="space-y-6">
165 {Array.from({ length: 2 }).map((_, g) => (
166 <div key={g}>
167 <Skeleton className="mb-2 h-3 w-32" />
168 <div className="divide-y divide-border overflow-hidden rounded-md border border-border">
169 {Array.from({ length: 4 }).map((_, i) => (
170 <div key={i} className="flex items-center gap-3 px-4 py-3">
171 <Skeleton className="size-4 rounded" />
172 <div className="flex-1 space-y-1.5">
173 <Skeleton className="h-4 w-2/3" />
174 <Skeleton className="h-3 w-1/4" />
175 </div>
176 <Skeleton className="h-3 w-14" />
177 </div>
178 ))}
179 </div>
180 </div>
181 ))}
182 </div>
183 );
184}