Merge "PerformanceMetrics: Log per request latency for configured operations"
diff --git a/Documentation/metrics.txt b/Documentation/metrics.txt
index a9b82d6..a510e25 100644
--- a/Documentation/metrics.txt
+++ b/Documentation/metrics.txt
@@ -93,10 +93,6 @@
 ** `plugin`:
    The name of the plugin that performed the operation.
 
-Performance metrics can be enabled via the
-link:config.gerrit.html#tracing.exportPerformanceMetrics[`tracing.exportPerformanceMetrics`]
-setting.
-
 === Pushes
 
 * `receivecommits/changes`: histogram of number of changes processed in a single
diff --git a/java/com/google/gerrit/acceptance/AssertUtil.java b/java/com/google/gerrit/acceptance/AssertUtil.java
index a1d3e79..f72c6d3 100644
--- a/java/com/google/gerrit/acceptance/AssertUtil.java
+++ b/java/com/google/gerrit/acceptance/AssertUtil.java
@@ -25,9 +25,9 @@
 public class AssertUtil {
   public static <T> void assertPrefs(T actual, T expected, String... fieldsToExclude)
       throws IllegalArgumentException, IllegalAccessException {
-    Set<String> exludedFields = new HashSet<>(Arrays.asList(fieldsToExclude));
+    Set<String> excludedFields = new HashSet<>(Arrays.asList(fieldsToExclude));
     for (Field field : actual.getClass().getDeclaredFields()) {
-      if (exludedFields.contains(field.getName()) || skipField(field)) {
+      if (excludedFields.contains(field.getName()) || skipField(field)) {
         continue;
       }
       Object actualVal = field.get(actual);
diff --git a/java/com/google/gerrit/server/cancellation/RequestCancelledException.java b/java/com/google/gerrit/server/cancellation/RequestCancelledException.java
index dc01567..9663427 100644
--- a/java/com/google/gerrit/server/cancellation/RequestCancelledException.java
+++ b/java/com/google/gerrit/server/cancellation/RequestCancelledException.java
@@ -15,7 +15,9 @@
 package com.google.gerrit.server.cancellation;
 
 import com.google.common.base.Throwables;
+import com.google.common.collect.Streams;
 import com.google.gerrit.common.Nullable;
+import java.util.Arrays;
 import java.util.Optional;
 import org.apache.commons.text.WordUtils;
 
@@ -28,7 +30,9 @@
    * {@link RequestCancelledException} is returned. If not, {@link Optional#empty()} is returned.
    */
   public static Optional<RequestCancelledException> getFromCausalChain(Throwable e) {
-    return Throwables.getCausalChain(e).stream()
+    return Streams.concat(
+            Throwables.getCausalChain(e).stream(),
+            Throwables.getCausalChain(e).stream().flatMap(t -> Arrays.stream(t.getSuppressed())))
         .filter(RequestCancelledException.class::isInstance)
         .map(RequestCancelledException.class::cast)
         .findFirst();
diff --git a/java/com/google/gerrit/server/config/CachedPreferences.java b/java/com/google/gerrit/server/config/CachedPreferences.java
index 6e957e6..3b013c0 100644
--- a/java/com/google/gerrit/server/config/CachedPreferences.java
+++ b/java/com/google/gerrit/server/config/CachedPreferences.java
@@ -47,6 +47,7 @@
   public Optional<CachedPreferencesProto> nonEmptyConfig() {
     return config().equals(EMPTY.config()) ? Optional.empty() : Optional.of(config());
   }
+
   /** Returns a cache-able representation of the preferences proto. */
   public static CachedPreferences fromUserPreferencesProto(UserPreferences proto) {
     return fromCachedPreferencesProto(
@@ -125,9 +126,8 @@
       CachedPreferencesProto userPreferencesProto = userPreferences.config();
       switch (userPreferencesProto.getPreferencesCase()) {
         case USER_PREFERENCES:
-          PreferencesT pref =
-              preferencesParser.fromUserPreferences(userPreferencesProto.getUserPreferences());
-          return preferencesParser.parse(pref, configOrNull(defaultPreferences));
+          return preferencesParser.fromUserPreferences(
+              userPreferencesProto.getUserPreferences(), configOrNull(defaultPreferences));
         case LEGACY_GIT_CONFIG:
           return preferencesParser.parse(
               userPreferences.asConfig(), configOrNull(defaultPreferences), null);
diff --git a/java/com/google/gerrit/server/config/PreferencesParserUtil.java b/java/com/google/gerrit/server/config/PreferencesParserUtil.java
index ecb0868..3e39e25 100644
--- a/java/com/google/gerrit/server/config/PreferencesParserUtil.java
+++ b/java/com/google/gerrit/server/config/PreferencesParserUtil.java
@@ -320,13 +320,12 @@
   }
 
   /** Provides methods for parsing user configs */
-  public interface PreferencesParser<T> {
+  interface PreferencesParser<T> {
     T parse(Config cfg, @Nullable Config defaultConfig, @Nullable T input)
         throws ConfigInvalidException;
 
-    T parse(T cfg, @Nullable Config defaultConfig) throws ConfigInvalidException;
-
-    T fromUserPreferences(UserPreferences userPreferences);
+    T fromUserPreferences(UserPreferences userPreferences, @Nullable Config defaultCfg)
+        throws ConfigInvalidException;
 
     T getJavaDefaults();
   }
@@ -346,14 +345,10 @@
     }
 
     @Override
-    public GeneralPreferencesInfo parse(GeneralPreferencesInfo cfg, @Nullable Config defaultCfg)
-        throws ConfigInvalidException {
-      return PreferencesParserUtil.parseGeneralPreferences(cfg, defaultCfg);
-    }
-
-    @Override
-    public GeneralPreferencesInfo fromUserPreferences(UserPreferences p) {
-      return GENERAL_PREFERENCES_INFO_CONVERTER.fromProto(p.getGeneralPreferencesInfo());
+    public GeneralPreferencesInfo fromUserPreferences(
+        UserPreferences p, @Nullable Config defaultCfg) throws ConfigInvalidException {
+      return PreferencesParserUtil.parseGeneralPreferences(
+          GENERAL_PREFERENCES_INFO_CONVERTER.fromProto(p.getGeneralPreferencesInfo()), defaultCfg);
     }
 
     @Override
@@ -376,14 +371,10 @@
     }
 
     @Override
-    public EditPreferencesInfo parse(EditPreferencesInfo cfg, @Nullable Config defaultCfg)
+    public EditPreferencesInfo fromUserPreferences(UserPreferences p, @Nullable Config defaultCfg)
         throws ConfigInvalidException {
-      return PreferencesParserUtil.parseEditPreferences(cfg, defaultCfg);
-    }
-
-    @Override
-    public EditPreferencesInfo fromUserPreferences(UserPreferences p) {
-      return EDIT_PREFERENCES_INFO_CONVERTER.fromProto(p.getEditPreferencesInfo());
+      return PreferencesParserUtil.parseEditPreferences(
+          EDIT_PREFERENCES_INFO_CONVERTER.fromProto(p.getEditPreferencesInfo()), defaultCfg);
     }
 
     @Override
@@ -406,14 +397,10 @@
     }
 
     @Override
-    public DiffPreferencesInfo parse(DiffPreferencesInfo cfg, @Nullable Config defaultCfg)
+    public DiffPreferencesInfo fromUserPreferences(UserPreferences p, @Nullable Config defaultCfg)
         throws ConfigInvalidException {
-      return PreferencesParserUtil.parseDiffPreferences(cfg, defaultCfg);
-    }
-
-    @Override
-    public DiffPreferencesInfo fromUserPreferences(UserPreferences p) {
-      return DIFF_PREFERENCES_INFO_CONVERTER.fromProto(p.getDiffPreferencesInfo());
+      return PreferencesParserUtil.parseDiffPreferences(
+          DIFF_PREFERENCES_INFO_CONVERTER.fromProto(p.getDiffPreferencesInfo()), defaultCfg);
     }
 
     @Override
diff --git a/javatests/com/google/gerrit/acceptance/api/accounts/EditPreferencesIT.java b/javatests/com/google/gerrit/acceptance/api/accounts/EditPreferencesIT.java
index 6802333..74829a3 100644
--- a/javatests/com/google/gerrit/acceptance/api/accounts/EditPreferencesIT.java
+++ b/javatests/com/google/gerrit/acceptance/api/accounts/EditPreferencesIT.java
@@ -15,6 +15,7 @@
 package com.google.gerrit.acceptance.api.accounts;
 
 import static com.google.common.truth.Truth.assertThat;
+import static com.google.gerrit.acceptance.AssertUtil.assertPrefs;
 
 import com.google.gerrit.acceptance.AbstractDaemonTest;
 import com.google.gerrit.acceptance.NoHttpd;
@@ -60,7 +61,7 @@
 
     EditPreferencesInfo info = gApi.accounts().id(admin.id().get()).setEditPreferences(out);
 
-    assertEditPreferences(info, out);
+    assertPrefs(info, out);
 
     // Partially filled input record
     EditPreferencesInfo in = new EditPreferencesInfo();
@@ -69,24 +70,6 @@
     info = gApi.accounts().id(admin.id().get()).setEditPreferences(in);
 
     out.tabSize = in.tabSize;
-    assertEditPreferences(info, out);
-  }
-
-  private void assertEditPreferences(EditPreferencesInfo out, EditPreferencesInfo in)
-      throws Exception {
-    assertThat(out.lineLength).isEqualTo(in.lineLength);
-    assertThat(out.indentUnit).isEqualTo(in.indentUnit);
-    assertThat(out.tabSize).isEqualTo(in.tabSize);
-    assertThat(out.cursorBlinkRate).isEqualTo(in.cursorBlinkRate);
-    assertThat(out.hideTopMenu).isEqualTo(in.hideTopMenu);
-    assertThat(out.showTabs).isNull();
-    assertThat(out.showWhitespaceErrors).isEqualTo(in.showWhitespaceErrors);
-    assertThat(out.syntaxHighlighting).isNull();
-    assertThat(out.hideLineNumbers).isEqualTo(in.hideLineNumbers);
-    assertThat(out.matchBrackets).isNull();
-    assertThat(out.lineWrapping).isEqualTo(in.lineWrapping);
-    assertThat(out.indentWithTabs).isEqualTo(in.indentWithTabs);
-    assertThat(out.autoCloseBrackets).isEqualTo(in.autoCloseBrackets);
-    assertThat(out.showBase).isEqualTo(in.showBase);
+    assertPrefs(info, out);
   }
 }
diff --git a/javatests/com/google/gerrit/server/config/CachedPreferencesTest.java b/javatests/com/google/gerrit/server/config/CachedPreferencesTest.java
index b149d09..131fb23 100644
--- a/javatests/com/google/gerrit/server/config/CachedPreferencesTest.java
+++ b/javatests/com/google/gerrit/server/config/CachedPreferencesTest.java
@@ -151,6 +151,58 @@
   }
 
   @Test
+  public void bothPreferencesTypes_getGeneralPreferencesAreEqual() throws Exception {
+    UserPreferences originalProto =
+        UserPreferences.newBuilder()
+            .setGeneralPreferencesInfo(
+                UserPreferences.GeneralPreferencesInfo.newBuilder().setChangesPerPage(19))
+            .build();
+    Config originalCfg = new Config();
+    originalCfg.fromText("[general]\n\tchangesPerPage = 19");
+
+    CachedPreferences protoPref = CachedPreferences.fromUserPreferencesProto(originalProto);
+    GeneralPreferencesInfo protoGeneral = CachedPreferences.general(Optional.empty(), protoPref);
+    CachedPreferences cfgPref = CachedPreferences.fromLegacyConfig(originalCfg);
+    GeneralPreferencesInfo cfgGeneral = CachedPreferences.general(Optional.empty(), cfgPref);
+
+    assertThat(protoGeneral).isEqualTo(cfgGeneral);
+  }
+
+  @Test
+  public void bothPreferencesTypes_getDiffPreferencesAreEqual() throws Exception {
+    UserPreferences originalProto =
+        UserPreferences.newBuilder()
+            .setDiffPreferencesInfo(UserPreferences.DiffPreferencesInfo.newBuilder().setContext(23))
+            .build();
+    Config originalCfg = new Config();
+    originalCfg.fromText("[diff]\n\tcontext = 23");
+
+    CachedPreferences protoPref = CachedPreferences.fromUserPreferencesProto(originalProto);
+    DiffPreferencesInfo protoDiff = CachedPreferences.diff(Optional.empty(), protoPref);
+    CachedPreferences cfgPref = CachedPreferences.fromLegacyConfig(originalCfg);
+    DiffPreferencesInfo cfgDiff = CachedPreferences.diff(Optional.empty(), cfgPref);
+
+    assertThat(protoDiff).isEqualTo(cfgDiff);
+  }
+
+  @Test
+  public void bothPreferencesTypes_getEditPreferencesAreEqual() throws Exception {
+    UserPreferences originalProto =
+        UserPreferences.newBuilder()
+            .setEditPreferencesInfo(UserPreferences.EditPreferencesInfo.newBuilder().setTabSize(27))
+            .build();
+    Config originalCfg = new Config();
+    originalCfg.fromText("[edit]\n\ttabSize = 27");
+
+    CachedPreferences protoPref = CachedPreferences.fromUserPreferencesProto(originalProto);
+    EditPreferencesInfo protoEdit = CachedPreferences.edit(Optional.empty(), protoPref);
+    CachedPreferences cfgPref = CachedPreferences.fromLegacyConfig(originalCfg);
+    EditPreferencesInfo cfgEdit = CachedPreferences.edit(Optional.empty(), cfgPref);
+
+    assertThat(protoEdit).isEqualTo(cfgEdit);
+  }
+
+  @Test
   public void defaultPreferences_acceptingGitConfig() throws Exception {
     Config cfg = new Config();
     cfg.fromText("[general]\n\tchangesPerPage = 19");
diff --git a/polygerrit-ui/app/elements/change/gr-related-changes-list/gr-related-changes-list.ts b/polygerrit-ui/app/elements/change/gr-related-changes-list/gr-related-changes-list.ts
index 984391d..e2a1d63 100644
--- a/polygerrit-ui/app/elements/change/gr-related-changes-list/gr-related-changes-list.ts
+++ b/polygerrit-ui/app/elements/change/gr-related-changes-list/gr-related-changes-list.ts
@@ -277,7 +277,8 @@
 
     return html`<section id="relatedChanges">
       <gr-related-collapse
-        title="Relation chain"
+        .name=${'Relation chain'}
+        title="parent changes are ordered after child changes"
         class=${classMap({first: isFirst})}
         .length=${this.relatedChanges.length}
         .numChangesWhenCollapsed=${sectionSize(Section.RELATED_CHANGES)}
@@ -342,7 +343,8 @@
     );
     return html`<section id="submittedTogether">
       <gr-related-collapse
-        title="Submitted together"
+        .name=${'Submitted together'}
+        title="parent changes are ordered after child changes"
         class=${classMap({first: isFirst})}
         .length=${submittedTogetherChanges.length}
         .numChangesWhenCollapsed=${sectionSize(Section.SUBMITTED_TOGETHER)}
@@ -405,7 +407,7 @@
     );
     return html`<section id="sameTopic">
       <gr-related-collapse
-        title="Same topic"
+        .name=${'Same topic'}
         class=${classMap({first: isFirst})}
         .length=${this.sameTopicChanges.length}
         .numChangesWhenCollapsed=${sectionSize(Section.SAME_TOPIC)}
@@ -445,7 +447,7 @@
     );
     return html`<section id="mergeConflicts">
       <gr-related-collapse
-        title="Merge conflicts"
+        .name=${'Merge conflicts'}
         class=${classMap({first: isFirst})}
         .length=${this.conflictingChanges.length}
         .numChangesWhenCollapsed=${sectionSize(Section.MERGE_CONFLICTS)}
@@ -490,7 +492,7 @@
     );
     return html`<section id="cherryPicks">
       <gr-related-collapse
-        title="Cherry picks"
+        .name=${'Cherry picks'}
         class=${classMap({first: isFirst})}
         .length=${this.cherryPickChanges.length}
         .numChangesWhenCollapsed=${sectionSize(Section.CHERRY_PICKS)}
diff --git a/polygerrit-ui/app/elements/change/gr-related-changes-list/gr-related-changes-list_test.ts b/polygerrit-ui/app/elements/change/gr-related-changes-list/gr-related-changes-list_test.ts
index 2e33333..e8bf442 100644
--- a/polygerrit-ui/app/elements/change/gr-related-changes-list/gr-related-changes-list_test.ts
+++ b/polygerrit-ui/app/elements/change/gr-related-changes-list/gr-related-changes-list_test.ts
@@ -200,7 +200,10 @@
             <gr-endpoint-param name="change"> </gr-endpoint-param>
             <gr-endpoint-slot name="top"> </gr-endpoint-slot>
             <section id="relatedChanges">
-              <gr-related-collapse class="first" title="Relation chain">
+              <gr-related-collapse
+                class="first"
+                title="parent changes are ordered after child changes"
+              >
                 <div class="relatedChangeLine show-when-collapsed">
                   <span class="marker space"> </span>
                   <gr-related-change
@@ -213,7 +216,9 @@
               </gr-related-collapse>
             </section>
             <section id="submittedTogether">
-              <gr-related-collapse title="Submitted together">
+              <gr-related-collapse
+                title="parent changes are ordered after child changes"
+              >
                 <div class="relatedChangeLine selected show-when-collapsed">
                   <span
                     aria-label="Arrow marking current change"
@@ -236,7 +241,7 @@
               <div class="note" hidden="">(+ )</div>
             </section>
             <section id="cherryPicks">
-              <gr-related-collapse title="Cherry picks">
+              <gr-related-collapse>
                 <div class="relatedChangeLine show-when-collapsed">
                   <span class="marker space"> </span>
                   <gr-related-change show-change-status="">
diff --git a/polygerrit-ui/app/elements/change/gr-related-changes-list/gr-related-collapse.ts b/polygerrit-ui/app/elements/change/gr-related-changes-list/gr-related-collapse.ts
index 30d2282..8c2f459 100644
--- a/polygerrit-ui/app/elements/change/gr-related-changes-list/gr-related-collapse.ts
+++ b/polygerrit-ui/app/elements/change/gr-related-changes-list/gr-related-collapse.ts
@@ -18,7 +18,7 @@
 @customElement('gr-related-collapse')
 export class GrRelatedCollapse extends LitElement {
   @property()
-  override title = '';
+  name = '';
 
   @property({type: Boolean})
   showAll = false;
@@ -64,7 +64,7 @@
   }
 
   override render() {
-    const title = html`<h3 class="title heading-3">${this.title}</h3>`;
+    const title = html`<h3 class="title heading-3">${this.name}</h3>`;
 
     const collapsible = this.length > this.numChangesWhenCollapsed;
     this.collapsed = !this.showAll && collapsible;
@@ -88,7 +88,7 @@
     e.stopPropagation();
     this.showAll = !this.showAll;
     this.reporting.reportInteraction(Interaction.TOGGLE_SHOW_ALL_BUTTON, {
-      sectionName: this.title,
+      sectionName: this.name,
       toState: this.showAll ? 'Show all' : 'Show less',
     });
   }
diff --git a/polygerrit-ui/app/elements/checks/gr-checks-results.ts b/polygerrit-ui/app/elements/checks/gr-checks-results.ts
index 65fdb93..6cff5ab 100644
--- a/polygerrit-ui/app/elements/checks/gr-checks-results.ts
+++ b/polygerrit-ui/app/elements/checks/gr-checks-results.ts
@@ -489,7 +489,7 @@
     return html`
       <!-- The &nbsp; is for being able to shrink a tiny amount without
        the text itself getting shrunk with an ellipsis. -->
-      <div class="summary" @click=${this.toggleExpanded} title=${text}>
+      <div class="summary" @click=${this.toggleExpandedClick} title=${text}>
         ${text}&nbsp;
       </div>
     `;
diff --git a/polygerrit-ui/app/elements/checks/gr-checks-results_test.ts b/polygerrit-ui/app/elements/checks/gr-checks-results_test.ts
index a349b58..22c2940 100644
--- a/polygerrit-ui/app/elements/checks/gr-checks-results_test.ts
+++ b/polygerrit-ui/app/elements/checks/gr-checks-results_test.ts
@@ -123,6 +123,22 @@
     `
     );
   });
+
+  test('click summary, toggle expand', async () => {
+    element.isExpandable = true;
+    await element.updateComplete;
+    assert.isFalse(element.isExpanded);
+
+    const summaryDiv: HTMLElement =
+      element.shadowRoot!.querySelector('.summary')!;
+    summaryDiv.click();
+    await element.updateComplete;
+    assert.isTrue(element.isExpanded);
+
+    summaryDiv.click();
+    await element.updateComplete;
+    assert.isFalse(element.isExpanded);
+  });
 });
 
 suite('gr-checks-results test', () => {
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 = [];