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 | {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 | |
| 124 | interface 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 | |
| 131 | export 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 | 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 |