Refactoring only: Split up the diff builder into two classes

GrDiffBuilder had two responsibilities:
1. Manage the data and the process for building a diff.
2. Create diff elements programmatically.

This change splits the builder up into two classes. It adds
`GrDiffBuilderLegacy` as a subclass of `GrDiffBuilder`.

The goal is to create alternative diff builders that are based on (Lit)
components instead of creating elements programmatically. For doing that
we will be re-using `GrDiffBuilder`, but replacing
`GrDiffBuilderLegacy`. That is why we need the split, although splitting
up large classes generally makes sense. :-)

We are also moving some stateless utilities into `gr-diff-utils.ts`.

Release-Notes: skip
Change-Id: Iaf92695139135edafdc81922fbf2435cfe2832c4
diff --git a/polygerrit-ui/app/embed/diff/gr-diff-builder/gr-diff-builder-binary.ts b/polygerrit-ui/app/embed/diff/gr-diff-builder/gr-diff-builder-binary.ts
index ec41fc8..6699b57 100644
--- a/polygerrit-ui/app/embed/diff/gr-diff-builder/gr-diff-builder-binary.ts
+++ b/polygerrit-ui/app/embed/diff/gr-diff-builder/gr-diff-builder-binary.ts
@@ -14,12 +14,10 @@
  * See the License for the specific language governing permissions and
  * limitations under the License.
  */
-
 import {GrDiffBuilderUnified} from './gr-diff-builder-unified';
 import {DiffInfo, DiffPreferencesInfo} from '../../../types/diff';
 import {GrDiffLine, GrDiffLineType} from '../gr-diff/gr-diff-line';
 import {queryAndAssert} from '../../../utils/common-util';
-import {RenderPreferences} from '../../../api/diff';
 import {createElementDiff} from '../gr-diff/gr-diff-utils';
 
 export class GrDiffBuilderBinary extends GrDiffBuilderUnified {
@@ -34,12 +32,10 @@
   override buildSectionElement(): HTMLElement {
     const section = createElementDiff('tbody', 'binary-diff');
     const line = new GrDiffLine(GrDiffLineType.BOTH, 'FILE', 'FILE');
-    const fileRow = this._createRow(line);
+    const fileRow = this.createRow(line);
     const contentTd = queryAndAssert(fileRow, 'td.both.file')!;
     contentTd.textContent = ' Difference in binary files';
     section.appendChild(fileRow);
     return section;
   }
-
-  override updateRenderPrefs(_renderPrefs: RenderPreferences) {}
 }
diff --git a/polygerrit-ui/app/embed/diff/gr-diff-builder/gr-diff-builder-element.ts b/polygerrit-ui/app/embed/diff/gr-diff-builder/gr-diff-builder-element.ts
index bbebb21..8421620 100644
--- a/polygerrit-ui/app/embed/diff/gr-diff-builder/gr-diff-builder-element.ts
+++ b/polygerrit-ui/app/embed/diff/gr-diff-builder/gr-diff-builder-element.ts
@@ -588,7 +588,7 @@
 
   setBlame(blame: BlameInfo[] | null) {
     if (!this._builder) return;
-    this._builder.setBlame(blame);
+    this._builder.setBlame(blame ?? []);
   }
 
   updateRenderPrefs(renderPrefs: RenderPreferences) {
diff --git a/polygerrit-ui/app/embed/diff/gr-diff-builder/gr-diff-builder-element_test.js b/polygerrit-ui/app/embed/diff/gr-diff-builder/gr-diff-builder-element_test.js
index feee78f..7ee2f17 100644
--- a/polygerrit-ui/app/embed/diff/gr-diff-builder/gr-diff-builder-element_test.js
+++ b/polygerrit-ui/app/embed/diff/gr-diff-builder/gr-diff-builder-element_test.js
@@ -28,6 +28,7 @@
 import {DiffViewMode, Side} from '../../../api/diff.js';
 import {stubRestApi} from '../../../test/test-utils.js';
 import {afterNextRender} from '@polymer/polymer/lib/utils/render-status';
+import {GrDiffBuilderLegacy} from './gr-diff-builder-legacy.js';
 
 const basicFixture = fixtureFromTemplate(html`
     <gr-diff-builder>
@@ -67,48 +68,7 @@
     stubRestApi('getProjectConfig').returns(Promise.resolve({}));
     stubBaseUrl('/r');
     prefs = {...DEFAULT_PREFS};
-    builder = new GrDiffBuilder({content: []}, prefs);
-  });
-
-  test('newlines 1', () => {
-    let text = 'abcdef';
-
-    assert.equal(builder._formatText(text, 'NONE', 4, 10).innerHTML, text);
-    text = 'a'.repeat(20);
-    assert.equal(builder._formatText(text, 'NONE', 4, 10).innerHTML,
-        'a'.repeat(10) +
-        LINE_BREAK_HTML +
-        'a'.repeat(10));
-  });
-
-  test('newlines 2', () => {
-    const text = '<span class="thumbsup">๐Ÿ‘</span>';
-    assert.equal(builder._formatText(text, 'NONE', 4, 10).innerHTML,
-        '&lt;span clas' +
-        LINE_BREAK_HTML +
-        's="thumbsu' +
-        LINE_BREAK_HTML +
-        'p"&gt;๐Ÿ‘&lt;/span' +
-        LINE_BREAK_HTML +
-        '&gt;');
-  });
-
-  test('newlines 3', () => {
-    const text = '01234\t56789';
-    assert.equal(builder._formatText(text, 'NONE', 4, 10).innerHTML,
-        '01234' + builder._getTabWrapper(3).outerHTML + '56' +
-        LINE_BREAK_HTML +
-        '789');
-  });
-
-  test('newlines 4', () => {
-    const text = '๐Ÿ‘๐Ÿ‘๐Ÿ‘๐Ÿ‘๐Ÿ‘๐Ÿ‘๐Ÿ‘๐Ÿ‘๐Ÿ‘๐Ÿ‘๐Ÿ‘๐Ÿ‘๐Ÿ‘๐Ÿ‘๐Ÿ‘๐Ÿ‘๐Ÿ‘๐Ÿ‘๐Ÿ‘๐Ÿ‘๐Ÿ‘๐Ÿ‘๐Ÿ‘๐Ÿ‘๐Ÿ‘๐Ÿ‘๐Ÿ‘๐Ÿ‘๐Ÿ‘๐Ÿ‘๐Ÿ‘๐Ÿ‘๐Ÿ‘๐Ÿ‘๐Ÿ‘๐Ÿ‘๐Ÿ‘๐Ÿ‘๐Ÿ‘๐Ÿ‘๐Ÿ‘๐Ÿ‘๐Ÿ‘๐Ÿ‘๐Ÿ‘๐Ÿ‘๐Ÿ‘๐Ÿ‘๐Ÿ‘๐Ÿ‘๐Ÿ‘๐Ÿ‘๐Ÿ‘๐Ÿ‘๐Ÿ‘๐Ÿ‘๐Ÿ‘๐Ÿ‘';
-    assert.equal(builder._formatText(text, 'NONE', 4, 20).innerHTML,
-        '๐Ÿ‘๐Ÿ‘๐Ÿ‘๐Ÿ‘๐Ÿ‘๐Ÿ‘๐Ÿ‘๐Ÿ‘๐Ÿ‘๐Ÿ‘๐Ÿ‘๐Ÿ‘๐Ÿ‘๐Ÿ‘๐Ÿ‘๐Ÿ‘๐Ÿ‘๐Ÿ‘๐Ÿ‘๐Ÿ‘' +
-        LINE_BREAK_HTML +
-        '๐Ÿ‘๐Ÿ‘๐Ÿ‘๐Ÿ‘๐Ÿ‘๐Ÿ‘๐Ÿ‘๐Ÿ‘๐Ÿ‘๐Ÿ‘๐Ÿ‘๐Ÿ‘๐Ÿ‘๐Ÿ‘๐Ÿ‘๐Ÿ‘๐Ÿ‘๐Ÿ‘๐Ÿ‘๐Ÿ‘' +
-        LINE_BREAK_HTML +
-        '๐Ÿ‘๐Ÿ‘๐Ÿ‘๐Ÿ‘๐Ÿ‘๐Ÿ‘๐Ÿ‘๐Ÿ‘๐Ÿ‘๐Ÿ‘๐Ÿ‘๐Ÿ‘๐Ÿ‘๐Ÿ‘๐Ÿ‘๐Ÿ‘๐Ÿ‘๐Ÿ‘');
+    builder = new GrDiffBuilderLegacy({content: []}, prefs);
   });
 
   test('line_length applied with <wbr> if line_wrapping is true', () => {
@@ -117,7 +77,7 @@
 
     const line = {text, highlights: []};
     const expected = 'a'.repeat(50) + WBR_HTML + 'a';
-    const result = builder._createTextEl(undefined, line).firstChild.innerHTML;
+    const result = builder.createTextEl(undefined, line).firstChild.innerHTML;
     assert.equal(result, expected);
   });
 
@@ -127,7 +87,7 @@
 
     const line = {text, highlights: []};
     const expected = 'a'.repeat(50) + LINE_BREAK_HTML + 'a';
-    const result = builder._createTextEl(undefined, line).firstChild.innerHTML;
+    const result = builder.createTextEl(undefined, line).firstChild.innerHTML;
     assert.equal(result, expected);
   });
 
@@ -152,10 +112,10 @@
         });
       });
 
-  test('_createTextEl linewrap with tabs', () => {
+  test('createTextEl linewrap with tabs', () => {
     const text = '\t'.repeat(7) + '!';
     const line = {text, highlights: []};
-    const el = builder._createTextEl(undefined, line);
+    const el = builder.createTextEl(undefined, line);
     assert.equal(el.innerText, text);
     // With line length 10 and tab size 2, there should be a line break
     // after every two tabs.
@@ -166,70 +126,6 @@
         newlineEl);
   });
 
-  test('text length with tabs and unicode', () => {
-    function expectTextLength(text, tabSize, expected) {
-      // Formatting to |expected| columns should not introduce line breaks.
-      const result = builder._formatText(text, 'NONE', tabSize, expected);
-      assert.isNotOk(result.querySelector('.contentText > .br'),
-          `  Expected the result of: \n` +
-          `      _formatText(${text}', 'NONE',  ${tabSize}, ${expected})\n` +
-          `  to not contain a br. But the actual result HTML was:\n` +
-          `      '${result.innerHTML}'\nwhereupon`);
-
-      // Increasing the line limit should produce the same markup.
-      assert.equal(
-          builder._formatText(text, 'NONE', tabSize, Infinity).innerHTML,
-          result.innerHTML);
-      assert.equal(
-          builder._formatText(text, 'NONE', tabSize, expected + 1).innerHTML,
-          result.innerHTML);
-
-      // Decreasing the line limit should introduce line breaks.
-      if (expected > 0) {
-        const tooSmall = builder._formatText(text,
-            'NONE', tabSize, expected - 1);
-        assert.isOk(tooSmall.querySelector('.contentText > .br'),
-            `  Expected the result of: \n` +
-            `      _formatText(${text}', ${tabSize}, ${expected - 1})\n` +
-            `  to contain a br. But the actual result HTML was:\n` +
-            `      '${tooSmall.innerHTML}'\nwhereupon`);
-      }
-    }
-    expectTextLength('12345', 4, 5);
-    expectTextLength('\t\t12', 4, 10);
-    expectTextLength('abc๐Ÿ’ข123', 4, 7);
-    expectTextLength('abc\t', 8, 8);
-    expectTextLength('abc\t\t', 10, 20);
-    expectTextLength('', 10, 0);
-    // 17 Thai combining chars.
-    expectTextLength('เธเน‰เน‰เน‰เน‰เน‰เน‰เน‰เน‰เน‰เน‰เน‰เน‰เน‰เน‰เน‰เน‰', 4, 17);
-    expectTextLength('abc\tde', 10, 12);
-    expectTextLength('abc\tde\t', 10, 20);
-    expectTextLength('\t\t\t\t\t', 20, 100);
-  });
-
-  test('tab wrapper insertion', () => {
-    const html = 'abc\tdef';
-    const tabSize = builder._prefs.tab_size;
-    const wrapper = builder._getTabWrapper(tabSize - 3);
-    assert.ok(wrapper);
-    assert.equal(wrapper.innerText, '\t');
-    assert.equal(
-        builder._formatText(html, 'NONE', tabSize, Infinity).innerHTML,
-        'abc' + wrapper.outerHTML + 'def');
-  });
-
-  test('tab wrapper style', () => {
-    const pattern = new RegExp('^<span class="style-scope gr-diff tab" ' +
-      'style="((?:-moz-)?tab-size: (\\d+);.?)+">\\t<\\/span>$');
-
-    for (const size of [1, 3, 8, 55]) {
-      const html = builder._getTabWrapper(size).outerHTML;
-      expect(html).to.match(pattern);
-      assert.equal(html.match(pattern)[2], size);
-    }
-  });
-
   test('_handlePreferenceError throws with invalid preference', () => {
     element.prefs = {tab_size: 0};
     assert.throws(() => element._getDiffBuilder());
@@ -244,14 +140,14 @@
       `Fix in diff preferences`);
   });
 
