Merge "Do not process keyboard shortcuts for invisible views"
diff --git a/Documentation/rest-api-changes.txt b/Documentation/rest-api-changes.txt
index e0bed37..ff12af2 100644
--- a/Documentation/rest-api-changes.txt
+++ b/Documentation/rest-api-changes.txt
@@ -6921,7 +6921,10 @@
 The subject of the commit (header line of the commit message).
 |`message`     ||The commit message.
 |`web_links`   |optional|
-Links to the commit in external sites as a list of
+Links to the patch set in external sites as a list of
+link:#web-link-info[WebLinkInfo] entities.
+|`resolve_conflicts_web_links`   |optional|
+Links to the commit in external sites for resolving conflicts as a list of
 link:#web-link-info[WebLinkInfo] entities.
 |===========================
 
diff --git a/java/com/google/gerrit/acceptance/ExtensionRegistry.java b/java/com/google/gerrit/acceptance/ExtensionRegistry.java
index 6c6bab0..85c4c13 100644
--- a/java/com/google/gerrit/acceptance/ExtensionRegistry.java
+++ b/java/com/google/gerrit/acceptance/ExtensionRegistry.java
@@ -35,6 +35,7 @@
 import com.google.gerrit.extensions.webui.EditWebLink;
 import com.google.gerrit.extensions.webui.FileHistoryWebLink;
 import com.google.gerrit.extensions.webui.PatchSetWebLink;
+import com.google.gerrit.extensions.webui.ResolveConflictsWebLink;
 import com.google.gerrit.server.ExceptionHook;
 import com.google.gerrit.server.account.GroupBackend;
 import com.google.gerrit.server.change.ChangeETagComputation;
@@ -76,6 +77,7 @@
   private final DynamicSet<GitReferenceUpdatedListener> refUpdatedListeners;
   private final DynamicSet<FileHistoryWebLink> fileHistoryWebLinks;
   private final DynamicSet<PatchSetWebLink> patchSetWebLinks;
+  private final DynamicSet<ResolveConflictsWebLink> resolveConflictsWebLinks;
   private final DynamicSet<EditWebLink> editWebLinks;
   private final DynamicSet<RevisionCreatedListener> revisionCreatedListeners;
   private final DynamicSet<GroupBackend> groupBackends;
@@ -111,6 +113,7 @@
       DynamicSet<GitReferenceUpdatedListener> refUpdatedListeners,
       DynamicSet<FileHistoryWebLink> fileHistoryWebLinks,
       DynamicSet<PatchSetWebLink> patchSetWebLinks,
+      DynamicSet<ResolveConflictsWebLink> resolveConflictsWebLinks,
       DynamicSet<EditWebLink> editWebLinks,
       DynamicSet<RevisionCreatedListener> revisionCreatedListeners,
       DynamicSet<GroupBackend> groupBackends,
@@ -143,6 +146,7 @@
     this.fileHistoryWebLinks = fileHistoryWebLinks;
     this.patchSetWebLinks = patchSetWebLinks;
     this.editWebLinks = editWebLinks;
+    this.resolveConflictsWebLinks = resolveConflictsWebLinks;
     this.revisionCreatedListeners = revisionCreatedListeners;
     this.groupBackends = groupBackends;
     this.accountActivationValidationListeners = accountActivationValidationListeners;
@@ -244,6 +248,10 @@
       return add(patchSetWebLinks, patchSetWebLink);
     }
 
+    public Registration add(ResolveConflictsWebLink resolveConflictsWebLink) {
+      return add(resolveConflictsWebLinks, resolveConflictsWebLink);
+    }
+
     public Registration add(EditWebLink editWebLink) {
       return add(editWebLinks, editWebLink);
     }
diff --git a/java/com/google/gerrit/extensions/common/CommitInfo.java b/java/com/google/gerrit/extensions/common/CommitInfo.java
index 1fd8755..202b829 100644
--- a/java/com/google/gerrit/extensions/common/CommitInfo.java
+++ b/java/com/google/gerrit/extensions/common/CommitInfo.java
@@ -29,6 +29,7 @@
   public String subject;
   public String message;
   public List<WebLinkInfo> webLinks;
+  public List<WebLinkInfo> resolveConflictsWebLinks;
 
   @Override
   public boolean equals(Object o) {
@@ -42,12 +43,14 @@
         && Objects.equals(committer, c.committer)
         && Objects.equals(subject, c.subject)
         && Objects.equals(message, c.message)
-        && Objects.equals(webLinks, c.webLinks);
+        && Objects.equals(webLinks, c.webLinks)
+        && Objects.equals(resolveConflictsWebLinks, c.resolveConflictsWebLinks);
   }
 
   @Override
   public int hashCode() {
-    return Objects.hash(commit, parents, author, committer, subject, message, webLinks);
+    return Objects.hash(
+        commit, parents, author, committer, subject, message, webLinks, resolveConflictsWebLinks);
   }
 
   @Override
@@ -64,6 +67,9 @@
     if (webLinks != null) {
       helper.add("webLinks", webLinks);
     }
+    if (resolveConflictsWebLinks != null) {
+      helper.add("resolveConflictsWebLinks", resolveConflictsWebLinks);
+    }
     return helper.toString();
   }
 }
diff --git a/java/com/google/gerrit/extensions/common/testing/CommitInfoSubject.java b/java/com/google/gerrit/extensions/common/testing/CommitInfoSubject.java
index d344e18..71fc564 100644
--- a/java/com/google/gerrit/extensions/common/testing/CommitInfoSubject.java
+++ b/java/com/google/gerrit/extensions/common/testing/CommitInfoSubject.java
@@ -20,6 +20,7 @@
 
 import com.google.common.truth.Correspondence;
 import com.google.common.truth.FailureMetadata;
+import com.google.common.truth.IterableSubject;
 import com.google.common.truth.StringSubject;
 import com.google.common.truth.Subject;
 import com.google.gerrit.extensions.common.CommitInfo;
@@ -68,6 +69,16 @@
     return check("message").that(commitInfo.message);
   }
 
+  public IterableSubject webLinks() {
+    isNotNull();
+    return check("webLinks").that(commitInfo.webLinks);
+  }
+
+  public IterableSubject resolveConflictsWebLinks() {
+    isNotNull();
+    return check("resolveConflictsWebLinks").that(commitInfo.resolveConflictsWebLinks);
+  }
+
   public static Correspondence<CommitInfo, String> hasCommit() {
     return NullAwareCorrespondence.transforming(commitInfo -> commitInfo.commit, "hasCommit");
   }
diff --git a/java/com/google/gerrit/extensions/webui/ResolveConflictsWebLink.java b/java/com/google/gerrit/extensions/webui/ResolveConflictsWebLink.java
new file mode 100644
index 0000000..19402a9
--- /dev/null
+++ b/java/com/google/gerrit/extensions/webui/ResolveConflictsWebLink.java
@@ -0,0 +1,40 @@
+// Copyright (C) 2021 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.
+
+package com.google.gerrit.extensions.webui;
+
+import com.google.gerrit.extensions.annotations.ExtensionPoint;
+import com.google.gerrit.extensions.common.WebLinkInfo;
+
+@ExtensionPoint
+public interface ResolveConflictsWebLink extends WebLink {
+
+  /**
+   * {@link com.google.gerrit.extensions.common.WebLinkInfo} describing a link from a patch set to
+   * an external service for the purpose of resolving merge conflicts.
+   *
+   * <p>In order for the web link to be visible {@link
+   * com.google.gerrit.extensions.common.WebLinkInfo#url} and {@link
+   * com.google.gerrit.extensions.common.WebLinkInfo#name} must be set.
+   *
+   * @param projectName name of the project
+   * @param commit commit of the patch set
+   * @param commitMessage the commit message of the change
+   * @param branchName target branch of the change
+   * @return WebLinkInfo that links to patch set in external service, {@code null} if there should
+   *     be no link.
+   */
+  WebLinkInfo getResolveConflictsWebLink(
+      String projectName, String commit, String commitMessage, String branchName);
+}
diff --git a/java/com/google/gerrit/server/WebLinks.java b/java/com/google/gerrit/server/WebLinks.java
index 3b626ea..4acef06 100644
--- a/java/com/google/gerrit/server/WebLinks.java
+++ b/java/com/google/gerrit/server/WebLinks.java
@@ -33,6 +33,7 @@
 import com.google.gerrit.extensions.webui.ParentWebLink;
 import com.google.gerrit.extensions.webui.PatchSetWebLink;
 import com.google.gerrit.extensions.webui.ProjectWebLink;
+import com.google.gerrit.extensions.webui.ResolveConflictsWebLink;
 import com.google.gerrit.extensions.webui.TagWebLink;
 import com.google.gerrit.extensions.webui.WebLink;
 import com.google.inject.Inject;
@@ -56,6 +57,7 @@
       };
 
   private final DynamicSet<PatchSetWebLink> patchSetLinks;
+  private final DynamicSet<ResolveConflictsWebLink> resolveConflictsLinks;
   private final DynamicSet<ParentWebLink> parentLinks;
   private final DynamicSet<EditWebLink> editLinks;
   private final DynamicSet<FileWebLink> fileLinks;
