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.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 | |
| 98 | interface 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 | |
| 105 | export 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 |