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