DiffView.tsx
Raw
1import type { RepositoryRow } from "../db/index.ts";
2import type { RenderedDiffFile } from "../services/diffHighlight.ts";
3
4export function slugify(s: string): string {
5 return `diff-${s.replace(/[^a-zA-Z0-9]/g, "-")}`;
6}
7
8export function statusLetter(status: string): string {
9 return (
10 (
11 {
12 added: "A",
13 deleted: "D",
14 modified: "M",
15 renamed: "R",
16 copied: "C",
17 } as Record<string, string>
18 )[status] ?? "M"
19 );
20}
21
22export type FileTreeNode =
23 | { type: "file"; file: RenderedDiffFile }
24 | { type: "dir"; children: Map<string, FileTreeNode> };
25
26export function buildFileTree(
27 files: RenderedDiffFile[],
28): Map<string, FileTreeNode> {
29 const root = new Map<string, FileTreeNode>();
30 for (const f of files) {
31 const p = f.newPath || f.oldPath;
32 const parts = p.split("/");
33 let current = root;
34 for (let i = 0; i < parts.length - 1; i++) {
35 const part = parts[i]!;
36 if (!current.has(part)) {
37 current.set(part, { type: "dir", children: new Map() });
38 }
39 const node = current.get(part)!;
40 if (node.type === "dir") current = node.children;
41 }
42 current.set(parts[parts.length - 1]!, { type: "file", file: f });
43 }
44 return root;
45}
46
47export function renderFileTree(tree: Map<string, FileTreeNode>): JSX.Element {
48 return (
49 <ul class="file-nav-list">
50 {[...tree.entries()]
51 .sort(([, a], [, b]) =>
52 a.type === b.type ? 0 : a.type === "dir" ? -1 : 1,
53 )
54 .map(([name, node]) =>
55 node.type === "dir" ? (
56 <li class="file-nav-dir">
57 <details open>
58 <summary class="file-nav-dir-toggle">
59 <span class="file-nav-toggle-icon">▾</span>
60 <span safe>{name}/</span>
61 </summary>
62 {renderFileTree(node.children)}
63 </details>
64 </li>
65 ) : (
66 <li>
67 <a
68 href={`#${slugify(node.file.newPath || node.file.oldPath)}`}
69 class="file-nav-item"
70 title={node.file.newPath || node.file.oldPath}
71 >
72 <span
73 class={`file-nav-status file-status-${node.file.status}`}
74 safe
75 >
76 {statusLetter(node.file.status)}
77 </span>
78 <span class="file-nav-name" safe>
79 {name}
80 </span>
81 <span class="file-nav-stat">
82 {node.file.isBinary ? (
83 <span class="nav-binary">
84 {node.file.binarySize ? (
85 <>
86 <span class="nav-del">
87 {
88 node.file.binarySize
89 .before
90 }
91 </span>
92 {" \u2192 "}
93 <span class="nav-add">
94 {
95 node.file.binarySize
96 .after
97 }
98 </span>
99 </>
100 ) : (
101 "Bin"
102 )}
103 </span>
104 ) : (
105 <>
106 {node.file.added > 0 && (
107 <span class="nav-add">
108 +{node.file.added}
109 </span>
110 )}
111 {node.file.removed > 0 && (
112 <span class="nav-del">
113 -{node.file.removed}
114 </span>
115 )}
116 </>
117 )}
118 </span>
119 </a>
120 </li>
121 ),
122 )}
123 </ul>
124 );
125}
126
127interface DiffViewProps {
128 files: RenderedDiffFile[];
129 repo: RepositoryRow;
130 /** If provided, an extra "view at sha" link is shown in each file header. */
131 sha?: string;
132}
133
134export function DiffView({ files, repo, sha }: DiffViewProps) {
135 const totalAdded = files.reduce((s, f) => s + f.added, 0);
136 const totalRemoved = files.reduce((s, f) => s + f.removed, 0);
137
138 const changedStr = [
139 `${files.length} file${files.length !== 1 ? "s" : ""} changed`,
140 totalAdded > 0
141 ? `${totalAdded} insertion${totalAdded !== 1 ? "s" : ""}(+)`
142 : "",
143 totalRemoved > 0
144 ? `${totalRemoved} deletion${totalRemoved !== 1 ? "s" : ""}(-)`
145 : "",
146 ]
147 .filter(Boolean)
148 .join(", ");
149
150 return (
151 <>
152 {files.length > 0 && (
153 <div class="commit-stats-bar">
154 <span class="commit-stats-text" safe>{changedStr}</span>
155 </div>
156 )}
157
158 {files.length === 0 ? (
159 <p class="text-muted" style="margin-top: var(--space-6)">
160 Empty diff or no diff available.
161 </p>
162 ) : (
163 <div class="commit-layout">
164 <aside class="commit-file-nav">
165 <details class="file-nav-details" open>
166 <summary class="file-nav-toggle">
167 <span class="file-nav-toggle-icon">▾</span>
168 <span>Files changed ({files.length})</span>
169 </summary>
170 {renderFileTree(buildFileTree(files))}
171 </details>
172 </aside>
173
174 <div class="commit-diffs">
175 {files.map((f) => {
176 const displayPath = f.newPath || f.oldPath;
177 const sl = statusLetter(f.status);
178 return (
179 <details
180 class="diff-file"
181 id={slugify(displayPath)}
182 open
183 >
184 <summary class="diff-file-header">
185 <div class="diff-file-header-left">
186 <span class="diff-file-toggle-icon">
187
188 </span>
189 <span
190 class={`diff-status-badge diff-status-${f.status}`}
191 safe
192 >
193 {sl}
194 </span>
195 <span class="diff-file-path mono" safe>
196 {displayPath}
197 </span>
198 {f.status === "renamed" &&
199 f.oldPath !== f.newPath && (
200 <span class="diff-rename-arrow" safe>
201 ← {f.oldPath}
202 </span>
203 )}
204 </div>
205 <div class="diff-file-header-right">
206 {f.isBinary ? (
207 <span class="diff-stat-binary">
208 {f.binarySize ? (
209 <>
210 {"Bin "}
211 <span class="diff-stat-del">
212 {
213 f.binarySize
214 .before
215 }
216 </span>
217 {" \u2192 "}
218 <span class="diff-stat-add">
219 {
220 f.binarySize
221 .after
222 }
223 </span>
224 {" bytes"}
225 </>
226 ) : (
227 "Binary"
228 )}
229 </span>
230 ) : (
231 <>
232 {f.added > 0 && (
233 <span class="diff-stat-add">
234 +{f.added}
235 </span>
236 )}
237 {f.removed > 0 && (
238 <span class="diff-stat-del">
239 -{f.removed}
240 </span>
241 )}
242 </>
243 )}
244 {f.status !== "deleted" && (
245 <>
246 {!!sha && (
247 <a
248 href={`/${repo.name}/blob/${sha}/${displayPath}`}
249 class="btn btn-xs btn-secondary"
250 title={`View file at ${sha.slice(0, 7)}`}
251 safe
252 >
253 @ {sha.slice(0, 7)}
254 </a>
255 )}
256 <a
257 href={`/${repo.name}/blob/${repo.default_branch}/${displayPath}`}
258 class="btn btn-xs btn-secondary"
259 title={`View file at ${repo.default_branch}`}
260 safe
261 >
262 @ {repo.default_branch}
263 </a>
264 </>
265 )}
266 </div>
267 </summary>
268
269 {f.isBinary ? (
270 <div class="diff-binary-notice">
271 Binary file — not shown
272 </div>
273 ) : f.hunks.length === 0 ? (
274 <div class="diff-binary-notice">
275 No textual changes.
276 </div>
277 ) : (
278 <div class="diff-file-body">
279 {f.hunks.map((hunk) => (
280 <div class="diff-hunk">
281 <table class="diff-table">
282 <thead>
283 <tr>
284 <th
285 colspan="4"
286 class="diff-hunk-header"
287 safe
288 >
289 {
290 hunk.header
291 }
292 </th>
293 </tr>
294 </thead>
295 <tbody>
296 {hunk.rows.map(
297 (row) => (
298 <tr
299 class={`diff-row diff-row-${row.type}`}
300 >
301 <td class="diff-ln diff-ln-old">
302 {row.oldLine ??
303 ""}
304 </td>
305 <td class="diff-ln diff-ln-new">
306 {row.newLine ??
307 ""}
308 </td>
309 <td class="diff-sign">
310 {row.type ===
311 "add"
312 ? "+"
313 : row.type ===
314 "del"
315 ? "-"
316 : " "}
317 </td>
318 <td class="diff-code">
319 {
320 row.html as "safe"
321 }
322 </td>
323 </tr>
324 ),
325 )}
326 </tbody>
327 </table>
328 </div>
329 ))}
330 </div>
331 )}
332 </details>
333 );
334 })}
335 </div>
336 </div>
337 )}
338 </>
339 );
340}
341