Merge "Unite gr-diff with gr-diff-builder-element"
diff --git a/polygerrit-ui/app/elements/diff/gr-diff-host/gr-diff-host_test.ts b/polygerrit-ui/app/elements/diff/gr-diff-host/gr-diff-host_test.ts
index 0abcaf5..e0d5f1d 100644
--- a/polygerrit-ui/app/elements/diff/gr-diff-host/gr-diff-host_test.ts
+++ b/polygerrit-ui/app/elements/diff/gr-diff-host/gr-diff-host_test.ts
@@ -630,16 +630,11 @@
       element.blame = [];
       await element.updateComplete;
       assertIsDefined(element.diffElement);
-      const setBlameSpy = sinon.spy(
-        element.diffElement.diffBuilder,
-        'setBlame'
-      );
       const isBlameLoadedStub = sinon.stub();
       element.addEventListener('is-blame-loaded-changed', isBlameLoadedStub);
       element.clearBlame();
       await element.updateComplete;
       assert.isNull(element.blame);
-      assert.isTrue(setBlameSpy.calledWithExactly(null));
       assert.isTrue(isBlameLoadedStub.calledOnce);
       assert.isFalse(isBlameLoadedStub.args[0][0].detail.value);
     });
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
deleted file mode 100644
index 1452e0f..0000000
--- a/polygerrit-ui/app/embed/diff/gr-diff-builder/gr-diff-builder-element.ts
+++ /dev/null
@@ -1,574 +0,0 @@
-/**
- * @license
- * Copyright 2016 Google LLC
- * SPDX-License-Identifier: Apache-2.0
- */
-import '../gr-diff-processor/gr-diff-processor';
-import '../../../elements/shared/gr-hovercard/gr-hovercard';
-import {GrAnnotation} from '../gr-diff-highlight/gr-annotation';
-import {
-  GrDiffBuilder,
-  DiffContextExpandedEventDetail,
-  isImageDiffBuilder,
-  isBinaryDiffBuilder,
-} from './gr-diff-builder';
-import {GrDiffBuilderImage} from './gr-diff-builder-image';
-import {GrDiffBuilderBinary} from './gr-diff-builder-binary';
-import {BlameInfo, ImageInfo} from '../../../types/common';
-import {DiffInfo, DiffPreferencesInfo} from '../../../types/diff';
-import {CoverageRange, DiffLayer} from '../../../types/types';
-import {
-  GrDiffProcessor,
-  GroupConsumer,
-  KeyLocations,
-} from '../gr-diff-processor/gr-diff-processor';
-import {
-  CommentRangeLayer,
-  GrRangedCommentLayer,
-} from '../gr-ranged-comment-layer/gr-ranged-comment-layer';
-import {GrCoverageLayer} from '../gr-coverage-layer/gr-coverage-layer';
-import {DiffViewMode, LineNumber, RenderPreferences} from '../../../api/diff';
-import {createDefaultDiffPrefs, Side} from '../../../constants/constants';
-import {GrDiffLine} from '../gr-diff/gr-diff-line';
-import {
-  GrDiffGroup,
-  GrDiffGroupType,
-  hideInContextControl,
-} from '../gr-diff/gr-diff-group';
-import {getLineNumber, getSideByLineEl} from '../gr-diff/gr-diff-utils';
-import {fireAlert, fire} from '../../../utils/event-util';
-import {assertIsDefined} from '../../../utils/common-util';
-
-const TRAILING_WHITESPACE_PATTERN = /\s+$/;
-const COMMIT_MSG_PATH = '/COMMIT_MSG';
-const COMMIT_MSG_LINE_LENGTH = 72;
-
-declare global {
-  interface HTMLElementEventMap {
-    /**
-     * Fired when the diff begins rendering - both for full renders and for
-     * partial rerenders.
-     */
-    'render-start': CustomEvent<{}>;
-    /**
-     * Fired when the diff finishes rendering text content - both for full
-     * renders and for partial rerenders.
-     */
-    'render-content': CustomEvent<{}>;
-  }
-}
-
-export function getLineNumberCellWidth(prefs: DiffPreferencesInfo) {
-  return prefs.font_size * 4;
-}
-
-function annotateSymbols(
-  contentEl: HTMLElement,
-  line: GrDiffLine,
-  separator: string | RegExp,
-  className: string
-) {
-  const split = line.text.split(separator);
-  if (!split || split.length < 2) {
-    return;
-  }
-  for (let i = 0, pos = 0; i < split.length - 1; i++) {
-    // Skip forward by the length of the content
-    pos += split[i].length;
-
-    GrAnnotation.annotateElement(contentEl, pos, 1, `gr-diff ${className}`);
-
-    pos++;
-  }
-}
-
-// TODO: Rename the class and the file and remove "element". This is not an
-// element anymore.
-export class GrDiffBuilderElement implements GroupConsumer {
-  diff?: DiffInfo;
-
-  diffElement?: HTMLTableElement;
-
-  viewMode?: string;
-
-  isImageDiff?: boolean;
-
-  baseImage: ImageInfo | null = null;
-
-  revisionImage: ImageInfo | null = null;
-
-  path?: string;
-
-  prefs: DiffPreferencesInfo = createDefaultDiffPrefs();
-
-  renderPrefs?: RenderPreferences;
-
-  useNewImageDiffUi = false;
-
-  /**
-   * Layers passed in from the outside.
-   *
-   * See `layersInternal` for where these layers will end up together with the
-   * internal layers.
-   */
-  layers: DiffLayer[] = [];
-
-  // visible for testing
-  builder?: GrDiffBuilder;
-
-  /**
-   * All layers, both from the outside and the default ones. See `layers` for
-   * the property that can be set from the outside.
-   */
-  // visible for testing
-  layersInternal: DiffLayer[] = [];
-
-  // visible for testing
-  showTabs?: boolean;
-
-  // visible for testing
-  showTrailingWhitespace?: boolean;
-
-  private coverageLayerLeft = new GrCoverageLayer(Side.LEFT);
-
-  private coverageLayerRight = new GrCoverageLayer(Side.RIGHT);
-
-  private rangeLayer?: GrRangedCommentLayer;
-
-  // visible for testing
-  processor?: GrDiffProcessor;
-
-  /**
-   * Groups are mostly just passed on to the diff builder (this.builder). But
-   * we also keep track of them here for being able to fire a `render-content`
-   * event when .element of each group has rendered.
-   *
-   * TODO: Refactor DiffBuilderElement and DiffBuilders with a cleaner
-   * separation of responsibilities.
-   */
-  private groups: GrDiffGroup[] = [];
-
-  updateCommentRanges(ranges: CommentRangeLayer[]) {
-    this.rangeLayer?.updateRanges(ranges);
-  }
-
-  updateCoverageRanges(rs: CoverageRange[]) {
-    this.coverageLayerLeft.setRanges(rs.filter(r => r?.side === Side.LEFT));
-    this.coverageLayerRight.setRanges(rs.filter(r => r?.side === Side.RIGHT));
-  }
-
-  render(keyLocations: KeyLocations): Promise<void> {
-    assertIsDefined(this.diff, 'diff');
-    assertIsDefined(this.diffElement, 'diff table');
-
-    // Setting up annotation layers must happen after plugins are
-    // installed, and |render| satisfies the requirement, however,
-    // |attached| doesn't because in the diff view page, the element is
-    // attached before plugins are installed.
-    this.setupAnnotationLayers();
-
-    this.showTabs = this.prefs.show_tabs;
-    this.showTrailingWhitespace = this.prefs.show_whitespace_errors;
-
-    this.cleanup();
-    this.builder = this.getDiffBuilder();
-    this.init();
-
-    // TODO: Just pass along the diff model here instead of setting many
-    // individual properties.
-    this.processor = new GrDiffProcessor();
-    this.processor.consumer = this;
-    this.processor.context = this.prefs.context;
-    this.processor.keyLocations = keyLocations;
-    if (this.renderPrefs?.num_lines_rendered_at_once) {
-      this.processor.asyncThreshold =
-        this.renderPrefs.num_lines_rendered_at_once;
-    }
-
-    this.clearDiffContent();
-    this.builder.addColumns(
-      this.diffElement,
-      getLineNumberCellWidth(this.prefs)
-    );
-
-    const isBinary = !!(this.isImageDiff || this.diff.binary);
-
-    fire(this.diffElement, 'render-start', {});
-    return (
-      this.processor
-        .process(this.diff.content, isBinary)
-        .then(async () => {
-          if (isImageDiffBuilder(this.builder)) {
-            this.builder.renderImageDiff();
-          } else if (isBinaryDiffBuilder(this.builder)) {
-            this.builder.renderBinaryDiff();
-          }
-          await this.untilGroupsRendered();
-          fire(this.diffElement, 'render-content', {});
-        })
-        // Mocha testing does not like uncaught rejections, so we catch
-        // the cancels which are expected and should not throw errors in
-        // tests.
-        .catch(e => {
-          if (!e.isCanceled) return Promise.reject(e);
-          return;
-        })
-    );
-  }
-
-  // visible for testing
-  async untilGroupsRendered(groups: readonly GrDiffGroup[] = this.groups) {
-    return Promise.all(groups.map(g => g.waitUntilRendered()));
-  }
-
-  private onDiffContextExpanded = (
-    e: CustomEvent<DiffContextExpandedEventDetail>
-  ) => {
-    // Don't stop propagation. The host may listen for reporting or
-    // resizing.
-    this.replaceGroup(e.detail.contextGroup, e.detail.groups);
-  };
-
-  // visible for testing
-  setupAnnotationLayers() {
-    this.rangeLayer = new GrRangedCommentLayer();
-
-    const layers: DiffLayer[] = [
-      this.createTrailingWhitespaceLayer(),
-      this.createIntralineLayer(),
-      this.createTabIndicatorLayer(),
-      this.createSpecialCharacterIndicatorLayer(),
-      this.rangeLayer,
-      this.coverageLayerLeft,
-      this.coverageLayerRight,
-    ];
-
-    if (this.layers) {
-      layers.push(...this.layers);
-    }
-    this.layersInternal = layers;
-  }
-
-  getContentTdByLine(lineNumber: LineNumber, side?: Side) {
-    if (!this.builder) return undefined;
-    return this.builder.getContentTdByLine(lineNumber, side);
-  }
-
-  getContentTdByLineEl(lineEl?: Element): Element | undefined {
-    if (!lineEl) return undefined;
-    const line = getLineNumber(lineEl);
-    if (!line) return undefined;
-    const side = getSideByLineEl(lineEl);
-    return this.getContentTdByLine(line, side);
-  }
-
-  getLineElByNumber(lineNumber: LineNumber, side?: Side) {
-    if (!this.builder) return undefined;
-    return this.builder.getLineElByNumber(lineNumber, side);
-  }
-
-  getLineNumberRows() {
-    if (!this.builder) return [];
-    return this.builder.getLineNumberRows();
-  }
-
-  getLineNumEls(side: Side) {
-    if (!this.builder) return [];
-    return this.builder.getLineNumEls(side);
-  }
-
-  /**
-   * When the line is hidden behind a context expander, expand it.
-   *
-   * @param lineNum A line number to expand. Using number here because other
-   *   special case line numbers are never hidden, so it does not make sense
-   *   to expand them.
-   * @param side The side the line number refer to.
-   */
-  unhideLine(lineNum: number, side: Side) {
-    if (!this.builder) return;
-    const group = this.builder.findGroup(side, lineNum);
-    // Cannot unhide a line that is not part of the diff.
-    if (!group) return;
-    // If it's already visible, great!
-    if (group.type !== GrDiffGroupType.CONTEXT_CONTROL) return;
-    const lineRange = group.lineRange[side];
-    const lineOffset = lineNum - lineRange.start_line;
-    const newGroups = [];
-    const groups = hideInContextControl(
-      group.contextGroups,
-      0,
-      lineOffset - 1 - this.prefs.context
-    );
-    // If there is a context group, it will be the first group because we
-    // start hiding from 0 offset
-    if (groups[0].type === GrDiffGroupType.CONTEXT_CONTROL) {
-      newGroups.push(groups.shift()!);
-    }
-    newGroups.push(
-      ...hideInContextControl(
-        groups,
-        lineOffset + 1 + this.prefs.context,
-        // Both ends inclusive, so difference is the offset of the last line.
-        // But we need to pass the first line not to hide, which is the element
-        // after.
-        lineRange.end_line - lineRange.start_line + 1
-      )
-    );
-    this.replaceGroup(group, newGroups);
-  }
-
-  /**
-   * Replace the group of a context control section by rendering the provided
-   * groups instead. This happens in response to expanding a context control
-   * group.
-   *
-   * @param contextGroup The context control group to replace
-   * @param newGroups The groups that are replacing the context control group
-   */
-  private replaceGroup(
-    contextGroup: GrDiffGroup,
-    newGroups: readonly GrDiffGroup[]
-  ) {
-    if (!this.builder) return;
-    fire(this.diffElement, 'render-start', {});
-    this.builder.replaceGroup(contextGroup, newGroups);
-    this.groups = this.groups.filter(g => g !== contextGroup);
-    this.groups.push(...newGroups);
-    this.untilGroupsRendered(newGroups).then(() => {
-      fire(this.diffElement, 'render-content', {});
-    });
-  }
-
-  /**
-   * This is meant to be called when the gr-diff component re-connects, or when
-   * the diff is (re-)rendered.
-   *
-   * Make sure that this method is symmetric with cleanup(), which is called
-   * when gr-diff disconnects.
-   */
-  init() {
-    this.cleanup();
-    this.diffElement?.addEventListener(
-      'diff-context-expanded-internal-new',
-      this.onDiffContextExpanded
-    );
-    this.builder?.init();
-  }
-
-  /**
-   * This is meant to be called when the gr-diff component disconnects, or when
-   * the diff is (re-)rendered.
-   *
-   * Make sure that this method is symmetric with init(), which is called when
-   * gr-diff re-connects.
-   */
-  cleanup() {
-    this.processor?.cancel();
-    this.builder?.cleanup();
-    this.diffElement?.removeEventListener(
-      'diff-context-expanded-internal-new',
-      this.onDiffContextExpanded
-    );
-  }
-
-  // visible for testing
-  handlePreferenceError(pref: string): never {
-    const message =
-      `The value of the '${pref}' user preference is ` +
-      'invalid. Fix in diff preferences';
-    assertIsDefined(this.diffElement, 'diff table');
-    fireAlert(this.diffElement, message);
-    throw Error(`Invalid preference value: ${pref}`);
-  }
-
-  // visible for testing
-  getDiffBuilder(): GrDiffBuilder {
-    assertIsDefined(this.diff, 'diff');
-    assertIsDefined(this.diffElement, 'diff table');
-    if (isNaN(this.prefs.tab_size) || this.prefs.tab_size <= 0) {
-      this.handlePreferenceError('tab size');
-    }
-
-    if (isNaN(this.prefs.line_length) || this.prefs.line_length <= 0) {
-      this.handlePreferenceError('diff width');
-    }
-
-    const localPrefs = {...this.prefs};
-    if (this.path === COMMIT_MSG_PATH) {
-      // override line_length for commit msg the same way as
-      // in gr-diff
-      localPrefs.line_length = COMMIT_MSG_LINE_LENGTH;
-    }
-
-    let builder = null;
-    if (this.isImageDiff) {
-      builder = new GrDiffBuilderImage(
-        this.diff,
-        localPrefs,
-        this.diffElement,
-        this.baseImage,
-        this.revisionImage,
-        this.renderPrefs,
-        this.useNewImageDiffUi
-      );
-    } else if (this.diff.binary) {
-      return new GrDiffBuilderBinary(this.diff, localPrefs, this.diffElement);
-    } else if (this.viewMode === DiffViewMode.SIDE_BY_SIDE) {
-      this.renderPrefs = {
-        ...this.renderPrefs,
-        view_mode: DiffViewMode.SIDE_BY_SIDE,
-      };
-      builder = new GrDiffBuilder(
-        this.diff,
-        localPrefs,
-        this.diffElement,
-        this.layersInternal,
-        this.renderPrefs
-      );
-    } else if (this.viewMode === DiffViewMode.UNIFIED) {
-      this.renderPrefs = {
-        ...this.renderPrefs,
-        view_mode: DiffViewMode.UNIFIED,
-      };
-      builder = new GrDiffBuilder(
-        this.diff,
-        localPrefs,
-        this.diffElement,
-        this.layersInternal,
-        this.renderPrefs
-      );
-    }
-    if (!builder) {
-      throw Error(`Unsupported diff view mode: ${this.viewMode}`);
-    }
-    return builder;
-  }
-
-  private clearDiffContent() {
-    assertIsDefined(this.diffElement, 'diff table');
-    this.diffElement.innerHTML = '';
-  }
-
-  /**
-   * Called when the processor starts converting the diff information from the
-   * server into chunks.
-   */
-  clearGroups() {
-    if (!this.builder) return;
-    this.groups = [];
-    this.builder.clearGroups();
-  }
-
-  /**
-   * Called when the processor is done converting a chunk of the diff.
-   */
-  addGroup(group: GrDiffGroup) {
-    if (!this.builder) return;
-    this.builder.addGroups([group]);
-    this.groups.push(group);
-  }
-
-  // visible for testing
-  createIntralineLayer(): DiffLayer {
-    return {
-      // Take a DIV.contentText element and a line object with intraline
-      // differences to highlight and apply them to the element as
-      // annotations.
-      annotate(contentEl: HTMLElement, _: HTMLElement, line: GrDiffLine) {
-        const HL_CLASS = 'gr-diff intraline';
-        for (const highlight of line.highlights) {
-          // The start and end indices could be the same if a highlight is
-          // meant to start at the end of a line and continue onto the
-          // next one. Ignore it.
-          if (highlight.startIndex === highlight.endIndex) {
-            continue;
-          }
-
-          // If endIndex isn't present, continue to the end of the line.
-          const endIndex =
-            highlight.endIndex === undefined
-              ? GrAnnotation.getStringLength(line.text)
-              : highlight.endIndex;
-
-          GrAnnotation.annotateElement(
-            contentEl,
-            highlight.startIndex,
-            endIndex - highlight.startIndex,
-            HL_CLASS
-          );
-        }
-      },
-    };
-  }
-
-  // visible for testing
-  createTabIndicatorLayer(): DiffLayer {
-    const show = () => this.showTabs;
-    return {
-      annotate(contentEl: HTMLElement, _: HTMLElement, line: GrDiffLine) {
-        // If visible tabs are disabled, do nothing.
-        if (!show()) {
-          return;
-        }
-
-        // Find and annotate the locations of tabs.
-        annotateSymbols(contentEl, line, '\t', 'tab-indicator');
-      },
-    };
-  }
-
-  private createSpecialCharacterIndicatorLayer(): DiffLayer {
-    return {
-      annotate(contentEl: HTMLElement, _: HTMLElement, line: GrDiffLine) {
-        // Find and annotate the locations of soft hyphen (\u00AD)
-        annotateSymbols(contentEl, line, '\u00AD', 'special-char-indicator');
-        // Find and annotate Stateful Unicode directional controls
-        annotateSymbols(
-          contentEl,
-          line,
-          /[\u202A-\u202E\u2066-\u2069]/,
-          'special-char-warning'
-        );
-      },
-    };
-  }
-
-  // visible for testing
-  createTrailingWhitespaceLayer(): DiffLayer {
-    const show = () => this.showTrailingWhitespace;
-
-    return {
-      annotate(contentEl: HTMLElement, _: HTMLElement, line: GrDiffLine) {
-        if (!show()) {
-          return;
-        }
-
-        const match = line.text.match(TRAILING_WHITESPACE_PATTERN);
-        if (match) {
-          // Normalize string positions in case there is unicode before or
-          // within the match.
-          const index = GrAnnotation.getStringLength(
-            line.text.substr(0, match.index)
-          );
-          const length = GrAnnotation.getStringLength(match[0]);
-          GrAnnotation.annotateElement(
-            contentEl,
-            index,
-            length,
-            'gr-diff trailing-whitespace'
-          );
-        }
-      },
-    };
-  }
-
-  setBlame(blame: BlameInfo[] | null) {
-    if (!this.builder) return;
-    this.builder.setBlame(blame ?? []);
-  }
-
-  updateRenderPrefs(renderPrefs: RenderPreferences) {
-    this.builder?.updateRenderPrefs(renderPrefs);
-  }
-}
diff --git a/polygerrit-ui/app/embed/diff/gr-diff-builder/gr-diff-builder-element_test.ts b/polygerrit-ui/app/embed/diff/gr-diff-builder/gr-diff-builder-element_test.ts
deleted file mode 100644
index f6f0cb3..0000000
--- a/polygerrit-ui/app/embed/diff/gr-diff-builder/gr-diff-builder-element_test.ts
+++ /dev/null
@@ -1,628 +0,0 @@
-/**
- * @license
- * Copyright 2016 Google LLC
- * SPDX-License-Identifier: Apache-2.0
- */
-import '../../../test/common-test-setup';
-import {
-  createConfig,
-  createEmptyDiff,
-} from '../../../test/test-data-generators';
-import './gr-diff-builder-element';
-import {stubBaseUrl, waitUntil} from '../../../test/test-utils';
-import {GrAnnotation} from '../gr-diff-highlight/gr-annotation';
-import {GrDiffLine} from '../gr-diff/gr-diff-line';
-import {
-  DiffContent,
-  DiffLayer,
-  DiffPreferencesInfo,
-  DiffViewMode,
-  GrDiffLineType,
-  Side,
-} from '../../../api/diff';
-import {stubRestApi} from '../../../test/test-utils';
-import {waitForEventOnce} from '../../../utils/event-util';
-import {GrDiffBuilderElement} from './gr-diff-builder-element';
-import {createDefaultDiffPrefs} from '../../../constants/constants';
-import {KeyLocations} from '../gr-diff-processor/gr-diff-processor';
-import {fixture, html, assert} from '@open-wc/testing';
-import {GrDiffRow} from './gr-diff-row';
-import {GrDiffBuilder} from './gr-diff-builder';
-import {querySelectorAll} from '../../../utils/dom-util';
-
-const DEFAULT_PREFS = createDefaultDiffPrefs();
-
-suite('gr-diff-builder tests', () => {
-  let element: GrDiffBuilderElement;
-  let builder: GrDiffBuilder;
-  let diffTable: HTMLTableElement;
-
-  const setBuilderPrefs = (prefs: Partial<DiffPreferencesInfo>) => {
-    builder = new GrDiffBuilder(
-      createEmptyDiff(),
-      {...createDefaultDiffPrefs(), ...prefs},
-      diffTable
-    );
-  };
-
-  const line = (text: string) => {
-    const line = new GrDiffLine(GrDiffLineType.BOTH);
-    line.text = text;
-    return line;
-  };
-
-  setup(async () => {
-    diffTable = await fixture(html`<table id="diffTable"></table>`);
-    element = new GrDiffBuilderElement();
-    element.diffElement = diffTable;
-    stubRestApi('getLoggedIn').returns(Promise.resolve(false));
-    stubRestApi('getProjectConfig').returns(Promise.resolve(createConfig()));
-    stubBaseUrl('/r');
-    setBuilderPrefs({});
-  });
-
-  [DiffViewMode.UNIFIED, DiffViewMode.SIDE_BY_SIDE].forEach(mode => {
-    test(`line_length used for regular files under ${mode}`, () => {
-      element.path = '/a.txt';
-      element.viewMode = mode;
-      element.diff = createEmptyDiff();
-      element.prefs = {
-        ...createDefaultDiffPrefs(),
-        tab_size: 4,
-        line_length: 50,
-      };
-      builder = element.getDiffBuilder();
-      assert.equal(builder.prefs.line_length, 50);
-    });
-
-    test(`line_length ignored for commit msg under ${mode}`, () => {
-      element.path = '/COMMIT_MSG';
-      element.viewMode = mode;
-      element.diff = createEmptyDiff();
-      element.prefs = {
-        ...createDefaultDiffPrefs(),
-        tab_size: 4,
-        line_length: 50,
-      };
-      builder = element.getDiffBuilder();
-      assert.equal(builder.prefs.line_length, 72);
-    });
-  });
-
-  test('_handlePreferenceError throws with invalid preference', () => {
-    element.prefs = {...createDefaultDiffPrefs(), tab_size: 0};
-    assert.throws(() => element.getDiffBuilder());
-  });
-
-  test('_handlePreferenceError triggers alert and javascript error', () => {
-    const errorStub = sinon.stub();
-    diffTable.addEventListener('show-alert', errorStub);
-    assert.throws(() => element.handlePreferenceError('tab size'));
-    assert.equal(
-      errorStub.lastCall.args[0].detail.message,
-      "The value of the 'tab size' user preference is invalid. " +
-        'Fix in diff preferences'
-    );
-  });
-
-  suite('intraline differences', () => {
-    let el: HTMLElement;
-    let str: string;
-    let annotateElementSpy: sinon.SinonSpy;
-    let layer: DiffLayer;
-    const lineNumberEl = document.createElement('td');
-
-    function slice(str: string, start: number, end?: number) {
-      return Array.from(str).slice(start, end).join('');
-    }
-
-    setup(async () => {
-      el = await fixture(html`
-        <div>Lorem ipsum dolor sit amet, suspendisse inceptos vehicula</div>
-      `);
-      str = el.textContent ?? '';
-      annotateElementSpy = sinon.spy(GrAnnotation, 'annotateElement');
-      layer = element.createIntralineLayer();
-    });
-
-    test('annotate no highlights', () => {
-      layer.annotate(el, lineNumberEl, line(str), Side.LEFT);
-
-      // The content is unchanged.
-      assert.isFalse(annotateElementSpy.called);
-      assert.equal(el.childNodes.length, 1);
-      assert.instanceOf(el.childNodes[0], Text);
-      assert.equal(str, el.childNodes[0].textContent);
-    });
-
-    test('annotate with highlights', () => {
-      const l = line(str);
-      l.highlights = [
-        {contentIndex: 0, startIndex: 6, endIndex: 12},
-        {contentIndex: 0, startIndex: 18, endIndex: 22},
-      ];
-      const str0 = slice(str, 0, 6);
-      const str1 = slice(str, 6, 12);
-      const str2 = slice(str, 12, 18);
-      const str3 = slice(str, 18, 22);
-      const str4 = slice(str, 22);
-
-      layer.annotate(el, lineNumberEl, l, Side.LEFT);
-
-      assert.isTrue(annotateElementSpy.called);
-      assert.equal(el.childNodes.length, 5);
-
-      assert.instanceOf(el.childNodes[0], Text);
-      assert.equal(el.childNodes[0].textContent, str0);
-
-      assert.notInstanceOf(el.childNodes[1], Text);
-      assert.equal(el.childNodes[1].textContent, str1);
-
-      assert.instanceOf(el.childNodes[2], Text);
-      assert.equal(el.childNodes[2].textContent, str2);
-
-      assert.notInstanceOf(el.childNodes[3], Text);
-      assert.equal(el.childNodes[3].textContent, str3);
-
-      assert.instanceOf(el.childNodes[4], Text);
-      assert.equal(el.childNodes[4].textContent, str4);
-    });
-
-    test('annotate without endIndex', () => {
-      const l = line(str);
-      l.highlights = [{contentIndex: 0, startIndex: 28}];
-
-      const str0 = slice(str, 0, 28);
-      const str1 = slice(str, 28);
-
-      layer.annotate(el, lineNumberEl, l, Side.LEFT);
-
-      assert.isTrue(annotateElementSpy.called);
-      assert.equal(el.childNodes.length, 2);
-
-      assert.instanceOf(el.childNodes[0], Text);
-      assert.equal(el.childNodes[0].textContent, str0);
-
-      assert.notInstanceOf(el.childNodes[1], Text);
-      assert.equal(el.childNodes[1].textContent, str1);
-    });
-
-    test('annotate ignores empty highlights', () => {
-      const l = line(str);
-      l.highlights = [{contentIndex: 0, startIndex: 28, endIndex: 28}];
-
-      layer.annotate(el, lineNumberEl, l, Side.LEFT);
-
-      assert.isFalse(annotateElementSpy.called);
-      assert.equal(el.childNodes.length, 1);
-    });
-
-    test('annotate handles unicode', () => {
-      // Put some unicode into the string:
-      str = str.replace(/\s/g, '💢');
-      el.textContent = str;
-      const l = line(str);
-      l.highlights = [{contentIndex: 0, startIndex: 6, endIndex: 12}];
-
-      const str0 = slice(str, 0, 6);
-      const str1 = slice(str, 6, 12);
-      const str2 = slice(str, 12);
-
-      layer.annotate(el, lineNumberEl, l, Side.LEFT);
-
-      assert.isTrue(annotateElementSpy.called);
-      assert.equal(el.childNodes.length, 3);
-
-      assert.instanceOf(el.childNodes[0], Text);
-      assert.equal(el.childNodes[0].textContent, str0);
-
-      assert.notInstanceOf(el.childNodes[1], Text);
-      assert.equal(el.childNodes[1].textContent, str1);
-
-      assert.instanceOf(el.childNodes[2], Text);
-      assert.equal(el.childNodes[2].textContent, str2);
-    });
-
-    test('annotate handles unicode w/o endIndex', () => {
-      // Put some unicode into the string:
-      str = str.replace(/\s/g, '💢');
-      el.textContent = str;
-
-      const l = line(str);
-      l.highlights = [{contentIndex: 0, startIndex: 6}];
-
-      const str0 = slice(str, 0, 6);
-      const str1 = slice(str, 6);
-      const numHighlightedChars = GrAnnotation.getStringLength(str1);
-
-      layer.annotate(el, lineNumberEl, l, Side.LEFT);
-
-      assert.isTrue(annotateElementSpy.calledWith(el, 6, numHighlightedChars));
-      assert.equal(el.childNodes.length, 2);
-
-      assert.instanceOf(el.childNodes[0], Text);
-      assert.equal(el.childNodes[0].textContent, str0);
-
-      assert.notInstanceOf(el.childNodes[1], Text);
-      assert.equal(el.childNodes[1].textContent, str1);
-    });
-  });
-
-  suite('tab indicators', () => {
-    let layer: DiffLayer;
-    const lineNumberEl = document.createElement('td');
-
-    setup(() => {
-      element.showTabs = true;
-      layer = element.createTabIndicatorLayer();
-    });
-
-    test('does nothing with empty line', () => {
-      const l = line('');
-      const el = document.createElement('div');
-      const annotateElementStub = sinon.stub(GrAnnotation, 'annotateElement');
-
-      layer.annotate(el, lineNumberEl, l, Side.LEFT);
-
-      assert.isFalse(annotateElementStub.called);
-    });
-
-    test('does nothing with no tabs', () => {
-      const str = 'lorem ipsum no tabs';
-      const l = line(str);
-      const el = document.createElement('div');
-      el.textContent = str;
-      const annotateElementStub = sinon.stub(GrAnnotation, 'annotateElement');
-
-      layer.annotate(el, lineNumberEl, l, Side.LEFT);
-
-      assert.isFalse(annotateElementStub.called);
-    });
-
-    test('annotates tab at beginning', () => {
-      const str = '\tlorem upsum';
-      const l = line(str);
-      const el = document.createElement('div');
-      el.textContent = str;
-      const annotateElementStub = sinon.stub(GrAnnotation, 'annotateElement');
-
-      layer.annotate(el, lineNumberEl, l, Side.LEFT);
-
-      assert.equal(annotateElementStub.callCount, 1);
-      const args = annotateElementStub.getCalls()[0].args;
-      assert.equal(args[0], el);
-      assert.equal(args[1], 0, 'offset of tab indicator');
-      assert.equal(args[2], 1, 'length of tab indicator');
-      assert.include(args[3], 'tab-indicator');
-    });
-
-    test('does not annotate when disabled', () => {
-      element.showTabs = false;
-
-      const str = '\tlorem upsum';
-      const l = line(str);
-      const el = document.createElement('div');
-      el.textContent = str;
-      const annotateElementStub = sinon.stub(GrAnnotation, 'annotateElement');
-
-      layer.annotate(el, lineNumberEl, l, Side.LEFT);
-
-      assert.isFalse(annotateElementStub.called);
-    });
-
-    test('annotates multiple in beginning', () => {
-      const str = '\t\tlorem upsum';
-      const l = line(str);
-      const el = document.createElement('div');
-      el.textContent = str;
-      const annotateElementStub = sinon.stub(GrAnnotation, 'annotateElement');
-
-      layer.annotate(el, lineNumberEl, l, Side.LEFT);
-
-      assert.equal(annotateElementStub.callCount, 2);
-
-      let args = annotateElementStub.getCalls()[0].args;
-      assert.equal(args[0], el);
-      assert.equal(args[1], 0, 'offset of tab indicator');
-      assert.equal(args[2], 1, 'length of tab indicator');
-      assert.include(args[3], 'tab-indicator');
-
-      args = annotateElementStub.getCalls()[1].args;
-      assert.equal(args[0], el);
-      assert.equal(args[1], 1, 'offset of tab indicator');
-      assert.equal(args[2], 1, 'length of tab indicator');
-      assert.include(args[3], 'tab-indicator');
-    });
-
-    test('annotates intermediate tabs', () => {
-      const str = 'lorem\tupsum';
-      const l = line(str);
-      const el = document.createElement('div');
-      el.textContent = str;
-      const annotateElementStub = sinon.stub(GrAnnotation, 'annotateElement');
-
-      layer.annotate(el, lineNumberEl, l, Side.LEFT);
-
-      assert.equal(annotateElementStub.callCount, 1);
-      const args = annotateElementStub.getCalls()[0].args;
-      assert.equal(args[0], el);
-      assert.equal(args[1], 5, 'offset of tab indicator');
-      assert.equal(args[2], 1, 'length of tab indicator');
-      assert.include(args[3], 'tab-indicator');
-    });
-  });
-
-  suite('layers', () => {
-    let initialLayersCount = 0;
-    let withLayerCount = 0;
-    setup(() => {
-      const layers: DiffLayer[] = [];
-      element.layers = layers;
-      element.showTrailingWhitespace = true;
-      element.setupAnnotationLayers();
-      initialLayersCount = element.layersInternal.length;
-    });
-
-    test('no layers', () => {
-      element.setupAnnotationLayers();
-      assert.equal(element.layersInternal.length, initialLayersCount);
-    });
-
-    suite('with layers', () => {
-      const layers: DiffLayer[] = [{annotate: () => {}}, {annotate: () => {}}];
-      setup(() => {
-        element.layers = layers;
-        element.showTrailingWhitespace = true;
-        element.setupAnnotationLayers();
-        withLayerCount = element.layersInternal.length;
-      });
-      test('with layers', () => {
-        element.setupAnnotationLayers();
-        assert.equal(element.layersInternal.length, withLayerCount);
-        assert.equal(initialLayersCount + layers.length, withLayerCount);
-      });
-    });
-  });
-
-  suite('trailing whitespace', () => {
-    let layer: DiffLayer;
-    const lineNumberEl = document.createElement('td');
-
-    setup(() => {
-      element.showTrailingWhitespace = true;
-      layer = element.createTrailingWhitespaceLayer();
-    });
-
-    test('does nothing with empty line', () => {
-      const l = line('');
-      const el = document.createElement('div');
-      const annotateElementStub = sinon.stub(GrAnnotation, 'annotateElement');
-      layer.annotate(el, lineNumberEl, l, Side.LEFT);
-      assert.isFalse(annotateElementStub.called);
-    });
-
-    test('does nothing with no trailing whitespace', () => {
-      const str = 'lorem ipsum blah blah';
-      const l = line(str);
-      const el = document.createElement('div');
-      el.textContent = str;
-      const annotateElementStub = sinon.stub(GrAnnotation, 'annotateElement');
-      layer.annotate(el, lineNumberEl, l, Side.LEFT);
-      assert.isFalse(annotateElementStub.called);
-    });
-
-    test('annotates trailing spaces', () => {
-      const str = 'lorem ipsum   ';
-      const l = line(str);
-      const el = document.createElement('div');
-      el.textContent = str;
-      const annotateElementStub = sinon.stub(GrAnnotation, 'annotateElement');
-      layer.annotate(el, lineNumberEl, l, Side.LEFT);
-      assert.isTrue(annotateElementStub.called);
-      assert.equal(annotateElementStub.lastCall.args[1], 11);
-      assert.equal(annotateElementStub.lastCall.args[2], 3);
-    });
-
-    test('annotates trailing tabs', () => {
-      const str = 'lorem ipsum\t\t\t';
-      const l = line(str);
-      const el = document.createElement('div');
-      el.textContent = str;
-      const annotateElementStub = sinon.stub(GrAnnotation, 'annotateElement');
-      layer.annotate(el, lineNumberEl, l, Side.LEFT);
-      assert.isTrue(annotateElementStub.called);
-      assert.equal(annotateElementStub.lastCall.args[1], 11);
-      assert.equal(annotateElementStub.lastCall.args[2], 3);
-    });
-
-    test('annotates mixed trailing whitespace', () => {
-      const str = 'lorem ipsum\t \t';
-      const l = line(str);
-      const el = document.createElement('div');
-      el.textContent = str;
-      const annotateElementStub = sinon.stub(GrAnnotation, 'annotateElement');
-      layer.annotate(el, lineNumberEl, l, Side.LEFT);
-      assert.isTrue(annotateElementStub.called);
-      assert.equal(annotateElementStub.lastCall.args[1], 11);
-      assert.equal(annotateElementStub.lastCall.args[2], 3);
-    });
-
-    test('unicode preceding trailing whitespace', () => {
-      const str = '💢\t';
-      const l = line(str);
-      const el = document.createElement('div');
-      el.textContent = str;
-      const annotateElementStub = sinon.stub(GrAnnotation, 'annotateElement');
-      layer.annotate(el, lineNumberEl, l, Side.LEFT);
-      assert.isTrue(annotateElementStub.called);
-      assert.equal(annotateElementStub.lastCall.args[1], 1);
-      assert.equal(annotateElementStub.lastCall.args[2], 1);
-    });
-
-    test('does not annotate when disabled', () => {
-      element.showTrailingWhitespace = false;
-      const str = 'lorem upsum\t \t ';
-      const l = line(str);
-      const el = document.createElement('div');
-      el.textContent = str;
-      const annotateElementStub = sinon.stub(GrAnnotation, 'annotateElement');
-      layer.annotate(el, lineNumberEl, l, Side.LEFT);
-      assert.isFalse(annotateElementStub.called);
-    });
-  });
-
-  suite('rendering text, images and binary files', () => {
-    let keyLocations: KeyLocations;
-    let content: DiffContent[] = [];
-
-    setup(() => {
-      element.viewMode = 'SIDE_BY_SIDE';
-      keyLocations = {left: {}, right: {}};
-      element.prefs = {
-        ...DEFAULT_PREFS,
-        context: -1,
-        syntax_highlighting: true,
-      };
-      content = [
-        {
-          a: ['all work and no play make andybons a dull boy'],
-          b: ['elgoog elgoog elgoog'],
-        },
-        {
-          ab: [
-            'Non eram nescius, Brute, cum, quae summis ingeniis ',
-            'exquisitaque doctrina philosophi Graeco sermone tractavissent',
-          ],
-        },
-      ];
-    });
-
-    test('text', async () => {
-      element.diff = {...createEmptyDiff(), content};
-      element.render(keyLocations);
-      await waitForEventOnce(diffTable, 'render-content');
-      assert.equal(querySelectorAll(diffTable, 'tbody')?.length, 4);
-    });
-
-    test('image', async () => {
-      element.diff = {...createEmptyDiff(), content, binary: true};
-      element.isImageDiff = true;
-      element.render(keyLocations);
-      await waitForEventOnce(diffTable, 'render-content');
-      assert.equal(querySelectorAll(diffTable, 'tbody')?.length, 4);
-    });
-
-    test('binary', async () => {
-      element.diff = {...createEmptyDiff(), content, binary: true};
-      element.render(keyLocations);
-      await waitForEventOnce(diffTable, 'render-content');
-      assert.equal(querySelectorAll(diffTable, 'tbody')?.length, 3);
-    });
-  });
-
-  suite('context hiding and expanding', () => {
-    let dispatchStub: sinon.SinonStub;
-
-    setup(async () => {
-      dispatchStub = sinon.stub(diffTable, 'dispatchEvent');
-      element.diff = {
-        ...createEmptyDiff(),
-        content: [
-          {ab: [1, 2, 3, 4, 5, 6, 7, 8, 9, 10].map(i => `unchanged ${i}`)},
-          {a: ['before'], b: ['after']},
-          {ab: [1, 2, 3, 4, 5, 6, 7, 8, 9, 10].map(i => `unchanged ${10 + i}`)},
-        ],
-      };
-      element.viewMode = DiffViewMode.SIDE_BY_SIDE;
-
-      const keyLocations: KeyLocations = {left: {}, right: {}};
-      element.prefs = {
-        ...DEFAULT_PREFS,
-        context: 1,
-      };
-      element.render(keyLocations);
-      // Make sure all listeners are installed.
-      await element.untilGroupsRendered();
-    });
-
-    test('hides lines behind two context controls', () => {
-      const contextControls = diffTable.querySelectorAll('gr-context-controls');
-      assert.equal(contextControls.length, 2);
-
-      const diffRows = diffTable.querySelectorAll('.diff-row');
-      // The first two are LOST and FILE line
-      assert.equal(diffRows.length, 2 + 1 + 1 + 1);
-      assert.include(diffRows[2].textContent, 'unchanged 10');
-      assert.include(diffRows[3].textContent, 'before');
-      assert.include(diffRows[3].textContent, 'after');
-      assert.include(diffRows[4].textContent, 'unchanged 11');
-    });
-
-    test('clicking +x common lines expands those lines', async () => {
-      const contextControls = diffTable.querySelectorAll('gr-context-controls');
-      const topExpandCommonButton =
-        contextControls[0].shadowRoot?.querySelectorAll<HTMLElement>(
-          '.showContext'
-        )[0];
-      assert.isOk(topExpandCommonButton);
-      assert.include(topExpandCommonButton!.textContent, '+9 common lines');
-      let diffRows = diffTable.querySelectorAll('.diff-row');
-      // 5 lines:
-      // FILE, LOST, the changed line plus one line of context in each direction
-      assert.equal(diffRows.length, 5);
-
-      topExpandCommonButton!.click();
-
-      await waitUntil(() => {
-        diffRows = diffTable.querySelectorAll<GrDiffRow>('.diff-row');
-        return diffRows.length === 14;
-      });
-      // 14 lines: The 5 above plus the 9 unchanged lines that were expanded
-      assert.equal(diffRows.length, 14);
-      assert.include(diffRows[2].textContent, 'unchanged 1');
-      assert.include(diffRows[3].textContent, 'unchanged 2');
-      assert.include(diffRows[4].textContent, 'unchanged 3');
-      assert.include(diffRows[5].textContent, 'unchanged 4');
-      assert.include(diffRows[6].textContent, 'unchanged 5');
-      assert.include(diffRows[7].textContent, 'unchanged 6');
-      assert.include(diffRows[8].textContent, 'unchanged 7');
-      assert.include(diffRows[9].textContent, 'unchanged 8');
-      assert.include(diffRows[10].textContent, 'unchanged 9');
-      assert.include(diffRows[11].textContent, 'unchanged 10');
-      assert.include(diffRows[12].textContent, 'before');
-      assert.include(diffRows[12].textContent, 'after');
-      assert.include(diffRows[13].textContent, 'unchanged 11');
-    });
-
-    test('unhideLine shows the line with context', async () => {
-      dispatchStub.reset();
-      element.unhideLine(4, Side.LEFT);
-
-      await waitUntil(() => {
-        const rows = diffTable.querySelectorAll<GrDiffRow>('.diff-row');
-        return rows.length === 2 + 5 + 1 + 1 + 1;
-      });
-
-      const diffRows = diffTable.querySelectorAll('.diff-row');
-      // The first two are LOST and FILE line
-      // Lines 3-5 (Line 4 plus 1 context in each direction) will be expanded
-      // Because context expanders do not hide <3 lines, lines 1-2 will also
-      // be shown.
-      // Lines 6-9 continue to be hidden
-      assert.equal(diffRows.length, 2 + 5 + 1 + 1 + 1);
-      assert.include(diffRows[2].textContent, 'unchanged 1');
-      assert.include(diffRows[3].textContent, 'unchanged 2');
-      assert.include(diffRows[4].textContent, 'unchanged 3');
-      assert.include(diffRows[5].textContent, 'unchanged 4');
-      assert.include(diffRows[6].textContent, 'unchanged 5');
-      assert.include(diffRows[7].textContent, 'unchanged 10');
-      assert.include(diffRows[8].textContent, 'before');
-      assert.include(diffRows[8].textContent, 'after');
-      assert.include(diffRows[9].textContent, 'unchanged 11');
-
-      await element.untilGroupsRendered();
-      const firedEventTypes = dispatchStub.getCalls().map(c => c.args[0].type);
-      assert.include(firedEventTypes, 'render-content');
-    });
-  });
-});
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 edd68dc..d87d4d8 100644
--- a/polygerrit-ui/app/embed/diff/gr-diff/gr-diff.ts
+++ b/polygerrit-ui/app/embed/diff/gr-diff/gr-diff.ts
@@ -6,7 +6,6 @@
 import '../../../styles/shared-styles';
 import '../../../elements/shared/gr-button/gr-button';
 import '../../../elements/shared/gr-icon/gr-icon';
