Merge "Add SHRINK_ONLY responsive mode."
diff --git a/Documentation/linux-quickstart.txt b/Documentation/linux-quickstart.txt
index e34071f..c45de05 100644
--- a/Documentation/linux-quickstart.txt
+++ b/Documentation/linux-quickstart.txt
@@ -104,7 +104,7 @@
 Now that you have a simple version of Gerrit running, use the installation to
 explore the user interface and learn about Gerrit. For more detailed
 installation instructions, see
-link:[Standalone Daemon Installation Guide](install.html).
+link:install.html[Standalone Daemon Installation Guide].
 
 GERRIT
 ------
diff --git a/Documentation/repository-maintenance.txt b/Documentation/repository-maintenance.txt
index 1672436..4bf84b5 100644
--- a/Documentation/repository-maintenance.txt
+++ b/Documentation/repository-maintenance.txt
@@ -28,7 +28,7 @@
 
 Unlike a typical server database, access to Git repositories is not
 marshalled through a single process or a set of inter communicating
-processes. Unfortuntatlely the design of the on-disk layout of a Git
+processes. Unfortunately the design of the on-disk layout of a Git
 repository does not allow for 100% race free operations when accessed by
 multiple actors concurrently. These design shortcomings are more likely
 to impact the operations of busy repositories since racy conditions are
diff --git a/java/com/google/gerrit/httpd/restapi/RestApiServlet.java b/java/com/google/gerrit/httpd/restapi/RestApiServlet.java
index 39db61d..7e6ab58 100644
--- a/java/com/google/gerrit/httpd/restapi/RestApiServlet.java
+++ b/java/com/google/gerrit/httpd/restapi/RestApiServlet.java
@@ -46,6 +46,7 @@
 import static javax.servlet.http.HttpServletResponse.SC_NOT_MODIFIED;
 import static javax.servlet.http.HttpServletResponse.SC_OK;
 import static javax.servlet.http.HttpServletResponse.SC_PRECONDITION_FAILED;
+import static javax.servlet.http.HttpServletResponse.SC_REQUEST_TIMEOUT;
 
 import com.google.common.annotations.VisibleForTesting;
 import com.google.common.base.Joiner;
@@ -112,6 +113,7 @@
 import com.google.gerrit.server.RequestListener;
 import com.google.gerrit.server.audit.ExtendedHttpAuditEvent;
 import com.google.gerrit.server.cache.PerThreadCache;
+import com.google.gerrit.server.cancellation.RequestCancelledException;
 import com.google.gerrit.server.change.ChangeFinder;
 import com.google.gerrit.server.change.RevisionResource;
 import com.google.gerrit.server.config.GerritServerConfig;
@@ -225,6 +227,7 @@
   public static final String XD_METHOD = "$m";
   public static final int SC_UNPROCESSABLE_ENTITY = 422;
   public static final int SC_TOO_MANY_REQUESTS = 429;
+  public static final int SC_CLIENT_CLOSED_REQUEST = 499;
 
   private static final int HEAP_EST_SIZE = 10 * 8 * 1024; // Presize 10 blocks.
   private static final String PLAIN_TEXT = "text/plain";
@@ -709,6 +712,25 @@
                 messageOr(e, "Quota limit reached"),
                 e.caching(),
                 e);
+      } catch (RequestCancelledException e) {
+        cause = Optional.of(e);
+        switch (e.getCancellationReason()) {
+          case CLIENT_CLOSED_REQUEST:
+            statusCode = SC_CLIENT_CLOSED_REQUEST;
+            break;
+          case CLIENT_PROVIDED_DEADLINE_EXCEEDED:
+          case SERVER_DEADLINE_EXCEEDED:
+            statusCode = SC_REQUEST_TIMEOUT;
+            break;
+        }
+
+        StringBuilder msg = new StringBuilder(e.formatCancellationReason());
+        if (e.getCancellationMessage().isPresent()) {
+          msg.append("\n\n");
+          msg.append(e.getCancellationMessage().get());
+        }
+
+        responseBytes = replyError(req, res, statusCode, msg.toString(), e);
       } catch (Exception e) {
         cause = Optional.of(e);
         statusCode = SC_INTERNAL_SERVER_ERROR;
diff --git a/java/com/google/gerrit/server/CommentsUtil.java b/java/com/google/gerrit/server/CommentsUtil.java
index 344549e..b18f499 100644
--- a/java/com/google/gerrit/server/CommentsUtil.java
+++ b/java/com/google/gerrit/server/CommentsUtil.java
@@ -438,7 +438,7 @@
       // unignore the test in PortedCommentsIT.
       Map<String, FileDiffOutput> modifiedFiles =
           diffOperations.listModifiedFilesAgainstParent(
-              change.getProject(), patchset.commitId(), /* parentNum= */ null);
+              change.getProject(), patchset.commitId(), /* parentNum= */ 0);
       return modifiedFiles.isEmpty()
           ? null
           : modifiedFiles.values().iterator().next().oldCommitId();
diff --git a/java/com/google/gerrit/server/cancellation/RequestCancelledException.java b/java/com/google/gerrit/server/cancellation/RequestCancelledException.java
new file mode 100644
index 0000000..3c668fb
--- /dev/null
+++ b/java/com/google/gerrit/server/cancellation/RequestCancelledException.java
@@ -0,0 +1,68 @@
+// 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.server.cancellation;
+
+import com.google.gerrit.common.Nullable;
+import java.util.Optional;
+import org.apache.commons.lang.WordUtils;
+
+/** Exception to signal that the current request is cancelled and should be aborted. */
+public class RequestCancelledException extends RuntimeException {
+  private static final long serialVersionUID = 1L;
+
+  private final RequestStateProvider.Reason cancellationReason;
+  private final Optional<String> cancellationMessage;
+
+  /**
+   * Create a {@code RequestCancelledException}.
+   *
+   * @param cancellationReason the reason why the request is cancelled
+   * @param cancellationMessage an optional message providing details about the cancellation
+   */
+  public RequestCancelledException(
+      RequestStateProvider.Reason cancellationReason, @Nullable String cancellationMessage) {
+    super(createMessage(cancellationReason, cancellationMessage));
+    this.cancellationReason = cancellationReason;
+    this.cancellationMessage = Optional.ofNullable(cancellationMessage);
+  }
+
+  private static String createMessage(
+      RequestStateProvider.Reason cancellationReason, @Nullable String message) {
+    StringBuilder messageBuilder = new StringBuilder();
+    messageBuilder.append(String.format("Request cancelled: %s", cancellationReason.name()));
+    if (message != null) {
+      messageBuilder.append(String.format(" (%s)", message));
+    }
+    return messageBuilder.toString();
+  }
+
+  /** Returns the reason why the request is cancelled. */
+  public RequestStateProvider.Reason getCancellationReason() {
+    return cancellationReason;
+  }
+
+  /** Returns the cancellation reason as a user-readable string. */
+  public String formatCancellationReason() {
+    return WordUtils.capitalizeFully(cancellationReason.name().replaceAll("_", " "));
+  }
+
+  /**
+   * Returns a message providing details about the cancellation, or {@link Optional#empty()} if none
+   * is available.
+   */
+  public Optional<String> getCancellationMessage() {
+    return cancellationMessage;
+  }
+}
diff --git a/java/com/google/gerrit/server/cancellation/RequestStateProvider.java b/java/com/google/gerrit/server/cancellation/RequestStateProvider.java
new file mode 100644
index 0000000..e1716eb
--- /dev/null
+++ b/java/com/google/gerrit/server/cancellation/RequestStateProvider.java
@@ -0,0 +1,59 @@
+// 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.server.cancellation;
+
+import com.google.gerrit.common.Nullable;
+
+/** Interface that provides information about the state of the current request. */
+public interface RequestStateProvider {
+  /**
+   * Checks whether the current request is cancelled.
+   *
+   * <p>Invoked by Gerrit to check whether the current request is cancelled and should be aborted.
+   *
+   * <p>If the current request is cancelled {@link OnCancelled#onCancel(Reason, String)} is invoked
+   * on the provided callback.
+   *
+   * @param onCancelled callback that should be invoked if the request is cancelled
+   */
+  void checkIfCancelled(OnCancelled onCancelled);
+
+  /** Callback interface to be invoked if a request is cancelled. */
+  interface OnCancelled {
+    /**
+     * Callback that is invoked if the request is cancelled.
+     *
+     * @param reason the reason for the cancellation of the request
+     * @param message an optional message providing details about the cancellation
+     */
+    void onCancel(Reason reason, @Nullable String message);
+  }
+
+  /** Reason why a request is cancelled. */
+  enum Reason {
+    /** The client got disconnected or has cancelled the request. */
+    CLIENT_CLOSED_REQUEST,
+
+    /** The deadline that the client provided for the request exceeded. */
+    CLIENT_PROVIDED_DEADLINE_EXCEEDED,
+
+    /**
+     * A server-side deadline for the request exceeded.
+     *
+     * <p>Server-side deadlines are usually configurable, but may also be hard-coded.
+     */
+    SERVER_DEADLINE_EXCEEDED;
+  }
+}
diff --git a/java/com/google/gerrit/server/change/FileInfoJson.java b/java/com/google/gerrit/server/change/FileInfoJson.java
index ad6f9c7..ab557dc 100644
--- a/java/com/google/gerrit/server/change/FileInfoJson.java
+++ b/java/com/google/gerrit/server/change/FileInfoJson.java
@@ -46,7 +46,8 @@
    *
    * @param change a Gerrit change.
    * @param objectId a commit SHA-1 identifying a patchset commit.
-   * @param parentNum an integer identifying the parent number used for comparison.
+   * @param parentNum 1-based integer identifying the parent number used for comparison. If zero,
+   *     the only parent will be used or the auto-merge if {@code newCommit} is a merge commit.
    * @return a mapping of the file paths to their related diff information.
    */
   default Map<String, FileInfo> getFileInfoMap(Change change, ObjectId objectId, int parentNum)
@@ -74,7 +75,8 @@
    *
    * @param project a project identifying a repository.
    * @param objectId a commit SHA-1 identifying a patchset commit.
-   * @param parentNum an integer identifying the parent number used for comparison.
+   * @param parentNum 1-based integer identifying the parent number used for comparison. If zero,
+   *     the only parent will be used or the auto-merge if {@code newCommit} is a merge commit.
    * @return a mapping of the file paths to their related diff information.
    */
   Map<String, FileInfo> getFileInfoMap(Project.NameKey project, ObjectId objectId, int parentNum)
