Merge "Move 650 lines of gr-diff css into its own file" into stable-3.8
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 396e9e2..328b577 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
@@ -14,7 +14,6 @@
 } from './gr-diff-builder';
 import {GrDiffBuilderImage} from './gr-diff-builder-image';
 import {GrDiffBuilderBinary} from './gr-diff-builder-binary';
-import {CancelablePromise, makeCancelable} from '../../../utils/async-util';
 import {BlameInfo, ImageInfo} from '../../../types/common';
 import {DiffInfo, DiffPreferencesInfo} from '../../../types/diff';
 import {CoverageRange, DiffLayer} from '../../../types/types';
@@ -130,13 +129,6 @@
   // visible for testing
   showTrailingWhitespace?: boolean;
 
-  /**
-   * The promise last returned from `render()` while the asynchronous
-   * rendering is running - `null` otherwise. Provides a `cancel()`
-   * method that rejects it with `{isCancelled: true}`.
-   */
-  private cancelableRenderPromise: CancelablePromise<unknown> | null = null;
-
   private coverageLayerLeft = new GrCoverageLayer(Side.LEFT);
 
   private coverageLayerRight = new GrCoverageLayer(Side.RIGHT);
@@ -144,7 +136,7 @@
   private rangeLayer?: GrRangedCommentLayer;
 
   // visible for testing
-  processor = new GrDiffProcessor();
+  processor?: GrDiffProcessor;
 
   /**
    * Groups are mostly just passed on to the diff builder (this.builder). But
@@ -156,10 +148,6 @@
    */
   private groups: GrDiffGroup[] = [];
 
-  constructor() {
-    this.processor.consumer = this;
-  }
-
   updateCommentRanges(ranges: CommentRangeLayer[]) {
     this.rangeLayer?.updateRanges(ranges);
   }
@@ -186,8 +174,16 @@
     this.builder = this.getDiffBuilder();
     this.init();
 
+    // TODO: Just pass along the diff model here instead of setting many
+    // individual properties.
+    this.processor = new GrDiffProcessor();
+    this.processor.consumer = this;
     this.processor.context = this.prefs.context;
     this.processor.keyLocations = keyLocations;
+    if (this.renderPrefs?.num_lines_rendered_at_once) {
+      this.processor.asyncThreshold =
+        this.renderPrefs.num_lines_rendered_at_once;
+    }
 
     this.clearDiffContent();
     this.builder.addColumns(
@@ -198,14 +194,9 @@
     const isBinary = !!(this.isImageDiff || this.diff.binary);
 
     fire(this.diffElement, 'render-start', {});
-    // TODO: processor.process() returns a cancelable promise already.
-    // Why wrap another one around it?
-    this.cancelableRenderPromise = makeCancelable(
-      this.processor.process(this.diff.content, isBinary)
-    );
-    // All then/catch/finally clauses must be outside of makeCancelable().
     return (
-      this.cancelableRenderPromise
+      this.processor
+        .process(this.diff.content, isBinary)
         .then(async () => {
           if (isImageDiffBuilder(this.builder)) {
             this.builder.renderImageDiff();
@@ -222,9 +213,6 @@
           if (!e.isCanceled) return Promise.reject(e);
           return;
         })
-        .finally(() => {
-          this.cancelableRenderPromise = null;
-        })
     );
   }
 
@@ -376,10 +364,8 @@
    * gr-diff re-connects.
    */
   cleanup() {
-    this.processor.cancel();
+    this.processor?.cancel();
     this.builder?.cleanup();
-    this.cancelableRenderPromise?.cancel();
-    this.cancelableRenderPromise = null;
     this.diffElement?.removeEventListener(
       'diff-context-expanded',
       this.onDiffContextExpanded
@@ -584,6 +570,5 @@
 
   updateRenderPrefs(renderPrefs: RenderPreferences) {
     this.builder?.updateRenderPrefs(renderPrefs);
-    this.processor.updateRenderPrefs(renderPrefs);
   }
 }
diff --git a/polygerrit-ui/app/embed/diff/gr-diff-builder/gr-diff-builder-element_test.ts b/polygerrit-ui/app/embed/diff/gr-diff-builder/gr-diff-builder-element_test.ts
index d776164..da2e9f1 100644
--- a/polygerrit-ui/app/embed/diff/gr-diff-builder/gr-diff-builder-element_test.ts
+++ b/polygerrit-ui/app/embed/diff/gr-diff-builder/gr-diff-builder-element_test.ts
@@ -27,6 +27,7 @@
 import {fixture, html, assert} from '@open-wc/testing';
 import {GrDiffRow} from './gr-diff-row';
 import {GrDiffBuilder} from './gr-diff-builder';
+import {querySelectorAll} from '../../../utils/dom-util';
 
 const DEFAULT_PREFS = createDefaultDiffPrefs();
 
@@ -470,15 +471,11 @@
   });
 
   suite('rendering text, images and binary files', () => {
-    let processStub: sinon.SinonStub;
     let keyLocations: KeyLocations;
     let content: DiffContent[] = [];
 
     setup(() => {
       element.viewMode = 'SIDE_BY_SIDE';
-      processStub = sinon
-        .stub(element.processor, 'process')
-        .returns(Promise.resolve());
       keyLocations = {left: {}, right: {}};
       element.prefs = {
         ...DEFAULT_PREFS,
@@ -503,8 +500,7 @@
       element.diff = {...createEmptyDiff(), content};
       element.render(keyLocations);
       await waitForEventOnce(diffTable, 'render-content');
-      assert.isTrue(processStub.calledOnce);
-      assert.isFalse(processStub.lastCall.args[1]);
+      assert.equal(querySelectorAll(diffTable, 'tbody')?.length, 4);
     });
 
     test('image', async () => {
@@ -512,16 +508,14 @@
       element.isImageDiff = true;
       element.render(keyLocations);
       await waitForEventOnce(diffTable, 'render-content');
-      assert.isTrue(processStub.calledOnce);
-      assert.isTrue(processStub.lastCall.args[1]);
+      assert.equal(querySelectorAll(diffTable, 'tbody')?.length, 4);
     });
 
     test('binary', async () => {
       element.diff = {...createEmptyDiff(), content, binary: true};
       element.render(keyLocations);
       await waitForEventOnce(diffTable, 'render-content');
-      assert.isTrue(processStub.calledOnce);
-      assert.isTrue(processStub.lastCall.args[1]);
+      assert.equal(querySelectorAll(diffTable, 'tbody')?.length, 3);
     });
   });
 
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 22a71a5..05e5d3b 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
@@ -15,12 +15,10 @@
   GrDiffGroupType,
   hideInContextControl,
 } from '../gr-diff/gr-diff-group';
