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