$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";
 12
 13const COMMIT_QUERY = gql`
 14  query CommitPageDetail($repo: String, $hash: String!) {
 15    repository(ref: $repo) {
 16      commit(hash: $hash) {
 17        hash
 18        shortHash
 19        message
 20        fullMessage
 21        authorName
 22        authorEmail
 23        date
 24        parents
 25        files {
 26          nodes {
 27            path
 28            oldPath
 29            status
 30          }
 31        }
 32      }
 33    }
 34  }
 35`;
 36
 37interface CommitQueryData {
 38  repository: {
 39    commit: {
 40      hash: string;
 41      shortHash: string;
 42      message: string;
 43      fullMessage: string;
 44      authorName: string;
 45      authorEmail: string | null;
 46      date: string;
 47      parents: string[];
 48      files: {
 49        nodes: { path: string; oldPath: string | null; status: string }[];
 50      } | null;
 51    } | null;
 52  } | null;
 53}
 54
 55export const Route = createFileRoute("/$repo/commit/$hash")({
 56  component: RouteComponent,
 57  pendingComponent: CommitPageSkeleton,
 58  loader: async ({ context: { preloadQuery, ref }, params: { hash } }) => {
 59    const commitRef = preloadQuery<CommitQueryData>(COMMIT_QUERY, {
 60      variables: { repo: ref, hash },
 61    });
 62    return { commitRef: await preloadQuery.toPromise(commitRef) };
 63  },
 64});
 65
 66function RouteComponent() {
 67  const { ref } = Route.useRouteContext();
 68  const { repo } = Route.useParams();
 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, 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              repo={ref}
141              hash={commit.hash}
142              path={file.path}
143              oldPath={file.oldPath ?? undefined}
144              status={file.status}
145            />
146          ))}
147        </div>
148      </div>
149    </div>
150  );
151}
152
153function CommitPageSkeleton() {
154  return (
155    <div className="space-y-6">
156      <Skeleton className="h-4 w-24" />
157      <div className="border-border space-y-3 rounded-md border p-5">
158        <Skeleton className="h-6 w-3/4" />
159        <Skeleton className="h-4 w-1/3" />
160        <Skeleton className="h-3 w-1/2" />
161      </div>
162      <div className="divide-border border-border divide-y rounded-md border">
163        {Array.from({ length: 5 }).map((_, i) => (
164          <div key={i} className="flex items-center gap-3 px-4 py-2.5">
165            <Skeleton className="size-4" />
166            <Skeleton className="h-4 flex-1" />
167          </div>
168        ))}
169      </div>
170    </div>
171  );
172}