-import '../gr-diff-builder/gr-diff-builder-element';
 import '../gr-diff-highlight/gr-diff-highlight';
 import '../gr-diff-selection/gr-diff-selection';
 import '../gr-syntax-themes/gr-syntax-theme';
@@ -25,6 +24,7 @@
   getResponsiveMode,
   isResponsive,
   isNewDiff,
+  getSideByLineEl,
 } from '../gr-diff/gr-diff-utils';
 import {BlameInfo, CommentRange, ImageInfo} from '../../../types/common';
 import {DiffInfo, DiffPreferencesInfo} from '../../../types/diff';
@@ -32,18 +32,20 @@
   CreateRangeCommentEventDetail,
   GrDiffHighlight,
 } from '../gr-diff-highlight/gr-diff-highlight';
-import {
-  GrDiffBuilderElement,
-  getLineNumberCellWidth,
-} from '../gr-diff-builder/gr-diff-builder-element';
 import {CoverageRange, DiffLayer} from '../../../types/types';
-import {CommentRangeLayer} from '../gr-ranged-comment-layer/gr-ranged-comment-layer';
+import {
+  CommentRangeLayer,
+  GrRangedCommentLayer,
+} from '../gr-ranged-comment-layer/gr-ranged-comment-layer';
 import {
   createDefaultDiffPrefs,
   DiffViewMode,
   Side,
 } from '../../../constants/constants';
