Do not collapse delta groups in context control group if they are
focussed

This is done by checking if the focussed range overlaps with the
delta group.

Change-Id: Iae08245503871925a6ca81da34aee1bcb53df3a7
Release-Notes: skip
Google-Bug-Id: b/345126277
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 1561dad..d138414 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
@@ -80,10 +80,14 @@
 
   private groups: GrDiffGroup[] = [];
 
+  // visible for testing
+  diffRangesToFocus?: DiffRangesToFocus;
+
   constructor(options: ProcessingOptions) {
     this.context = options.context;
     this.keyLocations = options.keyLocations ?? {left: {}, right: {}};
     this.isBinary = options.isBinary ?? false;
+    this.diffRangesToFocus = options.diffRangesToFocus;
   }
 
   /**
@@ -127,7 +131,7 @@
   processNext(state: State, chunks: DiffContent[]) {
     const firstUncollapsibleChunkIndex = this.firstUncollapsibleChunkIndex(
       chunks,
-      state.chunkIndex
+      state
     );
     if (firstUncollapsibleChunkIndex === state.chunkIndex) {
       const chunk = chunks[state.chunkIndex];
@@ -162,19 +166,67 @@
     return chunk.ab || chunk.b || [];
   }
 
-  private firstUncollapsibleChunkIndex(chunks: DiffContent[], offset: number) {
-    let chunkIndex = offset;
+  private firstUncollapsibleChunkIndex(chunks: DiffContent[], state: State) {
+    let chunkIndex = state.chunkIndex;
+    let offsetLeft = state.lineNums.left;
+    let offsetRight = state.lineNums.right;
     while (
       chunkIndex < chunks.length &&
-      this.isCollapsibleChunk(chunks[chunkIndex])
+      this.isCollapsibleChunk(chunks[chunkIndex], offsetLeft, offsetRight)
     ) {
+      offsetLeft += this.chunkLength(chunks[chunkIndex], Side.LEFT);
+      offsetRight += this.chunkLength(chunks[chunkIndex], Side.RIGHT);
       chunkIndex++;
     }
     return chunkIndex;
   }
 
-  private isCollapsibleChunk(chunk: DiffContent) {
-    return (chunk.ab || chunk.common || chunk.skip) && !chunk.keyLocation;
+  private isCollapsibleChunk(
+    chunk: DiffContent,
+    offsetLeft: number,
+    offsetRight: number
+  ) {
+    return (
+      (chunk.ab ||
+        chunk.common ||
+        chunk.skip ||
+        this.isChunkOutsideOfFocusRange(chunk, offsetLeft, offsetRight)) &&
+      !chunk.keyLocation
+    );
+  }
+
+  private isChunkOutsideOfFocusRange(
+    chunk: DiffContent,
+    offsetLeft: number,
+    offsetRight: number
+  ) {
+    if (!this.diffRangesToFocus) {
+      return false;
+    }
+    const leftLineCount = this.linesLeft(chunk).length;
+    const rightLineCount = this.linesRight(chunk).length;
+    const hasLeftSideOverlap = this.diffRangesToFocus.left.some(range =>
+      this.hasAnyOverlap(
+        {start: offsetLeft, end: offsetLeft + leftLineCount},
+        range
+      )
+    );
+    const hasRightSideOverlap = this.diffRangesToFocus.right.some(range =>
+      this.hasAnyOverlap(
+        {start: offsetRight, end: offsetRight + rightLineCount},
+        range
+      )
+    );
+    return !hasLeftSideOverlap && !hasRightSideOverlap;
+  }
+
+  private hasAnyOverlap(
+    firstRange: {start: number; end: number},
+    secondRange: {start: number; end: number}
+  ) {
+    const startOverlap = Math.max(firstRange.start, secondRange.start);
+    const endOverlap = Math.min(firstRange.end, secondRange.end);
+    return startOverlap <= endOverlap;
   }
 
   /**
@@ -196,8 +248,12 @@
       state.chunkIndex,
       firstUncollapsibleChunkIndex
     );
-    const lineCount = collapsibleChunks.reduce(
-      (sum, chunk) => sum + this.commonChunkLength(chunk),
+    const leftLineCount = collapsibleChunks.reduce(
+      (sum, chunk) => sum + this.chunkLength(chunk, Side.LEFT),
+      0
+    );
+    const rightLineCount = collapsibleChunks.reduce(
+      (sum, chunk) => sum + this.chunkLength(chunk, Side.RIGHT),
       0
     );
 
@@ -208,25 +264,50 @@
     );
 
     const hasSkippedGroup = !!groups.find(g => g.skip);
-    if (this.context !== FULL_CONTEXT || hasSkippedGroup) {
+    const hasNonCommonDeltaGroup = !!groups.find(
+      g => g.type === GrDiffGroupType.DELTA && !g.ignoredWhitespaceOnly
+    );
+    if (
+      this.context !== FULL_CONTEXT ||
+      hasSkippedGroup ||
+      hasNonCommonDeltaGroup
+    ) {
       const contextNumLines = this.context > 0 ? this.context : 0;
       const hiddenStart = state.chunkIndex === 0 ? 0 : contextNumLines;
-      const hiddenEnd =
-        lineCount -
+      const hiddenEndLeft =
+        leftLineCount -
         (firstUncollapsibleChunkIndex === chunks.length ? 0 : this.context);
-      groups = hideInContextControl(groups, hiddenStart, hiddenEnd);
+      const hiddenEndRight =
+        rightLineCount -
+        (firstUncollapsibleChunkIndex === chunks.length ? 0 : this.context);
+      groups = hideInContextControl(
+        groups,
+        hiddenStart,
+        hiddenEndLeft,
+        hiddenEndRight
+      );
     }
 
     return {
       lineDelta: {
-        left: lineCount,
-        right: lineCount,
+        left: leftLineCount,
+        right: rightLineCount,
       },
       groups,
       newChunkIndex: firstUncollapsibleChunkIndex,
     };
   }
 
+  private chunkLength(chunk: DiffContent, side: Side) {
+    if (chunk.skip || chunk.common || chunk.ab) {
+      return this.commonChunkLength(chunk);
+    } else if (side === Side.LEFT) {
+      return this.linesLeft(chunk).length;
+    } else {
+      return this.linesRight(chunk).length;
+    }
+  }
+
   private commonChunkLength(chunk: DiffContent) {
     if (chunk.skip) {
       return chunk.skip;
@@ -248,9 +329,8 @@
   ): GrDiffGroup[] {
     return chunks.map(chunk => {
       const group = this.chunkToGroup(chunk, offsetLeft, offsetRight);
-      const chunkLength = this.commonChunkLength(chunk);
-      offsetLeft += chunkLength;
-      offsetRight += chunkLength;
+      offsetLeft += this.chunkLength(chunk, Side.LEFT);
+      offsetRight += this.chunkLength(chunk, Side.RIGHT);
       return group;
     });
   }
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 f6b9737..3f01096 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
@@ -933,6 +933,128 @@
           );
         });
       });
+
+      suite('with diffRangesToFocus', () => {
+        let state: State;
+        let chunks: DiffContent[];
+
+        setup(() => {
+          state = {
+            lineNums: {left: 0, right: 0},
+            chunkIndex: 0,
+          };
+          processor.context = 3;
+          processor.diffRangesToFocus = {
+            left: [{start: 6, end: 7}],
+            right: [{start: 6, end: 6}],
+          };
+          chunks = [
+            {
+              ab: Array.from<string>({length: 5}).fill(
+                'all work and no play make jill a dull boy'
+              ),
+            },
+            {
+              a: ['Old ', ' Change!'],
+              b: ['New Change'],
+            },
+            {
+              ab: Array.from<string>({length: 5}).fill(
+                'all work and no play make jack a dull boy'
+              ),
+            },
+            {
+              a: ['Old ', ' Change!', '1'],
+              b: ['New Change', '2'],
+            },
+          ];
+        });
+
+        test('focussed group is not collapsed in context control group', () => {
+          const result = processor.processNext(state, chunks);
+
+          // This should consider second delta group as focussed and not collapse it.
+          // This result is first chunk itself.
+          assert.equal(result.groups.length, 1);
+          assert.equal(result.groups[0].type, GrDiffGroupType.BOTH);
+          assert.equal(result.groups[0].lines.length, 5);
+        });
+
+        test('collapsing delta group at end in context control group', () => {
+          state = {
+            lineNums: {left: 7, right: 6},
+            chunkIndex: 2,
+          };
+          const result = processor.processNext(state, [
+            ...chunks,
+            {
+              ab: Array.from<string>({length: 5}).fill(
+                'all work and no play make jack a dull boy'
+              ),
+            },
+          ]);
+
+          // The first chunk is split into two groups:
+          // 1) A common group which is rendered before contextControl group
+          // 2) Second group is a context control which contains split from 4th chunk
+          // and the delta group and the last unchanged group.
+          assert.equal(result.groups.length, 2);
+          assert.equal(result.groups[0].type, GrDiffGroupType.BOTH);
+          assert.equal(result.groups[0].lines.length, 3);
+          assert.equal(result.groups[1].type, GrDiffGroupType.CONTEXT_CONTROL);
+          assert.equal(result.groups[1].contextGroups.length, 3);
+          assert.equal(result.groups[1].contextGroups[0].lines.length, 2);
+          assert.equal(
+            result.groups[1].contextGroups[1].type,
+            GrDiffGroupType.DELTA
+          );
+          assert.equal(
+            result.groups[1].contextGroups[2].type,
+            GrDiffGroupType.BOTH
+          );
+        });
+
+        test('collapsing delta group in middle in context control group', () => {
+          state = {
+            lineNums: {left: 7, right: 6},
+            chunkIndex: 2,
+          };
+          const result = processor.processNext(state, chunks);
+
+          // The first chunk is split into two groups:
+          // 1) A common group which is rendered before contextControl group
+          // 2) Second group is a context control which contains split from 4th chunk
+          // and the delta group.
+          assert.equal(result.groups.length, 2);
+          assert.equal(result.groups[0].type, GrDiffGroupType.BOTH);
+          assert.equal(result.groups[0].lines.length, 3);
+          assert.equal(result.groups[1].type, GrDiffGroupType.CONTEXT_CONTROL);
+          assert.equal(result.groups[1].contextGroups.length, 2);
+          assert.equal(result.groups[1].contextGroups[0].lines.length, 2);
+          assert.equal(
+            result.groups[1].contextGroups[1].type,
+            GrDiffGroupType.DELTA
+          );
+        });
+
+        test('do not collapse if there are not enough context lines', () => {
+          processor.context = 10;
+          state = {
+            lineNums: {left: 7, right: 6},
+            chunkIndex: 2,
+          };
+          const result = processor.processNext(state, chunks);
+
+          // The first chunk is split into two groups:
+          // 1) A common group which is rendered before contextControl group
+          // 2) Second group is a context control which contains split from 4th chunk
+          // and the delta group.
+          assert.equal(result.groups.length, 2);
+          assert.equal(result.groups[0].type, GrDiffGroupType.BOTH);
+          assert.equal(result.groups[0].lines.length, 5);
+          assert.equal(result.groups[1].type, GrDiffGroupType.DELTA);
+        });
+      });
     });
 
     suite('gr-diff-processor helpers', () => {
diff --git a/polygerrit-ui/app/embed/diff/gr-diff/gr-diff-group.ts b/polygerrit-ui/app/embed/diff/gr-diff/gr-diff-group.ts
index c7e9f44..e7df002 100644
--- a/polygerrit-ui/app/embed/diff/gr-diff/gr-diff-group.ts
+++ b/polygerrit-ui/app/embed/diff/gr-diff/gr-diff-group.ts
@@ -42,40 +42,56 @@
  * @param hiddenStart The first element to be hidden, as a
  *     non-negative line number offset relative to the first group's start
  *     line, left and right respectively.
- * @param hiddenEnd The first visible element after the hidden range,
- *     as a non-negative line number offset relative to the first group's
- *     start line, left and right respectively.
+ * @param hiddenEndLeft The first visible element after the hidden range,
+ *     as a non-negative line number offset for left side relative to the first
+ *     group's start line.
+ * @param hiddenEndRight The first visible element after the hidden range,
+ *     as a non-negative line number offset for right side relative to the first
+ *     group's start line. If not provided hiddenEndLeft will be used.
  */
 export function hideInContextControl(
   groups: readonly GrDiffGroup[],
   hiddenStart: number,
-  hiddenEnd: number
+  hiddenEndLeft: number,
+  hiddenEndRight?: number
 ): GrDiffGroup[] {
   if (groups.length === 0) return [];
   // Clamp hiddenStart and hiddenEnd - inspired by e.g. substring
   hiddenStart = Math.max(hiddenStart, 0);
-  hiddenEnd = Math.max(hiddenEnd, hiddenStart);
+  hiddenEndLeft = Math.max(hiddenEndLeft, hiddenStart);
+  hiddenEndRight = Math.max(hiddenEndRight ?? hiddenEndLeft, hiddenStart);
 
   let before: GrDiffGroup[] = [];
   let hidden = groups;
   let after: readonly GrDiffGroup[] = [];
 
-  const numHidden = hiddenEnd - hiddenStart;
+  const numHiddenLeft = hiddenEndLeft - hiddenStart;
+  const numHiddenRight = hiddenEndRight - hiddenStart;
 
   // Showing a context control row for less than 4 lines does not make much,
   // because then that row would consume as much space as the collapsed code.
-  if (numHidden > 3) {
+  if (numHiddenLeft > 3 && numHiddenRight > 3) {
     if (hiddenStart) {
-      [before, hidden] = splitCommonGroups(hidden, hiddenStart);
+      [before, hidden] = splitCommonGroups(hidden, hiddenStart, hiddenStart);
     }
-    if (hiddenEnd) {
-      let beforeLength = 0;
+    if (hiddenEndLeft && hiddenEndRight) {
+      let beforeLengthLeft = 0;
+      let beforeLengthRight = 0;
       if (before.length > 0) {
-        const beforeStart = before[0].lineRange.left.start_line;
-        const beforeEnd = before[before.length - 1].lineRange.left.end_line;
-        beforeLength = beforeEnd - beforeStart + 1;
+        const beforeStartLeft = before[0].lineRange.left.start_line;
+        const beforeEndLeft = before[before.length - 1].lineRange.left.end_line;
+        beforeLengthLeft = beforeEndLeft - beforeStartLeft + 1;
+
+        const beforeStartRight = before[0].lineRange.right.start_line;
+        const beforeEndRight =
+          before[before.length - 1].lineRange.right.end_line;
+        beforeLengthRight = beforeEndRight - beforeStartRight + 1;
       }
-      [hidden, after] = splitCommonGroups(hidden, hiddenEnd - beforeLength);
+      [hidden, after] = splitCommonGroups(
+        hidden,
+        hiddenEndLeft - beforeLengthLeft,
+        hiddenEndRight - beforeLengthRight
+      );
     }
   } else {
     [hidden, after] = [[], hidden];
@@ -168,18 +184,21 @@
  * two groups, which will be put into the first and second list. Groups with
  * type DELTA which are not common will not be split.
  *
- * @param split A line number offset relative to the first group's
- *     start line at which the groups should be split.
+ * @param splitLeft A line number offset for left side relative to the first
+ *     group's start line at which the groups should be split.
+ * @param splitRight A line number offset for right side relative to the first
+ *     group's start line at which the groups should be split.
  * @return The outer array has 2 elements, the
  *   list of groups before and the list of groups after the split.
  */
 function splitCommonGroups(
   groups: readonly GrDiffGroup[],
-  split: number
+  splitLeft: number,
+  splitRight: number
 ): GrDiffGroup[][] {
   if (groups.length === 0) return [[], []];
-  const leftSplit = groups[0].lineRange.left.start_line + split;
-  const rightSplit = groups[0].lineRange.right.start_line + split;
+  const leftSplit = groups[0].lineRange.left.start_line + splitLeft;
+  const rightSplit = groups[0].lineRange.right.start_line + splitRight;
   let isSplitDone = false;
   const beforeGroups = [];
   const afterGroups = [];