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