diff --git a/java/com/google/gerrit/server/change/FileInfoJsonNewImpl.java b/java/com/google/gerrit/server/change/FileInfoJsonNewImpl.java
index 1ca2c93..7277404 100644
--- a/java/com/google/gerrit/server/change/FileInfoJsonNewImpl.java
+++ b/java/com/google/gerrit/server/change/FileInfoJsonNewImpl.java
@@ -47,8 +47,11 @@
       throws ResourceConflictException, PatchListNotAvailableException {
     try {
       if (base == null) {
+        // Setting parentNum=0 requests the default parent, which is the only parent for
+        // single-parent commits, or the auto-merge otherwise
         return asFileInfo(
-            diffs.listModifiedFilesAgainstParent(change.getProject(), objectId, null));
+            diffs.listModifiedFilesAgainstParent(
+                change.getProject(), objectId, /* parentNum= */ 0));
       }
       return asFileInfo(diffs.listModifiedFiles(change.getProject(), base.commitId(), objectId));
     } catch (DiffNotAvailableException e) {
@@ -63,7 +66,7 @@
       throws ResourceConflictException, PatchListNotAvailableException {
     try {
       Map<String, FileDiffOutput> modifiedFiles =
-          diffs.listModifiedFilesAgainstParent(project, objectId, parent + 1);
+          diffs.listModifiedFilesAgainstParent(project, objectId, parent);
       return asFileInfo(modifiedFiles);
     } catch (DiffNotAvailableException e) {
       convertException(e);
diff --git a/java/com/google/gerrit/server/change/FileInfoJsonOldImpl.java b/java/com/google/gerrit/server/change/FileInfoJsonOldImpl.java
index 55d162a..0570296 100644
--- a/java/com/google/gerrit/server/change/FileInfoJsonOldImpl.java
+++ b/java/com/google/gerrit/server/change/FileInfoJsonOldImpl.java
@@ -60,10 +60,10 @@
       Project.NameKey project, ObjectId objectId, int parentNum)
       throws ResourceConflictException, PatchListNotAvailableException {
     PatchListKey key =
-        parentNum == -1
+        parentNum == 0
             ? PatchListKey.againstDefaultBase(objectId, Whitespace.IGNORE_NONE)
             : PatchListKey.againstParentNum(
-                parentNum + 1, objectId, DiffPreferencesInfo.Whitespace.IGNORE_NONE);
+                parentNum, objectId, DiffPreferencesInfo.Whitespace.IGNORE_NONE);
     return toFileInfoMap(project, key);
   }
 
diff --git a/java/com/google/gerrit/server/events/EventFactory.java b/java/com/google/gerrit/server/events/EventFactory.java
index 3a4dcff..3f988a3 100644
--- a/java/com/google/gerrit/server/events/EventFactory.java
+++ b/java/com/google/gerrit/server/events/EventFactory.java
@@ -399,7 +399,7 @@
     try {
       Map<String, FileDiffOutput> modifiedFiles =
           diffOperations.listModifiedFilesAgainstParent(
-              change.getProject(), patchSet.commitId(), /* parent= */ null);
+              change.getProject(), patchSet.commitId(), /* parent= */ 0);
 
       for (FileDiffOutput diff : modifiedFiles.values()) {
         if (patchSetAttribute.files == null) {
@@ -456,7 +456,7 @@
 
       Map<String, FileDiffOutput> modifiedFiles =
           diffOperations.listModifiedFilesAgainstParent(
-              change.getProject(), patchSet.commitId(), /* parent= */ null);
+              change.getProject(), patchSet.commitId(), /* parent= */ 0);
       for (FileDiffOutput fileDiff : modifiedFiles.values()) {
         p.sizeDeletions += fileDiff.deletions();
         p.sizeInsertions += fileDiff.insertions();
diff --git a/java/com/google/gerrit/server/git/receive/ReceiveCommits.java b/java/com/google/gerrit/server/git/receive/ReceiveCommits.java
index 454df66..d074f1e 100644
--- a/java/com/google/gerrit/server/git/receive/ReceiveCommits.java
+++ b/java/com/google/gerrit/server/git/receive/ReceiveCommits.java
@@ -114,6 +114,7 @@
 import com.google.gerrit.server.RequestListener;
 import com.google.gerrit.server.account.AccountResolver;
 import com.google.gerrit.server.approval.ApprovalsUtil;
+import com.google.gerrit.server.cancellation.RequestCancelledException;
 import com.google.gerrit.server.change.AttentionSetUnchangedOp;
 import com.google.gerrit.server.change.ChangeInserter;
 import com.google.gerrit.server.change.NotifyResolver;
@@ -640,8 +641,17 @@
       Task commandProgress = progress.beginSubTask("refs", UNKNOWN);
       commands =
           commands.stream().map(c -> wrapReceiveCommand(c, commandProgress)).collect(toList());
-      processCommandsUnsafe(commands, progress);
-      rejectRemaining(commands, INTERNAL_SERVER_ERROR);
+
+      try {
+        processCommandsUnsafe(commands, progress);
+        rejectRemaining(commands, INTERNAL_SERVER_ERROR);
+      } catch (RequestCancelledException e) {
+        StringBuilder msg = new StringBuilder(e.formatCancellationReason());
+        if (e.getCancellationMessage().isPresent()) {
+          msg.append(String.format(" (%s)", e.getCancellationMessage().get()));
+        }
+        rejectRemaining(commands, msg.toString());
+      }
 
       // This sends error messages before the 'done' string of the progress monitor is sent.
       // Currently, the test framework relies on this ordering to understand if pushes completed
diff --git a/java/com/google/gerrit/server/patch/DiffOperations.java b/java/com/google/gerrit/server/patch/DiffOperations.java
index 7213581..d2da736 100644
--- a/java/com/google/gerrit/server/patch/DiffOperations.java
+++ b/java/com/google/gerrit/server/patch/DiffOperations.java
@@ -46,8 +46,8 @@
    *
    * @param project a project name representing a git repository.
    * @param newCommit 20 bytes SHA-1 of the new commit used in the diff.
-   * @param parentNum integer specifying which parent to use as base. If null, the only parent will
-   *     be used or the auto-merge if {@code newCommit} is a merge commit.
+   * @param parentNum 1-based integer specifying which parent to use as base. If zero, the only
+   *     parent will be used or the auto-merge if {@code newCommit} is a merge commit.
    * @return map of file paths to the file diffs. The map key is the new file path for all {@link
    *     ChangeType} file diffs except {@link ChangeType#DELETED} entries where the map key contains
    *     the old file path. The map entries are not sorted by key.
@@ -56,8 +56,7 @@
    *     an internal error occurred in Git while evaluating the diff.
    */
   Map<String, FileDiffOutput> listModifiedFilesAgainstParent(
-      Project.NameKey project, ObjectId newCommit, @Nullable Integer parentNum)
-      throws DiffNotAvailableException;
+      Project.NameKey project, ObjectId newCommit, int parentNum) throws DiffNotAvailableException;
 
   /**
    * Returns the list of added, deleted or modified files between two commits (patchsets). The
@@ -85,8 +84,8 @@
    *
    * @param project a project name representing a git repository.
    * @param newCommit 20 bytes SHA-1 of the new commit used in the diff.
-   * @param parentNum integer specifying which parent to use as base. If null, the only parent will
-   *     be used or the auto-merge if {@code newCommit} is a merge commit.
+   * @param parentNum 1-based integer specifying which parent to use as base. If zero, the only
+   *     parent will be used or the auto-merge if {@code newCommit} is a merge commit.
    * @param fileName the file name for which the diff should be evaluated.
    * @param whitespace preference controlling whitespace effect in diff computation.
    * @return the diff for the single file between the two commits.
@@ -96,7 +95,7 @@
   FileDiffOutput getModifiedFileAgainstParent(
       Project.NameKey project,
       ObjectId newCommit,
-      @Nullable Integer parentNum,
+      int parentNum,
       String fileName,
       @Nullable DiffPreferencesInfo.Whitespace whitespace)
       throws DiffNotAvailableException;
diff --git a/java/com/google/gerrit/server/patch/DiffOperationsImpl.java b/java/com/google/gerrit/server/patch/DiffOperationsImpl.java
index f500796..3423b32 100644
--- a/java/com/google/gerrit/server/patch/DiffOperationsImpl.java
+++ b/java/com/google/gerrit/server/patch/DiffOperationsImpl.java
@@ -91,8 +91,7 @@
 
   @Override
   public Map<String, FileDiffOutput> listModifiedFilesAgainstParent(
-      Project.NameKey project, ObjectId newCommit, @Nullable Integer parent)
-      throws DiffNotAvailableException {
+      Project.NameKey project, ObjectId newCommit, int parent) throws DiffNotAvailableException {
     try {
       DiffParameters diffParams = computeDiffParameters(project, newCommit, parent);
       return getModifiedFiles(diffParams);
@@ -120,7 +119,7 @@
   public FileDiffOutput getModifiedFileAgainstParent(
       Project.NameKey project,
       ObjectId newCommit,
-      @Nullable Integer parent,
+      int parent,
       String fileName,
       @Nullable DiffPreferencesInfo.Whitespace whitespace)
       throws DiffNotAvailableException {
@@ -376,7 +375,7 @@
       Project.NameKey project, ObjectId newCommit, Integer parent) throws IOException {
     DiffParameters.Builder result =
         DiffParameters.builder().project(project).newCommit(newCommit).parent(parent);
-    if (parent != null) {
+    if (parent > 0) {
       result.baseCommit(baseCommitUtil.getBaseCommit(project, newCommit, parent));
       result.comparisonType(ComparisonType.againstParent(parent));
       return result.build();
diff --git a/java/com/google/gerrit/server/patch/PatchScriptFactory.java b/java/com/google/gerrit/server/patch/PatchScriptFactory.java
index 0c648b5..fbb6559 100644
--- a/java/com/google/gerrit/server/patch/PatchScriptFactory.java
+++ b/java/com/google/gerrit/server/patch/PatchScriptFactory.java
@@ -172,7 +172,7 @@
 
     this.fileName = fileName;
     this.psa = patchSetA;
-    this.parentNum = -1;
+    this.parentNum = 0;
     this.psb = patchSetB;
     this.diffPrefs = diffPrefs;
     this.currentUser = currentUser;
@@ -223,7 +223,7 @@
     this.runNewDiffCache = cfg.getBoolean("cache", "diff_cache", "runNewDiffCache_GetDiff", false);
 
     changeId = patchSetB.changeId();
-    checkArgument(parentNum >= 0, "parentNum must be >= 0");
+    checkArgument(parentNum > 0, "parentNum must be > 0");
   }
 
   @Override
@@ -326,11 +326,7 @@
     FileDiffOutput fileDiffOutput =
         aId == null
             ? diffOperations.getModifiedFileAgainstParent(
-                notes.getProjectName(),
-                bId,
-                parentNum == -1 ? null : parentNum + 1,
-                fileName,
-                diffPrefs.ignoreWhitespace)
+                notes.getProjectName(), bId, parentNum, fileName, diffPrefs.ignoreWhitespace)
             : diffOperations.getModifiedFile(
                 notes.getProjectName(), aId, bId, fileName, diffPrefs.ignoreWhitespace);
     return newBuilder().toPatchScriptNew(git, fileDiffOutput);
@@ -395,7 +391,7 @@
     if (psa == null) {
       return Optional.empty();
     }
-    checkState(parentNum < 0, "expected no parentNum when psa is present");
+    checkState(parentNum == 0, "expected no parentNum when psa is present");
     checkArgument(psa.get() != 0, "edit not supported for left side");
     return Optional.of(getCommitId(psa));
   }
@@ -409,10 +405,10 @@
   }
 
   private PatchListKey keyFor(ObjectId aId, ObjectId bId, Whitespace whitespace) {
-    if (parentNum < 0) {
+    if (parentNum == 0) {
       return PatchListKey.againstCommit(aId, bId, whitespace);
     }
-    return PatchListKey.againstParentNum(parentNum + 1, bId, whitespace);
+    return PatchListKey.againstParentNum(parentNum, bId, whitespace);
   }
 
   private PatchList listFor(PatchListKey key) throws PatchListNotAvailableException {
diff --git a/java/com/google/gerrit/server/restapi/change/Files.java b/java/com/google/gerrit/server/restapi/change/Files.java
index 1efe378..320e57d 100644
--- a/java/com/google/gerrit/server/restapi/change/Files.java
+++ b/java/com/google/gerrit/server/restapi/change/Files.java
@@ -183,7 +183,7 @@
         r =
             Response.ok(
                 fileInfoJson.getFileInfoMap(
-                    resource.getChange(), resource.getPatchSet().commitId(), parentNum - 1));
+                    resource.getChange(), resource.getPatchSet().commitId(), parentNum));
       } else {
         r = Response.ok(fileInfoJson.getFileInfoMap(resource.getChange(), resource.getPatchSet()));
       }
@@ -280,11 +280,11 @@
 
         Map<String, FileDiffOutput> oldList =
             diffOperations.listModifiedFilesAgainstParent(
-                project, patchSet.commitId(), /* parentNum= */ null);
+                project, patchSet.commitId(), /* parentNum= */ 0);
 
         Map<String, FileDiffOutput> curList =
             diffOperations.listModifiedFilesAgainstParent(
-                project, resource.getPatchSet().commitId(), /* parentNum= */ null);
+                project, resource.getPatchSet().commitId(), /* parentNum= */ 0);
 
         int sz = paths.size();
         List<String> pathList = Lists.newArrayListWithCapacity(sz);
