Merge "Use correct type for diffPrefs.ignore_whitespace" into stable-3.6
diff --git a/polygerrit-ui/app/elements/change-list/gr-change-list-view/gr-change-list-view_test.ts b/polygerrit-ui/app/elements/change-list/gr-change-list-view/gr-change-list-view_test.ts
index c37c635..0633f46 100644
--- a/polygerrit-ui/app/elements/change-list/gr-change-list-view/gr-change-list-view_test.ts
+++ b/polygerrit-ui/app/elements/change-list/gr-change-list-view/gr-change-list-view_test.ts
@@ -20,7 +20,6 @@
 import {GrChangeListView} from './gr-change-list-view';
 import {page} from '../../../utils/page-wrapper-utils';
 import {GerritNav} from '../../core/gr-navigation/gr-navigation';
-import 'lodash/lodash';
 import {
   mockPromise,
   query,
@@ -140,7 +139,9 @@
   });
 
   test('prevArrow', async () => {
-    element.changes = _.times(25, _.constant(createChange()));
+    element.changes = Array(25)
+      .fill(0)
+      .map(_ => createChange());
     element.offset = 0;
     element.loading = false;
     await element.updateComplete;
@@ -152,32 +153,34 @@
   });
 
   test('nextArrow', async () => {
-    element.changes = _.times(
-      25,
-      _.constant({...createChange(), _more_changes: true})
-    ) as ChangeInfo[];
+    element.changes = Array(25)
+      .fill(0)
+      .map(_ => ({...createChange(), _more_changes: true} as ChangeInfo));
     element.loading = false;
     await element.updateComplete;
     assert.isOk(query(element, '#nextArrow'));
 
-    element.changes = _.times(25, _.constant(createChange()));
+    element.changes = Array(25)
+      .fill(0)
+      .map(_ => createChange());
     await element.updateComplete;
     assert.isNotOk(query(element, '#nextArrow'));
   });
 
   test('handleNextPage', async () => {
     const showStub = sinon.stub(page, 'show');
-    element.changes = _.times(25, _.constant(createChange()));
+    element.changes = Array(25)
+      .fill(0)
+      .map(_ => createChange());
     element.changesPerPage = 10;
     element.loading = false;
     await element.updateComplete;
     element.handleNextPage();
     assert.isFalse(showStub.called);
 
-    element.changes = _.times(
-      25,
-      _.constant({...createChange(), _more_changes: true})
-    ) as ChangeInfo[];
+    element.changes = Array(25)
+      .fill(0)
+      .map(_ => ({...createChange(), _more_changes: true} as ChangeInfo));
     element.loading = false;
     await element.updateComplete;
     element.handleNextPage();
@@ -187,7 +190,9 @@
   test('handlePreviousPage', async () => {
     const showStub = sinon.stub(page, 'show');
     element.offset = 0;
-    element.changes = _.times(25, _.constant(createChange()));
+    element.changes = Array(25)
+      .fill(0)
+      .map(_ => createChange());
     element.changesPerPage = 10;
     element.loading = false;
     await element.updateComplete;
diff --git a/polygerrit-ui/app/elements/change/gr-change-view/gr-change-view_test.ts b/polygerrit-ui/app/elements/change/gr-change-view/gr-change-view_test.ts
index e10105f..80cf8a4 100644
--- a/polygerrit-ui/app/elements/change/gr-change-view/gr-change-view_test.ts
+++ b/polygerrit-ui/app/elements/change/gr-change-view/gr-change-view_test.ts
@@ -34,8 +34,6 @@
 import {GerritNav} from '../../core/gr-navigation/gr-navigation';
 import {getPluginLoader} from '../../shared/gr-js-api-interface/gr-plugin-loader';
 import {EventType, PluginApi} from '../../../api/plugin';
-
-import 'lodash/lodash';
 import {
   mockPromise,
   queryAndAssert,
diff --git a/polygerrit-ui/app/elements/change/gr-reply-dialog/gr-reply-dialog.ts b/polygerrit-ui/app/elements/change/gr-reply-dialog/gr-reply-dialog.ts
index 85c92a0..4be988f 100644
--- a/polygerrit-ui/app/elements/change/gr-reply-dialog/gr-reply-dialog.ts
+++ b/polygerrit-ui/app/elements/change/gr-reply-dialog/gr-reply-dialog.ts
@@ -245,9 +245,6 @@
   reviewerConfirmationOverlay?: GrOverlay;
 
   @state()
-  hasDrafts = false;
-
-  @state()
   draft = '';
 
   @state()
@@ -596,12 +593,6 @@
   ];
 
   override willUpdate(changedProperties: PropertyValues) {
-    if (
-      changedProperties.has('draft') ||
-      changedProperties.has('draftCommentThreads')
-    ) {
-      this.computeHasDrafts();
-    }
     if (changedProperties.has('draft')) {
       this.draftChanged(changedProperties.get('draft') as string);
     }
@@ -642,7 +633,7 @@
       changedProperties.has('draftCommentThreads') ||
       changedProperties.has('includeComments') ||
       changedProperties.has('labelsChanged') ||
-      changedProperties.has('hasDrafts')
+      changedProperties.has('draft')
     ) {
       this.computeNewAttention();
     }
@@ -1230,7 +1221,7 @@
     }
   }
 
-  computeHasDrafts() {
+  hasDrafts() {
     if (this.draftCommentThreads === undefined) return false;
     return this.draft.length > 0 || this.draftCommentThreads.length > 0;
   }
@@ -1647,7 +1638,7 @@
       const notIsReviewerAndHasDraftOrLabel = (r: AccountInfo) =>
         !(
           r._account_id === this.account!._account_id &&
-          (this.hasDrafts || hasVote)
+          (this.hasDrafts() || hasVote)
         );
       this.reviewers
         .filter(r => isAccount(r))
@@ -1655,7 +1646,7 @@
         .filter(notIsReviewerAndHasDraftOrLabel)
         .forEach(r => newAttention.add((r as AccountInfo)._account_id!));
       // Add owner and uploader, if someone else replies.
-      if (this.hasDrafts || hasVote) {
+      if (this.hasDrafts() || hasVote) {
         if (this.uploader?._account_id && !isUploader) {
           newAttention.add(this.uploader._account_id);
         }
diff --git a/polygerrit-ui/app/elements/change/gr-reply-dialog/gr-reply-dialog_test.ts b/polygerrit-ui/app/elements/change/gr-reply-dialog/gr-reply-dialog_test.ts
index 78afaca..2bf2ef1 100644
--- a/polygerrit-ui/app/elements/change/gr-reply-dialog/gr-reply-dialog_test.ts
+++ b/polygerrit-ui/app/elements/change/gr-reply-dialog/gr-reply-dialog_test.ts
@@ -225,7 +225,9 @@
         ],
       },
       reviewers: [],
-      add_to_attention_set: [],
+      add_to_attention_set: [
+        {reason: '<GERRIT_ACCOUNT_1> replied on the change', user: 999},
+      ],
       remove_from_attention_set: [],
       ignore_automatic_attention_set_rules: true,
     });
@@ -347,7 +349,6 @@
     element.ccs = [];
     element.draftCommentThreads = draftThreads;
     element.includeComments = includeComments;
-    element.hasDrafts = draftThreads.length > 0;
 
     await element.updateComplete;
 
@@ -942,7 +943,9 @@
         ],
       },
       reviewers: [],
-      add_to_attention_set: [],
+      add_to_attention_set: [
+        {reason: '<GERRIT_ACCOUNT_1> replied on the change', user: 999},
+      ],
       remove_from_attention_set: [],
       ignore_automatic_attention_set_rules: true,
     });
@@ -1033,7 +1036,9 @@
         ],
       },
       reviewers: [],
-      add_to_attention_set: [],
+      add_to_attention_set: [
+        {reason: '<GERRIT_ACCOUNT_1> replied on the change', user: 999},
+      ],
       remove_from_attention_set: [],
       ignore_automatic_attention_set_rules: true,
     });
diff --git a/polygerrit-ui/app/elements/shared/gr-rest-api-interface/gr-etag-decorator_test.ts b/polygerrit-ui/app/elements/shared/gr-rest-api-interface/gr-etag-decorator_test.ts
index 91e7baf..ce2c72f 100644
--- a/polygerrit-ui/app/elements/shared/gr-rest-api-interface/gr-etag-decorator_test.ts
+++ b/polygerrit-ui/app/elements/shared/gr-rest-api-interface/gr-etag-decorator_test.ts
@@ -16,7 +16,6 @@
  */
 
 import '../../../test/common-test-setup-karma';
