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, useQuery } from "@apollo/client";
  5import { formatDistanceToNow } from "date-fns";
  6import { GitCommit } from "lucide-react";
  7import { useState } from "react";
  8import { Link } from "react-router-dom";
  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    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} &middot;{" "}
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}