Mercurial
comparison hg-web/src/components/app.tsx @ 225:70de0c80d093 hg-web
[hg-web] Add side-by-side changeset diffs
| author | MrJuneJune <me@mrjunejune.com> |
|---|---|
| date | Sun, 02 Aug 2026 14:41:09 -0700 |
| parents | 0e7b9464248d |
| children |
comparison
equal
deleted
inserted
replaced
| 224:3007ef5fc0ed | 225:70de0c80d093 |
|---|---|
| 32 diff: Array<{ | 32 diff: Array<{ |
| 33 blockno: number; | 33 blockno: number; |
| 34 lines: Array<{ t: string; n: number; l: string }>; | 34 lines: Array<{ t: string; n: number; l: string }>; |
| 35 }>; | 35 }>; |
| 36 }; | 36 }; |
| 37 | |
| 38 type DiffLine = ChangesetDetail['diff'][number]['lines'][number]; | |
| 39 | |
| 40 type DiffCell = { | |
| 41 lineNumber: number | null; | |
| 42 text: string; | |
| 43 kind: 'context' | 'add' | 'remove' | 'meta'; | |
| 44 }; | |
| 45 | |
| 46 type SideBySideRow = { | |
| 47 left?: DiffCell; | |
| 48 right?: DiffCell; | |
| 49 range?: string; | |
| 50 }; | |
| 51 | |
| 52 function trimDiffLine(line: string): string { | |
| 53 return line.endsWith('\n') ? line.slice(0, -1) : line; | |
| 54 } | |
| 55 | |
| 56 function contentDiffLine(line: DiffLine): string { | |
| 57 const text = trimDiffLine(line.l); | |
| 58 if ((line.t === '+' || line.t === '-' || line.t === '' || line.t === ' ') && | |
| 59 text.startsWith(line.t || ' ')) { | |
| 60 return text.slice(1); | |
| 61 } | |
| 62 return text; | |
| 63 } | |
| 64 | |
| 65 function buildSideBySideRows(lines: DiffLine[]): SideBySideRow[] { | |
| 66 const rows: SideBySideRow[] = []; | |
| 67 let removals: DiffCell[] = []; | |
| 68 let additions: DiffCell[] = []; | |
| 69 let oldLine: number | null = null; | |
| 70 let newLine: number | null = null; | |
| 71 let inHunk = false; | |
| 72 | |
| 73 const flushChanges = () => { | |
| 74 const count = Math.max(removals.length, additions.length); | |
| 75 for (let index = 0; index < count; index++) { | |
| 76 rows.push({ left: removals[index], right: additions[index] }); | |
| 77 } | |
| 78 removals = []; | |
| 79 additions = []; | |
| 80 }; | |
| 81 | |
| 82 for (const line of lines) { | |
| 83 if (line.t === '@') { | |
| 84 flushChanges(); | |
| 85 const range = trimDiffLine(line.l); | |
| 86 const match = range.match(/^@@ -(\d+)(?:,\d+)? \+(\d+)(?:,\d+)? @@/); | |
| 87 oldLine = match ? Number(match[1]) : null; | |
| 88 newLine = match ? Number(match[2]) : null; | |
| 89 inHunk = true; | |
| 90 rows.push({ range }); | |
| 91 continue; | |
| 92 } | |
| 93 | |
| 94 if (!inHunk && (line.t === '-' || line.t === '+')) { | |
| 95 const cell: DiffCell = { | |
| 96 lineNumber: null, | |
| 97 text: trimDiffLine(line.l), | |
| 98 kind: 'meta', | |
| 99 }; | |
| 100 if (line.t === '-') removals.push(cell); | |
| 101 else additions.push(cell); | |
| 102 continue; | |
| 103 } | |
| 104 | |
| 105 if (line.t === '-') { | |
| 106 removals.push({ | |
| 107 lineNumber: oldLine, | |
| 108 text: contentDiffLine(line), | |
| 109 kind: 'remove', | |
| 110 }); | |
| 111 if (oldLine !== null) oldLine++; | |
| 112 continue; | |
| 113 } | |
| 114 | |
| 115 if (line.t === '+') { | |
| 116 additions.push({ | |
| 117 lineNumber: newLine, | |
| 118 text: contentDiffLine(line), | |
| 119 kind: 'add', | |
| 120 }); | |
| 121 if (newLine !== null) newLine++; | |
| 122 continue; | |
| 123 } | |
| 124 | |
| 125 flushChanges(); | |
| 126 const text = contentDiffLine(line); | |
| 127 rows.push({ | |
| 128 left: { lineNumber: oldLine, text, kind: 'context' }, | |
| 129 right: { lineNumber: newLine, text, kind: 'context' }, | |
| 130 }); | |
| 131 if (oldLine !== null) oldLine++; | |
| 132 if (newLine !== null) newLine++; | |
| 133 } | |
| 134 | |
| 135 flushChanges(); | |
| 136 return rows; | |
| 137 } | |
| 138 | |
| 139 function diffBlockFilename( | |
| 140 block: ChangesetDetail['diff'][number], | |
| 141 fallback?: string, | |
| 142 ): string { | |
| 143 if (fallback) return fallback; | |
| 144 const newFileHeader = block.lines.find( | |
| 145 line => line.t === '+' && line.l.startsWith('+++ '), | |
| 146 ); | |
| 147 if (!newFileHeader) return `Diff block ${block.blockno}`; | |
| 148 return trimDiffLine(newFileHeader.l).replace(/^\+\+\+ (?:b\/)?/, '').split('\t')[0]; | |
| 149 } | |
| 150 | |
| 151 function SideBySideDiff({ | |
| 152 block, | |
| 153 filename, | |
| 154 }: { | |
| 155 block: ChangesetDetail['diff'][number]; | |
| 156 filename: string; | |
| 157 }) { | |
| 158 const rows = buildSideBySideRows(block.lines); | |
| 159 return ( | |
| 160 <div className="side-by-side-diff"> | |
| 161 <div className="diff-file-header">{filename}</div> | |
| 162 <div className="diff-column-headings"> | |
| 163 <span>Before</span> | |
| 164 <span>After</span> | |
| 165 </div> | |
| 166 <div className="diff-grid"> | |
| 167 {rows.map((row, index) => ( | |
| 168 row.range ? ( | |
| 169 <div className="diff-range-row" key={`range-${index}`}>{row.range}</div> | |
| 170 ) : ( | |
| 171 <React.Fragment key={`row-${index}`}> | |
| 172 <span className={`diff-side-number diff-${row.left?.kind || 'empty'}`}> | |
| 173 {row.left?.lineNumber ?? ''} | |
| 174 </span> | |
| 175 <code className={`diff-side-code diff-left diff-${row.left?.kind || 'empty'}`}> | |
| 176 {row.left?.text ?? ''} | |
| 177 </code> | |
| 178 <span className={`diff-side-number diff-column-divider diff-${row.right?.kind || 'empty'}`}> | |
| 179 {row.right?.lineNumber ?? ''} | |
| 180 </span> | |
| 181 <code className={`diff-side-code diff-right diff-${row.right?.kind || 'empty'}`}> | |
| 182 {row.right?.text ?? ''} | |
| 183 </code> | |
| 184 </React.Fragment> | |
| 185 ) | |
| 186 ))} | |
| 187 </div> | |
| 188 </div> | |
| 189 ); | |
| 190 } | |
| 37 | 191 |
| 38 // Icons | 192 // Icons |
| 39 const ICONS = { | 193 const ICONS = { |
| 40 folder: "/icons/folder.png", | 194 folder: "/icons/folder.png", |
| 41 }; | 195 }; |
| 388 | 542 |
| 389 <section className="changeset-diff" aria-label="Changeset diff"> | 543 <section className="changeset-diff" aria-label="Changeset diff"> |
| 390 <h3>Diff</h3> | 544 <h3>Diff</h3> |
| 391 {changeset.diff.length === 0 ? ( | 545 {changeset.diff.length === 0 ? ( |
| 392 <div className="empty-state">No textual changes in this changeset.</div> | 546 <div className="empty-state">No textual changes in this changeset.</div> |
| 393 ) : changeset.diff.map(block => ( | 547 ) : changeset.diff.map((block, index) => ( |
| 394 <pre key={block.blockno}> | 548 <SideBySideDiff |
| 395 {block.lines.map((line, index) => ( | 549 key={block.blockno} |
| 396 <span | 550 block={block} |
| 397 key={`${line.n}-${index}`} | 551 filename={diffBlockFilename(block, changeset.files[index]?.file)} |
| 398 className={ | 552 /> |
| 399 line.t === '+' ? 'diff-add' : | |
| 400 line.t === '-' ? 'diff-remove' : | |
| 401 line.t === '@' ? 'diff-range' : 'diff-context' | |
| 402 } | |
| 403 > | |
| 404 <span className="diff-line-number">{line.n}</span> | |
| 405 <span>{line.l}</span> | |
| 406 </span> | |
| 407 ))} | |
| 408 </pre> | |
| 409 ))} | 553 ))} |
| 410 </section> | 554 </section> |
| 411 </article> | 555 </article> |
| 412 )} | 556 )} |
| 413 </div> | 557 </div> |