-import 'lodash/lodash';
 import {GrEtagDecorator} from './gr-etag-decorator';
 
 suite('gr-etag-decorator', () => {
@@ -61,9 +60,9 @@
 
   test('discards etags in order used', () => {
     etag.collect('/foo', fakeRequest('bar'), '');
-    _.times(29, i => {
+    for (let i = 0; i < 29; i++) {
       etag.collect(`/qaz/${i}`, fakeRequest('qaz'), '');
-    });
+    }
     let options = etag.getOptions('/foo');
     assert.strictEqual(options!.headers!.get('If-None-Match'), 'bar');
     etag.collect('/zaq', fakeRequest('zaq'), '');
diff --git a/polygerrit-ui/app/embed/diff/gr-diff-processor/gr-diff-processor.ts b/polygerrit-ui/app/embed/diff/gr-diff-processor/gr-diff-processor.ts
index b588bc7..605d493 100644
--- a/polygerrit-ui/app/embed/diff/gr-diff-processor/gr-diff-processor.ts
+++ b/polygerrit-ui/app/embed/diff/gr-diff-processor/gr-diff-processor.ts
@@ -1,18 +1,7 @@
 /**
  * @license
- * Copyright (C) 2016 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
+ * Copyright 2016 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
  */
 import {PolymerElement} from '@polymer/polymer/polymer-element';
 import {
@@ -36,7 +25,8 @@
 
 const WHOLE_FILE = -1;
 
-interface State {
+// visible for testing
+export interface State {
   lineNums: {
     left: number;
     right: number;
@@ -59,7 +49,7 @@
  * into a series of chunks that are this size at most.
  *
  * Note: The value of 120 is chosen so that it is larger than the default
- * _asyncThreshold of 64, but feel free to tune this constant to your
+ * asyncThreshold of 64, but feel free to tune this constant to your
  * performance needs.
  */
 function calcMaxGroupSize(asyncThreshold?: number): number {
@@ -94,7 +84,6 @@
  */
 @customElement('gr-diff-processor')
 export class GrDiffProcessor extends PolymerElement {
-  @property({type: Number})
   context = 3;
 
   /**
@@ -107,20 +96,16 @@
   @property({type: Array, notify: true})
   groups: GrDiffGroup[] = [];
 
-  @property({type: Object})
   keyLocations: KeyLocations = {left: {}, right: {}};
 
-  @property({type: Number})
-  _asyncThreshold = 64;
+  private asyncThreshold = 64;
 
-  @property({type: Number})
-  _nextStepHandle: number | null = null;
+  private nextStepHandle: number | null = null;
 
-  @property({type: Object})
-  _processPromise: CancelablePromise<void> | null = null;
+  private processPromise: CancelablePromise<void> | null = null;
 
-  @property({type: Boolean})
-  _isScrolling?: boolean;
+  // visible for testing
+  isScrolling?: boolean;
 
   private resetIsScrollingTask?: DelayedTask;
 
@@ -137,10 +122,10 @@
   }
 
   private readonly handleWindowScroll = () => {
-    this._isScrolling = true;
+    this.isScrolling = true;
     this.resetIsScrollingTask = debounce(
       this.resetIsScrollingTask,
-      () => (this._isScrolling = false),
+      () => (this.isScrolling = false),
       50
     );
   };
@@ -158,8 +143,8 @@
     this.cancel();
 
     this.groups = [];
-    this.push('groups', this._makeGroup('LOST'));
-    this.push('groups', this._makeGroup(FILE));
+    this.push('groups', this.makeGroup('LOST'));
+    this.push('groups', this.makeGroup(FILE));
 
     // If it's a binary diff, we won't be rendering hunks of text differences
     // so finish processing.
@@ -167,31 +152,31 @@
       return Promise.resolve();
     }
 
-    this._processPromise = util.makeCancelable(
+    this.processPromise = util.makeCancelable(
       new Promise(resolve => {
         const state = {
           lineNums: {left: 0, right: 0},
           chunkIndex: 0,
         };
 
-        chunks = this._splitLargeChunks(chunks);
-        chunks = this._splitCommonChunksWithKeyLocations(chunks);
+        chunks = this.splitLargeChunks(chunks);
+        chunks = this.splitCommonChunksWithKeyLocations(chunks);
 
         let currentBatch = 0;
         const nextStep = () => {
-          if (this._isScrolling) {
-            this._nextStepHandle = window.setTimeout(nextStep, 100);
+          if (this.isScrolling) {
+            this.nextStepHandle = window.setTimeout(nextStep, 100);
             return;
           }
           // If we are done, resolve the promise.
           if (state.chunkIndex >= chunks.length) {
             resolve();
-            this._nextStepHandle = null;
+            this.nextStepHandle = null;
             return;
           }
 
           // Process the next chunk and incorporate the result.
-          const stateUpdate = this._processNext(state, chunks);
+          const stateUpdate = this.processNext(state, chunks);
           for (const group of stateUpdate.groups) {
             this.push('groups', group);
             currentBatch += group.lines.length;
@@ -201,9 +186,9 @@
 
           // Increment the index and recurse.
           state.chunkIndex = stateUpdate.newChunkIndex;
-          if (currentBatch >= this._asyncThreshold) {
+          if (currentBatch >= this.asyncThreshold) {
             currentBatch = 0;
-            this._nextStepHandle = window.setTimeout(nextStep, 1);
+            this.nextStepHandle = window.setTimeout(nextStep, 1);
           } else {
             nextStep.call(this);
           }
@@ -212,8 +197,8 @@
         nextStep.call(this);
       })
     );
-    return this._processPromise.finally(() => {
-      this._processPromise = null;
+    return this.processPromise.finally(() => {
+      this.processPromise = null;
     });
   }
 
@@ -221,20 +206,21 @@
    * Cancel any jobs that are running.
    */
   cancel() {
-    if (this._nextStepHandle !== null) {
-      window.clearTimeout(this._nextStepHandle);
-      this._nextStepHandle = null;
+    if (this.nextStepHandle !== null) {
+      window.clearTimeout(this.nextStepHandle);
+      this.nextStepHandle = null;
     }
-    if (this._processPromise) {
-      this._processPromise.cancel();
+    if (this.processPromise) {
+      this.processPromise.cancel();
     }
   }
 
   /**
    * Process the next uncollapsible chunk, or the next collapsible chunks.
    */
-  _processNext(state: State, chunks: DiffContent[]) {
-    const firstUncollapsibleChunkIndex = this._firstUncollapsibleChunkIndex(
+  // visible for testing
+  processNext(state: State, chunks: DiffContent[]) {
+    const firstUncollapsibleChunkIndex = this.firstUncollapsibleChunkIndex(
       chunks,
       state.chunkIndex
     );
@@ -242,11 +228,11 @@
       const chunk = chunks[state.chunkIndex];
       return {
         lineDelta: {
-          left: this._linesLeft(chunk).length,
-          right: this._linesRight(chunk).length,
+          left: this.linesLeft(chunk).length,
+          right: this.linesRight(chunk).length,
         },
         groups: [
-          this._chunkToGroup(
+          this.chunkToGroup(
             chunk,
             state.lineNums.left + 1,
             state.lineNums.right + 1
@@ -256,33 +242,33 @@
       };
     }
 
-    return this._processCollapsibleChunks(
+    return this.processCollapsibleChunks(
       state,
       chunks,
       firstUncollapsibleChunkIndex
     );
   }
 
-  _linesLeft(chunk: DiffContent) {
+  private linesLeft(chunk: DiffContent) {
     return chunk.ab || chunk.a || [];
   }
 
-  _linesRight(chunk: DiffContent) {
+  private linesRight(chunk: DiffContent) {
     return chunk.ab || chunk.b || [];
   }
 
-  _firstUncollapsibleChunkIndex(chunks: DiffContent[], offset: number) {
+  private firstUncollapsibleChunkIndex(chunks: DiffContent[], offset: number) {
     let chunkIndex = offset;
     while (
       chunkIndex < chunks.length &&
-      this._isCollapsibleChunk(chunks[chunkIndex])
+      this.isCollapsibleChunk(chunks[chunkIndex])
     ) {
       chunkIndex++;
     }
     return chunkIndex;
   }
 
-  _isCollapsibleChunk(chunk: DiffContent) {
+  private isCollapsibleChunk(chunk: DiffContent) {
     return (chunk.ab || chunk.common || chunk.skip) && !chunk.keyLocation;
   }
 
@@ -296,7 +282,7 @@
    * 3) Visible context after the hidden common code, unless it's the very
    * end of the file.
    */
-  _processCollapsibleChunks(
+  private processCollapsibleChunks(
     state: State,
     chunks: DiffContent[],
     firstUncollapsibleChunkIndex: number
@@ -306,11 +292,11 @@
       firstUncollapsibleChunkIndex
     );
     const lineCount = collapsibleChunks.reduce(
-      (sum, chunk) => sum + this._commonChunkLength(chunk),
+      (sum, chunk) => sum + this.commonChunkLength(chunk),
       0
     );
 
-    let groups = this._chunksToGroups(
+    let groups = this.chunksToGroups(
       collapsibleChunks,
       state.lineNums.left + 1,
       state.lineNums.right + 1
@@ -336,7 +322,7 @@
     };
   }
 
-  _commonChunkLength(chunk: DiffContent) {
+  private commonChunkLength(chunk: DiffContent) {
     if (chunk.skip) {
       return chunk.skip;
     }
@@ -347,31 +333,31 @@
       'common chunk needs same number of a and b lines: ',
       chunk
     );
-    return this._linesLeft(chunk).length;
+    return this.linesLeft(chunk).length;
   }
 
-  _chunksToGroups(
+  private chunksToGroups(
     chunks: DiffContent[],
     offsetLeft: number,
     offsetRight: number
   ): GrDiffGroup[] {
     return chunks.map(chunk => {
-      const group = this._chunkToGroup(chunk, offsetLeft, offsetRight);
-      const chunkLength = this._commonChunkLength(chunk);
+      const group = this.chunkToGroup(chunk, offsetLeft, offsetRight);
+      const chunkLength = this.commonChunkLength(chunk);
       offsetLeft += chunkLength;
       offsetRight += chunkLength;
       return group;
     });
   }
 
-  _chunkToGroup(
+  private chunkToGroup(
     chunk: DiffContent,
     offsetLeft: number,
     offsetRight: number
   ): GrDiffGroup {
     const type =
       chunk.ab || chunk.skip ? GrDiffGroupType.BOTH : GrDiffGroupType.DELTA;
-    const lines = this._linesFromChunk(chunk, offsetLeft, offsetRight);
+    const lines = this.linesFromChunk(chunk, offsetLeft, offsetRight);
     const options = {
       moveDetails: chunk.move_details,
       dueToRebase: !!chunk.due_to_rebase,
@@ -395,10 +381,14 @@
     }
   }
 
-  _linesFromChunk(chunk: DiffContent, offsetLeft: number, offsetRight: number) {
+  private linesFromChunk(
+    chunk: DiffContent,
+    offsetLeft: number,
+    offsetRight: number
+  ) {
     if (chunk.ab) {
       return chunk.ab.map((row, i) =>
-        this._lineFromRow(GrDiffLineType.BOTH, offsetLeft, offsetRight, row, i)
+        this.lineFromRow(GrDiffLineType.BOTH, offsetLeft, offsetRight, row, i)
       );
     }
     let lines: GrDiffLine[] = [];
@@ -406,7 +396,7 @@
       // Avoiding a.push(...b) because that causes callstack overflows for
       // large b, which can occur when large files are added removed.
       lines = lines.concat(
-        this._linesFromRows(
+        this.linesFromRows(
           GrDiffLineType.REMOVE,
           chunk.a,
           offsetLeft,
@@ -418,7 +408,7 @@
       // Avoiding a.push(...b) because that causes callstack overflows for
       // large b, which can occur when large files are added removed.
       lines = lines.concat(
-        this._linesFromRows(
+        this.linesFromRows(
           GrDiffLineType.ADD,
           chunk.b,
           offsetRight,
@@ -429,21 +419,22 @@
     return lines;
   }
 
-  _linesFromRows(
+  // visible for testing
+  linesFromRows(
     lineType: GrDiffLineType,
     rows: string[],
     offset: number,
     intralineInfos?: number[][]
   ): GrDiffLine[] {
     const grDiffHighlights = intralineInfos
-      ? this._convertIntralineInfos(rows, intralineInfos)
+      ? this.convertIntralineInfos(rows, intralineInfos)
       : undefined;
     return rows.map((row, i) =>
-      this._lineFromRow(lineType, offset, offset, row, i, grDiffHighlights)
+      this.lineFromRow(lineType, offset, offset, row, i, grDiffHighlights)
     );
   }
 
-  _lineFromRow(
+  private lineFromRow(
     type: GrDiffLineType,
     offsetLeft: number,
     offsetRight: number,
@@ -464,7 +455,7 @@
     return line;
   }
 
-  _makeGroup(number: LineNumber) {
+  private makeGroup(number: LineNumber) {
     const line = new GrDiffLine(GrDiffLineType.BOTH);
     line.beforeNumber = number;
     line.afterNumber = number;
@@ -486,12 +477,13 @@
    * @param chunks Chunks as returned from the server
    * @return Finer grained chunks.
    */
-  _splitLargeChunks(chunks: DiffContent[]): DiffContent[] {
+  // visible for testing
+  splitLargeChunks(chunks: DiffContent[]): DiffContent[] {
     const newChunks = [];
 
     for (const chunk of chunks) {
       if (!chunk.ab) {
-        for (const subChunk of this._breakdownChunk(chunk)) {
+        for (const subChunk of this.breakdownChunk(chunk)) {
           newChunks.push(subChunk);
         }
         continue;
@@ -501,7 +493,7 @@
       // chunks so they can be rendered incrementally. Note: this is not
       // enabled for any other context preference because manipulating the
       // chunks in this way violates assumptions by the context grouper logic.
-      const MAX_GROUP_SIZE = calcMaxGroupSize(this._asyncThreshold);
+      const MAX_GROUP_SIZE = calcMaxGroupSize(this.asyncThreshold);
       if (this.context === -1 && chunk.ab.length > MAX_GROUP_SIZE * 2) {
         // Split large shared chunks in two, where the first is the maximum
         // group size.
@@ -522,7 +514,8 @@
    * @param chunks DiffContents as returned from server.
    * @return Finer grained DiffContents.
    */
-  _splitCommonChunksWithKeyLocations(chunks: DiffContent[]): DiffContent[] {
+  // visible for testing
+  splitCommonChunksWithKeyLocations(chunks: DiffContent[]): DiffContent[] {
     const result = [];
     let leftLineNum = 1;
     let rightLineNum = 1;
@@ -545,8 +538,8 @@
           'DiffContent with common=true must always have equal length'
         );
       }
-      const numLines = this._commonChunkLength(chunk);
-      const chunkEnds = this._findChunkEndsAtKeyLocations(
+      const numLines = this.commonChunkLength(chunk);
+      const chunkEnds = this.findChunkEndsAtKeyLocations(
         numLines,
         leftLineNum,
         rightLineNum
@@ -562,7 +555,7 @@
         });
       } else if (chunk.ab) {
         result.push(
-          ...this._splitAtChunkEnds(chunk.ab, chunkEnds).map(
+          ...this.splitAtChunkEnds(chunk.ab, chunkEnds).map(
             ({lines, keyLocation}) => {
               return {
                 ...chunk,
@@ -573,8 +566,8 @@
           )
         );
       } else if (chunk.common) {
-        const aChunks = this._splitAtChunkEnds(chunk.a!, chunkEnds);
-        const bChunks = this._splitAtChunkEnds(chunk.b!, chunkEnds);
+        const aChunks = this.splitAtChunkEnds(chunk.a!, chunkEnds);
+        const bChunks = this.splitAtChunkEnds(chunk.b!, chunkEnds);
         result.push(
           ...aChunks.map(({lines, keyLocation}, i) => {
             return {
@@ -595,7 +588,7 @@
    * @return Offsets of the new chunk ends, including whether it's a key
    * location.
    */
-  _findChunkEndsAtKeyLocations(
+  private findChunkEndsAtKeyLocations(
     numLines: number,
     leftOffset: number,
     rightOffset: number
@@ -628,7 +621,7 @@
     return result;
   }
 
-  _splitAtChunkEnds(lines: string[], chunkEnds: ChunkEnd[]) {
+  private splitAtChunkEnds(lines: string[], chunkEnds: ChunkEnd[]) {
     const result = [];
     let lastChunkEndOffset = 0;
     for (const {offset, keyLocation} of chunkEnds) {
@@ -645,7 +638,8 @@
    * Converts `IntralineInfo`s return by the API to `GrLineHighlights` used
    * for rendering.
    */
-  _convertIntralineInfos(
+  // visible for testing
+  convertIntralineInfos(
     rows: string[],
     intralineInfos: number[][]
   ): Highlights[] {
@@ -695,7 +689,8 @@
    * of that type using the MAX_GROUP_SIZE. If the group is a shared chunk
    * or a delta it is returned as the single element of the result array.
    */
-  _breakdownChunk(chunk: DiffContent): DiffContent[] {
+  // visible for testing
+  breakdownChunk(chunk: DiffContent): DiffContent[] {
     let key: 'a' | 'b' | 'ab' | null = null;
     const {a, b, ab, move_details} = chunk;
     if (a?.length && !b?.length) {
@@ -712,8 +707,8 @@
       return [chunk];
     }
 
-    const MAX_GROUP_SIZE = calcMaxGroupSize(this._asyncThreshold);
-    return this._breakdown(chunk[key]!, MAX_GROUP_SIZE).map(subChunkLines => {
+    const MAX_GROUP_SIZE = calcMaxGroupSize(this.asyncThreshold);
+    return this.breakdown(chunk[key]!, MAX_GROUP_SIZE).map(subChunkLines => {
       const subChunk: DiffContent = {};
       subChunk[key!] = subChunkLines;
       if (chunk.due_to_rebase) {
@@ -730,7 +725,8 @@
    * Given an array and a size, return an array of arrays where no inner array
    * is larger than that size, preserving the original order.
    */
-  _breakdown<T>(array: T[], size: number): T[][] {
+  // visible for testing
+  breakdown<T>(array: T[], size: number): T[][] {
     if (!array.length) {
       return [];
     }
@@ -741,12 +737,12 @@
     const head = array.slice(0, array.length - size);
     const tail = array.slice(array.length - size);
 
-    return this._breakdown(head, size).concat([tail]);
+    return this.breakdown(head, size).concat([tail]);
   }
 
   updateRenderPrefs(renderPrefs: RenderPreferences) {
     if (renderPrefs.num_lines_rendered_at_once) {
-      this._asyncThreshold = renderPrefs.num_lines_rendered_at_once;
+      this.asyncThreshold = renderPrefs.num_lines_rendered_at_once;
     }
   }
 }
diff --git a/polygerrit-ui/app/embed/diff/gr-diff-processor/gr-diff-processor_test.js b/polygerrit-ui/app/embed/diff/gr-diff-processor/gr-diff-processor_test.ts
similarity index 67%
rename from polygerrit-ui/app/embed/diff/gr-diff-processor/gr-diff-processor_test.js
rename to polygerrit-ui/app/embed/diff/gr-diff-processor/gr-diff-processor_test.ts
index bebdf34..ba59af0 100644
--- a/polygerrit-ui/app/embed/diff/gr-diff-processor/gr-diff-processor_test.js
+++ b/polygerrit-ui/app/embed/diff/gr-diff-processor/gr-diff-processor_test.ts
@@ -1,42 +1,29 @@
 /**
  * @license
- * Copyright (C) 2016 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
+ * Copyright 2016 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
  */
-
-import '../../../test/common-test-setup-karma.js';
-import 'lodash/lodash.js';
-import './gr-diff-processor.js';
-import {GrDiffLineType, FILE} from '../gr-diff/gr-diff-line.js';
-import {GrDiffGroup, GrDiffGroupType} from '../gr-diff/gr-diff-group.js';
+import '../../../test/common-test-setup-karma';
+import './gr-diff-processor';
+import {GrDiffLineType, FILE, GrDiffLine} from '../gr-diff/gr-diff-line';
+import {GrDiffGroup, GrDiffGroupType} from '../gr-diff/gr-diff-group';
+import {GrDiffProcessor, State} from './gr-diff-processor';
+import {DiffContent} from '../../../types/diff';
 
 const basicFixture = fixtureFromElement('gr-diff-processor');
 
 suite('gr-diff-processor tests', () => {
   const WHOLE_FILE = -1;
   const loremIpsum =
-      'Lorem ipsum dolor sit amet, ei nonumes vituperata ius. ' +
-      'Duo  animal omnesque fabellas et. Id has phaedrum dignissim ' +
-      'deterruisset, pro ei petentium comprehensam, ut vis solum dicta. ' +
-      'Eos cu aliquam labores qualisque, usu postea inermis te, et solum ' +
-      'fugit assum per.';
+    'Lorem ipsum dolor sit amet, ei nonumes vituperata ius. ' +
+    'Duo  animal omnesque fabellas et. Id has phaedrum dignissim ' +
+    'deterruisset, pro ei petentium comprehensam, ut vis solum dicta. ' +
+    'Eos cu aliquam labores qualisque, usu postea inermis te, et solum ' +
+    'fugit assum per.';
 
-  let element;
+  let element: GrDiffProcessor;
 
-  setup(() => {
-
-  });
+  setup(() => {});
 
   suite('not logged in', () => {
     setup(() => {
@@ -46,21 +33,13 @@
     });
 
     test('process loaded content', () => {
-      const content = [
+      const content: DiffContent[] = [
         {
-          ab: [
-            '<!DOCTYPE html>',
-            '<meta charset="utf-8">',
-          ],
+          ab: ['<!DOCTYPE html>', '<meta charset="utf-8">'],
         },
         {
-          a: [
-            '  Welcome ',
-            '  to the wooorld of tomorrow!',
-          ],
-          b: [
-            '  Hello, world!',
-          ],
+          a: ['  Welcome ', '  to the wooorld of tomorrow!'],
+          b: ['  Hello, world!'],
         },
         {
           ab: [
@@ -71,7 +50,7 @@
         },
       ];
 
-      return element.process(content).then(() => {
+      return element.process(content, false).then(() => {
         const groups = element.groups;
         groups.shift(); // remove portedThreadsWithoutRangeGroup
         assert.equal(groups.length, 4);
@@ -87,9 +66,15 @@
         assert.equal(group.type, GrDiffGroupType.BOTH);
         assert.equal(group.lines.length, 2);
 
-        function beforeNumberFn(l) { return l.beforeNumber; }
-        function afterNumberFn(l) { return l.afterNumber; }
-        function textFn(l) { return l.text; }
+        function beforeNumberFn(l: GrDiffLine) {
+          return l.beforeNumber;
+        }
+        function afterNumberFn(l: GrDiffLine) {
+          return l.afterNumber;
+        }
+        function textFn(l: GrDiffLine) {
+          return l.text;
+        }
 
         assert.deepEqual(group.lines.map(beforeNumberFn), [1, 2]);
         assert.deepEqual(group.lines.map(afterNumberFn), [1, 2]);
@@ -109,9 +94,7 @@
           '  Welcome ',
           '  to the wooorld of tomorrow!',
         ]);
-        assert.deepEqual(group.adds.map(textFn), [
-          '  Hello, world!',
-        ]);
+        assert.deepEqual(group.adds.map(textFn), ['  Hello, world!']);
 
         group = groups[3];
         assert.equal(group.type, GrDiffGroupType.BOTH);
@@ -127,11 +110,9 @@
     });
 
     test('first group is for file', () => {
-      const content = [
-        {b: ['foo']},
-      ];
+      const content = [{b: ['foo']}];
 
-      return element.process(content).then(() => {
+      return element.process(content, false).then(() => {
         const groups = element.groups;
         groups.shift(); // remove portedThreadsWithoutRangeGroup
 
@@ -147,12 +128,15 @@
       test('at the beginning, larger than context', () => {
         element.context = 10;
         const content = [
-          {ab: new Array(100)
-              .fill('all work and no play make jack a dull boy')},
+          {
+            ab: new Array(100).fill(
+              'all work and no play make jack a dull boy'
+            ),
+          },
           {a: ['all work and no play make andybons a dull boy']},
         ];
 
-        return element.process(content).then(() => {
+        return element.process(content, false).then(() => {
           const groups = element.groups;
           groups.shift(); // remove portedThreadsWithoutRangeGroup
 
@@ -176,15 +160,13 @@
       test('at the beginning with skip chunks', async () => {
         element.context = 10;
         const content = [
-          {ab: new Array(20)
-              .fill('all work and no play make jack a dull boy')},
+          {ab: new Array(20).fill('all work and no play make jack a dull boy')},
           {skip: 43900},
-          {ab: new Array(30)
-              .fill('some other content')},
+          {ab: new Array(30).fill('some other content')},
           {a: ['some other content']},
         ];
 
-        await element.process(content);
+        await element.process(content, false);
 
         const groups = element.groups;
         groups.shift(); // remove portedThreadsWithoutRangeGroup
@@ -227,12 +209,11 @@
       test('at the beginning, smaller than context', () => {
         element.context = 10;
         const content = [
-          {ab: new Array(5)
-              .fill('all work and no play make jack a dull boy')},
+          {ab: new Array(5).fill('all work and no play make jack a dull boy')},
           {a: ['all work and no play make andybons a dull boy']},
         ];
 
-        return element.process(content).then(() => {
+        return element.process(content, false).then(() => {
           const groups = element.groups;
           groups.shift(); // remove portedThreadsWithoutRangeGroup
 
@@ -250,11 +231,14 @@
         element.context = 10;
         const content = [
           {a: ['all work and no play make andybons a dull boy']},
-          {ab: new Array(100)
-              .fill('all work and no play make jill a dull girl')},
+          {
+            ab: new Array(100).fill(
+              'all work and no play make jill a dull girl'
+            ),
+          },
         ];
 
-        return element.process(content).then(() => {
+        return element.process(content, false).then(() => {
           const groups = element.groups;
           groups.shift(); // remove portedThreadsWithoutRangeGroup
 
@@ -264,16 +248,14 @@
           assert.equal(groups[2].type, GrDiffGroupType.BOTH);
           assert.equal(groups[2].lines.length, 10);
           for (const l of groups[2].lines) {
-            assert.equal(
-                l.text, 'all work and no play make jill a dull girl');
+            assert.equal(l.text, 'all work and no play make jill a dull girl');
           }
 
           assert.equal(groups[3].type, GrDiffGroupType.CONTEXT_CONTROL);
           assert.instanceOf(groups[3].contextGroups[0], GrDiffGroup);
           assert.equal(groups[3].contextGroups[0].lines.length, 90);
           for (const l of groups[3].contextGroups[0].lines) {
-            assert.equal(
-                l.text, 'all work and no play make jill a dull girl');
+            assert.equal(l.text, 'all work and no play make jill a dull girl');
           }
         });
       });
@@ -282,11 +264,10 @@
         element.context = 10;
         const content = [
           {a: ['all work and no play make andybons a dull boy']},
-          {ab: new Array(5)
-              .fill('all work and no play make jill a dull girl')},
+          {ab: new Array(5).fill('all work and no play make jill a dull girl')},
         ];
 
-        return element.process(content).then(() => {
+        return element.process(content, false).then(() => {
           const groups = element.groups;
           groups.shift(); // remove portedThreadsWithoutRangeGroup
 
@@ -296,8 +277,7 @@
           assert.equal(groups[2].type, GrDiffGroupType.BOTH);
           assert.equal(groups[2].lines.length, 5);
           for (const l of groups[2].lines) {
-            assert.equal(
-                l.text, 'all work and no play make jill a dull girl');
+            assert.equal(l.text, 'all work and no play make jill a dull girl');
           }
         });
       });
@@ -306,29 +286,26 @@
         element.context = 10;
         const content = [
           {a: ['all work and no play make andybons a dull boy']},
-          {ab: new Array(3)
-              .fill('all work and no play make jill a dull girl')},
+          {ab: new Array(3).fill('all work and no play make jill a dull girl')},
           {
-            a: new Array(3).fill(
-                'all work and no play make jill a dull girl'),
+            a: new Array(3).fill('all work and no play make jill a dull girl'),
             b: new Array(3).fill(
-                '  all work and no play make jill a dull girl'),
+              '  all work and no play make jill a dull girl'
+            ),
             common: true,
           },
-          {ab: new Array(3)
-              .fill('all work and no play make jill a dull girl')},
+          {ab: new Array(3).fill('all work and no play make jill a dull girl')},
           {
-            a: new Array(3).fill(
-                'all work and no play make jill a dull girl'),
+            a: new Array(3).fill('all work and no play make jill a dull girl'),
             b: new Array(3).fill(
-                '  all work and no play make jill a dull girl'),
+              '  all work and no play make jill a dull girl'
+            ),
             common: true,
           },
-          {ab: new Array(3)
-              .fill('all work and no play make jill a dull girl')},
+          {ab: new Array(3).fill('all work and no play make jill a dull girl')},
         ];
 
-        return element.process(content).then(() => {
+        return element.process(content, false).then(() => {
           const groups = element.groups;
           groups.shift(); // remove portedThreadsWithoutRangeGroup
 
@@ -341,8 +318,7 @@
           assert.equal(groups[2].type, GrDiffGroupType.BOTH);
           assert.equal(groups[2].lines.length, 3);
           for (const l of groups[2].lines) {
-            assert.equal(
-                l.text, 'all work and no play make jill a dull girl');
+            assert.equal(l.text, 'all work and no play make jill a dull girl');
           }
 
           assert.equal(groups[3].type, GrDiffGroupType.DELTA);
@@ -350,19 +326,19 @@
           assert.equal(groups[3].adds.length, 3);
           assert.equal(groups[3].removes.length, 3);
           for (const l of groups[3].removes) {
-            assert.equal(
-                l.text, 'all work and no play make jill a dull girl');
+            assert.equal(l.text, 'all work and no play make jill a dull girl');
           }
           for (const l of groups[3].adds) {
             assert.equal(
-                l.text, '  all work and no play make jill a dull girl');
+              l.text,
+              '  all work and no play make jill a dull girl'
+            );
           }
 
           assert.equal(groups[4].type, GrDiffGroupType.BOTH);
           assert.equal(groups[4].lines.length, 3);
           for (const l of groups[4].lines) {
-            assert.equal(
-                l.text, 'all work and no play make jill a dull girl');
+            assert.equal(l.text, 'all work and no play make jill a dull girl');
           }
 
           // The next chunk is partially shown, so it results in two groups
@@ -372,12 +348,13 @@
           assert.equal(groups[5].adds.length, 1);
           assert.equal(groups[5].removes.length, 1);
           for (const l of groups[5].removes) {
-            assert.equal(
-                l.text, 'all work and no play make jill a dull girl');
+            assert.equal(l.text, 'all work and no play make jill a dull girl');
           }
           for (const l of groups[5].adds) {
             assert.equal(
-                l.text, '  all work and no play make jill a dull girl');
+              l.text,
+              '  all work and no play make jill a dull girl'
+            );
           }
 
           assert.equal(groups[6].type, GrDiffGroupType.CONTEXT_CONTROL);
@@ -387,22 +364,20 @@
           assert.equal(groups[6].contextGroups[0].removes.length, 2);
           assert.equal(groups[6].contextGroups[0].adds.length, 2);
           for (const l of groups[6].contextGroups[0].removes) {
-            assert.equal(
-                l.text, 'all work and no play make jill a dull girl');
+            assert.equal(l.text, 'all work and no play make jill a dull girl');
           }
           for (const l of groups[6].contextGroups[0].adds) {
             assert.equal(
-                l.text, '  all work and no play make jill a dull girl');
+              l.text,
+              '  all work and no play make jill a dull girl'
+            );
           }
 
           // The final chunk is completely hidden
-          assert.equal(
-              groups[6].contextGroups[1].type,
-              GrDiffGroupType.BOTH);
+          assert.equal(groups[6].contextGroups[1].type, GrDiffGroupType.BOTH);
           assert.equal(groups[6].contextGroups[1].lines.length, 3);
           for (const l of groups[6].contextGroups[1].lines) {
-            assert.equal(
-                l.text, 'all work and no play make jill a dull girl');
+            assert.equal(l.text, 'all work and no play make jill a dull girl');
           }
         });
       });
@@ -411,12 +386,15 @@
         element.context = 10;
         const content = [
           {a: ['all work and no play make andybons a dull boy']},
-          {ab: new Array(100)
-              .fill('all work and no play make jill a dull girl')},
+          {
+            ab: new Array(100).fill(
+              'all work and no play make jill a dull girl'
+            ),
+          },
           {a: ['all work and no play make andybons a dull boy']},
         ];
 
-        return element.process(content).then(() => {
+        return element.process(content, false).then(() => {
           const groups = element.groups;
           groups.shift(); // remove portedThreadsWithoutRangeGroup
 
@@ -426,23 +404,20 @@
           assert.equal(groups[2].type, GrDiffGroupType.BOTH);
           assert.equal(groups[2].lines.length, 10);
           for (const l of groups[2].lines) {
-            assert.equal(
-                l.text, 'all work and no play make jill a dull girl');
+            assert.equal(l.text, 'all work and no play make jill a dull girl');
           }
 
           assert.equal(groups[3].type, GrDiffGroupType.CONTEXT_CONTROL);
           assert.instanceOf(groups[3].contextGroups[0], GrDiffGroup);
           assert.equal(groups[3].contextGroups[0].lines.length, 80);
           for (const l of groups[3].contextGroups[0].lines) {
-            assert.equal(
-                l.text, 'all work and no play make jill a dull girl');
+            assert.equal(l.text, 'all work and no play make jill a dull girl');
           }
 
           assert.equal(groups[4].type, GrDiffGroupType.BOTH);
           assert.equal(groups[4].lines.length, 10);
           for (const l of groups[4].lines) {
-            assert.equal(
-                l.text, 'all work and no play make jill a dull girl');
+            assert.equal(l.text, 'all work and no play make jill a dull girl');
           }
         });
       });
@@ -451,12 +426,11 @@
         element.context = 10;
         const content = [
           {a: ['all work and no play make andybons a dull boy']},
-          {ab: new Array(5)
-              .fill('all work and no play make jill a dull girl')},
+          {ab: new Array(5).fill('all work and no play make jill a dull girl')},
           {a: ['all work and no play make andybons a dull boy']},
         ];
 
-        return element.process(content).then(() => {
+        return element.process(content, false).then(() => {
           const groups = element.groups;
           groups.shift(); // remove portedThreadsWithoutRangeGroup
 
@@ -466,8 +440,7 @@
           assert.equal(groups[2].type, GrDiffGroupType.BOTH);
           assert.equal(groups[2].lines.length, 5);
           for (const l of groups[2].lines) {
-            assert.equal(
-                l.text, 'all work and no play make jill a dull girl');
+            assert.equal(l.text, 'all work and no play make jill a dull girl');
           }
         });
       });
@@ -477,15 +450,13 @@
       element.context = 10;
       const content = [
         {a: ['all work and no play make andybons a dull boy']},
-        {ab: new Array(20)
-            .fill('all work and no play make jill a dull girl')},
+        {ab: new Array(20).fill('all work and no play make jill a dull girl')},
         {skip: 60},
-        {ab: new Array(20)
-            .fill('all work and no play make jill a dull girl')},
+        {ab: new Array(20).fill('all work and no play make jill a dull girl')},
         {a: ['all work and no play make andybons a dull boy']},
       ];
 
-      await element.process(content);
+      await element.process(content, false);
 
       const groups = element.groups;
       groups.shift(); // remove portedThreadsWithoutRangeGroup
@@ -501,8 +472,7 @@
       assert.instanceOf(commonGroup.contextGroups[0], GrDiffGroup);
       assert.equal(commonGroup.contextGroups[0].lines.length, 10);
       for (const l of commonGroup.contextGroups[0].lines) {
-        assert.equal(
-            l.text, 'all work and no play make jill a dull girl');
+        assert.equal(l.text, 'all work and no play make jill a dull girl');
       }
 
       // Skipped group
@@ -517,8 +487,7 @@
       // Hidden context after
       assert.equal(commonGroup.contextGroups[2].lines.length, 10);
       for (const l of commonGroup.contextGroups[2].lines) {
-        assert.equal(
-            l.text, 'all work and no play make jill a dull girl');
+        assert.equal(l.text, 'all work and no play make jill a dull girl');
       }
       // group[4] is the displayed part of the second ab
     });
@@ -536,7 +505,7 @@
             '',
             'Licensed under the Apache License, Version 2.0 (the "License");',
             'you may not use this file except in compliance with the ' +
-                'License.',
+              'License.',
             'You may obtain a copy of the License at',
             '',
             'http://www.apache.org/licenses/LICENSE-2.0',
@@ -546,12 +515,11 @@
             '"AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, ',
             'either express or implied. See the License for the specific ',
             'language governing permissions and limitations under the ' +
-                'License.',
+              'License.',
           ],
         },
       ];
-      const result =
-          element._splitCommonChunksWithKeyLocations(content);
+      const result = element.splitCommonChunksWithKeyLocations(content);
       assert.deepEqual(result, [
         {
           ab: ['Copyright (C) 2015 The Android Open Source Project'],
@@ -562,7 +530,7 @@
             '',
             'Licensed under the Apache License, Version 2.0 (the "License");',
             'you may not use this file except in compliance with the ' +
-                'License.',
+              'License.',
             'You may obtain a copy of the License at',
             '',
             'http://www.apache.org/licenses/LICENSE-2.0',
@@ -572,8 +540,7 @@
           keyLocation: false,
         },
         {
-          ab: [
-            'software distributed under the License is distributed on an '],
+          ab: ['software distributed under the License is distributed on an '],
           keyLocation: true,
         },
         {
@@ -581,7 +548,7 @@
             '"AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, ',
             'either express or implied. See the License for the specific ',
             'language governing permissions and limitations under the ' +
-                'License.',
+              'License.',
           ],
           keyLocation: false,
         },
@@ -591,11 +558,12 @@
     test('breaks down shared chunks w/ whole-file', () => {
       const maxGroupSize = 128;
       const size = maxGroupSize * 2 + 5;
-      const content = [{
-        ab: _.times(size, () => `${Math.random()}`),
-      }];
+      const ab = Array(size)
+        .fill(0)
+        .map(() => `${Math.random()}`);
+      const content = [{ab}];
       element.context = -1;
-      const result = element._splitLargeChunks(content);
+      const result = element.splitLargeChunks(content);
       assert.equal(result.length, 2);
       assert.deepEqual(result[0].ab, content[0].ab.slice(0, maxGroupSize));
       assert.deepEqual(result[1].ab, content[0].ab.slice(maxGroupSize));
@@ -604,10 +572,13 @@
     test('breaks down added chunks', () => {
       const maxGroupSize = 128;
       const size = maxGroupSize * 2 + 5;
-      const content = _.times(size, () => `${Math.random()}`);
+      const content = Array(size)
+        .fill(0)
+        .map(() => `${Math.random()}`);
       element.context = 5;
-      const splitContent = element._splitLargeChunks([{a: [], b: content}])
-          .map(r => r.b);
+      const splitContent = element
+        .splitLargeChunks([{a: [], b: content}])
+        .map(r => r.b);
       assert.equal(splitContent.length, 3);
       assert.deepEqual(splitContent[0], content.slice(0, 5));
       assert.deepEqual(splitContent[1], content.slice(5, maxGroupSize + 5));
@@ -617,10 +588,13 @@
     test('breaks down removed chunks', () => {
       const maxGroupSize = 128;
       const size = maxGroupSize * 2 + 5;
-      const content = _.times(size, () => `${Math.random()}`);
+      const content = Array(size)
+        .fill(0)
+        .map(() => `${Math.random()}`);
       element.context = 5;
-      const splitContent = element._splitLargeChunks([{a: content, b: []}])
-          .map(r => r.a);
+      const splitContent = element
+        .splitLargeChunks([{a: content, b: []}])
+        .map(r => r.a);
       assert.equal(splitContent.length, 3);
       assert.deepEqual(splitContent[0], content.slice(0, 5));
       assert.deepEqual(splitContent[1], content.slice(5, maxGroupSize + 5));
@@ -629,24 +603,30 @@
 
     test('does not break down moved chunks', () => {
       const size = 120 * 2 + 5;
-      const content = _.times(size, () => `${Math.random()}`);
+      const content = Array(size)
+        .fill(0)
+        .map(() => `${Math.random()}`);
       element.context = 5;
-      const splitContent = element._splitLargeChunks([{
-        a: content,
-        b: [],
-        move_details: {changed: false},
-      }]).map(r => r.a);
+      const splitContent = element
+        .splitLargeChunks([
+          {
+            a: content,
+            b: [],
+            move_details: {changed: false, range: {start: 1, end: 1}},
+          },
+        ])
+        .map(r => r.a);
       assert.equal(splitContent.length, 1);
       assert.deepEqual(splitContent[0], content);
     });
 
     test('does not break-down common chunks w/ context', () => {
-      const content = [{
-        ab: _.times(75, () => `${Math.random()}`),
-      }];
+      const ab = Array(75)
+        .fill(0)
+        .map(() => `${Math.random()}`);
+      const content = [{ab}];
       element.context = 4;
-      const result =
-          element._splitCommonChunksWithKeyLocations(content);
+      const result = element.splitCommonChunksWithKeyLocations(content);
       assert.equal(result.length, 1);
       assert.deepEqual(result[0].ab, content[0].ab);
       assert.isFalse(result[0].keyLocation);
@@ -658,15 +638,15 @@
       let content = [
         '      <section class="summary">',
         '        <gr-linked-text content="' +
-            '[[_computeCurrentRevisionMessage(change)]]"></gr-linked-text>',
+          '[[_computeCurrentRevisionMessage(change)]]"></gr-linked-text>',
         '      </section>',
       ];
       let highlights = [
-        [31, 34], [42, 26],
+        [31, 34],
+        [42, 26],
       ];
 
-      let results = element._convertIntralineInfos(content,
-          highlights);
+      let results = element.convertIntralineInfos(content, highlights);
       assert.deepEqual(results, [
         {
           contentIndex: 0,
@@ -687,8 +667,12 @@
           endIndex: 6,
         },
       ]);
-      const lines = element._linesFromRows(
-          GrDiffGroupType.BOTH, content, 0, highlights);
+      const lines = element.linesFromRows(
+        GrDiffLineType.BOTH,
+        content,
+        0,
+        highlights
+      );
       assert.equal(lines.length, 3);
       assert.isTrue(lines[0].hasIntralineInfo);
       assert.equal(lines[0].highlights.length, 1);
@@ -715,7 +699,7 @@
         [12, 67],
         [14, 29],
       ];
-      results = element._convertIntralineInfos(content, highlights);
+      results = element.convertIntralineInfos(content, highlights);
       assert.deepEqual(results, [
         {
           contentIndex: 0,
@@ -746,32 +730,20 @@
     });
 
     test('scrolling pauses rendering', () => {
-      const contentRow = {
-        ab: [
-          '',
-          '',
-        ],
-      };
-      const content = _.times(200, _.constant(contentRow));
-      element._isScrolling = true;
-      element.process(content);
+      const content = Array(200).fill({ab: ['', '']});
+      element.isScrolling = true;
+      element.process(content, false);
       // Just the files group - no more processing during scrolling.
       assert.equal(element.groups.length, 2);
 
-      element._isScrolling = false;
-      element.process(content);
+      element.isScrolling = false;
+      element.process(content, false);
       // More groups have been processed. How many does not matter here.
       assert.isAtLeast(element.groups.length, 3);
     });
 
     test('image diffs', () => {
-      const contentRow = {
-        ab: [
-          '',
-          '',
-        ],
-      };
-      const content = _.times(200, _.constant(contentRow));
+      const content = Array(200).fill({ab: ['', '']});
       element.process(content, true);
       assert.equal(element.groups.length, 2);
 
@@ -779,8 +751,8 @@
       assert.equal(element.groups[0].lines.length, 1);
     });
 
-    suite('_processNext', () => {
-      let rows;
+    suite('processNext', () => {
+      let rows: string[];
 
       setup(() => {
         rows = loremIpsum.split(' ');
@@ -788,16 +760,12 @@
 
       test('WHOLE_FILE', () => {
         element.context = WHOLE_FILE;
-        const state = {
+        const state: State = {
           lineNums: {left: 10, right: 100},
           chunkIndex: 1,
         };
-        const chunks = [
-          {a: ['foo']},
-          {ab: rows},
-          {a: ['bar']},
-        ];
-        const result = element._processNext(state, chunks);
+        const chunks = [{a: ['foo']}, {ab: rows}, {a: ['bar']}];
+        const result = element.processNext(state, chunks);
 
         // Results in one, uncollapsed group with all rows.
         assert.equal(result.groups.length, 1);
@@ -806,16 +774,22 @@
 
         // Line numbers are set correctly.
         assert.equal(
-            result.groups[0].lines[0].beforeNumber,
-            state.lineNums.left + 1);
+          result.groups[0].lines[0].beforeNumber,
+          state.lineNums.left + 1
+        );
         assert.equal(
-            result.groups[0].lines[0].afterNumber,
-            state.lineNums.right + 1);
+          result.groups[0].lines[0].afterNumber,
+          state.lineNums.right + 1
+        );
 
-        assert.equal(result.groups[0].lines[rows.length - 1].beforeNumber,
-            state.lineNums.left + rows.length);
-        assert.equal(result.groups[0].lines[rows.length - 1].afterNumber,
-            state.lineNums.right + rows.length);
+        assert.equal(
+          result.groups[0].lines[rows.length - 1].beforeNumber,
+          state.lineNums.left + rows.length
+        );
+        assert.equal(
+          result.groups[0].lines[rows.length - 1].afterNumber,
+          state.lineNums.right + rows.length
+        );
       });
 
       test('WHOLE_FILE with skip chunks still get collapsed', () => {
@@ -826,13 +800,8 @@
           chunkIndex: 1,
         };
         const skip = 10000;
-        const chunks = [
-          {a: ['foo']},
-          {skip},
-          {ab: rows},
-          {a: ['bar']},
-        ];
-        const result = element._processNext(state, chunks);
+        const chunks = [{a: ['foo']}, {skip}, {ab: rows}, {a: ['bar']}];
+        const result = element.processNext(state, chunks);
         // Results in one, uncollapsed group with all rows.
         assert.equal(result.groups.length, 1);
         assert.equal(result.groups[0].type, GrDiffGroupType.CONTEXT_CONTROL);
@@ -842,31 +811,27 @@
         const [skippedGroup, abGroup] = result.groups[0].contextGroups;
 
         // Line numbers are set correctly.
-        assert.deepEqual(
-            skippedGroup.lineRange,
-            {
-              left: {
-                start_line: lineNums.left + 1,
-                end_line: lineNums.left + skip,
-              },
-              right: {
-                start_line: lineNums.right + 1,
-                end_line: lineNums.right + skip,
-              },
-            });
+        assert.deepEqual(skippedGroup.lineRange, {
+          left: {
+            start_line: lineNums.left + 1,
+            end_line: lineNums.left + skip,
+          },
+          right: {
+            start_line: lineNums.right + 1,
+            end_line: lineNums.right + skip,
+          },
+        });
 
-        assert.deepEqual(
-            abGroup.lineRange,
-            {
-              left: {
-                start_line: lineNums.left + skip + 1,
-                end_line: lineNums.left + skip + rows.length,
-              },
-              right: {
-                start_line: lineNums.right + skip + 1,
-                end_line: lineNums.right + skip + rows.length,
-              },
-            });
+        assert.deepEqual(abGroup.lineRange, {
+          left: {
+            start_line: lineNums.left + skip + 1,
+            end_line: lineNums.left + skip + rows.length,
+          },
+          right: {
+            start_line: lineNums.right + skip + 1,
+            end_line: lineNums.right + skip + rows.length,
+          },
+        });
       });
 
       test('with context', () => {
@@ -875,12 +840,8 @@
           lineNums: {left: 10, right: 100},
           chunkIndex: 1,
         };
-        const chunks = [
-          {a: ['foo']},
-          {ab: rows},
-          {a: ['bar']},
-        ];
-        const result = element._processNext(state, chunks);
+        const chunks = [{a: ['foo']}, {ab: rows}, {a: ['bar']}];
+        const result = element.processNext(state, chunks);
         const expectedCollapseSize = rows.length - 2 * element.context;
 
         assert.equal(result.groups.length, 3, 'Results in three groups');
@@ -891,8 +852,10 @@
         assert.equal(result.groups[2].lines.length, element.context);
 
         // The collapsed group has the hidden lines as its context group.
-        assert.equal(result.groups[1].contextGroups[0].lines.length,
-            expectedCollapseSize);
+        assert.equal(
+          result.groups[1].contextGroups[0].lines.length,
+          expectedCollapseSize
+        );
       });
 
       test('first', () => {
@@ -901,12 +864,8 @@
           lineNums: {left: 10, right: 100},
           chunkIndex: 0,
         };
-        const chunks = [
-          {ab: rows},
-          {a: ['foo']},
-          {a: ['bar']},
-        ];
-        const result = element._processNext(state, chunks);
+        const chunks = [{ab: rows}, {a: ['foo']}, {a: ['bar']}];
+        const result = element.processNext(state, chunks);
         const expectedCollapseSize = rows.length - element.context;
 
         assert.equal(result.groups.length, 2, 'Results in two groups');
@@ -915,8 +874,10 @@
         assert.equal(result.groups[1].lines.length, element.context);
 
         // The collapsed group has the hidden lines as its context group.
-        assert.equal(result.groups[0].contextGroups[0].lines.length,
-            expectedCollapseSize);
+        assert.equal(
+          result.groups[0].contextGroups[0].lines.length,
+          expectedCollapseSize
+        );
       });
 
       test('few-rows', () => {
@@ -927,12 +888,8 @@
           lineNums: {left: 10, right: 100},
           chunkIndex: 0,
         };
-        const chunks = [
-          {ab: rows},
-          {a: ['foo']},
-          {a: ['bar']},
-        ];
-        const result = element._processNext(state, chunks);
+        const chunks = [{ab: rows}, {a: ['foo']}, {a: ['bar']}];
+        const result = element.processNext(state, chunks);
 
         // Results in one uncollapsed group with all rows.
         assert.equal(result.groups.length, 1, 'Results in one group');
@@ -946,12 +903,8 @@
           lineNums: {left: 10, right: 100},
           chunkIndex: 1,
         };
-        const chunks = [
-          {a: ['foo']},
-          {ab: rows},
-          {a: ['bar']},
-        ];
-        const result = element._processNext(state, chunks);
+        const chunks = [{a: ['foo']}, {ab: rows}, {a: ['bar']}];
+        const result = element.processNext(state, chunks);
 
         // Results in one uncollapsed group with all rows.
         assert.equal(result.groups.length, 1, 'Results in one group');
@@ -959,40 +912,39 @@
       });
 
       suite('with key location', () => {
-        let state;
-        let chunks;
+        let state: State;
+        let chunks: DiffContent[];
 
         setup(() => {
           state = {
             lineNums: {left: 10, right: 100},
+            chunkIndex: 0,
           };
           element.context = 10;
-          chunks = [
-            {ab: rows},
-            {ab: ['foo'], keyLocation: true},
-            {ab: rows},
-          ];
+          chunks = [{ab: rows}, {ab: ['foo'], keyLocation: true}, {ab: rows}];
         });
 
         test('context before', () => {
           state.chunkIndex = 0;
-          const result = element._processNext(state, chunks);
+          const result = element.processNext(state, chunks);
 
           // The first chunk is split into two groups:
           // 1) A context-control, hiding everything but the context before
           //    the key location.
           // 2) The context before the key location.
-          // The key location is not processed in this call to _processNext
+          // The key location is not processed in this call to processNext
           assert.equal(result.groups.length, 2);
           // The collapsed group has the hidden lines as its context group.
-          assert.equal(result.groups[0].contextGroups[0].lines.length,
-              rows.length - element.context);
+          assert.equal(
+            result.groups[0].contextGroups[0].lines.length,
+            rows.length - element.context
+          );
           assert.equal(result.groups[1].lines.length, element.context);
         });
 
         test('key location itself', () => {
           state.chunkIndex = 1;
-          const result = element._processNext(state, chunks);
+          const result = element.processNext(state, chunks);
 
           // The second chunk results in a single group, that is just the
           // line with the key location
@@ -1004,7 +956,7 @@
 
         test('context after', () => {
           state.chunkIndex = 2;
-          const result = element._processNext(state, chunks);
+          const result = element.processNext(state, chunks);
 
           // The last chunk is split into two groups:
           // 1) The context after the key location.
@@ -1013,98 +965,110 @@
           assert.equal(result.groups.length, 2);
           assert.equal(result.groups[0].lines.length, element.context);
           // The collapsed group has the hidden lines as its context group.
-          assert.equal(result.groups[1].contextGroups[0].lines.length,
-              rows.length - element.context);
+          assert.equal(
+            result.groups[1].contextGroups[0].lines.length,
+            rows.length - element.context
+          );
         });
       });
     });
 
     suite('gr-diff-processor helpers', () => {
-      let rows;
+      let rows: string[];
 
       setup(() => {
         rows = loremIpsum.split(' ');
       });
 
-      test('_linesFromRows', () => {
+      test('linesFromRows', () => {
         const startLineNum = 10;
-        let result = element._linesFromRows(GrDiffLineType.ADD, rows,
-            startLineNum + 1);
+        let result = element.linesFromRows(
+          GrDiffLineType.ADD,
+          rows,
+          startLineNum + 1
+        );
 
         assert.equal(result.length, rows.length);
         assert.equal(result[0].type, GrDiffLineType.ADD);
         assert.notOk(result[0].hasIntralineInfo);
         assert.equal(result[0].afterNumber, startLineNum + 1);
         assert.notOk(result[0].beforeNumber);
-        assert.equal(result[result.length - 1].afterNumber,
-            startLineNum + rows.length);
+        assert.equal(
+          result[result.length - 1].afterNumber,
+          startLineNum + rows.length
+        );
         assert.notOk(result[result.length - 1].beforeNumber);
 
-        result = element._linesFromRows(GrDiffLineType.REMOVE, rows,
-            startLineNum + 1);
+        result = element.linesFromRows(
+          GrDiffLineType.REMOVE,
+          rows,
+          startLineNum + 1
+        );
 
         assert.equal(result.length, rows.length);
         assert.equal(result[0].type, GrDiffLineType.REMOVE);
         assert.notOk(result[0].hasIntralineInfo);
         assert.equal(result[0].beforeNumber, startLineNum + 1);
         assert.notOk(result[0].afterNumber);
-        assert.equal(result[result.length - 1].beforeNumber,
-            startLineNum + rows.length);
+        assert.equal(
+          result[result.length - 1].beforeNumber,
+          startLineNum + rows.length
+        );
         assert.notOk(result[result.length - 1].afterNumber);
       });
     });
 
-    suite('_breakdown*', () => {
-      test('_breakdownChunk breaks down additions', () => {
-        sinon.spy(element, '_breakdown');
+    suite('breakdown*', () => {
+      test('breakdownChunk breaks down additions', () => {
+        const breakdownSpy = sinon.spy(element, 'breakdown');
         const chunk = {b: ['blah', 'blah', 'blah']};
-        const result = element._breakdownChunk(chunk);
+        const result = element.breakdownChunk(chunk);
         assert.deepEqual(result, [chunk]);
-        assert.isTrue(element._breakdown.called);
+        assert.isTrue(breakdownSpy.called);
       });
 
-      test('_breakdownChunk keeps due_to_rebase for broken down additions',
-          () => {
-            sinon.spy(element, '_breakdown');
-            const chunk = {b: ['blah', 'blah', 'blah'], due_to_rebase: true};
-            const result = element._breakdownChunk(chunk);
-            for (const subResult of result) {
-              assert.isTrue(subResult.due_to_rebase);
-            }
-          });
+      test('breakdownChunk keeps due_to_rebase for broken down additions', () => {
+        sinon.spy(element, 'breakdown');
+        const chunk = {b: ['blah', 'blah', 'blah'], due_to_rebase: true};
+        const result = element.breakdownChunk(chunk);
+        for (const subResult of result) {
+          assert.isTrue(subResult.due_to_rebase);
+        }
+      });
 
-      test('_breakdown common case', () => {
-        const array = 'Lorem ipsum dolor sit amet, suspendisse inceptos'
-            .split(' ');
+      test('breakdown common case', () => {
+        const array = 'Lorem ipsum dolor sit amet, suspendisse inceptos'.split(
+          ' '
+        );
         const size = 3;
 
-        const result = element._breakdown(array, size);
+        const result = element.breakdown(array, size);
 
         for (const subResult of result) {
           assert.isAtMost(subResult.length, size);
         }
-        const flattened = result
-            .reduce((a, b) => a.concat(b), []);
+        const flattened = result.reduce((a, b) => a.concat(b), []);
         assert.deepEqual(flattened, array);
       });
 
-      test('_breakdown smaller than size', () => {
-        const array = 'Lorem ipsum dolor sit amet, suspendisse inceptos'
-            .split(' ');
+      test('breakdown smaller than size', () => {
+        const array = 'Lorem ipsum dolor sit amet, suspendisse inceptos'.split(
+          ' '
+        );
         const size = 10;
         const expected = [array];
 
-        const result = element._breakdown(array, size);
+        const result = element.breakdown(array, size);
 
         assert.deepEqual(result, expected);
       });
 
-      test('_breakdown empty', () => {
-        const array = [];
+      test('breakdown empty', () => {
+        const array: string[] = [];
         const size = 10;
-        const expected = [];
+        const expected: string[][] = [];
 
-        const result = element._breakdown(array, size);
+        const result = element.breakdown(array, size);
 
         assert.deepEqual(result, expected);
       });
@@ -1113,9 +1077,8 @@
 
   test('detaching cancels', () => {
     element = basicFixture.instantiate();
-    sinon.stub(element, 'cancel');
+    const cancelStub = sinon.stub(element, 'cancel');
     element.disconnectedCallback();
-    assert(element.cancel.called);
+    assert(cancelStub.called);
   });
 });
-
diff --git a/polygerrit-ui/package.json b/polygerrit-ui/package.json
index 0dc7074..cdc03aa 100644
--- a/polygerrit-ui/package.json
+++ b/polygerrit-ui/package.json
@@ -4,7 +4,6 @@
   "browser": true,
   "dependencies": {
     "@types/chai": "^4.2.16",
-    "@types/lodash": "^4.14.168",
     "@types/mocha": "^8.2.2",
     "@types/sinon": "^10.0.0"
   },
@@ -20,11 +19,10 @@
     "karma-chrome-launcher": "^3.1.0",
     "karma-mocha": "^2.0.1",
     "karma-mocha-reporter": "^2.2.5",
-    "lodash": "^4.17.21",
     "mocha": "8.3.2",
     "sinon": "^10.0.0",
     "source-map-support": "^0.5.19"
   },
   "license": "Apache-2.0",
   "private": true
-}
+}
\ No newline at end of file
diff --git a/polygerrit-ui/yarn.lock b/polygerrit-ui/yarn.lock
index 95f6438..44dd946 100644
--- a/polygerrit-ui/yarn.lock
+++ b/polygerrit-ui/yarn.lock
@@ -1343,11 +1343,6 @@
   dependencies:
     "@types/koa" "*"
 
-"@types/lodash@^4.14.168":
-  version "4.14.172"
-  resolved "https://registry.yarnpkg.com/@types/lodash/-/lodash-4.14.172.tgz#aad774c28e7bfd7a67de25408e03ee5a8c3d028a"
-  integrity sha512-/BHF5HAx3em7/KkzVKm3LrsD6HZAXuXO1AJZQ3cRRBZj4oHZDviWPYu0aEplAqDFNHZPW6d3G7KN+ONcCCC7pw==
-
 "@types/lru-cache@^5.1.0":
   version "5.1.1"
   resolved "https://registry.yarnpkg.com/@types/lru-cache/-/lru-cache-5.1.1.tgz#c48c2e27b65d2a153b19bfc1a317e30872e01eef"