Add support to skip chunks

Change-Id: I4769eb25e9d990328e661f48230e2719d1515a0a
diff --git a/polygerrit-ui/app/elements/diff/gr-diff-builder/gr-diff-builder.ts b/polygerrit-ui/app/elements/diff/gr-diff-builder/gr-diff-builder.ts
index d1f52ae..91fd137 100644
--- a/polygerrit-ui/app/elements/diff/gr-diff-builder/gr-diff-builder.ts
+++ b/polygerrit-ui/app/elements/diff/gr-diff-builder/gr-diff-builder.ts
@@ -18,6 +18,7 @@
 import {GrDiffLine, GrDiffLineType, LineNumber} from '../gr-diff/gr-diff-line';
 import {
   GrDiffGroup,
+  GrDiffGroupRange,
   GrDiffGroupType,
   hideInContextControl,
   rangeBySide,
@@ -64,6 +65,10 @@
   };
 }
 
+export interface ContentLoadNeededEventDetail {
+  lineRange: GrDiffGroupRange;
+}
+
 export abstract class GrDiffBuilder {
   private readonly _diff: DiffInfo;
 
@@ -93,7 +98,7 @@
     this._numLinesLeft = this._diff.content
       ? this._diff.content.reduce((sum, chunk) => {
           const left = chunk.a || chunk.ab;
-          return sum + (left ? left.length : 0);
+          return sum + (left?.length || chunk.skip || 0);
         }, 0)
       : 0;
     this._prefs = prefs;
@@ -311,8 +316,13 @@
 
     const td = this._createElement('td');
     const showPartialLinks = numLines > PARTIAL_CONTEXT_AMOUNT;
-
-    if (showPartialLinks && leftStart > 1) {
+    const firstGroupIsSkipped = !!contextGroups[0].skip;
+    const lastGroupIsSkipped = !!contextGroups[contextGroups.length - 1].skip;
+    const showAboveButton =
+      showPartialLinks && leftStart > 1 && !firstGroupIsSkipped;
+    const showBelowButton =
+      showPartialLinks && leftEnd < this._numLinesLeft && !lastGroupIsSkipped;
+    if (showAboveButton) {
       td.appendChild(
         this._createContextButton(
           ContextButtonType.ABOVE,
@@ -322,7 +332,6 @@
         )
       );
     }
-
     td.appendChild(
       this._createContextButton(
         ContextButtonType.ALL,
@@ -331,8 +340,7 @@
         numLines
       )
     );
-
-    if (showPartialLinks && leftEnd < this._numLinesLeft) {
+    if (showBelowButton) {
       td.appendChild(
         this._createContextButton(
           ContextButtonType.BELOW,
@@ -342,7 +350,6 @@
         )
       );
     }
-
     return td;
   }
 
@@ -359,7 +366,9 @@
 
     let text = '';
     let groups: GrDiffGroup[] = []; // The groups that replace this one if tapped.
+    let requiresLoad = false;
     if (type === GrDiffBuilder.ContextButtonType.ALL) {
+      requiresLoad = contextGroups.find(c => !!c.skip) !== undefined;
       const icon = this._createElement('iron-icon', 'showContext');
       icon.setAttribute('icon', 'gr-icons:unfold-more');
       button.appendChild(icon);
@@ -368,6 +377,10 @@
       if (numLines > 1) {
         text += 's';
       }
+      if (requiresLoad) {
+        // Expanding content would require load of more data
+        text += ' (too large)';
+      }
       groups.push(...contextGroups);
     } else if (type === GrDiffBuilder.ContextButtonType.ABOVE) {
       text = `+${context} above`;
@@ -380,15 +393,36 @@
     textSpan.textContent = text;
     button.appendChild(textSpan);
 
-    button.addEventListener('tap', e => {
-      const event = e as ContextEvent;
-      event.detail = {
-        groups,
-        section,
-        numLines,
-      };
-      // Let it bubble up the DOM tree.
-    });
+    if (requiresLoad) {
+      button.addEventListener('tap', e => {
+        e.stopPropagation();
+        const firstRange = groups[0].lineRange;
+        const lastRange = groups[groups.length - 1].lineRange;
+        const lineRange = {
+          left: {start: firstRange.left.start, end: lastRange.left.end},
+          right: {start: firstRange.right.start, end: lastRange.right.end},
+        };
+        button.dispatchEvent(
+          new CustomEvent<ContentLoadNeededEventDetail>('content-load-needed', {
+            detail: {
+              lineRange,
+            },
+            bubbles: true,
+            composed: true,
+          })
+        );
+      });
+    } else {
+      button.addEventListener('tap', e => {
+        const event = e as ContextEvent;
+        event.detail = {
+          groups,
+          section,
+          numLines,
+        };
+        // Let it bubble up the DOM tree.
+      });
+    }
 
     return button;
   }
diff --git a/polygerrit-ui/app/elements/diff/gr-diff-processor/gr-diff-processor.ts b/polygerrit-ui/app/elements/diff/gr-diff-processor/gr-diff-processor.ts
index 39e12ca..08ea1a6 100644
--- a/polygerrit-ui/app/elements/diff/gr-diff-processor/gr-diff-processor.ts
+++ b/polygerrit-ui/app/elements/diff/gr-diff-processor/gr-diff-processor.ts
@@ -274,7 +274,7 @@
   }
 
   _isCollapsibleChunk(chunk: DiffContent) {
-    return (chunk.ab || chunk.common) && !chunk.keyLocation;
+    return (chunk.ab || chunk.common || chunk.skip) && !chunk.keyLocation;
   }
 
   /**
@@ -326,7 +326,11 @@
   }
 
   _commonChunkLength(chunk: DiffContent) {
+    if (chunk.skip) {
+      return chunk.skip;
+    }
     console.assert(!!chunk.ab || !!chunk.common);
+
     console.assert(
       !chunk.a || (!!chunk.b && chunk.a.length === chunk.b.length),
       'common chunk needs same number of a and b lines: ',
@@ -354,13 +358,21 @@
     offsetLeft: number,
     offsetRight: number
   ): GrDiffGroup {
-    const type = chunk.ab ? GrDiffGroupType.BOTH : GrDiffGroupType.DELTA;
+    const type =
+      chunk.ab || chunk.skip ? GrDiffGroupType.BOTH : GrDiffGroupType.DELTA;
     const lines = this._linesFromChunk(chunk, offsetLeft, offsetRight);
     const group = new GrDiffGroup(type, lines);
     group.keyLocation = !!chunk.keyLocation;
     group.dueToRebase = !!chunk.due_to_rebase;
     group.dueToMove = !!chunk.due_to_move;
+    group.skip = chunk.skip;
     group.ignoredWhitespaceOnly = !!chunk.common;
+    if (chunk.skip) {
+      group.lineRange = {
+        left: {start: offsetLeft, end: offsetLeft + chunk.skip - 1},
+        right: {start: offsetRight, end: offsetRight + chunk.skip - 1},
+      };
+    }
     return group;
   }
 
@@ -497,7 +509,7 @@
 
     for (const chunk of chunks) {
       // If it isn't a common chunk, append it as-is and update line numbers.
-      if (!chunk.ab && !chunk.common) {
+      if (!chunk.ab && !chunk.skip && !chunk.common) {
         if (chunk.a) {
           leftLineNum += chunk.a.length;
         }
@@ -522,7 +534,13 @@
       leftLineNum += numLines;
       rightLineNum += numLines;
 
-      if (chunk.ab) {
+      if (chunk.skip) {
+        result.push({
+          ...chunk,
+          skip: chunk.skip,
+          keyLocation: false,
+        });
+      } else if (chunk.ab) {
         result.push(
           ...this._splitAtChunkEnds(chunk.ab, chunkEnds).map(
             ({lines, keyLocation}) => {
diff --git a/polygerrit-ui/app/elements/diff/gr-diff-processor/gr-diff-processor_test.js b/polygerrit-ui/app/elements/diff/gr-diff-processor/gr-diff-processor_test.js
index 3a074bb..eed5900 100644
--- a/polygerrit-ui/app/elements/diff/gr-diff-processor/gr-diff-processor_test.js
+++ b/polygerrit-ui/app/elements/diff/gr-diff-processor/gr-diff-processor_test.js
@@ -171,6 +171,56 @@
         });
       });
 
+      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')},
+          {skip: 43900},
+          {ab: new Array(30)
+              .fill('some other content')},
+          {a: ['some other content']},
+        ];
+
+        await element.process(content);
+
+        const groups = element.groups;
+
+        // group[0] is the file group
+
+        const commonGroup = groups[1];
+
+        // Hidden context before
+        assert.equal(commonGroup.type, GrDiffGroupType.CONTEXT_CONTROL);
+        assert.instanceOf(commonGroup.contextGroups[0], GrDiffGroup);
+        assert.equal(commonGroup.contextGroups[0].lines.length, 20);
+        for (const l of commonGroup.contextGroups[0].lines) {
+          assert.equal(l.text, 'all work and no play make jack a dull boy');
+        }
+
+        // Skipped group
+        const skipGroup = commonGroup.contextGroups[1];
+        assert.equal(skipGroup.skip, 43900);
+        const expectedRange = {
+          left: {start: 21, end: 43920},
+          right: {start: 21, end: 43920},
+        };
+        assert.deepEqual(skipGroup.lineRange, expectedRange);
+
+        // Hidden context after
+        assert.equal(commonGroup.contextGroups[2].lines.length, 20);
+        for (const l of commonGroup.contextGroups[2].lines) {
+          assert.equal(l.text, 'some other content');
+        }
+
+        // Displayed lines
+        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, 'some other content');
+        }
+      });
+
       test('at the beginning, smaller than context', () => {
         element.context = 10;
         const content = [
@@ -414,6 +464,55 @@
       });
     });
 
+    test('in the middle with skip chunks', async () => {
+      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')},
+        {skip: 60},
+        {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);
+
+      const groups = element.groups;
+
+      // group[0] is the file group
+      // group[1] is the chunk with a
+      // group[2] is the displayed part of ab before
+
+      const commonGroup = groups[3];
+
+      // Hidden context before
+      assert.equal(commonGroup.type, GrDiffGroupType.CONTEXT_CONTROL);
+      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');
+      }
+
+      // Skipped group
+      const skipGroup = commonGroup.contextGroups[1];
+      assert.equal(skipGroup.skip, 60);
+      const expectedRange = {
+        left: {start: 22, end: 81},
+        right: {start: 21, end: 80},
+      };
+      assert.deepEqual(skipGroup.lineRange, expectedRange);
+
+      // 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');
+      }
+      // group[4] is the displayed part of the second ab
+    });
+
     test('break up common diff chunks', () => {
       element.keyLocations = {
         left: {1: true},
diff --git a/polygerrit-ui/app/elements/diff/gr-diff/gr-diff-group.ts b/polygerrit-ui/app/elements/diff/gr-diff/gr-diff-group.ts
index 9f5cdf3..588b9d1 100644
--- a/polygerrit-ui/app/elements/diff/gr-diff/gr-diff-group.ts
+++ b/polygerrit-ui/app/elements/diff/gr-diff/gr-diff-group.ts
@@ -38,7 +38,7 @@
   end: number | null;
 }
 
-interface GrDiffGroupRange {
+export interface GrDiffGroupRange {
   left: Range;
   right: Range;
 }
@@ -89,7 +89,13 @@
       [before, hidden] = _splitCommonGroups(hidden, hiddenStart);
     }
     if (hiddenEnd) {
-      [hidden, after] = _splitCommonGroups(hidden, hiddenEnd - hiddenStart);
+      let beforeLength = 0;
+      if (before.length > 0) {
+        const beforeStart = before[0].lineRange.left.start || 0;
+        const beforeEnd = before[before.length - 1].lineRange.left.end || 0;
+        beforeLength = beforeEnd - beforeStart + 1;
+      }
+      [hidden, after] = _splitCommonGroups(hidden, hiddenEnd - beforeLength);
     }
   } else {
     [hidden, after] = [[], hidden];
@@ -106,6 +112,67 @@
 }
 
 /**
+ * Splits a group in two, defined by leftSplit and rightSplit. Primarily to be
+ * used in function _splitCommonGroups
+ * Groups with some lines before and some lines after the split will be split
+ * into two groups, which will be put into the first and second list.
+ *
+ * @param group The group to be split in two
+ * @param leftSplit The line number relative to the split on the left side
+ * @param rightSplit The line number relative to the split on the right side
+ * @return two new groups, one before the split and another after it
+ */
+function _splitGroupInTwo(
+  group: GrDiffGroup,
+  leftSplit: number,
+  rightSplit: number
+) {
+  let beforeSplit: GrDiffGroup | undefined;
+  let afterSplit: GrDiffGroup | undefined;
+  // split line is in the middle of a group, we need to break the group
+  // in lines before and after the split.
+  if (group.skip) {
+    // Currently we assume skip chunks "refuse" to be split. Expanding this
+    // group will in the future mean load more data - and therefore we want to
+    // fire an event when user wants to do it.
+    const closerToStartThanEnd =
+      leftSplit - (group.lineRange.left.start || 0) <
+      (group.lineRange.right.end || 0) - leftSplit;
+    if (closerToStartThanEnd) {
+      afterSplit = group;
+    } else {
+      beforeSplit = group;
+    }
+  } else {
+    const before = [];
+    const after = [];
+    for (const line of group.lines) {
+      if (
+        (line.beforeNumber && line.beforeNumber < leftSplit) ||
+        (line.afterNumber && line.afterNumber < rightSplit)
+      ) {
+        before.push(line);
+      } else {
+        after.push(line);
+      }
+    }
+    if (before.length) {
+      beforeSplit =
+        before.length === group.lines.length
+          ? group
+          : group.cloneWithLines(before);
+    }
+    if (after.length) {
+      afterSplit =
+        after.length === group.lines.length
+          ? group
+          : group.cloneWithLines(after);
+    }
+  }
+  return {beforeSplit, afterSplit};
+}
+
+/**
  * Splits a list of common groups into two lists of groups.
  *
  * Groups where all lines are before or all lines are after the split will be
@@ -129,47 +196,28 @@
   const beforeGroups = [];
   const afterGroups = [];
   for (const group of groups) {
-    if (
+    const isCompletelyBefore =
       (group.lineRange.left.end || 0) < leftSplit ||
-      (group.lineRange.right.end || 0) < rightSplit
-    ) {
-      beforeGroups.push(group);
-      continue;
-    }
-    if (
+      (group.lineRange.right.end || 0) < rightSplit;
+    const isCompletelyAfter =
       leftSplit <= (group.lineRange.left.start || 0) ||
-      rightSplit <= (group.lineRange.right.start || 0)
-    ) {
+      rightSplit <= (group.lineRange.right.start || 0);
+    if (isCompletelyBefore) {
+      beforeGroups.push(group);
+    } else if (isCompletelyAfter) {
       afterGroups.push(group);
-      continue;
-    }
-
-    const before = [];
-    const after = [];
-    for (const line of group.lines) {
-      if (
-        (line.beforeNumber && line.beforeNumber < leftSplit) ||
-        (line.afterNumber && line.afterNumber < rightSplit)
-      ) {
-        before.push(line);
-      } else {
-        after.push(line);
+    } else {
+      const {beforeSplit, afterSplit} = _splitGroupInTwo(
+        group,
+        leftSplit,
+        rightSplit
+      );
+      if (beforeSplit) {
+        beforeGroups.push(beforeSplit);
       }
-    }
-
-    if (before.length) {
-      beforeGroups.push(
-        before.length === group.lines.length
-          ? group
-          : group.cloneWithLines(before)
-      );
-    }
-    if (after.length) {
-      afterGroups.push(
-        after.length === group.lines.length
-          ? group
-          : group.cloneWithLines(after)
-      );
+      if (afterSplit) {
+        afterGroups.push(afterSplit);
+      }
     }
   }
   return [beforeGroups, afterGroups];
@@ -213,6 +261,8 @@
 
   contextGroups: GrDiffGroup[] = [];
 
+  skip?: number;
+
   /** Both start and end line are inclusive. */
   lineRange: GrDiffGroupRange = {
     left: {start: null, end: null},
diff --git a/polygerrit-ui/app/elements/diff/gr-diff/gr-diff-group_test.js b/polygerrit-ui/app/elements/diff/gr-diff/gr-diff-group_test.js
index 13fc4d1..3423834 100644
--- a/polygerrit-ui/app/elements/diff/gr-diff/gr-diff-group_test.js
+++ b/polygerrit-ui/app/elements/diff/gr-diff/gr-diff-group_test.js
@@ -177,6 +177,35 @@
       assert.deepEqual(collapsedGroups[3].lines, groups[2].lines.slice(1));
     });
 