diff --git a/java/com/google/gerrit/server/restapi/change/GetDiff.java b/java/com/google/gerrit/server/restapi/change/GetDiff.java
index 2169d57..dd951a8 100644
--- a/java/com/google/gerrit/server/restapi/change/GetDiff.java
+++ b/java/com/google/gerrit/server/restapi/change/GetDiff.java
@@ -74,6 +74,7 @@
   @Option(name = "--base", metaVar = "REVISION")
   String base;
 
+  /** 1-based index of the parent's position in the commit object. */
   @Option(name = "--parent", metaVar = "parent-number")
   int parentNum;
 
@@ -143,7 +144,7 @@
     } else if (parentNum > 0) {
       psf =
           patchScriptFactoryFactory.create(
-              notes, fileName, parentNum - 1, pId, prefs, currentUser.get());
+              notes, fileName, parentNum, pId, prefs, currentUser.get());
     } else {
       psf = patchScriptFactoryFactory.create(notes, fileName, null, pId, prefs, currentUser.get());
     }
diff --git a/java/com/google/gerrit/server/restapi/project/FilesInCommitCollection.java b/java/com/google/gerrit/server/restapi/project/FilesInCommitCollection.java
index 7bee2f2..6d054bd 100644
--- a/java/com/google/gerrit/server/restapi/project/FilesInCommitCollection.java
+++ b/java/com/google/gerrit/server/restapi/project/FilesInCommitCollection.java
@@ -77,6 +77,10 @@
   }
 
   public static final class ListFiles implements RestReadView<CommitResource> {
+    /**
+     * The 1-based parent number. If zero, the default base commit will be used, which is the only
+     * parent for commits having one parent or the auto-merge commit otherwise.
+     */
     @Option(name = "--parent", metaVar = "parent-number")
     int parentNum;
 
@@ -97,8 +101,7 @@
         throws ResourceConflictException, PatchListNotAvailableException {
       RevCommit commit = resource.getCommit();
       return Response.ok(
-          fileInfoJson.getFileInfoMap(
-              resource.getProjectState().getNameKey(), commit, parentNum - 1));
+          fileInfoJson.getFileInfoMap(resource.getProjectState().getNameKey(), commit, parentNum));
     }
   }
 }
diff --git a/java/com/google/gerrit/sshd/SshCommand.java b/java/com/google/gerrit/sshd/SshCommand.java
index c94b25c..93c6c2c 100644
--- a/java/com/google/gerrit/sshd/SshCommand.java
+++ b/java/com/google/gerrit/sshd/SshCommand.java
@@ -19,6 +19,7 @@
 import com.google.gerrit.server.DynamicOptions;
 import com.google.gerrit.server.RequestInfo;
 import com.google.gerrit.server.RequestListener;
+import com.google.gerrit.server.cancellation.RequestCancelledException;
 import com.google.gerrit.server.config.GerritServerConfig;
 import com.google.gerrit.server.logging.PerformanceLogContext;
 import com.google.gerrit.server.logging.PerformanceLogger;
@@ -61,6 +62,12 @@
                   RequestInfo.builder(RequestInfo.RequestType.SSH, user, traceContext).build();
               requestListeners.runEach(l -> l.onRequest(requestInfo));
               SshCommand.this.run();
