$hash.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 { useReadQuery } from "@apollo/client/react";
  6import { createFileRoute, Link } 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 const Route = createFileRoute("/$repo/commit/$hash")({
 57  component: RouteComponent,
 58  pendingComponent: CommitPageSkeleton,
 59  loader: async ({ context: { preloadQuery, ref }, params: { hash } }) => {
 60    const commitRef = preloadQuery<CommitQueryData>(COMMIT_QUERY, {
 61      variables: { repo: ref, hash },
 62    });
 63    return { commitRef: await preloadQuery.toPromise(commitRef) };
 64  },
 65});
 66
 67function RouteComponent() {
 68  const repo = useRepo();
 69  const { commitRef } = Route.useLoaderData();
 70  const { data } = useReadQuery(commitRef);
 71
 72  const commit = data?.repository?.commit;
 73  if (!commit) return null;
 74
 75  const date = new Date(commit.date);
 76  const files = commit.files?.nodes ?? [];
 77
 78  return (
 79    <div>
 80      <button
 81        onClick={() => {
 82          window.history.back();
 83        }}
 84        className="text-muted-foreground hover:text-foreground mb-6 flex items-center gap-1.5 text-sm"
 85      >
 86        <ArrowLeft className="size-3.5" />
 87        Back
 88      </button>
 89
 90      <div className="border-border mb-6 rounded-md border p-5">
 91        <div className="mb-1 flex items-start gap-3">
 92          <GitCommit className="text-muted-foreground mt-1 size-5 shrink-0" />
 93          <h1 className="text-lg leading-snug font-semibold">{commit.message}</h1>
 94        </div>
 95
 96        {commit.fullMessage.includes("\n") && (
 97          <pre className="text-muted-foreground mt-3 mb-4 ml-8 font-sans text-sm whitespace-pre-wrap">
 98            {commit.fullMessage.split("\n").slice(1).join("\n").trim()}
 99          </pre>
100        )}
101
102        <div className="text-muted-foreground mt-3 ml-8 flex flex-wrap items-center gap-x-4 gap-y-1 text-sm">
103          <span>
104            <span className="text-foreground font-medium">{commit.authorName}</span>
105            {commit.authorEmail && <span> &lt;{commit.authorEmail}&gt;</span>}
106          </span>
107          <span title={date.toISOString()}>{format(date, "PPP")}</span>
108        </div>
109
110        <div className="mt-3 ml-8 flex flex-wrap gap-3 text-xs">
111          <span className="text-muted-foreground">
112            commit <code className="text-foreground font-mono">{commit.hash}</code>
113          </span>
114          {commit.parents.map((p: string) => (
115            <span key={p} className="text-muted-foreground">
116              parent{" "}
117              <Link
118                to="/$repo/commit/$hash"
119                params={{ repo: repo!, hash: p }}
120                className="text-foreground font-mono hover:underline"
121              >
122                {p.slice(0, 7)}
123              </Link>
124            </span>
125          ))}
126        </div>
127      </div>
128
129      <div>
130        <h2 className="text-muted-foreground mb-3 text-sm font-semibold">
131          {files.length} file{files.length !== 1 ? "s" : ""} changed
132        </h2>
133        <div className="divide-border border-border divide-y overflow-hidden rounded-md border">
134          {files.length === 0 && (
135            <p className="text-muted-foreground px-4 py-4 text-sm">No file changes.</p>
136          )}
137          {files.map((file: { path: string; oldPath?: string | null; status: string }) => (
138            <FileDiffView
139              key={file.path}
140              hash={commit.hash}
141              path={file.path}
142              oldPath={file.oldPath ?? undefined}
143              status={file.status}
144            />
145          ))}
146        </div>
147      </div>
148    </div>
149  );
150}
151
152function CommitPageSkeleton() {
153  return (
154    <div className="space-y-6">
155      <Skeleton className="h-4 w-24" />
156      <div className="border-border space-y-3 rounded-md border p-5">
157        <Skeleton className="h-6 w-3/4" />
158        <Skeleton className="h-4 w-1/3" />
159        <Skeleton className="h-3 w-1/2" />
160      </div>
161      <div className="divide-border border-border divide-y rounded-md border">
162        {Array.from({ length: 5 }).map((_, i) => (
163          <div key={i} className="flex items-center gap-3 px-4 py-2.5">
164            <Skeleton className="size-4" />
165            <Skeleton className="h-4 flex-1" />
166          </div>
167        ))}
168      </div>
169    </div>
170  );
171}