-  suite('_isTotal', () => {
+  suite('isTotal', () => {
     test('is total for add', () => {
       const lines = [];
       for (let idx = 0; idx < 10; idx++) {
         lines.push(new GrDiffLine(GrDiffLineType.ADD));
       }
       const group = new GrDiffGroup({type: GrDiffGroupType.DELTA, lines});
-      assert.isTrue(GrDiffBuilder.prototype._isTotal(group));
+      assert.isTrue(GrDiffBuilder.prototype.isTotal(group));
     });
 
     test('is total for remove', () => {
@@ -260,12 +156,12 @@
         lines.push(new GrDiffLine(GrDiffLineType.REMOVE));
       }
       const group = new GrDiffGroup({type: GrDiffGroupType.DELTA, lines});
-      assert.isTrue(GrDiffBuilder.prototype._isTotal(group));
+      assert.isTrue(GrDiffBuilder.prototype.isTotal(group));
     });
 
     test('not total for empty', () => {
       const group = new GrDiffGroup({type: GrDiffGroupType.BOTH});
-      assert.isFalse(GrDiffBuilder.prototype._isTotal(group));
+      assert.isFalse(GrDiffBuilder.prototype.isTotal(group));
     });
 
     test('not total for non-delta', () => {
@@ -274,7 +170,7 @@
         lines.push(new GrDiffLine(GrDiffLineType.BOTH));
       }
       const group = new GrDiffGroup({type: GrDiffGroupType.DELTA, lines});
-      assert.isFalse(GrDiffBuilder.prototype._isTotal(group));
+      assert.isFalse(GrDiffBuilder.prototype.isTotal(group));
     });
   });
 
@@ -1003,13 +899,13 @@
       }
     });
 