+            } catch (RequestCancelledException e) {
+              StringBuilder msg = new StringBuilder(e.formatCancellationReason());
+              if (e.getCancellationMessage().isPresent()) {
+                msg.append(String.format(" (%s)", e.getCancellationMessage().get()));
+              }
+              stderr.println(msg.toString());
             } finally {
               stdout.flush();
               stderr.flush();
diff --git a/javatests/com/google/gerrit/acceptance/api/revision/RevisionDiffIT.java b/javatests/com/google/gerrit/acceptance/api/revision/RevisionDiffIT.java
index d8dab33..a01b340 100644
--- a/javatests/com/google/gerrit/acceptance/api/revision/RevisionDiffIT.java
+++ b/javatests/com/google/gerrit/acceptance/api/revision/RevisionDiffIT.java
@@ -153,7 +153,8 @@
     PushOneCommit.Result result = push.to("refs/heads/master");
 
     Map<String, FileDiffOutput> modifiedFiles =
-        diffOperations.listModifiedFilesAgainstParent(project, result.getCommit(), null);
+        diffOperations.listModifiedFilesAgainstParent(
+            project, result.getCommit(), /* parentNum= */ 0);
 
     assertThat(modifiedFiles.keySet()).containsExactly("/COMMIT_MSG", "f.txt");
     assertThat(
diff --git a/javatests/com/google/gerrit/acceptance/rest/CancellationIT.java b/javatests/com/google/gerrit/acceptance/rest/CancellationIT.java
new file mode 100644
index 0000000..29d54cc
--- /dev/null
+++ b/javatests/com/google/gerrit/acceptance/rest/CancellationIT.java
@@ -0,0 +1,206 @@
+// 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.acceptance.rest;
+
+import static com.google.common.truth.Truth.assertThat;
+import static com.google.gerrit.httpd.restapi.RestApiServlet.SC_CLIENT_CLOSED_REQUEST;
+import static org.apache.http.HttpStatus.SC_REQUEST_TIMEOUT;
+
+import com.google.gerrit.acceptance.AbstractDaemonTest;
+import com.google.gerrit.acceptance.ExtensionRegistry;
+import com.google.gerrit.acceptance.ExtensionRegistry.Registration;
+import com.google.gerrit.acceptance.PushOneCommit;
+import com.google.gerrit.acceptance.RestResponse;
+import com.google.gerrit.server.cancellation.RequestCancelledException;
+import com.google.gerrit.server.cancellation.RequestStateProvider;
+import com.google.gerrit.server.events.CommitReceivedEvent;
+import com.google.gerrit.server.git.validators.CommitValidationException;
+import com.google.gerrit.server.git.validators.CommitValidationListener;
+import com.google.gerrit.server.git.validators.CommitValidationMessage;
+import com.google.gerrit.server.project.CreateProjectArgs;
+import com.google.gerrit.server.validators.ProjectCreationValidationListener;
+import com.google.gerrit.server.validators.ValidationException;
+import com.google.inject.Inject;
+import java.util.List;
+import org.junit.Test;
+
+public class CancellationIT extends AbstractDaemonTest {
+  @Inject private ExtensionRegistry extensionRegistry;
+
+  @Test
+  public void handleClientDisconnected() throws Exception {
+    ProjectCreationValidationListener projectCreationListener =
+        new ProjectCreationValidationListener() {
+          @Override
+          public void validateNewProject(CreateProjectArgs args) throws ValidationException {
+            // Simulate a request cancellation by throwing RequestCancelledException. In contrast to
+            // an actual request cancellation this allows us to verify the HTTP status code that is
+            // set when a request is cancelled.
+            throw new RequestCancelledException(
+                RequestStateProvider.Reason.CLIENT_CLOSED_REQUEST, /* cancellationMessage= */ null);
+          }
+        };
+    try (Registration registration =
+        extensionRegistry.newRegistration().add(projectCreationListener)) {
+      RestResponse response = adminRestSession.put("/projects/" + name("new"));
+      assertThat(response.getStatusCode()).isEqualTo(SC_CLIENT_CLOSED_REQUEST);
+      assertThat(response.getEntityContent()).isEqualTo("Client Closed Request");
+    }
+  }
+
+  @Test
+  public void handleClientDeadlineExceeded() throws Exception {
+    ProjectCreationValidationListener projectCreationListener =
+        new ProjectCreationValidationListener() {
+          @Override
+          public void validateNewProject(CreateProjectArgs args) throws ValidationException {
+            // Simulate an exceeded deadline by throwing RequestCancelledException.
+            throw new RequestCancelledException(
+                RequestStateProvider.Reason.CLIENT_PROVIDED_DEADLINE_EXCEEDED,
+                /* cancellationMessage= */ null);
+          }
+        };
+    try (Registration registration =
+        extensionRegistry.newRegistration().add(projectCreationListener)) {
+      RestResponse response = adminRestSession.put("/projects/" + name("new"));
+      assertThat(response.getStatusCode()).isEqualTo(SC_REQUEST_TIMEOUT);
+      assertThat(response.getEntityContent()).isEqualTo("Client Provided Deadline Exceeded");
+    }
+  }
+
+  @Test
+  public void handleServerDeadlineExceeded() throws Exception {
+    ProjectCreationValidationListener projectCreationListener =
+        new ProjectCreationValidationListener() {
+          @Override
+          public void validateNewProject(CreateProjectArgs args) throws ValidationException {
+            // Simulate an exceeded deadline by throwing RequestCancelledException.
+            throw new RequestCancelledException(
+                RequestStateProvider.Reason.SERVER_DEADLINE_EXCEEDED,
+                /* cancellationMessage= */ null);
+          }
+        };
+    try (Registration registration =
+        extensionRegistry.newRegistration().add(projectCreationListener)) {
+      RestResponse response = adminRestSession.put("/projects/" + name("new"));
+      assertThat(response.getStatusCode()).isEqualTo(SC_REQUEST_TIMEOUT);
+      assertThat(response.getEntityContent()).isEqualTo("Server Deadline Exceeded");
+    }
+  }
+
+  @Test
+  public void handleRequestCancellationWithMessage() throws Exception {
+    ProjectCreationValidationListener projectCreationListener =
+        new ProjectCreationValidationListener() {
+          @Override
+          public void validateNewProject(CreateProjectArgs args) throws ValidationException {
+            // Simulate an exceeded deadline by throwing RequestCancelledException.
+            throw new RequestCancelledException(
+                RequestStateProvider.Reason.SERVER_DEADLINE_EXCEEDED, "deadline = 10m");
+          }
+        };
+    try (Registration registration =
+        extensionRegistry.newRegistration().add(projectCreationListener)) {
+      RestResponse response = adminRestSession.put("/projects/" + name("new"));
+      assertThat(response.getStatusCode()).isEqualTo(SC_REQUEST_TIMEOUT);
+      assertThat(response.getEntityContent())
+          .isEqualTo("Server Deadline Exceeded\n\ndeadline = 10m");
+    }
+  }
+
+  @Test
+  public void handleClientDisconnectedForPush() throws Exception {
+    CommitValidationListener commitValidationListener =
+        new CommitValidationListener() {
+          @Override
+          public List<CommitValidationMessage> onCommitReceived(CommitReceivedEvent receiveEvent)
+              throws CommitValidationException {
+            // Simulate a request cancellation by throwing RequestCancelledException. In contrast to
+            // an actual request cancellation this allows us verify the error message that is sent
+            // to the client.
+            throw new RequestCancelledException(
+                RequestStateProvider.Reason.CLIENT_CLOSED_REQUEST, /* cancellationMessage= */ null);
+          }
+        };
+    try (Registration registration =
+        extensionRegistry.newRegistration().add(commitValidationListener)) {
+      PushOneCommit push = pushFactory.create(admin.newIdent(), testRepo);
+      PushOneCommit.Result r = push.to("refs/heads/master");
+      r.assertErrorStatus("Client Closed Request");
+    }
+  }
+
+  @Test
+  public void handleClientDeadlineExceededForPush() throws Exception {
+    CommitValidationListener commitValidationListener =
+        new CommitValidationListener() {
+          @Override
+          public List<CommitValidationMessage> onCommitReceived(CommitReceivedEvent receiveEvent)
+              throws CommitValidationException {
+            // Simulate an exceeded deadline by throwing RequestCancelledException.
+            throw new RequestCancelledException(
+                RequestStateProvider.Reason.CLIENT_PROVIDED_DEADLINE_EXCEEDED,
+                /* cancellationMessage= */ null);
+          }
+        };
+    try (Registration registration =
+        extensionRegistry.newRegistration().add(commitValidationListener)) {
+      PushOneCommit push = pushFactory.create(admin.newIdent(), testRepo);
+      PushOneCommit.Result r = push.to("refs/heads/master");
+      r.assertErrorStatus("Client Provided Deadline Exceeded");
+    }
+  }
+
+  @Test
+  public void handleServerDeadlineExceededForPush() throws Exception {
+    CommitValidationListener commitValidationListener =
+        new CommitValidationListener() {
+          @Override
+          public List<CommitValidationMessage> onCommitReceived(CommitReceivedEvent receiveEvent)
+              throws CommitValidationException {
+            // Simulate an exceeded deadline by throwing RequestCancelledException.
+            throw new RequestCancelledException(
+                RequestStateProvider.Reason.SERVER_DEADLINE_EXCEEDED,
+                /* cancellationMessage= */ null);
+          }
+        };
+    try (Registration registration =
+        extensionRegistry.newRegistration().add(commitValidationListener)) {
+      PushOneCommit push = pushFactory.create(admin.newIdent(), testRepo);
+      PushOneCommit.Result r = push.to("refs/heads/master");
+      r.assertErrorStatus("Server Deadline Exceeded");
+    }
+  }
+
+  @Test
+  public void handleRequestCancellationWithMessageForPush() throws Exception {
+    CommitValidationListener commitValidationListener =
+        new CommitValidationListener() {
+          @Override
+          public List<CommitValidationMessage> onCommitReceived(CommitReceivedEvent receiveEvent)
+              throws CommitValidationException {
+            // Simulate an exceeded deadline by throwing RequestCancelledException.
+            throw new RequestCancelledException(
+                RequestStateProvider.Reason.SERVER_DEADLINE_EXCEEDED, "deadline = 10m");
+          }
+        };
+    try (Registration registration =
+        extensionRegistry.newRegistration().add(commitValidationListener)) {
+      PushOneCommit push = pushFactory.create(admin.newIdent(), testRepo);
+      PushOneCommit.Result r = push.to("refs/heads/master");
+      r.assertErrorStatus("Server Deadline Exceeded (deadline = 10m)");
+    }
+  }
+}
diff --git a/javatests/com/google/gerrit/acceptance/ssh/SshCancellationIT.java b/javatests/com/google/gerrit/acceptance/ssh/SshCancellationIT.java
new file mode 100644
index 0000000..2cb9637
--- /dev/null
+++ b/javatests/com/google/gerrit/acceptance/ssh/SshCancellationIT.java
@@ -0,0 +1,102 @@
+// 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.acceptance.ssh;
+
+import com.google.gerrit.acceptance.AbstractDaemonTest;
+import com.google.gerrit.acceptance.ExtensionRegistry;
+import com.google.gerrit.acceptance.ExtensionRegistry.Registration;
+import com.google.gerrit.acceptance.UseSsh;
+import com.google.gerrit.server.cancellation.RequestCancelledException;
+import com.google.gerrit.server.cancellation.RequestStateProvider;
+import com.google.gerrit.server.project.CreateProjectArgs;
+import com.google.gerrit.server.validators.ProjectCreationValidationListener;
+import com.google.gerrit.server.validators.ValidationException;
+import com.google.inject.Inject;
+import org.junit.Test;
+
+@UseSsh
+public class SshCancellationIT extends AbstractDaemonTest {
+  @Inject private ExtensionRegistry extensionRegistry;
+
+  @Test
+  public void handleClientDisconnected() throws Exception {
+    ProjectCreationValidationListener projectCreationListener =
+        new ProjectCreationValidationListener() {
+          @Override
+          public void validateNewProject(CreateProjectArgs args) throws ValidationException {
+            throw new RequestCancelledException(
+                RequestStateProvider.Reason.CLIENT_CLOSED_REQUEST, /* cancellationMessage= */ null);
+          }
+        };
+    try (Registration registration =
+        extensionRegistry.newRegistration().add(projectCreationListener)) {
+      adminSshSession.exec("gerrit create-project " + name("new"));
+      adminSshSession.assertFailure("Client Closed Request");
+    }
+  }
+
+  @Test
+  public void handleClientDeadlineExceeded() throws Exception {
+    ProjectCreationValidationListener projectCreationListener =
+        new ProjectCreationValidationListener() {
+          @Override
+          public void validateNewProject(CreateProjectArgs args) throws ValidationException {
+            throw new RequestCancelledException(
+                RequestStateProvider.Reason.CLIENT_PROVIDED_DEADLINE_EXCEEDED,
+                /* cancellationMessage= */ null);
+          }
+        };
+    try (Registration registration =
+        extensionRegistry.newRegistration().add(projectCreationListener)) {
+      adminSshSession.exec("gerrit create-project " + name("new"));
+      adminSshSession.assertFailure("Client Provided Deadline Exceeded");
+    }
+  }
+
+  @Test
+  public void handleServerDeadlineExceeded() throws Exception {
+    ProjectCreationValidationListener projectCreationListener =
+        new ProjectCreationValidationListener() {
+          @Override
+          public void validateNewProject(CreateProjectArgs args) throws ValidationException {
+            throw new RequestCancelledException(
+                RequestStateProvider.Reason.SERVER_DEADLINE_EXCEEDED,
+                /* cancellationMessage= */ null);
+          }
+        };
+    try (Registration registration =
+        extensionRegistry.newRegistration().add(projectCreationListener)) {
+      adminSshSession.exec("gerrit create-project " + name("new"));
+      adminSshSession.assertFailure("Server Deadline Exceeded");
+    }
+  }
+
+  @Test
+  public void handleRequestCancellationWithMessage() throws Exception {
+    ProjectCreationValidationListener projectCreationListener =
+        new ProjectCreationValidationListener() {
+          @Override
+          public void validateNewProject(CreateProjectArgs args) throws ValidationException {
+            throw new RequestCancelledException(
+                RequestStateProvider.Reason.SERVER_DEADLINE_EXCEEDED, "deadline = 10m");
+          }
+        };
+    try (Registration registration =
+        extensionRegistry.newRegistration().add(projectCreationListener)) {
+      adminSshSession.exec("gerrit create-project " + name("new"));
+      adminSshSession.assertFailure("Server Deadline Exceeded (deadline = 10m)");
+    }
+  }
+}
diff --git a/javatests/com/google/gerrit/server/patch/DiffOperationsTest.java b/javatests/com/google/gerrit/server/patch/DiffOperationsTest.java
index 5bf5154..aa313e3 100644
--- a/javatests/com/google/gerrit/server/patch/DiffOperationsTest.java
+++ b/javatests/com/google/gerrit/server/patch/DiffOperationsTest.java
@@ -72,7 +72,7 @@
 
     FileDiffOutput diffOutput =
         diffOperations.getModifiedFileAgainstParent(
-            testProjectName, newCommitId, /* parentNum=*/ null, fileName2, /* whitespace=*/ null);
+            testProjectName, newCommitId, /* parentNum=*/ 0, fileName2, /* whitespace=*/ null);
 
     assertThat(diffOutput.oldCommitId()).isEqualTo(oldCommitId);
     assertThat(diffOutput.newCommitId()).isEqualTo(newCommitId);
diff --git a/polygerrit-ui/app/elements/change/gr-change-actions/gr-change-actions.ts b/polygerrit-ui/app/elements/change/gr-change-actions/gr-change-actions.ts
index a0b1794..fce7db9 100644
--- a/polygerrit-ui/app/elements/change/gr-change-actions/gr-change-actions.ts
+++ b/polygerrit-ui/app/elements/change/gr-change-actions/gr-change-actions.ts
@@ -1486,6 +1486,7 @@
   }
 
   _handleDeleteConfirm() {
+    this._hideAllDialogs();
     this._fireAction(
       '/',
       assertUIActionInfo(this.actions[ChangeActions.DELETE]),
diff --git a/polygerrit-ui/app/elements/change/gr-file-list/gr-file-list.ts b/polygerrit-ui/app/elements/change/gr-file-list/gr-file-list.ts
index 9dcb67e..5206fdb 100644
--- a/polygerrit-ui/app/elements/change/gr-file-list/gr-file-list.ts
+++ b/polygerrit-ui/app/elements/change/gr-file-list/gr-file-list.ts
@@ -952,7 +952,7 @@
         return;
       }
       e.preventDefault();
-      this.fileCursor.next();
+      this.fileCursor.next({circular: true});
       this.selectedIndex = this.fileCursor.index;
     }
   }
