CommitList.tsx

  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} &middot;{" "}
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}