-import {CancelablePromise, makeCancelable} from '../../../utils/async-util';
 import {DiffContent} from '../../../types/diff';
 import {Side} from '../../../constants/constants';
 import {debounce, DelayedTask} from '../../../utils/async-util';
-import {RenderPreferences} from '../../../api/diff';
-import {assertIsDefined} from '../../../utils/common-util';
+import {assert, assertIsDefined} from '../../../utils/common-util';
 import {GrAnnotation} from '../gr-diff-highlight/gr-annotation';
 
 const WHOLE_FILE = -1;
@@ -95,15 +93,17 @@
 
   keyLocations: KeyLocations = {left: {}, right: {}};
 
-  private asyncThreshold = 64;
-
-  private nextStepHandle: number | null = null;
-
-  private processPromise: CancelablePromise<void> | null = null;
+  asyncThreshold = 64;
 
   // visible for testing
   isScrolling?: boolean;
 
+  /** Just for making sure that process() is only called once. */
+  private isStarted = false;
+
+  /** Indicates that processing should be stopped. */
+  private isCancelled = false;
+
   private resetIsScrollingTask?: DelayedTask;
 
   private readonly handleWindowScroll = () => {
@@ -123,9 +123,9 @@
    * array of GrDiffGroups when the diff is completely processed.
    */
   process(chunks: DiffContent[], isBinary: boolean) {
-    // Cancel any still running process() calls, because they append to the
-    // same groups field.
-    this.cancel();
+    assert(this.isStarted === false, 'diff processor cannot be started twice');
+    this.isStarted = true;
+
     window.addEventListener('scroll', this.handleWindowScroll);
 
     assertIsDefined(this.consumer, 'consumer');
@@ -133,84 +133,61 @@
     this.consumer.addGroup(this.makeGroup('LOST'));
     this.consumer.addGroup(this.makeGroup(FILE));
 
-    // If it's a binary diff, we won't be rendering hunks of text differences
-    // so finish processing.
-    if (isBinary) {
-      return Promise.resolve();
-    }
+    if (isBinary) return Promise.resolve();
 
-    // TODO: Canceling this promise does not help much. `nextStep` will continue
-    // to be scheduled anyway. So either just remove the cancelable promise, so
-    // future programmers are not fooled about this promise can do. Or fix the
-    // scheduling of `nextStep` such that cancellation is taken into account.
-    // The easiest approach is likely to just not re-use the processor for
-    // multiple processing passes. There is no benefit from doing so.
-    this.processPromise = makeCancelable(
-      new Promise(resolve => {
-        const state = {
-          lineNums: {left: 0, right: 0},
-          chunkIndex: 0,
-        };
+    return new Promise<void>(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);
-            return;
-          }
-          // If we are done, resolve the promise.
-          if (state.chunkIndex >= chunks.length) {
-            resolve();
-            this.nextStepHandle = null;
-            return;
-          }
+      let currentBatch = 0;
+      const nextStep = () => {
+        if (this.isCancelled || state.chunkIndex >= chunks.length) {
+          resolve();
+          return;
+        }
+        if (this.isScrolling) {
+          window.setTimeout(nextStep, 100);
+          return;
+        }
 
-          // Process the next chunk and incorporate the result.
-          const stateUpdate = this.processNext(state, chunks);
-          for (const group of stateUpdate.groups) {
-            assertIsDefined(this.consumer, 'consumer');
-            this.consumer.addGroup(group);
-            currentBatch += group.lines.length;
-          }
-          state.lineNums.left += stateUpdate.lineDelta.left;
-          state.lineNums.right += stateUpdate.lineDelta.right;
+        const stateUpdate = this.processNext(state, chunks);
+        for (const group of stateUpdate.groups) {
+          this.consumer?.addGroup(group);
+          currentBatch += group.lines.length;
+        }
+        state.lineNums.left += stateUpdate.lineDelta.left;
+        state.lineNums.right += stateUpdate.lineDelta.right;
 
-          // Increment the index and recurse.
-          state.chunkIndex = stateUpdate.newChunkIndex;
-          if (currentBatch >= this.asyncThreshold) {
-            currentBatch = 0;
-            this.nextStepHandle = window.setTimeout(nextStep, 1);
-          } else {
-            nextStep.call(this);
-          }
-        };
+        state.chunkIndex = stateUpdate.newChunkIndex;
+        if (currentBatch >= this.asyncThreshold) {
+          currentBatch = 0;
+          window.setTimeout(nextStep, 1);
+        } else {
+          nextStep.call(this);
+        }
+      };
 
-        nextStep.call(this);
-      })
-    );
-    return this.processPromise.finally(() => {
-      this.processPromise = null;
-      window.removeEventListener('scroll', this.handleWindowScroll);
+      nextStep.call(this);
+    }).finally(() => {
+      this.finish();
     });
   }
 
