DiffView.tsx
| 1 | import type { RepositoryRow } from "../db/index.ts"; |
| 2 | import type { RenderedDiffFile } from "../services/diffHighlight.ts"; |
| 3 | |
| 4 | export function slugify(s: string): string { |
| 5 | return `diff-${s.replace(/[^a-zA-Z0-9]/g, "-")}`; |
| 6 | } |
| 7 | |
| 8 | export 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 | |
| 22 | export type FileTreeNode = |
| 23 | | { type: "file"; file: RenderedDiffFile } |
| 24 | | { type: "dir"; children: Map<string, FileTreeNode> }; |
| 25 | |
| 26 | export 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 | |
| 47 | export 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 | |
| 127 | interface 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 | |
| 134 | export 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 |