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;
 }
 
 /* ===========================================