-import {KeyLocations} from '../gr-diff-processor/gr-diff-processor';
+import {
+  GrDiffProcessor,
+  KeyLocations,
+} from '../gr-diff-processor/gr-diff-processor';
 import {fire, fireAlert} from '../../../utils/event-util';
 import {MovedLinkClickedEvent, ValueChangedEvent} from '../../../types/events';
 import {getContentEditableRange} from '../../../utils/safari-selection-util';
@@ -76,6 +78,24 @@
 import {provide} from '../../../models/dependency';
 import {grDiffStyles} from './gr-diff-styles';
 import {getDiffLength} from '../../../utils/diff-util';
+import {GrCoverageLayer} from '../gr-coverage-layer/gr-coverage-layer';
+import {
+  GrDiffBuilder,
+  isImageDiffBuilder,
+  isBinaryDiffBuilder,
+  DiffContextExpandedEventDetail,
+} from '../gr-diff-builder/gr-diff-builder';
+import {GrDiffBuilderBinary} from '../gr-diff-builder/gr-diff-builder-binary';
+import {GrDiffBuilderImage} from '../gr-diff-builder/gr-diff-builder-image';
+import {GrAnnotation} from '../gr-diff-highlight/gr-annotation';
+import {
+  GrDiffGroup,
+  GrDiffGroupType,
+  hideInContextControl,
+} from './gr-diff-group';
+import {GrDiffLine} from './gr-diff-line';
+
+const TRAILING_WHITESPACE_PATTERN = /\s+$/;
 
 const NO_NEWLINE_LEFT = 'No newline at end of left file.';
 const NO_NEWLINE_RIGHT = 'No newline at end of right file.';