@@ -68,6 +70,7 @@
   @Inject
   public WebLinks(
       DynamicSet<PatchSetWebLink> patchSetLinks,
+      DynamicSet<ResolveConflictsWebLink> resolveConflictsLinks,
       DynamicSet<ParentWebLink> parentLinks,
       DynamicSet<EditWebLink> editLinks,
       DynamicSet<FileWebLink> fileLinks,
@@ -77,6 +80,7 @@
       DynamicSet<BranchWebLink> branchLinks,
       DynamicSet<TagWebLink> tagLinks) {
     this.patchSetLinks = patchSetLinks;
+    this.resolveConflictsLinks = resolveConflictsLinks;
     this.parentLinks = parentLinks;
     this.editLinks = editLinks;
     this.fileLinks = fileLinks;
@@ -103,6 +107,21 @@
 
   /**
    * @param project Project name.
+   * @param revision SHA1 of commit.
+   * @param commitMessage the commit message of the commit.
+   * @param branchName branch of the commit.
+   * @return Links for resolving comflicts.
+   */
+  public ImmutableList<WebLinkInfo> getResolveConflictsLinks(
+      Project.NameKey project, String commit, String commitMessage, String branchName) {
+    return filterLinks(
+        resolveConflictsLinks,
+        webLink ->
+            webLink.getResolveConflictsWebLink(project.get(), commit, commitMessage, branchName));
+  }
+
+  /**
+   * @param project Project name.
    * @param revision SHA1 of the parent revision.
    * @param commitMessage the commit message of the parent revision.
    * @param branchName branch of the revision (and parent revision).
diff --git a/java/com/google/gerrit/server/change/RevisionJson.java b/java/com/google/gerrit/server/change/RevisionJson.java
index b702440..33f3d4f 100644
--- a/java/com/google/gerrit/server/change/RevisionJson.java
+++ b/java/com/google/gerrit/server/change/RevisionJson.java
@@ -182,9 +182,14 @@
     info.message = commit.getFullMessage();
 
     if (addLinks) {
-      ImmutableList<WebLinkInfo> links =
+      ImmutableList<WebLinkInfo> patchSetLinks =
           webLinks.getPatchSetLinks(project, commit.name(), commit.getFullMessage(), branchName);
-      info.webLinks = links.isEmpty() ? null : links;
+      info.webLinks = patchSetLinks.isEmpty() ? null : patchSetLinks;
+      ImmutableList<WebLinkInfo> resolveConflictsLinks =
+          webLinks.getResolveConflictsLinks(
+              project, commit.name(), commit.getFullMessage(), branchName);
+      info.resolveConflictsWebLinks =
+          resolveConflictsLinks.isEmpty() ? null : resolveConflictsLinks;
     }
 
     for (RevCommit parent : commit.getParents()) {
diff --git a/java/com/google/gerrit/server/config/GerritGlobalModule.java b/java/com/google/gerrit/server/config/GerritGlobalModule.java
index 339b350..4794858 100644
--- a/java/com/google/gerrit/server/config/GerritGlobalModule.java
+++ b/java/com/google/gerrit/server/config/GerritGlobalModule.java
@@ -72,6 +72,7 @@
 import com.google.gerrit.extensions.webui.ParentWebLink;
 import com.google.gerrit.extensions.webui.PatchSetWebLink;
 import com.google.gerrit.extensions.webui.ProjectWebLink;
+import com.google.gerrit.extensions.webui.ResolveConflictsWebLink;
 import com.google.gerrit.extensions.webui.TagWebLink;
 import com.google.gerrit.extensions.webui.TopMenu;
 import com.google.gerrit.extensions.webui.WebUiPlugin;
@@ -392,6 +393,7 @@
     DynamicMap.mapOf(binder(), ProjectConfigEntry.class);
     DynamicSet.setOf(binder(), PluginPushOption.class);
     DynamicSet.setOf(binder(), PatchSetWebLink.class);
+    DynamicSet.setOf(binder(), ResolveConflictsWebLink.class);
     DynamicSet.setOf(binder(), ParentWebLink.class);
     DynamicSet.setOf(binder(), FileWebLink.class);
     DynamicSet.setOf(binder(), FileHistoryWebLink.class);
diff --git a/java/com/google/gerrit/server/config/GitwebConfig.java b/java/com/google/gerrit/server/config/GitwebConfig.java
index 97cc830..f90a72e 100644
--- a/java/com/google/gerrit/server/config/GitwebConfig.java
+++ b/java/com/google/gerrit/server/config/GitwebConfig.java
@@ -33,6 +33,7 @@
 import com.google.gerrit.extensions.webui.ParentWebLink;
 import com.google.gerrit.extensions.webui.PatchSetWebLink;
 import com.google.gerrit.extensions.webui.ProjectWebLink;
+import com.google.gerrit.extensions.webui.ResolveConflictsWebLink;
 import com.google.gerrit.extensions.webui.TagWebLink;
 import com.google.inject.AbstractModule;
 import com.google.inject.Inject;
@@ -83,6 +84,7 @@
         if (!isNullOrEmpty(type.getRevision())) {
           DynamicSet.bind(binder(), PatchSetWebLink.class).to(GitwebLinks.class);
           DynamicSet.bind(binder(), ParentWebLink.class).to(GitwebLinks.class);
+          DynamicSet.bind(binder(), ResolveConflictsWebLink.class).to(GitwebLinks.class);
         }
 
         if (!isNullOrEmpty(type.getProject())) {
@@ -261,6 +263,7 @@
           PatchSetWebLink,
           ParentWebLink,
           ProjectWebLink,
+          ResolveConflictsWebLink,
           TagWebLink {
     private final String url;
     private final GitwebType type;
@@ -350,6 +353,13 @@
     }
 
     @Override
+    public WebLinkInfo getResolveConflictsWebLink(
+        String projectName, String commit, String commitMessage, String branchName) {
+      // For Gitweb treat resolve conflicts links the same as patch set links
+      return getPatchSetWebLink(projectName, commit, commitMessage, branchName);
+    }
+
+    @Override
     public WebLinkInfo getParentWebLink(
         String projectName, String commit, String commitMessage, String branchName) {
       // For Gitweb treat parent revision links the same as patch set links
diff --git a/javatests/com/google/gerrit/acceptance/api/revision/RevisionIT.java b/javatests/com/google/gerrit/acceptance/api/revision/RevisionIT.java
index 86385da..9d0b1f4 100644
--- a/javatests/com/google/gerrit/acceptance/api/revision/RevisionIT.java
+++ b/javatests/com/google/gerrit/acceptance/api/revision/RevisionIT.java
@@ -93,6 +93,7 @@
 import com.google.gerrit.extensions.restapi.ResourceNotFoundException;
 import com.google.gerrit.extensions.restapi.UnprocessableEntityException;
 import com.google.gerrit.extensions.webui.PatchSetWebLink;
+import com.google.gerrit.extensions.webui.ResolveConflictsWebLink;
 import com.google.gerrit.server.ChangeMessagesUtil;
 import com.google.gerrit.server.query.change.ChangeData;
 import com.google.gerrit.testing.FakeEmailSender;
@@ -1633,16 +1634,26 @@
 
   @Test
   public void commit() throws Exception {
-    WebLinkInfo expectedWebLinkInfo = new WebLinkInfo("foo", "imageUrl", "url");
-    PatchSetWebLink link =
+    WebLinkInfo expectedPatchSetLinkInfo = new WebLinkInfo("foo", "imageUrl", "url");
+    PatchSetWebLink patchSetLink =
         new PatchSetWebLink() {
           @Override
           public WebLinkInfo getPatchSetWebLink(
               String projectName, String commit, String commitMessage, String branchName) {
-            return expectedWebLinkInfo;
+            return expectedPatchSetLinkInfo;
           }
         };
-    try (Registration registration = extensionRegistry.newRegistration().add(link)) {
+    WebLinkInfo expectedResolveConflictsLinkInfo = new WebLinkInfo("bar", "img", "resolve");
+    ResolveConflictsWebLink resolveConflictsLink =
+        new ResolveConflictsWebLink() {
+          @Override
+          public WebLinkInfo getResolveConflictsWebLink(
+              String projectName, String commit, String commitMessage, String branchName) {
+            return expectedResolveConflictsLinkInfo;
+          }
+        };
+    try (Registration registration =
+        extensionRegistry.newRegistration().add(patchSetLink).add(resolveConflictsLink)) {
       PushOneCommit.Result r = createChange();
       RevCommit c = r.getCommit();
 
@@ -1659,11 +1670,20 @@
 
       commitInfo = gApi.changes().id(r.getChangeId()).current().commit(true);
       assertThat(commitInfo.webLinks).hasSize(1);
-      WebLinkInfo webLinkInfo = Iterables.getOnlyElement(commitInfo.webLinks);
-      assertThat(webLinkInfo.name).isEqualTo(expectedWebLinkInfo.name);
-      assertThat(webLinkInfo.imageUrl).isEqualTo(expectedWebLinkInfo.imageUrl);
-      assertThat(webLinkInfo.url).isEqualTo(expectedWebLinkInfo.url);
-      assertThat(webLinkInfo.target).isEqualTo(expectedWebLinkInfo.target);
+      WebLinkInfo patchSetLinkInfo = Iterables.getOnlyElement(commitInfo.webLinks);
+      assertThat(patchSetLinkInfo.name).isEqualTo(expectedPatchSetLinkInfo.name);
+      assertThat(patchSetLinkInfo.imageUrl).isEqualTo(expectedPatchSetLinkInfo.imageUrl);
+      assertThat(patchSetLinkInfo.url).isEqualTo(expectedPatchSetLinkInfo.url);
+      assertThat(patchSetLinkInfo.target).isEqualTo(expectedPatchSetLinkInfo.target);
+
+      assertThat(commitInfo.resolveConflictsWebLinks).hasSize(1);
+      WebLinkInfo resolveCommentsLinkInfo =
+          Iterables.getOnlyElement(commitInfo.resolveConflictsWebLinks);
+      assertThat(resolveCommentsLinkInfo.name).isEqualTo(expectedResolveConflictsLinkInfo.name);
+      assertThat(resolveCommentsLinkInfo.imageUrl)
+          .isEqualTo(expectedResolveConflictsLinkInfo.imageUrl);
+      assertThat(resolveCommentsLinkInfo.url).isEqualTo(expectedResolveConflictsLinkInfo.url);
+      assertThat(resolveCommentsLinkInfo.target).isEqualTo(expectedResolveConflictsLinkInfo.target);
     }
   }
 
diff --git a/polygerrit-ui/app/api/checks.ts b/polygerrit-ui/app/api/checks.ts
index 62b9ad9..5d20f3f 100644
--- a/polygerrit-ui/app/api/checks.ts
+++ b/polygerrit-ui/app/api/checks.ts
@@ -298,6 +298,11 @@
   externalId?: string;
 
   /**
+   * SUCCESS: Indicates that some build, test or check is passing. A COMPLETED
+   *          run without results will also be treated as "passing" and will get
+   *          an artificial SUCCESS result. But you can also make this explicit,
+   *          which also allows one run to have multiple "passing" results,
+   *          maybe along with results of other categories.
    * INFO:    The user will typically not bother to look into this category,
    *          only for looking up something that they are searching for. Can be
    *          used for reporting secondary metrics and analysis, or a wider
@@ -383,6 +388,7 @@
 }
 
 export enum Category {
+  SUCCESS = 'SUCCESS',
   INFO = 'INFO',
   WARNING = 'WARNING',
   ERROR = 'ERROR',
diff --git a/polygerrit-ui/app/elements/change/gr-reply-dialog/gr-reply-dialog_test.js b/polygerrit-ui/app/elements/change/gr-reply-dialog/gr-reply-dialog_test.js
index a4dd4bc..b51eb5f 100644
--- a/polygerrit-ui/app/elements/change/gr-reply-dialog/gr-reply-dialog_test.js
+++ b/polygerrit-ui/app/elements/change/gr-reply-dialog/gr-reply-dialog_test.js
@@ -203,17 +203,17 @@
   });
 
   function checkComputeAttention(status, userId, reviewerIds, ownerId,
-      attSetIds, replyToIds, expectedIds, uploaderId, hasDraft,
+      attSetIds, replyToIds, expectedIds, uploaderId, hasDraft = true,
       includeComments = true) {
     const user = {_account_id: userId};
     const reviewers = {base: reviewerIds.map(id => {
       return {_account_id: id};
     })};
-    const draftThreads = [
-      {comments: []},
-    ];
+    let draftThreads = [];
     if (hasDraft) {
-      draftThreads[0].comments.push({__draft: true, unresolved: true});
+      draftThreads = [
+        {comments: [{__draft: true, unresolved: true}]},
+      ];
     }
     replyToIds.forEach(id => draftThreads[0].comments.push({
       author: {_account_id: id},
@@ -240,7 +240,6 @@
   }
 
   test('computeNewAttention NEW', () => {
-    checkComputeAttention('NEW', null, [], 999, [], [], [999]);
     checkComputeAttention('NEW', 1, [], 999, [], [], [999]);
     checkComputeAttention('NEW', 1, [], 999, [1], [], [999]);
     checkComputeAttention('NEW', 1, [22], 999, [], [], [999]);
@@ -264,14 +263,14 @@
   });
 
   test('computeNewAttention MERGED', () => {
-    checkComputeAttention('MERGED', null, [], 999, [], [], []);
-    checkComputeAttention('MERGED', 1, [], 999, [], [], []);
-    checkComputeAttention('MERGED', 1, [], 999, [], [], [999], undefined, true);
+    checkComputeAttention('MERGED', 1, [], 999, [], [], [], undefined, false);
+    checkComputeAttention('MERGED', 1, [], 999, [], [], [999], undefined);
     checkComputeAttention(
         'MERGED', 1, [], 999, [], [], [], undefined, true, false);
-    checkComputeAttention('MERGED', 1, [], 999, [1], [], []);
-    checkComputeAttention('MERGED', 1, [22], 999, [], [], []);
-    checkComputeAttention('MERGED', 1, [22], 999, [22], [], [22]);
+    checkComputeAttention('MERGED', 1, [], 999, [1], [], [], undefined, false);
+    checkComputeAttention('MERGED', 1, [22], 999, [], [], [], undefined, false);
+    checkComputeAttention('MERGED', 1, [22], 999, [22], [], [22], undefined,
+        false);
     checkComputeAttention('MERGED', 1, [22], 999, [], [22], []);
     checkComputeAttention('MERGED', 1, [22, 33], 999, [33], [22], [33]);
     checkComputeAttention('MERGED', 1, [], 1, [], [], []);
diff --git a/polygerrit-ui/app/elements/checks/gr-checks-results.ts b/polygerrit-ui/app/elements/checks/gr-checks-results.ts
index 49f51e02..1c60161 100644
--- a/polygerrit-ui/app/elements/checks/gr-checks-results.ts
+++ b/polygerrit-ui/app/elements/checks/gr-checks-results.ts
@@ -39,17 +39,15 @@
 import {sharedStyles} from '../../styles/shared-styles';
 import {
   allActions$,
-  checksPatchsetNumber$,
-  someProvidersAreLoading$,
-  RunResult,
-  CheckRun,
   allLinks$,
+  CheckRun,
+  checksPatchsetNumber$,
+  RunResult,
+  someProvidersAreLoading$,
 } from '../../services/checks/checks-model';
 import {
   allResults,
   fireActionTriggered,
-  hasCompletedWithoutResults,
-  hasResultsOf,
   iconForCategory,
   iconForLink,
   tooltipForLink,
@@ -462,11 +460,11 @@
   }
 }
 
-const SHOW_ALL_THRESHOLDS: Map<Category | 'SUCCESS', number> = new Map();
+const SHOW_ALL_THRESHOLDS: Map<Category, number> = new Map();
 SHOW_ALL_THRESHOLDS.set(Category.ERROR, 20);
 SHOW_ALL_THRESHOLDS.set(Category.WARNING, 10);
 SHOW_ALL_THRESHOLDS.set(Category.INFO, 5);
-SHOW_ALL_THRESHOLDS.set('SUCCESS', 5);
+SHOW_ALL_THRESHOLDS.set(Category.SUCCESS, 5);
 
 @customElement('gr-checks-results')
 export class GrChecksResults extends GrLitElement {
@@ -514,21 +512,21 @@
 
   /** Maintains the state of which result sections should show all results. */
   @internalProperty()
-  isShowAll: Map<Category | 'SUCCESS', boolean> = new Map();
+  isShowAll: Map<Category, boolean> = new Map();
 
   /**
    * This is the current state of whether a section is expanded or not. As long
    * as isSectionExpandedByUser is false this will be computed by a default rule
    * on every render.
    */
-  private isSectionExpanded = new Map<Category | 'SUCCESS', boolean>();
+  private isSectionExpanded = new Map<Category, boolean>();
 
   /**
    * Keeps track of whether the user intentionally changed the expansion state.
    * Once this is true the default rule for showing a section expanded or not
    * is not applied anymore.
    */
-  private isSectionExpandedByUser = new Map<Category | 'SUCCESS', boolean>();
+  private isSectionExpandedByUser = new Map<Category, boolean>();
 
   private readonly checksService = appContext.checksService;
 
@@ -732,7 +730,8 @@
       <div class="body">
         ${this.renderSection(Category.ERROR)}
         ${this.renderSection(Category.WARNING)}
-        ${this.renderSection(Category.INFO)} ${this.renderSection('SUCCESS')}
+        ${this.renderSection(Category.INFO)}
+        ${this.renderSection(Category.SUCCESS)}
       </div>
     `;
   }
@@ -859,16 +858,11 @@
     this.filterRegExp = new RegExp(this.filterInput.value, 'i');
   }
 
-  renderSection(category: Category | 'SUCCESS') {
+  renderSection(category: Category) {
     const catString = category.toString().toLowerCase();
-    let allRuns = this.runs.filter(run =>
+    const allRuns = this.runs.filter(run =>
       isAttemptSelected(this.selectedAttempts, run)
     );
-    if (category === 'SUCCESS') {
-      allRuns = allRuns.filter(hasCompletedWithoutResults);
-    } else {
-      allRuns = allRuns.filter(r => hasResultsOf(r, category));
-    }
     const all = allRuns.reduce(
       (results: RunResult[], run) => [
         ...results,
@@ -928,7 +922,7 @@
   }
 
   renderShowAllButton(
-    category: Category | 'SUCCESS',
+    category: Category,
     isShowAll: boolean,
     showAllThreshold: number,
     resultCount: number
@@ -947,7 +941,7 @@
     `;
   }
 
