| 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 | */ |
| 17 | import { |
| 18 | parseDiffFromFile, |
| 19 | type FileContents, |
| 20 | type FileDiffMetadata, |
| 21 | } from "@pierre/diffs"; |
| 22 | import { FileDiff } from "@pierre/diffs/react"; |
| 23 | import { useEffect, useState, type ReactNode } from "react"; |
| 24 | import { getBlob, type DiffEntry, type FileStatus } from "../client.ts"; |
| 25 | import { Button, ErrorBox } from "../components.tsx"; |
| 26 | import { blobLimiter, MAX_RENDER_BYTES, useFileDiffOptions } from "../diff.tsx"; |
| 27 | import { decodeText, formatBytes } from "../format.ts"; |
| 28 | import type { RepoContext } from "./Repo.tsx"; |
| 29 | |
| 30 | // ── per-file diff card ────────────────────────────────────────────────────── |
| 31 | |
| 32 | type FileState = |
| 33 | | { kind: "loading" } |
| 34 | | { kind: "text"; fileDiff: FileDiffMetadata } |
| 35 | | { kind: "binary" } |
| 36 | | { kind: "error"; error: unknown }; |
| 37 | |
| 38 | const 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. */ |
| 53 | const NoteRow = ({ children }: { children: ReactNode }) => ( |
| 54 | <div className="px-4 py-6 text-center text-sm text-fg-muted">{children}</div> |
| 55 | ); |
| 56 | |
| 57 | const sizeLabel = (entry: DiffEntry): string | null => { |
| 58 | const size = entry.newSize ?? entry.oldSize; |
| 59 | return size == null ? null : formatBytes(size); |
| 60 | }; |
| 61 | |
| 62 | export 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. */ |
| 188 | const PAGE_SIZE = 25; |
| 189 | |
| 190 | /** Paged list of file-diff cards (resets paging when `files` changes). */ |
| 191 | const 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 | |
| 230 | export default FileDiffList; |