1/**
2 * Shared changed-file renderer used by the commit view (`Commit.tsx`)
3 * and the pull-request view (`Pull.tsx`): one card per `DiffEntry` with
4 * a status badge header and a `@pierre/diffs` `<FileDiff>` body, plus
5 * "Show more files" pagination.
6 *
7 * IMPORTANT: this module imports the diffs+shiki runtime (via
8 * `../diff.tsx`). Only `React.lazy` chunks may import it — never
9 * `main.tsx`/`Repo.tsx` statically — so the library stays out of the
10 * initial bundle.
11 *
12 * Each card fetches its own old/new blob contents by oid (shared
13 * 4-concurrent limiter) and diffs locally. Gitlinks, mode-only changes,
14 * oversize and binary blobs render placeholder rows and never
15 * fetch/parse.
16 */
17import {
18 parseDiffFromFile,
19 type FileContents,
20 type FileDiffMetadata,
21} from "@pierre/diffs";
22import { FileDiff } from "@pierre/diffs/react";
23import { useEffect, useState, type ReactNode } from "react";
24import { getBlob, type DiffEntry, type FileStatus } from "../client.ts";
25import { Button, ErrorBox } from "../components.tsx";
26import { blobLimiter, MAX_RENDER_BYTES, useFileDiffOptions } from "../diff.tsx";
27import { decodeText, formatBytes } from "../format.ts";
28import type { RepoContext } from "./Repo.tsx";
29
30// ── per-file diff card ──────────────────────────────────────────────────────
31
32type FileState =
33 | { kind: "loading" }
34 | { kind: "text"; fileDiff: FileDiffMetadata }
35 | { kind: "binary" }
36 | { kind: "error"; error: unknown };
37
38const StatusBadge = ({ status }: { status: FileStatus }) => (
39 <span
40 className={`shrink-0 rounded-full border px-2 py-0.5 font-medium ${
41 status === "added"
42 ? "border-success/40 text-success"
43 : status === "removed"
44 ? "border-danger/40 text-danger"
45 : "border-attention/40 text-attention"
46 }`}
47 >
48 {status}
49 </span>
50);
51
52/** Placeholder body for files we deliberately don't render as a diff. */
53const NoteRow = ({ children }: { children: ReactNode }) => (
54 <div className="px-4 py-6 text-center text-sm text-fg-muted">{children}</div>
55);
56
57const sizeLabel = (entry: DiffEntry): string | null => {
58 const size = entry.newSize ?? entry.oldSize;
59 return size == null ? null : formatBytes(size);
60};
61
62export const FileDiffCard = ({
63 context,
64 entry,
65}: {
66 context: RepoContext;
67 entry: DiffEntry;
68}) => {
69 const options = useFileDiffOptions();
70 const { connection, repo } = context;
71
72 // Cases that never fetch contents.
73 const gitlink = entry.oldMode === "160000" || entry.newMode === "160000";
74 const modeOnly =
75 entry.status === "modified" &&
76 entry.oldOid != null &&
77 entry.oldOid === entry.newOid;
78 const oversize =
79 (entry.oldSize ?? 0) > MAX_RENDER_BYTES ||
80 (entry.newSize ?? 0) > MAX_RENDER_BYTES;
81 const skip = gitlink || modeOnly || oversize;
82
83 const [state, setState] = useState<FileState>({ kind: "loading" });
84
85 useEffect(() => {
86 if (skip) return;
87 let cancelled = false;
88 setState({ kind: "loading" });
89 void blobLimiter(async () => {
90 // Absent sides arrive as null over JSON (added/removed files) —
91 // treat null and undefined alike or we fetch "blobs/null".
92 const side = (
93 oid: string | null | undefined,
94 size: number | null | undefined,
95 ) =>
96 oid == null
97 ? Promise.resolve(null)
98 : getBlob(connection, repo.owner, repo.name, oid, {
99 size: size ?? undefined,
100 });
101 const [oldBytes, newBytes] = await Promise.all([
102 side(entry.oldOid, entry.oldSize),
103 side(entry.newOid, entry.newSize),
104 ]);
105 if (cancelled) return;
106 const oldText = oldBytes === null ? null : decodeText(oldBytes);
107 const newText = newBytes === null ? null : decodeText(newBytes);
108 // A side that exists but doesn't decode as UTF-8 is binary.
109 if (
110 (oldBytes !== null && oldText === null) ||
111 (newBytes !== null && newText === null)
112 ) {
113 setState({ kind: "binary" });
114 return;
115 }
116 // The blob oid is a perfect cacheKey: content-addressed identity.
117 const contents = (
118 text: string | null,
119 oid: string | null | undefined,
120 ): FileContents | null =>
121 text === null
122 ? null
123 : { name: entry.path, contents: text, cacheKey: oid ?? undefined };
124 // The server guarantees ≥ 1 side, so this never sees (null, null).
125 const fileDiff = parseDiffFromFile(
126 contents(oldText, entry.oldOid),
127 contents(newText, entry.newOid),
128 );
129 setState({ kind: "text", fileDiff });
130 }).catch((error: unknown) => {
131 if (!cancelled) setState({ kind: "error", error });
132 });
133 return () => {
134 cancelled = true;
135 };
136 // eslint-disable-next-line react-hooks/exhaustive-deps
137 }, [entry.oldOid, entry.newOid, skip, repo.repoId]);
138
139 const size = sizeLabel(entry);
140 const modeChanged =
141 entry.oldMode !== undefined &&
142 entry.newMode !== undefined &&
143 entry.oldMode !== entry.newMode;
144
145 return (
146 <div className="overflow-hidden rounded-md border border-border-muted">
147 <div className="flex items-center justify-between gap-3 border-b border-border-muted bg-canvas-subtle px-3 py-2 text-xs">
148 <div className="flex min-w-0 items-center gap-2">
149 <StatusBadge status={entry.status} />
150 <span className="truncate font-mono">{entry.path}</span>
151 {modeChanged && (
152 <span className="shrink-0 text-fg-muted">
153 {entry.oldMode} → {entry.newMode}
154 </span>
155 )}
156 </div>
157 {size !== null && (
158 <span className="shrink-0 text-fg-muted">{size}</span>
159 )}
160 </div>
161 {gitlink ? (
162 <NoteRow>
163 Subproject commit{" "}
164 <code className="font-mono">{entry.newOid ?? entry.oldOid}</code>
165 </NoteRow>
166 ) : modeOnly ? (
167 <NoteRow>File mode changed — contents unchanged</NoteRow>
168 ) : oversize ? (
169 <NoteRow>Large file not rendered ({size})</NoteRow>
170 ) : state.kind === "loading" ? (
171 <div className="flex justify-center py-6">
172 <div className="size-5 animate-spin rounded-full border-2 border-border-muted border-t-accent" />
173 </div>
174 ) : state.kind === "binary" ? (
175 <NoteRow>Binary file not shown{size !== null && ` (${size})`}</NoteRow>
176 ) : state.kind === "error" ? (
177 <ErrorBox error={state.error} />
178 ) : (
179 <FileDiff fileDiff={state.fileDiff} options={options} />
180 )}
181 </div>
182 );
183};
184
185// ── the paged list ──────────────────────────────────────────────────────────
186
187/** Files rendered before the "Show more" affordance kicks in. */
188const PAGE_SIZE = 25;
189
190/** Paged list of file-diff cards (resets paging when `files` changes). */
191const FileDiffList = ({
192 context,
193 files,
194}: {
195 context: RepoContext;
196 files: DiffEntry[];
197}) => {
198 const [visible, setVisible] = useState(PAGE_SIZE);
199 useEffect(() => {
200 setVisible(PAGE_SIZE);
201 }, [files]);
202
203 return (
204 <div>
205 <div className="flex flex-col gap-4">
206 {files.slice(0, visible).map((entry) => (
207 <FileDiffCard
208 key={`${entry.status}:${entry.path}`}
209 context={context}
210 entry={entry}
211 />
212 ))}
213 {files.length === 0 && (
214 <div className="rounded-md border border-border-muted px-4 py-8 text-center text-sm text-fg-muted">
215 No changes
216 </div>
217 )}
218 </div>
219 {visible < files.length && (
220 <div className="mt-4 flex justify-center">
221 <Button onClick={() => setVisible((count) => count + PAGE_SIZE)}>
222 Show more files ({files.length - visible} remaining)
223 </Button>
224 </div>
225 )}
226 </div>
227 );
228};
229
230export default FileDiffList;