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 {name}/
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 >
75 {statusLetter(node.file.status)}
76 </span>
77 <span class="file-nav-name">{name}</span>
78 <span class="file-nav-stat">
79 {node.file.isBinary ? (
80 <span class="nav-binary">
81 {node.file.binarySize ? (
82 <>
83 <span class="nav-del">
84 {
85 node.file.binarySize
86 .before
87 }
88 </span>
89 {" \u2192 "}
90 <span class="nav-add">
91 {
92 node.file.binarySize
93 .after
94 }
95 </span>
96 </>
97 ) : (
98 "Bin"
99 )}
100 </span>
101 ) : (
102 <>
103 {node.file.added > 0 && (
104 <span class="nav-add">
105 +{node.file.added}
106 </span>
107 )}
108 {node.file.removed > 0 && (
109 <span class="nav-del">
110 -{node.file.removed}
111 </span>
112 )}
113 </>
114 )}
115 </span>
116 </a>
117 </li>
118 ),
119 )}
120 </ul>
121 );
122}
123
124interface DiffViewProps {
125 files: RenderedDiffFile[];
126 repo: RepositoryRow;
127 /** If provided, an extra "view at sha" link is shown in each file header. */
128 sha?: string;
129}
130
131export function DiffView({ files, repo, sha }: DiffViewProps) {
132 const totalAdded = files.reduce((s, f) => s + f.added, 0);
133 const totalRemoved = files.reduce((s, f) => s + f.removed, 0);
134
135 const changedStr = [
136 `${files.length} file${files.length !== 1 ? "s" : ""} changed`,
137 totalAdded > 0
138 ? `${totalAdded} insertion${totalAdded !== 1 ? "s" : ""}(+)`
139 : "",
140 totalRemoved > 0
141 ? `${totalRemoved} deletion${totalRemoved !== 1 ? "s" : ""}(-)`
142 : "",
143 ]
144 .filter(Boolean)
145 .join(", ");
146
147 return (
148 <>
149 {files.length > 0 && (
150 <div class="commit-stats-bar">
151 <span class="commit-stats-text">{changedStr}</span>
152 </div>
153 )}
154
155 {files.length === 0 ? (
156 <p class="text-muted" style="margin-top: var(--space-6)">
157 Empty diff or no diff available.
158 </p>
159 ) : (
160 <div class="commit-layout">
161 <aside class="commit-file-nav">
162 <details class="file-nav-details" open>
163 <summary class="file-nav-toggle">
164 <span class="file-nav-toggle-icon">▾</span>
165 <span>Files changed ({files.length})</span>
166 </summary>
167 {renderFileTree(buildFileTree(files))}
168 </details>
169 </aside>
170
171 <div class="commit-diffs">
172 {files.map((f) => {
173 const displayPath = f.newPath || f.oldPath;
174 const sl = statusLetter(f.status);
175 return (
176 <details
177 class="diff-file"
178 id={slugify(displayPath)}
179 open
180 >
181 <summary class="diff-file-header">
182 <div class="diff-file-header-left">
183 <span class="diff-file-toggle-icon">
184
185 </span>
186 <span
187 class={`diff-status-badge diff-status-${f.status}`}
188 >
189 {sl}
190 </span>
191 <span class="diff-file-path mono">
192 {displayPath}
193 </span>
194 {f.status === "renamed" &&
195 f.oldPath !== f.newPath && (
196 <span class="diff-rename-arrow">
197 ← {f.oldPath}
198 </span>
199 )}
200 </div>
201 <div class="diff-file-header-right">
202 {f.isBinary ? (
203 <span class="diff-stat-binary">
204 {f.binarySize ? (
205 <>
206 {"Bin "}
207 <span class="diff-stat-del">
208 {
209 f.binarySize
210 .before
211 }
212 </span>
213 {" \u2192 "}
214 <span class="diff-stat-add">
215 {
216 f.binarySize
217 .after
218 }
219 </span>
220 {" bytes"}
221 </>
222 ) : (
223 "Binary"
224 )}
225 </span>
226 ) : (
227 <>
228 {f.added > 0 && (
229 <span class="diff-stat-add">
230 +{f.added}
231 </span>
232 )}
233 {f.removed > 0 && (
234 <span class="diff-stat-del">
235 -{f.removed}
236 </span>
237 )}
238 </>
239 )}
240 {f.status !== "deleted" && (
241 <>
242 {sha && (
243 <a
244 href={`/${repo.name}/blob/${sha}/${displayPath}`}
245 class="btn btn-xs btn-secondary"
246 title={`View file at ${sha.slice(0, 7)}`}
247 >
248 @ {sha.slice(0, 7)}
249 </a>
250 )}
251 <a
252 href={`/${repo.name}/blob/${repo.default_branch}/${displayPath}`}
253 class="btn btn-xs btn-secondary"
254 title={`View file at ${repo.default_branch}`}
255 >
256 @ {repo.default_branch}
257 </a>
258 </>
259 )}
260 </div>
261 </summary>
262
263 {f.isBinary ? (
264 <div class="diff-binary-notice">
265 Binary file — not shown
266 </div>
267 ) : f.hunks.length === 0 ? (
268 <div class="diff-binary-notice">
269 No textual changes.
270 </div>
271 ) : (
272 <div class="diff-file-body">
273 {f.hunks.map((hunk) => (
274 <div class="diff-hunk">
275 <table class="diff-table">
276 <thead>
277 <tr>
278 <th
279 colspan="4"
280 class="diff-hunk-header"
281 >
282 {
283 hunk.header
284 }
285 </th>
286 </tr>
287 </thead>
288 <tbody>
289 {hunk.rows.map(
290 (row) => (
291 <tr
292 class={`diff-row diff-row-${row.type}`}
293 >
294 <td class="diff-ln diff-ln-old">
295 {row.oldLine ??
296 ""}
297 </td>
298 <td class="diff-ln diff-ln-new">
299 {row.newLine ??
300 ""}
301 </td>
302 <td class="diff-sign">
303 {row.type ===
304 "add"
305 ? "+"
306 : row.type ===
307 "del"
308 ? "-"
309 : " "}
310 </td>
311 <td class="diff-code">
312 {
313 row.html
314 }
315 </td>
316 </tr>
317 ),
318 )}
319 </tbody>
320 </table>
321 </div>
322 ))}
323 </div>
324 )}
325 </details>
326 );
327 })}
328 </div>
329 </div>
330 )}
331 </>
332 );
333}
334