@@ -972,7 +972,7 @@
         return;
       }
       e.preventDefault();
-      this.fileCursor.previous();
+      this.fileCursor.previous({circular: true});
       this.selectedIndex = this.fileCursor.index;
     }
   }
diff --git a/polygerrit-ui/app/elements/change/gr-file-list/gr-file-list_test.js b/polygerrit-ui/app/elements/change/gr-file-list/gr-file-list_test.js
index 0655721..79bc9f6 100644
--- a/polygerrit-ui/app/elements/change/gr-file-list/gr-file-list_test.js
+++ b/polygerrit-ui/app/elements/change/gr-file-list/gr-file-list_test.js
@@ -533,7 +533,7 @@
         assert.equal(element.fileCursor.index, 2);
 
         // up should not move the cursor.
-        MockInteractions.pressAndReleaseKeyOn(element, 38, null, 'down');
+        MockInteractions.pressAndReleaseKeyOn(element, 38, null, 'up');
         assert.equal(element.fileCursor.index, 2);
 
         MockInteractions.pressAndReleaseKeyOn(element, 75, null, 'k');
@@ -548,8 +548,8 @@
         MockInteractions.pressAndReleaseKeyOn(element, 75, null, 'k');
         MockInteractions.pressAndReleaseKeyOn(element, 75, null, 'k');
         MockInteractions.pressAndReleaseKeyOn(element, 75, null, 'k');
-        assert.equal(element.fileCursor.index, 0);
-        assert.equal(element.selectedIndex, 0);
+        assert.equal(element.fileCursor.index, 1);
+        assert.equal(element.selectedIndex, 1);
 
         const createCommentInPlaceStub = sinon.stub(element.diffCursor,
             'createCommentInPlace');
diff --git a/polygerrit-ui/app/elements/change/gr-reply-dialog/gr-reply-dialog_html.ts b/polygerrit-ui/app/elements/change/gr-reply-dialog/gr-reply-dialog_html.ts
index bd4cb76..8201dbc 100644
--- a/polygerrit-ui/app/elements/change/gr-reply-dialog/gr-reply-dialog_html.ts
+++ b/polygerrit-ui/app/elements/change/gr-reply-dialog/gr-reply-dialog_html.ts
@@ -487,8 +487,8 @@
                   account="[[account]]"
                   force-attention="[[_computeHasNewAttention(account, _newAttentionSet)]]"
                   selected="[[_computeHasNewAttention(account, _newAttentionSet)]]"
-                  hide-hovercard=""
-                  selection-chip-style
+                  hideHovercard
+                  selectionChipStyle
                   on-click="_handleAttentionClick"
                 ></gr-account-label>
               </template>
@@ -558,8 +558,8 @@
               account="[[_owner]]"
               force-attention="[[_computeHasNewAttention(_owner, _newAttentionSet)]]"
               selected="[[_computeHasNewAttention(_owner, _newAttentionSet)]]"
-              hide-hovercard=""
-              selection-chip-style
+              hideHovercard
+              selectionChipStyle
               on-click="_handleAttentionClick"
             >
             </gr-account-label>
@@ -573,8 +573,8 @@
                 account="[[_uploader]]"
                 force-attention="[[_computeHasNewAttention(_uploader, _newAttentionSet)]]"
                 selected="[[_computeHasNewAttention(_uploader, _newAttentionSet)]]"
-                hide-hovercard=""
-                selection-chip-style
+                hideHovercard
+                selectionChipStyle
                 on-click="_handleAttentionClick"
               >
               </gr-account-label>
@@ -593,8 +593,8 @@
                 account="[[account]]"
                 force-attention="[[_computeHasNewAttention(account, _newAttentionSet)]]"
                 selected="[[_computeHasNewAttention(account, _newAttentionSet)]]"
-                hide-hovercard=""
-                selection-chip-style
+                hideHovercard
+                selectionChipStyle
                 on-click="_handleAttentionClick"
               >
               </gr-account-label>
@@ -614,8 +614,8 @@
                   account="[[account]]"
                   force-attention="[[_computeHasNewAttention(account, _newAttentionSet)]]"
                   selected="[[_computeHasNewAttention(account, _newAttentionSet)]]"
-                  hide-hovercard=""
-                  selection-chip-style
+                  hideHovercard
+                  selectionChipStyle
                   on-click="_handleAttentionClick"
                 >
                 </gr-account-label>
diff --git a/polygerrit-ui/app/elements/change/gr-thread-list/gr-thread-list_html.ts b/polygerrit-ui/app/elements/change/gr-thread-list/gr-thread-list_html.ts
index 73f3dd3..93a432b 100644
--- a/polygerrit-ui/app/elements/change/gr-thread-list/gr-thread-list_html.ts
+++ b/polygerrit-ui/app/elements/change/gr-thread-list/gr-thread-list_html.ts
@@ -109,7 +109,7 @@
           <gr-account-label
             account="[[item]]"
             on-click="handleAccountClicked"
-            selection-chip-style
+            selectionChipStyle
             selected="[[isSelected(item, selectedAuthors)]]"
           > </gr-account-label>
         </template>
diff --git a/polygerrit-ui/app/elements/shared/gr-account-label/gr-account-label.ts b/polygerrit-ui/app/elements/shared/gr-account-label/gr-account-label.ts
index cc66734..66214b4 100644
--- a/polygerrit-ui/app/elements/shared/gr-account-label/gr-account-label.ts
+++ b/polygerrit-ui/app/elements/shared/gr-account-label/gr-account-label.ts
@@ -15,30 +15,25 @@
  * limitations under the License.
  */
 import '@polymer/iron-icon/iron-icon';
-import '../../../styles/shared-styles';
 import '../gr-avatar/gr-avatar';
 import '../gr-hovercard-account/gr-hovercard-account';
-import {PolymerElement} from '@polymer/polymer/polymer-element';
-import {htmlTemplate} from './gr-account-label_html';
 import {appContext} from '../../../services/app-context';
 import {getDisplayName} from '../../../utils/display-name-util';
 import {isSelf, isServiceUser} from '../../../utils/account-util';
-import {customElement, property} from '@polymer/decorators';
 import {ReportingService} from '../../../services/gr-reporting/gr-reporting';
 import {ChangeInfo, AccountInfo, ServerInfo} from '../../../types/common';
 import {hasOwnProperty} from '../../../utils/common-util';
 import {fireEvent} from '../../../utils/event-util';
 import {isInvolved} from '../../../utils/change-util';
 import {ShowAlertEventDetail} from '../../../types/events';
+import {GrLitElement} from '../../lit/gr-lit-element';
+import {css, customElement, html, property, state} from 'lit-element';
+import {classMap} from 'lit-html/directives/class-map';
 
 @customElement('gr-account-label')
