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