Add lit element based gr-diff elements

This is rolling forward change 330300, see there for a more detailed
description of the changes. The change 330300 had to be rolled back
just because of the 2-line-change to `_handleDown()` in
`gr-diff-selection`. Everything else in the change was fine and
already reviewed. So we are rolling forward without any changes
outside of the added files that could possibly affect the existing
rendering. We think this is absolutely safe to submit and release.

It is expected that the new diff elements may not yet be working
perfectly, but we want to separate the already reviewed changes from
the fixes. We believe that this change does not need a substantial
review.

Release-Notes: skip
Google-Bug-Id: b/237393560
Change-Id: I9cd6cf23de233719cf7da8e834179f98b5ea65bf
diff --git a/polygerrit-ui/app/embed/diff/gr-context-controls/gr-context-controls-section.ts b/polygerrit-ui/app/embed/diff/gr-context-controls/gr-context-controls-section.ts
new file mode 100644
index 0000000..d1478b3
--- /dev/null
+++ b/polygerrit-ui/app/embed/diff/gr-context-controls/gr-context-controls-section.ts
@@ -0,0 +1,113 @@
+/**
+ * @license
+ * Copyright 2022 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+import '../../../elements/shared/gr-button/gr-button';
+import {html, LitElement} from 'lit';
+import {customElement, property, state} from 'lit/decorators.js';
+import {DiffInfo, DiffViewMode, RenderPreferences} from '../../../api/diff';
+import {GrDiffGroup, GrDiffGroupType} from '../gr-diff/gr-diff-group';
+import {diffClasses} from '../gr-diff/gr-diff-utils';
+import {getShowConfig} from './gr-context-controls';
+import {ifDefined} from 'lit/directives/if-defined.js';
+
+@customElement('gr-context-controls-section')
+export class GrContextControlsSection extends LitElement {
+  /** Should context controls be rendered for expanding above the section? */
+  @property({type: Boolean}) showAbove = false;
+
+  /** Should context controls be rendered for expanding below the section? */
+  @property({type: Boolean}) showBelow = false;
+
+  @property({type: Object}) viewMode = DiffViewMode.SIDE_BY_SIDE;
+
+  /** Must be of type GrDiffGroupType.CONTEXT_CONTROL. */
+  @property({type: Object})
+  group?: GrDiffGroup;
+
+  @property({type: Object})
+  diff?: DiffInfo;
+
+  @property({type: Object})
+  renderPrefs?: RenderPreferences;
+
+  /**
+   * Semantic DOM diff testing does not work with just table fragments, so when
+   * running such tests the render() method has to wrap the DOM in a proper
+   * <table> element.
+   */
+  @state()
+  addTableWrapperForTesting = false;
+
+  /**
+   * The browser API for handling selection does not (yet) work for selection
+   * across multiple shadow DOM elements. So we are rendering gr-diff components
+   * into the light DOM instead of the shadow DOM by overriding this method,
+   * which was the recommended workaround by the lit team.
+   * See also https://github.com/WICG/webcomponents/issues/79.
+   */
+  override createRenderRoot() {
+    return this;
+  }
+
+  private renderPaddingRow(whereClass: 'above' | 'below') {
+    if (!this.showAbove && whereClass === 'above') return;
+    if (!this.showBelow && whereClass === 'below') return;
+    const sideBySide = this.viewMode === DiffViewMode.SIDE_BY_SIDE;
+    const modeClass = sideBySide ? 'side-by-side' : 'unified';
+    const type = sideBySide ? GrDiffGroupType.CONTEXT_CONTROL : undefined;
+    return html`
+      <tr
+        class=${diffClasses('contextBackground', modeClass, whereClass)}
+        left-type=${ifDefined(type)}
+        right-type=${ifDefined(type)}
+      >
+        <td class=${diffClasses('blame')} data-line-number="0"></td>
+        <td class=${diffClasses('contextLineNum')}></td>
+        ${sideBySide ? html`<td class=${diffClasses()}></td>` : ''}
+        <td class=${diffClasses('contextLineNum')}></td>
+        <td class=${diffClasses()}></td>
+      </tr>
+    `;
+  }
+
+  private createContextControlRow() {
+    const sideBySide = this.viewMode === DiffViewMode.SIDE_BY_SIDE;
+    const showConfig = getShowConfig(this.showAbove, this.showBelow);
+    return html`
+      <tr class=${diffClasses('dividerRow', `show-${showConfig}`)}>
+        <td class=${diffClasses('blame')} data-line-number="0"></td>
+        ${sideBySide ? html`<td class=${diffClasses()}></td>` : ''}
+        <td class=${diffClasses('dividerCell')} colspan="3">
+          <gr-context-controls
+            .diff=${this.diff}
+            .renderPreferences=${this.renderPrefs}
+            .group=${this.group}
+            .showConfig=${showConfig}
+          >
+          </gr-context-controls>
+        </td>
+      </tr>
+    `;
+  }
+
+  override render() {
+    const rows = html`
+      ${this.renderPaddingRow('above')} ${this.createContextControlRow()}
+      ${this.renderPaddingRow('below')}
+    `;
+    if (this.addTableWrapperForTesting) {
+      return html`<table>
+        ${rows}
+      </table>`;
+    }
+    return rows;
+  }
+}
+
+declare global {
+  interface HTMLElementTagNameMap {
+    'gr-context-controls-section': GrContextControlsSection;
+  }
+}
diff --git a/polygerrit-ui/app/embed/diff/gr-context-controls/gr-context-controls-section_test.ts b/polygerrit-ui/app/embed/diff/gr-context-controls/gr-context-controls-section_test.ts
new file mode 100644
index 0000000..7f920d5
--- /dev/null
+++ b/polygerrit-ui/app/embed/diff/gr-context-controls/gr-context-controls-section_test.ts
@@ -0,0 +1,65 @@
+/**
+ * @license
+ * Copyright 2022 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+import '../../../test/common-test-setup';
+import './gr-context-controls-section';
+import {GrContextControlsSection} from './gr-context-controls-section';
+import {fixture, html, assert} from '@open-wc/testing';
+
+suite('gr-context-controls-section test', () => {
+  let element: GrContextControlsSection;
+
+  setup(async () => {
+    element = await fixture<GrContextControlsSection>(
+      html`<gr-context-controls-section></gr-context-controls-section>`
+    );
+    element.addTableWrapperForTesting = true;
+    await element.updateComplete;
+  });
+
+  test('render: normal with showAbove and showBelow', async () => {
+    element.showAbove = true;
+    element.showBelow = true;
+    await element.updateComplete;
+    assert.lightDom.equal(
+      element,
+      /* HTML */ `
+        <table>
+          <tbody>
+            <tr
+              class="above contextBackground gr-diff side-by-side style-scope"
+              left-type="contextControl"
+              right-type="contextControl"
+            >
+              <td class="blame gr-diff style-scope" data-line-number="0"></td>
+              <td class="contextLineNum gr-diff style-scope"></td>
+              <td class="gr-diff style-scope"></td>
+              <td class="contextLineNum gr-diff style-scope"></td>
+              <td class="gr-diff style-scope"></td>
+            </tr>
+            <tr class="dividerRow gr-diff show-both style-scope">
+              <td class="blame gr-diff style-scope" data-line-number="0"></td>
+              <td class="gr-diff style-scope"></td>
+              <td class="dividerCell gr-diff style-scope" colspan="3">
+                <gr-context-controls showconfig="both"> </gr-context-controls>
+              </td>
+            </tr>
+            <tr
+              class="below contextBackground gr-diff side-by-side style-scope"
+              left-type="contextControl"
+              right-type="contextControl"
+            >
+              <td class="blame gr-diff style-scope" data-line-number="0"></td>
+              <td class="contextLineNum gr-diff style-scope"></td>
+              <td class="gr-diff style-scope"></td>
+              <td class="contextLineNum gr-diff style-scope"></td>
+              <td class="gr-diff style-scope"></td>
+            </tr>
+          </tbody>
+        </table>
+      `
+    );
+  });
+});
diff --git a/polygerrit-ui/app/embed/diff/gr-context-controls/gr-context-controls.ts b/polygerrit-ui/app/embed/diff/gr-context-controls/gr-context-controls.ts
index a0d06f6..1679ee0 100644
--- a/polygerrit-ui/app/embed/diff/gr-context-controls/gr-context-controls.ts
+++ b/polygerrit-ui/app/embed/diff/gr-context-controls/gr-context-controls.ts
@@ -69,6 +69,19 @@
 
 export type GrContextControlsShowConfig = 'above' | 'below' | 'both';
 