@@ -264,11 +284,40 @@
   // Private but used in tests.
   highlights = new GrDiffHighlight();
 
-  // Private but used in tests.
-  diffBuilder = new GrDiffBuilderElement();
-
   private diffModel = new DiffModel(undefined);
 
+  // visible for testing
+  builder?: GrDiffBuilder;
+
+  /**
+   * All layers, both from the outside and the default ones. See `layers` for
+   * the property that can be set from the outside.
+   */
+  // visible for testing
+  layersInternal: DiffLayer[] = [];
+
+  // visible for testing
+  showTabs?: boolean;
+
+  // visible for testing
+  showTrailingWhitespace?: boolean;
+
+  private coverageLayerLeft = new GrCoverageLayer(Side.LEFT);
+
+  private coverageLayerRight = new GrCoverageLayer(Side.RIGHT);
+
+  private rangeLayer?: GrRangedCommentLayer;
+
+  // visible for testing
+  processor?: GrDiffProcessor;
+
+  /**
+   * Groups are mostly just passed on to the diff builder (this.builder). But
+   * we also keep track of them here for being able to fire a `render-content`
+   * event when .element of each group has rendered.
+   */
+  private groups: GrDiffGroup[] = [];
+
   static override get styles() {
     return [
       iconStyles,
@@ -301,10 +350,10 @@
     if (this.diff && this.diffTable) {
       this.diffSelection.init(this.diff, this.diffTable);
     }
-    if (this.diffTable && this.diffBuilder) {
-      this.highlights.init(this.diffTable, this.diffBuilder);
+    if (this.diffTable) {
+      this.highlights.init(this.diffTable, this);
     }
-    this.diffBuilder.init();
+    this.diffBuilderInit();
   }
 
   override disconnectedCallback() {
@@ -312,7 +361,7 @@
     this.renderDiffTableTask?.cancel();
     this.diffSelection.cleanup();
     this.highlights.cleanup();
-    this.diffBuilder.cleanup();
+    this.diffBuilderCleanup();
     super.disconnectedCallback();
   }
 
@@ -340,7 +389,7 @@
       }
     }
     if (changedProperties.has('coverageRanges')) {
-      this.diffBuilder.updateCoverageRanges(this.coverageRanges);
+      this.updateCoverageRanges(this.coverageRanges);
     }
     if (changedProperties.has('lineOfInterest')) {
       this.lineOfInterestChanged();
@@ -439,10 +488,6 @@
     document.removeEventListener('mouseup', this.handleMouseUp);
   }
 
-  getLineNumEls(side: Side): HTMLElement[] {
-    return this.diffBuilder.getLineNumEls(side);
-  }
-
   // Private but used in tests.
   showNoChangeMessage() {
     return (
@@ -526,7 +571,7 @@
       });
     }
 
-    this.diffBuilder.updateCommentRanges(this.commentRanges);
+    this.updateCommentRanges(this.commentRanges);
   }
 
   /**
@@ -534,7 +579,8 @@
    * where lines should not be collapsed.
    *
    */
