Mercurial
changeset 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 | 3007ef5fc0ed |
| children | 3fa4bf481f42 2de49ee4fdd6 |
| files | hg-web/e2e/app_e2e_test.js hg-web/src/components/app.tsx hg-web/src/components/directory-browser.tsx hg-web/src/index.css |
| diffstat | 4 files changed, 269 insertions(+), 33 deletions(-) [+] |
line wrap: on
line diff
--- a/hg-web/e2e/app_e2e_test.js Sun Aug 02 13:51:11 2026 -0700 +++ b/hg-web/e2e/app_e2e_test.js Sun Aug 02 14:41:09 2026 -0700 @@ -74,6 +74,10 @@ fs.writeFileSync(path.join(root, 'docs', 'README.md'), '# Documentation\n\nNested README.\n'); fs.writeFileSync(path.join(root, 'src', 'main.c'), 'int main(void) { return 0; }\n'); fs.writeFileSync( + path.join(root, 'BUILD'), + 'cc_library(\n name = "fixture",\n srcs = ["src/main.c"],\n)\n', + ); + fs.writeFileSync( path.join(root, 'pixel.png'), Buffer.from( 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAusB9Wl2nWQAAAAASUVORK5CYII=', @@ -88,7 +92,10 @@ run('hg', ['--repository', root, 'commit', '-u', 'Test User <[email protected]>', '-m', 'Initial fixture'], options); const firstNode = run('hg', ['--repository', root, 'log', '-r', '.', '-T', '{node}'], options); - fs.appendFileSync(path.join(root, 'README.md'), '\nSecond revision.\n'); + fs.writeFileSync( + path.join(root, 'README.md'), + '# Updated Fixture Repository\n\n<script>window.__hgWebXss = true</script>\n\nSecond revision.\n', + ); fs.writeFileSync(path.join(root, 'new-file.txt'), 'new file\n'); run('hg', ['add', 'new-file.txt'], { ...options, cwd: root }); run('hg', ['--repository', root, 'commit', '-u', 'Test User <[email protected]>', '-m', 'Update fixture'], options); @@ -282,7 +289,7 @@ await assertPageHasNoBrowserErrors(browser, '/directory', async page => { await page.getByText('Repository Files').waitFor(); await page.getByRole('link', { name: 'README.md' }).first().click(); - await page.getByText('Fixture Repository').waitFor(); + await page.getByText('Updated Fixture Repository').waitFor(); assert.equal(await page.evaluate(() => window.__hgWebXss), undefined); await page.keyboard.press('Escape'); @@ -305,7 +312,7 @@ }), ]); await page.getByRole('link', { name: 'root' }).click(); - await page.getByText('Fixture Repository').waitFor(); + await page.getByText('Updated Fixture Repository').waitFor(); await page.waitForTimeout(600); assert.equal(await page.getByText('Documentation').count(), 0); assert.equal(delayedReadmeRequested, true); @@ -325,6 +332,13 @@ await page.getByRole('dialog', { name: 'Preview pixel.png' }).waitFor(); await page.keyboard.press('Escape'); + await page.getByRole('link', { name: 'BUILD' }).click(); + const buildCode = page.locator('code.language-python'); + await buildCode.waitFor(); + await page.getByText('cc_library').waitFor(); + assert.match(await buildCode.textContent(), /name = "fixture"/); + await page.keyboard.press('Escape'); + await page.getByRole('link', { name: 'src' }).click(); await page.getByRole('link', { name: 'main.c' }).click(); await page.getByText('int main(void)').waitFor(); @@ -355,6 +369,14 @@ await page.locator('.changeset-files code').filter({ hasText: 'new-file.txt' }).waitFor(); await page.getByText('added', { exact: true }).waitFor(); await page.getByRole('region', { name: 'Changeset diff' }).waitFor(); + await page.locator('.diff-column-headings').getByText('Before').first().waitFor(); + await page.locator('.diff-column-headings').getByText('After').first().waitFor(); + await page.locator('.diff-left.diff-remove').filter({ hasText: '# Fixture Repository' }).waitFor(); + await page.locator('.diff-right.diff-add').filter({ hasText: '# Updated Fixture Repository' }).waitFor(); + assert.equal( + await page.locator('.diff-left.diff-context').filter({ hasText: '<script>' }).first().textContent(), + '<script>window.__hgWebXss = true</script>', + ); assert.equal( await page.locator('.changeset-paper').evaluate( element => getComputedStyle(element).backgroundImage,
--- a/hg-web/src/components/app.tsx Sun Aug 02 13:51:11 2026 -0700 +++ b/hg-web/src/components/app.tsx Sun Aug 02 14:41:09 2026 -0700 @@ -35,6 +35,160 @@ }>; }; +type DiffLine = ChangesetDetail['diff'][number]['lines'][number]; + +type DiffCell = { + lineNumber: number | null; + text: string; + kind: 'context' | 'add' | 'remove' | 'meta'; +}; + +type SideBySideRow = { + left?: DiffCell; + right?: DiffCell; + range?: string; +}; + +function trimDiffLine(line: string): string { + return line.endsWith('\n') ? line.slice(0, -1) : line; +} + +function contentDiffLine(line: DiffLine): string { + const text = trimDiffLine(line.l); + if ((line.t === '+' || line.t === '-' || line.t === '' || line.t === ' ') && + text.startsWith(line.t || ' ')) { + return text.slice(1); + } + return text; +} + +function buildSideBySideRows(lines: DiffLine[]): SideBySideRow[] { + const rows: SideBySideRow[] = []; + let removals: DiffCell[] = []; + let additions: DiffCell[] = []; + let oldLine: number | null = null; + let newLine: number | null = null; + let inHunk = false; + + const flushChanges = () => { + const count = Math.max(removals.length, additions.length); + for (let index = 0; index < count; index++) { + rows.push({ left: removals[index], right: additions[index] }); + } + removals = []; + additions = []; + }; + + for (const line of lines) { + if (line.t === '@') { + flushChanges(); + const range = trimDiffLine(line.l); + const match = range.match(/^@@ -(\d+)(?:,\d+)? \+(\d+)(?:,\d+)? @@/); + oldLine = match ? Number(match[1]) : null; + newLine = match ? Number(match[2]) : null; + inHunk = true; + rows.push({ range }); + continue; + } + + if (!inHunk && (line.t === '-' || line.t === '+')) { + const cell: DiffCell = { + lineNumber: null, + text: trimDiffLine(line.l), + kind: 'meta', + }; + if (line.t === '-') removals.push(cell); + else additions.push(cell); + continue; + } + + if (line.t === '-') { + removals.push({ + lineNumber: oldLine, + text: contentDiffLine(line), + kind: 'remove', + }); + if (oldLine !== null) oldLine++; + continue; + } + + if (line.t === '+') { + additions.push({ + lineNumber: newLine, + text: contentDiffLine(line), + kind: 'add', + }); + if (newLine !== null) newLine++; + continue; + } + + flushChanges(); + const text = contentDiffLine(line); + rows.push({ + left: { lineNumber: oldLine, text, kind: 'context' }, + right: { lineNumber: newLine, text, kind: 'context' }, + }); + if (oldLine !== null) oldLine++; + if (newLine !== null) newLine++; + } + + flushChanges(); + return rows; +} + +function diffBlockFilename( + block: ChangesetDetail['diff'][number], + fallback?: string, +): string { + if (fallback) return fallback; + const newFileHeader = block.lines.find( + line => line.t === '+' && line.l.startsWith('+++ '), + ); + if (!newFileHeader) return `Diff block ${block.blockno}`; + return trimDiffLine(newFileHeader.l).replace(/^\+\+\+ (?:b\/)?/, '').split('\t')[0]; +} + +function SideBySideDiff({ + block, + filename, +}: { + block: ChangesetDetail['diff'][number]; + filename: string; +}) { + const rows = buildSideBySideRows(block.lines); + return ( + <div className="side-by-side-diff"> + <div className="diff-file-header">{filename}</div> + <div className="diff-column-headings"> + <span>Before</span> + <span>After</span> + </div> + <div className="diff-grid"> + {rows.map((row, index) => ( + row.range ? ( + <div className="diff-range-row" key={`range-${index}`}>{row.range}</div> + ) : ( + <React.Fragment key={`row-${index}`}> + <span className={`diff-side-number diff-${row.left?.kind || 'empty'}`}> + {row.left?.lineNumber ?? ''} + </span> + <code className={`diff-side-code diff-left diff-${row.left?.kind || 'empty'}`}> + {row.left?.text ?? ''} + </code> + <span className={`diff-side-number diff-column-divider diff-${row.right?.kind || 'empty'}`}> + {row.right?.lineNumber ?? ''} + </span> + <code className={`diff-side-code diff-right diff-${row.right?.kind || 'empty'}`}> + {row.right?.text ?? ''} + </code> + </React.Fragment> + ) + ))} + </div> + </div> + ); +} + // Icons const ICONS = { folder: "/icons/folder.png", @@ -390,22 +544,12 @@ <h3>Diff</h3> {changeset.diff.length === 0 ? ( <div className="empty-state">No textual changes in this changeset.</div> - ) : changeset.diff.map(block => ( - <pre key={block.blockno}> - {block.lines.map((line, index) => ( - <span - key={`${line.n}-${index}`} - className={ - line.t === '+' ? 'diff-add' : - line.t === '-' ? 'diff-remove' : - line.t === '@' ? 'diff-range' : 'diff-context' - } - > - <span className="diff-line-number">{line.n}</span> - <span>{line.l}</span> - </span> - ))} - </pre> + ) : changeset.diff.map((block, index) => ( + <SideBySideDiff + key={block.blockno} + block={block} + filename={diffBlockFilename(block, changeset.files[index]?.file)} + /> ))} </section> </article>
--- a/hg-web/src/components/directory-browser.tsx Sun Aug 02 13:51:11 2026 -0700 +++ b/hg-web/src/components/directory-browser.tsx Sun Aug 02 14:41:09 2026 -0700 @@ -19,7 +19,12 @@ 'sass', 'less', 'json', 'xml', 'yaml', 'yml', 'toml', 'ini', 'cfg', 'conf', 'md', 'markdown', 'txt', 'log', 'sql', 'graphql', 'vue', 'svelte', 'astro', 'prisma', 'dockerfile', 'makefile', 'cmake', - 'gradle', 'pom', 'lock', 'gitignore', 'env', 'example', 'sample' + 'gradle', 'pom', 'lock', 'gitignore', 'env', 'example', 'sample', + 'bzl', 'bazel' +]); + +const BAZEL_FILENAMES = new Set([ + 'build', 'build.bazel', 'module.bazel', 'workspace', 'workspace.bazel' ]); type StaticPreviewKind = 'image' | 'video' | 'audio' | 'pdf'; @@ -38,6 +43,7 @@ const basename = filename.toLowerCase(); return CODE_EXTENSIONS.has(ext) || CODE_EXTENSIONS.has(basename) || + BAZEL_FILENAMES.has(basename) || basename === 'dockerfile' || basename === 'makefile' || basename.startsWith('.'); @@ -172,6 +178,10 @@ const getLanguage = () => { const ext = filename.split('.').pop()?.toLowerCase() || ''; + const basename = filename.toLowerCase(); + if (BAZEL_FILENAMES.has(basename) || ext === 'bzl' || ext === 'bazel') { + return 'python'; + } const langMap: Record<string, string> = { js: 'javascript', jsx: 'javascript', ts: 'typescript', tsx: 'typescript', py: 'python', rb: 'ruby', rs: 'rust', go: 'go', java: 'java',
--- a/hg-web/src/index.css Sun Aug 02 13:51:11 2026 -0700 +++ b/hg-web/src/index.css Sun Aug 02 14:41:09 2026 -0700 @@ -478,30 +478,77 @@ margin-bottom: 10px; } -.changeset-diff pre { - margin: 0 0 16px; - padding: 0; +.side-by-side-diff { + margin-bottom: 18px; overflow-x: auto; border: 1px solid var(--border); + border-radius: 6px; + background: var(--bg-code); +} + +.diff-file-header { + padding: 9px 12px; + border-bottom: 1px solid var(--border); + background: var(--bg-subtle); + font-family: monospace; + font-weight: 600; } -.changeset-diff pre > span { - display: flex; - min-width: max-content; - padding-right: 12px; +.diff-column-headings { + display: grid; + grid-template-columns: minmax(380px, 1fr) minmax(380px, 1fr); + min-width: 760px; + border-bottom: 1px solid var(--border); + color: var(--text-secondary); + font-size: 12px; + font-weight: 600; + text-transform: uppercase; +} + +.diff-column-headings span { + padding: 6px 12px; } -.diff-line-number { - width: 54px; - flex-shrink: 0; - margin-right: 12px; - padding-right: 10px; +.diff-column-headings span + span { + border-left: 1px solid var(--border); +} + +.diff-grid { + display: grid; + grid-template-columns: 52px minmax(328px, 1fr) 52px minmax(328px, 1fr); + min-width: 760px; + font-family: 'SFMono-Regular', Consolas, 'Liberation Mono', Menlo, monospace; + font-size: 12px; + line-height: 1.5; +} + +.diff-side-number, +.diff-side-code { + min-height: 22px; + border-bottom: 1px solid color-mix(in srgb, var(--border) 55%, transparent); +} + +.diff-side-number { + padding: 2px 8px; color: var(--text-secondary); text-align: right; user-select: none; border-right: 1px solid var(--border); } +.diff-side-code { + display: block; + margin: 0; + padding: 2px 10px; + border-radius: 0; + background: transparent; + white-space: pre; +} + +.diff-column-divider { + border-left: 1px solid var(--border); +} + .diff-add { background: color-mix(in srgb, var(--success) 18%, transparent); } @@ -510,9 +557,22 @@ background: color-mix(in srgb, var(--danger) 18%, transparent); } -.diff-range { +.diff-empty { + background: color-mix(in srgb, var(--bg-subtle) 70%, transparent); +} + +.diff-meta { + color: var(--text-secondary); + background: var(--bg-subtle); +} + +.diff-range-row { + grid-column: 1 / -1; + padding: 4px 10px; color: var(--accent); background: var(--bg-subtle); + border-bottom: 1px solid var(--border); + white-space: pre; } /* ===========================================