+export function getShowConfig(
+  showAbove: boolean,
+  showBelow: boolean
+): GrContextControlsShowConfig {
+  if (showAbove && !showBelow) return 'above';
+  if (!showAbove && showBelow) return 'below';
+
+  // Note that !showAbove && !showBelow also intentionally returns 'both'.
+  // This means the file is completely collapsed, which is unusual, but at least
+  // happens in one test.
+  return 'both';
+}
+
 @customElement('gr-context-controls')
 export class GrContextControls extends LitElement {
   @property({type: Object}) renderPreferences?: RenderPreferences;
diff --git a/polygerrit-ui/app/embed/diff/gr-diff-builder/gr-diff-builder-element.ts b/polygerrit-ui/app/embed/diff/gr-diff-builder/gr-diff-builder-element.ts
index cf76b8c..a9f857e 100644
--- a/polygerrit-ui/app/embed/diff/gr-diff-builder/gr-diff-builder-element.ts
+++ b/polygerrit-ui/app/embed/diff/gr-diff-builder/gr-diff-builder-element.ts
@@ -12,6 +12,7 @@
 import {GrDiffBuilderImage} from './gr-diff-builder-image';
 import {GrDiffBuilderUnified} from './gr-diff-builder-unified';
 import {GrDiffBuilderBinary} from './gr-diff-builder-binary';
+import {GrDiffBuilderLit} from './gr-diff-builder-lit';
 import {CancelablePromise, makeCancelable} from '../../../scripts/util';
 import {BlameInfo, ImageInfo} from '../../../types/common';
 import {DiffInfo, DiffPreferencesInfo} from '../../../types/diff';
@@ -424,13 +425,24 @@
       // If the diff is binary, but not an image.
       return new GrDiffBuilderBinary(this.diff, localPrefs, this.diffElement);
     } else if (this.viewMode === DiffViewMode.SIDE_BY_SIDE) {
-      builder = new GrDiffBuilderSideBySide(
-        this.diff,
-        localPrefs,
-        this.diffElement,
-        this.layersInternal,
-        this.renderPrefs
-      );
+      const useLit = this.renderPrefs?.use_lit_components;
+      if (useLit) {
+        builder = new GrDiffBuilderLit(
+          this.diff,
+          localPrefs,
+          this.diffElement,
+          this.layersInternal,
+          this.renderPrefs
+        );
+      } else {
+        builder = new GrDiffBuilderSideBySide(
+          this.diff,
+          localPrefs,
+          this.diffElement,
+          this.layersInternal,
+          this.renderPrefs
+        );
+      }
     } else if (this.viewMode === DiffViewMode.UNIFIED) {
       builder = new GrDiffBuilderUnified(
         this.diff,
diff --git a/polygerrit-ui/app/embed/diff/gr-diff-builder/gr-diff-builder-lit.ts b/polygerrit-ui/app/embed/diff/gr-diff-builder/gr-diff-builder-lit.ts
new file mode 100644
index 0000000..3465389
--- /dev/null
+++ b/polygerrit-ui/app/embed/diff/gr-diff-builder/gr-diff-builder-lit.ts
@@ -0,0 +1,185 @@
+/**
+ * @license
+ * Copyright 2022 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+import {RenderPreferences} from '../../../api/diff';
+import {LineNumber} from '../gr-diff/gr-diff-line';
+import {GrDiffGroup} from '../gr-diff/gr-diff-group';
+import {DiffInfo, DiffPreferencesInfo} from '../../../types/diff';
+import {Side} from '../../../constants/constants';
+import {DiffLayer, isDefined} from '../../../types/types';
+import {diffClasses} from '../gr-diff/gr-diff-utils';
+import {GrDiffBuilder} from './gr-diff-builder';
+import {BlameInfo} from '../../../types/common';
+import {html, render} from 'lit';
+import {GrDiffSection} from './gr-diff-section';
+import '../gr-context-controls/gr-context-controls';
+import './gr-diff-section';
+import {GrDiffRow} from './gr-diff-row';
+
+/**
+ * Base class for builders that are creating the diff using Lit elements.
+ */
+export class GrDiffBuilderLit extends GrDiffBuilder {
+  constructor(
+    diff: DiffInfo,
+    prefs: DiffPreferencesInfo,
+    outputEl: HTMLElement,
+    layers: DiffLayer[] = [],
+    renderPrefs?: RenderPreferences
+  ) {
+    super(diff, prefs, outputEl, layers, renderPrefs);
+  }
+
+  override getContentTdByLine(
+    lineNumber: LineNumber,
+    side?: Side,
+    _root: Element = this.outputEl
+  ): HTMLTableCellElement | null {
+    if (!side) return null;
+    const row = this.findRow(lineNumber, side);
+    return row?.getContentCell(side) ?? null;
+  }
+
+  override getLineElByNumber(lineNumber: LineNumber, side: Side) {
+    const row = this.findRow(lineNumber, side);
+    return row?.getLineNumberCell(side) ?? null;
+  }
+
+  private findRow(lineNumber?: LineNumber, side?: Side): GrDiffRow | undefined {
+    if (!side || !lineNumber) return undefined;
+    const group = this.findGroup(side, lineNumber);
+    if (!group) return undefined;
+    const section = this.findSection(group);
+    if (!section) return undefined;
+    return section.findRow(side, lineNumber);
+  }
+
+  private getDiffRows() {
+    const sections = [
+      ...this.outputEl.querySelectorAll<GrDiffSection>('gr-diff-section'),
+    ];
+    return sections.map(s => s.getDiffRows()).flat();
+  }
+
+  override getLineNumberRows(): HTMLTableRowElement[] {
+    const rows = this.getDiffRows();
+    return rows.map(r => r.getTableRow()).filter(isDefined);
+  }
+
+  override getLineNumEls(side: Side): HTMLTableCellElement[] {
+    const rows = this.getDiffRows();
+    return rows.map(r => r.getLineNumberCell(side)).filter(isDefined);
+  }
+
+  override getBlameTdByLine(lineNumber: number): Element | undefined {
+    return this.findRow(lineNumber, Side.LEFT)?.getBlameCell();
+  }
+
+  override getContentByLine(
+    lineNumber: LineNumber,
+    side?: Side,
+    _root?: HTMLElement
+  ): HTMLElement | null {
+    const cell = this.getContentTdByLine(lineNumber, side);
+    return (cell?.firstChild ?? null) as HTMLElement | null;
+  }
+
+  override renderContentByRange(
+    start: LineNumber,
+    end: LineNumber,
+    side: Side
+  ) {
+    // TODO: Revisit whether there is maybe a more efficient and reliable
+    // approach. renderContentByRange() is only used when layers announce
+    // updates. We have to look deeper into the design of layers anyway. So
+    // let's defer optimizing this code until a refactor of layers in general.
+    const groups = this.getGroupsByLineRange(start, end, side);
+    for (const group of groups) {
+      const section = this.findSection(group);
+      for (const row of section?.getDiffRows() ?? []) {
+        row.requestUpdate();
+      }
+    }
+  }
+
+  private findSection(group?: GrDiffGroup): GrDiffSection | undefined {
+    if (!group) return undefined;
+    const leftClass = `left-${group.lineRange.left.start_line}`;
+    const rightClass = `right-${group.lineRange.right.start_line}`;
+    return (
+      this.outputEl.querySelector<GrDiffSection>(
+        `gr-diff-section.${leftClass}.${rightClass}`
+      ) ?? undefined
+    );
+  }
+
+  override renderBlameByRange(
+    blameInfo: BlameInfo,
+    start: number,
+    end: number
+  ) {
+    for (let lineNumber = start; lineNumber <= end; lineNumber++) {
+      const row = this.findRow(lineNumber, Side.LEFT);
+      if (!row) continue;
+      row.blameInfo = blameInfo;
+    }
+  }
+
+  // TODO: Refactor this such that adding the move controls becomes part of the
+  // lit element.
+  protected override getMoveControlsConfig() {
+    return {
+      numberOfCells: 4, // How many cells does the diff table have?
+      movedOutIndex: 1, // Index of left content column in diff table.
+      movedInIndex: 3, // Index of right content column in diff table.
+      lineNumberCols: [0, 2], // Indices of line number columns in diff table.
+    };
+  }
+
+  protected override buildSectionElement(group: GrDiffGroup) {
+    const leftCl = `left-${group.lineRange.left.start_line}`;
+    const rightCl = `right-${group.lineRange.right.start_line}`;
+    const section = html`
+      <gr-diff-section
+        class="${leftCl} ${rightCl}"
+        .group=${group}
+        .diff=${this._diff}
+        .layers=${this.layers}
+        .diffPrefs=${this._prefs}
+        .renderPrefs=${this.renderPrefs}
+      ></gr-diff-section>
+    `;
+    // TODO: Refactor GrDiffBuilder.emitGroup() and buildSectionElement()
+    // such that we can render directly into the correct container.
+    const tempContainer = document.createElement('div');
+    render(section, tempContainer);
+    return tempContainer.firstElementChild as GrDiffSection;
+  }
+
+  override addColumns(outputEl: HTMLElement, lineNumberWidth: number): void {
+    render(
+      html`
+        <colgroup>
+         <col class=${diffClasses('blame')}></col>
+         <col class=${diffClasses(Side.LEFT)} width=${lineNumberWidth}></col>
+         <col class=${diffClasses(Side.LEFT)}></col>
+         <col class=${diffClasses(Side.RIGHT)} width=${lineNumberWidth}></col>
+         <col class=${diffClasses(Side.RIGHT)}></col>
+        </colgroup>
+      `,
+      outputEl
+    );
+  }
+
+  protected override getNextContentOnSide(
+    _content: HTMLElement,
+    _side: Side
+  ): HTMLElement | null {
+    // TODO: getNextContentOnSide() is not required by lit based rendering.
+    // So let's refactor it to be moved into gr-diff-builder-legacy.
+    console.warn('unimplemented method getNextContentOnSide() called');
+    return null;
+  }
+}
diff --git a/polygerrit-ui/app/embed/diff/gr-diff-builder/gr-diff-row.ts b/polygerrit-ui/app/embed/diff/gr-diff-builder/gr-diff-row.ts
new file mode 100644
index 0000000..2d42aec
--- /dev/null
+++ b/polygerrit-ui/app/embed/diff/gr-diff-builder/gr-diff-row.ts
@@ -0,0 +1,368 @@
+/**
+ * @license
+ * Copyright 2022 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+import {html, LitElement, TemplateResult} from 'lit';
+import {customElement, property, state} from 'lit/decorators.js';
+import {ifDefined} from 'lit/directives/if-defined.js';
+import {createRef, Ref, ref} from 'lit/directives/ref.js';
+import {
+  DiffResponsiveMode,
+  Side,
+  LineNumber,
+  DiffLayer,
+} from '../../../api/diff';
+import {BlameInfo} from '../../../types/common';
+import {assertIsDefined} from '../../../utils/common-util';
+import {fire} from '../../../utils/event-util';
+import {getBaseUrl} from '../../../utils/url-util';
+import './gr-diff-text';
+import {GrDiffLine, GrDiffLineType} from '../gr-diff/gr-diff-line';
+import {diffClasses, isResponsive} from '../gr-diff/gr-diff-utils';
+
+@customElement('gr-diff-row')
+export class GrDiffRow extends LitElement {
+  contentLeftRef: Ref<HTMLDivElement> = createRef();
+
+  contentRightRef: Ref<HTMLDivElement> = createRef();
+
+  lineNumberLeftRef: Ref<HTMLTableCellElement> = createRef();
+
+  lineNumberRightRef: Ref<HTMLTableCellElement> = createRef();
+
+  blameCellRef: Ref<HTMLTableCellElement> = createRef();
+
+  tableRowRef: Ref<HTMLTableRowElement> = createRef();
+
+  @property({type: Object})
+  left?: GrDiffLine;
+
+  @property({type: Object})
+  right?: GrDiffLine;
+
+  @property({type: Object})
+  blameInfo?: BlameInfo;
+
+  @property({type: Object})
+  responsiveMode?: DiffResponsiveMode;
+
+  @property({type: Number})
+  tabSize = 2;
+
+  @property({type: Number})
+  lineLength = 80;
+
+  @property({type: Boolean})
+  hideFileCommentButton = false;
+
+  @property({type: Object})
+  layers: DiffLayer[] = [];
+
+  /**
+   * While not visible we are trying to optimize rendering performance by
+   * rendering a simpler version of the diff. Once this has become true it
+   * cannot be set back to false.
+   */
+  @state()
+  isVisible = false;
+
+  /**
+   * Semantic DOM diff testing does not work with just table fragments, so when
+   * running such tests the render() method has to wrap the DOM in a proper
+   * <table> element.
+   */
+  @state()
+  addTableWrapperForTesting = false;
+
+  /**
+   * The browser API for handling selection does not (yet) work for selection
+   * across multiple shadow DOM elements. So we are rendering gr-diff components
+   * into the light DOM instead of the shadow DOM by overriding this method,
+   * which was the recommended workaround by the lit team.
+   * See also https://github.com/WICG/webcomponents/issues/79.
+   */
+  override createRenderRoot() {
+    return this;
+  }
+
+  override updated() {
+    this.updateLayers(Side.LEFT);
+    this.updateLayers(Side.RIGHT);
+  }
+
+  /**
+   * TODO: This needs some refinement, because layers do not detect whether they
+   * have already applied their information, so at the moment all layers would
+   * constantly re-apply their information to the diff in each lit rendering
+   * pass.
+   */
+  private updateLayers(side: Side) {
+    if (!this.isVisible) return;
+    const line = this.line(side);
+    const contentEl = this.contentRef(side).value;
+    const lineNumberEl = this.lineNumberRef(side).value;
+    if (!line || !contentEl || !lineNumberEl) return;
+    for (const layer of this.layers) {
+      if (typeof layer.annotate === 'function') {
+        layer.annotate(contentEl, lineNumberEl, line, side);
+      }
+    }
+  }
+
+  private renderInvisible() {
+    return html`
+      <tr>
+        <td class="style-scope gr-diff blame"></td>
+        <td class="style-scope gr-diff left"></td>
+        <td class="style-scope gr-diff left content">
+          <div>${this.left?.text ?? ''}</div>
+        </td>
+        <td class="style-scope gr-diff right"></td>
+        <td class="style-scope gr-diff right content">
+          <div>${this.right?.text ?? ''}</div>
+        </td>
+      </tr>
+    `;
+  }
+
+  override render() {
+    if (!this.left || !this.right) return;
+    if (!this.isVisible) return this.renderInvisible();
+    const row = html`
+      <tr
+        ${ref(this.tableRowRef)}
+        class=${diffClasses('diff-row', 'side-by-side')}
+        left-type=${this.left.type}
+        right-type=${this.right.type}
+        tabindex="-1"
+      >
+        ${this.renderBlameCell()} ${this.renderLineNumberCell(Side.LEFT)}
+        ${this.renderContentCell(Side.LEFT)}
+        ${this.renderLineNumberCell(Side.RIGHT)}
+        ${this.renderContentCell(Side.RIGHT)}
+      </tr>
+    `;
+    if (this.addTableWrapperForTesting) {
+      return html`<table>
+        ${row}
+      </table>`;
+    }
+    return row;
+  }
+
+  getTableRow(): HTMLTableRowElement | undefined {
+    return this.tableRowRef.value;
+  }
+
+  getLineNumberCell(side: Side): HTMLTableCellElement | undefined {
+    return this.lineNumberRef(side).value;
+  }
+
+  getContentCell(side: Side) {
+    const div = this.contentRef(side)?.value;
+    if (!div) return undefined;
+    return div.parentElement as HTMLTableCellElement;
+  }
+
+  getBlameCell() {
+    return this.blameCellRef.value;
+  }
+
+  private renderBlameCell() {
+    // td.blame has `white-space: pre`, so prettier must not add spaces.
+    // prettier-ignore
+    return html`
+      <td
+        ${ref(this.blameCellRef)}
+        class=${diffClasses('blame')}
+        data-line-number=${this.left?.beforeNumber ?? 0}
+      >${this.renderBlameElement()}</td>
+    `;
+  }
+
+  private renderBlameElement() {
+    const lineNum = this.left?.beforeNumber;
+    const commit = this.blameInfo;
+    if (!lineNum || !commit) return;
+
+    const isStartOfRange = commit.ranges.some(r => r.start === lineNum);
+    const extras: string[] = [];
+    if (isStartOfRange) extras.push('startOfRange');
+    const date = new Date(commit.time * 1000).toLocaleDateString();
+    const shortName = commit.author.split(' ')[0];
+    const url = `${getBaseUrl()}/q/${commit.id}`;
+
+    // td.blame has `white-space: pre`, so prettier must not add spaces.
+    // prettier-ignore
+    return html`<span class=${diffClasses(...extras)}
+        ><a href=${url} class=${diffClasses('blameDate')}>${date}</a
+        ><span class=${diffClasses('blameAuthor')}> ${shortName}</span
+        ><gr-hovercard class=${diffClasses()}>
+          <span class=${diffClasses('blameHoverCard')}>
+            Commit ${commit.id}<br />
+            Author: ${commit.author}<br />
+            Date: ${date}<br />
+            <br />
+            ${commit.commit_msg}
+          </span>
+        </gr-hovercard
+      ></span>`;
+  }
+
+  private renderLineNumberCell(side: Side): TemplateResult {
+    const line = this.line(side);
+    const lineNumber = this.lineNumber(side);
+    if (!line || !lineNumber || line.type === GrDiffLineType.BLANK) {
+      return html`<td
+        ${ref(this.lineNumberRef(side))}
+        class=${diffClasses(side)}
+      ></td>`;
+    }
+
+    return html`<td
+      ${ref(this.lineNumberRef(side))}
+      class=${diffClasses(side, 'lineNum')}
+      data-value=${lineNumber}
+    >
+      ${this.renderLineNumberButton(line, lineNumber, side)}
+    </td>`;
+  }
+
+  private renderLineNumberButton(
+    line: GrDiffLine,
+    lineNumber: LineNumber,
+    side: Side
+  ) {
+    if (this.hideFileCommentButton && lineNumber === 'FILE') return;
+    if (lineNumber === 'LOST') return;
+    // .lineNumButton has `white-space: pre`, so prettier must not add spaces.
+    // prettier-ignore
+    return html`
+      <button
+        class=${diffClasses('lineNumButton', side)}
+        tabindex="-1"
+        data-value=${lineNumber}
+        aria-label=${ifDefined(
+          this.computeLineNumberAriaLabel(line, lineNumber)
+        )}
+        @mouseenter=${() =>
+          fire(this, 'line-mouse-enter', {lineNum: lineNumber, side})}
+        @mouseleave=${() =>
+          fire(this, 'line-mouse-leave', {lineNum: lineNumber, side})}
+      >${lineNumber === 'FILE' ? 'File' : lineNumber.toString()}</button>
+    `;
+  }
+
+  private computeLineNumberAriaLabel(line: GrDiffLine, lineNumber: LineNumber) {
+    if (lineNumber === 'FILE') return 'Add file comment';
+
+    // Add aria-labels for valid line numbers.
+    // For unified diff, this method will be called with number set to 0 for
+    // the empty line number column for added/removed lines. This should not
+    // be announced to the screenreader.
+    if (lineNumber <= 0) return undefined;
+
+    switch (line.type) {
+      case GrDiffLineType.REMOVE:
+        return `${lineNumber} removed`;
+      case GrDiffLineType.ADD:
+        return `${lineNumber} added`;
+      case GrDiffLineType.BOTH:
+      case GrDiffLineType.BLANK:
+        return undefined;
+    }
+  }
+
+  private renderContentCell(side: Side): TemplateResult {
+    const line = this.line(side);
+    const lineNumber = this.lineNumber(side);
+    assertIsDefined(line, 'line');
+    const extras: string[] = [line.type, side];
+    if (line.type !== GrDiffLineType.BLANK) extras.push('content');
+    if (!line.hasIntralineInfo) extras.push('no-intraline-info');
+    if (line.beforeNumber === 'FILE') extras.push('file');
+    if (line.beforeNumber === 'LOST') extras.push('lost');
+
+    // .content has `white-space: pre`, so prettier must not add spaces.
+    // prettier-ignore
+    return html`
+      <td
+        class=${diffClasses(...extras)}
+        @mouseenter=${() => {
+          if (lineNumber)
+            fire(this, 'line-mouse-enter', {lineNum: lineNumber, side});
+        }}
+        @mouseleave=${() => {
+          if (lineNumber)
+            fire(this, 'line-mouse-leave', {lineNum: lineNumber, side});
+        }}
+      >${this.renderText(side)}${this.renderThreadGroup(side, lineNumber)}</td>
+    `;
+  }
+
+  private renderThreadGroup(side: Side, lineNumber?: LineNumber) {
+    if (!lineNumber) return;
+    // TODO: For the LOST line number the convention is that a <tr> will always
+    // be rendered, but it will not be visible, because of all cells being
+    // empty. For this to work with lit-based rendering we may only render a
+    // thread-group and a <slot> when there is a thread using that slot. The
+    // cleanest solution for that is probably introducing a gr-diff-model, where
+    // each diff row can look up or observe comment threads.
+    // .content has `white-space: pre`, so prettier must not add spaces.
+    // prettier-ignore
+    return html`<div class="thread-group" data-side=${side}><slot name="${side}-${lineNumber}"></slot></div>`;
+  }
+
+  private contentRef(side: Side) {
+    return side === Side.LEFT ? this.contentLeftRef : this.contentRightRef;
+  }
+
+  private lineNumberRef(side: Side) {
+    return side === Side.LEFT
+      ? this.lineNumberLeftRef
+      : this.lineNumberRightRef;
+  }
+
+  private lineNumber(side: Side) {
+    return this.line(side)?.lineNumber(side);
+  }
+
+  private line(side: Side) {
+    return side === Side.LEFT ? this.left : this.right;
+  }
+
+  /**
+   * Returns a 'div' element containing the supplied |text| as its innerText,
+   * with '\t' characters expanded to a width determined by |tabSize|, and the
+   * text wrapped at column |lineLimit|, which may be Infinity if no wrapping is
+   * desired.
+   */
+  private renderText(side: Side) {
+    const line = this.line(side);
+    const lineNumber = this.lineNumber(side);
+    if (lineNumber === 'FILE' || lineNumber === 'LOST') return;
+    // prettier-ignore
+    const textElement = line?.text
+      ? html`<gr-diff-text
+          ${ref(this.contentRef(side))}
+          .text=${line?.text}
+          .tabSize=${this.tabSize}
+          .lineLimit=${this.lineLength}
+          .isResponsive=${isResponsive(this.responsiveMode)}
+        ></gr-diff-text>` : '';
+    // .content has `white-space: pre`, so prettier must not add spaces.
+    // prettier-ignore
+    return html`<div
+        class=${diffClasses('contentText', side)}
+        .ariaLabel=${line?.text ?? ''}
+        data-side=${ifDefined(side)}
+      >${textElement}</div>`;
+  }
+}
+
+declare global {
+  interface HTMLElementTagNameMap {
+    'gr-diff-row': GrDiffRow;
+  }
+}
diff --git a/polygerrit-ui/app/embed/diff/gr-diff-builder/gr-diff-row_test.ts b/polygerrit-ui/app/embed/diff/gr-diff-builder/gr-diff-row_test.ts
new file mode 100644
index 0000000..75214a8
--- /dev/null
+++ b/polygerrit-ui/app/embed/diff/gr-diff-builder/gr-diff-row_test.ts
@@ -0,0 +1,207 @@
+/**
+ * @license
+ * Copyright 2022 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+import '../../../test/common-test-setup';
+import './gr-diff-row';
+import {GrDiffRow} from './gr-diff-row';
+import {fixture, html, assert} from '@open-wc/testing';
+import {GrDiffLine} from '../gr-diff/gr-diff-line';
+import {GrDiffLineType} from '../../../api/diff';
+
+suite('gr-diff-row test', () => {
+  let element: GrDiffRow;
+
+  setup(async () => {
+    element = await fixture<GrDiffRow>(html`<gr-diff-row></gr-diff-row>`);
+    element.isVisible = true;
+    element.addTableWrapperForTesting = true;
+    await element.updateComplete;
+  });
+
+  test('both', async () => {
+    const line = new GrDiffLine(GrDiffLineType.BOTH, 1, 1);
+    line.text = 'lorem ipsum';
+    element.left = line;
+    element.right = line;
+    await element.updateComplete;
+    assert.lightDom.equal(
+      element,
+      /* HTML */ `
+        <table>
+          <tbody>
+            <tr
+              class="diff-row gr-diff side-by-side style-scope"
+              left-type="both"
+              right-type="both"
+              tabindex="-1"
+            >
+              <td class="blame gr-diff style-scope" data-line-number="1"></td>
+              <td class="gr-diff left lineNum style-scope" data-value="1">
+                <button
+                  class="gr-diff left lineNumButton style-scope"
+                  data-value="1"
+                  tabindex="-1"
+                >
+                  1
+                </button>
+              </td>
+              <td
+                class="both content gr-diff left no-intraline-info style-scope"
+              >
+                <div
+                  aria-label="lorem ipsum"
+                  class="contentText gr-diff left style-scope"
+                  data-side="left"
+                >
+                  <gr-diff-text>lorem ipsum</gr-diff-text>
+                </div>
+                <div class="thread-group" data-side="left">
+                  <slot name="left-1"> </slot>
+                </div>
+              </td>
+              <td class="gr-diff lineNum right style-scope" data-value="1">
+                <button
+                  class="gr-diff lineNumButton right style-scope"
+                  data-value="1"
+                  tabindex="-1"
+                >
+                  1
+                </button>
+              </td>
+              <td
+                class="both content gr-diff no-intraline-info right style-scope"
+              >
+                <div
+                  aria-label="lorem ipsum"
+                  class="contentText gr-diff right style-scope"
+                  data-side="right"
+                >
+                  <gr-diff-text>lorem ipsum</gr-diff-text>
+                </div>
+                <div class="thread-group" data-side="right">
+                  <slot name="right-1"> </slot>
+                </div>
+              </td>
+            </tr>
+          </tbody>
+        </table>
+      `
+    );
+  });
+
+  test('add', async () => {
+    const line = new GrDiffLine(GrDiffLineType.ADD, 0, 1);
+    line.text = 'lorem ipsum';
+    element.left = new GrDiffLine(GrDiffLineType.BLANK);
+    element.right = line;
+    await element.updateComplete;
+    assert.lightDom.equal(
+      element,
+      /* HTML */ `
+        <table>
+          <tbody>
+            <tr
+              class="diff-row gr-diff side-by-side style-scope"
+              left-type="blank"
+              right-type="add"
+              tabindex="-1"
+            >
+              <td class="blame gr-diff style-scope" data-line-number="0"></td>
+              <td class="gr-diff left style-scope"></td>
+              <td class="blank gr-diff left no-intraline-info style-scope">
+                <div
+                  aria-label=""
+                  class="contentText gr-diff left style-scope"
+                  data-side="left"
+                ></div>
+              </td>
+              <td class="gr-diff lineNum right style-scope" data-value="1">
+                <button
+                  aria-label="1 added"
+                  class="gr-diff lineNumButton right style-scope"
+                  data-value="1"
+                  tabindex="-1"
+                >
+                  1
+                </button>
+              </td>
+              <td
+                class="add content gr-diff no-intraline-info right style-scope"
+              >
+                <div
+                  aria-label="lorem ipsum"
+                  class="contentText gr-diff right style-scope"
+                  data-side="right"
+                >
+                  <gr-diff-text>lorem ipsum</gr-diff-text>
+                </div>
+                <div class="thread-group" data-side="right">
+                  <slot name="right-1"> </slot>
+                </div>
+              </td>
+            </tr>
+          </tbody>
+        </table>
+      `
+    );
+  });
+
+  test('remove', async () => {
+    const line = new GrDiffLine(GrDiffLineType.REMOVE, 1, 0);
+    line.text = 'lorem ipsum';
+    element.left = line;
+    element.right = new GrDiffLine(GrDiffLineType.BLANK);
+    await element.updateComplete;
+    assert.lightDom.equal(
+      element,
+      /* HTML */ `
+        <table>
+          <tbody>
+            <tr
+              class="diff-row gr-diff side-by-side style-scope"
+              left-type="remove"
+              right-type="blank"
+              tabindex="-1"
+            >
+              <td class="blame gr-diff style-scope" data-line-number="1"></td>
+              <td class="gr-diff left lineNum style-scope" data-value="1">
+                <button
+                  aria-label="1 removed"
+                  class="gr-diff left lineNumButton style-scope"
+                  data-value="1"
+                  tabindex="-1"
+                >
+                  1
+                </button>
+              </td>
+              <td
+                class="content gr-diff left no-intraline-info remove style-scope"
+              >
+                <div
+                  aria-label="lorem ipsum"
+                  class="contentText gr-diff left style-scope"
+                  data-side="left"
+                >
+                  <gr-diff-text>lorem ipsum</gr-diff-text>
+                </div>
+                <div class="thread-group" data-side="left">
+                  <slot name="left-1"> </slot>
+                </div>
+              </td>
+              <td class="gr-diff right style-scope"></td>
+              <td class="blank gr-diff no-intraline-info right style-scope">
+                <div
+                  aria-label=""
+                  class="contentText gr-diff right style-scope"
+                  data-side="right"
+                ></div>
+              </td>
+            </tr>
+          </tbody>
+        </table>
+      `
+    );
+  });
+});
diff --git a/polygerrit-ui/app/embed/diff/gr-diff-builder/gr-diff-section.ts b/polygerrit-ui/app/embed/diff/gr-diff-builder/gr-diff-section.ts
new file mode 100644
index 0000000..a1cde67
--- /dev/null
+++ b/polygerrit-ui/app/embed/diff/gr-diff-builder/gr-diff-section.ts
@@ -0,0 +1,240 @@
+/**
+ * @license
+ * Copyright 2022 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+import {html, LitElement} from 'lit';
+import {customElement, property, state} from 'lit/decorators.js';
+import {
+  DiffInfo,
+  DiffLayer,
+  DiffViewMode,
+  MovedLinkClickedEventDetail,
+  RenderPreferences,
+  Side,
+  LineNumber,
+  DiffPreferencesInfo,
+} from '../../../api/diff';
+import {GrDiffGroup, GrDiffGroupType} from '../gr-diff/gr-diff-group';
+import {countLines, diffClasses} from '../gr-diff/gr-diff-utils';
+import {GrDiffRow} from './gr-diff-row';
+import '../gr-context-controls/gr-context-controls-section';
+import '../gr-context-controls/gr-context-controls';
+import '../gr-range-header/gr-range-header';
+import './gr-diff-row';
+import {whenVisible} from '../../../utils/dom-util';
+
+@customElement('gr-diff-section')
+export class GrDiffSection extends LitElement {
+  @property({type: Object})
+  group?: GrDiffGroup;
+
+  @property({type: Object})
+  diff?: DiffInfo;
+
+  @property({type: Object})
+  renderPrefs?: RenderPreferences;
+
+  @property({type: Object})
+  diffPrefs?: DiffPreferencesInfo;
+
+  @property({type: Object})
+  layers: DiffLayer[] = [];
+
+  /**
+   * While not visible we are trying to optimize rendering performance by
+   * rendering a simpler version of the diff.
+   */
+  @state()
+  isVisible = false;
+
+  /**
+   * Semantic DOM diff testing does not work with just table fragments, so when
+   * running such tests the render() method has to wrap the DOM in a proper
+   * <table> element.
+   */
+  @state()
+  addTableWrapperForTesting = false;
+
+  /**
+   * The browser API for handling selection does not (yet) work for selection
+   * across multiple shadow DOM elements. So we are rendering gr-diff components
+   * into the light DOM instead of the shadow DOM by overriding this method,
+   * which was the recommended workaround by the lit team.
+   * See also https://github.com/WICG/webcomponents/issues/79.
+   */
+  override createRenderRoot() {
+    return this;
+  }
+
+  override connectedCallback() {
+    super.connectedCallback();
+    // TODO: Refine this obviously simplistic approach to optimized rendering.
+    whenVisible(this.parentElement!, () => (this.isVisible = true), 1000);
+  }
+
+  override render() {
+    if (!this.group) return;
+    const extras: string[] = [];
+    extras.push('section');
+    extras.push(this.group.type);
+    if (this.group.isTotal()) extras.push('total');
+    if (this.group.dueToRebase) extras.push('dueToRebase');
+    if (this.group.moveDetails) extras.push('dueToMove');
+    if (this.group.ignoredWhitespaceOnly) extras.push('ignoredWhitespaceOnly');
+
+    const isControl = this.group.type === GrDiffGroupType.CONTEXT_CONTROL;
+    const pairs = isControl ? [] : this.group.getSideBySidePairs();
+    const body = html`
+      <tbody class=${diffClasses(...extras)}>
+        ${this.renderContextControls()} ${this.renderMoveControls()}
+        ${pairs.map(pair => {
+          const leftCl = `left-${pair.left.lineNumber(Side.LEFT)}`;
+          const rightCl = `right-${pair.right.lineNumber(Side.RIGHT)}`;
+          return html`
+            <gr-diff-row
+              class="${leftCl} ${rightCl}"
+              .left=${pair.left}
+              .right=${pair.right}
+              .layers=${this.layers}
+              .lineLength=${this.diffPrefs?.line_length ?? 80}
+              .tabSize=${this.diffPrefs?.tab_size ?? 2}
+              .isVisible=${this.isVisible}
+            >
+            </gr-diff-row>
+          `;
+        })}
+      </tbody>
+    `;
+    if (this.addTableWrapperForTesting) {
+      return html`<table>
+        ${body}
+      </table>`;
+    }
+    return body;
+  }
+
+  getDiffRows(): GrDiffRow[] {
+    return [...this.querySelectorAll<GrDiffRow>('gr-diff-row')];
+  }
+
+  private renderContextControls() {
+    if (this.group?.type !== GrDiffGroupType.CONTEXT_CONTROL) return;
+
+    const leftStart = this.group.lineRange.left.start_line;
+    const leftEnd = this.group.lineRange.left.end_line;
+    const firstGroupIsSkipped = !!this.group.contextGroups[0].skip;
+    const lastGroupIsSkipped =
+      !!this.group.contextGroups[this.group.contextGroups.length - 1].skip;
+    const lineCountLeft = countLines(this.diff, Side.LEFT);
+    const containsWholeFile = lineCountLeft === leftEnd - leftStart + 1;
+    const showAbove =
+      (leftStart > 1 && !firstGroupIsSkipped) || containsWholeFile;
+    const showBelow = leftEnd < lineCountLeft && !lastGroupIsSkipped;
+
+    return html`
+      <gr-context-controls-section
+        .showAbove=${showAbove}
+        .showBelow=${showBelow}
+        .group=${this.group}
+        .diff=${this.diff}
+        .renderPrefs=${this.renderPrefs}
+        .viewMode=${DiffViewMode.SIDE_BY_SIDE}
+      >
+      </gr-context-controls-section>
+    `;
+  }
+
+  findRow(side: Side, lineNumber: LineNumber): GrDiffRow | undefined {
+    return (
+      this.querySelector<GrDiffRow>(`gr-diff-row.${side}-${lineNumber}`) ??
+      undefined
+    );
+  }
+
+  private renderMoveControls() {
+    if (!this.group?.moveDetails) return;
+    const movedIn = this.group.adds.length > 0;
+    const plainCell = html`<td class=${diffClasses()}></td>`;
+    const lineNumberCell = html`
+      <td class=${diffClasses('moveControlsLineNumCol')}></td>
+    `;
+    const moveCell = html`
+      <td class=${diffClasses('moveHeader')}>
+        <gr-range-header class=${diffClasses()} icon="gr-icons:move-item">
+          ${this.renderMoveDescription(movedIn)}
+        </gr-range-header>
+      </td>
+    `;
+    return html`
+      <tr
+        class=${diffClasses('moveControls', movedIn ? 'movedIn' : 'movedOut')}
+      >
+        ${lineNumberCell} ${movedIn ? plainCell : moveCell} ${lineNumberCell}
+        ${movedIn ? moveCell : plainCell}
+      </tr>
+    `;
+  }
+
+  private renderMoveDescription(movedIn: boolean) {
+    if (this.group?.moveDetails?.range) {
+      const {changed, range} = this.group.moveDetails;
+      const otherSide = movedIn ? Side.LEFT : Side.RIGHT;
+      const andChangedLabel = changed ? 'and changed ' : '';
+      const direction = movedIn ? 'from' : 'to';
+      const textLabel = `Moved ${andChangedLabel}${direction} lines `;
+      return html`
+        <div class=${diffClasses()}>
+          <span class=${diffClasses()}>${textLabel}</span>
+          ${this.renderMovedLineAnchor(range.start, otherSide)}
+          <span class=${diffClasses()}> - </span>
+          ${this.renderMovedLineAnchor(range.end, otherSide)}
+        </div>
+      `;
+    }
+
+    return html`
+      <div class=${diffClasses()}>
+        <span class=${diffClasses()}
+          >${movedIn ? 'Moved in' : 'Moved out'}</span
+        >
+      </div>
+    `;
+  }
+
+  private renderMovedLineAnchor(line: number, side: Side) {
+    const listener = (e: MouseEvent) => {
+      e.preventDefault();
+      this.handleMovedLineAnchorClick(e.target, side, line);
+    };
+    // `href` is not actually used but important for Screen Readers
+    return html`
+      <a class=${diffClasses()} href=${`#${line}`} @click=${listener}
+        >${line}</a
+      >
+    `;
+  }
+
+  private handleMovedLineAnchorClick(
+    anchor: EventTarget | null,
+    side: Side,
+    line: number
+  ) {
+    anchor?.dispatchEvent(
+      new CustomEvent<MovedLinkClickedEventDetail>('moved-link-clicked', {
+        detail: {
+          lineNum: line,
+          side,
+        },
+        composed: true,
+        bubbles: true,
+      })
+    );
+  }
+}
+
+declare global {
+  interface HTMLElementTagNameMap {
+    'gr-diff-section': GrDiffSection;
+  }
+}
diff --git a/polygerrit-ui/app/embed/diff/gr-diff-builder/gr-diff-section_test.ts b/polygerrit-ui/app/embed/diff/gr-diff-builder/gr-diff-section_test.ts
new file mode 100644
index 0000000..043cbde
--- /dev/null
+++ b/polygerrit-ui/app/embed/diff/gr-diff-builder/gr-diff-section_test.ts
@@ -0,0 +1,213 @@
+/**
+ * @license
+ * Copyright 2022 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+import '../../../test/common-test-setup';
+import './gr-diff-section';
+import {GrDiffSection} from './gr-diff-section';
+import {fixture, html, assert} from '@open-wc/testing';
+import {GrDiffGroup, GrDiffGroupType} from '../gr-diff/gr-diff-group';
+import {GrDiffLine} from '../gr-diff/gr-diff-line';
+import {GrDiffLineType} from '../../../api/diff';
+
+suite('gr-diff-section test', () => {
+  let element: GrDiffSection;
+
+  setup(async () => {
+    element = await fixture<GrDiffSection>(
+      html`<gr-diff-section></gr-diff-section>`
+    );
+    element.addTableWrapperForTesting = true;
+    element.isVisible = true;
+    await element.updateComplete;
+  });
+
+  test('3 normal unchanged rows', async () => {
+    const lines = [
+      new GrDiffLine(GrDiffLineType.BOTH, 1, 1),
+      new GrDiffLine(GrDiffLineType.BOTH, 1, 1),
+      new GrDiffLine(GrDiffLineType.BOTH, 1, 1),
+    ];
+    lines[0].text = 'asdf';
+    lines[1].text = 'qwer';
+    lines[2].text = 'zxcv';
+    const group = new GrDiffGroup({type: GrDiffGroupType.BOTH, lines});
+    element.group = group;
+    await element.updateComplete;
+    assert.lightDom.equal(
+      element,
+      /* HTML */ `
+        <gr-diff-row class="left-1 right-1"> </gr-diff-row>
+        <gr-diff-row class="left-1 right-1"> </gr-diff-row>
+        <gr-diff-row class="left-1 right-1"> </gr-diff-row>
+        <table>
+          <tbody class="both gr-diff section style-scope">
+            <tr
+              class="diff-row gr-diff side-by-side style-scope"
+              left-type="both"
+              right-type="both"
+              tabindex="-1"
+            >
+              <td class="blame gr-diff style-scope" data-line-number="1"></td>
+              <td class="gr-diff left lineNum style-scope" data-value="1">
+                <button
+                  class="gr-diff left lineNumButton style-scope"
+                  data-value="1"
+                  tabindex="-1"
+                >
+                  1
+                </button>
+              </td>
+              <td
+                class="both content gr-diff left no-intraline-info style-scope"
+              >
+                <div
+                  aria-label="asdf"
+                  class="contentText gr-diff left style-scope"
+                  data-side="left"
+                >
+                  <gr-diff-text></gr-diff-text>
+                </div>
+                <div class="thread-group" data-side="left">
+                  <slot name="left-1"> </slot>
+                </div>
+              </td>
+              <td class="gr-diff lineNum right style-scope" data-value="1">
+                <button
+                  class="gr-diff lineNumButton right style-scope"
+                  data-value="1"
+                  tabindex="-1"
+                >
+                  1
+                </button>
+              </td>
+              <td
+                class="both content gr-diff no-intraline-info right style-scope"
+              >
+                <div
+                  aria-label="asdf"
+                  class="contentText gr-diff right style-scope"
+                  data-side="right"
+                >
+                  <gr-diff-text></gr-diff-text>
+                </div>
+                <div class="thread-group" data-side="right">
+                  <slot name="right-1"> </slot>
+                </div>
+              </td>
+            </tr>
+            <tr
+              class="diff-row gr-diff side-by-side style-scope"
+              left-type="both"
+              right-type="both"
+              tabindex="-1"
+            >
+              <td class="blame gr-diff style-scope" data-line-number="1"></td>
+              <td class="gr-diff left lineNum style-scope" data-value="1">
+                <button
+                  class="gr-diff left lineNumButton style-scope"
+                  data-value="1"
+                  tabindex="-1"
+                >
+                  1
+                </button>
+              </td>
+              <td
+                class="both content gr-diff left no-intraline-info style-scope"
+              >
+                <div
+                  aria-label="qwer"
+                  class="contentText gr-diff left style-scope"
+                  data-side="left"
+                >
+                  <gr-diff-text></gr-diff-text>
+                </div>
+                <div class="thread-group" data-side="left">
+                  <slot name="left-1"> </slot>
+                </div>
+              </td>
+              <td class="gr-diff lineNum right style-scope" data-value="1">
+                <button
+                  class="gr-diff lineNumButton right style-scope"
+                  data-value="1"
+                  tabindex="-1"
+                >
+                  1
+                </button>
+              </td>
+              <td
+                class="both content gr-diff no-intraline-info right style-scope"
+              >
+                <div
+                  aria-label="qwer"
+                  class="contentText gr-diff right style-scope"
+                  data-side="right"
+                >
+                  <gr-diff-text></gr-diff-text>
+                </div>
+                <div class="thread-group" data-side="right">
+                  <slot name="right-1"> </slot>
+                </div>
+              </td>
+            </tr>
+            <tr
+              class="diff-row gr-diff side-by-side style-scope"
+              left-type="both"
+              right-type="both"
+              tabindex="-1"
+            >
+              <td class="blame gr-diff style-scope" data-line-number="1"></td>
+              <td class="gr-diff left lineNum style-scope" data-value="1">
+                <button
+                  class="gr-diff left lineNumButton style-scope"
+                  data-value="1"
+                  tabindex="-1"
+                >
+                  1
+                </button>
+              </td>
+              <td
+                class="both content gr-diff left no-intraline-info style-scope"
+              >
+                <div
+                  aria-label="zxcv"
+                  class="contentText gr-diff left style-scope"
+                  data-side="left"
+                >
+                  <gr-diff-text></gr-diff-text>
+                </div>
+                <div class="thread-group" data-side="left">
+                  <slot name="left-1"> </slot>
+                </div>
+              </td>
+              <td class="gr-diff lineNum right style-scope" data-value="1">
+                <button
+                  class="gr-diff lineNumButton right style-scope"
+                  data-value="1"
+                  tabindex="-1"
+                >
+                  1
+                </button>
+              </td>
+              <td
+                class="both content gr-diff no-intraline-info right style-scope"
+              >
+                <div
+                  aria-label="zxcv"
+                  class="contentText gr-diff right style-scope"
+                  data-side="right"
+                >
+                  <gr-diff-text></gr-diff-text>
+                </div>
+                <div class="thread-group" data-side="right">
+                  <slot name="right-1"> </slot>
+                </div>
+              </td>
+            </tr>
+          </tbody>
+        </table>
+      `
+    );
+  });
+});
diff --git a/polygerrit-ui/app/embed/diff/gr-diff-builder/gr-diff-text.ts b/polygerrit-ui/app/embed/diff/gr-diff-builder/gr-diff-text.ts
new file mode 100644
index 0000000..ebc5724
--- /dev/null
+++ b/polygerrit-ui/app/embed/diff/gr-diff-builder/gr-diff-text.ts
@@ -0,0 +1,138 @@
+/**
+ * @license
+ * Copyright 2022 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+import {LitElement, html, TemplateResult} from 'lit';
+import {customElement, property} from 'lit/decorators.js';
+import {diffClasses} from '../gr-diff/gr-diff-utils';
+
+const SURROGATE_PAIR = /[\uD800-\uDBFF][\uDC00-\uDFFF]/;
+
+const TAB = '\t';
+
+@customElement('gr-diff-text')
+export class GrDiffText extends LitElement {
+  /**
+   * The browser API for handling selection does not (yet) work for selection
+   * across multiple shadow DOM elements. So we are rendering gr-diff components
+   * into the light DOM instead of the shadow DOM by overriding this method,
+   * which was the recommended workaround by the lit team.
+   * See also https://github.com/WICG/webcomponents/issues/79.
+   */
+  override createRenderRoot() {
+    return this;
+  }
+
+  @property({type: String})
+  text = '';
+
+  @property({type: Boolean})
+  isResponsive = false;
+
+  @property({type: Number})
+  tabSize = 2;
+
+  @property({type: Number})
+  lineLimit = 80;
+
+  /** Temporary state while rendering. */
+  private textOffset = 0;
+
+  /** Temporary state while rendering. */
+  private columnPos = 0;
+
+  /** Temporary state while rendering. */
+  private pieces: (string | TemplateResult)[] = [];
+
+  /** Split up the string into tabs, surrogate pairs and regular segments. */
+  override render() {
+    this.textOffset = 0;
+    this.columnPos = 0;
+    this.pieces = [];
+    const splitByTab = this.text.split('\t');
+    for (let i = 0; i < splitByTab.length; i++) {
+      const splitBySurrogate = splitByTab[i].split(SURROGATE_PAIR);
+      for (let j = 0; j < splitBySurrogate.length; j++) {
+        this.renderSegment(splitBySurrogate[j]);
+        if (j < splitBySurrogate.length - 1) {
+          this.renderSurrogatePair();
+        }
+      }
+      if (i < splitByTab.length - 1) {
+        this.renderTab();
+      }
+    }
+    if (this.textOffset !== this.text.length) throw new Error('unfinished');
+    return this.pieces;
+  }
+
+  /** Render regular characters, but insert line breaks appropriately. */
+  private renderSegment(segment: string) {
+    let segmentOffset = 0;
+    while (segmentOffset < segment.length) {
+      const newOffset = Math.min(
+        segment.length,
+        segmentOffset + this.lineLimit - this.columnPos
+      );
+      this.renderString(segment.substring(segmentOffset, newOffset));
+      segmentOffset = newOffset;
+      if (segmentOffset < segment.length && this.columnPos === this.lineLimit) {
+        this.renderLineBreak();
+      }
+    }
+  }
+
+  /** Render regular characters. */
+  private renderString(s: string) {
+    if (s.length === 0) return;
+    this.pieces.push(s);
+    this.textOffset += s.length;
+    this.columnPos += s.length;
+    if (this.columnPos > this.lineLimit) throw new Error('over line limit');
+  }
+
+  /** Render a tab character. */
+  private renderTab() {
+    let tabSize = this.tabSize - (this.columnPos % this.tabSize);
+    if (this.columnPos + tabSize > this.lineLimit) {
+      this.renderLineBreak();
+      tabSize = this.tabSize;
+    }
+    const piece = html`<span
+      class=${diffClasses('tab')}
+      style="tab-size: ${tabSize}; -moz-tab-size: ${tabSize};"
+      >${TAB}</span
+    >`;
+    this.pieces.push(piece);
+    this.textOffset += 1;
+    this.columnPos += tabSize;
+  }
+
+  /** Render a surrogate pair: string length is 2, but is just 1 char. */
+  private renderSurrogatePair() {
+    if (this.columnPos === this.lineLimit) {
+      this.renderLineBreak();
+    }
+    this.pieces.push(this.text.substring(this.textOffset, this.textOffset + 2));
+    this.textOffset += 2;
+    this.columnPos += 1;
+  }
+
+  /** Render a line break, don't advance text offset, reset col position. */
+  private renderLineBreak() {
+    if (this.isResponsive) {
+      this.pieces.push(html`<wbr class=${diffClasses()}></wbr>`);
+    } else {
+      this.pieces.push(html`<span class=${diffClasses('br')}></span>`);
+    }
+    // this.textOffset += 0;
+    this.columnPos = 0;
+  }
+}
+
+declare global {
+  interface HTMLElementTagNameMap {
+    'gr-diff-text': GrDiffText;
+  }
+}
diff --git a/polygerrit-ui/app/embed/diff/gr-diff-builder/gr-diff-text_test.ts b/polygerrit-ui/app/embed/diff/gr-diff-builder/gr-diff-text_test.ts
new file mode 100644
index 0000000..9e8d735
--- /dev/null
+++ b/polygerrit-ui/app/embed/diff/gr-diff-builder/gr-diff-text_test.ts
@@ -0,0 +1,153 @@
+/**
+ * @license
+ * Copyright 2022 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+import '../../../test/common-test-setup';
+import './gr-diff-text';
+import {GrDiffText} from './gr-diff-text';
+import {fixture, html, assert} from '@open-wc/testing';
+
+const LINE_BREAK = '<span class="style-scope gr-diff br"></span>';
+
+const TAB = '<span class="" style=""></span>';
+
+const TAB_IGNORE = ['class', 'style'];
+
+suite('gr-diff-text test', () => {
+  let element: GrDiffText;
+
+  setup(async () => {
+    element = await fixture<GrDiffText>(html`<gr-diff-text></gr-diff-text>`);
+    await element.updateComplete;
+  });
+
+  const check = async (
+    text: string,
+    html: string,
+    ignoreAttributes: string[] = []
+  ) => {
+    element.text = text;
+    element.tabSize = 4;
+    element.lineLimit = 10;
+    await element.updateComplete;
+    assert.lightDom.equal(element, html, {ignoreAttributes});
+  };
+
+  suite('lit rendering', () => {
+    test('renderText newlines 1', () => {
+      check('abcdef', 'abcdef');
+      check('a'.repeat(20), `aaaaaaaaaa${LINE_BREAK}aaaaaaaaaa`);
+    });
+
+    test('renderText newlines 2', () => {
+      check(
+        '<span class="thumbsup">👍</span>',
+        '&lt;span clas' +
+          LINE_BREAK +
+          's="thumbsu' +
+          LINE_BREAK +
+          'p"&gt;👍&lt;/span' +
+          LINE_BREAK +
+          '&gt;'
+      );
+    });
+
+    test('renderText newlines 3', () => {
+      check(
+        '01234\t56789',
+        '01234' + TAB + '56' + LINE_BREAK + '789',
+        TAB_IGNORE
+      );
+    });
+
+    test('renderText newlines 4', async () => {
+      element.lineLimit = 20;
+      await element.updateComplete;
+      check(
+        '👍'.repeat(58),
+        '👍'.repeat(20) +
+          LINE_BREAK +
+          '👍'.repeat(20) +
+          LINE_BREAK +
+          '👍'.repeat(18)
+      );
+    });
+
+    test('tab wrapper style', async () => {
+      for (const size of [1, 3, 8, 55]) {
+        element.tabSize = size;
+        await element.updateComplete;
+        check(
+          '\t',
+          /* HTML */ `
+            <span
+              class="style-scope gr-diff tab"
+              style="tab-size: ${size}; -moz-tab-size: ${size};"
+            >
+            </span>
+          `
+        );
+      }
+    });
+
+    test('tab wrapper insertion', () => {
+      check('abc\tdef', 'abc' + TAB + 'def', TAB_IGNORE);
+    });
+
+    test('escaping HTML', async () => {
+      element.lineLimit = 100;
+      await element.updateComplete;
+      check(
+        '<script>alert("XSS");<' + '/script>',
+        '&lt;script&gt;alert("XSS");&lt;/script&gt;'
+      );
+      check('& < > " \' / `', '&amp; &lt; &gt; " \' / `');
+    });
+
+    test('text length with tabs and unicode', async () => {
+      async function expectTextLength(
+        text: string,
+        tabSize: number,
+        expected: number
+      ) {
+        element.text = text;
+        element.tabSize = tabSize;
+        element.lineLimit = expected;
+        await element.updateComplete;
+        const result = element.innerHTML;
+
+        // Must not contain a line break.
+        assert.isNotOk(element.querySelector('span.br'));
+
+        // Increasing the line limit by 1 should not change anything.
+        element.lineLimit = expected + 1;
+        await element.updateComplete;
+        const resultPlusOne = element.innerHTML;
+        assert.equal(resultPlusOne, result);
+
+        // Increasing the line limit to infinity should not change anything.
+        element.lineLimit = Infinity;
+        await element.updateComplete;
+        const resultInf = element.innerHTML;
+        assert.equal(resultInf, result);
+
+        // Decreasing the line limit by 1 should introduce a line break.
+        element.lineLimit = expected + 1;
+        await element.updateComplete;
+        assert.isNotOk(element.querySelector('span.br'));
+      }
+      expectTextLength('12345', 4, 5);
+      expectTextLength('\t\t12', 4, 10);
+      expectTextLength('abc💢123', 4, 7);
+      expectTextLength('abc\t', 8, 8);
+      expectTextLength('abc\t\t', 10, 20);
+      expectTextLength('', 10, 0);
+      // 17 Thai combining chars.
+      expectTextLength('ก้้้้้้้้้้้้้้้้', 4, 17);
+      expectTextLength('abc\tde', 10, 12);
+      expectTextLength('abc\tde\t', 10, 20);
+      expectTextLength('\t\t\t\t\t', 20, 100);
+    });
+  });
+});
diff --git a/polygerrit-ui/app/embed/diff/gr-diff/gr-diff-line.ts b/polygerrit-ui/app/embed/diff/gr-diff/gr-diff-line.ts
index 7ca4a03..338a275 100644
--- a/polygerrit-ui/app/embed/diff/gr-diff/gr-diff-line.ts
+++ b/polygerrit-ui/app/embed/diff/gr-diff/gr-diff-line.ts
@@ -7,6 +7,7 @@
   GrDiffLine as GrDiffLineApi,
   GrDiffLineType,
   LineNumber,