-  private computeKeyLocations() {
+  // visible for testing
+  computeKeyLocations() {
     const keyLocations: KeyLocations = {left: {}, right: {}};
     if (this.lineOfInterest) {
       const side = this.lineOfInterest.side;
@@ -572,7 +618,7 @@
 
   /** Cancel any remaining diff builder rendering work. */
   cancel() {
-    this.diffBuilder.cleanup();
+    this.diffBuilderCleanup();
     this.renderDiffTableTask?.cancel();
   }
 
@@ -580,8 +626,7 @@
     if (this.hidden && this.noAutoRender) return [];
 
     // Get rendered stops.
-    const stops: Array<HTMLElement | AbortStop> =
-      this.diffBuilder.getLineNumberRows();
+    const stops: Array<HTMLElement | AbortStop> = this.getLineNumberRows();
 
     // If we are still loading this diff, abort after the rendered stops to
     // avoid skipping over to e.g. the next file.
@@ -600,7 +645,7 @@
   }
 
   private blameChanged() {
-    this.diffBuilder.setBlame(this.blame);
+    this.setBlame(this.blame);
     if (this.blame) {
       this.classList.add('showBlame');
     } else {
@@ -669,7 +714,7 @@
 
   createCommentForSelection(side: Side, range: CommentRange) {
     const lineNum = range.end_line;
-    const lineEl = this.diffBuilder.getLineElByNumber(lineNum, side);
+    const lineEl = this.getLineElByNumber(lineNum, side);
     if (lineEl) {
       this.createComment(lineEl, lineNum, side, range);
     }
@@ -690,7 +735,7 @@
     side?: Side,
     range?: CommentRange
   ) {
-    const contentEl = this.diffBuilder.getContentTdByLineEl(lineEl);
+    const contentEl = this.getContentTdByLineEl(lineEl);
     if (!contentEl) throw new Error('content el not found for line el');
     side = side ?? this.getCommentSideByLineAndContent(lineEl, contentEl);
     fire(this, 'create-comment', {
@@ -715,7 +760,7 @@
     if (!this.lineOfInterest) return;
     const lineNum = this.lineOfInterest.lineNum;
     if (typeof lineNum !== 'number') return;
-    this.diffBuilder.unhideLine(lineNum, this.lineOfInterest.side);
+    this.unhideLine(lineNum, this.lineOfInterest.side);
   }
 
   private cleanup() {
@@ -815,7 +860,7 @@
     if (this.prefs) {
       this.updatePreferenceStyles();
     }
-    this.diffBuilder.updateRenderPrefs(this.renderPrefs);
+    this.updateRenderPrefs(this.renderPrefs);
   }
 
   private diffChanged() {
@@ -826,7 +871,7 @@
       this.debounceRenderDiffTable();
       assertIsDefined(this.diffTable, 'diffTable');
       this.diffSelection.init(this.diff, this.diffTable);
-      this.highlights.init(this.diffTable, this.diffBuilder);
+      this.highlights.init(this.diffTable, this);
     }
   }
 
@@ -871,7 +916,7 @@
       return;
     }
     if (
-      this.prefs.context === -1 &&
+      this.getBypassPrefs().context === -1 &&
       this.diffLength &&
       this.diffLength >= LARGE_DIFF_THRESHOLD_LINES &&
       this.safetyBypass === null
@@ -883,8 +928,6 @@
 
     this.showWarning = false;
 
-    const keyLocations = this.computeKeyLocations();
-
     this.diffModel.setState({
       diff: this.diff,
       path: this.path,
@@ -892,25 +935,9 @@
       diffPrefs: this.prefs,
     });
 
-    // TODO: Setting tons of public properties like this is obviously a code
-    // smell. We are introducing a diff model for managing all this
-    // data. Then diff builder will only need access to that model.
-    this.diffBuilder.prefs = this.getBypassPrefs();
-    this.diffBuilder.renderPrefs = this.renderPrefs;
-    this.diffBuilder.diff = this.diff;
-    this.diffBuilder.path = this.path;
-    this.diffBuilder.viewMode = this.viewMode;
-    this.diffBuilder.layers = this.layers ?? [];
-    this.diffBuilder.isImageDiff = this.isImageDiff;
-    this.diffBuilder.baseImage = this.baseImage ?? null;
-    this.diffBuilder.revisionImage = this.revisionImage ?? null;
-    this.diffBuilder.useNewImageDiffUi = this.useNewImageDiffUi;
-    this.diffBuilder.diffElement = this.diffTable;
-    // `this.commentRanges` are probably empty here, because they will only be
-    // populated by the node observer, which starts observing *after* rendering.
-    this.diffBuilder.updateCommentRanges(this.commentRanges);
-    this.diffBuilder.updateCoverageRanges(this.coverageRanges);
-    await this.diffBuilder.render(keyLocations);
+    this.updateCommentRanges(this.commentRanges);
+    this.updateCoverageRanges(this.coverageRanges);
+    await this.legacyRender();
   }
 
   private handleRenderContent() {
@@ -959,7 +986,7 @@
       const commentSide = getSide(threadEl);
       const range = getRange(threadEl);
       if (!commentSide) continue;
-      const lineEl = this.diffBuilder.getLineElByNumber(lineNum, commentSide);
+      const lineEl = this.getLineElByNumber(lineNum, commentSide);
       // When the line the comment refers to does not exist, log an error
       // but don't crash. This can happen e.g. if the API does not fully
       // validate e.g. (robot) comments
@@ -972,7 +999,7 @@
         );
         continue;
       }
-      const contentEl = this.diffBuilder.getContentTdByLineEl(lineEl);
+      const contentEl = this.getContentTdByLineEl(lineEl);
       if (!contentEl) continue;
       if (lineNum === LOST) {
         this.insertPortedCommentsWithoutRangeMessage(contentEl);
@@ -1029,7 +1056,8 @@
   /**
    * Get the preferences object including the safety bypass context (if any).
    */
-  private getBypassPrefs() {
+  // visible for testing
+  getBypassPrefs() {
     assertIsDefined(this.prefs, 'prefs');
     if (this.safetyBypass !== null) {
       return {...this.prefs, context: this.safetyBypass};
@@ -1040,9 +1068,7 @@
   clearDiffContent() {
     this.unobserveNodes();
     if (!this.diffTable) return;
-    while (this.diffTable.hasChildNodes()) {
-      this.diffTable.removeChild(this.diffTable.lastChild!);
-    }
+    this.diffTable.innerHTML = '';
   }
 
   // Private but used in tests.
@@ -1099,6 +1125,425 @@
     }
     return messages.join(' \u2014 '); // \u2014 - '—'
   }
+
+  private updateCommentRanges(ranges: CommentRangeLayer[]) {
+    this.rangeLayer?.updateRanges(ranges);
+  }
+
+  private updateCoverageRanges(rs: CoverageRange[]) {
+    this.coverageLayerLeft.setRanges(rs.filter(r => r?.side === Side.LEFT));
+    this.coverageLayerRight.setRanges(rs.filter(r => r?.side === Side.RIGHT));
+  }
+
+  legacyRender(): Promise<void> {
+    assertIsDefined(this.diff, 'diff');
+    assertIsDefined(this.diffTable, 'diff table');
+    assertIsDefined(this.prefs, 'prefs');
+
+    // Setting up annotation layers must happen after plugins are
+    // installed, and |render| satisfies the requirement, however,
+    // |attached| doesn't because in the diff view page, the element is
+    // attached before plugins are installed.
+    this.setupAnnotationLayers();
+
+    this.showTabs = this.prefs.show_tabs;
+    this.showTrailingWhitespace = this.prefs.show_whitespace_errors;
+
+    this.diffBuilderCleanup();
+    this.builder = this.getDiffBuilder();
+    this.diffBuilderInit();
+
+    // TODO: Just pass along the diff model here instead of setting many
+    // individual properties.
+    this.processor = new GrDiffProcessor();
+    this.processor.consumer = this;
+    this.processor.context = this.getBypassPrefs().context;
+    this.processor.keyLocations = this.computeKeyLocations();
+    if (this.renderPrefs?.num_lines_rendered_at_once) {
+      this.processor.asyncThreshold =
+        this.renderPrefs.num_lines_rendered_at_once;
+    }
+
+    this.diffTable.innerHTML = '';
+    this.builder.addColumns(this.diffTable, getLineNumberCellWidth(this.prefs));
+
+    const isBinary = !!(this.isImageDiff || this.diff.binary);
+
+    fire(this.diffTable, 'render-start', {});
+    return (
+      this.processor
+        .process(this.diff.content, isBinary)
+        .then(async () => {
+          if (isImageDiffBuilder(this.builder)) {
+            this.builder.renderImageDiff();
+          } else if (isBinaryDiffBuilder(this.builder)) {
+            this.builder.renderBinaryDiff();
+          }
+          await this.untilGroupsRendered();
+          fire(this.diffTable, 'render-content', {});
+        })
+        // Mocha testing does not like uncaught rejections, so we catch
+        // the cancels which are expected and should not throw errors in
+        // tests.
+        .catch(e => {
+          if (!e.isCanceled) return Promise.reject(e);
+          return;
+        })
+    );
+  }
+
+  // visible for testing
+  async untilGroupsRendered(groups: readonly GrDiffGroup[] = this.groups) {
+    return Promise.all(groups.map(g => g.waitUntilRendered()));
+  }
+
+  private onDiffContextExpanded = (
+    e: CustomEvent<DiffContextExpandedEventDetail>
+  ) => {
+    // Don't stop propagation. The host may listen for reporting or
+    // resizing.
+    this.replaceGroup(e.detail.contextGroup, e.detail.groups);
+  };
+
+  // visible for testing
+  setupAnnotationLayers() {
+    this.rangeLayer = new GrRangedCommentLayer();
+
+    const layers: DiffLayer[] = [
+      this.createTrailingWhitespaceLayer(),
+      this.createIntralineLayer(),
+      this.createTabIndicatorLayer(),
+      this.createSpecialCharacterIndicatorLayer(),
+      this.rangeLayer,
+      this.coverageLayerLeft,
+      this.coverageLayerRight,
+    ];
+
+    if (this.layers) {
+      layers.push(...this.layers);
+    }
+    this.layersInternal = layers;
+  }
+
+  getContentTdByLine(lineNumber: LineNumber, side?: Side) {
+    if (!this.builder) return undefined;
+    return this.builder.getContentTdByLine(lineNumber, side);
+  }
+
+  getContentTdByLineEl(lineEl?: Element): Element | undefined {
+    if (!lineEl) return undefined;
+    const line = getLineNumber(lineEl);
+    if (!line) return undefined;
+    const side = getSideByLineEl(lineEl);
+    return this.getContentTdByLine(line, side);
+  }
+
+  getLineElByNumber(lineNumber: LineNumber, side?: Side) {
+    if (!this.builder) return undefined;
+    return this.builder.getLineElByNumber(lineNumber, side);
+  }
+
+  getLineNumberRows() {
+    if (!this.builder) return [];
+    return this.builder.getLineNumberRows();
+  }
+
+  getLineNumEls(side: Side) {
+    if (!this.builder) return [];
+    return this.builder.getLineNumEls(side);
+  }
+
+  /**
+   * When the line is hidden behind a context expander, expand it.
+   *
+   * @param lineNum A line number to expand. Using number here because other
+   *   special case line numbers are never hidden, so it does not make sense
+   *   to expand them.
+   * @param side The side the line number refer to.
+   */
+  unhideLine(lineNum: number, side: Side) {
+    assertIsDefined(this.prefs, 'prefs');
+    if (!this.builder) return;
+    const group = this.builder.findGroup(side, lineNum);
+    // Cannot unhide a line that is not part of the diff.
+    if (!group) return;
+    // If it's already visible, great!
+    if (group.type !== GrDiffGroupType.CONTEXT_CONTROL) return;
+    const lineRange = group.lineRange[side];
+    const lineOffset = lineNum - lineRange.start_line;
+    const newGroups = [];
+    const groups = hideInContextControl(
+      group.contextGroups,
+      0,
+      lineOffset - 1 - this.prefs.context
+    );
+    // If there is a context group, it will be the first group because we
+    // start hiding from 0 offset
+    if (groups[0].type === GrDiffGroupType.CONTEXT_CONTROL) {
+      newGroups.push(groups.shift()!);
+    }
+    newGroups.push(
+      ...hideInContextControl(
+        groups,
+        lineOffset + 1 + this.prefs.context,
+        // Both ends inclusive, so difference is the offset of the last line.
+        // But we need to pass the first line not to hide, which is the element
+        // after.
+        lineRange.end_line - lineRange.start_line + 1
+      )
+    );
+    this.replaceGroup(group, newGroups);
+  }
+
+  /**
+   * Replace the group of a context control section by rendering the provided
+   * groups instead. This happens in response to expanding a context control
+   * group.
+   *
+   * @param contextGroup The context control group to replace
+   * @param newGroups The groups that are replacing the context control group
+   */
+  private replaceGroup(
+    contextGroup: GrDiffGroup,
+    newGroups: readonly GrDiffGroup[]
+  ) {
+    if (!this.builder) return;
+    fire(this.diffTable, 'render-start', {});
+    this.builder.replaceGroup(contextGroup, newGroups);
+    this.groups = this.groups.filter(g => g !== contextGroup);
+    this.groups.push(...newGroups);
+    this.untilGroupsRendered(newGroups).then(() => {
+      fire(this.diffTable, 'render-content', {});
+    });
+  }
+
+  /**
+   * This is meant to be called when the gr-diff component re-connects, or when
+   * the diff is (re-)rendered.
+   *
+   * Make sure that this method is symmetric with cleanup(), which is called
+   * when gr-diff disconnects.
+   */
+  private diffBuilderInit() {
+    this.cleanup();
+    this.diffTable?.addEventListener(
+      'diff-context-expanded-internal-new',
+      this.onDiffContextExpanded
+    );
+    this.builder?.init();
+  }
+
+  /**
+   * This is meant to be called when the gr-diff component disconnects, or when
+   * the diff is (re-)rendered.
+   *
+   * Make sure that this method is symmetric with init(), which is called when
+   * gr-diff re-connects.
+   */
+  private diffBuilderCleanup() {
+    this.processor?.cancel();
+    this.builder?.cleanup();
+    this.diffTable?.removeEventListener(
+      'diff-context-expanded-internal-new',
+      this.onDiffContextExpanded
+    );
+  }
+
+  // visible for testing
+  handlePreferenceError(pref: string): never {
+    const message =
+      `The value of the '${pref}' user preference is ` +
+      'invalid. Fix in diff preferences';
+    assertIsDefined(this.diffTable, 'diff table');
+    fireAlert(this.diffTable, message);
+    throw Error(`Invalid preference value: ${pref}`);
+  }
+
+  // visible for testing
+  getDiffBuilder(): GrDiffBuilder {
+    assertIsDefined(this.diff, 'diff');
+    assertIsDefined(this.diffTable, 'diff table');
+    assertIsDefined(this.prefs, 'prefs');
+    if (isNaN(this.prefs.tab_size) || this.prefs.tab_size <= 0) {
+      this.handlePreferenceError('tab size');
+    }
+
+    if (isNaN(this.prefs.line_length) || this.prefs.line_length <= 0) {
+      this.handlePreferenceError('diff width');
+    }
+
+    const localPrefs = {...this.prefs};
+    if (this.path === COMMIT_MSG_PATH) {
+      // override line_length for commit msg the same way as
+      // in gr-diff
+      localPrefs.line_length = COMMIT_MSG_LINE_LENGTH;
+    }
+
+    let builder = null;
+    if (this.isImageDiff) {
+      builder = new GrDiffBuilderImage(
+        this.diff,
+        localPrefs,
+        this.diffTable,
+        this.baseImage ?? null,
+        this.revisionImage ?? null,
+        this.renderPrefs,
+        this.useNewImageDiffUi
+      );
+    } else if (this.diff.binary) {
+      return new GrDiffBuilderBinary(this.diff, localPrefs, this.diffTable);
+    } else if (this.viewMode === DiffViewMode.SIDE_BY_SIDE) {
+      this.renderPrefs = {
+        ...this.renderPrefs,
+        view_mode: DiffViewMode.SIDE_BY_SIDE,
+      };
+      builder = new GrDiffBuilder(
+        this.diff,
+        localPrefs,
+        this.diffTable,
+        this.layersInternal,
+        this.renderPrefs
+      );
+    } else if (this.viewMode === DiffViewMode.UNIFIED) {
+      this.renderPrefs = {
+        ...this.renderPrefs,
+        view_mode: DiffViewMode.UNIFIED,
+      };
+      builder = new GrDiffBuilder(
+        this.diff,
+        localPrefs,
+        this.diffTable,
+        this.layersInternal,
+        this.renderPrefs
+      );
+    }
+    if (!builder) {
+      throw Error(`Unsupported diff view mode: ${this.viewMode}`);
+    }
+    return builder;
+  }
+
+  /**
+   * Called when the processor starts converting the diff information from the
+   * server into chunks.
+   */
+  clearGroups() {
+    if (!this.builder) return;
+    this.groups = [];
+    this.builder.clearGroups();
+  }
+
+  /**
+   * Called when the processor is done converting a chunk of the diff.
+   */
+  addGroup(group: GrDiffGroup) {
+    if (!this.builder) return;
+    this.builder.addGroups([group]);
+    this.groups.push(group);
+  }
+
+  // visible for testing
+  createIntralineLayer(): DiffLayer {
+    return {
+      // Take a DIV.contentText element and a line object with intraline
+      // differences to highlight and apply them to the element as
+      // annotations.
+      annotate(contentEl: HTMLElement, _: HTMLElement, line: GrDiffLine) {
+        const HL_CLASS = 'gr-diff intraline';
+        for (const highlight of line.highlights) {
+          // The start and end indices could be the same if a highlight is
+          // meant to start at the end of a line and continue onto the
+          // next one. Ignore it.
+          if (highlight.startIndex === highlight.endIndex) {
+            continue;
+          }
+
+          // If endIndex isn't present, continue to the end of the line.
+          const endIndex =
+            highlight.endIndex === undefined
+              ? GrAnnotation.getStringLength(line.text)
+              : highlight.endIndex;
+
+          GrAnnotation.annotateElement(
+            contentEl,
+            highlight.startIndex,
+            endIndex - highlight.startIndex,
+            HL_CLASS
+          );
+        }
+      },
+    };
+  }
+
+  // visible for testing
+  createTabIndicatorLayer(): DiffLayer {
+    const show = () => this.showTabs;
+    return {
+      annotate(contentEl: HTMLElement, _: HTMLElement, line: GrDiffLine) {
+        // If visible tabs are disabled, do nothing.
+        if (!show()) {
+          return;
+        }
+
+        // Find and annotate the locations of tabs.
+        annotateSymbols(contentEl, line, '\t', 'tab-indicator');
+      },
+    };
+  }
+
+  private createSpecialCharacterIndicatorLayer(): DiffLayer {
+    return {
+      annotate(contentEl: HTMLElement, _: HTMLElement, line: GrDiffLine) {
+        // Find and annotate the locations of soft hyphen (\u00AD)
+        annotateSymbols(contentEl, line, '\u00AD', 'special-char-indicator');
+        // Find and annotate Stateful Unicode directional controls
+        annotateSymbols(
+          contentEl,
+          line,
+          /[\u202A-\u202E\u2066-\u2069]/,
+          'special-char-warning'
+        );
+      },
+    };
+  }
+
+  // visible for testing
+  createTrailingWhitespaceLayer(): DiffLayer {
+    const show = () => this.showTrailingWhitespace;
+
+    return {
+      annotate(contentEl: HTMLElement, _: HTMLElement, line: GrDiffLine) {
+        if (!show()) {
+          return;
+        }
+
+        const match = line.text.match(TRAILING_WHITESPACE_PATTERN);
+        if (match) {
+          // Normalize string positions in case there is unicode before or
+          // within the match.
+          const index = GrAnnotation.getStringLength(
+            line.text.substr(0, match.index)
+          );
+          const length = GrAnnotation.getStringLength(match[0]);
+          GrAnnotation.annotateElement(
+            contentEl,
+            index,
+            length,
+            'gr-diff trailing-whitespace'
+          );
+        }
+      },
+    };
+  }
+
+  setBlame(blame: BlameInfo[] | null) {
+    if (!this.builder) return;
+    this.builder.setBlame(blame ?? []);
+  }
+
+  updateRenderPrefs(renderPrefs: RenderPreferences) {
+    this.builder?.updateRenderPrefs(renderPrefs);
+  }
 }
 
 function extractAddedNodes(mutations: MutationRecord[]) {
@@ -1109,6 +1554,30 @@
   return mutations.flatMap(mutation => [...mutation.removedNodes]);
 }
 
+function getLineNumberCellWidth(prefs: DiffPreferencesInfo) {
+  return prefs.font_size * 4;
+}
+
+function annotateSymbols(
+  contentEl: HTMLElement,
+  line: GrDiffLine,
+  separator: string | RegExp,
+  className: string
+) {
+  const split = line.text.split(separator);
+  if (!split || split.length < 2) {
+    return;
+  }
+  for (let i = 0, pos = 0; i < split.length - 1; i++) {
+    // Skip forward by the length of the content
+    pos += split[i].length;
+
+    GrAnnotation.annotateElement(contentEl, pos, 1, `gr-diff ${className}`);
+
+    pos++;
+  }
+}
+
 // TODO(newdiff-cleanup): Remove once newdiff migration is completed.
 if (isNewDiff()) {
   customElements.define('gr-diff', GrDiff);
@@ -1124,5 +1593,15 @@
     'comment-thread-mouseleave': CustomEvent<{}>;
     'loading-changed': ValueChangedEvent<boolean>;
     'render-required': CustomEvent<{}>;
+    /**
+     * Fired when the diff begins rendering - both for full renders and for
+     * partial rerenders.
+     */
+    'render-start': CustomEvent<{}>;
+    /**
+     * Fired when the diff finishes rendering text content - both for full
+     * renders and for partial rerenders.
+     */
+    'render-content': CustomEvent<{}>;
   }
 }
diff --git a/polygerrit-ui/app/embed/diff/gr-diff/gr-diff_test.ts b/polygerrit-ui/app/embed/diff/gr-diff/gr-diff_test.ts
index f0826ad..d542633 100644
--- a/polygerrit-ui/app/embed/diff/gr-diff/gr-diff_test.ts
+++ b/polygerrit-ui/app/embed/diff/gr-diff/gr-diff_test.ts
@@ -4,15 +4,21 @@
  * SPDX-License-Identifier: Apache-2.0
  */
 import '../../../test/common-test-setup';
-import {createDiff} from '../../../test/test-data-generators';
+import {
+  createConfig,
+  createDiff,
+  createEmptyDiff,
+} from '../../../test/test-data-generators';
 import './gr-diff';
-import {getComputedStyleValue} from '../../../utils/dom-util';
+import {getComputedStyleValue, querySelectorAll} from '../../../utils/dom-util';
 import '@polymer/paper-button/paper-button';
 import {
   DiffContent,
   DiffInfo,
+  DiffLayer,
   DiffPreferencesInfo,
   DiffViewMode,
+  GrDiffLineType,
   IgnoreWhitespaceType,
   Side,
 } from '../../../api/diff';
@@ -22,6 +28,8 @@
   query,
   queryAll,
   queryAndAssert,
+  stubBaseUrl,
+  stubRestApi,
   waitEventLoop,
   waitQueryAndAssert,
   waitUntil,
@@ -33,6 +41,13 @@
 import {GrRangedCommentHint} from '../gr-ranged-comment-hint/gr-ranged-comment-hint';
 import {assertIsDefined} from '../../../utils/common-util';
 import {fixture, html, assert} from '@open-wc/testing';
+import {createDefaultDiffPrefs} from '../../../constants/constants';
+import {GrDiffBuilder} from '../gr-diff-builder/gr-diff-builder';
+import {GrDiffRow} from '../gr-diff-builder/gr-diff-row';
+import {GrAnnotation} from '../gr-diff-highlight/gr-annotation';
+import {GrDiffLine} from './gr-diff-line';
+
+const DEFAULT_PREFS = createDefaultDiffPrefs();
 
 suite('gr-diff a11y test', () => {
   test('audit', async () => {
@@ -3021,12 +3036,6 @@
     });
   });
 
-  test('cancel', () => {
-    const cleanupStub = sinon.stub(element.diffBuilder, 'cleanup');
-    element.cancel();
-    assert.isTrue(cleanupStub.calledOnce);
-  });
-
   test('line limit with line_wrapping', async () => {
     element.prefs = {...MINIMAL_PREFS, line_wrapping: true};
     await element.updateComplete;
@@ -3796,10 +3805,9 @@
     let renderStub: sinon.SinonStub;
 
     setup(async () => {
-      renderStub = sinon.stub(element.diffBuilder, 'render').callsFake(() => {
+      renderStub = sinon.stub(element, 'legacyRender').callsFake(() => {
         assertIsDefined(element.diffTable);
-        const diffTable = element.diffTable;
-        diffTable.dispatchEvent(
+        element.diffTable.dispatchEvent(
           new CustomEvent('render', {bubbles: true, composed: true})
         );
         return Promise.resolve();
@@ -3847,7 +3855,7 @@
 
       assert.equal(element.prefs.context, 3);
       assert.equal(element.safetyBypass, -1);
-      assert.equal(element.diffBuilder.prefs.context, -1);
+      assert.equal(element.getBypassPrefs().context, -1);
     });
 
     test('toggles collapse context from bypass', async () => {
@@ -3860,7 +3868,7 @@
 
       assert.equal(element.prefs.context, 3);
       assert.isNull(element.safetyBypass);
-      assert.equal(element.diffBuilder.prefs.context, 3);
+      assert.equal(element.getBypassPrefs().context, 3);
     });
 
     test('toggles collapse context from pref using default', async () => {
@@ -3872,14 +3880,14 @@
 
       assert.equal(element.prefs.context, -1);
       assert.equal(element.safetyBypass, 10);
-      assert.equal(element.diffBuilder.prefs.context, 10);
+      assert.equal(element.getBypassPrefs().context, 10);
     });
   });
 
   suite('blame', () => {
     test('unsetting', async () => {
       element.blame = [];
-      const setBlameSpy = sinon.spy(element.diffBuilder, 'setBlame');
+      const setBlameSpy = sinon.spy(element, 'setBlame');
       element.classList.add('showBlame');
       element.blame = null;
       await element.updateComplete;
@@ -3957,20 +3965,15 @@
   });
 
   suite('key locations', () => {
-    let renderStub: sinon.SinonStub;
-
     setup(async () => {
       element.prefs = {...MINIMAL_PREFS};
       element.diff = createDiff();
-      renderStub = sinon.stub(element.diffBuilder, 'render');
       await element.updateComplete;
     });
 
     test('lineOfInterest is a key location', () => {
       element.lineOfInterest = {lineNum: 789, side: Side.LEFT};
-      element.renderDiffTable();
-      assert.isTrue(renderStub.called);
-      assert.deepEqual(renderStub.lastCall.args[0], {
+      assert.deepEqual(element.computeKeyLocations(), {
         left: {789: true},
         right: {},
       });
@@ -3984,9 +3987,7 @@
       element.appendChild(threadEl);
       await element.updateComplete;
 
-      element.renderDiffTable();
-      assert.isTrue(renderStub.called);
-      assert.deepEqual(renderStub.lastCall.args[0], {
+      assert.deepEqual(element.computeKeyLocations(), {
         left: {},
         right: {3: true},
       });
@@ -3999,9 +4000,7 @@
       element.appendChild(threadEl);
       await element.updateComplete;
 
-      element.renderDiffTable();
-      assert.isTrue(renderStub.called);
-      assert.deepEqual(renderStub.lastCall.args[0], {
+      assert.deepEqual(element.computeKeyLocations(), {
         left: {FILE: true},
         right: {},
       });
@@ -4057,8 +4056,7 @@
     ];
     function diffTableHasContent() {
       assertIsDefined(element.diffTable);
-      const diffTable = element.diffTable;
-      return diffTable.innerText.includes(content[0].a?.[0] ?? '');
+      return element.diffTable.innerText.includes(content[0].a?.[0] ?? '');
     }
     await setupSampleDiff({content});
     await waitUntil(diffTableHasContent);
@@ -4066,8 +4064,7 @@
     await element.updateComplete;
     // immediately cleaned up
     assertIsDefined(element.diffTable);
-    const diffTable = element.diffTable;
-    assert.equal(diffTable.innerHTML, '');
+    assert.equal(element.diffTable.innerHTML, '');
     element.renderDiffTable();
     await element.updateComplete;
     // rendered again
@@ -4182,3 +4179,598 @@
     assert.equal(element.getDiffLength(diff), 52);
   });
 });
+
+suite('former gr-diff-builder tests', () => {
+  let element: GrDiff;
+  let builder: GrDiffBuilder;
+  let diffTable: HTMLTableElement;
+
+  const setBuilderPrefs = (prefs: Partial<DiffPreferencesInfo>) => {
+    builder = new GrDiffBuilder(
+      createEmptyDiff(),
+      {...createDefaultDiffPrefs(), ...prefs},
+      diffTable
+    );
+  };
+
+  const line = (text: string) => {
+    const line = new GrDiffLine(GrDiffLineType.BOTH);
+    line.text = text;
+    return line;
+  };
+
+  setup(async () => {
+    element = await fixture<GrDiff>(html`<gr-diff></gr-diff>`);
+    element.diff = createEmptyDiff();
+    await element.updateComplete;
+    stubRestApi('getLoggedIn').returns(Promise.resolve(false));
+    stubRestApi('getProjectConfig').returns(Promise.resolve(createConfig()));
+    stubBaseUrl('/r');
+    setBuilderPrefs({});
+  });
+
+  [DiffViewMode.UNIFIED, DiffViewMode.SIDE_BY_SIDE].forEach(mode => {
+    test(`line_length used for regular files under ${mode}`, () => {
+      element.path = '/a.txt';
+      element.viewMode = mode;
+      element.diff = createEmptyDiff();
+      element.prefs = {
+        ...createDefaultDiffPrefs(),
+        tab_size: 4,
+        line_length: 50,
+      };
+      builder = element.getDiffBuilder();
+      assert.equal(builder.prefs.line_length, 50);
+    });
+
+    test(`line_length ignored for commit msg under ${mode}`, () => {
+      element.path = '/COMMIT_MSG';
+      element.viewMode = mode;
+      element.diff = createEmptyDiff();
+      element.prefs = {
+        ...createDefaultDiffPrefs(),
+        tab_size: 4,
+        line_length: 50,
+      };
+      builder = element.getDiffBuilder();
+      assert.equal(builder.prefs.line_length, 72);
+    });
+  });
+
+  test('_handlePreferenceError throws with invalid preference', () => {
+    element.prefs = {...createDefaultDiffPrefs(), tab_size: 0};
+    assert.throws(() => element.getDiffBuilder());
+  });
+
+  test('_handlePreferenceError triggers alert and javascript error', () => {
+    const errorStub = sinon.stub();
+    element.diffTable!.addEventListener('show-alert', errorStub);
+    assert.throws(() => element.handlePreferenceError('tab size'));
+    assert.equal(
+      errorStub.lastCall.args[0].detail.message,
+      "The value of the 'tab size' user preference is invalid. " +
+        'Fix in diff preferences'
+    );
+  });
+
+  suite('intraline differences', () => {
+    let el: HTMLElement;
+    let str: string;
+    let annotateElementSpy: sinon.SinonSpy;
+    let layer: DiffLayer;
+    const lineNumberEl = document.createElement('td');
+
+    function slice(str: string, start: number, end?: number) {
+      return Array.from(str).slice(start, end).join('');
+    }
+
+    setup(async () => {
+      el = await fixture(html`
+        <div>Lorem ipsum dolor sit amet, suspendisse inceptos vehicula</div>
+      `);
+      str = el.textContent ?? '';
+      annotateElementSpy = sinon.spy(GrAnnotation, 'annotateElement');
+      layer = element.createIntralineLayer();
+    });
+
+    test('annotate no highlights', () => {
+      layer.annotate(el, lineNumberEl, line(str), Side.LEFT);
+
+      // The content is unchanged.
+      assert.isFalse(annotateElementSpy.called);
+      assert.equal(el.childNodes.length, 1);
+      assert.instanceOf(el.childNodes[0], Text);
+      assert.equal(str, el.childNodes[0].textContent);
+    });
+
+    test('annotate with highlights', () => {
+      const l = line(str);
+      l.highlights = [
+        {contentIndex: 0, startIndex: 6, endIndex: 12},
+        {contentIndex: 0, startIndex: 18, endIndex: 22},
+      ];
+      const str0 = slice(str, 0, 6);
+      const str1 = slice(str, 6, 12);
+      const str2 = slice(str, 12, 18);
+      const str3 = slice(str, 18, 22);
+      const str4 = slice(str, 22);
+
+      layer.annotate(el, lineNumberEl, l, Side.LEFT);
+
+      assert.isTrue(annotateElementSpy.called);
+      assert.equal(el.childNodes.length, 5);
+
+      assert.instanceOf(el.childNodes[0], Text);
+      assert.equal(el.childNodes[0].textContent, str0);
+
+      assert.notInstanceOf(el.childNodes[1], Text);
+      assert.equal(el.childNodes[1].textContent, str1);
+
+      assert.instanceOf(el.childNodes[2], Text);
+      assert.equal(el.childNodes[2].textContent, str2);
+
+      assert.notInstanceOf(el.childNodes[3], Text);
+      assert.equal(el.childNodes[3].textContent, str3);
+
+      assert.instanceOf(el.childNodes[4], Text);
+      assert.equal(el.childNodes[4].textContent, str4);
+    });
+
+    test('annotate without endIndex', () => {
+      const l = line(str);
+      l.highlights = [{contentIndex: 0, startIndex: 28}];
+
+      const str0 = slice(str, 0, 28);
+      const str1 = slice(str, 28);
+
+      layer.annotate(el, lineNumberEl, l, Side.LEFT);
+
+      assert.isTrue(annotateElementSpy.called);
+      assert.equal(el.childNodes.length, 2);
+
+      assert.instanceOf(el.childNodes[0], Text);
+      assert.equal(el.childNodes[0].textContent, str0);
+
+      assert.notInstanceOf(el.childNodes[1], Text);
+      assert.equal(el.childNodes[1].textContent, str1);
+    });
+
+    test('annotate ignores empty highlights', () => {
+      const l = line(str);
+      l.highlights = [{contentIndex: 0, startIndex: 28, endIndex: 28}];
+
+      layer.annotate(el, lineNumberEl, l, Side.LEFT);
+
+      assert.isFalse(annotateElementSpy.called);
+      assert.equal(el.childNodes.length, 1);
+    });
+
+    test('annotate handles unicode', () => {
+      // Put some unicode into the string:
+      str = str.replace(/\s/g, '💢');
+      el.textContent = str;
+      const l = line(str);
+      l.highlights = [{contentIndex: 0, startIndex: 6, endIndex: 12}];
+
+      const str0 = slice(str, 0, 6);
+      const str1 = slice(str, 6, 12);
+      const str2 = slice(str, 12);
+
+      layer.annotate(el, lineNumberEl, l, Side.LEFT);
+
+      assert.isTrue(annotateElementSpy.called);
+      assert.equal(el.childNodes.length, 3);
+
+      assert.instanceOf(el.childNodes[0], Text);
+      assert.equal(el.childNodes[0].textContent, str0);
+
+      assert.notInstanceOf(el.childNodes[1], Text);
+      assert.equal(el.childNodes[1].textContent, str1);
+
+      assert.instanceOf(el.childNodes[2], Text);
+      assert.equal(el.childNodes[2].textContent, str2);
+    });
+
+    test('annotate handles unicode w/o endIndex', () => {
+      // Put some unicode into the string:
+      str = str.replace(/\s/g, '💢');
+      el.textContent = str;
+
+      const l = line(str);
+      l.highlights = [{contentIndex: 0, startIndex: 6}];
+
+      const str0 = slice(str, 0, 6);
+      const str1 = slice(str, 6);
+      const numHighlightedChars = GrAnnotation.getStringLength(str1);
+
+      layer.annotate(el, lineNumberEl, l, Side.LEFT);
+
+      assert.isTrue(annotateElementSpy.calledWith(el, 6, numHighlightedChars));
+      assert.equal(el.childNodes.length, 2);
+
+      assert.instanceOf(el.childNodes[0], Text);
+      assert.equal(el.childNodes[0].textContent, str0);
+
+      assert.notInstanceOf(el.childNodes[1], Text);
+      assert.equal(el.childNodes[1].textContent, str1);
+    });
+  });
+
+  suite('tab indicators', () => {
+    let layer: DiffLayer;
+    const lineNumberEl = document.createElement('td');
+
+    setup(() => {
+      element.showTabs = true;
+      layer = element.createTabIndicatorLayer();
+    });
+
+    test('does nothing with empty line', () => {
+      const l = line('');
+      const el = document.createElement('div');
+      const annotateElementStub = sinon.stub(GrAnnotation, 'annotateElement');
+
+      layer.annotate(el, lineNumberEl, l, Side.LEFT);
+
+      assert.isFalse(annotateElementStub.called);
+    });
+
+    test('does nothing with no tabs', () => {
+      const str = 'lorem ipsum no tabs';
+      const l = line(str);
+      const el = document.createElement('div');
+      el.textContent = str;
+      const annotateElementStub = sinon.stub(GrAnnotation, 'annotateElement');
+
+      layer.annotate(el, lineNumberEl, l, Side.LEFT);
+
+      assert.isFalse(annotateElementStub.called);
+    });
+
+    test('annotates tab at beginning', () => {
+      const str = '\tlorem upsum';
+      const l = line(str);
+      const el = document.createElement('div');
+      el.textContent = str;
+      const annotateElementStub = sinon.stub(GrAnnotation, 'annotateElement');
+
+      layer.annotate(el, lineNumberEl, l, Side.LEFT);
+
+      assert.equal(annotateElementStub.callCount, 1);
+      const args = annotateElementStub.getCalls()[0].args;
+      assert.equal(args[0], el);
+      assert.equal(args[1], 0, 'offset of tab indicator');
+      assert.equal(args[2], 1, 'length of tab indicator');
+      assert.include(args[3], 'tab-indicator');
+    });
+
+    test('does not annotate when disabled', () => {
+      element.showTabs = false;
+
+      const str = '\tlorem upsum';
+      const l = line(str);
+      const el = document.createElement('div');
+      el.textContent = str;
+      const annotateElementStub = sinon.stub(GrAnnotation, 'annotateElement');
+
+      layer.annotate(el, lineNumberEl, l, Side.LEFT);
+
+      assert.isFalse(annotateElementStub.called);
+    });
+
+    test('annotates multiple in beginning', () => {
+      const str = '\t\tlorem upsum';
+      const l = line(str);
+      const el = document.createElement('div');
+      el.textContent = str;
+      const annotateElementStub = sinon.stub(GrAnnotation, 'annotateElement');
+
+      layer.annotate(el, lineNumberEl, l, Side.LEFT);
+
+      assert.equal(annotateElementStub.callCount, 2);
+
+      let args = annotateElementStub.getCalls()[0].args;
+      assert.equal(args[0], el);
+      assert.equal(args[1], 0, 'offset of tab indicator');
+      assert.equal(args[2], 1, 'length of tab indicator');
+      assert.include(args[3], 'tab-indicator');
+
+      args = annotateElementStub.getCalls()[1].args;
+      assert.equal(args[0], el);
+      assert.equal(args[1], 1, 'offset of tab indicator');
+      assert.equal(args[2], 1, 'length of tab indicator');
+      assert.include(args[3], 'tab-indicator');
+    });
+
+    test('annotates intermediate tabs', () => {
+      const str = 'lorem\tupsum';
+      const l = line(str);
+      const el = document.createElement('div');
+      el.textContent = str;
+      const annotateElementStub = sinon.stub(GrAnnotation, 'annotateElement');
+
+      layer.annotate(el, lineNumberEl, l, Side.LEFT);
+
+      assert.equal(annotateElementStub.callCount, 1);
+      const args = annotateElementStub.getCalls()[0].args;
+      assert.equal(args[0], el);
+      assert.equal(args[1], 5, 'offset of tab indicator');
+      assert.equal(args[2], 1, 'length of tab indicator');
+      assert.include(args[3], 'tab-indicator');
+    });
+  });
+
+  suite('layers', () => {
+    let initialLayersCount = 0;
+    let withLayerCount = 0;
+    setup(() => {
+      const layers: DiffLayer[] = [];
+      element.layers = layers;
+      element.showTrailingWhitespace = true;
+      element.setupAnnotationLayers();
+      initialLayersCount = element.layersInternal.length;
+    });
+
+    test('no layers', () => {
+      element.setupAnnotationLayers();
+      assert.equal(element.layersInternal.length, initialLayersCount);
+    });
+
+    suite('with layers', () => {
+      const layers: DiffLayer[] = [{annotate: () => {}}, {annotate: () => {}}];
+      setup(() => {
+        element.layers = layers;
+        element.showTrailingWhitespace = true;
+        element.setupAnnotationLayers();
+        withLayerCount = element.layersInternal.length;
+      });
+      test('with layers', () => {
+        element.setupAnnotationLayers();
+        assert.equal(element.layersInternal.length, withLayerCount);
+        assert.equal(initialLayersCount + layers.length, withLayerCount);
+      });
+    });
+  });
+
+  suite('trailing whitespace', () => {
+    let layer: DiffLayer;
+    const lineNumberEl = document.createElement('td');
+
+    setup(() => {
+      element.showTrailingWhitespace = true;
+      layer = element.createTrailingWhitespaceLayer();
+    });
+
+    test('does nothing with empty line', () => {
+      const l = line('');
+      const el = document.createElement('div');
+      const annotateElementStub = sinon.stub(GrAnnotation, 'annotateElement');
+      layer.annotate(el, lineNumberEl, l, Side.LEFT);
+      assert.isFalse(annotateElementStub.called);
+    });
+
+    test('does nothing with no trailing whitespace', () => {
+      const str = 'lorem ipsum blah blah';
+      const l = line(str);
+      const el = document.createElement('div');
+      el.textContent = str;
+      const annotateElementStub = sinon.stub(GrAnnotation, 'annotateElement');
+      layer.annotate(el, lineNumberEl, l, Side.LEFT);
+      assert.isFalse(annotateElementStub.called);
+    });
+
+    test('annotates trailing spaces', () => {
+      const str = 'lorem ipsum   ';
+      const l = line(str);
+      const el = document.createElement('div');
+      el.textContent = str;
+      const annotateElementStub = sinon.stub(GrAnnotation, 'annotateElement');
+      layer.annotate(el, lineNumberEl, l, Side.LEFT);
+      assert.isTrue(annotateElementStub.called);
+      assert.equal(annotateElementStub.lastCall.args[1], 11);
+      assert.equal(annotateElementStub.lastCall.args[2], 3);
+    });
+
+    test('annotates trailing tabs', () => {
+      const str = 'lorem ipsum\t\t\t';
+      const l = line(str);
+      const el = document.createElement('div');
+      el.textContent = str;
+      const annotateElementStub = sinon.stub(GrAnnotation, 'annotateElement');
+      layer.annotate(el, lineNumberEl, l, Side.LEFT);
+      assert.isTrue(annotateElementStub.called);
+      assert.equal(annotateElementStub.lastCall.args[1], 11);
+      assert.equal(annotateElementStub.lastCall.args[2], 3);
+    });
+
+    test('annotates mixed trailing whitespace', () => {
+      const str = 'lorem ipsum\t \t';
+      const l = line(str);
+      const el = document.createElement('div');
+      el.textContent = str;
+      const annotateElementStub = sinon.stub(GrAnnotation, 'annotateElement');
+      layer.annotate(el, lineNumberEl, l, Side.LEFT);
+      assert.isTrue(annotateElementStub.called);
+      assert.equal(annotateElementStub.lastCall.args[1], 11);
+      assert.equal(annotateElementStub.lastCall.args[2], 3);
+    });
+
+    test('unicode preceding trailing whitespace', () => {
+      const str = '💢\t';
+      const l = line(str);
+      const el = document.createElement('div');
+      el.textContent = str;
+      const annotateElementStub = sinon.stub(GrAnnotation, 'annotateElement');
+      layer.annotate(el, lineNumberEl, l, Side.LEFT);
+      assert.isTrue(annotateElementStub.called);
+      assert.equal(annotateElementStub.lastCall.args[1], 1);
+      assert.equal(annotateElementStub.lastCall.args[2], 1);
+    });
+
+    test('does not annotate when disabled', () => {
+      element.showTrailingWhitespace = false;
+      const str = 'lorem upsum\t \t ';
+      const l = line(str);
+      const el = document.createElement('div');
+      el.textContent = str;
+      const annotateElementStub = sinon.stub(GrAnnotation, 'annotateElement');
+      layer.annotate(el, lineNumberEl, l, Side.LEFT);
+      assert.isFalse(annotateElementStub.called);
+    });
+  });
+
+  suite('rendering text, images and binary files', () => {
+    let content: DiffContent[] = [];
+
+    setup(() => {
+      element.viewMode = DiffViewMode.SIDE_BY_SIDE;
+      element.prefs = {
+        ...DEFAULT_PREFS,
+        context: -1,
+        syntax_highlighting: true,
+      };
+      content = [
+        {
+          a: ['all work and no play make andybons a dull boy'],
+          b: ['elgoog elgoog elgoog'],
+        },
+        {
+          ab: [
+            'Non eram nescius, Brute, cum, quae summis ingeniis ',
+            'exquisitaque doctrina philosophi Graeco sermone tractavissent',
+          ],
+        },
+      ];
+    });
+
+    test('text', async () => {
+      element.diff = {...createEmptyDiff(), content};
+      await waitForEventOnce(element.diffTable!, 'render-content');
+      assert.equal(querySelectorAll(element.diffTable!, 'tbody')?.length, 4);
+    });
+
+    test('image', async () => {
+      element.diff = {...createEmptyDiff(), content, binary: true};
+      element.isImageDiff = true;
+      await waitForEventOnce(element.diffTable!, 'render-content');
+      assert.equal(querySelectorAll(element.diffTable!, 'tbody')?.length, 4);
+    });
+
+    test('binary', async () => {
+      element.diff = {...createEmptyDiff(), content, binary: true};
+      await waitForEventOnce(element.diffTable!, 'render-content');
+      assert.equal(querySelectorAll(element.diffTable!, 'tbody')?.length, 3);
+    });
+  });
+
+  suite('context hiding and expanding', () => {
+    let dispatchStub: sinon.SinonStub;
+
+    setup(async () => {
+      dispatchStub = sinon.stub(element.diffTable!, 'dispatchEvent');
+      element.diff = {
+        ...createEmptyDiff(),
+        content: [
+          {ab: [1, 2, 3, 4, 5, 6, 7, 8, 9, 10].map(i => `unchanged ${i}`)},
+          {a: ['before'], b: ['after']},
+          {ab: [1, 2, 3, 4, 5, 6, 7, 8, 9, 10].map(i => `unchanged ${10 + i}`)},
+        ],
+      };
+      element.viewMode = DiffViewMode.SIDE_BY_SIDE;
+
+      element.prefs = {
+        ...DEFAULT_PREFS,
+        context: 1,
+      };
+      await element.updateComplete;
+      element.legacyRender();
+      // Make sure all listeners are installed.
+      await element.untilGroupsRendered();
+    });
+
+    test('hides lines behind two context controls', () => {
+      const contextControls = element.diffTable!.querySelectorAll(
+        'gr-context-controls'
+      );
+      assert.equal(contextControls.length, 2);
+
+      const diffRows = element.diffTable!.querySelectorAll('.diff-row');
+      // The first two are LOST and FILE line
+      assert.equal(diffRows.length, 2 + 1 + 1 + 1);
+      assert.include(diffRows[2].textContent, 'unchanged 10');
+      assert.include(diffRows[3].textContent, 'before');
+      assert.include(diffRows[3].textContent, 'after');
+      assert.include(diffRows[4].textContent, 'unchanged 11');
+    });
+
+    test('clicking +x common lines expands those lines', async () => {
+      const contextControls = element.diffTable!.querySelectorAll(
+        'gr-context-controls'
+      );
+      const topExpandCommonButton =
+        contextControls[0].shadowRoot?.querySelectorAll<HTMLElement>(
+          '.showContext'
+        )[0];
+      assert.isOk(topExpandCommonButton);
+      assert.include(topExpandCommonButton!.textContent, '+9 common lines');
+      let diffRows = element.diffTable!.querySelectorAll('.diff-row');
+      // 5 lines:
+      // FILE, LOST, the changed line plus one line of context in each direction
+      assert.equal(diffRows.length, 5);
+
+      topExpandCommonButton!.click();
+
+      await waitUntil(() => {
+        diffRows = element.diffTable!.querySelectorAll<GrDiffRow>('.diff-row');
+        return diffRows.length === 14;
+      });
+      // 14 lines: The 5 above plus the 9 unchanged lines that were expanded
+      assert.equal(diffRows.length, 14);
+      assert.include(diffRows[2].textContent, 'unchanged 1');
+      assert.include(diffRows[3].textContent, 'unchanged 2');
+      assert.include(diffRows[4].textContent, 'unchanged 3');
+      assert.include(diffRows[5].textContent, 'unchanged 4');
+      assert.include(diffRows[6].textContent, 'unchanged 5');
+      assert.include(diffRows[7].textContent, 'unchanged 6');
+      assert.include(diffRows[8].textContent, 'unchanged 7');
+      assert.include(diffRows[9].textContent, 'unchanged 8');
+      assert.include(diffRows[10].textContent, 'unchanged 9');
+      assert.include(diffRows[11].textContent, 'unchanged 10');
+      assert.include(diffRows[12].textContent, 'before');
+      assert.include(diffRows[12].textContent, 'after');
+      assert.include(diffRows[13].textContent, 'unchanged 11');
+    });
+
+    test('unhideLine shows the line with context', async () => {
+      dispatchStub.reset();
+      element.unhideLine(4, Side.LEFT);
+
+      await waitUntil(() => {
+        const rows =
+          element.diffTable!.querySelectorAll<GrDiffRow>('.diff-row');
+        return rows.length === 2 + 5 + 1 + 1 + 1;
+      });
+
+      const diffRows = element.diffTable!.querySelectorAll('.diff-row');
+      // The first two are LOST and FILE line
+      // Lines 3-5 (Line 4 plus 1 context in each direction) will be expanded
+      // Because context expanders do not hide <3 lines, lines 1-2 will also
+      // be shown.
+      // Lines 6-9 continue to be hidden
+      assert.equal(diffRows.length, 2 + 5 + 1 + 1 + 1);
+      assert.include(diffRows[2].textContent, 'unchanged 1');
+      assert.include(diffRows[3].textContent, 'unchanged 2');
+      assert.include(diffRows[4].textContent, 'unchanged 3');
+      assert.include(diffRows[5].textContent, 'unchanged 4');
+      assert.include(diffRows[6].textContent, 'unchanged 5');
+      assert.include(diffRows[7].textContent, 'unchanged 10');
+      assert.include(diffRows[8].textContent, 'before');
+      assert.include(diffRows[8].textContent, 'after');
+      assert.include(diffRows[9].textContent, 'unchanged 11');
+
+      await element.untilGroupsRendered();
+      const firedEventTypes = dispatchStub.getCalls().map(c => c.args[0].type);
+      assert.include(firedEventTypes, 'render-content');
+    });
+  });
+});