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>
155 {changedStr}
156 </span>
157 </div>
158 )}
159
160 {files.length === 0 ? (
161 <p class="text-muted" style="margin-top: var(--space-6)">
162 Empty diff or no diff available.
163 </p>
164 ) : (
165 <div class="commit-layout">
166 <aside class="commit-file-nav">
167 <details class="file-nav-details" open>
168 <summary class="file-nav-toggle">
169 <span class="file-nav-toggle-icon">▾</span>
170 <span>Files changed ({files.length})</span>
171 </summary>
172 {renderFileTree(buildFileTree(files))}
173 </details>
174 </aside>
175
176 <div class="commit-diffs">
177 {files.map((f) => {
178 const displayPath = f.newPath || f.oldPath;
179 const sl = statusLetter(f.status);
180 return (
181 <details
182 class="diff-file"
183 id={slugify(displayPath)}
184 open
185 >
186 <summary class="diff-file-header">
187 <div class="diff-file-header-left">
188 <span class="diff-file-toggle-icon">
189
190 </span>
191 <span
192 class={`diff-status-badge diff-status-${f.status}`}
193 safe
194 >
195 {sl}
196 </span>
197 <span
198 class="diff-file-path mono"
199 safe
200 >
201 {displayPath}
202 </span>
203 {f.status === "renamed" &&
204 f.oldPath !== f.newPath && (
205 <span
206 class="diff-rename-arrow"
207 safe
208 >
209 ← {f.oldPath}
210 </span>
211 )}
212 </div>
213 <div class="diff-file-header-right">
214 {f.isBinary ? (
215 <span class="diff-stat-binary">
216 {f.binarySize ? (
217 <>
218 {"Bin "}
219 <span class="diff-stat-del">
220 {
221 f.binarySize
222 .before
223 }
224 </span>
225 {" \u2192 "}
226 <span class="diff-stat-add">
227 {
228 f.binarySize
229 .after
230 }
231 </span>
232 {" bytes"}
233 </>
234 ) : (
235 "Binary"
236 )}
237 </span>
238 ) : (
239 <>
240 {f.added > 0 && (
241 <span class="diff-stat-add">
242 +{f.added}
243 </span>
244 )}
245 {f.removed > 0 && (
246 <span class="diff-stat-del">
247 -{f.removed}
248 </span>
249 )}
250 </>
251 )}
252 {f.status !== "deleted" && (
253 <>
254 {!!sha && (
255 <a
256 href={`/${repo.name}/blob/${sha}/${displayPath}`}
257 class="btn btn-xs btn-secondary"
258 title={`View file at ${sha.slice(0, 7)}`}
259 safe
260 >
261 @ {sha.slice(0, 7)}
262 </a>
263 )}
264 <a
265 href={`/${repo.name}/blob/${repo.default_branch}/${displayPath}`}
266 class="btn btn-xs btn-secondary"
267 title={`View file at ${repo.default_branch}`}
268 safe
269 >
270 @ {repo.default_branch}
271 </a>
272 </>
273 )}
274 </div>
275 </summary>
276
277 {f.isBinary ? (
278 <div class="diff-binary-notice">
279 Binary file — not shown
280 </div>
281 ) : f.hunks.length === 0 ? (
282 <div class="diff-binary-notice">
283 No textual changes.
284 </div>
285 ) : (
286 <div class="diff-file-body">
287 {f.hunks.map((hunk) => (
288 <div class="diff-hunk">
289 <table class="diff-table">
290 <thead>
291 <tr>
292 <th
293 colspan="4"
294 class="diff-hunk-header"
295 safe
296 >
297 {
298 hunk.header
299 }
300 </th>
301 </tr>
302 </thead>
303 <tbody>
304 {hunk.rows.map(
305 (row) => (
306 <tr
307 class={`diff-row diff-row-${row.type}`}
308 >
309 <td class="diff-ln diff-ln-old">
310 {row.oldLine ??
311 ""}
312 </td>
313 <td class="diff-ln diff-ln-new">
314 {row.newLine ??
315 ""}
316 </td>
317 <td class="diff-sign">
318 {row.type ===
319 "add"
320 ? "+"
321 : row.type ===
322 "del"
323 ? "-"
324 : " "}
325 </td>
326 <td class="diff-code">
327 {
328 row.html as "safe"
329 }
330 </td>
331 </tr>
332 ),
333 )}
334 </tbody>
335 </table>
336 </div>
337 ))}
338 </div>
339 )}
340 </details>
341 );
342 })}
343 </div>
344 </div>
345 )}
346 </>
347 );
348}
349