1/**
2 * The commit view (`/:owner/:repo/commit/:oid`): GitHub-style page with
3 * the commit message/metadata on top and one rendered diff per changed
4 * file below.
5 *
6 * The server returns the changed-file *list* only (`commits/:oid/diff`);
7 * the shared `FileDiffList` (see `DiffList.tsx`) fetches blob contents
8 * and diffs locally with `@pierre/diffs`. This module is
9 * `React.lazy`-loaded from `Repo.tsx` so the diffs+shiki runtime stays
10 * out of the initial bundle.
11 */
12import { useEffect, useMemo, useState } from "react";
13import {
14 getCommit,
15 getCommitDiff,
16 type CommitDiff,
17 type CommitInfo,
18} from "../client.ts";
19import { CopyButton, ErrorBox, Spinner } from "../components.tsx";
20import { shortOid, subject, timeAgo } from "../format.ts";
21import { href, Link } from "../router.tsx";
22import FileDiffList from "./DiffList.tsx";
23import type { RepoContext } from "./Repo.tsx";
24
25const CommitPage = ({
26 context,
27 oid,
28}: {
29 context: RepoContext;
30 /** Full 40-hex commit oid (links always carry the full oid). */
31 oid: string;
32}) => {
33 const [data, setData] = useState<{
34 commit: CommitInfo;
35 diff: CommitDiff;
36 } | null>(null);
37 const [error, setError] = useState<unknown>(null);
38 const { connection, repo } = context;
39
40 useEffect(() => {
41 let cancelled = false;
42 setData(null);
43 setError(null);
44 void Promise.all([
45 getCommit(connection, repo.owner, repo.name, oid),
46 getCommitDiff(connection, repo.owner, repo.name, oid),
47 ])
48 .then(([commit, diff]) => {
49 if (!cancelled) setData({ commit, diff });
50 })
51 .catch((cause) => {
52 if (!cancelled) setError(cause);
53 });
54 return () => {
55 cancelled = true;
56 };
57 // eslint-disable-next-line react-hooks/exhaustive-deps
58 }, [oid, repo.repoId]);
59
60 const body = useMemo(
61 () =>
62 data === null
63 ? ""
64 : data.commit.message.split("\n").slice(1).join("\n").trim(),
65 [data],
66 );
67
68 if (error != null) return <ErrorBox error={error} />;
69 if (data === null) return <Spinner />;
70 const { commit, diff } = data;
71 const files = diff.files;
72
73 return (
74 <div>
75 {/* commit header */}
76 <div className="mb-4 overflow-hidden rounded-md border border-border-muted">
77 <div className="px-4 py-3">
78 <h2 className="text-base font-semibold">{subject(commit.message)}</h2>
79 {body.length > 0 && (
80 <pre className="mt-2 overflow-x-auto font-mono text-xs whitespace-pre-wrap text-fg-muted">
81 {body}
82 </pre>
83 )}
84 <p className="mt-2 text-xs text-fg-muted">
85 <span className="font-semibold text-fg-default">
86 {commit.author.name}
87 </span>{" "}
88 committed {timeAgo(commit.author.date * 1000)}
89 {commit.committer.name !== commit.author.name && (
90 <> · committed by {commit.committer.name}</>
91 )}
92 </p>
93 </div>
94 <div className="flex flex-wrap items-center justify-between gap-2 border-t border-border-muted bg-canvas-subtle px-4 py-2 text-xs text-fg-muted">
95 <span className="flex items-center gap-1.5">
96 {commit.parents.length === 0 ? (
97 <span>root commit</span>
98 ) : (
99 <>
100 {commit.parents.length === 1 ? "parent" : "parents"}{" "}
101 {commit.parents.map((parent, index) => (
102 <span key={parent}>
103 {index > 0 && <span className="mr-1.5">+</span>}
104 <Link
105 to={href(repo.owner, repo.name, "commit", parent)}
106 className="font-mono text-accent hover:underline"
107 >
108 {shortOid(parent)}
109 </Link>
110 </span>
111 ))}
112 </>
113 )}
114 </span>
115 <span className="flex items-center gap-1">
116 commit{" "}
117 <code className="rounded border border-border-muted px-2 py-0.5 font-mono text-accent">
118 {shortOid(commit.oid)}
119 </code>
120 <CopyButton text={commit.oid} />
121 </span>
122 </div>
123 </div>
124
125 {/* summary + truncation notice */}
126 <p className="mb-3 text-sm text-fg-muted">
127 {files.length} file{files.length === 1 ? "" : "s"} changed
128 {diff.parent === null &&
129 " (root commit — diffed against the empty tree)"}
130 </p>
131 {diff.truncated && (
132 <div className="mb-3 rounded-md border border-attention/40 bg-attention/5 px-4 py-2 text-sm text-attention">
133 This commit changes more files than shown — the list was truncated at{" "}
134 {files.length} files by the server.
135 </div>
136 )}
137
138 {/* file diffs */}
139 <FileDiffList context={context} files={files} />
140 </div>
141 );
142};
143
144export default CommitPage;