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>