-  /**
-   * Cancel any jobs that are running.
-   */
-  cancel() {
-    if (this.nextStepHandle !== null) {
-      window.clearTimeout(this.nextStepHandle);
-      this.nextStepHandle = null;
-    }
-    if (this.processPromise) {
-      this.processPromise.cancel();
-    }
+  finish() {
+    this.consumer = undefined;
     window.removeEventListener('scroll', this.handleWindowScroll);
   }
 
+  cancel() {
+    this.isCancelled = true;
+    this.finish();
+  }
+
   /**
    * Process the next uncollapsible chunk, or the next collapsible chunks.
    */
@@ -739,10 +716,4 @@
 
     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;
-    }
-  }
 }
diff --git a/polygerrit-ui/app/embed/diff/gr-diff-processor/gr-diff-processor_test.ts b/polygerrit-ui/app/embed/diff/gr-diff-processor/gr-diff-processor_test.ts
index 5b23ee7..adcfff8 100644
--- a/polygerrit-ui/app/embed/diff/gr-diff-processor/gr-diff-processor_test.ts
+++ b/polygerrit-ui/app/embed/diff/gr-diff-processor/gr-diff-processor_test.ts
@@ -784,13 +784,16 @@
       ]);
     });
 
-    test('scrolling pauses rendering', () => {
+    test('isScrolling paused', () => {
       const content = Array(200).fill({ab: ['', '']});
       element.isScrolling = true;
       element.process(content, false);
-      // Just the files group - no more processing during scrolling.
+      // Just the FILE and LOST groups.
       assert.equal(groups.length, 2);
+    });
 
+    test('isScrolling unpaused', () => {
+      const content = Array(200).fill({ab: ['', '']});
       element.isScrolling = false;
       element.process(content, false);
       // More groups have been processed. How many does not matter here.