+    suite('with skip chunks', () => {
+      setup(() => {
+        const skipGroup = new GrDiffGroup(GrDiffGroupType.BOTH);
+        skipGroup.skip = 60;
+        skipGroup.lineRange = {
+          left: {start: 8, end: 67},
+          right: {start: 10, end: 69},
+        };
+        groups = [
+          new GrDiffGroup(GrDiffGroupType.BOTH, [
+            new GrDiffLine(GrDiffLineType.BOTH, 5, 7),
+            new GrDiffLine(GrDiffLineType.BOTH, 6, 8),
+            new GrDiffLine(GrDiffLineType.BOTH, 7, 9),
+          ]),
+          skipGroup,
+          new GrDiffGroup(GrDiffGroupType.BOTH, [
+            new GrDiffLine(GrDiffLineType.BOTH, 68, 70),
+            new GrDiffLine(GrDiffLineType.BOTH, 69, 71),
+            new GrDiffLine(GrDiffLineType.BOTH, 70, 72),
+          ]),
+        ];
+      });
+
+      test('refuses to split skip group when closer to before', () => {
+        const collapsedGroups = hideInContextControl(groups, 4, 10);
+        assert.deepEqual(groups, collapsedGroups);
+      });
+    });
+
     test('groups unchanged if the hidden range is empty', () => {
       assert.deepEqual(
           hideInContextControl(groups, 0, 0), groups);
diff --git a/polygerrit-ui/app/types/common.ts b/polygerrit-ui/app/types/common.ts
index 6b184b9..96cb02a 100644
--- a/polygerrit-ui/app/types/common.ts
+++ b/polygerrit-ui/app/types/common.ts
@@ -1254,7 +1254,7 @@
   edit_b?: number[][];
   due_to_rebase?: boolean;
   due_to_move?: boolean;
-  skip?: string;
+  skip?: number;
   common?: string;
   keyLocation?: boolean;
 }