-export class GrAccountLabel extends PolymerElement {
-  static get template() {
-    return htmlTemplate;
-  }
-
+export class GrAccountLabel extends GrLitElement {
   @property({type: Object})
-  account!: AccountInfo;
+  account?: AccountInfo;
 
   @property({type: Object})
   _selfAccount?: AccountInfo;
@@ -49,7 +44,7 @@
    * related features like adding the user as a reviewer.
    */
   @property({type: Object})
-  change!: ChangeInfo;
+  change?: ChangeInfo;
 
   @property({type: String})
   voteableText?: string;
@@ -83,44 +78,190 @@
 
   @property({
     type: Boolean,
-    reflectToAttribute: true,
-    computed:
-      '_computeCancelLeftPadding(hideAvatar, ' +
-      'highlightAttention, account, change, forceAttention)',
+    reflect: true,
   })
   cancelLeftPadding = false;
 
   @property({type: Boolean})
   hideStatus = false;
 
-  @property({type: Object})
+  @state()
   _config?: ServerInfo;
 
-  @property({type: Boolean, reflectToAttribute: true})
+  @property({type: Boolean, reflect: true})
   selectionChipStyle = false;
 
   @property({
     type: Boolean,
-    reflectToAttribute: true,
-    observer: 'selectedChanged',
+    reflect: true,
   })
   selected = false;
 
-  @property({type: Boolean, reflectToAttribute: true})
+  @property({type: Boolean, reflect: true})
   deselected = false;
 
   reporting: ReportingService;
 
   private readonly restApiService = appContext.restApiService;
 
+  static get styles() {
+    return [
+      css`
+        :host {
+          display: inline-block;
+          vertical-align: top;
+          position: relative;
+          border-radius: var(--label-border-radius);
+          box-sizing: border-box;
+          white-space: nowrap;
+          padding: 0 var(--account-label-padding-horizontal, 0);
+        }
+        /* If the first element is the avatar, then we cancel the left padding,
+        so we can fit nicely into the gr-account-chip rounding. The obvious
+        alternative of 'chip has padding' and 'avatar gets negative margin'
+        does not work, because we need 'overflow:hidden' on the label. */
+        :host([cancelLeftPadding]) {
+          padding-left: 0;
+        }
+        :host::after {
+          content: var(--account-label-suffix);
+        }
+        :host([deselected][selectionChipStyle]) {
+          background-color: var(--background-color-primary);
+          border: 1px solid var(--comment-separator-color);
+          border-radius: 8px;
+          color: var(--deemphasized-text-color);
+        }
+        :host([selected][selectionChipStyle]) {
+          background-color: var(--chip-selected-background-color);
+          border: 1px solid var(--chip-selected-background-color);
+          border-radius: 8px;
+          color: var(--chip-selected-text-color);
+        }
+        :host([selected]) iron-icon.attention {
+          color: var(--chip-selected-text-color);
+        }
+        gr-avatar {
+          height: calc(var(--line-height-normal) - 2px);
+          width: calc(var(--line-height-normal) - 2px);
+          vertical-align: top;
+          position: relative;
+          top: 1px;
+        }
+        #attentionButton {
+          /* This negates the 4px horizontal padding, which we appreciate as a
+         larger click target, but which we don't want to consume space. :-) */
+          margin: 0 -4px 0 -4px;
+          vertical-align: top;
+        }
+        iron-icon.attention {
+          color: var(--deemphasized-text-color);
+          width: 12px;
+          height: 12px;
+          vertical-align: top;
+        }
+        iron-icon.status {
+          color: var(--deemphasized-text-color);
+          width: 14px;
+          height: 14px;
+          vertical-align: top;
+          position: relative;
+          top: 2px;
+        }
+        .name {
+          display: inline-block;
+          text-decoration: inherit;
+          vertical-align: top;
+          overflow: hidden;
+          text-overflow: ellipsis;
+          max-width: var(--account-max-length, 180px);
+        }
+        .hasAttention .name {
+          font-weight: var(--font-weight-bold);
+        }
+      `,
+    ];
+  }
+
+  render() {
+    const {account, change, highlightAttention, forceAttention} = this;
+    if (!account) return;
+    const hasAttention =
+      forceAttention ||
+      this._hasUnforcedAttention(highlightAttention, account, change);
+    this.deselected = !this.selected;
+    this.cancelLeftPadding = !this.hideAvatar && !hasAttention;
+    return html`<span>
+        ${!this.hideHovercard
+          ? html`<gr-hovercard-account
+              for="hovercardTarget"
+              .account="${account}"
+              .change="${change}"
+              ?highlight-attention=${highlightAttention}
+              .voteable-text=${this.voteableText}
+            ></gr-hovercard-account>`
+          : ''}
+        ${hasAttention
+          ? html`<gr-button
+              id="attentionButton"
+              link=""
+              aria-label="Remove user from attention set"
+              @click=${this._handleRemoveAttentionClick}
+              ?disabled=${!this._computeAttentionButtonEnabled(
+                highlightAttention,
+                account,
+                change,
+                this.selected,
+                this._selfAccount
+              )}
+              ?has-tooltip=${this._computeAttentionButtonEnabled(
+                highlightAttention,
+                account,
+                change,
+                false,
+                this._selfAccount
+              )}
+              title="${this._computeAttentionIconTitle(
+                highlightAttention,
+                account,
+                change,
+                forceAttention,
+                this.selected,
+                this._selfAccount
+              )}"
+              ><iron-icon
+                class="attention"
+                icon="gr-icons:attention"
+              ></iron-icon>
+            </gr-button>`
+          : ''}
+      </span>
+      <span
+        id="hovercardTarget"
+        class="${classMap({
+          hasAttention: !!hasAttention,
+        })}"
+      >
+        ${!this.hideAvatar
+          ? html`<gr-avatar .account="${account}" imageSize="32"></gr-avatar>`
+          : ''}
+        <span class="text" part="gr-account-label-text">
+          <span class="name"
+            >${this._computeName(account, this.firstName, this._config)}</span
+          >
+          ${!this.hideStatus && account.status
+            ? html`<iron-icon
+                class="status"
+                icon="gr-icons:calendar"
+              ></iron-icon>`
+            : ''}
+        </span>
+      </span>`;
+  }
+
   constructor() {
     super();
     this.reporting = appContext.reportingService;
-  }
-
-  /** @override */
-  ready() {
-    super.ready();
     this.restApiService.getConfig().then(config => {
       this._config = config;
     });
@@ -129,72 +270,42 @@
     });
     this.addEventListener('attention-set-updated', () => {
       // For re-evaluation of everything that depends on 'change'.
-      this.change = {...this.change};
+      if (this.change) this.change = {...this.change};
     });
   }
 
-  selectedChanged(selected?: boolean) {
-    this.deselected = !selected;
-  }
-
   _isAttentionSetEnabled(
     highlight: boolean,
     account: AccountInfo,
-    change: ChangeInfo
+    change?: ChangeInfo
   ) {
     return highlight && !!change && !!account && !isServiceUser(account);
   }
 
-  _computeCancelLeftPadding(
-    hideAvatar: boolean,
-    highlight: boolean,
-    account: AccountInfo,
-    change: ChangeInfo,
-    force: boolean
-  ) {
-    return (
-      !hideAvatar && !this._hasAttention(highlight, account, change, force)
-    );
-  }
-
-  _hasAttention(
-    highlight: boolean,
-    account: AccountInfo,
-    change: ChangeInfo,
-    force: boolean
-  ) {
-    return force || this._hasUnforcedAttention(highlight, account, change);
-  }
-
   _hasUnforcedAttention(
     highlight: boolean,
     account: AccountInfo,
-    change: ChangeInfo
+    change?: ChangeInfo
   ) {
     return (
       this._isAttentionSetEnabled(highlight, account, change) &&
+      change &&
       change.attention_set &&
       !!account._account_id &&
       hasOwnProperty(change.attention_set, account._account_id)
     );
   }
 