-    test('_renderContentByRange', () => {
-      const spy = sinon.spy(builder, '_createTextEl');
+    test('renderContentByRange', () => {
+      const spy = sinon.spy(builder, 'createTextEl');
       const start = 9;
       const end = 14;
       const count = end - start + 1;
 
-      builder._renderContentByRange(start, end, 'left');
+      builder.renderContentByRange(start, end, 'left');
 
       assert.equal(spy.callCount, count);
       spy.getCalls().forEach((call, i) => {
@@ -1017,10 +913,10 @@
       });
     });
 
-    test('_renderContentByRange notexistent elements', () => {
-      const spy = sinon.spy(builder, '_createTextEl');
+    test('renderContentByRange notexistent elements', () => {
+      const spy = sinon.spy(builder, 'createTextEl');
 
-      sinon.stub(builder, '_getLineNumberEl').returns(
+      sinon.stub(builder, 'getLineNumberEl').returns(
           document.createElement('div')
       );
       sinon.stub(builder, 'findLinesByRange').callsFake(
@@ -1039,29 +935,29 @@
             lines.push(new GrDiffLine(GrDiffLineType.BOTH));
           });
 
-      builder._renderContentByRange(1, 10, 'left');
+      builder.renderContentByRange(1, 10, 'left');
       // Should be called only once because only one line had a corresponding
       // element.
       assert.equal(spy.callCount, 1);
     });
 
-    test('_getLineNumberEl side-by-side left', () => {
+    test('getLineNumberEl side-by-side left', () => {
       const contentEl = builder.getContentByLine(5, 'left',
           element.$.diffTable);
-      const lineNumberEl = builder._getLineNumberEl(contentEl, 'left');
+      const lineNumberEl = builder.getLineNumberEl(contentEl, 'left');
       assert.isTrue(lineNumberEl.classList.contains('lineNum'));
       assert.isTrue(lineNumberEl.classList.contains('left'));
     });
 
-    test('_getLineNumberEl side-by-side right', () => {
+    test('getLineNumberEl side-by-side right', () => {
       const contentEl = builder.getContentByLine(5, 'right',
           element.$.diffTable);
-      const lineNumberEl = builder._getLineNumberEl(contentEl, 'right');
+      const lineNumberEl = builder.getLineNumberEl(contentEl, 'right');
       assert.isTrue(lineNumberEl.classList.contains('lineNum'));
       assert.isTrue(lineNumberEl.classList.contains('right'));
     });
 
-    test('_getLineNumberEl unified left', async () => {
+    test('getLineNumberEl unified left', async () => {
       // Re-render as unified:
       element.viewMode = 'UNIFIED_DIFF';
       await element.render(keyLocations);
@@ -1069,12 +965,12 @@
 
       const contentEl = builder.getContentByLine(5, 'left',
           element.$.diffTable);
-      const lineNumberEl = builder._getLineNumberEl(contentEl, 'left');
+      const lineNumberEl = builder.getLineNumberEl(contentEl, 'left');
       assert.isTrue(lineNumberEl.classList.contains('lineNum'));
       assert.isTrue(lineNumberEl.classList.contains('left'));
     });
 
-    test('_getLineNumberEl unified right', async () => {
+    test('getLineNumberEl unified right', async () => {
       // Re-render as unified:
       element.viewMode = 'UNIFIED_DIFF';
       await element.render(keyLocations);
@@ -1082,36 +978,36 @@
 
       const contentEl = builder.getContentByLine(5, 'right',
           element.$.diffTable);
-      const lineNumberEl = builder._getLineNumberEl(contentEl, 'right');
+      const lineNumberEl = builder.getLineNumberEl(contentEl, 'right');
       assert.isTrue(lineNumberEl.classList.contains('lineNum'));
       assert.isTrue(lineNumberEl.classList.contains('right'));
     });
 
-    test('_getNextContentOnSide side-by-side left', () => {
+    test('getNextContentOnSide side-by-side left', () => {
       const startElem = builder.getContentByLine(5, 'left',
           element.$.diffTable);
       const expectedStartString = diff.content[2].ab[0];
       const expectedNextString = diff.content[2].ab[1];
       assert.equal(startElem.textContent, expectedStartString);
 
-      const nextElem = builder._getNextContentOnSide(startElem,
+      const nextElem = builder.getNextContentOnSide(startElem,
           'left');
       assert.equal(nextElem.textContent, expectedNextString);
     });
 
-    test('_getNextContentOnSide side-by-side right', () => {
+    test('getNextContentOnSide side-by-side right', () => {
       const startElem = builder.getContentByLine(5, 'right',
           element.$.diffTable);
       const expectedStartString = diff.content[1].b[0];
       const expectedNextString = diff.content[1].b[1];
       assert.equal(startElem.textContent, expectedStartString);
 
-      const nextElem = builder._getNextContentOnSide(startElem,
+      const nextElem = builder.getNextContentOnSide(startElem,
           'right');
       assert.equal(nextElem.textContent, expectedNextString);
     });
 
-    test('_getNextContentOnSide unified left', async () => {
+    test('getNextContentOnSide unified left', async () => {
       // Re-render as unified:
       element.viewMode = 'UNIFIED_DIFF';
       await element.render(keyLocations);
@@ -1123,12 +1019,12 @@
       const expectedNextString = diff.content[2].ab[1];
       assert.equal(startElem.textContent, expectedStartString);
 
-      const nextElem = builder._getNextContentOnSide(startElem,
+      const nextElem = builder.getNextContentOnSide(startElem,
           'left');
       assert.equal(nextElem.textContent, expectedNextString);
     });
 
-    test('_getNextContentOnSide unified right', async () => {
+    test('getNextContentOnSide unified right', async () => {
       // Re-render as unified:
       element.viewMode = 'UNIFIED_DIFF';
       await element.render(keyLocations);
@@ -1140,22 +1036,10 @@
       const expectedNextString = diff.content[1].b[1];
       assert.equal(startElem.textContent, expectedStartString);
 
-      const nextElem = builder._getNextContentOnSide(startElem,
+      const nextElem = builder.getNextContentOnSide(startElem,
           'right');
       assert.equal(nextElem.textContent, expectedNextString);
     });
-
-    test('escaping HTML', () => {
-      let input = '<script>alert("XSS");<' + '/script>';
-      let expected = '&lt;script&gt;alert("XSS");&lt;/script&gt;';
-      let result = builder._formatText(input, 1, Infinity).innerHTML;
-      assert.equal(result, expected);
-
-      input = '& < > " \' / `';
-      expected = '&amp; &lt; &gt; " \' / `';
-      result = builder._formatText(input, 1, Infinity).innerHTML;
-      assert.equal(result, expected);
-    });
   });
 
   suite('blame', () => {
@@ -1169,73 +1053,68 @@
     });
 
     test('setBlame attempts to render each blamed line', () => {
-      const getBlameStub = sinon.stub(builder, '_getBlameByLineNum')
+      const getBlameStub = sinon.stub(builder, 'getBlameTdByLine')
           .returns(null);
       builder.setBlame(mockBlame);
       assert.equal(getBlameStub.callCount, 32);
     });
 
-    test('_getBlameCommitForBaseLine', () => {
-      sinon.stub(builder, '_getBlameByLineNum').returns(null);
+    test('getBlameCommitForBaseLine', () => {
+      sinon.stub(builder, 'getBlameTdByLine').returns(undefined);
       builder.setBlame(mockBlame);
-      assert.isOk(builder._getBlameCommitForBaseLine(1));
-      assert.equal(builder._getBlameCommitForBaseLine(1).id, 'commit 1');
+      assert.isOk(builder.getBlameCommitForBaseLine(1));
+      assert.equal(builder.getBlameCommitForBaseLine(1).id, 'commit 1');
 
-      assert.isOk(builder._getBlameCommitForBaseLine(11));
-      assert.equal(builder._getBlameCommitForBaseLine(11).id, 'commit 1');
+      assert.isOk(builder.getBlameCommitForBaseLine(11));
+      assert.equal(builder.getBlameCommitForBaseLine(11).id, 'commit 1');
 
-      assert.isOk(builder._getBlameCommitForBaseLine(32));
-      assert.equal(builder._getBlameCommitForBaseLine(32).id, 'commit 2');
+      assert.isOk(builder.getBlameCommitForBaseLine(32));
+      assert.equal(builder.getBlameCommitForBaseLine(32).id, 'commit 2');
 
-      assert.isNull(builder._getBlameCommitForBaseLine(33));
+      assert.isUndefined(builder.getBlameCommitForBaseLine(33));
     });
 
-    test('_getBlameCommitForBaseLine w/o blame returns null', () => {
-      assert.isNull(builder._getBlameCommitForBaseLine(1));
-      assert.isNull(builder._getBlameCommitForBaseLine(11));
-      assert.isNull(builder._getBlameCommitForBaseLine(31));
+    test('getBlameCommitForBaseLine w/o blame returns null', () => {
+      assert.isUndefined(builder.getBlameCommitForBaseLine(1));
+      assert.isUndefined(builder.getBlameCommitForBaseLine(11));
+      assert.isUndefined(builder.getBlameCommitForBaseLine(31));
     });
 
-    test('_createBlameCell', () => {
-      const mocbBlameCell = document.createElement('span');
-      const getBlameStub = sinon.stub(builder, '_getBlameForBaseLine')
-          .returns(mocbBlameCell);
-      const line = new GrDiffLine(GrDiffLineType.BOTH);
-      line.beforeNumber = 3;
-      line.afterNumber = 5;
-
-      const result = builder._createBlameCell(line.beforeNumber);
-
-      assert.isTrue(getBlameStub.calledWithExactly(3));
-      assert.equal(result.getAttribute('data-line-number'), '3');
-      assert.equal(result.firstChild, mocbBlameCell);
-    });
-
-    test('_getBlameForBaseLine', () => {
-      const mockCommit = {
-        time: 1576105200,
+    test('createBlameCell', () => {
+      const mockBlameInfo = {
+        time: 1576155200,
         id: 1234567890,
         author: 'Clark Kent',
         commit_msg: 'Testing Commit',
         ranges: [1],
       };
-      const blameNode = builder._getBlameForBaseLine(1, mockCommit);
+      const getBlameStub = sinon.stub(builder, 'getBlameCommitForBaseLine')
+          .returns(mockBlameInfo);
+      const line = new GrDiffLine(GrDiffLineType.BOTH);
+      line.beforeNumber = 3;
+      line.afterNumber = 5;
 
-      const authors = blameNode.getElementsByClassName('blameAuthor');
-      assert.equal(authors.length, 1);
-      assert.equal(authors[0].innerText, ' Clark');
+      const result = builder.createBlameCell(line.beforeNumber);
 
-      const date = (new Date(mockCommit.time * 1000)).toLocaleDateString();
-      flush();
-      const cards = blameNode.getElementsByClassName('blameHoverCard');
-      assert.equal(cards.length, 1);
-      assert.equal(cards[0].innerHTML,
-          `Commit 1234567890<br>Author: Clark Kent<br>Date: ${date}`
-        + '<br><br>Testing Commit'
-      );
-
-      const url = blameNode.getElementsByClassName('blameDate');
-      assert.equal(url[0].getAttribute('href'), '/r/q/1234567890');
+      assert.isTrue(getBlameStub.calledWithExactly(3));
+      assert.equal(result.getAttribute('data-line-number'), '3');
+      expect(result).dom.to.equal(/* HTML */`
+        <span class="gr-diff style-scope">
+          <a class="blameDate gr-diff style-scope" href="/r/q/1234567890">
+            12/12/2019
+          </a>
+          <span class="blameAuthor gr-diff style-scope">Clark</span>
+          <gr-hovercard class="gr-diff style-scope">
+            <span class="blameHoverCard gr-diff style-scope">
+              Commit 1234567890<br>
+              Author: Clark Kent<br>
+              Date: 12/12/2019<br>
+              <br>
+              Testing Commit
+            </span>
+          </gr-hovercard>
+        </span>
+      `);
     });
   });
 });
diff --git a/polygerrit-ui/app/embed/diff/gr-diff-builder/gr-diff-builder-image.ts b/polygerrit-ui/app/embed/diff/gr-diff-builder/gr-diff-builder-image.ts
index 92d99e1..3bcec06 100644
--- a/polygerrit-ui/app/embed/diff/gr-diff-builder/gr-diff-builder-image.ts
+++ b/polygerrit-ui/app/embed/diff/gr-diff-builder/gr-diff-builder-image.ts
@@ -47,13 +47,13 @@
     if (this._useNewImageDiffUi) {
       this._emitImageViewer(section);
 
-      this._outputEl.appendChild(section);
+      this.outputEl.appendChild(section);
     } else {
       this._emitImagePair(section);
       this._emitImageLabels(section);
 
-      this._outputEl.appendChild(section);
-      this._outputEl.appendChild(this._createEndpoint());
+      this.outputEl.appendChild(section);
+      this.outputEl.appendChild(this._createEndpoint());
     }
   }
 
@@ -98,7 +98,7 @@
     imageViewer.baseUrl = this._getImageSrc(this._baseImage);
     imageViewer.revisionUrl = this._getImageSrc(this._revisionImage);
     imageViewer.automaticBlink =
-      !!this._renderPrefs?.image_diff_prefs?.automatic_blink;
+      !!this.renderPrefs?.image_diff_prefs?.automatic_blink;
 
     td.appendChild(imageViewer);
     tr.appendChild(td);
@@ -218,7 +218,7 @@
   }
 
   override updateRenderPrefs(renderPrefs: RenderPreferences) {
-    const imageViewer = this._outputEl.querySelector(
+    const imageViewer = this.outputEl.querySelector(
       'gr-image-viewer'
     ) as GrImageViewer;
     if (this._useNewImageDiffUi && imageViewer) {
diff --git a/polygerrit-ui/app/embed/diff/gr-diff-builder/gr-diff-builder-legacy.ts b/polygerrit-ui/app/embed/diff/gr-diff-builder/gr-diff-builder-legacy.ts
new file mode 100644
index 0000000..298b280
--- /dev/null
+++ b/polygerrit-ui/app/embed/diff/gr-diff-builder/gr-diff-builder-legacy.ts
@@ -0,0 +1,467 @@
+/**
+ * @license
+ * Copyright (C) 2016 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the 'License');
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an 'AS IS' BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+import {
+  MovedLinkClickedEventDetail,
+  RenderPreferences,
+} from '../../../api/diff';
+import {fire} from '../../../utils/event-util';
+import {GrDiffLine, GrDiffLineType, LineNumber} from '../gr-diff/gr-diff-line';
+import {GrDiffGroup, GrDiffGroupType} from '../gr-diff/gr-diff-group';
+import '../gr-context-controls/gr-context-controls';
+import {
+  GrContextControls,
+  GrContextControlsShowConfig,
+} from '../gr-context-controls/gr-context-controls';
+import {DiffInfo, DiffPreferencesInfo} from '../../../types/diff';
+import {DiffViewMode, Side} from '../../../constants/constants';
+import {DiffLayer} from '../../../types/types';
+import {
+  createBlameElement,
+  createElementDiff,
+  createElementDiffWithText,
+  formatText,
+  getResponsiveMode,
+} from '../gr-diff/gr-diff-utils';
+import {GrDiffBuilder} from './gr-diff-builder';
+import {BlameInfo} from '../../../types/common';
+
+/**
+ * Base class for builders that are creating the DOM elements programmatically
+ * by calling `document.createElement()` and such. We are calling such builders
+ * "legacy", because we want to create (Lit) component based diff elements.
+ *
+ * TODO: Do not subclass `GrDiffBuilder`. Use composition and interfaces.
+ */
+export abstract class GrDiffBuilderLegacy extends GrDiffBuilder {
+  constructor(
+    diff: DiffInfo,
+    prefs: DiffPreferencesInfo,
+    outputEl: HTMLElement,
+    layers: DiffLayer[] = [],
+    renderPrefs?: RenderPreferences
+  ) {
+    super(diff, prefs, outputEl, layers, renderPrefs);
+  }
+
+  override getContentTdByLine(
+    lineNumber: LineNumber,
+    side?: Side,
+    root: Element = this.outputEl
+  ): Element | null {
+    const sideSelector: string = side ? `.${side}` : '';
+    return root.querySelector(
+      `td.lineNum[data-value="${lineNumber}"]${sideSelector} ~ td.content`
+    );
+  }
+
+  override getBlameTdByLine(lineNum: number): Element | undefined {
+    return (
+      this.outputEl.querySelector(`td.blame[data-line-number="${lineNum}"]`) ??
+      undefined
+    );
+  }
+
+  override getContentByLine(
+    lineNumber: LineNumber,
+    side?: Side,
+    root?: HTMLElement
+  ): HTMLElement | null {
+    const td = this.getContentTdByLine(lineNumber, side, root);
+    return td ? td.querySelector('.contentText') : null;
+  }
+
+  override renderContentByRange(
+    start: LineNumber,
+    end: LineNumber,
+    side: Side
+  ) {
+    const lines: GrDiffLine[] = [];
+    const elements: HTMLElement[] = [];
+    let line;
+    let el;
+    this.findLinesByRange(start, end, side, lines, elements);
+    for (let i = 0; i < lines.length; i++) {
+      line = lines[i];
+      el = elements[i];
+      if (!el || !el.parentElement) {
+        // Cannot re-render an element if it does not exist. This can happen
+        // if lines are collapsed and not visible on the page yet.
+        continue;
+      }
+      const lineNumberEl = this.getLineNumberEl(el, side);
+      el.parentElement.replaceChild(
+        this.createTextEl(lineNumberEl, line, side).firstChild!,
+        el
+      );
+    }
+  }
+
+  override renderBlameByRange(blame: BlameInfo, start: number, end: number) {
+    for (let i = start; i <= end; i++) {
+      // TODO(wyatta): this query is expensive, but, when traversing a
+      // range, the lines are consecutive, and given the previous blame
+      // cell, the next one can be reached cheaply.
+      const blameCell = this.getBlameTdByLine(i);
+      if (!blameCell) continue;
+
+      // Remove the element's children (if any).
+      while (blameCell.hasChildNodes()) {
+        blameCell.removeChild(blameCell.lastChild!);
+      }
+      const blameEl = createBlameElement(i, blame);
+      if (blameEl) blameCell.appendChild(blameEl);
+    }
+  }
+
+  /**
+   * Finds the line number element given the content element by walking up the
+   * DOM tree to the diff row and then querying for a .lineNum element on the
+   * requested side.
+   *
+   * TODO(brohlfs): Consolidate this with getLineEl... methods in html file.
+   */
+  private getLineNumberEl(
+    content: HTMLElement,
+    side: Side
+  ): HTMLElement | null {
+    let row: HTMLElement | null = content;
+    while (row && !row.classList.contains('diff-row')) row = row.parentElement;
+    return row ? (row.querySelector('.lineNum.' + side) as HTMLElement) : null;
+  }
+
+  /**
+   * Adds <tr> table rows to a <tbody> section for allowing the user to expand
+   * collapsed of lines. Called by subclasses.
+   */
+  protected createContextControls(
+    section: HTMLElement,
+    group: GrDiffGroup,
+    viewMode: DiffViewMode
+  ) {
+    const leftStart = group.lineRange.left.start_line;
+    const leftEnd = group.lineRange.left.end_line;
+    const firstGroupIsSkipped = !!group.contextGroups[0].skip;
+    const lastGroupIsSkipped =
+      !!group.contextGroups[group.contextGroups.length - 1].skip;
+
+    const containsWholeFile = this.numLinesLeft === leftEnd - leftStart + 1;
+    const showAbove =
+      (leftStart > 1 && !firstGroupIsSkipped) || containsWholeFile;
+    const showBelow = leftEnd < this.numLinesLeft && !lastGroupIsSkipped;
+
+    if (showAbove) {
+      const paddingRow = this.createContextControlPaddingRow(viewMode);
+      paddingRow.classList.add('above');
+      section.appendChild(paddingRow);
+    }
+    section.appendChild(
+      this.createContextControlRow(
+        section,
+        group,
+        showAbove,
+        showBelow,
+        viewMode
+      )
+    );
+    if (showBelow) {
+      const paddingRow = this.createContextControlPaddingRow(viewMode);
+      paddingRow.classList.add('below');
+      section.appendChild(paddingRow);
+    }
+  }
+
+  /**
+   * Creates a context control <tr> table row for with buttons the allow the
+   * user to expand collapsed lines. Buttons extend from the gap created by this
+   * method up or down into the area of code that they affect.
+   */
+  private createContextControlRow(
+    section: HTMLElement,
+    group: GrDiffGroup,
+    showAbove: boolean,
+    showBelow: boolean,
+    viewMode: DiffViewMode
+  ): HTMLElement {
+    const row = createElementDiff('tr', 'dividerRow');
+    let showConfig: GrContextControlsShowConfig;
+    if (showAbove && !showBelow) {
+      showConfig = 'above';
+    } else if (!showAbove && showBelow) {
+      showConfig = 'below';
+    } else {
+      // Note that !showAbove && !showBelow also intentionally creates
+      // "show-both". This means the file is completely collapsed, which is
+      // unusual, but at least happens in one test.
+      showConfig = 'both';
+    }
+    row.classList.add(`show-${showConfig}`);
+
+    row.appendChild(this.createBlameCell(0));
+    if (viewMode === DiffViewMode.SIDE_BY_SIDE) {
+      row.appendChild(createElementDiff('td'));
+    }
+
+    const cell = createElementDiff('td', 'dividerCell');
+    cell.setAttribute('colspan', '3');
+    row.appendChild(cell);
+
+    const contextControls = createElementDiff(
+      'gr-context-controls'
+    ) as GrContextControls;
+    contextControls.diff = this._diff;
+    contextControls.renderPreferences = this.renderPrefs;
+    contextControls.section = section;
+    contextControls.group = group;
+    contextControls.showConfig = showConfig;
+    cell.appendChild(contextControls);
+    return row;
+  }
+
+  /**
+   * Creates a table row to serve as padding between code and context controls.
+   * Blame column, line gutters, and content area will continue visually, but
+   * context controls can render over this background to map more clearly to
+   * the area of code they expand.
+   */
+  private createContextControlPaddingRow(viewMode: DiffViewMode) {
+    const row = createElementDiff('tr', 'contextBackground');
+
+    if (viewMode === DiffViewMode.SIDE_BY_SIDE) {
+      row.classList.add('side-by-side');
+      row.setAttribute('left-type', GrDiffGroupType.CONTEXT_CONTROL);
+      row.setAttribute('right-type', GrDiffGroupType.CONTEXT_CONTROL);
+    } else {
+      row.classList.add('unified');
+    }
+
+    row.appendChild(this.createBlameCell(0));
+    row.appendChild(createElementDiff('td', 'contextLineNum'));
+    if (viewMode === DiffViewMode.SIDE_BY_SIDE) {
+      row.appendChild(createElementDiff('td'));
+    }
+    row.appendChild(createElementDiff('td', 'contextLineNum'));
+    row.appendChild(createElementDiff('td'));
+
+    return row;
+  }
+
+  protected createLineEl(
+    line: GrDiffLine,
+    number: LineNumber,
+    type: GrDiffLineType,
+    side: Side
+  ) {
+    const td = createElementDiff('td');
+    td.classList.add(side);
+    if (line.type === GrDiffLineType.BLANK) {
+      return td;
+    }
+    if (line.type === GrDiffLineType.BOTH || line.type === type) {
+      td.classList.add('lineNum');
+      td.dataset['value'] = number.toString();
+
+      if (
+        ((this._prefs.show_file_comment_button === false ||
+          this.renderPrefs?.show_file_comment_button === false) &&
+          number === 'FILE') ||
+        number === 'LOST'
+      ) {
+        return td;
+      }
+
+      const button = createElementDiff('button');
+      td.appendChild(button);
+      button.tabIndex = -1;
+      button.classList.add('lineNumButton');
+      button.classList.add(side);
+      button.dataset['value'] = number.toString();
+      button.textContent = number === 'FILE' ? 'File' : number.toString();
+      if (number === 'FILE') {
+        button.setAttribute('aria-label', 'Add file comment');
+      }
+
+      // Add aria-labels for valid line numbers.
+      // For unified diff, this method will be called with number set to 0 for
+      // the empty line number column for added/removed lines. This should not
+      // be announced to the screenreader.
+      if (number > 0) {
+        if (line.type === GrDiffLineType.REMOVE) {
+          button.setAttribute('aria-label', `${number} removed`);
+        } else if (line.type === GrDiffLineType.ADD) {
+          button.setAttribute('aria-label', `${number} added`);
+        }
+      }
+      this.addLineNumberMouseEvents(td, number, side);
+    }
+    return td;
+  }
+
+  private addLineNumberMouseEvents(
+    el: HTMLElement,
+    number: LineNumber,
+    side: Side
+  ) {
+    el.addEventListener('mouseenter', () => {
+      fire(el, 'line-mouse-enter', {lineNum: number, side});
+    });
+    el.addEventListener('mouseleave', () => {
+      fire(el, 'line-mouse-leave', {lineNum: number, side});
+    });
+  }
+
+  protected createTextEl(
+    lineNumberEl: HTMLElement | null,
+    line: GrDiffLine,
+    side?: Side
+  ) {
+    const td = createElementDiff('td');
+    if (line.type !== GrDiffLineType.BLANK) {
+      td.classList.add('content');
+    }
+
+    // If intraline info is not available, the entire line will be
+    // considered as changed and marked as dark red / green color
+    if (!line.hasIntralineInfo) {
+      td.classList.add('no-intraline-info');
+    }
+    td.classList.add(line.type);
+
+    const {beforeNumber, afterNumber} = line;
+    if (beforeNumber !== 'FILE' && beforeNumber !== 'LOST') {
+      const responsiveMode = getResponsiveMode(this._prefs, this.renderPrefs);
+      const contentText = formatText(
+        line.text,
+        responsiveMode,
+        this._prefs.tab_size,
+        this._prefs.line_length
+      );
+
+      if (side) {
+        contentText.setAttribute('data-side', side);
+        const number = side === Side.LEFT ? beforeNumber : afterNumber;
+        this.addLineNumberMouseEvents(td, number, side);
+      }
+
+      if (lineNumberEl && side) {
+        for (const layer of this.layers) {
+          if (typeof layer.annotate === 'function') {
+            layer.annotate(contentText, lineNumberEl, line, side);
+          }
+        }
+      } else {
+        console.error('lineNumberEl or side not set, skipping layer.annotate');
+      }
+
+      td.appendChild(contentText);
+    } else if (line.beforeNumber === 'FILE') td.classList.add('file');
+    else if (line.beforeNumber === 'LOST') td.classList.add('lost');
+
+    return td;
+  }
+
+  private createMovedLineAnchor(line: number, side: Side) {
+    const anchor = createElementDiffWithText('a', `${line}`);
+
+    // href is not actually used but important for Screen Readers
+    anchor.setAttribute('href', `#${line}`);
+    anchor.addEventListener('click', e => {
+      e.preventDefault();
+      anchor.dispatchEvent(
+        new CustomEvent<MovedLinkClickedEventDetail>('moved-link-clicked', {
+          detail: {
+            lineNum: line,
+            side,
+          },
+          composed: true,
+          bubbles: true,
+        })
+      );
+    });
+    return anchor;
+  }
+
+  private createMoveDescriptionDiv(movedIn: boolean, group: GrDiffGroup) {
+    const div = createElementDiff('div');
+    if (group.moveDetails?.range) {
+      const {changed, range} = group.moveDetails;
+      const otherSide = movedIn ? Side.LEFT : Side.RIGHT;
+      const andChangedLabel = changed ? 'and changed ' : '';
+      const direction = movedIn ? 'from' : 'to';
+      const textLabel = `Moved ${andChangedLabel}${direction} lines `;
+      div.appendChild(createElementDiffWithText('span', textLabel));
+      div.appendChild(this.createMovedLineAnchor(range.start, otherSide));
+      div.appendChild(createElementDiffWithText('span', ' - '));
+      div.appendChild(this.createMovedLineAnchor(range.end, otherSide));
+    } else {
+      div.appendChild(
+        createElementDiffWithText('span', movedIn ? 'Moved in' : 'Moved out')
+      );
+    }
+    return div;
+  }
+
+  protected buildMoveControls(group: GrDiffGroup) {
+    const movedIn = group.adds.length > 0;
+    const {numberOfCells, movedOutIndex, movedInIndex, lineNumberCols} =
+      this.getMoveControlsConfig();
+
+    let controlsClass;
+    let descriptionIndex;
+    const descriptionTextDiv = this.createMoveDescriptionDiv(movedIn, group);
+    if (movedIn) {
+      controlsClass = 'movedIn';
+      descriptionIndex = movedInIndex;
+    } else {
+      controlsClass = 'movedOut';
+      descriptionIndex = movedOutIndex;
+    }
+
+    const controls = createElementDiff('tr', `moveControls ${controlsClass}`);
+    const cells = [...Array(numberOfCells).keys()].map(() =>
+      createElementDiff('td')
+    );
+    lineNumberCols.forEach(index => {
+      cells[index].classList.add('moveControlsLineNumCol');
+    });
+
+    const moveRangeHeader = createElementDiff('gr-range-header');
+    moveRangeHeader.setAttribute('icon', 'gr-icons:move-item');
+    moveRangeHeader.appendChild(descriptionTextDiv);
+    cells[descriptionIndex].classList.add('moveHeader');
+    cells[descriptionIndex].appendChild(moveRangeHeader);
+    cells.forEach(c => {
+      controls.appendChild(c);
+    });
+    return controls;
+  }
+
+  /**
+   * Create a blame cell for the given base line. Blame information will be
+   * included in the cell if available.
+   */
+  protected createBlameCell(lineNumber: LineNumber): HTMLTableCellElement {
+    const blameTd = createElementDiff('td', 'blame') as HTMLTableCellElement;
+    blameTd.setAttribute('data-line-number', lineNumber.toString());
+    if (!lineNumber) return blameTd;
+
+    const blameInfo = this.getBlameCommitForBaseLine(lineNumber);
+    if (!blameInfo) return blameTd;
+
+    blameTd.appendChild(createBlameElement(lineNumber, blameInfo));
+    return blameTd;
+  }
+}
diff --git a/polygerrit-ui/app/embed/diff/gr-diff-builder/gr-diff-builder-side-by-side.ts b/polygerrit-ui/app/embed/diff/gr-diff-builder/gr-diff-builder-side-by-side.ts
index 5008d10..873e6b7 100644
--- a/polygerrit-ui/app/embed/diff/gr-diff-builder/gr-diff-builder-side-by-side.ts
+++ b/polygerrit-ui/app/embed/diff/gr-diff-builder/gr-diff-builder-side-by-side.ts
@@ -14,8 +14,6 @@
  * See the License for the specific language governing permissions and
  * limitations under the License.
  */
-
-import {GrDiffBuilder} from './gr-diff-builder';
 import {GrDiffGroup, GrDiffGroupType} from '../gr-diff/gr-diff-group';
 import {DiffInfo, DiffPreferencesInfo} from '../../../types/diff';
 import {GrDiffLine, LineNumber} from '../gr-diff/gr-diff-line';
@@ -23,8 +21,9 @@
 import {DiffLayer} from '../../../types/types';
 import {RenderPreferences} from '../../../api/diff';
 import {createElementDiff} from '../gr-diff/gr-diff-utils';
+import {GrDiffBuilderLegacy} from './gr-diff-builder-legacy';
 
-export class GrDiffBuilderSideBySide extends GrDiffBuilder {
+export class GrDiffBuilderSideBySide extends GrDiffBuilderLegacy {
   constructor(
     diff: DiffInfo,
     prefs: DiffPreferencesInfo,
@@ -35,7 +34,7 @@
     super(diff, prefs, outputEl, layers, renderPrefs);
   }
 
-  _getMoveControlsConfig() {
+  protected override getMoveControlsConfig() {
     return {
       numberOfCells: 4,
       movedOutIndex: 1,
@@ -44,10 +43,10 @@
     };
   }
 
-  buildSectionElement(group: GrDiffGroup) {
+  protected override buildSectionElement(group: GrDiffGroup) {
     const sectionEl = createElementDiff('tbody', 'section');
     sectionEl.classList.add(group.type);
-    if (this._isTotal(group)) {
+    if (this.isTotal(group)) {
       sectionEl.classList.add('total');
     }
     if (group.dueToRebase) {
@@ -55,24 +54,24 @@
     }
     if (group.moveDetails) {
       sectionEl.classList.add('dueToMove');
-      sectionEl.appendChild(this._buildMoveControls(group));
+      sectionEl.appendChild(this.buildMoveControls(group));
     }
     if (group.ignoredWhitespaceOnly) {
       sectionEl.classList.add('ignoredWhitespaceOnly');
     }
     if (group.type === GrDiffGroupType.CONTEXT_CONTROL) {
-      this._createContextControls(sectionEl, group, DiffViewMode.SIDE_BY_SIDE);
+      this.createContextControls(sectionEl, group, DiffViewMode.SIDE_BY_SIDE);
       return sectionEl;
     }
 
     const pairs = group.getSideBySidePairs();
     for (let i = 0; i < pairs.length; i++) {
-      sectionEl.appendChild(this._createRow(pairs[i].left, pairs[i].right));
+      sectionEl.appendChild(this.createRow(pairs[i].left, pairs[i].right));
     }
     return sectionEl;
   }
 
-  addColumns(outputEl: HTMLElement, lineNumberWidth: number): void {
+  override addColumns(outputEl: HTMLElement, lineNumberWidth: number): void {
     const colgroup = document.createElement('colgroup');
 
     // Add the blame column.
@@ -98,7 +97,7 @@
     outputEl.appendChild(colgroup);
   }
 
-  _createRow(leftLine: GrDiffLine, rightLine: GrDiffLine) {
+  private createRow(leftLine: GrDiffLine, rightLine: GrDiffLine) {
     const row = createElementDiff('tr');
     row.classList.add('diff-row', 'side-by-side');
     row.setAttribute('left-type', leftLine.type);
@@ -106,25 +105,28 @@
     // TabIndex makes screen reader read a row when navigating with j/k
     row.tabIndex = -1;
 
-    row.appendChild(this._createBlameCell(leftLine.beforeNumber));
+    row.appendChild(this.createBlameCell(leftLine.beforeNumber));
 
-    this._appendPair(row, leftLine, leftLine.beforeNumber, Side.LEFT);
-    this._appendPair(row, rightLine, rightLine.afterNumber, Side.RIGHT);
+    this.appendPair(row, leftLine, leftLine.beforeNumber, Side.LEFT);
+    this.appendPair(row, rightLine, rightLine.afterNumber, Side.RIGHT);
     return row;
   }
 
-  _appendPair(
+  private appendPair(
     row: HTMLElement,
     line: GrDiffLine,
     lineNumber: LineNumber,
     side: Side
   ) {
-    const lineNumberEl = this._createLineEl(line, lineNumber, line.type, side);
+    const lineNumberEl = this.createLineEl(line, lineNumber, line.type, side);
     row.appendChild(lineNumberEl);
-    row.appendChild(this._createTextEl(lineNumberEl, line, side));
+    row.appendChild(this.createTextEl(lineNumberEl, line, side));
   }
 
-  _getNextContentOnSide(content: HTMLElement, side: Side): HTMLElement | null {
+  protected override getNextContentOnSide(
+    content: HTMLElement,
+    side: Side
+  ): HTMLElement | null {
     let tr: HTMLElement = content.parentElement!.parentElement!;
     while ((tr = tr.nextSibling as HTMLElement)) {
       const nextContent = tr.querySelector(
@@ -134,6 +136,4 @@
     }
     return null;
   }
-
-  override updateRenderPrefs(_renderPrefs: RenderPreferences) {}
 }
diff --git a/polygerrit-ui/app/embed/diff/gr-diff-builder/gr-diff-builder-unified.ts b/polygerrit-ui/app/embed/diff/gr-diff-builder/gr-diff-builder-unified.ts
index 59b9c5f3..3494586 100644
--- a/polygerrit-ui/app/embed/diff/gr-diff-builder/gr-diff-builder-unified.ts
+++ b/polygerrit-ui/app/embed/diff/gr-diff-builder/gr-diff-builder-unified.ts
@@ -15,15 +15,15 @@
  * limitations under the License.
  */
 import {GrDiffLine, GrDiffLineType} from '../gr-diff/gr-diff-line';
-import {GrDiffBuilder} from './gr-diff-builder';
 import {GrDiffGroup, GrDiffGroupType} from '../gr-diff/gr-diff-group';
 import {DiffInfo, DiffPreferencesInfo} from '../../../types/diff';
 import {DiffViewMode, Side} from '../../../constants/constants';
 import {DiffLayer} from '../../../types/types';
 import {RenderPreferences} from '../../../api/diff';
 import {createElementDiff} from '../gr-diff/gr-diff-utils';
+import {GrDiffBuilderLegacy} from './gr-diff-builder-legacy';
 
-export class GrDiffBuilderUnified extends GrDiffBuilder {
+export class GrDiffBuilderUnified extends GrDiffBuilderLegacy {
   constructor(
     diff: DiffInfo,
     prefs: DiffPreferencesInfo,
@@ -34,7 +34,7 @@
     super(diff, prefs, outputEl, layers, renderPrefs);
   }
 
-  _getMoveControlsConfig() {
+  protected override getMoveControlsConfig() {
     return {
       numberOfCells: 3,
       movedOutIndex: 2,
@@ -43,10 +43,10 @@
     };
   }
 
-  buildSectionElement(group: GrDiffGroup): HTMLElement {
+  protected override buildSectionElement(group: GrDiffGroup): HTMLElement {
     const sectionEl = createElementDiff('tbody', 'section');
     sectionEl.classList.add(group.type);
-    if (this._isTotal(group)) {
+    if (this.isTotal(group)) {
       sectionEl.classList.add('total');
     }
     if (group.dueToRebase) {
@@ -54,13 +54,13 @@
     }
     if (group.moveDetails) {
       sectionEl.classList.add('dueToMove');
-      sectionEl.appendChild(this._buildMoveControls(group));
+      sectionEl.appendChild(this.buildMoveControls(group));
     }
     if (group.ignoredWhitespaceOnly) {
       sectionEl.classList.add('ignoredWhitespaceOnly');
     }
     if (group.type === GrDiffGroupType.CONTEXT_CONTROL) {
-      this._createContextControls(sectionEl, group, DiffViewMode.UNIFIED);
+      this.createContextControls(sectionEl, group, DiffViewMode.UNIFIED);
       return sectionEl;
     }
 
@@ -71,12 +71,12 @@
       if (group.ignoredWhitespaceOnly && line.type === GrDiffLineType.REMOVE) {
         continue;
       }
-      sectionEl.appendChild(this._createRow(line));
+      sectionEl.appendChild(this.createRow(line));
     }
     return sectionEl;
   }
 
-  addColumns(outputEl: HTMLElement, lineNumberWidth: number): void {
+  override addColumns(outputEl: HTMLElement, lineNumberWidth: number): void {
     const colgroup = document.createElement('colgroup');
 
     // Add the blame column.
@@ -99,20 +99,20 @@
     outputEl.appendChild(colgroup);
   }
 
-  _createRow(line: GrDiffLine) {
+  protected createRow(line: GrDiffLine) {
     const row = createElementDiff('tr', line.type);
     row.classList.add('diff-row', 'unified');
     // TabIndex makes screen reader read a row when navigating with j/k
     row.tabIndex = -1;
-    row.appendChild(this._createBlameCell(line.beforeNumber));
-    let lineNumberEl = this._createLineEl(
+    row.appendChild(this.createBlameCell(line.beforeNumber));
+    let lineNumberEl = this.createLineEl(
       line,
       line.beforeNumber,
       GrDiffLineType.REMOVE,
       Side.LEFT
     );
     row.appendChild(lineNumberEl);
-    lineNumberEl = this._createLineEl(
+    lineNumberEl = this.createLineEl(
       line,
       line.afterNumber,
       GrDiffLineType.ADD,
@@ -126,11 +126,11 @@
     if (line.type === GrDiffLineType.REMOVE) {
       side = Side.LEFT;
     }
-    row.appendChild(this._createTextEl(lineNumberEl, line, side));
+    row.appendChild(this.createTextEl(lineNumberEl, line, side));
     return row;
   }
 
-  _getNextContentOnSide(content: HTMLElement, side: Side): HTMLElement | null {
+  getNextContentOnSide(content: HTMLElement, side: Side): HTMLElement | null {
     let tr: HTMLElement = content.parentElement!.parentElement!;
     while ((tr = tr.nextSibling as HTMLElement)) {
       if (
@@ -143,6 +143,4 @@
     }
     return null;
   }
-
-  override updateRenderPrefs(_renderPrefs: RenderPreferences) {}
 }
diff --git a/polygerrit-ui/app/embed/diff/gr-diff-builder/gr-diff-builder.ts b/polygerrit-ui/app/embed/diff/gr-diff-builder/gr-diff-builder.ts
index ac109e3..b255c68 100644
--- a/polygerrit-ui/app/embed/diff/gr-diff-builder/gr-diff-builder.ts
+++ b/polygerrit-ui/app/embed/diff/gr-diff-builder/gr-diff-builder.ts
@@ -17,53 +17,16 @@
 import {
   ContentLoadNeededEventDetail,
   DiffContextExpandedExternalDetail,
-  MovedLinkClickedEventDetail,
   RenderPreferences,
 } from '../../../api/diff';
-import {getBaseUrl} from '../../../utils/url-util';
-import {fire} from '../../../utils/event-util';
 import {GrDiffLine, GrDiffLineType, LineNumber} from '../gr-diff/gr-diff-line';
 import {GrDiffGroup, GrDiffGroupType} from '../gr-diff/gr-diff-group';
 
 import '../gr-context-controls/gr-context-controls';
-import {
-  GrContextControls,
-  GrContextControlsShowConfig,
-} from '../gr-context-controls/gr-context-controls';
 import {BlameInfo} from '../../../types/common';
-import {
-  DiffInfo,
-  DiffPreferencesInfo,
-  DiffResponsiveMode,
-} from '../../../types/diff';
-import {DiffViewMode, Side} from '../../../constants/constants';
+import {DiffInfo, DiffPreferencesInfo} from '../../../types/diff';
+import {Side} from '../../../constants/constants';
 import {DiffLayer} from '../../../types/types';
-import {
-  createElementDiff,
-  createElementDiffWithText,
-} from '../gr-diff/gr-diff-utils';
-
-/**
- * In JS, unicode code points above 0xFFFF occupy two elements of a string.
- * For example '๐€'.length is 2. An occurrence of such a code point is called a
- * surrogate pair.
- *
- * This regex segments a string along tabs ('\t') and surrogate pairs, since
- * these are two cases where '1 char' does not automatically imply '1 column'.
- *
- * TODO: For human languages whose orthographies use combining marks, this
- * approach won't correctly identify the grapheme boundaries. In those cases,
- * a grapheme consists of multiple code points that should count as only one
- * character against the column limit. Getting that correct (if it's desired)
- * is probably beyond the limits of a regex, but there are nonstandard APIs to
- * do this, and proposed (but, as of Nov 2017, unimplemented) standard APIs.
- *
- * Further reading:
- *   On Unicode in JS: https://mathiasbynens.be/notes/javascript-unicode
- *   Graphemes: http://unicode.org/reports/tr29/#Grapheme_Cluster_Boundaries
- *   A proposed JS API: https://github.com/tc39/proposal-intl-segmenter
- */
-const REGEX_TAB_OR_SURROGATE_PAIR = /\t|[\uD800-\uDBFF][\uDC00-\uDFFF]/;
 
 export interface DiffContextExpandedEventDetail
   extends DiffContextExpandedExternalDetail {
@@ -79,26 +42,6 @@
   }
 }
 
-export function getResponsiveMode(
-  prefs: DiffPreferencesInfo,
-  renderPrefs?: RenderPreferences
-): DiffResponsiveMode {
-  if (renderPrefs?.responsive_mode) {
-    return renderPrefs.responsive_mode;
-  }
-  // Backwards compatibility to the line_wrapping param.
-  if (prefs.line_wrapping) {
-    return 'FULL_RESPONSIVE';
-  }
-  return 'NONE';
-}
-
-export function isResponsive(responsiveMode: DiffResponsiveMode) {
-  return (
-    responsiveMode === 'FULL_RESPONSIVE' || responsiveMode === 'SHRINK_ONLY'
-  );
-}
-
 /**
  * Given that GrDiffBuilder has ~1,000 lines of code, this interface is just
  * making refactorings easier by emphasizing what the public facing "contract"
@@ -113,13 +56,14 @@
   ): GrDiffGroup[];
   getIndexOfSection(sectionEl: HTMLElement): number;
   addColumns(outputEl: HTMLElement, fontSize: number): void;
+  // TODO: Change `null` to `undefined`.
   getContentTdByLine(
     lineNumber: LineNumber,
     side?: Side,
     root?: Element
   ): Element | null;
-  setBlame(blame: BlameInfo[] | null): void;
-  updateRenderPrefs(_renderPrefs: RenderPreferences): void;
+  setBlame(blame: BlameInfo[]): void;
+  updateRenderPrefs(renderPrefs: RenderPreferences): void;
 }
 
 /**
@@ -130,23 +74,25 @@
  * DOM. Callers can use the spliceGroups method to add groups that
  * will then be rendered - or remove groups whose sections will then be
  * removed from the DOM.
+ *
+ * TODO: Do not subclass `GrDiffBuilder`. Use composition and interfaces.
  */
 export abstract class GrDiffBuilder implements DiffBuilder {
-  private readonly _diff: DiffInfo;
+  protected readonly _diff: DiffInfo;
 
-  private readonly _numLinesLeft: number;
+  protected readonly numLinesLeft: number;
 
-  private readonly _prefs: DiffPreferencesInfo;
+  protected readonly _prefs: DiffPreferencesInfo;
 
-  protected readonly _renderPrefs?: RenderPreferences;
+  protected readonly renderPrefs?: RenderPreferences;
 
-  protected readonly _outputEl: HTMLElement;
+  protected readonly outputEl: HTMLElement;
 
   protected readonly groups: GrDiffGroup[];
 
-  private blameInfo: BlameInfo[] | null;
+  private blameInfo: BlameInfo[] = [];
 
-  private readonly _layerUpdateListener: (
+  private readonly layerUpdateListener: (
     start: LineNumber,
     end: LineNumber,
     side: Side
@@ -160,17 +106,16 @@
     renderPrefs?: RenderPreferences
   ) {
     this._diff = diff;
-    this._numLinesLeft = this._diff.content
+    this.numLinesLeft = this._diff.content
       ? this._diff.content.reduce((sum, chunk) => {
           const left = chunk.a || chunk.ab;
           return sum + (left?.length || chunk.skip || 0);
         }, 0)
       : 0;
     this._prefs = prefs;
-    this._renderPrefs = renderPrefs;
-    this._outputEl = outputEl;
+    this.renderPrefs = renderPrefs;
+    this.outputEl = outputEl;
     this.groups = [];
-    this.blameInfo = null;
 
     if (isNaN(prefs.tab_size) || prefs.tab_size <= 0) {
       throw Error('Invalid tab size from preferences.');
@@ -180,14 +125,14 @@
       throw Error('Invalid line length from preferences.');
     }
 
-    this._layerUpdateListener = (
+    this.layerUpdateListener = (
       start: LineNumber,
       end: LineNumber,
       side: Side
-    ) => this._handleLayerUpdate(start, end, side);
+    ) => this.renderContentByRange(start, end, side);
     for (const layer of this.layers) {
       if (layer.addListener) {
-        layer.addListener(this._layerUpdateListener);
+        layer.addListener(this.layerUpdateListener);
       }
     }
   }
@@ -195,24 +140,11 @@
   clear() {
     for (const layer of this.layers) {
       if (layer.removeListener) {
-        layer.removeListener(this._layerUpdateListener);
+        layer.removeListener(this.layerUpdateListener);
       }
     }
   }
 
-  // TODO(TS): Convert to enum.
-  static readonly GroupType = {
-    ADDED: 'b',
-    BOTH: 'ab',
-    REMOVED: 'a',
-  };
-
-  // TODO(TS): Convert to enum.
-  static readonly Highlights = {
-    ADDED: 'edit_b',
-    REMOVED: 'edit_a',
-  };
-
   abstract addColumns(outputEl: HTMLElement, fontSize: number): void;
 
   protected abstract buildSectionElement(group: GrDiffGroup): HTMLElement;
@@ -226,6 +158,7 @@
     deleteCount: number,
     ...addedGroups: GrDiffGroup[]
   ) {
+    // TODO: Change `null` to `undefined`.
     const sectionBeforeWhichToInsert =
       start < this.groups.length ? this.groups[start].element ?? null : null;
     // Update the groups array
@@ -247,9 +180,10 @@
     return deletedGroups;
   }
 
+  // TODO: Change `null` to `undefined`.
   private emitGroup(group: GrDiffGroup, beforeSection: HTMLElement | null) {
     const element = this.buildSectionElement(group);
-    this._outputEl.insertBefore(element, beforeSection);
+    this.outputEl.insertBefore(element, beforeSection);
     group.element = element;
   }
 
@@ -271,25 +205,21 @@
       .filter(group => group.lines.length > 0);
   }
 
-  getContentTdByLine(
+  // TODO: Change `null` to `undefined`.
+  abstract getContentTdByLine(
     lineNumber: LineNumber,
     side?: Side,
-    root: Element = this._outputEl
-  ): Element | null {
-    const sideSelector: string = side ? `.${side}` : '';
-    return root.querySelector(
-      `td.lineNum[data-value="${lineNumber}"]${sideSelector} ~ td.content`
-    );
-  }
+    root?: Element
+  ): Element | null;
 
-  getContentByLine(
+  protected abstract getBlameTdByLine(lineNum: number): Element | undefined;
+
+  // TODO: Change `null` to `undefined`.
+  protected abstract getContentByLine(
     lineNumber: LineNumber,
     side?: Side,
     root?: HTMLElement
-  ): HTMLElement | null {
-    const td = this.getContentTdByLine(lineNumber, side, root);
-    return td ? td.querySelector('.contentText') : null;
-  }
+  ): HTMLElement | null;
 
   /**
    * Find line elements or line objects by a range of line numbers and a side.
@@ -298,10 +228,14 @@
    * @param end The last line number
    * @param side The side of the range. Either 'left' or 'right'.
    * @param out_lines The output list of line objects. Use null if not desired.
+   *        TODO: Change `null` to `undefined` in paramete type. Also: Do we
+   *        really need to support null/undefined? Also change to camelCase.
    * @param out_elements The output list of line elements. Use null if not
    *        desired.
+   *        TODO: Change `null` to `undefined` in paramete type. Also: Do we
+   *        really need to support null/undefined? Also change to camelCase.
    */
-  findLinesByRange(
+  protected findLinesByRange(
     start: LineNumber,
     end: LineNumber,
     side: Side,
@@ -329,7 +263,7 @@
         }
         if (out_elements) {
           if (content) {
-            content = this._getNextContentOnSide(content, side);
+            content = this.getNextContentOnSide(content, side);
           } else {
             content = this.getContentByLine(lineNumber, side, group.element);
           }
@@ -341,359 +275,25 @@
     }
   }
 
-  /**
-   * Re-renders the DIV.contentText elements for the given side and range of
-   * diff content.
-   */
-  _renderContentByRange(start: LineNumber, end: LineNumber, side: Side) {
-    const lines: GrDiffLine[] = [];
-    const elements: HTMLElement[] = [];
-    let line;
-    let el;
-    this.findLinesByRange(start, end, side, lines, elements);
-    for (let i = 0; i < lines.length; i++) {
-      line = lines[i];
-      el = elements[i];
-      if (!el || !el.parentElement) {
-        // Cannot re-render an element if it does not exist. This can happen
-        // if lines are collapsed and not visible on the page yet.
-        continue;
-      }
-      const lineNumberEl = this._getLineNumberEl(el, side);
-      el.parentElement.replaceChild(
-        this._createTextEl(lineNumberEl, line, side).firstChild!,
-        el
-      );
-    }
-  }
-
-  _createContextControls(
-    section: HTMLElement,
-    group: GrDiffGroup,
-    viewMode: DiffViewMode
-  ) {
-    const leftStart = group.lineRange.left.start_line;
-    const leftEnd = group.lineRange.left.end_line;
-    const firstGroupIsSkipped = !!group.contextGroups[0].skip;
-    const lastGroupIsSkipped =
-      !!group.contextGroups[group.contextGroups.length - 1].skip;
-
-    const containsWholeFile = this._numLinesLeft === leftEnd - leftStart + 1;
-    const showAbove =
-      (leftStart > 1 && !firstGroupIsSkipped) || containsWholeFile;
-    const showBelow = leftEnd < this._numLinesLeft && !lastGroupIsSkipped;
-
-    if (showAbove) {
-      const paddingRow = this._createContextControlPaddingRow(viewMode);
-      paddingRow.classList.add('above');
-      section.appendChild(paddingRow);
-    }
-    section.appendChild(
-      this._createContextControlRow(
-        section,
-        group,
-        showAbove,
-        showBelow,
-        viewMode
-      )
-    );
-    if (showBelow) {
-      const paddingRow = this._createContextControlPaddingRow(viewMode);
-      paddingRow.classList.add('below');
-      section.appendChild(paddingRow);
-    }
-  }
-
-  /**
-   * Creates context controls. Buttons extend from the gap created by this
-   * method up or down into the area of code that they affect.
-   */
-  _createContextControlRow(
-    section: HTMLElement,
-    group: GrDiffGroup,
-    showAbove: boolean,
-    showBelow: boolean,
-    viewMode: DiffViewMode
-  ): HTMLElement {
-    const row = createElementDiff('tr', 'dividerRow');
-    let showConfig: GrContextControlsShowConfig;
-    if (showAbove && !showBelow) {
-      showConfig = 'above';
-    } else if (!showAbove && showBelow) {
-      showConfig = 'below';
-    } else {
-      // Note that !showAbove && !showBelow also intentionally creates
-      // "show-both". This means the file is completely collapsed, which is
-      // unusual, but at least happens in one test.
-      showConfig = 'both';
-    }
-    row.classList.add(`show-${showConfig}`);
-
-    row.appendChild(this._createBlameCell(0));
-    if (viewMode === DiffViewMode.SIDE_BY_SIDE) {
-      row.appendChild(createElementDiff('td'));
-    }
-
-    const cell = createElementDiff('td', 'dividerCell');
-    cell.setAttribute('colspan', '3');
-    row.appendChild(cell);
-
-    const contextControls = createElementDiff(
-      'gr-context-controls'
-    ) as GrContextControls;
-    contextControls.diff = this._diff;
-    contextControls.renderPreferences = this._renderPrefs;
-    contextControls.section = section;
-    contextControls.group = group;
-    contextControls.showConfig = showConfig;
-    cell.appendChild(contextControls);
-    return row;
-  }
-
-  /**
-   * Creates a table row to serve as padding between code and context controls.
-   * Blame column, line gutters, and content area will continue visually, but
-   * context controls can render over this background to map more clearly to
-   * the area of code they expand.
-   */
-  _createContextControlPaddingRow(viewMode: DiffViewMode) {
-    const row = createElementDiff('tr', 'contextBackground');
-
-    if (viewMode === DiffViewMode.SIDE_BY_SIDE) {
-      row.classList.add('side-by-side');
-      row.setAttribute('left-type', GrDiffGroupType.CONTEXT_CONTROL);
-      row.setAttribute('right-type', GrDiffGroupType.CONTEXT_CONTROL);
-    } else {
-      row.classList.add('unified');
-    }
-
-    row.appendChild(this._createBlameCell(0));
-    row.appendChild(createElementDiff('td', 'contextLineNum'));
-    if (viewMode === DiffViewMode.SIDE_BY_SIDE) {
-      row.appendChild(createElementDiff('td'));
-    }
-    row.appendChild(createElementDiff('td', 'contextLineNum'));
-    row.appendChild(createElementDiff('td'));
-
-    return row;
-  }
-
-  _createLineEl(
-    line: GrDiffLine,
-    number: LineNumber,
-    type: GrDiffLineType,
+  protected abstract renderContentByRange(
+    start: LineNumber,
+    end: LineNumber,
     side: Side
-  ) {
-    const td = createElementDiff('td');
-    td.classList.add(side);
-    if (line.type === GrDiffLineType.BLANK) {
-      return td;
-    }
-    if (line.type === GrDiffLineType.BOTH || line.type === type) {
-      td.classList.add('lineNum');
-      td.dataset['value'] = number.toString();
+  ): void;
 
-      if (
-        ((this._prefs.show_file_comment_button === false ||
-          this._renderPrefs?.show_file_comment_button === false) &&
-          number === 'FILE') ||
-        number === 'LOST'
-      ) {
-        return td;
-      }
-
-      const button = createElementDiff('button');
-      td.appendChild(button);
-      button.tabIndex = -1;
-      button.classList.add('lineNumButton');
-      button.classList.add(side);
-      button.dataset['value'] = number.toString();
-      button.textContent = number === 'FILE' ? 'File' : number.toString();
-      if (number === 'FILE') {
-        button.setAttribute('aria-label', 'Add file comment');
-      }
-
-      // Add aria-labels for valid line numbers.
-      // For unified diff, this method will be called with number set to 0 for
-      // the empty line number column for added/removed lines. This should not
-      // be announced to the screenreader.
-      if (number > 0) {
-        if (line.type === GrDiffLineType.REMOVE) {
-          button.setAttribute('aria-label', `${number} removed`);
-        } else if (line.type === GrDiffLineType.ADD) {
-          button.setAttribute('aria-label', `${number} added`);
-        }
-      }
-      this._addLineNumberMouseEvents(td, number, side);
-    }
-    return td;
-  }
-
-  _addLineNumberMouseEvents(el: HTMLElement, number: LineNumber, side: Side) {
-    el.addEventListener('mouseenter', () => {
-      fire(el, 'line-mouse-enter', {lineNum: number, side});
-    });
-    el.addEventListener('mouseleave', () => {
-      fire(el, 'line-mouse-leave', {lineNum: number, side});
-    });
-  }
-
-  _createTextEl(
-    lineNumberEl: HTMLElement | null,
-    line: GrDiffLine,
-    side?: Side
-  ) {
-    const td = createElementDiff('td');
-    if (line.type !== GrDiffLineType.BLANK) {
-      td.classList.add('content');
-    }
-
-    // If intraline info is not available, the entire line will be
-    // considered as changed and marked as dark red / green color
-    if (!line.hasIntralineInfo) {
-      td.classList.add('no-intraline-info');
-    }
-    td.classList.add(line.type);
-
-    const {beforeNumber, afterNumber} = line;
-    if (beforeNumber !== 'FILE' && beforeNumber !== 'LOST') {
-      const responsiveMode = getResponsiveMode(this._prefs, this._renderPrefs);
-      const contentText = this._formatText(
-        line.text,
-        responsiveMode,
-        this._prefs.tab_size,
-        this._prefs.line_length
-      );
-
-      if (side) {
-        contentText.setAttribute('data-side', side);
-        const number = side === Side.LEFT ? beforeNumber : afterNumber;
-        this._addLineNumberMouseEvents(td, number, side);
-      }
-
-      if (lineNumberEl && side) {
-        for (const layer of this.layers) {
-          if (typeof layer.annotate === 'function') {
-            layer.annotate(contentText, lineNumberEl, line, side);
-          }
-        }
-      } else {
-        console.error('lineNumberEl or side not set, skipping layer.annotate');
-      }
-
-      td.appendChild(contentText);
-    } else if (line.beforeNumber === 'FILE') td.classList.add('file');
-    else if (line.beforeNumber === 'LOST') td.classList.add('lost');
-
-    return td;
-  }
-
-  private createLineBreak(responsive: boolean) {
-    return responsive
-      ? createElementDiff('wbr')
-      : createElementDiff('span', 'br');
-  }
-
-  /**
-   * Returns a 'div' element containing the supplied |text| as its innerText,
-   * with '\t' characters expanded to a width determined by |tabSize|, and the
-   * text wrapped at column |lineLimit|, which may be Infinity if no wrapping is
-   * desired.
-   *
-   * @param text The text to be formatted.
-   * @param tabSize The width of each tab stop.
-   * @param lineLimit The column after which to wrap lines.
-   */
-  _formatText(
-    text: string,
-    responsiveMode: DiffResponsiveMode,
-    tabSize: number,
-    lineLimit: number
-  ): HTMLElement {
-    const contentText = createElementDiff('div', 'contentText');
-    contentText.ariaLabel = text;
-    const responsive = isResponsive(responsiveMode);
-    let columnPos = 0;
-    let textOffset = 0;
-    for (const segment of text.split(REGEX_TAB_OR_SURROGATE_PAIR)) {
-      if (segment) {
-        // |segment| contains only normal characters. If |segment| doesn't fit
-        // entirely on the current line, append chunks of |segment| followed by
-        // line breaks.
-        let rowStart = 0;
-        let rowEnd = lineLimit - columnPos;
-        while (rowEnd < segment.length) {
-          contentText.appendChild(
-            document.createTextNode(segment.substring(rowStart, rowEnd))
-          );
-          contentText.appendChild(this.createLineBreak(responsive));
-          columnPos = 0;
-          rowStart = rowEnd;
-          rowEnd += lineLimit;
-        }
-        // Append the last part of |segment|, which fits on the current line.
-        contentText.appendChild(
-          document.createTextNode(segment.substring(rowStart))
-        );
-        columnPos += segment.length - rowStart;
-        textOffset += segment.length;
-      }
-      if (textOffset < text.length) {
-        // Handle the special character at |textOffset|.
-        if (text.startsWith('\t', textOffset)) {
-          // Append a single '\t' character.
-          let effectiveTabSize = tabSize - (columnPos % tabSize);
-          if (columnPos + effectiveTabSize > lineLimit) {
-            contentText.appendChild(this.createLineBreak(responsive));
-            columnPos = 0;
-            effectiveTabSize = tabSize;
-          }
-          contentText.appendChild(this._getTabWrapper(effectiveTabSize));
-          columnPos += effectiveTabSize;
-          textOffset++;
-        } else {
-          // Append a single surrogate pair.
-          if (columnPos >= lineLimit) {
-            contentText.appendChild(this.createLineBreak(responsive));
-            columnPos = 0;
-          }
-          contentText.appendChild(
-            document.createTextNode(text.substring(textOffset, textOffset + 2))
-          );
-          textOffset += 2;
-          columnPos += 1;
-        }
-      }
-    }
-    return contentText;
-  }
-
-  /**
-   * Returns a <span> element holding a '\t' character, that will visually
-   * occupy |tabSize| many columns.
-   *
-   * @param tabSize The effective size of this tab stop.
-   */
-  _getTabWrapper(tabSize: number): HTMLElement {
-    // Force this to be a number to prevent arbitrary injection.
-    const result = createElementDiff('span', 'tab');
-    result.setAttribute(
-      'style',
-      `tab-size: ${tabSize}; -moz-tab-size: ${tabSize};`
-    );
-    result.innerText = '\t';
-    return result;
-  }
-
-  _handleLayerUpdate(start: LineNumber, end: LineNumber, side: Side) {
-    this._renderContentByRange(start, end, side);
-  }
+  protected abstract renderBlameByRange(
+    blame: BlameInfo,
+    start: number,
+    end: number
+  ): void;
 
   /**
    * Finds the next DIV.contentText element following the given element, and on
    * the same side. Will only search within a group.
+   *
+   * TODO: Change `null` to `undefined`.
    */
-  abstract _getNextContentOnSide(
+  protected abstract getNextContentOnSide(
     content: HTMLElement,
     side: Side
   ): HTMLElement | null;
@@ -702,7 +302,7 @@
    * Gets configuration for creating move controls for chunks marked with
    * dueToMove
    */
-  abstract _getMoveControlsConfig(): {
+  protected abstract getMoveControlsConfig(): {
     numberOfCells: number;
     movedOutIndex: number;
     movedInIndex: number;
@@ -713,7 +313,7 @@
    * Determines whether the given group is either totally an addition or totally
    * a removal.
    */
-  _isTotal(group: GrDiffGroup): boolean {
+  protected isTotal(group: GrDiffGroup): boolean {
     return (
       group.type === GrDiffGroupType.DELTA &&
       (!group.adds.length || !group.removes.length) &&
@@ -725,117 +325,15 @@
    * Set the blame information for the diff. For any already-rendered line,
    * re-render its blame cell content.
    */
-  setBlame(blame: BlameInfo[] | null) {
+  setBlame(blame: BlameInfo[]) {
     this.blameInfo = blame;
-    if (!blame) return;
-
-    // TODO(wyatta): make this loop asynchronous.
     for (const commit of blame) {
       for (const range of commit.ranges) {
-        for (let i = range.start; i <= range.end; i++) {
-          // TODO(wyatta): this query is expensive, but, when traversing a
-          // range, the lines are consecutive, and given the previous blame
-          // cell, the next one can be reached cheaply.
-          const el = this._getBlameByLineNum(i);
-          if (!el) {
-            continue;
-          }
-          // Remove the element's children (if any).
-          while (el.hasChildNodes()) {
-            el.removeChild(el.lastChild!);
-          }
-          const blame = this._getBlameForBaseLine(i, commit);
-          if (blame) el.appendChild(blame);
-        }
+        this.renderBlameByRange(commit, range.start, range.end);
       }
     }
   }
 
-  _createMovedLineAnchor(line: number, side: Side) {
-    const anchor = createElementDiffWithText('a', `${line}`);
-
-    // href is not actually used but important for Screen Readers
-    anchor.setAttribute('href', `#${line}`);
-    anchor.addEventListener('click', e => {
-      e.preventDefault();
-      anchor.dispatchEvent(
-        new CustomEvent<MovedLinkClickedEventDetail>('moved-link-clicked', {
-          detail: {
-            lineNum: line,
-            side,
-          },
-          composed: true,
-          bubbles: true,
-        })
-      );
-    });
-    return anchor;
-  }
-
-  _createMoveDescriptionDiv(movedIn: boolean, group: GrDiffGroup) {
-    const div = createElementDiff('div');
-    if (group.moveDetails?.range) {
-      const {changed, range} = group.moveDetails;
-      const otherSide = movedIn ? Side.LEFT : Side.RIGHT;
-      const andChangedLabel = changed ? 'and changed ' : '';
-      const direction = movedIn ? 'from' : 'to';
-      const textLabel = `Moved ${andChangedLabel}${direction} lines `;
-      div.appendChild(createElementDiffWithText('span', textLabel));
-      div.appendChild(this._createMovedLineAnchor(range.start, otherSide));
-      div.appendChild(createElementDiffWithText('span', ' - '));
-      div.appendChild(this._createMovedLineAnchor(range.end, otherSide));
-    } else {
-      div.appendChild(
-        createElementDiffWithText('span', movedIn ? 'Moved in' : 'Moved out')
-      );
-    }
-    return div;
-  }
-
-  _buildMoveControls(group: GrDiffGroup) {
-    const movedIn = group.adds.length > 0;
-    const {numberOfCells, movedOutIndex, movedInIndex, lineNumberCols} =
-      this._getMoveControlsConfig();
-
-    let controlsClass;
-    let descriptionIndex;
-    const descriptionTextDiv = this._createMoveDescriptionDiv(movedIn, group);
-    if (movedIn) {
-      controlsClass = 'movedIn';
-      descriptionIndex = movedInIndex;
-    } else {
-      controlsClass = 'movedOut';
-      descriptionIndex = movedOutIndex;
-    }
-
-    const controls = createElementDiff('tr', `moveControls ${controlsClass}`);
-    const cells = [...Array(numberOfCells).keys()].map(() =>
-      createElementDiff('td')
-    );
-    lineNumberCols.forEach(index => {
-      cells[index].classList.add('moveControlsLineNumCol');
-    });
-
-    const moveRangeHeader = createElementDiff('gr-range-header');
-    moveRangeHeader.setAttribute('icon', 'gr-icons:move-item');
-    moveRangeHeader.appendChild(descriptionTextDiv);
-    cells[descriptionIndex].classList.add('moveHeader');
-    cells[descriptionIndex].appendChild(moveRangeHeader);
-    cells.forEach(c => {
-      controls.appendChild(c);
-    });
-    return controls;
-  }
-
-  /**
-   * Find the blame cell for a given line number.
-   */
-  _getBlameByLineNum(lineNum: number): Element | null {
-    return this._outputEl.querySelector(
-      `td.blame[data-line-number="${lineNum}"]`
-    );
-  }
-
   /**
    * Given a base line number, return the commit containing that line in the
    * current set of blame information. If no blame information has been
@@ -843,11 +341,9 @@
    *
    * @return The commit information.
    */
-  _getBlameCommitForBaseLine(lineNum: LineNumber) {
-    if (!this.blameInfo) {
-      return null;
-    }
-
+  protected getBlameCommitForBaseLine(
+    lineNum: LineNumber
+  ): BlameInfo | undefined {
     for (const blameCommit of this.blameInfo) {
       for (const range of blameCommit.ranges) {
         if (range.start <= lineNum && range.end >= lineNum) {
@@ -855,86 +351,12 @@
         }
       }
     }
-    return null;
+    return undefined;
   }
 
   /**
-   * Given the number of a base line, get the content for the blame cell of that
-   * line. If there is no blame information for that line, returns null.
-   *
-   * @param commit Optionally provide the commit object, so that
-   *     it does not need to be searched.
+   * Only special builders need to implement this. The default is to
+   * just ignore it.
    */
-  _getBlameForBaseLine(
-    lineNum: LineNumber,
-    commit: BlameInfo | null = this._getBlameCommitForBaseLine(lineNum)
-  ): HTMLElement | null {
-    if (!commit) {
-      return null;
-    }
-
-    const isStartOfRange = commit.ranges.some(r => r.start === lineNum);
-
-    const date = new Date(commit.time * 1000).toLocaleDateString();
-    const blameNode = createElementDiff(
-      'span',
-      isStartOfRange ? 'startOfRange' : ''
-    );
-
-    const shaNode = createElementDiff('a', 'blameDate');
-    shaNode.innerText = `${date}`;
-    shaNode.setAttribute('href', `${getBaseUrl()}/q/${commit.id}`);
-    blameNode.appendChild(shaNode);
-
-    const shortName = commit.author.split(' ')[0];
-    const authorNode = createElementDiff('span', 'blameAuthor');
-    authorNode.innerText = ` ${shortName}`;
-    blameNode.appendChild(authorNode);
-
-    const hoverCardFragment = createElementDiff('span', 'blameHoverCard');
-    hoverCardFragment.innerText = `Commit ${commit.id}
-Author: ${commit.author}
-Date: ${date}
-
-${commit.commit_msg}`;
-    const hovercard = createElementDiff('gr-hovercard');
-    hovercard.appendChild(hoverCardFragment);
-    blameNode.appendChild(hovercard);
-
-    return blameNode;
-  }
-
-  /**
-   * Create a blame cell for the given base line. Blame information will be
-   * included in the cell if available.
-   */
-  _createBlameCell(lineNumber: LineNumber): HTMLTableDataCellElement {
-    const blameTd = createElementDiff(
-      'td',
-      'blame'
-    ) as HTMLTableDataCellElement;
-    blameTd.setAttribute('data-line-number', lineNumber.toString());
-    if (lineNumber) {
-      const content = this._getBlameForBaseLine(lineNumber);
-      if (content) {
-        blameTd.appendChild(content);
-      }
-    }
-    return blameTd;
-  }
-
-  /**
-   * Finds the line number element given the content element by walking up the
-   * DOM tree to the diff row and then querying for a .lineNum element on the
-   * requested side.
-   *
-   * TODO(brohlfs): Consolidate this with getLineEl... methods in html file.
-   */
-  _getLineNumberEl(content: HTMLElement, side: Side): HTMLElement | null {
-    let row: HTMLElement | null = content;
-    while (row && !row.classList.contains('diff-row')) row = row.parentElement;
-    return row ? (row.querySelector('.lineNum.' + side) as HTMLElement) : null;
-  }
-
-  updateRenderPrefs(_renderPrefs: RenderPreferences) {}
+  updateRenderPrefs(_: RenderPreferences) {}
 }
diff --git a/polygerrit-ui/app/embed/diff/gr-diff/gr-diff-utils.ts b/polygerrit-ui/app/embed/diff/gr-diff/gr-diff-utils.ts
index 633b5f6..94a7a84 100644
--- a/polygerrit-ui/app/embed/diff/gr-diff/gr-diff-utils.ts
+++ b/polygerrit-ui/app/embed/diff/gr-diff/gr-diff-utils.ts
@@ -14,16 +14,63 @@
  * See the License for the specific language governing permissions and
  * limitations under the License.
  */
-
-import {CommentRange} from '../../../types/common';
+import {BlameInfo, CommentRange} from '../../../types/common';
 import {FILE, LineNumber} from './gr-diff-line';
 import {Side} from '../../../constants/constants';
 import {DiffInfo} from '../../../types/diff';
+import {
+  DiffPreferencesInfo,
+  DiffResponsiveMode,
+  RenderPreferences,
+} from '../../../api/diff';
+import {getBaseUrl} from '../../../utils/url-util';
+
+/**
+ * In JS, unicode code points above 0xFFFF occupy two elements of a string.
+ * For example '๐€'.length is 2. An occurrence of such a code point is called a
+ * surrogate pair.
+ *
+ * This regex segments a string along tabs ('\t') and surrogate pairs, since
+ * these are two cases where '1 char' does not automatically imply '1 column'.
+ *
+ * TODO: For human languages whose orthographies use combining marks, this
+ * approach won't correctly identify the grapheme boundaries. In those cases,
+ * a grapheme consists of multiple code points that should count as only one
+ * character against the column limit. Getting that correct (if it's desired)
+ * is probably beyond the limits of a regex, but there are nonstandard APIs to
+ * do this, and proposed (but, as of Nov 2017, unimplemented) standard APIs.
+ *
+ * Further reading:
+ *   On Unicode in JS: https://mathiasbynens.be/notes/javascript-unicode
+ *   Graphemes: http://unicode.org/reports/tr29/#Grapheme_Cluster_Boundaries
+ *   A proposed JS API: https://github.com/tc39/proposal-intl-segmenter
+ */
+const REGEX_TAB_OR_SURROGATE_PAIR = /\t|[\uD800-\uDBFF][\uDC00-\uDFFF]/;
 
 // If any line of the diff is more than the character limit, then disable
 // syntax highlighting for the entire file.
 export const SYNTAX_MAX_LINE_LENGTH = 500;
 
+export function getResponsiveMode(
+  prefs: DiffPreferencesInfo,
+  renderPrefs?: RenderPreferences
+): DiffResponsiveMode {
+  if (renderPrefs?.responsive_mode) {
+    return renderPrefs.responsive_mode;
+  }
+  // Backwards compatibility to the line_wrapping param.
+  if (prefs.line_wrapping) {
+    return 'FULL_RESPONSIVE';
+  }
+  return 'NONE';
+}
+
+export function isResponsive(responsiveMode: DiffResponsiveMode) {
+  return (
+    responsiveMode === 'FULL_RESPONSIVE' || responsiveMode === 'SHRINK_ONLY'
+  );
+}
+
 /**
  * Compare two ranges. Either argument may be falsy, but will only return
  * true if both are falsy or if neither are falsy and have the same position
@@ -186,3 +233,139 @@
   element.textContent = textContent;
   return element;
 }
+
+export function createLineBreak(mode: DiffResponsiveMode) {
+  return isResponsive(mode)
+    ? createElementDiff('wbr')
+    : createElementDiff('span', 'br');
+}
+
+/**
+ * Returns a <span> element holding a '\t' character, that will visually
+ * occupy |tabSize| many columns.
+ *
+ * @param tabSize The effective size of this tab stop.
+ */
+export function createTabWrapper(tabSize: number): HTMLElement {
+  // Force this to be a number to prevent arbitrary injection.
+  const result = createElementDiff('span', 'tab');
+  result.setAttribute(
+    'style',
+    `tab-size: ${tabSize}; -moz-tab-size: ${tabSize};`
+  );
+  result.innerText = '\t';
+  return result;
+}
+
+/**
+ * Returns a 'div' element containing the supplied |text| as its innerText,
+ * with '\t' characters expanded to a width determined by |tabSize|, and the
+ * text wrapped at column |lineLimit|, which may be Infinity if no wrapping is
+ * desired.
+ *
+ * @param text The text to be formatted.
+ * @param responsiveMode The responsive mode of the diff.
+ * @param tabSize The width of each tab stop.
+ * @param lineLimit The column after which to wrap lines.
+ */
+export function formatText(
+  text: string,
+  responsiveMode: DiffResponsiveMode,
+  tabSize: number,
+  lineLimit: number
+): HTMLElement {
+  const contentText = createElementDiff('div', 'contentText');
+  contentText.ariaLabel = text;
+  let columnPos = 0;
+  let textOffset = 0;
+  for (const segment of text.split(REGEX_TAB_OR_SURROGATE_PAIR)) {
+    if (segment) {
+      // |segment| contains only normal characters. If |segment| doesn't fit
+      // entirely on the current line, append chunks of |segment| followed by
+      // line breaks.
+      let rowStart = 0;
+      let rowEnd = lineLimit - columnPos;
+      while (rowEnd < segment.length) {
+        contentText.appendChild(
+          document.createTextNode(segment.substring(rowStart, rowEnd))
+        );
+        contentText.appendChild(createLineBreak(responsiveMode));
+        columnPos = 0;
+        rowStart = rowEnd;
+        rowEnd += lineLimit;
+      }
+      // Append the last part of |segment|, which fits on the current line.
+      contentText.appendChild(
+        document.createTextNode(segment.substring(rowStart))
+      );
+      columnPos += segment.length - rowStart;
+      textOffset += segment.length;
+    }
+    if (textOffset < text.length) {
+      // Handle the special character at |textOffset|.
+      if (text.startsWith('\t', textOffset)) {
+        // Append a single '\t' character.
+        let effectiveTabSize = tabSize - (columnPos % tabSize);
+        if (columnPos + effectiveTabSize > lineLimit) {
+          contentText.appendChild(createLineBreak(responsiveMode));
+          columnPos = 0;
+          effectiveTabSize = tabSize;
+        }
+        contentText.appendChild(createTabWrapper(effectiveTabSize));
+        columnPos += effectiveTabSize;
+        textOffset++;
+      } else {
+        // Append a single surrogate pair.
+        if (columnPos >= lineLimit) {
+          contentText.appendChild(createLineBreak(responsiveMode));
+          columnPos = 0;
+        }
+        contentText.appendChild(
+          document.createTextNode(text.substring(textOffset, textOffset + 2))
+        );
+        textOffset += 2;
+        columnPos += 1;
+      }
+    }
+  }
+  return contentText;
+}
+
+/**
+ * Given the number of a base line and the BlameInfo create a <span> element
+ * with a hovercard. This is supposed to be put into a <td> cell of the diff.
+ */
+export function createBlameElement(
+  lineNum: LineNumber,
+  commit: BlameInfo
+): HTMLElement {
+  const isStartOfRange = commit.ranges.some(r => r.start === lineNum);
+
+  const date = new Date(commit.time * 1000).toLocaleDateString();
+  const blameNode = createElementDiff(
+    'span',
+    isStartOfRange ? 'startOfRange' : ''
+  );
+
+  const shaNode = createElementDiff('a', 'blameDate');
+  shaNode.innerText = `${date}`;
+  shaNode.setAttribute('href', `${getBaseUrl()}/q/${commit.id}`);
+  blameNode.appendChild(shaNode);
+
+  const shortName = commit.author.split(' ')[0];
+  const authorNode = createElementDiff('span', 'blameAuthor');
+  authorNode.innerText = ` ${shortName}`;
+  blameNode.appendChild(authorNode);
+
+  const hoverCardFragment = createElementDiff('span', 'blameHoverCard');
+  hoverCardFragment.innerText = `Commit ${commit.id}
+Author: ${commit.author}
+Date: ${date}
+
+${commit.commit_msg}`;
+  const hovercard = createElementDiff('gr-hovercard');
+  hovercard.appendChild(hoverCardFragment);
+  blameNode.appendChild(hovercard);
+
+  return blameNode;
+}
diff --git a/polygerrit-ui/app/embed/diff/gr-diff/gr-diff-utils_test.ts b/polygerrit-ui/app/embed/diff/gr-diff/gr-diff-utils_test.ts
index 19ed185..600913e 100644
--- a/polygerrit-ui/app/embed/diff/gr-diff/gr-diff-utils_test.ts
+++ b/polygerrit-ui/app/embed/diff/gr-diff/gr-diff-utils_test.ts
@@ -4,7 +4,9 @@
  * SPDX-License-Identifier: Apache-2.0
  */
 import '../../../test/common-test-setup-karma';
-import {createElementDiff} from './gr-diff-utils';
+import {createElementDiff, formatText, createTabWrapper} from './gr-diff-utils';
+
+const LINE_BREAK_HTML = '<span class="style-scope gr-diff br"></span>';
 
 suite('gr-diff-utils tests', () => {
   test('createElementDiff classStr applies all classes', () => {
@@ -13,4 +15,139 @@
     assert.isTrue(node.classList.contains('test'));
     assert.isTrue(node.classList.contains('classes'));
   });
+
+  test('formatText newlines 1', () => {
+    let text = 'abcdef';
+
+    assert.equal(formatText(text, 'NONE', 4, 10).innerHTML, text);
+    text = 'a'.repeat(20);
+    assert.equal(
+      formatText(text, 'NONE', 4, 10).innerHTML,
+      'a'.repeat(10) + LINE_BREAK_HTML + 'a'.repeat(10)
+    );
+  });
+
+  test('formatText newlines 2', () => {
+    const text = '<span class="thumbsup">๐Ÿ‘</span>';
+    assert.equal(
+      formatText(text, 'NONE', 4, 10).innerHTML,
+      '&lt;span clas' +
+        LINE_BREAK_HTML +
+        's="thumbsu' +
+        LINE_BREAK_HTML +
+        'p"&gt;๐Ÿ‘&lt;/span' +
+        LINE_BREAK_HTML +
+        '&gt;'
+    );
+  });
+
+  test('formatText newlines 3', () => {
+    const text = '01234\t56789';
+    assert.equal(
+      formatText(text, 'NONE', 4, 10).innerHTML,
+      '01234' + createTabWrapper(3).outerHTML + '56' + LINE_BREAK_HTML + '789'
+    );
+  });
+
+  test('formatText newlines 4', () => {
+    const text = '๐Ÿ‘'.repeat(58);
+    assert.equal(
+      formatText(text, 'NONE', 4, 20).innerHTML,
+      '๐Ÿ‘'.repeat(20) +
+        LINE_BREAK_HTML +
+        '๐Ÿ‘'.repeat(20) +
+        LINE_BREAK_HTML +
+        '๐Ÿ‘'.repeat(18)
+    );
+  });
+
+  test('tab wrapper style', () => {
+    const pattern = new RegExp(
+      '^<span class="style-scope gr-diff tab" ' +
+        'style="((?:-moz-)?tab-size: (\\d+);.?)+">\\t<\\/span>$'
+    );
+
+    for (const size of [1, 3, 8, 55]) {
+      const html = createTabWrapper(size).outerHTML;
+      expect(html).to.match(pattern);
+      assert.equal(html.match(pattern)?.[2], size.toString());
+    }
+  });
+
+  test('tab wrapper insertion', () => {
+    const html = 'abc\tdef';
+    const tabSize = 8;
+    const wrapper = createTabWrapper(tabSize - 3);
+    assert.ok(wrapper);
+    assert.equal(wrapper.innerText, '\t');
+    assert.equal(
+      formatText(html, 'NONE', tabSize, Infinity).innerHTML,
+      'abc' + wrapper.outerHTML + 'def'
+    );
+  });
+
+  test('escaping HTML', () => {
+    let input = '<script>alert("XSS");<' + '/script>';
+    let expected = '&lt;script&gt;alert("XSS");&lt;/script&gt;';
+
+    let result = formatText(
+      input,
+      'NONE',
+      1,
+      Number.POSITIVE_INFINITY
+    ).innerHTML;
+    assert.equal(result, expected);
+
+    input = '& < > " \' / `';
+    expected = '&amp; &lt; &gt; " \' / `';
+    result = formatText(input, 'NONE', 1, Number.POSITIVE_INFINITY).innerHTML;
+    assert.equal(result, expected);
+  });
+
+  test('text length with tabs and unicode', () => {
+    function expectTextLength(text: string, tabSize: number, expected: number) {
+      // Formatting to |expected| columns should not introduce line breaks.
+      const result = formatText(text, 'NONE', tabSize, expected);
+      assert.isNotOk(
+        result.querySelector('.contentText > .br'),
+        '  Expected the result of: \n' +
+          `      _formatText(${text}', 'NONE',  ${tabSize}, ${expected})\n` +
+          '  to not contain a br. But the actual result HTML was:\n' +
+          `      '${result.innerHTML}'\nwhereupon`
+      );
+
+      // Increasing the line limit should produce the same markup.
+      assert.equal(
+        formatText(text, 'NONE', tabSize, Infinity).innerHTML,
+        result.innerHTML
+      );
+      assert.equal(
+        formatText(text, 'NONE', tabSize, expected + 1).innerHTML,
+        result.innerHTML
+      );
+
+      // Decreasing the line limit should introduce line breaks.
+      if (expected > 0) {
+        const tooSmall = formatText(text, 'NONE', tabSize, expected - 1);
+        assert.isOk(
+          tooSmall.querySelector('.contentText > .br'),
+          '  Expected the result of: \n' +
+            `      _formatText(${text}', ${tabSize}, ${expected - 1})\n` +
+            '  to contain a br. But the actual result HTML was:\n' +
+            `      '${tooSmall.innerHTML}'\nwhereupon`
+        );
+      }
+    }
+    expectTextLength('12345', 4, 5);
+    expectTextLength('\t\t12', 4, 10);
+    expectTextLength('abc๐Ÿ’ข123', 4, 7);
+    expectTextLength('abc\t', 8, 8);
+    expectTextLength('abc\t\t', 10, 20);
+    expectTextLength('', 10, 0);
+    // 17 Thai combining chars.
+    expectTextLength('เธเน‰เน‰เน‰เน‰เน‰เน‰เน‰เน‰เน‰เน‰เน‰เน‰เน‰เน‰เน‰เน‰', 4, 17);
+    expectTextLength('abc\tde', 10, 12);
+    expectTextLength('abc\tde\t', 10, 20);
+    expectTextLength('\t\t\t\t\t', 20, 100);
+  });
 });
diff --git a/polygerrit-ui/app/embed/diff/gr-diff/gr-diff.ts b/polygerrit-ui/app/embed/diff/gr-diff/gr-diff.ts
index 0cabf52..863efca 100644
--- a/polygerrit-ui/app/embed/diff/gr-diff/gr-diff.ts
+++ b/polygerrit-ui/app/embed/diff/gr-diff/gr-diff.ts
@@ -37,6 +37,8 @@
   isLongCommentRange,
   isThreadEl,
   rangesEqual,
+  getResponsiveMode,
+  isResponsive,
 } from './gr-diff-utils';
 import {getHiddenScroll} from '../../../scripts/hiddenscroll';
 import {customElement, observe, property} from '@polymer/decorators';
@@ -83,10 +85,6 @@
 import {isSafari, toggleClass} from '../../../utils/dom-util';
 import {assertIsDefined} from '../../../utils/common-util';
 import {debounce, DelayedTask} from '../../../utils/async-util';
-import {
-  getResponsiveMode,
-  isResponsive,
-} from '../gr-diff-builder/gr-diff-builder';
 
 const NO_NEWLINE_LEFT = 'No newline at end of left file.';
 const NO_NEWLINE_RIGHT = 'No newline at end of right file.';