CommitPage.tsx

  1// Commit detail page (/:repo/commit/:hash). Shows commit metadata, full
  2// message, parent links, and changed files with lazy diffs.
  3
  4import { gql } from "@apollo/client";
  5import { useQuery } from "@apollo/client/react";
  6import { Link, useParams } from "@tanstack/react-router";
  7import { format } from "date-fns";
  8import { ArrowLeft, GitCommit } from "lucide-react";
  9
 10import { FileDiffView } from "@/components/code/FileDiffView";
 11import { Skeleton } from "@/components/ui/skeleton";
 12import { useRepo } from "@/lib/repo";
 13
 14const COMMIT_QUERY = gql`
 15  query CommitPageDetail($repo: String, $hash: String!) {
 16    repository(ref: $repo) {
 17      commit(hash: $hash) {
 18        hash
 19        shortHash
 20        message
 21        fullMessage
 22        authorName
 23        authorEmail
 24        date
 25        parents
 26        files {
 27          nodes {
 28            path
 29            oldPath
 30            status
 31          }
 32        }
 33      }
 34    }
 35  }
 36`;
 37
 38interface CommitQueryData {
 39  repository: {
 40    commit: {
 41      hash: string;
 42      shortHash: string;
 43      message: string;
 44      fullMessage: string;
 45      authorName: string;
 46      authorEmail: string | null;
 47      date: string;
 48      parents: string[];
 49      files: {
 50        nodes: { path: string; oldPath: string | null; status: string }[];
 51      } | null;
 52    } | null;
 53  } | null;
 54}
 55
 56export function CommitPage() {
 57  const { hash } = useParams({ strict: false });
 58  const repo = useRepo();
 59
 60  const { data, loading, error } = useQuery<CommitQueryData>(COMMIT_QUERY, {
 61    variables: { repo, hash },
 62    skip: !hash,
 63  });
 64
 65  if (loading) return <CommitPageSkeleton />;
 66
 67  if (error) {
 68    return (
 69      <div className="text-destructive py-16 text-center text-sm">
 70        Failed to load commit: {error.message}
 71      </div>
 72    );
 73  }
 74
 75  const commit = data?.repository?.commit;
 76  if (!commit) return null;
 77
 78  const date = new Date(commit.date);
 79  const files = commit.files?.nodes ?? [];
 80
 81  return (
 82    <div>
 83      <button
 84        onClick={() => {
 85          window.history.back();
 86        }}
 87        className="text-muted-foreground hover:text-foreground mb-6 flex items-center gap-1.5 text-sm"
 88      >
 89        <ArrowLeft className="size-3.5" />
 90        Back
 91      </button>
 92
 93      <div className="border-border mb-6 rounded-md border p-5">
 94        <div className="mb-1 flex items-start gap-3">
 95          <GitCommit className="text-muted-foreground mt-1 size-5 shrink-0" />
 96          <h1 className="text-lg leading-snug font-semibold">{commit.message}</h1>
 97        </div>
 98
 99        {commit.fullMessage.includes("\n") && (
100          <pre className="text-muted-foreground mt-3 mb-4 ml-8 font-sans text-sm whitespace-pre-wrap">
101            {commit.fullMessage.split("\n").slice(1).join("\n").trim()}
102          </pre>
103        )}
104
105        <div className="text-muted-foreground mt-3 ml-8 flex flex-wrap items-center gap-x-4 gap-y-1 text-sm">
106          <span>
107            <span className="text-foreground font-medium">{commit.authorName}</span>
108            {commit.authorEmail && <span> &lt;{commit.authorEmail}&gt;</span>}
109          </span>
110          <span title={date.toISOString()}>{format(date, "PPP")}</span>
111        </div>
112
113        <div className="mt-3 ml-8 flex flex-wrap gap-3 text-xs">
114          <span className="text-muted-foreground">
115            commit <code className="text-foreground font-mono">{commit.hash}</code>
116          </span>
117          {commit.parents.map((p: string) => (
118            <span key={p} className="text-muted-foreground">
119              parent{" "}
120              <Link
121                to="/$repo/commit/$hash"
122                params={{ repo: repo!, hash: p }}
123                className="text-foreground font-mono hover:underline"
124              >
125                {p.slice(0, 7)}
126              </Link>
127            </span>
128          ))}
129        </div>
130      </div>
131
132      <div>
133        <h2 className="text-muted-foreground mb-3 text-sm font-semibold">
134          {files.length} file{files.length !== 1 ? "s" : ""} changed
135        </h2>
136        <div className="divide-border border-border divide-y overflow-hidden rounded-md border">
137          {files.length === 0 && (
138            <p className="text-muted-foreground px-4 py-4 text-sm">No file changes.</p>
139          )}
140          {files.map((file: { path: string; oldPath?: string | null; status: string }) => (
141            <FileDiffView
142              key={file.path}
143              hash={commit.hash}
144              path={file.path}
145              oldPath={file.oldPath ?? undefined}
146              status={file.status}
147            />
148          ))}
149        </div>
150      </div>
151    </div>
152  );
153}
154
155function CommitPageSkeleton() {
156  return (
157    <div className="space-y-6">
158      <Skeleton className="h-4 w-24" />
159      <div className="border-border space-y-3 rounded-md border p-5">
160        <Skeleton className="h-6 w-3/4" />
161        <Skeleton className="h-4 w-1/3" />
162        <Skeleton className="h-3 w-1/2" />
163      </div>
164      <div className="divide-border border-border divide-y rounded-md border">
165        {Array.from({ length: 5 }).map((_, i) => (
166          <div key={i} className="flex items-center gap-3 px-4 py-2.5">
167            <Skeleton className="size-4" />
168            <Skeleton className="h-4 flex-1" />
169          </div>
170        ))}
171      </div>
172    </div>
173  );
174}