+  Side,
 } from '../../../api/diff';
 
 export {GrDiffLineType};
@@ -27,6 +28,10 @@
 
   text = '';
 
+  lineNumber(side: Side) {
+    return side === Side.LEFT ? this.beforeNumber : this.afterNumber;
+  }
+
   // TODO(TS): remove this properties
   static readonly Type = GrDiffLineType;
 
diff --git a/polygerrit-ui/app/embed/diff/gr-diff/gr-diff-utils.ts b/polygerrit-ui/app/embed/diff/gr-diff/gr-diff-utils.ts
index 2b61c8c..1f54a89 100644
--- a/polygerrit-ui/app/embed/diff/gr-diff/gr-diff-utils.ts
+++ b/polygerrit-ui/app/embed/diff/gr-diff/gr-diff-utils.ts
@@ -34,12 +34,20 @@
  *   Graphemes: http://unicode.org/reports/tr29/#Grapheme_Cluster_Boundaries
  *   A proposed JS API: https://github.com/tc39/proposal-intl-segmenter
  */
-const REGEX_TAB_OR_SURROGATE_PAIR = /\t|[\uD800-\uDBFF][\uDC00-\uDFFF]/;
+export const REGEX_TAB_OR_SURROGATE_PAIR = /\t|[\uD800-\uDBFF][\uDC00-\uDFFF]/;
 
 // If any line of the diff is more than the character limit, then disable
 // syntax highlighting for the entire file.
 export const SYNTAX_MAX_LINE_LENGTH = 500;
 