-  toggleShowAll(category: Category | 'SUCCESS') {
+  toggleShowAll(category: Category) {
     const current = this.isShowAll.get(category) ?? false;
     this.isShowAll.set(category, !current);
     this.requestUpdate();
@@ -1011,7 +1005,7 @@
     return html`(${filtered.length} of ${all.length})`;
   }
 
-  toggleExpanded(category: Category | 'SUCCESS') {
+  toggleExpanded(category: Category) {
     const expanded = this.isSectionExpanded.get(category);
     assertIsDefined(expanded, 'expanded must have been set in initial render');
     this.isSectionExpanded.set(category, !expanded);
@@ -1019,8 +1013,11 @@
     this.requestUpdate();
   }
 
-  computeRunResults(category: Category | 'SUCCESS', run: CheckRun) {
-    if (category === 'SUCCESS') return [this.computeSuccessfulRunResult(run)];
+  computeRunResults(category: Category, run: CheckRun) {
+    const noResults = (run.results ?? []).length === 0;
+    if (noResults && category === Category.SUCCESS) {
+      return [this.computeSuccessfulRunResult(run)];
+    }
     return (
       run.results
         ?.filter(result => result.category === category)
@@ -1033,7 +1030,7 @@
   computeSuccessfulRunResult(run: CheckRun): RunResult {
     const adaptedRun: RunResult = {
       internalResultId: run.internalRunId + '-0',
-      category: Category.INFO, // will not be used, but is required
+      category: Category.SUCCESS,
       summary: run.statusDescription ?? '',
       ...run,
     };
diff --git a/polygerrit-ui/app/elements/diff/gr-context-controls/gr-context-controls.ts b/polygerrit-ui/app/elements/diff/gr-context-controls/gr-context-controls.ts
new file mode 100644
index 0000000..60b1a1a
--- /dev/null
+++ b/polygerrit-ui/app/elements/diff/gr-context-controls/gr-context-controls.ts
@@ -0,0 +1,463 @@
+/**
+ * @license
+ * Copyright (C) 2021 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.
+ */
+import '@polymer/paper-button/paper-button';
+import '@polymer/paper-card/paper-card';
+import '@polymer/paper-checkbox/paper-checkbox';
+import '@polymer/paper-dropdown-menu/paper-dropdown-menu';
+import '@polymer/paper-fab/paper-fab';
+import '@polymer/paper-icon-button/paper-icon-button';
+import '@polymer/paper-item/paper-item';
+import '@polymer/paper-listbox/paper-listbox';
+import '@polymer/paper-tooltip/paper-tooltip.js';
+
+import '../../shared/gr-button/gr-button';
+import {pluralize} from '../../../utils/string-util';
+import {fire} from '../../../utils/event-util';
+import {DiffInfo} from '../../../types/diff';
+import {assertIsDefined} from '../../../utils/common-util';
+import {css, customElement, html, LitElement, property} from 'lit-element';
+
+import {
+  ContextButtonType,
+  RenderPreferences,
+  SyntaxBlock,
+} from '../../../api/diff';
+
+import {GrDiffGroup, hideInContextControl} from '../gr-diff/gr-diff-group';
+
+const PARTIAL_CONTEXT_AMOUNT = 10;
+
+/**
+ * Traverses a hierarchical structure of syntax blocks and
+ * finds the most local/nested block that can be associated line.
+ * It finds the closest block that contains the whole line and
+ * returns the whole path from the syntax layer (blocks) sent as parameter
+ * to the most nested block - the complete path from the top to bottom layer of
+ * a syntax tree. Example: [myNamepace, MyClass, myMethod1, aLocalFunctionInsideMethod1]
+ *
+ * @param lineNum line number for the targeted line.
+ * @param blocks Blocks for a specific syntax level in the file (to allow recursive calls)
+ */
+function findBlockTreePathForLine(
+  lineNum: number,
+  blocks?: SyntaxBlock[]
+): SyntaxBlock[] {
+  const containingBlock = blocks?.find(
+    ({range}) => range.start_line < lineNum && range.end_line > lineNum
+  );
+  if (!containingBlock) return [];
+  const innerPathInChild = findBlockTreePathForLine(
+    lineNum,
+    containingBlock?.children
+  );
+  return [containingBlock].concat(innerPathInChild);
+}
+
+@customElement('gr-context-controls')
+export class GrContextControls extends LitElement {
+  @property({type: Object}) renderPreferences?: RenderPreferences;
+
+  @property({type: Object}) diff?: DiffInfo;
+
+  @property({type: Object}) section?: HTMLElement;
+
+  @property({type: Object}) contextGroups: GrDiffGroup[] = [];
+
+  @property({type: Boolean}) showAbove = false;
+
+  @property({type: Boolean}) showBelow = false;
+
+  static styles = css`
+    :host {
+      display: flex;
+      width: 100%;
+      height: 100%;
+      justify-content: center;
+      position: absolute;
+    }
+    .contextControlButton {
+      background-color: var(--default-button-background-color);
+      font: var(--context-control-button-font, inherit);
+      /* All position is relative to container, so ignore sibling buttons. */
+      position: absolute;
+    }
+    .contextControlButton:first-child {
+      /* First button needs to claim width to display without text wrapping. */
+      position: relative;
+    }
+    .centeredButton {
+      /* Center over divider. */
+      top: 50%;
+      transform: translateY(-50%);
+    }
+    .aboveBelowButtons {
+      display: flex;
+      flex-direction: column;
+      margin-left: var(--spacing-m);
+      position: relative;
+    }
+    .aboveBelowButtons:first-child {
+      margin-left: 0;
+    }
+
+    .aboveButton {
+      /* Display over preceding content / background placeholder. */
+      transform: translateY(-100%);
+    }
+    .belowButton {
+      top: calc(100% + var(--divider-border));
+    }
+    .breadcrumbTooltip {
+      white-space: nowrap;
+    }
+  `;
+
+  // To pass CSS mixins for @apply to Polymer components, they need to be
+  // wrapped in a <custom-style>.
+  static customStyles = html`
+    <custom-style>
+      <style>
+        .centeredButton {
+          --gr-button: {
+            color: var(--diff-context-control-color);
+            border-style: solid;
+            border-color: var(--border-color);
+            border-top-width: 1px;
+            border-right-width: 1px;
+            border-bottom-width: 1px;
+            border-left-width: 1px;
+
+            border-top-left-radius: var(--border-radius);
+            border-top-right-radius: var(--border-radius);
+            border-bottom-right-radius: var(--border-radius);
+            border-bottom-left-radius: var(--border-radius);
+            padding: var(--spacing-s) var(--spacing-l);
+          }
+        }
+        .aboveButton {
+          --gr-button: {
+            color: var(--diff-context-control-color);
+            border-style: solid;
+            border-color: var(--border-color);
+            border-top-width: 1px;
+            border-right-width: 1px;
+            border-bottom-width: 0;
+            border-left-width: 1px;
+
+            border-top-left-radius: var(--border-radius);
+            border-top-right-radius: var(--border-radius);
+            border-bottom-right-radius: 0;
+            border-bottom-left-radius: var(--border-radius);
+            padding: var(--spacing-xxs) var(--spacing-l);
+          }
+        }
+        .belowButton {
+          --gr-button: {
+            color: var(--diff-context-control-color);
+            border-style: solid;
+            border-color: var(--border-color);
+            border-top-width: 0;
+            border-right-width: 1px;
+            border-bottom-width: 1px;
+            border-left-width: 1px;
+
+            border-top-left-radius: 0;
+            border-top-right-radius: 0;
+            border-bottom-right-radius: var(--border-radius);
+            border-bottom-left-radius: var(--border-radius);
+            padding: var(--spacing-xxs) var(--spacing-l);
+          }
+        }
+      </style>
+    </custom-style>
+  `;
+
+  private numLines() {
+    const {leftStart, leftEnd} = this.contextRange();
+    return leftEnd - leftStart + 1;
+  }
+
+  private createExpandAllButtonContainer() {
+    return html` <div
+      class="style-scope gr-diff aboveBelowButtons fullExpansion"
+    >
+      ${this.createContextButton(ContextButtonType.ALL, this.numLines())}
+    </div>`;
+  }
+
+  /**
+   * Creates a specific expansion button (e.g. +X common lines, +10, +Block).
+   */
+  private createContextButton(
+    type: ContextButtonType,
+    linesToExpand: number,
+    tooltipText?: string
+  ) {
+    let text = '';
+    let groups: GrDiffGroup[] = []; // The groups that replace this one if tapped.
+    let ariaLabel = '';
+    let classes = 'contextControlButton showContext ';
+
+    if (type === ContextButtonType.ALL) {
+      text = `+${pluralize(linesToExpand, 'common line')}`;
+      ariaLabel = `Show ${pluralize(linesToExpand, 'common line')}`;
+      classes +=
+        this.showAbove && this.showBelow
+          ? 'centeredButton'
+          : this.showAbove
+          ? 'aboveButton'
+          : 'belowButton';
+      if (this.partialContent) {
+        // Expanding content would require load of more data
+        text += ' (too large)';
+      }
+      groups.push(...this.contextGroups);
+    } else if (type === ContextButtonType.ABOVE) {
+      groups = hideInContextControl(
+        this.contextGroups,
+        linesToExpand,
+        this.numLines()
+      );
+      text = `+${linesToExpand}`;
+      classes += 'aboveButton';
+      ariaLabel = `Show ${pluralize(linesToExpand, 'line')} above`;
+    } else if (type === ContextButtonType.BELOW) {
+      groups = hideInContextControl(
+        this.contextGroups,
+        0,
+        this.numLines() - linesToExpand
+      );
+      text = `+${linesToExpand}`;
+      classes += 'belowButton';
+      ariaLabel = `Show ${pluralize(linesToExpand, 'line')} below`;
+    } else if (type === ContextButtonType.BLOCK_ABOVE) {
+      groups = hideInContextControl(
+        this.contextGroups,
+        linesToExpand,
+        this.numLines()
+      );
+      text = '+Block';
+      classes += 'aboveButton';
+      ariaLabel = 'Show block above';
+    } else if (type === ContextButtonType.BLOCK_BELOW) {
+      groups = hideInContextControl(
+        this.contextGroups,
+        0,
+        this.numLines() - linesToExpand
+      );
+      text = '+Block';
+      classes += 'belowButton';
+      ariaLabel = 'Show block below';
+    }
+    const expandHandler = this.createExpansionHandler(
+      linesToExpand,
+      type,
+      groups
+    );
+
+    const tooltip = tooltipText
+      ? html`<paper-tooltip offset="10"
+          ><div class="breadcrumbTooltip">${tooltipText}</div></paper-tooltip
+        >`
+      : undefined;
+    const button = html` <gr-button
+      class="${classes}"
+      link="true"
+      no-uppercase="true"
+      aria-label="${ariaLabel}"
+      @click="${expandHandler}"
+    >
+      <span class="showContext">${text}</span>
+      ${tooltip}
+    </gr-button>`;
+    return button;
+  }
+
+  private createExpansionHandler(
+    linesToExpand: number,
+    type: ContextButtonType,
+    groups: GrDiffGroup[]
+  ) {
+    return (e: Event) => {
+      e.stopPropagation();
+      if (type === ContextButtonType.ALL && this.partialContent) {
+        const {leftStart, leftEnd, rightStart, rightEnd} = this.contextRange();
+        const lineRange = {
+          left: {
+            start_line: leftStart,
+            end_line: leftEnd,
+          },
+          right: {
+            start_line: rightStart,
+            end_line: rightEnd,
+          },
+        };
+        fire(this, 'content-load-needed', {
+          lineRange,
+        });
+      } else {
+        assertIsDefined(this.section, 'section');
+        fire(this, 'diff-context-expanded', {
+          groups,
+          section: this.section!,
+          numLines: this.numLines(),
+          buttonType: type,
+          expandedLines: linesToExpand,
+        });
+      }
+    };
+  }
+
+  private showPartialLinks() {
+    return this.numLines() > PARTIAL_CONTEXT_AMOUNT;
+  }
+
+  /**
+   * Creates a container div with partial (+10) expansion buttons (above and/or below).
+   */
+  private createPartialExpansionButtons() {
+    if (!this.showPartialLinks()) {
+      return undefined;
+    }
+    let aboveButton;
+    let belowButton;
+    if (this.showAbove) {
+      aboveButton = this.createContextButton(
+        ContextButtonType.ABOVE,
+        PARTIAL_CONTEXT_AMOUNT
+      );
+    }
+    if (this.showBelow) {
+      belowButton = this.createContextButton(
+        ContextButtonType.BELOW,
+        PARTIAL_CONTEXT_AMOUNT
+      );
+    }
+    return aboveButton || belowButton
+      ? html` <div class="aboveBelowButtons partialExpansion">
+          ${aboveButton} ${belowButton}
+        </div>`
+      : undefined;
+  }
+
+  /**
+   * Checks if the collapsed section contains unavailable content (skip chunks).
+   */
+  private get partialContent() {
+    return this.contextGroups.some(c => !!c.skip);
+  }
+
+  /**
+   * Creates a container div with block expansion buttons (above and/or below).
+   */
+  private createBlockExpansionButtons() {
+    if (
+      !this.showPartialLinks() ||
+      !this.renderPreferences?.use_block_expansion ||
+      this.partialContent
+    ) {
+      return undefined;
+    }
+    let aboveBlockButton;
+    let belowBlockButton;
+    if (this.showAbove) {
+      aboveBlockButton = this.createBlockButton(
+        ContextButtonType.BLOCK_ABOVE,
+        this.numLines(),
+        this.contextRange().rightStart - 1
+      );
+    }
+    if (this.showBelow) {
+      belowBlockButton = this.createBlockButton(
+        ContextButtonType.BLOCK_BELOW,
+        this.numLines(),
+        this.contextRange().rightEnd + 1
+      );
+    }
+    if (aboveBlockButton || belowBlockButton) {
+      return html` <div class="aboveBelowButtons blockExpansion">
+        ${aboveBlockButton} ${belowBlockButton}
+      </div>`;
+    }
+    return undefined;
+  }
+
+  private createBlockButton(
+    buttonType: ContextButtonType,
+    numLines: number,
+    referenceLine: number
+  ) {
+    assertIsDefined(this.diff, 'diff');
+    const syntaxTree = this.diff!.meta_b.syntax_tree;
+    const outlineSyntaxPath = findBlockTreePathForLine(
+      referenceLine,
+      syntaxTree
+    );
+    let linesToExpand = numLines;
+    let tooltipText = `${linesToExpand} common lines`;
+    if (outlineSyntaxPath.length) {
+      const {range} = outlineSyntaxPath[outlineSyntaxPath.length - 1];
+      // Create breadcrumb string:
+      // myNamepace > MyClass > myMethod1 > aLocalFunctionInsideMethod1 > (anonymous)
+      tooltipText = outlineSyntaxPath
+        .map(b => b.name || '(anonymous)')
+        .join(' > ');
+      const targetLine =
+        buttonType === ContextButtonType.BLOCK_ABOVE
+          ? range.end_line
+          : range.start_line;
+      const distanceToTargetLine = Math.abs(targetLine - referenceLine);
+      if (distanceToTargetLine < numLines) {
+        linesToExpand = distanceToTargetLine;
+      }
+    }
+    return this.createContextButton(buttonType, linesToExpand, tooltipText);
+  }
+
+  private contextRange() {
+    return {
+      leftStart: this.contextGroups[0].lineRange.left.start_line,
+      leftEnd: this.contextGroups[this.contextGroups.length - 1].lineRange.left
+        .end_line,
+      rightStart: this.contextGroups[0].lineRange.right.start_line,
+      rightEnd: this.contextGroups[this.contextGroups.length - 1].lineRange
+        .right.end_line,
+    };
+  }
+
+  private hasValidProperties() {
+    return !!(this.diff && this.section && this.contextGroups?.length);
+  }
+
+  render() {
+    if (!this.hasValidProperties()) {
+      console.error('Invalid properties for gr-context-controls!');
+      return html`<p>invalid properties</p>`;
+    }
+    return html`
+      ${GrContextControls.customStyles} ${this.createExpandAllButtonContainer()}
+      ${this.createPartialExpansionButtons()}
+      ${this.createBlockExpansionButtons()}
+    `;
+  }
+}
+
+declare global {
+  interface HTMLElementTagNameMap {
+    'gr-context-controls': GrContextControls;
+  }
+}
diff --git a/polygerrit-ui/app/elements/diff/gr-context-controls/gr-context-controls_test.ts b/polygerrit-ui/app/elements/diff/gr-context-controls/gr-context-controls_test.ts
new file mode 100644
index 0000000..d866c1a
--- /dev/null
+++ b/polygerrit-ui/app/elements/diff/gr-context-controls/gr-context-controls_test.ts
@@ -0,0 +1,377 @@
+/**
+ * @license
+ * Copyright (C) 2021 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.
+ */
+
+import '../../../test/common-test-setup-karma';
+import '../gr-diff/gr-diff-group';
+import './gr-context-controls';
+import {GrContextControls} from './gr-context-controls';
+import {flush} from '@polymer/polymer/lib/legacy/polymer.dom';
+
+import {GrDiffLine, GrDiffLineType} from '../gr-diff/gr-diff-line';
+import {GrDiffGroup, GrDiffGroupType} from '../gr-diff/gr-diff-group';
+import {DiffFileMetaInfo, DiffInfo, SyntaxBlock} from '../../../api/diff';
+
+const blankFixture = fixtureFromElement('div');
+
+suite('gr-context-control tests', () => {
+  let element: GrContextControls;
+
+  setup(async () => {
+    element = document.createElement('gr-context-controls');
+    element.diff = ({content: []} as any) as DiffInfo;
+    element.renderPreferences = {};
+    element.section = document.createElement('div');
+    blankFixture.instantiate().appendChild(element);
+    await flush();
+  });
+
+  function createContextGroups(options: {offset?: number; count?: number}) {
+    const offset = options.offset || 0;
+    const numLines = options.count || 10;
+    const lines = [];
+    for (let i = 0; i < numLines; i++) {
+      const line = new GrDiffLine(GrDiffLineType.BOTH);
+      line.beforeNumber = offset + i + 1;
+      line.afterNumber = offset + i + 1;
+      line.text = 'lorem upsum';
+      lines.push(line);
+    }
+
+    return [new GrDiffGroup(GrDiffGroupType.BOTH, lines)];
+  }
+
+  test('no +10 buttons for 10 or less lines', async () => {
+    element.contextGroups = createContextGroups({count: 10});
+
+    await flush();
+
+    const buttons = element.shadowRoot!.querySelectorAll(
+      'gr-button.showContext'
+    );
+    assert.equal(buttons.length, 1);
+    assert.equal(buttons[0].textContent!.trim(), '+10 common lines');
+  });
+
+  test('context control at the top', async () => {
+    element.contextGroups = createContextGroups({offset: 0, count: 20});
+    element.showBelow = true;
+
+    await flush();
+
+    const buttons = element.shadowRoot!.querySelectorAll(
+      'gr-button.showContext'
+    );
+
+    assert.equal(buttons.length, 2);
+    assert.equal(buttons[0].textContent!.trim(), '+20 common lines');
+    assert.equal(buttons[1].textContent!.trim(), '+10');
+
+    assert.include([...buttons[0].classList.values()], 'belowButton');
+    assert.include([...buttons[1].classList.values()], 'belowButton');
+  });
+
+  test('context control in the middle', async () => {
+    element.contextGroups = createContextGroups({offset: 10, count: 20});
+    element.showAbove = true;
+    element.showBelow = true;
+
+    await flush();
+
+    const buttons = element.shadowRoot!.querySelectorAll(
+      'gr-button.showContext'
+    );
+
+    assert.equal(buttons.length, 3);
+    assert.equal(buttons[0].textContent!.trim(), '+20 common lines');
+    assert.equal(buttons[1].textContent!.trim(), '+10');
+    assert.equal(buttons[2].textContent!.trim(), '+10');
+
+    assert.include([...buttons[0].classList.values()], 'centeredButton');
+    assert.include([...buttons[1].classList.values()], 'aboveButton');
+    assert.include([...buttons[2].classList.values()], 'belowButton');
+  });
+
+  test('context control at the bottom', async () => {
+    element.contextGroups = createContextGroups({offset: 30, count: 20});
+    element.showAbove = true;
+
+    await flush();
+
+    const buttons = element.shadowRoot!.querySelectorAll(
+      'gr-button.showContext'
+    );
+
+    assert.equal(buttons.length, 2);
+    assert.equal(buttons[0].textContent!.trim(), '+20 common lines');
+    assert.equal(buttons[1].textContent!.trim(), '+10');
+
+    assert.include([...buttons[0].classList.values()], 'aboveButton');
+    assert.include([...buttons[1].classList.values()], 'aboveButton');
+  });
+
+  function prepareForBlockExpansion(syntaxTree: SyntaxBlock[]) {
+    element.renderPreferences!.use_block_expansion = true;
+    element.diff!.meta_b = ({
+      syntax_tree: syntaxTree,
+    } as any) as DiffFileMetaInfo;
+  }
+
+  test('context control with block expansion at the top', async () => {
+    prepareForBlockExpansion([]);
+    element.contextGroups = createContextGroups({offset: 0, count: 20});
+    element.showBelow = true;
+
+    await flush();
+
+    const fullExpansionButtons = element.shadowRoot!.querySelectorAll(
+      '.fullExpansion gr-button'
+    );
+    const partialExpansionButtons = element.shadowRoot!.querySelectorAll(
+      '.partialExpansion gr-button'
+    );
+    const blockExpansionButtons = element.shadowRoot!.querySelectorAll(
+      '.blockExpansion gr-button'
+    );
+    assert.equal(fullExpansionButtons.length, 1);
+    assert.equal(partialExpansionButtons.length, 1);
+    assert.equal(blockExpansionButtons.length, 1);
+    assert.equal(
+      blockExpansionButtons[0].querySelector('span')!.textContent!.trim(),
+      '+Block'
+    );
+    assert.include(
+      [...blockExpansionButtons[0].classList.values()],
+      'belowButton'
+    );
+  });
+
+  test('context control with block expansion in the middle', async () => {
+    prepareForBlockExpansion([]);
+    element.contextGroups = createContextGroups({offset: 10, count: 20});
+    element.showAbove = true;
+    element.showBelow = true;
+
+    await flush();
+
+    const fullExpansionButtons = element.shadowRoot!.querySelectorAll(
+      '.fullExpansion gr-button'
+    );
+    const partialExpansionButtons = element.shadowRoot!.querySelectorAll(
+      '.partialExpansion gr-button'
+    );
+    const blockExpansionButtons = element.shadowRoot!.querySelectorAll(
+      '.blockExpansion gr-button'
+    );
+    assert.equal(fullExpansionButtons.length, 1);
+    assert.equal(partialExpansionButtons.length, 2);
+    assert.equal(blockExpansionButtons.length, 2);
+    assert.equal(
+      blockExpansionButtons[0].querySelector('span')!.textContent!.trim(),
+      '+Block'
+    );
+    assert.equal(
+      blockExpansionButtons[1].querySelector('span')!.textContent!.trim(),
+      '+Block'
+    );
+    assert.include(
+      [...blockExpansionButtons[0].classList.values()],
+      'aboveButton'
+    );
+    assert.include(
+      [...blockExpansionButtons[1].classList.values()],
+      'belowButton'
+    );
+  });
+
+  test('context control with block expansion at the bottom', async () => {
+    prepareForBlockExpansion([]);
+    element.contextGroups = createContextGroups({offset: 30, count: 20});
+    element.showAbove = true;
+
+    await flush();
+
+    const fullExpansionButtons = element.shadowRoot!.querySelectorAll(
+      '.fullExpansion gr-button'
+    );
+    const partialExpansionButtons = element.shadowRoot!.querySelectorAll(
+      '.partialExpansion gr-button'
+    );
+    const blockExpansionButtons = element.shadowRoot!.querySelectorAll(
+      '.blockExpansion gr-button'
+    );
+    assert.equal(fullExpansionButtons.length, 1);
+    assert.equal(partialExpansionButtons.length, 1);
+    assert.equal(blockExpansionButtons.length, 1);
+    assert.equal(
+      blockExpansionButtons[0].querySelector('span')!.textContent!.trim(),
+      '+Block'
+    );
+    assert.include(
+      [...blockExpansionButtons[0].classList.values()],
+      'aboveButton'
+    );
+  });
+
+  test('+ Block tooltip tooltip shows syntax block containing the target lines above and below', async () => {
+    prepareForBlockExpansion([
+      {
+        name: 'aSpecificFunction',
+        range: {start_line: 1, start_column: 0, end_line: 25, end_column: 0},
+        children: [],
+      },
+      {
+        name: 'anotherFunction',
+        range: {start_line: 26, start_column: 0, end_line: 50, end_column: 0},
+        children: [],
+      },
+    ]);
+    element.contextGroups = createContextGroups({offset: 10, count: 20});
+    element.showAbove = true;
+    element.showBelow = true;
+
+    await flush();
+
+    const blockExpansionButtons = element.shadowRoot!.querySelectorAll(
+      '.blockExpansion gr-button'
+    );
+    assert.equal(
+      blockExpansionButtons[0]
+        .querySelector('.breadcrumbTooltip')!
+        .textContent?.trim(),
+      'aSpecificFunction'
+    );
+    assert.equal(
+      blockExpansionButtons[1]
+        .querySelector('.breadcrumbTooltip')!
+        .textContent?.trim(),
+      'anotherFunction'
+    );
+  });
+
+  test('+Block tooltip shows nested syntax blocks as breadcrumbs', async () => {
+    prepareForBlockExpansion([
+      {
+        name: 'aSpecificNamespace',
+        range: {start_line: 1, start_column: 0, end_line: 200, end_column: 0},
+        children: [
+          {
+            name: 'MyClass',
+            range: {
+              start_line: 2,
+              start_column: 0,
+              end_line: 100,
+              end_column: 0,
+            },
+            children: [
+              {
+                name: 'aMethod',
+                range: {
+                  start_line: 5,
+                  start_column: 0,
+                  end_line: 80,
+                  end_column: 0,
+                },
+                children: [],
+              },
+            ],
+          },
+        ],
+      },
+    ]);
+    element.contextGroups = createContextGroups({offset: 10, count: 20});
+    element.showAbove = true;
+    element.showBelow = true;
+
+    await flush();
+
+    const blockExpansionButtons = element.shadowRoot!.querySelectorAll(
+      '.blockExpansion gr-button'
+    );
+    assert.equal(
+      blockExpansionButtons[0]
+        .querySelector('.breadcrumbTooltip')!
+        .textContent?.trim(),
+      'aSpecificNamespace > MyClass > aMethod'
+    );
+  });
+
+  test('+Block tooltip shows (anonymous) for empty blocks', async () => {
+    prepareForBlockExpansion([
+      {
+        name: 'aSpecificNamespace',
+        range: {start_line: 1, start_column: 0, end_line: 200, end_column: 0},
+        children: [
+          {
+            name: '',
+            range: {
+              start_line: 2,
+              start_column: 0,
+              end_line: 100,
+              end_column: 0,
+            },
+            children: [
+              {
+                name: 'aMethod',
+                range: {
+                  start_line: 5,
+                  start_column: 0,
+                  end_line: 80,
+                  end_column: 0,
+                },
+                children: [],
+              },
+            ],
+          },
+        ],
+      },
+    ]);
+    element.contextGroups = createContextGroups({offset: 10, count: 20});
+    element.showAbove = true;
+    element.showBelow = true;
+    await flush();
+
+    const blockExpansionButtons = element.shadowRoot!.querySelectorAll(
+      '.blockExpansion gr-button'
+    );
+    assert.equal(
+      blockExpansionButtons[0]
+        .querySelector('.breadcrumbTooltip')!
+        .textContent?.trim(),
+      'aSpecificNamespace > (anonymous) > aMethod'
+    );
+  });
+
+  test('+Block tooltip shows "all common lines" for empty syntax tree', async () => {
+    prepareForBlockExpansion([]);
+
+    element.contextGroups = createContextGroups({offset: 10, count: 20});
+    element.showAbove = true;
+    element.showBelow = true;
+    await flush();
+
+    const blockExpansionButtons = element.shadowRoot!.querySelectorAll(
+      '.blockExpansion gr-button'
+    );
+    // querySelector('.breadcrumbTooltip')!.textContent!.trim()
+    assert.equal(
+      blockExpansionButtons[0]
+        .querySelector('.breadcrumbTooltip')!
+        .textContent?.trim(),
+      '20 common lines'
+    );
+  });
+});
diff --git a/polygerrit-ui/app/elements/diff/gr-diff-builder/gr-diff-builder-element_test.js b/polygerrit-ui/app/elements/diff/gr-diff-builder/gr-diff-builder-element_test.js
index 5ba3606..4455bd5 100644
--- a/polygerrit-ui/app/elements/diff/gr-diff-builder/gr-diff-builder-element_test.js
+++ b/polygerrit-ui/app/elements/diff/gr-diff-builder/gr-diff-builder-element_test.js
@@ -18,6 +18,7 @@
 import '../../../test/common-test-setup-karma.js';
 import '../gr-diff/gr-diff-group.js';
 import './gr-diff-builder.js';
+import '../gr-context-controls/gr-context-controls.js';
 import {getMockDiffResponse} from '../../../test/mocks/diff-response.js';
 import './gr-diff-builder-element.js';
 import {stubBaseUrl} from '../../../test/test-utils.js';
@@ -74,152 +75,6 @@
     assert.isTrue(node.classList.contains('classes'));
   });
 
-  suite('context control', () => {
-    function createContextGroups(options) {
-      const offset = options.offset || 0;
-      const numLines = options.count || 10;
-      const lines = [];
-      for (let i = 0; i < numLines; i++) {
-        const line = new GrDiffLine(GrDiffLineType.BOTH);
-        line.beforeNumber = offset + i + 1;
-        line.afterNumber = offset + i + 1;
-        line.text = 'lorem upsum';
-        lines.push(line);
-      }
-
-      return [new GrDiffGroup(GrDiffGroupType.BOTH, lines)];
-    }
-
-    function createContextSectionForGroups(options) {
-      const section = document.createElement('div');
-      builder._createContextControls(
-          section, createContextGroups(options), DiffViewMode.UNIFIED);
-      return section;
-    }
-
-    let diffInfo;
-    let renderPrefs;
-
-    setup(() => {
-      diffInfo = {content: []};
-      renderPrefs = {};
-      builder = new GrDiffBuilder(diffInfo, prefs, null, [], renderPrefs);
-    });
-
-    test('no +10 buttons for 10 or less lines', () => {
-      const section = createContextSectionForGroups({count: 10});
-      const buttons = section.querySelectorAll('gr-button.showContext');
-
-      assert.equal(buttons.length, 1);
-      assert.equal(buttons[0].textContent, '+10 common lines');
-    });
-
-    test('context control at the top', () => {
-      builder._numLinesLeft = 50;
-      const section = createContextSectionForGroups({offset: 0, count: 20});
-      const buttons = section.querySelectorAll('gr-button.showContext');
-
-      assert.equal(buttons.length, 2);
-      assert.equal(buttons[0].textContent, '+20 common lines');
-      assert.equal(buttons[1].textContent, '+10');
-
-      assert.include([...buttons[0].classList.values()], 'belowButton');
-      assert.include([...buttons[1].classList.values()], 'belowButton');
-    });
-
-    test('context control in the middle', () => {
-      builder._numLinesLeft = 50;
-      const section = createContextSectionForGroups({offset: 10, count: 20});
-      const buttons = section.querySelectorAll('gr-button.showContext');
-
-      assert.equal(buttons.length, 3);
-      assert.equal(buttons[0].textContent, '+20 common lines');
-      assert.equal(buttons[1].textContent, '+10');
-      assert.equal(buttons[2].textContent, '+10');
-
-      assert.include([...buttons[0].classList.values()], 'centeredButton');
-      assert.include([...buttons[1].classList.values()], 'aboveButton');
-      assert.include([...buttons[2].classList.values()], 'belowButton');
-    });
-
-    test('context control at the bottom', () => {
-      builder._numLinesLeft = 50;
-      const section = createContextSectionForGroups({offset: 30, count: 20});
-      const buttons = section.querySelectorAll('gr-button.showContext');
-
-      assert.equal(buttons.length, 2);
-      assert.equal(buttons[0].textContent, '+20 common lines');
-      assert.equal(buttons[1].textContent, '+10');
-
-      assert.include([...buttons[0].classList.values()], 'aboveButton');
-      assert.include([...buttons[1].classList.values()], 'aboveButton');
-    });
-
-    suite('with block expansion', () => {
-      setup(() => {
-        builder._numLinesLeft = 50;
-        renderPrefs.use_block_expansion = true;
-        diffInfo.meta_b = {
-          syntax_tree: [],
-        };
-      });
-
-      test('context control with block expansion at the top', () => {
-        const section = createContextSectionForGroups({offset: 0, count: 20});
-
-        const fullExpansionButtons = section
-            .querySelectorAll('.fullExpansion gr-button');
-        const partialExpansionButtons = section
-            .querySelectorAll('.partialExpansion gr-button');
-        const blockExpansionButtons = section
-            .querySelectorAll('.blockExpansion gr-button');
-        assert.equal(fullExpansionButtons.length, 1);
-        assert.equal(partialExpansionButtons.length, 1);
-        assert.equal(blockExpansionButtons.length, 1);
-        assert.equal(blockExpansionButtons[0].textContent, '+Block');
-        assert.include([...blockExpansionButtons[0].classList.values()],
-            'belowButton');
-      });
-
-      test('context control in the middle', () => {
-        const section = createContextSectionForGroups({offset: 10, count: 20});
-
-        const fullExpansionButtons = section
-            .querySelectorAll('.fullExpansion gr-button');
-        const partialExpansionButtons = section
-            .querySelectorAll('.partialExpansion gr-button');
-        const blockExpansionButtons = section
-            .querySelectorAll('.blockExpansion gr-button');
-        assert.equal(fullExpansionButtons.length, 1);
-        assert.equal(partialExpansionButtons.length, 2);
-        assert.equal(blockExpansionButtons.length, 2);
-        assert.equal(blockExpansionButtons[0].textContent, '+Block');
-        assert.equal(blockExpansionButtons[1].textContent, '+Block');
-        assert.include([...blockExpansionButtons[0].classList.values()],
-            'aboveButton');
-        assert.include([...blockExpansionButtons[1].classList.values()],
-            'belowButton');
-      });
-
-      test('context control at the bottom', () => {
-        const section = createContextSectionForGroups({offset: 30, count: 20});
-
-        const fullExpansionButtons = section
-            .querySelectorAll('.fullExpansion gr-button');
-        const partialExpansionButtons = section
-            .querySelectorAll('.partialExpansion gr-button');
-        const blockExpansionButtons = section
-            .querySelectorAll('.blockExpansion gr-button');
-        assert.equal(fullExpansionButtons.length, 1);
-        assert.equal(partialExpansionButtons.length, 1);
-        assert.equal(blockExpansionButtons.length, 1);
-        assert.equal(blockExpansionButtons[0].textContent, '+Block');
-        assert.include([...blockExpansionButtons[0].classList.values()],
-            'aboveButton');
-      });
-    });
-  });
-
   test('newlines 1', () => {
     let text = 'abcdef';
 
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 adfe75f..0ccf8bc 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
@@ -16,25 +16,20 @@
  */
 import {
   ContentLoadNeededEventDetail,
-  ContextButtonType,
   DiffContextExpandedExternalDetail,
   MovedLinkClickedEventDetail,
   RenderPreferences,
-  SyntaxBlock,
 } from '../../../api/diff';
 import {getBaseUrl} from '../../../utils/url-util';
 import {GrDiffLine, GrDiffLineType, LineNumber} from '../gr-diff/gr-diff-line';
-import {
-  GrDiffGroup,
-  GrDiffGroupType,
-  hideInContextControl,
-} from '../gr-diff/gr-diff-group';
+import {GrDiffGroup, GrDiffGroupType} from '../gr-diff/gr-diff-group';
+
+import '../gr-context-controls/gr-context-controls';
+import {GrContextControls} from '../gr-context-controls/gr-context-controls';
 import {BlameInfo} from '../../../types/common';
 import {DiffInfo, DiffPreferencesInfo} from '../../../types/diff';
 import {DiffViewMode, Side} from '../../../constants/constants';
 import {DiffLayer} from '../../../types/types';
-import {pluralize} from '../../../utils/string-util';
-import {fire} from '../../../utils/event-util';
 
 /**
  * In JS, unicode code points above 0xFFFF occupy two elements of a string.
@@ -58,8 +53,6 @@
  */
 const REGEX_TAB_OR_SURROGATE_PAIR = /\t|[\uD800-\uDBFF][\uDC00-\uDFFF]/;
 
-const PARTIAL_CONTEXT_AMOUNT = 10;
-
 export interface DiffContextExpandedEventDetail
   extends DiffContextExpandedExternalDetail {
   groups: GrDiffGroup[];
@@ -74,19 +67,6 @@
   }
 }
 
-function findMostNestedContainingBlock(
-  lineNum: number,
-  blocks?: SyntaxBlock[]
-): SyntaxBlock | undefined {
-  const containingBlock = blocks?.find(
-    ({range}) => range.start_line < lineNum && range.end_line > lineNum
-  );
-  const containingChildBlock = containingBlock
-    ? findMostNestedContainingBlock(lineNum, containingBlock?.children)
-    : undefined;
-  return containingChildBlock || containingBlock;
-}
-
 export abstract class GrDiffBuilder {
   private readonly _diff: DiffInfo;
 
@@ -320,7 +300,6 @@
     );
   }
 
-  // TODO(renanoliveira): Move context controls to polymer component (or at least a separate class).
   _createContextControls(
     section: HTMLElement,
     contextGroups: GrDiffGroup[],
@@ -329,10 +308,6 @@
     const leftStart = contextGroups[0].lineRange.left.start_line;
     const leftEnd =
       contextGroups[contextGroups.length - 1].lineRange.left.end_line;
-    const rightStart = contextGroups[0].lineRange.right.start_line;
-    const rightEnd =
-      contextGroups[contextGroups.length - 1].lineRange.right.end_line;
-
     const firstGroupIsSkipped = !!contextGroups[0].skip;
     const lastGroupIsSkipped = !!contextGroups[contextGroups.length - 1].skip;
 
@@ -349,9 +324,7 @@
         section,
         contextGroups,
         showAbove,
-        showBelow,
-        rightStart,
-        rightEnd
+        showBelow
       )
     );
     if (showBelow) {
@@ -369,13 +342,8 @@
     section: HTMLElement,
     contextGroups: GrDiffGroup[],
     showAbove: boolean,
-    showBelow: boolean,
-    rightStart: number,
-    rightEnd: number
+    showBelow: boolean
   ): HTMLElement {
-    const numLines = rightEnd - rightStart + 1;
-    if (numLines === 0) console.error('context group without lines');
-
     const row = this._createElement('tr', 'contextDivider');
     if (!(showAbove && showBelow)) {
       row.classList.add('collapsed');
@@ -384,193 +352,19 @@
     const element = this._createElement('td', 'dividerCell');
     row.appendChild(element);
 
-    const showAllContainer = this._createExpandAllButtonContainer(
-      section,
-      contextGroups,
-      showAbove,
-      showBelow,
-      numLines
-    );
-    element.appendChild(showAllContainer);
-
-    const showPartialLinks = numLines > PARTIAL_CONTEXT_AMOUNT;
-    if (showPartialLinks) {
-      const partialExpansionContainer = this._createPartialExpansionButtons(
-        section,
-        contextGroups,
-        showAbove,
-        showBelow,
-        numLines
-      );
-      if (partialExpansionContainer) {
-        element.appendChild(partialExpansionContainer);
-      }
-      const blockExpansionContainer = this._createBlockExpansionButtons(
-        section,
-        contextGroups,
-        showAbove,
-        showBelow,
-        rightStart,
-        rightEnd,
-        numLines
-      );
-      if (blockExpansionContainer) {
-        element.appendChild(blockExpansionContainer);
-      }
-    }
+    const contextControls = this._createElement(
+      'gr-context-controls'
+    ) as GrContextControls;
+    contextControls.diff = this._diff;
+    contextControls.renderPreferences = this._renderPrefs;
+    contextControls.section = section;
+    contextControls.contextGroups = contextGroups;
+    contextControls.showAbove = showAbove;
+    contextControls.showBelow = showBelow;
+    element.appendChild(contextControls);
     return row;
   }
 
-  private _createExpandAllButtonContainer(
-    section: HTMLElement,
-    contextGroups: GrDiffGroup[],
-    showAbove: boolean,
-    showBelow: boolean,
-    numLines: number
-  ) {
-    const showAllButton = this._createContextButton(
-      ContextButtonType.ALL,
-      section,
-      contextGroups,
-      numLines,
-      numLines
-    );
-    showAllButton.classList.add(
-      showAbove && showBelow
-        ? 'centeredButton'
-        : showAbove
-        ? 'aboveButton'
-        : 'belowButton'
-    );
-    const showAllContainer = this._createElement(
-      'div',
-      'aboveBelowButtons fullExpansion'
-    );
-    showAllContainer.appendChild(showAllButton);
-    return showAllContainer;
-  }
-
-  private _createPartialExpansionButtons(
-    section: HTMLElement,
-    contextGroups: GrDiffGroup[],
-    showAbove: boolean,
-    showBelow: boolean,
-    numLines: number
-  ) {
-    let aboveButton;
-    let belowButton;
-    if (showAbove) {
-      aboveButton = this._createContextButton(
-        ContextButtonType.ABOVE,
-        section,
-        contextGroups,
-        numLines,
-        PARTIAL_CONTEXT_AMOUNT
-      );
-    }
-    if (showBelow) {
-      belowButton = this._createContextButton(
-        ContextButtonType.BELOW,
-        section,
-        contextGroups,
-        numLines,
-        PARTIAL_CONTEXT_AMOUNT
-      );
-    }
-    if (aboveButton || belowButton) {
-      const partialExpansionContainer = this._createElement(
-        'div',
-        'aboveBelowButtons partialExpansion'
-      );
-      aboveButton && partialExpansionContainer.appendChild(aboveButton);
-      belowButton && partialExpansionContainer.appendChild(belowButton);
-      return partialExpansionContainer;
-    }
-    return undefined;
-  }
-
-  private _createBlockExpansionButtons(
-    section: HTMLElement,
-    contextGroups: GrDiffGroup[],
-    showAbove: boolean,
-    showBelow: boolean,
-    rightStart: number,
-    rightEnd: number,
-    numLines: number
-  ) {
-    const fullContentNotAvailable =
-      contextGroups.find(c => !!c.skip) !== undefined;
-    if (!this._renderPrefs?.use_block_expansion || fullContentNotAvailable) {
-      return undefined;
-    }
-    let aboveBlockButton;
-    let belowBlockButton;
-    const rightSyntaxTree = this._diff.meta_b.syntax_tree;
-    if (showAbove) {
-      aboveBlockButton = this._createBlockButton(
-        section,
-        contextGroups,
-        ContextButtonType.BLOCK_ABOVE,
-        numLines,
-        rightStart - 1,
-        rightSyntaxTree
-      );
-    }
-    if (showBelow) {
-      belowBlockButton = this._createBlockButton(
-        section,
-        contextGroups,
-        ContextButtonType.BLOCK_BELOW,
-        numLines,
-        rightEnd + 1,
-        rightSyntaxTree
-      );
-    }
-    if (aboveBlockButton || belowBlockButton) {
-      const blockExpansionContainer = this._createElement(
-        'div',
-        'blockExpansion aboveBelowButtons'
-      );
-      aboveBlockButton && blockExpansionContainer.appendChild(aboveBlockButton);
-      belowBlockButton && blockExpansionContainer.appendChild(belowBlockButton);
-      return blockExpansionContainer;
-    }
-    return undefined;
-  }
-
-  private _createBlockButton(
-    section: HTMLElement,
-    contextGroups: GrDiffGroup[],
-    buttonType: ContextButtonType,
-    numLines: number,
-    referenceLine: number,
-    syntaxTree?: SyntaxBlock[]
-  ) {
-    const containingBlock = findMostNestedContainingBlock(
-      referenceLine,
-      syntaxTree
-    );
-    let linesToExpand = numLines;
-    if (containingBlock) {
-      const {range} = containingBlock;
-      const targetLine =
-        buttonType === ContextButtonType.BLOCK_ABOVE
-          ? range.end_line
-          : range.start_line;
-      const distanceToTargetLine = Math.abs(targetLine - referenceLine);
-      if (distanceToTargetLine < numLines) {
-        linesToExpand = distanceToTargetLine;
-      }
-    }
-    return this._createContextButton(
-      buttonType,
-      section,
-      contextGroups,
-      numLines,
-      linesToExpand
-    );
-  }
-
   /**
    * Creates a table row to serve as padding between code and context controls.
    * Blame column, line gutters, and content area will continue visually, but
@@ -599,99 +393,6 @@
     return row;
   }
 
-  _createContextButton(
-    type: ContextButtonType,
-    section: HTMLElement,
-    contextGroups: GrDiffGroup[],
-    numLines: number,
-    linesToExpand: number
-  ) {
-    const button = this._createElement('gr-button', 'showContext');
-    button.classList.add('contextControlButton');
-    button.setAttribute('link', 'true');
-    button.setAttribute('no-uppercase', 'true');
-
-    let text = '';
-    let groups: GrDiffGroup[] = []; // The groups that replace this one if tapped.
-    let requiresLoad = false;
-    if (type === ContextButtonType.ALL) {
-      text = `+${pluralize(linesToExpand, 'common line')}`;
-      button.setAttribute(
-        'aria-label',
-        `Show ${pluralize(linesToExpand, 'common line')}`
-      );
-      requiresLoad = contextGroups.find(c => !!c.skip) !== undefined;
-      if (requiresLoad) {
-        // Expanding content would require load of more data
-        text += ' (too large)';
-      }
-      groups.push(...contextGroups);
-    } else if (type === ContextButtonType.ABOVE) {
-      groups = hideInContextControl(contextGroups, linesToExpand, numLines);
-      text = `+${linesToExpand}`;
-      button.classList.add('aboveButton');
-      button.setAttribute(
-        'aria-label',
-        `Show ${pluralize(linesToExpand, 'line')} above`
-      );
-    } else if (type === ContextButtonType.BELOW) {
-      groups = hideInContextControl(contextGroups, 0, numLines - linesToExpand);
-      text = `+${linesToExpand}`;
-      button.classList.add('belowButton');
-      button.setAttribute(
-        'aria-label',
-        `Show ${pluralize(linesToExpand, 'line')} below`
-      );
-    } else if (type === ContextButtonType.BLOCK_ABOVE) {
-      groups = hideInContextControl(contextGroups, linesToExpand, numLines);
-      text = '+Block';
-      button.classList.add('aboveButton');
-      button.setAttribute('aria-label', 'Show block above');
-    } else if (type === ContextButtonType.BLOCK_BELOW) {
-      groups = hideInContextControl(contextGroups, 0, numLines - linesToExpand);
-      text = '+Block';
-      button.classList.add('belowButton');
-      button.setAttribute('aria-label', 'Show block below');
-    }
-    const textSpan = this._createElement('span', 'showContext');
-    textSpan.textContent = text;
-    button.appendChild(textSpan);
-
-    if (requiresLoad) {
-      button.addEventListener('click', e => {
-        e.stopPropagation();
-        const firstRange = groups[0].lineRange;
-        const lastRange = groups[groups.length - 1].lineRange;
-        const lineRange = {
-          left: {
-            start_line: firstRange.left.start_line,
-            end_line: lastRange.left.end_line,
-          },
-          right: {
-            start_line: firstRange.right.start_line,
-            end_line: lastRange.right.end_line,
-          },
-        };
-        fire(button, 'content-load-needed', {
-          lineRange,
-        });
-      });
-    } else {
-      button.addEventListener('click', e => {
-        e.stopPropagation();
-        fire(button, 'diff-context-expanded', {
-          groups,
-          section,
-          numLines,
-          buttonType: type,
-          expandedLines: linesToExpand,
-        });
-      });
-    }
-
-    return button;
-  }
-
   _createLineEl(
     line: GrDiffLine,
     number: LineNumber,
diff --git a/polygerrit-ui/app/elements/diff/gr-diff-cursor/gr-diff-cursor_test.js b/polygerrit-ui/app/elements/diff/gr-diff-cursor/gr-diff-cursor_test.js
index 75439c8..901f72a 100644
--- a/polygerrit-ui/app/elements/diff/gr-diff-cursor/gr-diff-cursor_test.js
+++ b/polygerrit-ui/app/elements/diff/gr-diff-cursor/gr-diff-cursor_test.js
@@ -617,6 +617,7 @@
   test('expand context updates stops', done => {
     sinon.spy(cursorElement, '_updateStops');
     MockInteractions.tap(diffElement.shadowRoot
+        .querySelector('gr-context-controls').shadowRoot
         .querySelector('.showContext'));
     flush(() => {
       assert.isTrue(cursorElement._updateStops.called);
diff --git a/polygerrit-ui/app/elements/diff/gr-diff/gr-diff_html.ts b/polygerrit-ui/app/elements/diff/gr-diff/gr-diff_html.ts
index 285c942..f48d15a 100644
--- a/polygerrit-ui/app/elements/diff/gr-diff/gr-diff_html.ts
+++ b/polygerrit-ui/app/elements/diff/gr-diff/gr-diff_html.ts
@@ -344,81 +344,6 @@
       top: 0;
       left: 0;
     }
-    .contextControlButton {
-      background-color: var(--default-button-background-color);
-      font: var(--context-control-button-font, inherit);
-      /* All position is relative to container, so ignore sibling buttons. */
-      position: absolute;
-    }
-    .contextControlButton:first-child {
-      /* First button needs to claim width to display without text wrapping. */
-      position: relative;
-    }
-    .centeredButton {
-      /* Center over divider. */
-      top: 50%;
-      transform: translateY(-50%);
-      --gr-button: {
-        color: var(--diff-context-control-color);
-        border-style: solid;
-        border-color: var(--border-color);
-        border-top-width: 1px;
-        border-right-width: 1px;
-        border-bottom-width: 1px;
-        border-left-width: 1px;
-        border-top-left-radius: var(--border-radius);
-        border-top-right-radius: var(--border-radius);
-        border-bottom-right-radius: var(--border-radius);
-        border-bottom-left-radius: var(--border-radius);
-        padding: var(--spacing-s) var(--spacing-l);
-      }
-    }
-    .aboveBelowButtons {
-      display: flex;
-      flex-direction: column;
-      margin-left: var(--spacing-m);
-      position: relative;
-    }
-    .aboveBelowButtons:first-child {
-      margin-left: 0;
-    }
-    .aboveButton {
-      /* Display over preceding content / background placeholder. */
-      transform: translateY(-100%);
-      --gr-button: {
-        color: var(--diff-context-control-color);
-        border-style: solid;
-        border-color: var(--border-color);
-        border-top-width: 1px;
-        border-right-width: 1px;
-        border-bottom-width: 0;
-        border-left-width: 1px;
-        border-top-left-radius: var(--border-radius);
-        border-top-right-radius: var(--border-radius);
-        border-bottom-right-radius: 0;
-        border-bottom-left-radius: 0;
-        padding: var(--spacing-xxs) var(--spacing-l);
-      }
-    }
-    .belowButton {
-      /* Display over following content / background placeholder. */
-      top: calc(100% + var(--divider-border));
-      --gr-button: {
-        color: var(--diff-context-control-color);
-        border-style: solid;
-        border-color: var(--border-color);
-        border-top-width: 0;
-        border-right-width: 1px;
-        border-bottom-width: 1px;
-        border-left-width: 1px;
-        border-top-left-radius: 0;
-        border-top-right-radius: 0;
-        border-bottom-right-radius: var(--border-radius);
-        border-bottom-left-radius: var(--border-radius);
-        padding: var(--spacing-xxs) var(--spacing-l);
-      }
-    }
-
     .displayLine .diff-row.target-row td {
       box-shadow: inset 0 -1px var(--border-color);
     }
diff --git a/polygerrit-ui/app/services/checks/checks-util.ts b/polygerrit-ui/app/services/checks/checks-util.ts
index 15841f3..0a0881b 100644
--- a/polygerrit-ui/app/services/checks/checks-util.ts
+++ b/polygerrit-ui/app/services/checks/checks-util.ts
@@ -79,10 +79,11 @@
   if (hasResultsOf(run, Category.ERROR)) return Category.ERROR;
   if (hasResultsOf(run, Category.WARNING)) return Category.WARNING;
   if (hasResultsOf(run, Category.INFO)) return Category.INFO;
+  if (hasResultsOf(run, Category.SUCCESS)) return Category.SUCCESS;
   return undefined;
 }
 
-export function iconForCategory(category: Category | 'SUCCESS') {
+export function iconForCategory(category: Category) {
   switch (category) {
     case Category.ERROR:
       return 'error';
@@ -90,7 +91,7 @@
       return 'info-outline';
     case Category.WARNING:
       return 'warning';
-    case 'SUCCESS':
+    case Category.SUCCESS:
       return 'check-circle-outline';
     default:
       assertNever(category, `Unsupported category: ${category}`);
@@ -210,12 +211,14 @@
 export function level(cat?: Category) {
   if (!cat) return -1;
   switch (cat) {
-    case Category.INFO:
+    case Category.SUCCESS:
       return 0;
-    case Category.WARNING:
+    case Category.INFO:
       return 1;
-    case Category.ERROR:
+    case Category.WARNING:
       return 2;
+    case Category.ERROR:
+      return 3;
   }
 }
 
diff --git a/polygerrit-ui/app/types/common.ts b/polygerrit-ui/app/types/common.ts
index 65095dd..70cddc0 100644
--- a/polygerrit-ui/app/types/common.ts
+++ b/polygerrit-ui/app/types/common.ts
@@ -638,6 +638,7 @@
   subject: string;
   message: string;
   web_links?: WebLinkInfo[];
+  resolve_conflicts_web_links?: WebLinkInfo[];
 }
 
 export interface CommitInfoWithRequiredCommit extends CommitInfo {