-  _computeHasAttentionClass(
-    highlight: boolean,
-    account: AccountInfo,
-    change: ChangeInfo,
-    force: boolean
+  _computeName(
+    account?: AccountInfo,
+    firstName?: boolean,
+    config?: ServerInfo
   ) {
-    return this._hasAttention(highlight, account, change, force)
-      ? 'hasAttention'
-      : '';
-  }
-
-  _computeName(account: AccountInfo, firstName: boolean, config?: ServerInfo) {
     return getDisplayName(config, account, firstName);
   }
 
   _handleRemoveAttentionClick(e: MouseEvent) {
+    if (!this.account || !this.change) return;
     if (this.selected) return;
     e.preventDefault();
     e.stopPropagation();
@@ -236,6 +347,7 @@
   }
 
   _reportingDetails() {
+    if (!this.account) return;
     const targetId = this.account._account_id;
     const ownerId =
       (this.change && this.change.owner && this.change.owner._account_id) || -1;
@@ -259,7 +371,7 @@
   _computeAttentionButtonEnabled(
     highlight: boolean,
     account: AccountInfo,
-    change: ChangeInfo,
+    change: ChangeInfo | undefined,
     selected: boolean,
     selfAccount?: AccountInfo
   ) {
@@ -273,7 +385,7 @@
   _computeAttentionIconTitle(
     highlight: boolean,
     account: AccountInfo,
-    change: ChangeInfo,
+    change: ChangeInfo | undefined,
     force: boolean,
     selected: boolean,
     selfAccount?: AccountInfo
diff --git a/polygerrit-ui/app/elements/shared/gr-account-label/gr-account-label_html.ts b/polygerrit-ui/app/elements/shared/gr-account-label/gr-account-label_html.ts
deleted file mode 100644
index 352763b..0000000
--- a/polygerrit-ui/app/elements/shared/gr-account-label/gr-account-label_html.ts
+++ /dev/null
@@ -1,137 +0,0 @@
-/**
- * @license
- * Copyright (C) 2020 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 {html} from '@polymer/polymer/lib/utils/html-tag';
-
-export const htmlTemplate = html`
-  <style>
-    :host {
-      display: inline-block;
-      vertical-align: top;
-      position: relative;
-      border-radius: var(--label-border-radius);
-      box-sizing: border-box;
-      white-space: nowrap;
-      padding: 0 var(--account-label-padding-horizontal, 0);
-    }
-    /* If the first element is the avatar, then we cancel the left padding, so
-       we can fit nicely into the gr-account-chip rounding.
-       The obvious alternative of 'chip has padding' and 'avatar gets negative
-       margin' does not work, because we need 'overflow:hidden' on the label. */
-    :host([cancel-left-padding]) {
-      padding-left: 0;
-    }
-    :host::after {
-      content: var(--account-label-suffix);
-    }
-    :host([deselected][selection-chip-style]) {
-      background-color: var(--background-color-primary);
-      border: 1px solid var(--comment-separator-color);
-      border-radius: 8px;
-      color: var(--deemphasized-text-color);
-    }
-    :host([selected][selection-chip-style]) {
-      background-color: var(--chip-selected-background-color);
-      border: 1px solid var(--chip-selected-background-color);
-      border-radius: 8px;
-      color: var(--chip-selected-text-color);
-    }
-    :host([selected]) iron-icon.attention {
-      color: var(--chip-selected-text-color);
-    }
-    gr-avatar {
-      height: calc(var(--line-height-normal) - 2px);
-      width: calc(var(--line-height-normal) - 2px);
-      vertical-align: top;
-      position: relative;
-      top: 1px;
-    }
-    #attentionButton {
-      /* This negates the 4px horizontal padding, which we appreciate as a
-         larger click target, but which we don't want to consume space. :-) */
-      margin: 0 -4px 0 -4px;
-      vertical-align: top;
-    }
-    iron-icon.attention {
-      color: var(--deemphasized-text-color);
-      width: 12px;
-      height: 12px;
-      vertical-align: top;
-    }
-    iron-icon.status {
-      color: var(--deemphasized-text-color);
-      width: 14px;
-      height: 14px;
-      vertical-align: top;
-      position: relative;
-      top: 2px;
-    }
-    .name {
-      display: inline-block;
-      text-decoration: inherit;
-      vertical-align: top;
-      overflow: hidden;
-      text-overflow: ellipsis;
-      max-width: var(--account-max-length, 180px);
-    }
-    .hasAttention .name {
-      font-weight: var(--font-weight-bold);
-    }
-  </style>
-  <span>
-    <template is="dom-if" if="[[!hideHovercard]]">
-      <gr-hovercard-account
-        for="hovercardTarget"
-        account="[[account]]"
-        change="[[change]]"
-        highlight-attention="[[highlightAttention]]"
-        voteable-text="[[voteableText]]"
-      >
-      </gr-hovercard-account>
-    </template>
-    <template
-      is="dom-if"
-      if="[[_hasAttention(highlightAttention, account, change, forceAttention)]]"
-    >
-      <gr-button
-        id="attentionButton"
-        link=""
-        aria-label="Remove user from attention set"
-        on-click="_handleRemoveAttentionClick"
-        disabled="[[!_computeAttentionButtonEnabled(highlightAttention, account, change, selected, _selfAccount)]]"
-        has-tooltip="[[_computeAttentionButtonEnabled(highlightAttention, account, change, false, _selfAccount)]]"
-        title="[[_computeAttentionIconTitle(highlightAttention, account, change, forceAttention, selected, _selfAccount)]]"
-        ><iron-icon class="attention" icon="gr-icons:attention"></iron-icon>
-      </gr-button>
-    </template>
-  </span>
-  <span
-    id="hovercardTarget"
-    class$="[[_computeHasAttentionClass(highlightAttention, account, change, forceAttention)]]"
-  >
-    <template is="dom-if" if="[[!hideAvatar]]">
-      <gr-avatar account="[[account]]" imageSize="32"></gr-avatar>
-    </template>
-    <span class="text" part="gr-account-label-text">
-      <span class="name">[[_computeName(account, firstName, _config)]]</span>
-      <template is="dom-if" if="[[!hideStatus]]">
-        <template is="dom-if" if="[[account.status]]">
-          <iron-icon class="status" icon="gr-icons:calendar"></iron-icon>
-        </template>
-      </template>
-    </span>
-  </span>
-`;
diff --git a/polygerrit-ui/app/elements/shared/gr-account-link/gr-account-link.ts b/polygerrit-ui/app/elements/shared/gr-account-link/gr-account-link.ts
index 317806c..a610ffa 100644
--- a/polygerrit-ui/app/elements/shared/gr-account-link/gr-account-link.ts
+++ b/polygerrit-ui/app/elements/shared/gr-account-link/gr-account-link.ts
@@ -89,12 +89,12 @@
         <gr-account-label
           .account="${this.account}"
           .change="${this.change}"
-          ?force-attention=${this.forceAttention}
-          ?highlight-attention=${this.highlightAttention}
-          ?hide-avatar=${this.hideAvatar}
-          ?hide-status=${this.hideStatus}
-          ?first-name=${this.firstName}
-          .voteable-text=${this.voteableText}
+          ?forceAttention=${this.forceAttention}
+          ?highlightAttention=${this.highlightAttention}
+          ?hideAvatar=${this.hideAvatar}
+          ?hideStatus=${this.hideStatus}
+          ?firstName=${this.firstName}
+          .voteableText=${this.voteableText}
           part="gr-account-link-text => gr-account-label-text"
         >
         </gr-account-label>
diff --git a/polygerrit-ui/app/elements/shared/gr-comment/gr-comment_html.ts b/polygerrit-ui/app/elements/shared/gr-comment/gr-comment_html.ts
index a848b2f..3e0b9a4 100644
--- a/polygerrit-ui/app/elements/shared/gr-comment/gr-comment_html.ts
+++ b/polygerrit-ui/app/elements/shared/gr-comment/gr-comment_html.ts
@@ -262,7 +262,7 @@
           <gr-account-label
             account="[[_getAuthor(comment, _selfAccount)]]"
             class$="[[_computeAccountLabelClass(draft)]]"
-            hide-status=""
+            hideStatus
           >
           </gr-account-label>
         </template>
diff --git a/polygerrit-ui/app/elements/shared/gr-comment/gr-comment_test.ts b/polygerrit-ui/app/elements/shared/gr-comment/gr-comment_test.ts
index bbf3442..a218959 100644
--- a/polygerrit-ui/app/elements/shared/gr-comment/gr-comment_test.ts
+++ b/polygerrit-ui/app/elements/shared/gr-comment/gr-comment_test.ts
@@ -367,23 +367,20 @@
         getTimerStub = stubReporting('getTimer').returns(mockTimer);
       });
 
-      test('create', () => {
+      test('create', async () => {
         element.patchNum = 1 as PatchSetNum;
         element.comment = {};
-        return element._handleSave(mockEvent)!.then(() => {
-          assert.equal(
-            (queryAndAssert(
-              element,
-              'gr-account-label'
-            ).shadowRoot?.querySelector(
-              'span.name'
-            ) as HTMLSpanElement).innerText.trim(),
-            'Dhruv Srivastava'
-          );
-          assert.isTrue(endStub.calledOnce);
-          assert.isTrue(getTimerStub.calledOnce);
-          assert.equal(getTimerStub.lastCall.args[0], 'CreateDraftComment');
-        });
+        await element._handleSave(mockEvent);
+        await flush();
+        const grAccountLabel = queryAndAssert(element, 'gr-account-label');
+        const spanName = queryAndAssert<HTMLSpanElement>(
+          grAccountLabel,
+          'span.name'
+        );
+        assert.equal(spanName.innerText.trim(), 'Dhruv Srivastava');
+        assert.isTrue(endStub.calledOnce);
+        assert.isTrue(getTimerStub.calledOnce);
+        assert.equal(getTimerStub.lastCall.args[0], 'CreateDraftComment');
       });
 
       test('update', () => {
diff --git a/polygerrit-ui/app/elements/shared/gr-cursor-manager/gr-cursor-manager.ts b/polygerrit-ui/app/elements/shared/gr-cursor-manager/gr-cursor-manager.ts
index 017ba50..9dce127 100644
--- a/polygerrit-ui/app/elements/shared/gr-cursor-manager/gr-cursor-manager.ts
+++ b/polygerrit-ui/app/elements/shared/gr-cursor-manager/gr-cursor-manager.ts
@@ -87,7 +87,7 @@
   }
 
   /**
-   * Move the cursor forward. Clipped to the ends of the stop list.
+   * Move the cursor forward. Clipped to the end of the stop list.
    *
    * @param options.filter Skips any stops for which filter returns false.
    * @param options.getTargetHeight Optional function to calculate the
@@ -95,22 +95,36 @@
    *    sometimes different, used by the diff cursor.
    * @param options.clipToTop When none of the next indices match, move
    *     back to first instead of to last.
+   * @param options.circular When on last element, you get to first element.
    * @return If a move was performed or why not.
-   * @private
    */
   next(
     options: {
       filter?: (stop: HTMLElement) => boolean;
       getTargetHeight?: (target: HTMLElement) => number;
       clipToTop?: boolean;
+      circular?: boolean;
     } = {}
   ): CursorMoveResult {
     return this._moveCursor(1, options);
   }
 
+  /**
+   * Move the cursor backward. Clipped to the beginning of stop list.
+   *
+   * @param options.filter Skips any stops for which filter returns false.
+   * @param options.getTargetHeight Optional function to calculate the
+   *    height of the target's 'section'. The height of the target itself is
+   *    sometimes different, used by the diff cursor.
+   * @param options.clipToTop When none of the next indices match, move
+   * back to first instead of to last.
+   * @param options.circular When on first element, you get to last element.
+   * @return  If a move was performed or why not.
+   */
   previous(
     options: {
       filter?: (stop: HTMLElement) => boolean;
+      circular?: boolean;
     } = {}
   ): CursorMoveResult {
     return this._moveCursor(-1, options);
@@ -276,34 +290,18 @@
     }
   }
 
-  /**
-   * Move the cursor forward or backward by delta. Clipped to the beginning or
-   * end of stop list.
-   *
-   * @param delta either -1 or 1.
-   * @param options.abort Will abort moving the cursor when encountering a
-   *    stop for which this condition is met. Will abort even if the stop
-   *    would have been filtered
-   * @param options.filter Will keep going and skip any stops for which this
-   *    condition is not met.
-   * @param options.getTargetHeight Optional function to calculate the
-   * height of the target's 'section'. The height of the target itself is
-   * sometimes different, used by the diff cursor.
-   * @param options.clipToTop When none of the next indices match, move
-   * back to first instead of to last.
-   * @return  If a move was performed or why not.
-   * @private
-   */
   _moveCursor(
     delta: number,
     {
       filter,
       getTargetHeight,
       clipToTop,
+      circular,
     }: {
       filter?: (stop: HTMLElement) => boolean;
       getTargetHeight?: (target: HTMLElement) => number;
       clipToTop?: boolean;
+      circular?: boolean;
     } = {}
   ): CursorMoveResult {
     if (!this.stops.length) {
@@ -326,7 +324,10 @@
         (delta > 0 && newIndex >= this.stops.length) ||
         (delta < 0 && newIndex < 0)
       ) {
-        newIndex = delta < 0 || clipToTop ? 0 : this.stops.length - 1;
+        newIndex =
+          (delta < 0 && !circular) || (delta > 0 && circular) || clipToTop
+            ? 0
+            : this.stops.length - 1;
         newStop = this.stops[newIndex];
         clipped = true;
         break;
diff --git a/polygerrit-ui/app/elements/shared/gr-cursor-manager/gr-cursor-manager_test.js b/polygerrit-ui/app/elements/shared/gr-cursor-manager/gr-cursor-manager_test.js
index ba7e4f8..d0bd420 100644
--- a/polygerrit-ui/app/elements/shared/gr-cursor-manager/gr-cursor-manager_test.js
+++ b/polygerrit-ui/app/elements/shared/gr-cursor-manager/gr-cursor-manager_test.js
@@ -255,6 +255,25 @@
     assert.isTrue(cursor.target.focus.called);
   });
 
+  suite('circular options', () => {
+    const options = {circular: true};
+    setup(() => {
+      cursor.stops = [...list.querySelectorAll('li')];
+    });
+
+    test('previous() on first element goes to last element', () => {
+      cursor.setCursor(list.children[0]);
+      cursor.previous(options);
+      assert.equal(cursor.index, list.children.length - 1);
+    });
+
+    test('next() on last element goes to first element', () => {
+      cursor.setCursor(list.children[list.children.length - 1]);
+      cursor.next(options);
+      assert.equal(cursor.index, 0);
+    });
+  });
+
   suite('_scrollToTarget', () => {
     let scrollStub;
     setup(() => {
diff --git a/polygerrit-ui/app/mixins/keyboard-shortcut-mixin/keyboard-shortcut-mixin.ts b/polygerrit-ui/app/mixins/keyboard-shortcut-mixin/keyboard-shortcut-mixin.ts
index 3d5a208..5f53819 100644
--- a/polygerrit-ui/app/mixins/keyboard-shortcut-mixin/keyboard-shortcut-mixin.ts
+++ b/polygerrit-ui/app/mixins/keyboard-shortcut-mixin/keyboard-shortcut-mixin.ts
@@ -815,8 +815,11 @@
           (tagName === 'INPUT' && type !== 'checkbox') ||
           tagName === 'TEXTAREA' ||
           // Suppress shortcuts if the key is 'enter'
-          // and target is an anchor or button.
-          (e.keyCode === 13 && (tagName === 'A' || tagName === 'BUTTON'))
+          // and target is an anchor or button or paper-tab.
+          (e.keyCode === 13 &&
+            (tagName === 'A' ||
+              tagName === 'BUTTON' ||
+              tagName === 'PAPER-TAB'))
         ) {
           return true;
         }
diff --git a/polygerrit-ui/app/styles/shared-styles.ts b/polygerrit-ui/app/styles/shared-styles.ts
index 287cf68..a8274cc 100644
--- a/polygerrit-ui/app/styles/shared-styles.ts
+++ b/polygerrit-ui/app/styles/shared-styles.ts
@@ -239,9 +239,13 @@
       font-family: var(--header-font-family);
       -webkit-font-smoothing: initial;
     }
+    --paper-tab-content: {
+      margin-bottom: var(--spacing-s);
+    }
     --paper-tab-content-focused: {
       /* paper-tabs uses 700 here, which can look awkward */
       font-weight: var(--font-weight-h3);
+      background: var(--gray-background-focus);
     }
     --paper-tab-content-unselected: {
       /* paper-tabs uses 0.8 here, but we want to control the color directly */
@@ -249,6 +253,10 @@
       color: var(--deemphasized-text-color);
     }
   }
+  paper-tab:focus {
+    padding-left: 0px;
+    padding-right: 0px;
+  }
   iron-autogrow-textarea {
     /** This is needed for firefox */
     --iron-autogrow-textarea_-_white-space: pre-wrap;
diff --git a/polygerrit-ui/app/styles/themes/app-theme.ts b/polygerrit-ui/app/styles/themes/app-theme.ts
index 1996800..134003b 100644
--- a/polygerrit-ui/app/styles/themes/app-theme.ts
+++ b/polygerrit-ui/app/styles/themes/app-theme.ts
@@ -128,20 +128,20 @@
 
     --error-foreground: var(--red-700);
     --error-background: var(--red-50);
-    --error-background-hover: linear-gradient(var(--red-700-04), var(--red-700-04)), var(--red-50);
-    --error-background-focus: linear-gradient(var(--red-700-12), var(--red-700-12)), var(--red-50);
+    --error-background-hover: linear-gradient(var(--red-700-04), var(--red-700-04), var(--red-50));
+    --error-background-focus: linear-gradient(var(--red-700-12), var(--red-700-12), var(--red-50));
     --error-ripple: var(--red-700-10);
 
     --warning-foreground: var(--orange-700);
     --warning-background: var(--orange-50);
-    --warning-background-hover: linear-gradient(var(--orange-700-04), var(--orange-700-04)), var(--orange-50);
-    --warning-background-focus: linear-gradient(var(--orange-700-12), var(--orange-700-12)), var(--orange-50);
+    --warning-background-hover: linear-gradient(var(--orange-700-04), var(--orange-700-04), var(--orange-50));
+    --warning-background-focus: linear-gradient(var(--orange-700-12), var(--orange-700-12), var(--orange-50));
     --warning-ripple: var(--orange-700-10);
 
     --info-foreground: var(--blue-700);
     --info-background: var(--blue-50);
-    --info-background-hover: linear-gradient(var(--blue-700-04), var(--blue-700-04)), var(--blue-50);
-    --info-background-focus: linear-gradient(var(--blue-700-12), var(--blue-700-12)), var(--blue-50);
+    --info-background-hover: linear-gradient(var(--blue-700-04), var(--blue-700-04), var(--blue-50));
+    --info-background-focus: linear-gradient(var(--blue-700-12), var(--blue-700-12), var(--blue-50));
     --info-ripple: var(--blue-700-10);
 
     --primary-button-text-color: white;
@@ -154,14 +154,14 @@
 
     --success-foreground: var(--green-700);
     --success-background: var(--green-50);
-    --success-background-hover: linear-gradient(var(--green-700-04), var(--green-700-04)), var(--green-50);
-    --success-background-focus: linear-gradient(var(--green-700-12), var(--green-700-12)), var(--green-50);
+    --success-background-hover: linear-gradient(var(--green-700-04), var(--green-700-04), var(--green-50));
+    --success-background-focus: linear-gradient(var(--green-700-12), var(--green-700-12), var(--green-50));
     --success-ripple: var(--green-700-10);
 
     --gray-foreground: var(--gray-700);
     --gray-background: var(--gray-100);
-    --gray-background-hover: linear-gradient(var(--gray-700-04), var(--gray-700-04)), var(--gray-100);
-    --gray-background-focus: linear-gradient(var(--gray-700-12), var(--gray-700-12)), var(--gray-100);
+    --gray-background-hover: linear-gradient(var(--gray-700-04), var(--gray-700-04), var(--gray-100));
+    --gray-background-focus: linear-gradient(var(--gray-700-12), var(--gray-700-12), var(--gray-100));
     --gray-ripple: var(--gray-700-10);
 
     --disabled-foreground: var(--gray-800-38);
diff --git a/polygerrit-ui/app/styles/themes/dark-theme.ts b/polygerrit-ui/app/styles/themes/dark-theme.ts
index 926b02d..69256b2 100644
--- a/polygerrit-ui/app/styles/themes/dark-theme.ts
+++ b/polygerrit-ui/app/styles/themes/dark-theme.ts
@@ -38,20 +38,20 @@
 
       --error-foreground: var(--red-200);
       --error-background: var(--red-tonal);
-      --error-background-hover: linear-gradient(var(--white-04), var(--white-04)), var(--red-tonal);
-      --error-background-focus: linear-gradient(var(--white-12), var(--white-12)), var(--red-tonal);
+      --error-background-hover: linear-gradient(var(--white-04), var(--white-04), var(--red-tonal));
+      --error-background-focus: linear-gradient(var(--white-12), var(--white-12), var(--red-tonal));
       --error-ripple: var(--white-10);
 
       --warning-foreground: var(--orange-200);
       --warning-background: var(--orange-tonal);
-      --warning-background-hover: linear-gradient(var(--white-04), var(--white-04)), var(--orange-tonal);
-      --warning-background-focus: linear-gradient(var(--white-12), var(--white-12)), var(--orange-tonal);
+      --warning-background-hover: linear-gradient(var(--white-04), var(--white-04), var(--orange-tonal));
+      --warning-background-focus: linear-gradient(var(--white-12), var(--white-12), var(--orange-tonal));
       --warning-ripple: var(--white-10);
 
       --info-foreground: var(--blue-200);
       --info-background: var(--blue-tonal);
-      --info-background-hover: linear-gradient(var(--white-04), var(--white-04)), var(--blue-tonal);
-      --info-background-focus: linear-gradient(var(--white-12), var(--white-12)), var(--blue-tonal);
+      --info-background-hover: linear-gradient(var(--white-04), var(--white-04), var(--blue-tonal));
+      --info-background-focus: linear-gradient(var(--white-12), var(--white-12), var(--blue-tonal));
       --info-ripple: var(--white-10);
 
       --primary-button-text-color: black;
@@ -64,14 +64,14 @@
 
       --success-foreground: var(--green-200);
       --success-background: var(--green-tonal);
-      --success-background-hover: linear-gradient(var(--white-04), var(--white-04)), var(--green-tonal);
-      --success-background-focus: linear-gradient(var(--white-12), var(--white-12)), var(--green-tonal);
+      --success-background-hover: linear-gradient(var(--white-04), var(--white-04), var(--green-tonal));
+      --success-background-focus: linear-gradient(var(--white-12), var(--white-12), var(--green-tonal));
       --success-ripple: var(--white-10);
 
       --gray-foreground: var(--gray-300);
       --gray-background: var(--gray-tonal);
-      --gray-background-hover: linear-gradient(var(--white-04), var(--white-04)), var(--gray-tonal);
-      --gray-background-focus: linear-gradient(var(--white-12), var(--white-12)), var(--gray-tonal);
+      --gray-background-hover: linear-gradient(var(--white-04), var(--white-04), var(--gray-tonal));
+      --gray-background-focus: linear-gradient(var(--white-12), var(--white-12), var(--gray-tonal));
       --gray-ripple: var(--white-10);
 
       --disabled-foreground: var(--gray-200-38);