+export function countLines(diff?: DiffInfo, side?: Side) {
+  if (!diff?.content || !side) return 0;
+  return diff.content.reduce((sum, chunk) => {
+    const sideChunk = side === Side.LEFT ? chunk.a : chunk.b;
+    return sum + (sideChunk?.length ?? chunk.ab?.length ?? chunk.skip ?? 0);
+  }, 0);
+}
+
 export function getResponsiveMode(
   prefs: DiffPreferencesInfo,
   renderPrefs?: RenderPreferences
@@ -54,7 +62,7 @@
   return 'NONE';
 }
 
-export function isResponsive(responsiveMode: DiffResponsiveMode) {
+export function isResponsive(responsiveMode?: DiffResponsiveMode) {
   return (
     responsiveMode === 'FULL_RESPONSIVE' || responsiveMode === 'SHRINK_ONLY'
   );
@@ -105,7 +113,12 @@
         return null;
       }
     }
-    node = node.previousSibling ?? node.parentElement ?? undefined;
+    node =
+      (node as Element).assignedSlot ??
+      (node as ShadowRoot).host ??
+      node.previousSibling ??
+      node.parentNode ??
+      undefined;
   }
   return null;
 }
@@ -184,6 +197,19 @@
 }
 
 /**
+ * Simple helper method for creating element classes in the context of
+ * gr-diff.
+ *
+ * We are adding 'style-scope', 'gr-diff' classes for compatibility with
+ * Shady DOM. TODO: Is that still required??
+ *
+ * Otherwise this is just a super simple convenience function.
+ */
+export function diffClasses(...additionalClasses: string[]) {
+  return ['style-scope', 'gr-diff', ...additionalClasses].join(' ');
+}
+
+/**
  * Simple helper method for creating elements in the context of gr-diff.
  *
  * Otherwise this is just a super simple convenience function.
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 806e7b2..91b7e5e 100644
--- a/polygerrit-ui/app/embed/diff/gr-diff/gr-diff.ts
+++ b/polygerrit-ui/app/embed/diff/gr-diff/gr-diff.ts
@@ -967,6 +967,12 @@
           */
           z-index: 10;
         }
+
+        gr-diff-section,
+        gr-context-controls-section,
+        gr-diff-row {
+          display: contents;
+        }
       `,
     ];
   }