Merge changes from topic "gr-change-requirements-to-ts"

* changes:
  Convert gr-change-requirements to typescript
  Rename files to preserve history
diff --git a/Documentation/config-gerrit.txt b/Documentation/config-gerrit.txt
index f02d89a..879ec99 100644
--- a/Documentation/config-gerrit.txt
+++ b/Documentation/config-gerrit.txt
@@ -4423,19 +4423,25 @@
 
 [[receiveemail.filter.mode]]receiveemail.filter.mode::
 +
-A black- and whitelist filter to filter incoming emails.
+An allow and block filter to filter incoming emails.
 +
 If `OFF`, emails are not filtered by the list filter.
 +
-If `WHITELIST`, only emails where a pattern from
+If `ALLOW`, only emails where a pattern from
 <<receiveemail.filter.patterns,receiveemail.filter.patterns>>
 matches 'From' will be processed.
 +
-If `BLACKLIST`, only emails where no pattern from
+If `BLOCK`, only emails where no pattern from
 <<receiveemail.filter.patterns,receiveemail.filter.patterns>>
 matches 'From' will be processed.
 +
 Defaults to `OFF`.
++
+The previous filter-names 'BLACKLIST' and 'WHITELIST' have been deprecated
+since they may be considered disrespectful and there's no technical or
+practical reason to use these exact terms for the filters.
+For backwards compatibility they are still supported but support for these
+deprecated terms will be removed in future releases.
 
 [[receiveemail.filter.patterns]]receiveemail.filter.patterns::
 +
@@ -4566,9 +4572,10 @@
 
 [[sendemail.allowrcpt]]sendemail.allowrcpt::
 +
-If present, each value adds one entry to the whitelist of email
-addresses that Gerrit can send email to.  If set to a complete
-email address, that one address is added to the white list.
+If present, each value adds one entry to the list of allowed email
+addresses that Gerrit can send emails to.  If set to a complete
+email address, that one address is added to the list of allowed
+emails.
 If set to a domain name, any address at that domain can receive
 email from Gerrit.
 +
@@ -4579,9 +4586,10 @@
 
 [[sendemail.denyrcpt]]sendemail.denyrcpt::
 +
-If present, each value adds one entry to the blacklist of email
-addresses that Gerrit can send email to.  If set to a complete
-email address, that one address is added to the blacklist.
+If present, each value adds one entry to the list of email
+addresses that Gerrit can't send emails to.  If set to a complete
+email address, that one address is added to the list of blocked
+emails.
 If set to a domain name, any address at that domain can *not* receive
 email from Gerrit.
 +
diff --git a/java/com/google/gerrit/mail/HtmlParser.java b/java/com/google/gerrit/mail/HtmlParser.java
index 2fc659d..ba73bdd 100644
--- a/java/com/google/gerrit/mail/HtmlParser.java
+++ b/java/com/google/gerrit/mail/HtmlParser.java
@@ -35,7 +35,7 @@
           "gmail_quote" // Used for quoting original content
           );
 
-  private static final ImmutableSet<String> WHITELISTED_HTML_TAGS =
+  private static final ImmutableSet<String> ALLOWED_HTML_TAGS =
       ImmutableSet.of(
           "div", // Most user-typed comments are contained in a <div> tag
           "a", // We allow links to be contained in a comment
@@ -120,8 +120,8 @@
         // There is no user-input in quoted text
         continue;
       }
-      if (!WHITELISTED_HTML_TAGS.contains(elementName)) {
-        // We only accept a set of whitelisted tags that can contain user input
+      if (!ALLOWED_HTML_TAGS.contains(elementName)) {
+        // We only accept a set of allowed tags that can contain user input
         continue;
       }
       if (elementName.equals("a") && e.attr("href").startsWith("mailto:")) {
diff --git a/java/com/google/gerrit/mail/MailHeader.java b/java/com/google/gerrit/mail/MailHeader.java
index 2f31a9c..2700f81 100644
--- a/java/com/google/gerrit/mail/MailHeader.java
+++ b/java/com/google/gerrit/mail/MailHeader.java
@@ -18,6 +18,7 @@
 public enum MailHeader {
   // Gerrit metadata holders
   ASSIGNEE("Gerrit-Assignee"),
+  ATTENTION("Gerrit-Attention"),
   BRANCH("Gerrit-Branch"),
   CC("Gerrit-CC"),
   COMMENT_IN_REPLY_TO("Comment-In-Reply-To"),
diff --git a/java/com/google/gerrit/server/mail/ListMailFilter.java b/java/com/google/gerrit/server/mail/ListMailFilter.java
index 23f7e12..67cef45 100644
--- a/java/com/google/gerrit/server/mail/ListMailFilter.java
+++ b/java/com/google/gerrit/server/mail/ListMailFilter.java
@@ -31,8 +31,8 @@
 
   public enum ListFilterMode {
     OFF,
-    WHITELIST,
-    BLACKLIST
+    ALLOW,
+    BLOCK
   }
 
   private final ListFilterMode mode;
@@ -40,12 +40,37 @@
 
   @Inject
   ListMailFilter(@GerritServerConfig Config cfg) {
-    this.mode = cfg.getEnum("receiveemail", "filter", "mode", ListFilterMode.OFF);
+    mode = getListFilterMode(cfg);
     String[] addresses = cfg.getStringList("receiveemail", "filter", "patterns");
     String concat = Arrays.asList(addresses).stream().collect(joining("|"));
     this.mailPattern = Pattern.compile(concat);
   }
 
+  private static final String LEGACY_ALLOW = "WHITELIST";
+  private static final String LEGACY_BLOCK = "BLACKLIST";
+
+  /** Legacy names are supported, but should be removed in the future. */
+  private ListFilterMode getListFilterMode(Config cfg) {
+    ListFilterMode mode;
+    String modeString = cfg.getString("receiveemail", "filter", "mode");
+    if (modeString == null) {
+      modeString = "";
+    }
+    switch (modeString) {
+      case LEGACY_ALLOW:
+      case "ALLOW":
+        mode = ListFilterMode.ALLOW;
+        break;
+      case LEGACY_BLOCK:
+      case "BLOCK":
+        mode = ListFilterMode.BLOCK;
+        break;
+      default:
+        mode = ListFilterMode.OFF;
+    }
+    return mode;
+  }
+
   @Override
   public boolean shouldProcessMessage(MailMessage message) {
     if (mode == ListFilterMode.OFF) {
@@ -53,8 +78,7 @@
     }
 
     boolean match = mailPattern.matcher(message.from().email()).find();
-    if ((mode == ListFilterMode.WHITELIST && !match)
-        || (mode == ListFilterMode.BLACKLIST && match)) {
+    if ((mode == ListFilterMode.ALLOW && !match) || (mode == ListFilterMode.BLOCK && match)) {
       logger.atInfo().log("Mail message from %s rejected by list filter", message.from());
       return false;
     }
diff --git a/java/com/google/gerrit/server/mail/send/ChangeEmail.java b/java/com/google/gerrit/server/mail/send/ChangeEmail.java
index 1e984c1..7b2bf12 100644
--- a/java/com/google/gerrit/server/mail/send/ChangeEmail.java
+++ b/java/com/google/gerrit/server/mail/send/ChangeEmail.java
@@ -14,6 +14,8 @@
 
 package com.google.gerrit.server.mail.send;
 
+import static com.google.gerrit.server.util.AttentionSetUtil.additionsOnly;
+
 import com.google.common.base.Splitter;
 import com.google.common.collect.ImmutableList;
 import com.google.common.collect.ImmutableMap;
@@ -56,6 +58,7 @@
 import java.util.Map;
 import java.util.Set;
 import java.util.TreeSet;
+import java.util.stream.Collectors;
 import org.apache.james.mime4j.dom.field.FieldName;
 import org.eclipse.jgit.diff.DiffFormatter;
 import org.eclipse.jgit.internal.JGitText;
@@ -484,6 +487,9 @@
     for (String reviewer : getEmailsByState(ReviewerStateInternal.CC)) {
       footers.add(MailHeader.CC.withDelimiter() + reviewer);
     }
+    for (String attentionUser : getAttentionSet()) {
+      footers.add(MailHeader.ATTENTION.withDelimiter() + attentionUser);
+    }
   }
 
   /**
@@ -509,6 +515,19 @@
     return reviewers;
   }
 
+  private Set<String> getAttentionSet() {
+    Set<String> attentionSet = new TreeSet<>();
+    try {
+      attentionSet =
+          additionsOnly(changeData.attentionSet()).stream()
+              .map(a -> getNameEmailFor(a.account()))
+              .collect(Collectors.toSet());
+    } catch (StorageException e) {
+      logger.atWarning().withCause(e).log("Cannot get change attention set");
+    }
+    return attentionSet;
+  }
+
   public boolean getIncludeDiff() {
     return args.settings.includeDiff;
   }
diff --git a/javatests/com/google/gerrit/acceptance/rest/change/AttentionSetIT.java b/javatests/com/google/gerrit/acceptance/rest/change/AttentionSetIT.java
index ed4c33a..cc6b199 100644
--- a/javatests/com/google/gerrit/acceptance/rest/change/AttentionSetIT.java
+++ b/javatests/com/google/gerrit/acceptance/rest/change/AttentionSetIT.java
@@ -1355,6 +1355,30 @@
     assertThat(attentionSet).hasReasonThat().isEqualTo("Reviewer was added");
   }
 
+  @Test
+  public void attentionSetEmailFooter() throws Exception {
+    PushOneCommit.Result r = createChange();
+
+    // Add user to attention set. They receive an email with the attention footer.
+    change(r).addReviewer(user.id().toString());
+    assertThat(Iterables.getOnlyElement(sender.getMessages()).body())
+        .contains("Gerrit-Attention: " + user.fullName());
+    sender.clear();
+
+    // Irrelevant reply, User is still in the attention set.
+    change(r).current().review(ReviewInput.approve());
+    assertThat(Iterables.getOnlyElement(sender.getMessages()).body())
+        .contains("Gerrit-Attention: " + user.fullName());
+    sender.clear();
+
+    // Abandon the change which removes user from attention set; there is an email but without the
+    // attention footer.
+    change(r).abandon();
+    assertThat(Iterables.getOnlyElement(sender.getMessages()).body())
+        .doesNotContain("Gerrit-Attention: " + user.fullName());
+    sender.clear();
+  }
+
   private List<AttentionSetUpdate> getAttentionSetUpdatesForUser(
       PushOneCommit.Result r, TestAccount account) {
     return getAttentionSetUpdates(r.getChange().getId()).stream()
diff --git a/javatests/com/google/gerrit/acceptance/server/mail/ListMailFilterIT.java b/javatests/com/google/gerrit/acceptance/server/mail/ListMailFilterIT.java
index e961c67..49b184b 100644
--- a/javatests/com/google/gerrit/acceptance/server/mail/ListMailFilterIT.java
+++ b/javatests/com/google/gerrit/acceptance/server/mail/ListMailFilterIT.java
@@ -45,11 +45,11 @@
   }
 
   @Test
-  @GerritConfig(name = "receiveemail.filter.mode", value = "WHITELIST")
+  @GerritConfig(name = "receiveemail.filter.mode", value = "ALLOW")
   @GerritConfig(
       name = "receiveemail.filter.patterns",
       values = {".+ser@example\\.com", "a@b\\.com"})
-  public void listFilterWhitelistDoesNotFilterListedUser() throws Exception {
+  public void listFilterAllowDoesNotFilterListedUser() throws Exception {
     ChangeInfo changeInfo = createChangeAndReplyByEmail();
     // Check that the comments from the email have been persisted
     Collection<ChangeMessageInfo> messages = gApi.changes().id(changeInfo.id).get().messages;
@@ -57,11 +57,11 @@
   }
 
   @Test
-  @GerritConfig(name = "receiveemail.filter.mode", value = "WHITELIST")
+  @GerritConfig(name = "receiveemail.filter.mode", value = "ALLOW")
   @GerritConfig(
       name = "receiveemail.filter.patterns",
       values = {".+@gerritcodereview\\.com", "a@b\\.com"})
-  public void listFilterWhitelistFiltersNotListedUser() throws Exception {
+  public void listFilterAllowFiltersNotListedUser() throws Exception {
     ChangeInfo changeInfo = createChangeAndReplyByEmail();
     // Check that the comments from the email have NOT been persisted
     Collection<ChangeMessageInfo> messages = gApi.changes().id(changeInfo.id).get().messages;
@@ -72,11 +72,11 @@
   }
 
   @Test
-  @GerritConfig(name = "receiveemail.filter.mode", value = "BLACKLIST")
+  @GerritConfig(name = "receiveemail.filter.mode", value = "BLOCK")
   @GerritConfig(
       name = "receiveemail.filter.patterns",
       values = {".+@gerritcodereview\\.com", "a@b\\.com"})
-  public void listFilterBlacklistDoesNotFilterNotListedUser() throws Exception {
+  public void listFilterBlockDoesNotFilterNotListedUser() throws Exception {
     ChangeInfo changeInfo = createChangeAndReplyByEmail();
     // Check that the comments from the email have been persisted
     Collection<ChangeMessageInfo> messages = gApi.changes().id(changeInfo.id).get().messages;
@@ -84,11 +84,11 @@
   }
 
   @Test
-  @GerritConfig(name = "receiveemail.filter.mode", value = "BLACKLIST")
+  @GerritConfig(name = "receiveemail.filter.mode", value = "BLOCK")
   @GerritConfig(
       name = "receiveemail.filter.patterns",
       values = {".+@example\\.com", "a@b\\.com"})
-  public void listFilterBlacklistFiltersListedUser() throws Exception {
+  public void listFilterBlockFiltersListedUser() throws Exception {
     ChangeInfo changeInfo = createChangeAndReplyByEmail();
     // Check that the comments from the email have been persisted
     Collection<ChangeMessageInfo> messages = gApi.changes().id(changeInfo.id).get().messages;
diff --git a/polygerrit-ui/app/elements/admin/gr-repo-detail-list/gr-repo-detail-list.js b/polygerrit-ui/app/elements/admin/gr-repo-detail-list/gr-repo-detail-list.js
deleted file mode 100644
index 4989365..0000000
--- a/polygerrit-ui/app/elements/admin/gr-repo-detail-list/gr-repo-detail-list.js
+++ /dev/null
@@ -1,309 +0,0 @@
-/**
- * @license
- * Copyright (C) 2017 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/iron-input/iron-input.js';
-import '../../../styles/gr-form-styles.js';
-import '../../../styles/gr-table-styles.js';
-import '../../../styles/shared-styles.js';
-import '../../shared/gr-account-link/gr-account-link.js';
-import '../../shared/gr-button/gr-button.js';
-import '../../shared/gr-date-formatter/gr-date-formatter.js';
-import '../../shared/gr-dialog/gr-dialog.js';
-import '../../shared/gr-list-view/gr-list-view.js';
-import '../../shared/gr-overlay/gr-overlay.js';
-import '../../shared/gr-rest-api-interface/gr-rest-api-interface.js';
-import '../gr-create-pointer-dialog/gr-create-pointer-dialog.js';
-import '../gr-confirm-delete-item-dialog/gr-confirm-delete-item-dialog.js';
-import {flush} from '@polymer/polymer/lib/legacy/polymer.dom.js';
-import {GestureEventListeners} from '@polymer/polymer/lib/mixins/gesture-event-listeners.js';
-import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin.js';
-import {PolymerElement} from '@polymer/polymer/polymer-element.js';
-import {htmlTemplate} from './gr-repo-detail-list_html.js';
-import {ListViewMixin} from '../../../mixins/gr-list-view-mixin/gr-list-view-mixin.js';
-import {encodeURL} from '../../../utils/url-util.js';
-
-const DETAIL_TYPES = {
-  BRANCHES: 'branches',
-  TAGS: 'tags',
-};
-
-const PGP_START = '-----BEGIN PGP SIGNATURE-----';
-
-/**
- * @appliesMixin ListViewMixin
- * @extends PolymerElement
- */
-class GrRepoDetailList extends ListViewMixin(GestureEventListeners(
-    LegacyElementMixin(
-        PolymerElement))) {
-  static get template() { return htmlTemplate; }
-
-  static get is() { return 'gr-repo-detail-list'; }
-
-  static get properties() {
-    return {
-    /**
-     * URL params passed from the router.
-     */
-      params: {
-        type: Object,
-        observer: '_paramsChanged',
-      },
-      /**
-       * The kind of detail we are displaying, possibilities are determined by
-       * the const DETAIL_TYPES.
-       */
-      detailType: String,
-
-      _editing: {
-        type: Boolean,
-        value: false,
-      },
-      _isOwner: {
-        type: Boolean,
-        value: false,
-      },
-      _loggedIn: {
-        type: Boolean,
-        value: false,
-      },
-      /**
-       * Offset of currently visible query results.
-       */
-      _offset: Number,
-      _repo: Object,
-      _items: Array,
-      /**
-       * Because  we request one more than the projectsPerPage, _shownProjects
-       * maybe one less than _projects.
-       */
-      _shownItems: {
-        type: Array,
-        computed: 'computeShownItems(_items)',
-      },
-      _itemsPerPage: {
-        type: Number,
-        value: 25,
-      },
-      _loading: {
-        type: Boolean,
-        value: true,
-      },
-      _filter: String,
-      _refName: String,
-      _hasNewItemName: Boolean,
-      _isEditing: Boolean,
-      _revisedRef: String,
-    };
-  }
-
-  _determineIfOwner(repo) {
-    return this.$.restAPI.getRepoAccess(repo)
-        .then(access =>
-          this._isOwner = access && !!access[repo].is_owner);
-  }
-
-  _paramsChanged(params) {
-    if (!params || !params.repo) { return; }
-
-    this._repo = params.repo;
-
-    this._getLoggedIn().then(loggedIn => {
-      this._loggedIn = loggedIn;
-      if (loggedIn) {
-        this._determineIfOwner(this._repo);
-      }
-    });
-
-    this.detailType = params.detail;
-
-    this._filter = this.getFilterValue(params);
-    this._offset = this.getOffsetValue(params);
-
-    return this._getItems(this._filter, this._repo,
-        this._itemsPerPage, this._offset, this.detailType);
-  }
-
-  _getItems(filter, repo, itemsPerPage, offset, detailType) {
-    this._loading = true;
-    this._items = [];
-    flush();
-    const errFn = response => {
-      this.dispatchEvent(new CustomEvent('page-error', {
-        detail: {response},
-        composed: true, bubbles: true,
-      }));
-    };
-    if (detailType === DETAIL_TYPES.BRANCHES) {
-      return this.$.restAPI.getRepoBranches(
-          filter, repo, itemsPerPage, offset, errFn).then(items => {
-        if (!items) { return; }
-        this._items = items;
-        this._loading = false;
-      });
-    } else if (detailType === DETAIL_TYPES.TAGS) {
-      return this.$.restAPI.getRepoTags(
-          filter, repo, itemsPerPage, offset, errFn).then(items => {
-        if (!items) { return; }
-        this._items = items;
-        this._loading = false;
-      });
-    }
-  }
-
-  _getPath(repo) {
-    return `/admin/repos/${encodeURL(repo, false)},` +
-        `${this.detailType}`;
-  }
-
-  _computeWeblink(repo) {
-    if (!repo.web_links) { return ''; }
-    const webLinks = repo.web_links;
-    return webLinks.length ? webLinks : null;
-  }
-
-  _computeMessage(message) {
-    if (!message) { return; }
-    // Strip PGP info.
-    return message.split(PGP_START)[0];
-  }
-
-  _stripRefs(item, detailType) {
-    if (detailType === DETAIL_TYPES.BRANCHES) {
-      return item.replace('refs/heads/', '');
-    } else if (detailType === DETAIL_TYPES.TAGS) {
-      return item.replace('refs/tags/', '');
-    }
-  }
-
-  _getLoggedIn() {
-    return this.$.restAPI.getLoggedIn();
-  }
-
-  _computeEditingClass(isEditing) {
-    return isEditing ? 'editing' : '';
-  }
-
-  _computeCanEditClass(ref, detailType, isOwner) {
-    return isOwner && this._stripRefs(ref, detailType) === 'HEAD' ?
-      'canEdit' : '';
-  }
-
-  _handleEditRevision(e) {
-    this._revisedRef = e.model.get('item.revision');
-    this._isEditing = true;
-  }
-
-  _handleCancelRevision() {
-    this._isEditing = false;
-  }
-
-  _handleSaveRevision(e) {
-    this._setRepoHead(this._repo, this._revisedRef, e);
-  }
-
-  _setRepoHead(repo, ref, e) {
-    return this.$.restAPI.setRepoHead(repo, ref).then(res => {
-      if (res.status < 400) {
-        this._isEditing = false;
-        e.model.set('item.revision', ref);
-        // This is needed to refresh _items property with fresh data,
-        // specifically can_delete from the json response.
-        this._getItems(
-            this._filter, this._repo, this._itemsPerPage,
-            this._offset, this.detailType);
-      }
-    });
-  }
-
-  _computeItemName(detailType) {
-    if (detailType === DETAIL_TYPES.BRANCHES) {
-      return 'Branch';
-    } else if (detailType === DETAIL_TYPES.TAGS) {
-      return 'Tag';
-    }
-  }
-
-  _handleDeleteItemConfirm() {
-    this.$.overlay.close();
-    if (this.detailType === DETAIL_TYPES.BRANCHES) {
-      return this.$.restAPI.deleteRepoBranches(this._repo, this._refName)
-          .then(itemDeleted => {
-            if (itemDeleted.status === 204) {
-              this._getItems(
-                  this._filter, this._repo, this._itemsPerPage,
-                  this._offset, this.detailType);
-            }
-          });
-    } else if (this.detailType === DETAIL_TYPES.TAGS) {
-      return this.$.restAPI.deleteRepoTags(this._repo, this._refName)
-          .then(itemDeleted => {
-            if (itemDeleted.status === 204) {
-              this._getItems(
-                  this._filter, this._repo, this._itemsPerPage,
-                  this._offset, this.detailType);
-            }
-          });
-    }
-  }
-
-  _handleConfirmDialogCancel() {
-    this.$.overlay.close();
-  }
-
-  _handleDeleteItem(e) {
-    const name = this._stripRefs(e.model.get('item.ref'), this.detailType);
-    if (!name) { return; }
-    this._refName = name;
-    this.$.overlay.open();
-  }
-
-  _computeHideDeleteClass(owner, canDelete) {
-    if (canDelete || owner) {
-      return 'show';
-    }
-
-    return '';
-  }
-
-  _handleCreateItem() {
-    this.$.createNewModal.handleCreateItem();
-    this._handleCloseCreate();
-  }
-
-  _handleCloseCreate() {
-    this.$.createOverlay.close();
-  }
-
-  _handleCreateClicked() {
-    this.$.createOverlay.open();
-  }
-
-  _hideIfBranch(type) {
-    if (type === DETAIL_TYPES.BRANCHES) {
-      return 'hideItem';
-    }
-
-    return '';
-  }
-
-  _computeHideTagger(tagger) {
-    return tagger ? '' : 'hide';
-  }
-}
-
-customElements.define(GrRepoDetailList.is, GrRepoDetailList);
diff --git a/polygerrit-ui/app/elements/admin/gr-repo-detail-list/gr-repo-detail-list.ts b/polygerrit-ui/app/elements/admin/gr-repo-detail-list/gr-repo-detail-list.ts
new file mode 100644
index 0000000..828d447
--- /dev/null
+++ b/polygerrit-ui/app/elements/admin/gr-repo-detail-list/gr-repo-detail-list.ts
@@ -0,0 +1,381 @@
+/**
+ * @license
+ * Copyright (C) 2017 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/iron-input/iron-input';
+import '../../../styles/gr-form-styles';
+import '../../../styles/gr-table-styles';
+import '../../../styles/shared-styles';
+import '../../shared/gr-account-link/gr-account-link';
+import '../../shared/gr-button/gr-button';
+import '../../shared/gr-date-formatter/gr-date-formatter';
+import '../../shared/gr-dialog/gr-dialog';
+import '../../shared/gr-list-view/gr-list-view';
+import '../../shared/gr-overlay/gr-overlay';
+import '../../shared/gr-rest-api-interface/gr-rest-api-interface';
+import '../gr-create-pointer-dialog/gr-create-pointer-dialog';
+import '../gr-confirm-delete-item-dialog/gr-confirm-delete-item-dialog';
+import {flush} from '@polymer/polymer/lib/legacy/polymer.dom';
+import {GestureEventListeners} from '@polymer/polymer/lib/mixins/gesture-event-listeners';
+import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin';
+import {PolymerElement} from '@polymer/polymer/polymer-element';
+import {htmlTemplate} from './gr-repo-detail-list_html';
+import {ListViewMixin} from '../../../mixins/gr-list-view-mixin/gr-list-view-mixin';
+import {encodeURL} from '../../../utils/url-util';
+import {customElement, property} from '@polymer/decorators';
+import {
+  ErrorCallback,
+  RestApiService,
+} from '../../../services/services/gr-rest-api/gr-rest-api';
+import {GrOverlay} from '../../shared/gr-overlay/gr-overlay';
+import {GrCreatePointerDialog} from '../gr-create-pointer-dialog/gr-create-pointer-dialog';
+import {
+  RepoName,
+  ProjectInfo,
+  BranchInfo,
+  GitRef,
+  TagInfo,
+  GitPersonInfo,
+} from '../../../types/common';
+import {AppElementRepoParams} from '../../gr-app-types';
+import {PolymerDomRepeatEvent} from '../../../types/types';
+import {RepoDetailView} from '../../core/gr-navigation/gr-navigation';
+
+const PGP_START = '-----BEGIN PGP SIGNATURE-----';
+
+export interface GrRepoDetailList {
+  $: {
+    restAPI: RestApiService & Element;
+    overlay: GrOverlay;
+    createOverlay: GrOverlay;
+    createNewModal: GrCreatePointerDialog;
+  };
+}
+@customElement('gr-repo-detail-list')
+export class GrRepoDetailList extends ListViewMixin(
+  GestureEventListeners(LegacyElementMixin(PolymerElement))
+) {
+  static get template() {
+    return htmlTemplate;
+  }
+
+  @property({type: Object, observer: '_paramsChanged'})
+  params?: AppElementRepoParams;
+
+  @property({type: String})
+  detailType?: RepoDetailView;
+
+  @property({type: Boolean})
+  _editing = false;
+
+  @property({type: Boolean})
+  _isOwner = false;
+
+  @property({type: Boolean})
+  _loggedIn = false;
+
+  @property({type: Number})
+  _offset?: number;
+
+  @property({type: String})
+  _repo?: RepoName;
+
+  @property({type: Array})
+  _items?: BranchInfo[] | TagInfo[];
+
+  @property({type: Array, computed: 'computeShownItems(_items)'})
+  _shownItems?: BranchInfo[] | TagInfo[];
+
+  @property({type: Number})
+  _itemsPerPage = 25;
+
+  @property({type: Boolean})
+  _loading = true;
+
+  @property({type: String})
+  _filter?: string;
+
+  @property({type: String})
+  _refName?: GitRef;
+
+  @property({type: Boolean})
+  _hasNewItemName?: boolean;
+
+  @property({type: Boolean})
+  _isEditing?: boolean;
+
+  @property({type: String})
+  _revisedRef?: GitRef;
+
+  _determineIfOwner(repo: RepoName) {
+    return this.$.restAPI
+      .getRepoAccess(repo)
+      .then(access => (this._isOwner = !!access && !!access[repo].is_owner));
+  }
+
+  _paramsChanged(params?: AppElementRepoParams) {
+    if (!params?.repo) {
+      return Promise.reject(new Error('undefined repo'));
+    }
+
+    this._repo = params.repo;
+
+    this._getLoggedIn().then(loggedIn => {
+      this._loggedIn = loggedIn;
+      if (loggedIn && this._repo) {
+        this._determineIfOwner(this._repo);
+      }
+    });
+
+    this.detailType = params.detail;
+
+    this._filter = this.getFilterValue(params);
+    this._offset = this.getOffsetValue(params);
+    if (!this.detailType)
+      return Promise.reject(new Error('undefined detailType'));
+
+    return this._getItems(
+      this._filter,
+      this._repo,
+      this._itemsPerPage,
+      this._offset,
+      this.detailType
+    );
+  }
+
+  // TODO(TS) Move this to object for easier read, understand.
+  _getItems(
+    filter: string | undefined,
+    repo: RepoName | undefined,
+    itemsPerPage: number,
+    offset: number | undefined,
+    detailType: string
+  ) {
+    if (filter === undefined || !repo || offset === undefined) {
+      return Promise.reject(new Error('filter or repo or offset undefined'));
+    }
+    this._loading = true;
+    this._items = [];
+    flush();
+    const errFn: ErrorCallback = response => {
+      this.dispatchEvent(
+        new CustomEvent('page-error', {
+          detail: {response},
+          composed: true,
+          bubbles: true,
+        })
+      );
+    };
+    if (detailType === RepoDetailView.BRANCHES) {
+      return this.$.restAPI
+        .getRepoBranches(filter, repo, itemsPerPage, offset, errFn)
+        .then(items => {
+          if (!items) {
+            return;
+          }
+          this._items = items;
+          this._loading = false;
+        });
+    } else if (detailType === RepoDetailView.TAGS) {
+      return this.$.restAPI
+        .getRepoTags(filter, repo, itemsPerPage, offset, errFn)
+        .then(items => {
+          if (!items) {
+            return;
+          }
+          this._items = items;
+          this._loading = false;
+        });
+    }
+    return Promise.reject(new Error('unknown detail type'));
+  }
+
+  _getPath(repo: RepoName) {
+    return `/admin/repos/${encodeURL(repo, false)},${this.detailType}`;
+  }
+
+  _computeWeblink(repo: ProjectInfo) {
+    if (!repo.web_links) {
+      return '';
+    }
+    const webLinks = repo.web_links;
+    return webLinks.length ? webLinks : null;
+  }
+
+  _computeMessage(message?: string) {
+    if (!message) {
+      return;
+    }
+    // Strip PGP info.
+    return message.split(PGP_START)[0];
+  }
+
+  _stripRefs(item: GitRef, detailType?: string) {
+    if (detailType === RepoDetailView.BRANCHES) {
+      return item.replace('refs/heads/', '');
+    } else if (detailType === RepoDetailView.TAGS) {
+      return item.replace('refs/tags/', '');
+    }
+    throw new Error('unknown detailType');
+  }
+
+  _getLoggedIn() {
+    return this.$.restAPI.getLoggedIn();
+  }
+
+  _computeEditingClass(isEditing: boolean) {
+    return isEditing ? 'editing' : '';
+  }
+
+  _computeCanEditClass(ref: GitRef, detailType: string, isOwner: boolean) {
+    return isOwner && this._stripRefs(ref, detailType) === 'HEAD'
+      ? 'canEdit'
+      : '';
+  }
+
+  _handleEditRevision(e: PolymerDomRepeatEvent<BranchInfo | TagInfo>) {
+    this._revisedRef = (e.model.get('item.revision') as unknown) as GitRef;
+    this._isEditing = true;
+  }
+
+  _handleCancelRevision() {
+    this._isEditing = false;
+  }
+
+  _handleSaveRevision(e: PolymerDomRepeatEvent<GitRef>) {
+    if (this._revisedRef && this._repo)
+      this._setRepoHead(this._repo, this._revisedRef, e);
+  }
+
+  _setRepoHead(repo: RepoName, ref: GitRef, e: PolymerDomRepeatEvent<GitRef>) {
+    return this.$.restAPI.setRepoHead(repo, ref).then(res => {
+      if (res.status < 400) {
+        this._isEditing = false;
+        e.model.set('item.revision', ref);
+        // This is needed to refresh _items property with fresh data,
+        // specifically can_delete from the json response.
+        this._getItems(
+          this._filter,
+          this._repo,
+          this._itemsPerPage,
+          this._offset,
+          this.detailType!
+        );
+      }
+    });
+  }
+
+  _computeItemName(detailType: string) {
+    if (detailType === RepoDetailView.BRANCHES) {
+      return 'Branch';
+    } else if (detailType === RepoDetailView.TAGS) {
+      return 'Tag';
+    }
+    throw new Error('unknown detailType');
+  }
+
+  _handleDeleteItemConfirm() {
+    this.$.overlay.close();
+    if (!this._repo || !this._refName) {
+      return Promise.reject(new Error('undefined repo or refName'));
+    }
+    if (this.detailType === RepoDetailView.BRANCHES) {
+      return this.$.restAPI
+        .deleteRepoBranches(this._repo, this._refName)
+        .then(itemDeleted => {
+          if (itemDeleted.status === 204) {
+            this._getItems(
+              this._filter,
+              this._repo,
+              this._itemsPerPage,
+              this._offset,
+              this.detailType!
+            );
+          }
+        });
+    } else if (this.detailType === RepoDetailView.TAGS) {
+      return this.$.restAPI
+        .deleteRepoTags(this._repo, this._refName)
+        .then(itemDeleted => {
+          if (itemDeleted.status === 204) {
+            this._getItems(
+              this._filter,
+              this._repo,
+              this._itemsPerPage,
+              this._offset,
+              this.detailType!
+            );
+          }
+        });
+    }
+    return Promise.reject(new Error('unknown detail type'));
+  }
+
+  _handleConfirmDialogCancel() {
+    this.$.overlay.close();
+  }
+
+  _handleDeleteItem(e: PolymerDomRepeatEvent<GitRef>) {
+    const name = this._stripRefs(
+      e.model.get('item.ref'),
+      this.detailType
+    ) as GitRef;
+    if (!name) {
+      return;
+    }
+    this._refName = name;
+    this.$.overlay.open();
+  }
+
+  _computeHideDeleteClass(owner: boolean, canDelete: boolean) {
+    if (canDelete || owner) {
+      return 'show';
+    }
+
+    return '';
+  }
+
+  _handleCreateItem() {
+    this.$.createNewModal.handleCreateItem();
+    this._handleCloseCreate();
+  }
+
+  _handleCloseCreate() {
+    this.$.createOverlay.close();
+  }
+
+  _handleCreateClicked() {
+    this.$.createOverlay.open();
+  }
+
+  _hideIfBranch(type: string) {
+    if (type === RepoDetailView.BRANCHES) {
+      return 'hideItem';
+    }
+
+    return '';
+  }
+
+  _computeHideTagger(tagger: GitPersonInfo) {
+    return tagger ? '' : 'hide';
+  }
+}
+
+declare global {
+  interface HTMLElementTagNameMap {
+    'gr-repo-detail-list': GrRepoDetailList;
+  }
+}
diff --git a/polygerrit-ui/app/elements/admin/gr-repo-detail-list/gr-repo-detail-list_test.js b/polygerrit-ui/app/elements/admin/gr-repo-detail-list/gr-repo-detail-list_test.js
index 7f6ba08..f86f613 100644
--- a/polygerrit-ui/app/elements/admin/gr-repo-detail-list/gr-repo-detail-list_test.js
+++ b/polygerrit-ui/app/elements/admin/gr-repo-detail-list/gr-repo-detail-list_test.js
@@ -17,6 +17,7 @@
 
 import '../../../test/common-test-setup-karma.js';
 import './gr-repo-detail-list.js';
+import 'lodash/lodash.js';
 import {page} from '../../../utils/page-wrapper-utils.js';
 import {dom} from '@polymer/polymer/lib/legacy/polymer.dom.js';
 
diff --git a/polygerrit-ui/app/elements/admin/gr-repo/gr-repo.js b/polygerrit-ui/app/elements/admin/gr-repo/gr-repo.js
deleted file mode 100644
index 9cbfda0..0000000
--- a/polygerrit-ui/app/elements/admin/gr-repo/gr-repo.js
+++ /dev/null
@@ -1,382 +0,0 @@
-/**
- * @license
- * Copyright (C) 2017 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/iron-autogrow-textarea/iron-autogrow-textarea.js';
-import '@polymer/iron-input/iron-input.js';
-import '../../plugins/gr-endpoint-decorator/gr-endpoint-decorator.js';
-import '../../plugins/gr-endpoint-param/gr-endpoint-param.js';
-import '../../shared/gr-download-commands/gr-download-commands.js';
-import '../../shared/gr-rest-api-interface/gr-rest-api-interface.js';
-import '../../shared/gr-select/gr-select.js';
-import '../../../styles/gr-form-styles.js';
-import '../../../styles/gr-subpage-styles.js';
-import '../../../styles/shared-styles.js';
-import '../gr-repo-plugin-config/gr-repo-plugin-config.js';
-import {GestureEventListeners} from '@polymer/polymer/lib/mixins/gesture-event-listeners.js';
-import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin.js';
-import {PolymerElement} from '@polymer/polymer/polymer-element.js';
-import {htmlTemplate} from './gr-repo_html.js';
-import {GerritNav} from '../../core/gr-navigation/gr-navigation.js';
-
-const STATES = {
-  active: {value: 'ACTIVE', label: 'Active'},
-  readOnly: {value: 'READ_ONLY', label: 'Read Only'},
-  hidden: {value: 'HIDDEN', label: 'Hidden'},
-};
-
-const SUBMIT_TYPES = {
-  // Exclude INHERIT, which is handled specially.
-  mergeIfNecessary: {
-    value: 'MERGE_IF_NECESSARY',
-    label: 'Merge if necessary',
-  },
-  fastForwardOnly: {
-    value: 'FAST_FORWARD_ONLY',
-    label: 'Fast forward only',
-  },
-  rebaseAlways: {
-    value: 'REBASE_ALWAYS',
-    label: 'Rebase Always',
-  },
-  rebaseIfNecessary: {
-    value: 'REBASE_IF_NECESSARY',
-    label: 'Rebase if necessary',
-  },
-  mergeAlways: {
-    value: 'MERGE_ALWAYS',
-    label: 'Merge always',
-  },
-  cherryPick: {
-    value: 'CHERRY_PICK',
-    label: 'Cherry pick',
-  },
-};
-
-/**
- * @extends PolymerElement
- */
-class GrRepo extends GestureEventListeners(
-    LegacyElementMixin(PolymerElement)) {
-  // Notes for future TS conversion:
-  // _repoConfig: ConfigInfo
-  // _pluginData: PluginData[], can't be null, PluginData from gr-repo-plugin-config.ts
-
-  static get template() { return htmlTemplate; }
-
-  static get is() { return 'gr-repo'; }
-
-  static get properties() {
-    return {
-      params: Object,
-      repo: String,
-
-      _configChanged: {
-        type: Boolean,
-        value: false,
-      },
-      _loading: {
-        type: Boolean,
-        value: true,
-      },
-      _loggedIn: {
-        type: Boolean,
-        value: false,
-        observer: '_loggedInChanged',
-      },
-      /** @type {?} */
-      _repoConfig: Object,
-      /** @type {?} */
-      _pluginData: {
-        type: Array,
-        computed: '_computePluginData(_repoConfig.plugin_config.*)',
-      },
-      _readOnly: {
-        type: Boolean,
-        value: true,
-      },
-      _states: {
-        type: Array,
-        value() {
-          return Object.values(STATES);
-        },
-      },
-      _submitTypes: {
-        type: Array,
-        value() {
-          return Object.values(SUBMIT_TYPES);
-        },
-      },
-      _schemes: {
-        type: Array,
-        value() { return []; },
-        computed: '_computeSchemes(_schemesObj)',
-        observer: '_schemesChanged',
-      },
-      _selectedCommand: {
-        type: String,
-        value: 'Clone',
-      },
-      _selectedScheme: String,
-      _schemesObj: Object,
-    };
-  }
-
-  static get observers() {
-    return [
-      '_handleConfigChanged(_repoConfig.*)',
-    ];
-  }
-
-  /** @override */
-  attached() {
-    super.attached();
-    this._loadRepo();
-
-    this.dispatchEvent(new CustomEvent('title-change', {
-      detail: {title: this.repo},
-      composed: true, bubbles: true,
-    }));
-  }
-
-  _computePluginData(configRecord) {
-    if (!configRecord ||
-        !configRecord.base) { return []; }
-
-    const pluginConfig = configRecord.base;
-    return Object.keys(pluginConfig)
-        .map(name => { return {name, config: pluginConfig[name]}; });
-  }
-
-  _loadRepo() {
-    if (!this.repo) { return Promise.resolve(); }
-
-    const promises = [];
-
-    const errFn = response => {
-      this.dispatchEvent(new CustomEvent('page-error', {
-        detail: {response},
-        composed: true, bubbles: true,
-      }));
-    };
-
-    promises.push(this._getLoggedIn().then(loggedIn => {
-      this._loggedIn = loggedIn;
-      if (loggedIn) {
-        this.$.restAPI.getRepoAccess(this.repo).then(access => {
-          if (!access) { return Promise.resolve(); }
-
-          // If the user is not an owner, is_owner is not a property.
-          this._readOnly = !access[this.repo].is_owner;
-        });
-      }
-    }));
-
-    promises.push(this.$.restAPI.getProjectConfig(this.repo, errFn)
-        .then(config => {
-          if (!config) { return Promise.resolve(); }
-
-          if (config.default_submit_type) {
-            // The gr-select is bound to submit_type, which needs to be the
-            // *configured* submit type. When default_submit_type is
-            // present, the server reports the *effective* submit type in
-            // submit_type, so we need to overwrite it before storing the
-            // config in this.
-            config.submit_type =
-                config.default_submit_type.configured_value;
-          }
-          if (!config.state) {
-            config.state = STATES.active.value;
-          }
-          this._repoConfig = config;
-          this._loading = false;
-        }));
-
-    promises.push(this.$.restAPI.getConfig().then(config => {
-      if (!config) { return Promise.resolve(); }
-
-      this._schemesObj = config.download.schemes;
-    }));
-
-    return Promise.all(promises);
-  }
-
-  _computeLoadingClass(loading) {
-    return loading ? 'loading' : '';
-  }
-
-  _computeHideClass(arr) {
-    return !arr || !arr.length ? 'hide' : '';
-  }
-
-  _loggedInChanged(_loggedIn) {
-    if (!_loggedIn) { return; }
-    this.$.restAPI.getPreferences().then(prefs => {
-      if (prefs.download_scheme) {
-        // Note (issue 5180): normalize the download scheme with lower-case.
-        this._selectedScheme = prefs.download_scheme.toLowerCase();
-      }
-    });
-  }
-
-  _formatBooleanSelect(item) {
-    if (!item) { return; }
-    let inheritLabel = 'Inherit';
-    if (!(item.inherited_value === undefined)) {
-      inheritLabel = `Inherit (${item.inherited_value})`;
-    }
-    return [
-      {
-        label: inheritLabel,
-        value: 'INHERIT',
-      },
-      {
-        label: 'True',
-        value: 'TRUE',
-      }, {
-        label: 'False',
-        value: 'FALSE',
-      },
-    ];
-  }
-
-  _formatSubmitTypeSelect(projectConfig) {
-    if (!projectConfig) { return; }
-    const allValues = Object.values(SUBMIT_TYPES);
-    const type = projectConfig.default_submit_type;
-    if (!type) {
-      // Server is too old to report default_submit_type, so assume INHERIT
-      // is not a valid value.
-      return allValues;
-    }
-
-    let inheritLabel = 'Inherit';
-    if (type.inherited_value) {
-      let inherited = type.inherited_value;
-      for (const val of allValues) {
-        if (val.value === type.inherited_value) {
-          inherited = val.label;
-          break;
-        }
-      }
-      inheritLabel = `Inherit (${inherited})`;
-    }
-    return [
-      {
-        label: inheritLabel,
-        value: 'INHERIT',
-      },
-      ...allValues,
-    ];
-  }
-
-  _isLoading() {
-    return this._loading || this._loading === undefined;
-  }
-
-  _getLoggedIn() {
-    return this.$.restAPI.getLoggedIn();
-  }
-
-  _formatRepoConfigForSave(repoConfig) {
-    const configInputObj = {};
-    for (const key in repoConfig) {
-      if (repoConfig.hasOwnProperty(key)) {
-        if (key === 'default_submit_type') {
-          // default_submit_type is not in the input type, and the
-          // configured value was already copied to submit_type by
-          // _loadProject. Omit this property when saving.
-          continue;
-        }
-        if (key === 'plugin_config') {
-          configInputObj.plugin_config_values = repoConfig[key];
-        } else if (typeof repoConfig[key] === 'object') {
-          configInputObj[key] = repoConfig[key].configured_value;
-        } else {
-          configInputObj[key] = repoConfig[key];
-        }
-      }
-    }
-    return configInputObj;
-  }
-
-  _handleSaveRepoConfig() {
-    return this.$.restAPI.saveRepoConfig(this.repo,
-        this._formatRepoConfigForSave(this._repoConfig)).then(() => {
-      this._configChanged = false;
-    });
-  }
-
-  _handleConfigChanged() {
-    if (this._isLoading()) { return; }
-    this._configChanged = true;
-  }
-
-  _computeButtonDisabled(readOnly, configChanged) {
-    return readOnly || !configChanged;
-  }
-
-  _computeHeaderClass(configChanged) {
-    return configChanged ? 'edited' : '';
-  }
-
-  _computeSchemes(schemesObj) {
-    return Object.keys(schemesObj);
-  }
-
-  _schemesChanged(schemes) {
-    if (schemes.length === 0) { return; }
-    if (!schemes.includes(this._selectedScheme)) {
-      this._selectedScheme = schemes.sort()[0];
-    }
-  }
-
-  _computeCommands(repo, schemesObj, _selectedScheme) {
-    if (!schemesObj || !repo || !_selectedScheme) {
-      return [];
-    }
-    const commands = [];
-    let commandObj;
-    if (schemesObj.hasOwnProperty(_selectedScheme)) {
-      commandObj = schemesObj[_selectedScheme].clone_commands;
-    }
-    for (const title in commandObj) {
-      if (!commandObj.hasOwnProperty(title)) { continue; }
-      commands.push({
-        title,
-        command: commandObj[title]
-            .replace(/\${project}/gi, encodeURI(repo))
-            .replace(/\${project-base-name}/gi,
-                encodeURI(repo.substring(repo.lastIndexOf('/') + 1))),
-      });
-    }
-    return commands;
-  }
-
-  _computeRepositoriesClass(config) {
-    return config ? 'showConfig': '';
-  }
-
-  _computeChangesUrl(name) {
-    return GerritNav.getUrlForProjectChanges(name);
-  }
-
-  _handlePluginConfigChanged({detail: {name, config, notifyPath}}) {
-    this._repoConfig.plugin_config[name] = config;
-    this.notifyPath('_repoConfig.plugin_config.' + notifyPath);
-  }
-}
-
-customElements.define(GrRepo.is, GrRepo);
diff --git a/polygerrit-ui/app/elements/admin/gr-repo/gr-repo.ts b/polygerrit-ui/app/elements/admin/gr-repo/gr-repo.ts
new file mode 100644
index 0000000..101c77a
--- /dev/null
+++ b/polygerrit-ui/app/elements/admin/gr-repo/gr-repo.ts
@@ -0,0 +1,455 @@
+/**
+ * @license
+ * Copyright (C) 2017 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/iron-autogrow-textarea/iron-autogrow-textarea';
+import '@polymer/iron-input/iron-input';
+import '../../plugins/gr-endpoint-decorator/gr-endpoint-decorator';
+import '../../plugins/gr-endpoint-param/gr-endpoint-param';
+import '../../shared/gr-download-commands/gr-download-commands';
+import '../../shared/gr-rest-api-interface/gr-rest-api-interface';
+import '../../shared/gr-select/gr-select';
+import '../../../styles/gr-form-styles';
+import '../../../styles/gr-subpage-styles';
+import '../../../styles/shared-styles';
+import '../gr-repo-plugin-config/gr-repo-plugin-config';
+import {GestureEventListeners} from '@polymer/polymer/lib/mixins/gesture-event-listeners';
+import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin';
+import {PolymerElement} from '@polymer/polymer/polymer-element';
+import {htmlTemplate} from './gr-repo_html';
+import {GerritNav} from '../../core/gr-navigation/gr-navigation';
+import {customElement, property, observe} from '@polymer/decorators';
+import {
+  RestApiService,
+  ErrorCallback,
+} from '../../../services/services/gr-rest-api/gr-rest-api';
+import {
+  ConfigInfo,
+  RepoName,
+  InheritedBooleanInfo,
+  SchemesInfoMap,
+  ConfigInput,
+  PluginParameterToConfigParameterInfoMap,
+  PluginNameToPluginParametersMap,
+} from '../../../types/common';
+import {PluginData} from '../gr-repo-plugin-config/gr-repo-plugin-config';
+import {ProjectState} from '../../../constants/constants';
+import {PolymerDeepPropertyChange} from '@polymer/polymer/interfaces';
+import {hasOwnProperty} from '../../../utils/common-util';
+
+const STATES = {
+  active: {value: ProjectState.ACTIVE, label: 'Active'},
+  readOnly: {value: ProjectState.READ_ONLY, label: 'Read Only'},
+  hidden: {value: ProjectState.HIDDEN, label: 'Hidden'},
+};
+
+const SUBMIT_TYPES = {
+  // Exclude INHERIT, which is handled specially.
+  mergeIfNecessary: {
+    value: 'MERGE_IF_NECESSARY',
+    label: 'Merge if necessary',
+  },
+  fastForwardOnly: {
+    value: 'FAST_FORWARD_ONLY',
+    label: 'Fast forward only',
+  },
+  rebaseAlways: {
+    value: 'REBASE_ALWAYS',
+    label: 'Rebase Always',
+  },
+  rebaseIfNecessary: {
+    value: 'REBASE_IF_NECESSARY',
+    label: 'Rebase if necessary',
+  },
+  mergeAlways: {
+    value: 'MERGE_ALWAYS',
+    label: 'Merge always',
+  },
+  cherryPick: {
+    value: 'CHERRY_PICK',
+    label: 'Cherry pick',
+  },
+};
+
+export interface GrRepo {
+  $: {
+    restAPI: RestApiService & Element;
+  };
+}
+@customElement('gr-repo')
+export class GrRepo extends GestureEventListeners(
+  LegacyElementMixin(PolymerElement)
+) {
+  static get template() {
+    return htmlTemplate;
+  }
+
+  @property({type: String})
+  repo?: RepoName;
+
+  @property({type: Boolean})
+  _configChanged = false;
+
+  @property({type: Boolean})
+  _loading = true;
+
+  @property({type: Boolean, observer: '_loggedInChanged'})
+  _loggedIn = false;
+
+  @property({type: Object})
+  _repoConfig?: ConfigInfo;
+
+  @property({
+    type: Array,
+    computed: '_computePluginData(_repoConfig.plugin_config.*)',
+  })
+  _pluginData?: PluginData[];
+
+  @property({type: Boolean})
+  _readOnly = true;
+
+  @property({type: Array})
+  _states = Object.values(STATES);
+
+  @property({
+    type: Array,
+    computed: '_computeSchemes(_schemesDefault, _schemesObj)',
+    observer: '_schemesChanged',
+  })
+  _schemes: string[] = [];
+
+  // This is workaround to have _schemes with default value [],
+  // because assignment doesn't work when property has a computed attribute.
+  @property({type: Array})
+  _schemesDefault: string[] = [];
+
+  @property({type: String})
+  _selectedCommand = 'Clone';
+
+  @property({type: String})
+  _selectedScheme?: string;
+
+  @property({type: Object})
+  _schemesObj?: SchemesInfoMap;
+
+  /** @override */
+  attached() {
+    super.attached();
+    this._loadRepo();
+
+    this.dispatchEvent(
+      new CustomEvent('title-change', {
+        detail: {title: this.repo},
+        composed: true,
+        bubbles: true,
+      })
+    );
+  }
+
+  _computePluginData(
+    configRecord: PolymerDeepPropertyChange<
+      PluginNameToPluginParametersMap,
+      PluginNameToPluginParametersMap
+    >
+  ) {
+    if (!configRecord || !configRecord.base) {
+      return [];
+    }
+
+    const pluginConfig = configRecord.base;
+    return Object.keys(pluginConfig).map(name => {
+      return {name, config: pluginConfig[name]};
+    });
+  }
+
+  _loadRepo() {
+    if (!this.repo) {
+      return Promise.resolve();
+    }
+
+    const promises = [];
+
+    const errFn: ErrorCallback = response => {
+      this.dispatchEvent(
+        new CustomEvent('page-error', {
+          detail: {response},
+          composed: true,
+          bubbles: true,
+        })
+      );
+    };
+
+    promises.push(
+      this._getLoggedIn().then(loggedIn => {
+        this._loggedIn = loggedIn;
+        if (loggedIn) {
+          const repo = this.repo;
+          if (!repo) throw new Error('undefined repo');
+          this.$.restAPI.getRepoAccess(repo).then(access => {
+            if (!access || this.repo !== repo) {
+              return;
+            }
+
+            // If the user is not an owner, is_owner is not a property.
+            this._readOnly = !access[repo].is_owner;
+          });
+        }
+      })
+    );
+
+    promises.push(
+      this.$.restAPI.getProjectConfig(this.repo, errFn).then(config => {
+        if (!config) {
+          return;
+        }
+
+        if (config.default_submit_type) {
+          // The gr-select is bound to submit_type, which needs to be the
+          // *configured* submit type. When default_submit_type is
+          // present, the server reports the *effective* submit type in
+          // submit_type, so we need to overwrite it before storing the
+          // config in this.
+          config.submit_type = config.default_submit_type.configured_value;
+        }
+        if (!config.state) {
+          config.state = STATES.active.value;
+        }
+        this._repoConfig = config;
+        this._loading = false;
+      })
+    );
+
+    promises.push(
+      this.$.restAPI.getConfig().then(config => {
+        if (!config) {
+          return;
+        }
+
+        this._schemesObj = config.download.schemes;
+      })
+    );
+
+    return Promise.all(promises);
+  }
+
+  _computeLoadingClass(loading: boolean) {
+    return loading ? 'loading' : '';
+  }
+
+  _computeHideClass(arr?: PluginData[] | string[]) {
+    return !arr || !arr.length ? 'hide' : '';
+  }
+
+  _loggedInChanged(_loggedIn?: boolean) {
+    if (!_loggedIn) {
+      return;
+    }
+    this.$.restAPI.getPreferences().then(prefs => {
+      if (prefs?.download_scheme) {
+        // Note (issue 5180): normalize the download scheme with lower-case.
+        this._selectedScheme = prefs.download_scheme.toLowerCase();
+      }
+    });
+  }
+
+  _formatBooleanSelect(item: InheritedBooleanInfo) {
+    if (!item) {
+      return;
+    }
+    let inheritLabel = 'Inherit';
+    if (!(item.inherited_value === undefined)) {
+      inheritLabel = `Inherit (${item.inherited_value})`;
+    }
+    return [
+      {
+        label: inheritLabel,
+        value: 'INHERIT',
+      },
+      {
+        label: 'True',
+        value: 'TRUE',
+      },
+      {
+        label: 'False',
+        value: 'FALSE',
+      },
+    ];
+  }
+
+  _formatSubmitTypeSelect(projectConfig: ConfigInfo) {
+    if (!projectConfig) {
+      return;
+    }
+    const allValues = Object.values(SUBMIT_TYPES);
+    const type = projectConfig.default_submit_type;
+    if (!type) {
+      // Server is too old to report default_submit_type, so assume INHERIT
+      // is not a valid value.
+      return allValues;
+    }
+
+    let inheritLabel = 'Inherit';
+    if (type.inherited_value) {
+      inheritLabel = `Inherit (${type.inherited_value})`;
+      for (const val of allValues) {
+        if (val.value === type.inherited_value) {
+          inheritLabel = `Inherit (${val.label})`;
+          break;
+        }
+      }
+    }
+    return [
+      {
+        label: inheritLabel,
+        value: 'INHERIT',
+      },
+      ...allValues,
+    ];
+  }
+
+  _isLoading() {
+    return this._loading || this._loading === undefined;
+  }
+
+  _getLoggedIn() {
+    return this.$.restAPI.getLoggedIn();
+  }
+
+  _formatRepoConfigForSave(repoConfig: ConfigInfo): ConfigInput {
+    const configInputObj: ConfigInput = {};
+    for (const configKey of Object.keys(repoConfig)) {
+      const key = configKey as keyof ConfigInfo;
+      if (key === 'default_submit_type') {
+        // default_submit_type is not in the input type, and the
+        // configured value was already copied to submit_type by
+        // _loadProject. Omit this property when saving.
+        continue;
+      }
+      if (key === 'plugin_config') {
+        configInputObj.plugin_config_values = repoConfig.plugin_config;
+      } else if (typeof repoConfig[key] === 'object') {
+        const repoConfigObj: any = repoConfig[key];
+        if (repoConfigObj.configured_value) {
+          configInputObj[key as keyof ConfigInput] =
+            repoConfigObj.configured_value;
+        }
+      } else {
+        configInputObj[key as keyof ConfigInput] = repoConfig[key] as any;
+      }
+    }
+    return configInputObj;
+  }
+
+  _handleSaveRepoConfig() {
+    if (!this._repoConfig || !this.repo)
+      return Promise.reject(new Error('undefined repoConfig or repo'));
+    return this.$.restAPI
+      .saveRepoConfig(
+        this.repo,
+        this._formatRepoConfigForSave(this._repoConfig)
+      )
+      .then(() => {
+        this._configChanged = false;
+      });
+  }
+
+  @observe('_repoConfig.*')
+  _handleConfigChanged() {
+    if (this._isLoading()) {
+      return;
+    }
+    this._configChanged = true;
+  }
+
+  _computeButtonDisabled(readOnly: boolean, configChanged: boolean) {
+    return readOnly || !configChanged;
+  }
+
+  _computeHeaderClass(configChanged: boolean) {
+    return configChanged ? 'edited' : '';
+  }
+
+  _computeSchemes(schemesDefault: string[], schemesObj?: SchemesInfoMap) {
+    return !schemesObj ? schemesDefault : Object.keys(schemesObj);
+  }
+
+  _schemesChanged(schemes: string[]) {
+    if (schemes.length === 0) {
+      return;
+    }
+    if (!this._selectedScheme || !schemes.includes(this._selectedScheme)) {
+      this._selectedScheme = schemes.sort()[0];
+    }
+  }
+
+  _computeCommands(
+    repo?: RepoName,
+    schemesObj?: SchemesInfoMap,
+    _selectedScheme?: string
+  ) {
+    if (!schemesObj || !repo || !_selectedScheme) {
+      return [];
+    }
+    const commands = [];
+    let commandObj: {[title: string]: string} = {};
+    if (hasOwnProperty(schemesObj, _selectedScheme)) {
+      commandObj = schemesObj[_selectedScheme].clone_commands;
+    }
+    for (const title in commandObj) {
+      if (!hasOwnProperty(commandObj, title)) {
+        continue;
+      }
+      commands.push({
+        title,
+        command: commandObj[title]
+          .replace(/\${project}/gi, encodeURI(repo))
+          .replace(
+            /\${project-base-name}/gi,
+            encodeURI(repo.substring(repo.lastIndexOf('/') + 1))
+          ),
+      });
+    }
+    return commands;
+  }
+
+  _computeRepositoriesClass(config: InheritedBooleanInfo) {
+    return config ? 'showConfig' : '';
+  }
+
+  _computeChangesUrl(name: RepoName) {
+    return GerritNav.getUrlForProjectChanges(name);
+  }
+
+  _handlePluginConfigChanged({
+    detail: {name, config, notifyPath},
+  }: {
+    detail: {
+      name: string;
+      config: PluginParameterToConfigParameterInfoMap;
+      notifyPath: string;
+    };
+  }) {
+    if (this._repoConfig?.plugin_config) {
+      this._repoConfig.plugin_config[name] = config;
+      this.notifyPath('_repoConfig.plugin_config.' + notifyPath);
+    }
+  }
+}
+
+declare global {
+  interface HTMLElementTagNameMap {
+    'gr-repo': GrRepo;
+  }
+}
diff --git a/polygerrit-ui/app/elements/change-list/gr-change-list/gr-change-list.js b/polygerrit-ui/app/elements/change-list/gr-change-list/gr-change-list.js
deleted file mode 100644
index 2230c38..0000000
--- a/polygerrit-ui/app/elements/change-list/gr-change-list/gr-change-list.js
+++ /dev/null
@@ -1,441 +0,0 @@
-/**
- * @license
- * Copyright (C) 2015 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 '../../../styles/gr-change-list-styles.js';
-import '../../shared/gr-cursor-manager/gr-cursor-manager.js';
-import '../../shared/gr-rest-api-interface/gr-rest-api-interface.js';
-import '../gr-change-list-item/gr-change-list-item.js';
-import '../../../styles/shared-styles.js';
-import '../../plugins/gr-endpoint-decorator/gr-endpoint-decorator.js';
-import {afterNextRender} from '@polymer/polymer/lib/utils/render-status.js';
-import {GestureEventListeners} from '@polymer/polymer/lib/mixins/gesture-event-listeners.js';
-import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin.js';
-import {PolymerElement} from '@polymer/polymer/polymer-element.js';
-import {htmlTemplate} from './gr-change-list_html.js';
-import {appContext} from '../../../services/app-context.js';
-import {ChangeTableMixin} from '../../../mixins/gr-change-table-mixin/gr-change-table-mixin.js';
-import {KeyboardShortcutMixin, Shortcut} from '../../../mixins/keyboard-shortcut-mixin/keyboard-shortcut-mixin.js';
-import {GerritNav} from '../../core/gr-navigation/gr-navigation.js';
-import {getPluginEndpoints} from '../../shared/gr-js-api-interface/gr-plugin-endpoints.js';
-import {getPluginLoader} from '../../shared/gr-js-api-interface/gr-plugin-loader.js';
-import {changeIsOpen} from '../../../utils/change-util.js';
-
-const NUMBER_FIXED_COLUMNS = 3;
-const CLOSED_STATUS = ['MERGED', 'ABANDONED'];
-const LABEL_PREFIX_INVALID_PROLOG = 'Invalid-Prolog-Rules-Label-Name--';
-const MAX_SHORTCUT_CHARS = 5;
-
-/**
- * @extends PolymerElement
- */
-class GrChangeList extends ChangeTableMixin(
-    KeyboardShortcutMixin(GestureEventListeners(
-        LegacyElementMixin(PolymerElement)))) {
-  static get template() { return htmlTemplate; }
-
-  static get is() { return 'gr-change-list'; }
-  /**
-   * Fired when next page key shortcut was pressed.
-   *
-   * @event next-page
-   */
-
-  /**
-   * Fired when previous page key shortcut was pressed.
-   *
-   * @event previous-page
-   */
-
-  static get properties() {
-    return {
-    /**
-     * The logged-in user's account, or an empty object if no user is logged
-     * in.
-     */
-      account: {
-        type: Object,
-        value: null,
-      },
-      /**
-       * An array of ChangeInfo objects to render.
-       * https://gerrit-review.googlesource.com/Documentation/rest-api-changes.html#change-info
-       */
-      changes: {
-        type: Array,
-        observer: '_changesChanged',
-      },
-      /**
-       * ChangeInfo objects grouped into arrays. The sections and changes
-       * properties should not be used together.
-       *
-       * @type {!Array<{
-       *   name: string,
-       *   query: string,
-       *   results: !Array<!Object>
-       * }>}
-       */
-      sections: {
-        type: Array,
-        value() { return []; },
-      },
-      labelNames: {
-        type: Array,
-        computed: '_computeLabelNames(sections)',
-      },
-      _dynamicHeaderEndpoints: {
-        type: Array,
-      },
-      selectedIndex: {
-        type: Number,
-        notify: true,
-      },
-      showNumber: Boolean, // No default value to prevent flickering.
-      showStar: {
-        type: Boolean,
-        value: false,
-      },
-      showReviewedState: {
-        type: Boolean,
-        value: false,
-      },
-      keyEventTarget: {
-        type: Object,
-        value() { return document.body; },
-      },
-      changeTableColumns: Array,
-      visibleChangeTableColumns: Array,
-      preferences: Object,
-      _config: Object,
-    };
-  }
-
-  static get observers() {
-    return [
-      '_sectionsChanged(sections.*)',
-      '_computePreferences(account, preferences, _config)',
-    ];
-  }
-
-  keyboardShortcuts() {
-    return {
-      [Shortcut.CURSOR_NEXT_CHANGE]: '_nextChange',
-      [Shortcut.CURSOR_PREV_CHANGE]: '_prevChange',
-      [Shortcut.NEXT_PAGE]: '_nextPage',
-      [Shortcut.PREV_PAGE]: '_prevPage',
-      [Shortcut.OPEN_CHANGE]: '_openChange',
-      [Shortcut.TOGGLE_CHANGE_REVIEWED]: '_toggleChangeReviewed',
-      [Shortcut.TOGGLE_CHANGE_STAR]: '_toggleChangeStar',
-      [Shortcut.REFRESH_CHANGE_LIST]: '_refreshChangeList',
-    };
-  }
-
-  constructor() {
-    super();
-    this.flagsService = appContext.flagsService;
-  }
-
-  /** @override */
-  created() {
-    super.created();
-    this.addEventListener('keydown',
-        e => this._scopedKeydownHandler(e));
-  }
-
-  /** @override */
-  ready() {
-    super.ready();
-    this.$.restAPI.getConfig().then(config => {
-      this._config = config;
-    });
-  }
-
-  /** @override */
-  attached() {
-    super.attached();
-    getPluginLoader().awaitPluginsLoaded()
-        .then(() => {
-          this._dynamicHeaderEndpoints = getPluginEndpoints().
-              getDynamicEndpoints('change-list-header');
-        });
-  }
-
-  /**
-   * Iron-a11y-keys-behavior catches keyboard events globally. Some keyboard
-   * events must be scoped to a component level (e.g. `enter`) in order to not
-   * override native browser functionality.
-   *
-   * Context: Issue 7294
-   */
-  _scopedKeydownHandler(e) {
-    if (e.keyCode === 13) {
-      // Enter.
-      this._openChange(e);
-    }
-  }
-
-  _lowerCase(column) {
-    return column.toLowerCase();
-  }
-
-  _computePreferences(account, preferences, config) {
-    // Polymer 2: check for undefined
-    if ([account, preferences, config].includes(undefined)) {
-      return;
-    }
-
-    this.changeTableColumns = this.columnNames;
-    this.showNumber = false;
-    this.visibleChangeTableColumns = this.getEnabledColumns(this.columnNames,
-        config, this.flagsService.enabledExperiments);
-
-    if (account) {
-      this.showNumber = !!(preferences &&
-          preferences.legacycid_in_change_table);
-      if (preferences.change_table &&
-          preferences.change_table.length > 0) {
-        const prefColumns = this.getVisibleColumns(preferences.change_table);
-        this.visibleChangeTableColumns = this.getEnabledColumns(prefColumns,
-            config, this.flagsService.enabledExperiments);
-      }
-    }
-  }
-
-  _computeColspan(changeTableColumns, labelNames) {
-    if (!changeTableColumns || !labelNames) return;
-    return changeTableColumns.length + labelNames.length +
-        NUMBER_FIXED_COLUMNS;
-  }
-
-  _computeLabelNames(sections) {
-    if (!sections) { return []; }
-    let labels = [];
-    const nonExistingLabel = function(item) {
-      return !labels.includes(item);
-    };
-    for (const section of sections) {
-      if (!section.results) { continue; }
-      for (const change of section.results) {
-        if (!change.labels) { continue; }
-        const currentLabels = Object.keys(change.labels);
-        labels = labels.concat(currentLabels.filter(nonExistingLabel));
-      }
-    }
-    return labels.sort();
-  }
-
-  _computeLabelShortcut(labelName) {
-    if (labelName.startsWith(LABEL_PREFIX_INVALID_PROLOG)) {
-      labelName = labelName.slice(LABEL_PREFIX_INVALID_PROLOG.length);
-    }
-    return labelName.split('-')
-        .reduce((a, i) => {
-          if (!i) { return a; }
-          return a + i[0].toUpperCase();
-        }, '')
-        .slice(0, MAX_SHORTCUT_CHARS);
-  }
-
-  _changesChanged(changes) {
-    this.sections = changes ? [{results: changes}] : [];
-  }
-
-  _processQuery(query) {
-    let tokens = query.split(' ');
-    const invalidTokens = ['limit:', 'age:', '-age:'];
-    tokens = tokens.filter(token => !invalidTokens
-        .some(invalidToken => token.startsWith(invalidToken)));
-    return tokens.join(' ');
-  }
-
-  _sectionHref(query) {
-    return GerritNav.getUrlForSearchQuery(this._processQuery(query));
-  }
-
-  /**
-   * Maps an index local to a particular section to the absolute index
-   * across all the changes on the page.
-   *
-   * @param {number} sectionIndex index of section
-   * @param {number} localIndex index of row within section
-   * @return {number} absolute index of row in the aggregate dashboard
-   */
-  _computeItemAbsoluteIndex(sectionIndex, localIndex) {
-    let idx = 0;
-    for (let i = 0; i < sectionIndex; i++) {
-      idx += this.sections[i].results.length;
-    }
-    return idx + localIndex;
-  }
-
-  _computeItemSelected(sectionIndex, index, selectedIndex) {
-    const idx = this._computeItemAbsoluteIndex(sectionIndex, index);
-    return idx == selectedIndex;
-  }
-
-  _computeTabIndex(sectionIndex, index, selectedIndex) {
-    return this._computeItemSelected(sectionIndex, index, selectedIndex)
-      ? 0 : undefined;
-  }
-
-  _computeItemNeedsReview(account, change, showReviewedState, config) {
-    const isAttentionSetEnabled =
-        !!config && !!config.change && config.change.enable_attention_set;
-    return !isAttentionSetEnabled && showReviewedState && !change.reviewed &&
-        !change.work_in_progress &&
-        changeIsOpen(change) &&
-        (!account || account._account_id != change.owner._account_id);
-  }
-
-  _computeItemHighlight(account, change) {
-    // Do not show the assignee highlight if the change is not open.
-    if (!change ||!change.assignee ||
-        !account ||
-        CLOSED_STATUS.indexOf(change.status) !== -1) {
-      return false;
-    }
-    return account._account_id === change.assignee._account_id;
-  }
-
-  _nextChange(e) {
-    if (this.shouldSuppressKeyboardShortcut(e) ||
-        this.modifierPressed(e)) { return; }
-
-    e.preventDefault();
-    this.$.cursor.next();
-  }
-
-  _prevChange(e) {
-    if (this.shouldSuppressKeyboardShortcut(e) ||
-        this.modifierPressed(e)) { return; }
-
-    e.preventDefault();
-    this.$.cursor.previous();
-  }
-
-  _openChange(e) {
-    if (this.shouldSuppressKeyboardShortcut(e) ||
-        this.modifierPressed(e)) { return; }
-
-    e.preventDefault();
-    GerritNav.navigateToChange(this._changeForIndex(this.selectedIndex));
-  }
-
-  _nextPage(e) {
-    if (this.shouldSuppressKeyboardShortcut(e) ||
-        this.modifierPressed(e) && !this.isModifierPressed(e, 'shiftKey')) {
-      return;
-    }
-
-    e.preventDefault();
-    this.dispatchEvent(new CustomEvent('next-page', {
-      composed: true, bubbles: true,
-    }));
-  }
-
-  _prevPage(e) {
-    if (this.shouldSuppressKeyboardShortcut(e) ||
-        this.modifierPressed(e) && !this.isModifierPressed(e, 'shiftKey')) {
-      return;
-    }
-
-    e.preventDefault();
-    this.dispatchEvent(new CustomEvent('previous-page', {
-      composed: true, bubbles: true,
-    }));
-  }
-
-  _toggleChangeReviewed(e) {
-    if (this.shouldSuppressKeyboardShortcut(e) ||
-        this.modifierPressed(e)) { return; }
-
-    e.preventDefault();
-    this._toggleReviewedForIndex(this.selectedIndex);
-  }
-
-  _toggleReviewedForIndex(index) {
-    const changeEls = this._getListItems();
-    if (index >= changeEls.length || !changeEls[index]) {
-      return;
-    }
-
-    const changeEl = changeEls[index];
-    changeEl.toggleReviewed();
-  }
-
-  _refreshChangeList(e) {
-    if (this.shouldSuppressKeyboardShortcut(e)) { return; }
-
-    e.preventDefault();
-    this._reloadWindow();
-  }
-
-  _reloadWindow() {
-    window.location.reload();
-  }
-
-  _toggleChangeStar(e) {
-    if (this.shouldSuppressKeyboardShortcut(e) ||
-        this.modifierPressed(e)) { return; }
-
-    e.preventDefault();
-    this._toggleStarForIndex(this.selectedIndex);
-  }
-
-  _toggleStarForIndex(index) {
-    const changeEls = this._getListItems();
-    if (index >= changeEls.length || !changeEls[index]) {
-      return;
-    }
-
-    const changeEl = changeEls[index];
-    changeEl.shadowRoot
-        .querySelector('gr-change-star').toggleStar();
-  }
-
-  _changeForIndex(index) {
-    const changeEls = this._getListItems();
-    if (index < changeEls.length && changeEls[index]) {
-      return changeEls[index].change;
-    }
-    return null;
-  }
-
-  _getListItems() {
-    return Array.from(
-        this.root.querySelectorAll('gr-change-list-item'));
-  }
-
-  _sectionsChanged() {
-    // Flush DOM operations so that the list item elements will be loaded.
-    afterNextRender(this, () => {
-      this.$.cursor.stops = this._getListItems();
-      this.$.cursor.moveToStart();
-    });
-  }
-
-  _getSpecialEmptySlot(section) {
-    if (section.isOutgoing) return 'empty-outgoing';
-    if (section.name === 'Your Turn') return 'empty-your-turn';
-    return '';
-  }
-
-  _isEmpty(section) {
-    return !section.results.length;
-  }
-}
-
-customElements.define(GrChangeList.is, GrChangeList);
diff --git a/polygerrit-ui/app/elements/change-list/gr-change-list/gr-change-list.ts b/polygerrit-ui/app/elements/change-list/gr-change-list/gr-change-list.ts
new file mode 100644
index 0000000..a05ccf4
--- /dev/null
+++ b/polygerrit-ui/app/elements/change-list/gr-change-list/gr-change-list.ts
@@ -0,0 +1,508 @@
+/**
+ * @license
+ * Copyright (C) 2015 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 '../../../styles/gr-change-list-styles';
+import '../../shared/gr-cursor-manager/gr-cursor-manager';
+import '../../shared/gr-rest-api-interface/gr-rest-api-interface';
+import '../gr-change-list-item/gr-change-list-item';
+import '../../../styles/shared-styles';
+import '../../plugins/gr-endpoint-decorator/gr-endpoint-decorator';
+import {afterNextRender} from '@polymer/polymer/lib/utils/render-status';
+import {GestureEventListeners} from '@polymer/polymer/lib/mixins/gesture-event-listeners';
+import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin';
+import {PolymerElement} from '@polymer/polymer/polymer-element';
+import {htmlTemplate} from './gr-change-list_html';
+import {appContext} from '../../../services/app-context';
+import {ChangeTableMixin} from '../../../mixins/gr-change-table-mixin/gr-change-table-mixin';
+import {
+  KeyboardShortcutMixin,
+  Shortcut,
+  CustomKeyboardEvent,
+  Modifier,
+} from '../../../mixins/keyboard-shortcut-mixin/keyboard-shortcut-mixin';
+import {
+  GerritNav,
+  DashboardSection,
+} from '../../core/gr-navigation/gr-navigation';
+import {getPluginEndpoints} from '../../shared/gr-js-api-interface/gr-plugin-endpoints';
+import {getPluginLoader} from '../../shared/gr-js-api-interface/gr-plugin-loader';
+import {changeIsOpen} from '../../../utils/change-util';
+import {customElement, property, observe} from '@polymer/decorators';
+import {RestApiService} from '../../../services/services/gr-rest-api/gr-rest-api';
+import {GrCursorManager} from '../../shared/gr-cursor-manager/gr-cursor-manager';
+import {
+  AccountInfo,
+  ChangeInfo,
+  ServerInfo,
+  PreferencesInput,
+} from '../../../types/common';
+
+const NUMBER_FIXED_COLUMNS = 3;
+const CLOSED_STATUS = ['MERGED', 'ABANDONED'];
+const LABEL_PREFIX_INVALID_PROLOG = 'Invalid-Prolog-Rules-Label-Name--';
+const MAX_SHORTCUT_CHARS = 5;
+
+export interface ChangeListSection {
+  results: ChangeInfo[];
+}
+export interface GrChangeList {
+  $: {
+    restAPI: RestApiService & Element;
+    cursor: GrCursorManager;
+  };
+}
+@customElement('gr-change-list')
+export class GrChangeList extends ChangeTableMixin(
+  KeyboardShortcutMixin(
+    GestureEventListeners(LegacyElementMixin(PolymerElement))
+  )
+) {
+  static get template() {
+    return htmlTemplate;
+  }
+
+  /**
+   * Fired when next page key shortcut was pressed.
+   *
+   * @event next-page
+   */
+
+  /**
+   * Fired when previous page key shortcut was pressed.
+   *
+   * @event previous-page
+   */
+
+  /**
+   * The logged-in user's account, or an empty object if no user is logged
+   * in.
+   */
+  @property({type: Object})
+  account: AccountInfo | undefined = undefined;
+
+  @property({type: Array, observer: '_changesChanged'})
+  changes?: ChangeInfo[];
+
+  /**
+   * ChangeInfo objects grouped into arrays. The sections and changes
+   * properties should not be used together.
+   */
+  @property({type: Array})
+  sections: ChangeListSection[] = [];
+
+  @property({type: Array, computed: '_computeLabelNames(sections)'})
+  labelNames?: string[];
+
+  @property({type: Array})
+  _dynamicHeaderEndpoints?: string[];
+
+  @property({type: Number, notify: true})
+  selectedIndex?: number;
+
+  @property({type: Boolean})
+  showNumber?: boolean; // No default value to prevent flickering.
+
+  @property({type: Boolean})
+  showStar = false;
+
+  @property({type: Boolean})
+  showReviewedState = false;
+
+  @property({type: Object})
+  keyEventTarget: HTMLElement = document.body;
+
+  @property({type: Array})
+  changeTableColumns?: string[];
+
+  @property({type: Array})
+  visibleChangeTableColumns?: string[];
+
+  @property({type: Object})
+  preferences?: PreferencesInput;
+
+  @property({type: Object})
+  _config?: ServerInfo;
+
+  flagsService = appContext.flagsService;
+
+  keyboardShortcuts() {
+    return {
+      [Shortcut.CURSOR_NEXT_CHANGE]: '_nextChange',
+      [Shortcut.CURSOR_PREV_CHANGE]: '_prevChange',
+      [Shortcut.NEXT_PAGE]: '_nextPage',
+      [Shortcut.PREV_PAGE]: '_prevPage',
+      [Shortcut.OPEN_CHANGE]: '_openChange',
+      [Shortcut.TOGGLE_CHANGE_REVIEWED]: '_toggleChangeReviewed',
+      [Shortcut.TOGGLE_CHANGE_STAR]: '_toggleChangeStar',
+      [Shortcut.REFRESH_CHANGE_LIST]: '_refreshChangeList',
+    };
+  }
+
+  /** @override */
+  created() {
+    super.created();
+    this.addEventListener('keydown', e => this._scopedKeydownHandler(e));
+  }
+
+  /** @override */
+  ready() {
+    super.ready();
+    this.$.restAPI.getConfig().then(config => {
+      this._config = config;
+    });
+  }
+
+  /** @override */
+  attached() {
+    super.attached();
+    getPluginLoader()
+      .awaitPluginsLoaded()
+      .then(() => {
+        this._dynamicHeaderEndpoints = getPluginEndpoints().getDynamicEndpoints(
+          'change-list-header'
+        );
+      });
+  }
+
+  /**
+   * Iron-a11y-keys-behavior catches keyboard events globally. Some keyboard
+   * events must be scoped to a component level (e.g. `enter`) in order to not
+   * override native browser functionality.
+   *
+   * Context: Issue 7294
+   */
+  _scopedKeydownHandler(e: KeyboardEvent) {
+    if (e.keyCode === 13) {
+      // Enter.
+      this._openChange((e as unknown) as CustomKeyboardEvent);
+    }
+  }
+
+  _lowerCase(column: string) {
+    return column.toLowerCase();
+  }
+
+  @observe('account', 'preferences', '_config')
+  _computePreferences(
+    account?: AccountInfo,
+    preferences?: PreferencesInput,
+    config?: ServerInfo
+  ) {
+    if (!config) {
+      return;
+    }
+
+    this.changeTableColumns = this.columnNames;
+    this.showNumber = false;
+    this.visibleChangeTableColumns = this.getEnabledColumns(
+      this.columnNames,
+      config,
+      this.flagsService.enabledExperiments
+    );
+
+    if (account && preferences) {
+      this.showNumber = !!(
+        preferences && preferences.legacycid_in_change_table
+      );
+      if (preferences.change_table && preferences.change_table.length > 0) {
+        const prefColumns = this.getVisibleColumns(preferences.change_table);
+        this.visibleChangeTableColumns = this.getEnabledColumns(
+          prefColumns,
+          config,
+          this.flagsService.enabledExperiments
+        );
+      }
+    }
+  }
+
+  _computeColspan(changeTableColumns: string[], labelNames: string[]) {
+    if (!changeTableColumns || !labelNames) return;
+    return changeTableColumns.length + labelNames.length + NUMBER_FIXED_COLUMNS;
+  }
+
+  _computeLabelNames(sections: ChangeListSection[]) {
+    if (!sections) {
+      return [];
+    }
+    let labels: string[] = [];
+    const nonExistingLabel = function (item: string) {
+      return !labels.includes(item);
+    };
+    for (const section of sections) {
+      if (!section.results) {
+        continue;
+      }
+      for (const change of section.results) {
+        if (!change.labels) {
+          continue;
+        }
+        const currentLabels = Object.keys(change.labels);
+        labels = labels.concat(currentLabels.filter(nonExistingLabel));
+      }
+    }
+    return labels.sort();
+  }
+
+  _computeLabelShortcut(labelName: string) {
+    if (labelName.startsWith(LABEL_PREFIX_INVALID_PROLOG)) {
+      labelName = labelName.slice(LABEL_PREFIX_INVALID_PROLOG.length);
+    }
+    return labelName
+      .split('-')
+      .reduce((a, i) => {
+        if (!i) {
+          return a;
+        }
+        return a + i[0].toUpperCase();
+      }, '')
+      .slice(0, MAX_SHORTCUT_CHARS);
+  }
+
+  _changesChanged(changes: ChangeInfo[]) {
+    this.sections = changes ? [{results: changes}] : [];
+  }
+
+  _processQuery(query: string) {
+    let tokens = query.split(' ');
+    const invalidTokens = ['limit:', 'age:', '-age:'];
+    tokens = tokens.filter(
+      token =>
+        !invalidTokens.some(invalidToken => token.startsWith(invalidToken))
+    );
+    return tokens.join(' ');
+  }
+
+  _sectionHref(query: string) {
+    return GerritNav.getUrlForSearchQuery(this._processQuery(query));
+  }
+
+  /**
+   * Maps an index local to a particular section to the absolute index
+   * across all the changes on the page.
+   *
+   * @param sectionIndex index of section
+   * @param localIndex index of row within section
+   * @return absolute index of row in the aggregate dashboard
+   */
+  _computeItemAbsoluteIndex(sectionIndex: number, localIndex: number) {
+    let idx = 0;
+    for (let i = 0; i < sectionIndex; i++) {
+      idx += this.sections[i].results.length;
+    }
+    return idx + localIndex;
+  }
+
+  _computeItemSelected(
+    sectionIndex: number,
+    index: number,
+    selectedIndex: number
+  ) {
+    const idx = this._computeItemAbsoluteIndex(sectionIndex, index);
+    return idx === selectedIndex;
+  }
+
+  _computeTabIndex(sectionIndex: number, index: number, selectedIndex: number) {
+    return this._computeItemSelected(sectionIndex, index, selectedIndex)
+      ? 0
+      : undefined;
+  }
+
+  _computeItemNeedsReview(
+    account: AccountInfo | undefined,
+    change: ChangeInfo,
+    showReviewedState: boolean,
+    config?: ServerInfo
+  ) {
+    const isAttentionSetEnabled =
+      !!config && !!config.change && config.change.enable_attention_set;
+    return (
+      !isAttentionSetEnabled &&
+      showReviewedState &&
+      !change.reviewed &&
+      !change.work_in_progress &&
+      changeIsOpen(change) &&
+      (!account || account._account_id !== change.owner._account_id)
+    );
+  }
+
+  _computeItemHighlight(account?: AccountInfo, change?: ChangeInfo) {
+    // Do not show the assignee highlight if the change is not open.
+    if (
+      !change ||
+      !change.assignee ||
+      !account ||
+      CLOSED_STATUS.indexOf(change.status) !== -1
+    ) {
+      return false;
+    }
+    return account._account_id === change.assignee._account_id;
+  }
+
+  _nextChange(e: CustomKeyboardEvent) {
+    if (this.shouldSuppressKeyboardShortcut(e) || this.modifierPressed(e)) {
+      return;
+    }
+
+    e.preventDefault();
+    this.$.cursor.next();
+  }
+
+  _prevChange(e: CustomKeyboardEvent) {
+    if (this.shouldSuppressKeyboardShortcut(e) || this.modifierPressed(e)) {
+      return;
+    }
+
+    e.preventDefault();
+    this.$.cursor.previous();
+  }
+
+  _openChange(e: CustomKeyboardEvent) {
+    if (this.shouldSuppressKeyboardShortcut(e) || this.modifierPressed(e)) {
+      return;
+    }
+
+    e.preventDefault();
+    const change = this._changeForIndex(this.selectedIndex);
+    if (change) GerritNav.navigateToChange(change);
+  }
+
+  _nextPage(e: CustomKeyboardEvent) {
+    if (
+      this.shouldSuppressKeyboardShortcut(e) ||
+      (this.modifierPressed(e) &&
+        !this.isModifierPressed(e, Modifier.SHIFT_KEY))
+    ) {
+      return;
+    }
+
+    e.preventDefault();
+    this.dispatchEvent(
+      new CustomEvent('next-page', {
+        composed: true,
+        bubbles: true,
+      })
+    );
+  }
+
+  _prevPage(e: CustomKeyboardEvent) {
+    if (
+      this.shouldSuppressKeyboardShortcut(e) ||
+      (this.modifierPressed(e) &&
+        !this.isModifierPressed(e, Modifier.SHIFT_KEY))
+    ) {
+      return;
+    }
+
+    e.preventDefault();
+    this.dispatchEvent(
+      new CustomEvent('previous-page', {
+        composed: true,
+        bubbles: true,
+      })
+    );
+  }
+
+  _toggleChangeReviewed(e: CustomKeyboardEvent) {
+    if (this.shouldSuppressKeyboardShortcut(e) || this.modifierPressed(e)) {
+      return;
+    }
+
+    e.preventDefault();
+    this._toggleReviewedForIndex(this.selectedIndex);
+  }
+
+  _toggleReviewedForIndex(index?: number) {
+    const changeEls = this._getListItems();
+    if (index === undefined || index >= changeEls.length || !changeEls[index]) {
+      return;
+    }
+
+    const changeEl = changeEls[index];
+    changeEl.toggleReviewed();
+  }
+
+  _refreshChangeList(e: CustomKeyboardEvent) {
+    if (this.shouldSuppressKeyboardShortcut(e)) {
+      return;
+    }
+
+    e.preventDefault();
+    this._reloadWindow();
+  }
+
+  _reloadWindow() {
+    window.location.reload();
+  }
+
+  _toggleChangeStar(e: CustomKeyboardEvent) {
+    if (this.shouldSuppressKeyboardShortcut(e) || this.modifierPressed(e)) {
+      return;
+    }
+
+    e.preventDefault();
+    this._toggleStarForIndex(this.selectedIndex);
+  }
+
+  _toggleStarForIndex(index?: number) {
+    const changeEls = this._getListItems();
+    if (index === undefined || index >= changeEls.length || !changeEls[index]) {
+      return;
+    }
+
+    const changeEl = changeEls[index];
+    const grChangeStar = changeEl?.shadowRoot?.querySelector('gr-change-star');
+    if (grChangeStar) grChangeStar.toggleStar();
+  }
+
+  _changeForIndex(index?: number) {
+    const changeEls = this._getListItems();
+    if (index !== undefined && index < changeEls.length && changeEls[index]) {
+      return changeEls[index].change;
+    }
+    return null;
+  }
+
+  _getListItems() {
+    const items = this.root?.querySelectorAll('gr-change-list-item');
+    return !items ? [] : Array.from(items);
+  }
+
+  @observe('sections.*')
+  _sectionsChanged() {
+    // Flush DOM operations so that the list item elements will be loaded.
+    afterNextRender(this, () => {
+      this.$.cursor.stops = this._getListItems();
+      this.$.cursor.moveToStart();
+    });
+  }
+
+  _getSpecialEmptySlot(section: DashboardSection) {
+    if (section.isOutgoing) return 'empty-outgoing';
+    if (section.name === 'Your Turn') return 'empty-your-turn';
+    return '';
+  }
+
+  _isEmpty(section: DashboardSection) {
+    return !section.results?.length;
+  }
+}
+
+declare global {
+  interface HTMLElementTagNameMap {
+    'gr-change-list': GrChangeList;
+  }
+}
diff --git a/polygerrit-ui/app/elements/change-list/gr-change-list/gr-change-list_test.js b/polygerrit-ui/app/elements/change-list/gr-change-list/gr-change-list_test.js
index 0493966..56a16b5 100644
--- a/polygerrit-ui/app/elements/change-list/gr-change-list/gr-change-list_test.js
+++ b/polygerrit-ui/app/elements/change-list/gr-change-list/gr-change-list_test.js
@@ -50,8 +50,8 @@
   suite('test show change number not logged in', () => {
     setup(() => {
       element = basicFixture.instantiate();
-      element.account = null;
-      element.preferences = null;
+      element.account = undefined;
+      element.preferences = undefined;
       element._config = {};
     });
 
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 54828ea..1be2f75 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
@@ -104,6 +104,12 @@
       display: flex;
       width: 100%;
     }
+    gr-endpoint-decorator[name='reply-text'] {
+      flex-direction: column;
+    }
+    #textarea {
+      flex: 1;
+    }
     .previewContainer gr-formatted-text {
       background: var(--table-header-background-color);
       padding: var(--spacing-l);
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 358ebb0..c70678f 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
@@ -1508,7 +1508,9 @@
         .querySelector('gr-button.send'));
     assert.isFalse(sendStub.called);
 
-    element.draftCommentThreads = [{comments: [{__draft: true}]}];
+    element.draftCommentThreads = [{comments: [
+      {__draft: true, path: 'test', line: 1, patch_set: 1},
+    ]}];
     flushAsynchronousOperations();
 
     MockInteractions.tap(element.shadowRoot
@@ -1521,7 +1523,9 @@
     // computed to false.
     element.draftCommentThreads = [];
     assert.equal(element.getFocusStops().end, element.$.cancelButton);
-    element.draftCommentThreads = [{comments: [{__draft: true}]}];
+    element.draftCommentThreads = [{comments: [
+      {__draft: true, path: 'test', line: 1, patch_set: 1},
+    ]}];
     assert.equal(element.getFocusStops().end, element.$.sendButton);
   });
 
diff --git a/polygerrit-ui/app/elements/core/gr-navigation/gr-navigation.ts b/polygerrit-ui/app/elements/core/gr-navigation/gr-navigation.ts
index 5016c40..19e733e 100644
--- a/polygerrit-ui/app/elements/core/gr-navigation/gr-navigation.ts
+++ b/polygerrit-ui/app/elements/core/gr-navigation/gr-navigation.ts
@@ -133,6 +133,7 @@
   hideIfEmpty?: boolean;
   assigneeOnly?: boolean;
   isOutgoing?: boolean;
+  results?: ChangeInfo[];
 }
 
 export interface UserDashboardConfig {
diff --git a/polygerrit-ui/app/elements/diff/gr-comment-api/gr-comment-api.ts b/polygerrit-ui/app/elements/diff/gr-comment-api/gr-comment-api.ts
index b49f522..ba37387 100644
--- a/polygerrit-ui/app/elements/diff/gr-comment-api/gr-comment-api.ts
+++ b/polygerrit-ui/app/elements/diff/gr-comment-api/gr-comment-api.ts
@@ -45,6 +45,7 @@
 export interface HumanCommentInfoWithPath extends CommentInfo {
   path: string;
   __draft?: boolean;
+  __date?: Date;
 }
 
 export interface RobotCommentInfoWithPath extends RobotCommentInfo {
@@ -80,6 +81,27 @@
   return !!(x as PatchSetFile).path;
 }
 
+export function sortComments<
+  T extends CommentInfoWithPath | CommentInfoWithTwoPaths
+>(comments: T[]): T[] {
+  return comments.slice(0).sort((c1, c2) => {
+    const d1 = !!(c1 as HumanCommentInfoWithPath).__draft;
+    const d2 = !!(c2 as HumanCommentInfoWithPath).__draft;
+    if (d1 !== d2) return d1 ? 1 : -1;
+    const date1 =
+      (c1.updated && parseDate(c1.updated)) ||
+      (c1 as HumanCommentInfoWithPath).__date;
+    const date2 =
+      (c2.updated && parseDate(c2.updated)) ||
+      (c2 as HumanCommentInfoWithPath).__date;
+    const dateDiff = date1.valueOf() - date2.valueOf();
+    if (dateDiff) {
+      return dateDiff;
+    }
+    return c1.id < c2.id ? -1 : c1.id > c2.id ? 1 : 0;
+  });
+}
+
 export interface CommentThread {
   comments: CommentInfoWithTwoPaths[];
   patchNum?: PatchSetNum;
@@ -519,7 +541,7 @@
     // However, this doesn't affect the final result of computeUnresolvedNum
     // This should be fixed by removing CommentInfoWithTwoPaths later
     const threads = this.getCommentThreads(
-      this._sortComments(comments) as CommentInfoWithTwoPaths[]
+      sortComments(comments) as CommentInfoWithTwoPaths[]
     );
 
     const unresolvedThreads = threads.filter(
@@ -533,26 +555,10 @@
 
   getAllThreadsForChange() {
     const comments = this._commentObjToArrayWithFile(this.getAllComments(true));
-    const sortedComments = this._sortComments(comments);
+    const sortedComments = sortComments(comments);
     return this.getCommentThreads(sortedComments);
   }
 
-  _sortComments<T extends CommentInfoWithPath | CommentInfoWithTwoPaths>(
-    comments: T[]
-  ): T[] {
-    return comments.slice(0).sort((c1, c2) => {
-      const d1 = !!(c1 as HumanCommentInfoWithPath).__draft;
-      const d2 = !!(c2 as HumanCommentInfoWithPath).__draft;
-      if (d1 !== d2) return d1 ? 1 : -1;
-      const dateDiff =
-        parseDate(c1.updated).valueOf() - parseDate(c2.updated).valueOf();
-      if (dateDiff) {
-        return dateDiff;
-      }
-      return c1.id < c2.id ? -1 : c1.id > c2.id ? 1 : 0;
-    });
-  }
-
   /**
    * Computes all of the comments in thread format.
    *
diff --git a/polygerrit-ui/app/elements/diff/gr-diff-host/gr-diff-host.js b/polygerrit-ui/app/elements/diff/gr-diff-host/gr-diff-host.js
index 7693d56..02d8cfa 100644
--- a/polygerrit-ui/app/elements/diff/gr-diff-host/gr-diff-host.js
+++ b/polygerrit-ui/app/elements/diff/gr-diff-host/gr-diff-host.js
@@ -24,11 +24,11 @@
 import {PolymerElement} from '@polymer/polymer/polymer-element.js';
 import {htmlTemplate} from './gr-diff-host_html.js';
 import {GrDiffBuilder} from '../gr-diff-builder/gr-diff-builder.js';
-import {parseDate} from '../../../utils/date-util.js';
 import {GerritNav} from '../../core/gr-navigation/gr-navigation.js';
 import {DiffSide, rangesEqual} from '../gr-diff/gr-diff-utils.js';
 import {appContext} from '../../../services/app-context.js';
 import {getParentIndex, isMergeParent} from '../../../utils/patch-set-util.js';
+import {sortComments} from '../gr-comment-api/gr-comment-api.js';
 
 const MSG_EMPTY_BLAME = 'No blame information for this diff.';
 
@@ -692,20 +692,12 @@
     }
   }
 
-  _sortComments(comments) {
-    return comments.slice(0).sort((a, b) => {
-      if (b.__draft && !a.__draft ) { return -1; }
-      if (a.__draft && !b.__draft ) { return 1; }
-      return parseDate(a.updated) - parseDate(b.updated);
-    });
-  }
-
   /**
    * @param {!Array<!Object>} comments
    * @return {!Array<!Object>} Threads for the given comments.
    */
   _createThreads(comments) {
-    const sortedComments = this._sortComments(comments);
+    const sortedComments = sortComments(comments);
     const threads = [];
     for (const comment of sortedComments) {
       // If the comment is in reply to another comment, find that comment's
diff --git a/polygerrit-ui/app/elements/diff/gr-diff-host/gr-diff-host_test.js b/polygerrit-ui/app/elements/diff/gr-diff-host/gr-diff-host_test.js
index cd43fdb..11539b1 100644
--- a/polygerrit-ui/app/elements/diff/gr-diff-host/gr-diff-host_test.js
+++ b/polygerrit-ui/app/elements/diff/gr-diff-host/gr-diff-host_test.js
@@ -21,6 +21,7 @@
 import {GerritNav} from '../../core/gr-navigation/gr-navigation.js';
 import {dom} from '@polymer/polymer/lib/legacy/polymer.dom.js';
 import {DiffSide} from '../gr-diff/gr-diff-utils.js';
+import {sortComments} from '../gr-comment-api/gr-comment-api.js';
 
 const basicFixture = fixtureFromElement('gr-diff-host');
 
@@ -1131,7 +1132,7 @@
         in_reply_to: 'sallys_confession',
       },
     ];
-    const sortedComments = element._sortComments(comments);
+    const sortedComments = sortComments(comments);
     assert.equal(sortedComments[0], comments[1]);
     assert.equal(sortedComments[1], comments[2]);
     assert.equal(sortedComments[2], comments[0]);
diff --git a/polygerrit-ui/app/elements/settings/gr-settings-view/gr-settings-view_html.ts b/polygerrit-ui/app/elements/settings/gr-settings-view/gr-settings-view_html.ts
index e80e646..ea26a51 100644
--- a/polygerrit-ui/app/elements/settings/gr-settings-view/gr-settings-view_html.ts
+++ b/polygerrit-ui/app/elements/settings/gr-settings-view/gr-settings-view_html.ts
@@ -494,6 +494,15 @@
               </td>
             </tr>
             <tr>
+              <td>Changes requesting my attention</td>
+              <td>
+                <code class="queryExample">
+                  "Gerrit-Attention: <em>Your Name</em>
+                  &lt;<em>your.email@example.com</em>&gt;"
+                </code>
+              </td>
+            </tr>
+            <tr>
               <td>Changes from a specific owner</td>
               <td>
                 <code class="queryExample">
diff --git a/polygerrit-ui/app/elements/shared/gr-comment-thread/gr-comment-thread.js b/polygerrit-ui/app/elements/shared/gr-comment-thread/gr-comment-thread.js
index 5de7a66..661da6f 100644
--- a/polygerrit-ui/app/elements/shared/gr-comment-thread/gr-comment-thread.js
+++ b/polygerrit-ui/app/elements/shared/gr-comment-thread/gr-comment-thread.js
@@ -24,11 +24,11 @@
 import {PolymerElement} from '@polymer/polymer/polymer-element.js';
 import {htmlTemplate} from './gr-comment-thread_html.js';
 import {KeyboardShortcutMixin} from '../../../mixins/keyboard-shortcut-mixin/keyboard-shortcut-mixin.js';
-import {parseDate} from '../../../utils/date-util.js';
 import {GerritNav} from '../../core/gr-navigation/gr-navigation.js';
 import {appContext} from '../../../services/app-context.js';
 import {SpecialFilePath} from '../../../constants/constants.js';
 import {computeDisplayPath} from '../../../utils/path-list-util.js';
+import {sortComments} from '../../diff/gr-comment-api/gr-comment-api.js';
 
 const UNRESOLVED_EXPAND_COUNT = 5;
 const NEWLINE_PATTERN = /\n/g;
@@ -273,7 +273,7 @@
   }
 
   _commentsChanged() {
-    this._orderedComments = this._sortedComments(this.comments);
+    this._orderedComments = sortComments(this.comments);
     this.updateThreadProperties();
   }
 
@@ -342,22 +342,6 @@
     }
   }
 
-  _sortedComments(comments) {
-    return comments.slice().sort((c1, c2) => {
-      const c1Date = c1.__date || parseDate(c1.updated);
-      const c2Date = c2.__date || parseDate(c2.updated);
-      const dateCompare = c1Date - c2Date;
-      // Ensure drafts are at the end. There should only be one but in edge
-      // cases could be more. In the unlikely event two drafts are being
-      // compared, use the typical date compare.
-      if (c2.__draft && !c1.__draft ) { return -1; }
-      if (c1.__draft && !c2.__draft ) { return 1; }
-      if (dateCompare === 0 && (!c1.id || !c1.id.localeCompare)) { return 0; }
-      // If same date, fall back to sorting by id.
-      return dateCompare ? dateCompare : c1.id.localeCompare(c2.id);
-    });
-  }
-
   _createReplyComment(content, opt_isEditing,
       opt_unresolved) {
     this.reporting.recordDraftInteraction();
diff --git a/polygerrit-ui/app/elements/shared/gr-comment-thread/gr-comment-thread_test.js b/polygerrit-ui/app/elements/shared/gr-comment-thread/gr-comment-thread_test.js
index a25a4c7..7c176ac 100644
--- a/polygerrit-ui/app/elements/shared/gr-comment-thread/gr-comment-thread_test.js
+++ b/polygerrit-ui/app/elements/shared/gr-comment-thread/gr-comment-thread_test.js
@@ -19,6 +19,7 @@
 import './gr-comment-thread.js';
 import {GerritNav} from '../../core/gr-navigation/gr-navigation.js';
 import {SpecialFilePath} from '../../../constants/constants.js';
+import {sortComments} from '../../diff/gr-comment-api/gr-comment-api.js';
 
 const basicFixture = fixtureFromElement('gr-comment-thread');
 
@@ -34,6 +35,9 @@
       });
 
       element = basicFixture.instantiate();
+      element.patchNum = '3';
+      element.changeNum = '1';
+      flushAsynchronousOperations();
     });
 
     test('comments are sorted correctly', () => {
@@ -66,7 +70,7 @@
           updated: '2015-12-24 15:00:20.396000000',
         },
       ];
-      const results = element._sortedComments(comments);
+      const results = sortComments(comments);
       assert.deepEqual(results, [
         {
           id: 'sally_to_dr_finklestein',
@@ -248,6 +252,8 @@
       deleteDiffDraft() { return Promise.resolve({ok: true}); },
     });
     element = withCommentFixture.instantiate();
+    element.patchNum = '1';
+    element.changeNum = '1';
     element.comments = [{
       author: {
         name: 'Mr. Peanutbutter',
@@ -310,6 +316,7 @@
         email: 'tenn1sballchaser@aol.com',
       },
       id: 'baf0414d_60047215',
+      path: 'test',
       line: 5,
       message: 'is this a crossover episode!?\nIt might be!',
       updated: '2015-12-08 19:48:33.843000000',
@@ -495,6 +502,7 @@
       },
       id: 'baf0414d_60047215',
       line: 5,
+      path: 'test',
       message: 'is this a crossover episode!?',
       updated: '2015-12-08 19:48:33.843000000',
       __draft: true,
@@ -519,6 +527,7 @@
         email: 'tenn1sballchaser@aol.com',
       },
       id: 'baf0414d_60047215',
+      path: 'test',
       line: 5,
       message: 'is this a crossover episode!?',
       updated: '2015-12-08 19:48:31.843000000',
@@ -530,6 +539,7 @@
       },
       __draftID: '1',
       in_reply_to: 'baf0414d_60047215',
+      path: 'test',
       line: 5,
       message: 'yes',
       updated: '2015-12-08 19:48:32.843000000',
@@ -543,6 +553,7 @@
       },
       __draftID: '2',
       in_reply_to: 'baf0414d_60047215',
+      path: 'test',
       line: 5,
       message: 'no',
       updated: '2015-12-08 19:48:33.843000000',
@@ -823,6 +834,8 @@
       deleteDiffDraft() { return Promise.resolve({ok: true}); },
     });
     element = withCommentFixture.instantiate();
+    element.patchNum = '1';
+    element.changeNum = '1';
     element.comments = [{
       author: {
         name: 'Mr. Peanutbutter',
diff --git a/polygerrit-ui/app/elements/shared/gr-comment/gr-comment.ts b/polygerrit-ui/app/elements/shared/gr-comment/gr-comment.ts
index d240f2c..23e024b 100644
--- a/polygerrit-ui/app/elements/shared/gr-comment/gr-comment.ts
+++ b/polygerrit-ui/app/elements/shared/gr-comment/gr-comment.ts
@@ -159,10 +159,10 @@
    */
 
   @property({type: Number})
-  changeNum!: number;
+  changeNum?: number;
 
   @property({type: Object, notify: true, observer: '_commentChanged'})
-  comment!: Comment | RobotComment;
+  comment?: Comment | RobotComment;
 
   @property({type: Array})
   comments?: (Comment | RobotComment)[];
@@ -186,7 +186,7 @@
   hasChildren?: boolean;
 
   @property({type: String})
-  patchNum!: PatchSetNum;
+  patchNum?: PatchSetNum;
 
   @property({type: Boolean})
   showActions?: boolean;
@@ -275,18 +275,6 @@
   reporting = appContext.reportingService;
 
   /** @override */
-  ready() {
-    super.ready();
-    if (
-      this.changeNum === undefined ||
-      this.patchNum === undefined ||
-      this.comment === undefined
-    ) {
-      throw new Error('Not all required properties are defined.');
-    }
-  }
-
-  /** @override */
   attached() {
     super.attached();
     this.$.restAPI.getAccount().then(account => {
@@ -460,7 +448,7 @@
           resComment.__draft = true;
           // Maintain the ephemeral draft ID for identification by other
           // elements.
-          if (this.comment.__draftID) {
+          if (this.comment?.__draftID) {
             resComment.__draftID = this.comment.__draftID;
           }
           resComment.__commentSide = this.commentSide;
@@ -482,8 +470,10 @@
     // prior to it being saved.
     this.cancelDebouncer('store');
 
-    if (!this.comment.path || this.comment.line === undefined)
-      throw new Error('Cannot erase Draft Comment');
+    if (!this.comment?.path) throw new Error('Cannot erase Draft Comment');
+    if (this.changeNum === undefined) {
+      throw new Error('undefined changeNum');
+    }
     this.$.storage.eraseDraftComment({
       changeNum: this.changeNum,
       patchNum: this._getPatchNum(),
@@ -504,12 +494,13 @@
 
   @observe('comment', 'comments.*')
   _computeHasHumanReply() {
-    if (!this.comment || !this.comments) return;
+    const comment = this.comment;
+    if (!comment || !this.comments) return;
     // hide please fix button for robot comment that has human reply
     this._hasHumanReply = this.comments.some(
       c =>
         c.in_reply_to &&
-        c.in_reply_to === this.comment.id &&
+        c.in_reply_to === comment.id &&
         !(c as RobotComment).robot_id
     );
   }
@@ -582,7 +573,11 @@
     return isAdmin && !draft ? 'showDeleteButtons' : '';
   }
 
-  _computeSaveDisabled(draft: string, comment: Comment, resolved?: boolean) {
+  _computeSaveDisabled(
+    draft: string,
+    comment: Comment | undefined,
+    resolved?: boolean
+  ) {
     // If resolved state has changed and a msg exists, save should be enabled.
     if (!comment || (comment.unresolved === resolved && draft)) {
       return false;
@@ -628,37 +623,42 @@
       return;
     }
 
-    this.debounce(
-      'store',
-      () => {
-        const message = this._messageText;
-        if (!this.comment.path || this.comment.line === undefined)
-          throw new Error('missing path or line in comment');
-        const commentLocation: StorageLocation = {
-          changeNum: this.changeNum,
-          patchNum: this._getPatchNum(),
-          path: this.comment.path,
-          line: this.comment.line,
-          range: this.comment.range,
-        };
+    const patchNum = this.comment.patch_set
+      ? this.comment.patch_set
+      : this._getPatchNum();
+    const {path, line, range} = this.comment;
+    if (path) {
+      this.debounce(
+        'store',
+        () => {
+          const message = this._messageText;
+          if (this.changeNum === undefined) {
+            throw new Error('undefined changeNum');
+          }
+          const commentLocation: StorageLocation = {
+            changeNum: this.changeNum,
+            patchNum,
+            path,
+            line,
+            range,
+          };
 
-        if ((!this._messageText || !this._messageText.length) && oldValue) {
-          // If the draft has been modified to be empty, then erase the storage
-          // entry.
-          this.$.storage.eraseDraftComment(commentLocation);
-        } else {
-          this.$.storage.setDraftComment(commentLocation, message);
-        }
-      },
-      STORAGE_DEBOUNCE_INTERVAL
-    );
+          if ((!message || !message.length) && oldValue) {
+            // If the draft has been modified to be empty, then erase the storage
+            // entry.
+            this.$.storage.eraseDraftComment(commentLocation);
+          } else {
+            this.$.storage.setDraftComment(commentLocation, message);
+          }
+        },
+        STORAGE_DEBOUNCE_INTERVAL
+      );
+    }
   }
 
   _handleAnchorClick(e: Event) {
     e.preventDefault();
-    if (!this.comment.line) {
-      return;
-    }
+    if (!this.comment) return;
     this.dispatchEvent(
       new CustomEvent('comment-anchor-tap', {
         bubbles: true,
@@ -673,8 +673,7 @@
 
   _handleEdit(e: Event) {
     e.preventDefault();
-    if (!this.comment.message) throw new Error('message undefined');
-    this._messageText = this.comment.message;
+    if (this.comment?.message) this._messageText = this.comment.message;
     this.editing = true;
     this.reporting.recordDraftInteraction();
   }
@@ -686,7 +685,7 @@
     if (this.disabled) {
       return;
     }
-    const timingLabel = this.comment.id
+    const timingLabel = this.comment?.id
       ? REPORT_UPDATE_DRAFT
       : REPORT_CREATE_DRAFT;
     const timer = this.reporting.getTimer(timingLabel);
@@ -700,7 +699,7 @@
     e.preventDefault();
 
     if (
-      !this.comment.message ||
+      !this.comment?.message ||
       this.comment.message.trim().length === 0 ||
       !this.comment.id
     ) {
@@ -773,6 +772,7 @@
   }
 
   _discardDraft() {
+    if (!this.comment) return Promise.reject(new Error('undefined comment'));
     if (!this.comment.__draft) {
       return Promise.reject(new Error('Cannot discard a non-draft comment.'));
     }
@@ -871,7 +871,10 @@
     this._handleFailedDraftRequest();
   }
 
-  _saveDraft(draft: Comment) {
+  _saveDraft(draft?: Comment) {
+    if (!draft || this.changeNum === undefined || this.patchNum === undefined) {
+      throw new Error('undefined draft or changeNum or patchNum');
+    }
     this._showStartRequest();
     return this.$.restAPI
       .saveDiffDraft(this.changeNum, this.patchNum, draft)
@@ -893,6 +896,9 @@
   }
 
   _deleteDraft(draft: Comment) {
+    if (this.changeNum === undefined || this.patchNum === undefined) {
+      throw new Error('undefined changeNum or patchNum');
+    }
     this._showStartRequest();
     return this.$.restAPI
       .deleteDiffDraft(this.changeNum, this.patchNum, draft)
@@ -907,7 +913,11 @@
   }
 
   _getPatchNum(): PatchSetNum {
-    return this.isOnParent() ? ('PARENT' as PatchSetNum) : this.patchNum;
+    const patchNum = this.isOnParent()
+      ? ('PARENT' as PatchSetNum)
+      : this.patchNum;
+    if (patchNum === undefined) throw new Error('patchNum undefined');
+    return patchNum;
   }
 
   @observe('changeNum', 'patchNum', 'comment')
@@ -931,9 +941,7 @@
       comment.id ||
       comment.message ||
       comment.__otherEditing ||
-      !comment.path ||
-      !comment.line ||
-      !comment.range
+      !comment.path
     ) {
       if (comment) delete comment.__otherEditing;
       return;
@@ -958,6 +966,9 @@
     // Modify payload instead of this.comment, as this.comment is passed from
     // the parent by ref.
     const payload = this._getEventPayload();
+    if (!payload.comment) {
+      throw new Error('comment not defined in payload');
+    }
     payload.comment.unresolved = !this.$.resolvedCheckbox.checked;
     this.dispatchEvent(
       new CustomEvent('comment-update', {
@@ -1007,6 +1018,13 @@
     if (!dialog || !dialog.message) {
       throw new Error('missing confirm delete dialog');
     }
+    if (
+      !this.comment ||
+      this.changeNum === undefined ||
+      this.patchNum === undefined
+    ) {
+      throw new Error('undefined comment or changeNum or patchNum');
+    }
     this.$.restAPI
       .deleteComment(
         this.changeNum,
diff --git a/polygerrit-ui/app/elements/shared/gr-comment/gr-comment_test.js b/polygerrit-ui/app/elements/shared/gr-comment/gr-comment_test.js
index 1299b90..3847135 100644
--- a/polygerrit-ui/app/elements/shared/gr-comment/gr-comment_test.js
+++ b/polygerrit-ui/app/elements/shared/gr-comment/gr-comment_test.js
@@ -19,6 +19,7 @@
 import './gr-comment.js';
 import {html} from '@polymer/polymer/lib/utils/html-tag.js';
 import {__testOnly_UNSAVED_MESSAGE} from './gr-comment.js';
+import {SpecialFilePath} from '../../../constants/constants.js';
 
 const basicFixture = fixtureFromElement('gr-comment');
 
@@ -149,6 +150,7 @@
           email: 'tenn1sballchaser@aol.com',
         },
         line: 5,
+        path: 'test',
       };
       flush(() => {
         assert.isTrue(loadSpy.called);
@@ -315,6 +317,7 @@
       });
 
       test('create', () => {
+        element.patchNum = 1;
         element.comment = {};
         return element._handleSave(mockEvent).then(() => {
           assert.equal(element.shadowRoot.querySelector('gr-account-label').
@@ -368,11 +371,13 @@
 
     test('failed save draft request', done => {
       element.draft = true;
+      element.changeNum = 1;
+      element.patchNum = 1;
       const updateRequestStub = sinon.stub(element, '_updateRequestToast');
       const diffDraftStub =
         sinon.stub(element.$.restAPI, 'saveDiffDraft').returns(
             Promise.resolve({ok: false}));
-      element._saveDraft();
+      element._saveDraft({id: 'abc_123'});
       flush(() => {
         let args = updateRequestStub.lastCall.args;
         assert.deepEqual(args, [0, true]);
@@ -384,7 +389,7 @@
             .querySelector('.save')), 'save is visible');
         diffDraftStub.returns(
             Promise.resolve({ok: true}));
-        element._saveDraft();
+        element._saveDraft({id: 'abc_123'});
         flush(() => {
           args = updateRequestStub.lastCall.args;
           assert.deepEqual(args, [0]);
@@ -402,11 +407,13 @@
 
     test('failed save draft request with promise failure', done => {
       element.draft = true;
+      element.changeNum = 1;
+      element.patchNum = 1;
       const updateRequestStub = sinon.stub(element, '_updateRequestToast');
       const diffDraftStub =
         sinon.stub(element.$.restAPI, 'saveDiffDraft').returns(
             Promise.reject(new Error()));
-      element._saveDraft();
+      element._saveDraft({id: 'abc_123'});
       flush(() => {
         let args = updateRequestStub.lastCall.args;
         assert.deepEqual(args, [0, true]);
@@ -418,7 +425,7 @@
             .querySelector('.save')), 'save is visible');
         diffDraftStub.returns(
             Promise.resolve({ok: true}));
-        element._saveDraft();
+        element._saveDraft({id: 'abc_123'});
         flush(() => {
           args = updateRequestStub.lastCall.args;
           assert.deepEqual(args, [0]);
@@ -721,6 +728,27 @@
       });
     });
 
+    test('patchset level comment', done => {
+      const comment = {...element.comment,
+        path: SpecialFilePath.PATCHSET_LEVEL_COMMENTS, line: undefined,
+        range: undefined};
+      element.comment = comment;
+      flushAsynchronousOperations();
+      MockInteractions.tap(element.shadowRoot
+          .querySelector('.edit'));
+      assert.isTrue(element.editing);
+
+      element._messageText = 'hello world';
+      const eraseMessageDraftSpy = sinon.spy(element.$.storage,
+          'eraseDraftComment');
+      const mockEvent = {preventDefault: sinon.stub()};
+      element._handleSave(mockEvent);
+      flush(() => {
+        assert.isTrue(eraseMessageDraftSpy.called);
+        done();
+      });
+    });
+
     test('draft creation/cancellation', done => {
       assert.isFalse(element.editing);
       element.draft = true;
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 a880320..47a1cbb 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
@@ -34,6 +34,18 @@
 // Time in which pressing n key again after the toast navigates to next file
 const NAVIGATE_TO_NEXT_FILE_TIMEOUT_MS = 5000;
 
+/**
+ * Return type for cursor moves, that indicate whether a move was possible.
+ */
+export enum CursorMoveResult {
+  /** The cursor was successfully moved. */
+  MOVED,
+  /** There were no stops - the cursor was reset. */
+  NO_STOPS,
+  /** There was no more stop to move to - the cursor was clipped to the end. */
+  CLIPPED,
+}
+
 @customElement('gr-cursor-manager')
 export class GrCursorManager extends GestureEventListeners(
   LegacyElementMixin(PolymerElement)
@@ -104,16 +116,16 @@
    *     back to first instead of to last.
    * @param navigateToNextFile Navigate to next unreviewed file
    *     if user presses next on the last diff chunk
+   * @return If a move was performed or why not.
    * @private
    */
-
   next(
     condition?: Function,
     getTargetHeight?: (target: HTMLElement) => number,
     clipToTop?: boolean,
     navigateToNextFile?: boolean
-  ) {
-    this._moveCursor(
+  ): CursorMoveResult {
+    return this._moveCursor(
       1,
       condition,
       getTargetHeight,
@@ -122,8 +134,8 @@
     );
   }
 
-  previous(condition?: Function) {
-    this._moveCursor(-1, condition);
+  previous(condition?: Function): CursorMoveResult {
+    return this._moveCursor(-1, condition);
   }
 
   /**
@@ -233,7 +245,9 @@
   }
 
   isAtEnd() {
-    return this.index === this.stops.length - 1;
+    // Unset cursor should not be considered "at end", even when there are no
+    // cursor stops.
+    return this.index !== -1 && this.index === this.stops.length - 1;
   }
 
   moveToStart() {
@@ -267,6 +281,7 @@
    * back to first instead of to last.
    * @param navigateToNextFile Navigate to next unreviewed file
    * if user presses next on the last diff chunk
+   * @return  If a move was performed or why not.
    * @private
    */
   _moveCursor(
@@ -275,62 +290,58 @@
     getTargetHeight?: (target: HTMLElement) => number,
     clipToTop?: boolean,
     navigateToNextFile?: boolean
-  ) {
+  ): CursorMoveResult {
     if (!this.stops.length) {
       this.unsetCursor();
-      return;
+      return CursorMoveResult.NO_STOPS;
     }
 
     this._unDecorateTarget();
 
     const newIndex = this._getNextindex(delta, condition, clipToTop);
+    const newTarget = newIndex !== -1 ? this.stops[newIndex] : null;
 
-    let newTarget = null;
-    if (newIndex !== -1) {
-      newTarget = this.stops[newIndex];
-    }
+    const clipped = this.index === newIndex;
 
     /*
      * If user presses n on the last diff chunk, show a toast informing user
      * that pressing n again will navigate them to next unreviewed file.
      * If click happens within the time limit, then navigate to next file
      */
-    if (navigateToNextFile && this.index === newIndex) {
-      if (newIndex === this.stops.length - 1) {
-        if (
-          this._lastDisplayedNavigateToNextFileToast &&
-          Date.now() - this._lastDisplayedNavigateToNextFileToast <=
-            NAVIGATE_TO_NEXT_FILE_TIMEOUT_MS
-        ) {
-          // reset for next file
-          this._lastDisplayedNavigateToNextFileToast = null;
-          this.dispatchEvent(
-            new CustomEvent('navigate-to-next-unreviewed-file', {
-              composed: true,
-              bubbles: true,
-            })
-          );
-          return;
-        }
-        this._lastDisplayedNavigateToNextFileToast = Date.now();
+    if (navigateToNextFile && clipped && this.isAtEnd()) {
+      if (
+        this._lastDisplayedNavigateToNextFileToast &&
+        Date.now() - this._lastDisplayedNavigateToNextFileToast <=
+          NAVIGATE_TO_NEXT_FILE_TIMEOUT_MS
+      ) {
+        // reset for next file
+        this._lastDisplayedNavigateToNextFileToast = null;
         this.dispatchEvent(
-          new CustomEvent('show-alert', {
-            detail: {
-              message: 'Press n again to navigate to next unreviewed file',
-            },
+          new CustomEvent('navigate-to-next-unreviewed-file', {
             composed: true,
             bubbles: true,
           })
         );
-        return;
+        return CursorMoveResult.CLIPPED;
       }
+      this._lastDisplayedNavigateToNextFileToast = Date.now();
+      this.dispatchEvent(
+        new CustomEvent('show-alert', {
+          detail: {
+            message: 'Press n again to navigate to next unreviewed file',
+          },
+          composed: true,
+          bubbles: true,
+        })
+      );
+      return CursorMoveResult.CLIPPED;
     }
 
     this.index = newIndex;
     this.target = newTarget as HTMLElement;
 
     if (!newTarget) {
-      return;
+      return CursorMoveResult.NO_STOPS;
     }
 
     if (getTargetHeight) {
@@ -344,6 +355,8 @@
     }
 
     this._decorateTarget();
+
+    return clipped ? CursorMoveResult.CLIPPED : CursorMoveResult.MOVED;
   }
 
   _decorateTarget() {
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 bc07d84..5c0bb42 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
@@ -18,6 +18,7 @@
 import '../../../test/common-test-setup-karma.js';
 import './gr-cursor-manager.js';
 import {html} from '@polymer/polymer/lib/utils/html-tag.js';
+import {CursorMoveResult} from './gr-cursor-manager.js';
 
 const basicTestFixutre = fixtureFromTemplate(html`
     <gr-cursor-manager cursor-target-class="targeted"></gr-cursor-manager>
@@ -66,10 +67,11 @@
     assert.isFalse(element.isAtEnd());
 
     // Progress the cursor.
-    element.next();
+    let result = element.next();
 
     // Confirm that the next stop is selected and that the previous stop is
     // unselected.
+    assert.equal(result, CursorMoveResult.MOVED);
     assert.equal(element.index, 3);
     assert.equal(element.target, list.children[3]);
     assert.isTrue(element.isAtEnd());
@@ -77,19 +79,23 @@
     assert.isTrue(list.children[3].classList.contains('targeted'));
 
     // Progress the cursor.
-    element.next();
+    result = element.next();
 
     // We should still be at the end.
+    assert.equal(result, CursorMoveResult.CLIPPED);
     assert.equal(element.index, 3);
     assert.equal(element.target, list.children[3]);
     assert.isTrue(element.isAtEnd());
 
     // Wind the cursor all the way back to the first stop.
-    element.previous();
-    element.previous();
-    element.previous();
+    result = element.previous();
+    assert.equal(result, CursorMoveResult.MOVED);
+    result = element.previous();
+    assert.equal(result, CursorMoveResult.MOVED);
+    result = element.previous();
+    assert.equal(result, CursorMoveResult.MOVED);
 
-    // The element state should reflect the end of the list.
+    // The element state should reflect the start of the list.
     assert.equal(element.index, 0);
     assert.equal(element.target, list.children[0]);
     assert.isTrue(element.isAtStart());
@@ -113,8 +119,9 @@
 
   test('next() goes to first element when no cursor is set', () => {
     element.stops = list.querySelectorAll('li');
-    element.next();
+    const result = element.next();
 
+    assert.equal(result, CursorMoveResult.MOVED);
     assert.equal(element.index, 0);
     assert.equal(element.target, list.children[0]);
     assert.isTrue(list.children[0].classList.contains('targeted'));
@@ -122,10 +129,23 @@
     assert.isFalse(element.isAtEnd());
   });
 
-  test('next() goes to first element when no cursor is set', () => {
-    element.stops = list.querySelectorAll('li');
-    element.previous();
+  test('next() resets the cursor when there are no stops', () => {
+    element.stops = [];
+    const result = element.next();
 
+    assert.equal(result, CursorMoveResult.NO_STOPS);
+    assert.equal(element.index, -1);
+    assert.isNotOk(element.target);
+    assert.isFalse(list.children[1].classList.contains('targeted'));
+    assert.isFalse(element.isAtStart());
+    assert.isFalse(element.isAtEnd());
+  });
+
+  test('previous() goes to last element when no cursor is set', () => {
+    element.stops = list.querySelectorAll('li');
+    const result = element.previous();
+
+    assert.equal(result, CursorMoveResult.MOVED);
     const lastIndex = list.children.length - 1;
     assert.equal(element.index, lastIndex);
     assert.equal(element.target, list.children[lastIndex]);
@@ -134,6 +154,18 @@
     assert.isTrue(element.isAtEnd());
   });
 
+  test('previous() resets the cursor when there are no stops', () => {
+    element.stops = [];
+    const result = element.previous();
+
+    assert.equal(result, CursorMoveResult.NO_STOPS);
+    assert.equal(element.index, -1);
+    assert.isNotOk(element.target);
+    assert.isFalse(list.children[1].classList.contains('targeted'));
+    assert.isFalse(element.isAtStart());
+    assert.isFalse(element.isAtEnd());
+  });
+
   test('_moveCursor', () => {
     // Initialize the cursor with its stops.
     element.stops = list.querySelectorAll('li');
diff --git a/polygerrit-ui/app/elements/shared/gr-formatted-text/gr-formatted-text_html.ts b/polygerrit-ui/app/elements/shared/gr-formatted-text/gr-formatted-text_html.ts
index 468bbee..bc1dfe0 100644
--- a/polygerrit-ui/app/elements/shared/gr-formatted-text/gr-formatted-text_html.ts
+++ b/polygerrit-ui/app/elements/shared/gr-formatted-text/gr-formatted-text_html.ts
@@ -55,6 +55,7 @@
       list-style-type: disc;
       margin-left: var(--spacing-xl);
     }
+    code,
     gr-linked-text.pre {
       font-family: var(--monospace-font-family);
       font-size: var(--font-size-code);
diff --git a/polygerrit-ui/app/elements/shared/gr-hovercard/gr-hovercard-behavior.ts b/polygerrit-ui/app/elements/shared/gr-hovercard/gr-hovercard-behavior.ts
index c4ebe1e..a3233be 100644
--- a/polygerrit-ui/app/elements/shared/gr-hovercard/gr-hovercard-behavior.ts
+++ b/polygerrit-ui/app/elements/shared/gr-hovercard/gr-hovercard-behavior.ts
@@ -137,6 +137,11 @@
         this.listen(this, 'mouseleave', 'unlock');
       }
 
+      detached() {
+        super.detached();
+        this.unlock();
+      }
+
       /** @override */
       ready() {
         super.ready();
diff --git a/polygerrit-ui/app/elements/shared/gr-rest-api-interface/gr-rest-api-interface.ts b/polygerrit-ui/app/elements/shared/gr-rest-api-interface/gr-rest-api-interface.ts
index 3dfe105..8461027 100644
--- a/polygerrit-ui/app/elements/shared/gr-rest-api-interface/gr-rest-api-interface.ts
+++ b/polygerrit-ui/app/elements/shared/gr-rest-api-interface/gr-rest-api-interface.ts
@@ -125,6 +125,7 @@
   ProjectAccessInfo,
   CapabilityInfoMap,
   ProjectInfoWithName,
+  TagInfo,
 } from '../../../types/common';
 import {
   CancelConditionCallback,
@@ -1795,11 +1796,11 @@
       `/projects/${encodedRepo}/tags` + `?n=${n}&S=${offset}` + encodedFilter;
     // TODO(kaspern): Rename rest api from /projects/ to /repos/ once backend
     // supports it.
-    return this._restApiHelper.fetchJSON({
+    return (this._restApiHelper.fetchJSON({
       url,
       errFn,
       anonymizedUrl: '/projects/*/tags',
-    });
+    }) as unknown) as Promise<TagInfo[]>;
   }
 
   getPlugins(
diff --git a/polygerrit-ui/app/elements/shared/gr-storage/gr-storage.ts b/polygerrit-ui/app/elements/shared/gr-storage/gr-storage.ts
index 15914c5..3233420 100644
--- a/polygerrit-ui/app/elements/shared/gr-storage/gr-storage.ts
+++ b/polygerrit-ui/app/elements/shared/gr-storage/gr-storage.ts
@@ -24,7 +24,7 @@
   changeNum: number;
   patchNum: PatchSetNum;
   path: string;
-  line: number;
+  line?: number;
   range?: CommentRange;
 }
 
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 a1703a2..4e56daf 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
@@ -829,13 +829,18 @@
       shouldSuppressKeyboardShortcut(event: CustomKeyboardEvent) {
         const e = getKeyboardEvent(event);
         // TODO(TS): maybe override the EventApi, narrow it down to Element always
-        const tagName = ((dom(e) as EventApi).rootTarget as Element).tagName;
+        const target = (dom(e) as EventApi).rootTarget as Element;
+        const tagName = target.tagName;
+        const type = target.getAttribute('type');
         if (
-          tagName === 'INPUT' ||
+          // Suppress shortcuts on <input> and <textarea>, but not on
+          // checkboxes, because we want to enable workflows like 'click
+          // mark-reviewed and then press ] to go to the next file'.
+          (tagName === 'INPUT' && type !== 'checkbox') ||
           tagName === 'TEXTAREA' ||
+          // Suppress shortcuts if the key is 'enter' and target is an anchor.
           (e.keyCode === 13 && tagName === 'A')
         ) {
-          // Suppress shortcuts if the key is 'enter' and target is an anchor.
           return true;
         }
         for (let i = 0; e.path && i < e.path.length; i++) {
diff --git a/polygerrit-ui/app/mixins/keyboard-shortcut-mixin/keyboard-shortcut-mixin_test.js b/polygerrit-ui/app/mixins/keyboard-shortcut-mixin/keyboard-shortcut-mixin_test.js
index defc7dc..180dbe7 100644
--- a/polygerrit-ui/app/mixins/keyboard-shortcut-mixin/keyboard-shortcut-mixin_test.js
+++ b/polygerrit-ui/app/mixins/keyboard-shortcut-mixin/keyboard-shortcut-mixin_test.js
@@ -311,6 +311,17 @@
     MockInteractions.keyDownOn(inputEl, 75, null, 'k');
   });
 
+  test('doesn’t block kb shortcuts for checkboxes', done => {
+    const inputEl = document.createElement('input');
+    inputEl.setAttribute('type', 'checkbox');
+    element.appendChild(inputEl);
+    element._handleKey = e => {
+      assert.isFalse(element.shouldSuppressKeyboardShortcut(e));
+      done();
+    };
+    MockInteractions.keyDownOn(inputEl, 75, null, 'k');
+  });
+
   test('blocks kb shortcuts for textarea els', done => {
     const textareaEl = document.createElement('textarea');
     element.appendChild(textareaEl);
diff --git a/polygerrit-ui/app/services/services/gr-rest-api/gr-rest-api.ts b/polygerrit-ui/app/services/services/gr-rest-api/gr-rest-api.ts
index 05500ec..c85e307 100644
--- a/polygerrit-ui/app/services/services/gr-rest-api/gr-rest-api.ts
+++ b/polygerrit-ui/app/services/services/gr-rest-api/gr-rest-api.ts
@@ -79,6 +79,9 @@
   EncodedGroupId,
   Base64FileContent,
   UrlEncodedCommentId,
+  TagInfo,
+  GitRef,
+  ConfigInput,
 } from '../../../types/common';
 import {ParsedChangeInfo} from '../../../elements/shared/gr-rest-api-interface/gr-reviewer-updates-parser';
 import {HttpMethod} from '../../../constants/constants';
@@ -675,4 +678,16 @@
     path: string,
     contents: string
   ): Promise<Response>;
+  getRepoTags(
+    filter: string,
+    repo: RepoName,
+    reposTagsPerPage: number,
+    offset?: number,
+    errFn?: ErrorCallback
+  ): Promise<TagInfo[]>;
+
+  setRepoHead(repo: RepoName, ref: GitRef): Promise<Response>;
+  deleteRepoTags(repo: RepoName, ref: GitRef): Promise<Response>;
+  deleteRepoBranches(repo: RepoName, ref: GitRef): Promise<Response>;
+  saveRepoConfig(repo: RepoName, config: ConfigInput): Promise<Response>;
 }
diff --git a/polygerrit-ui/app/test/common-test-setup-karma.js b/polygerrit-ui/app/test/common-test-setup-karma.js
index cc934fc..2335f28 100644
--- a/polygerrit-ui/app/test/common-test-setup-karma.js
+++ b/polygerrit-ui/app/test/common-test-setup-karma.js
@@ -20,10 +20,15 @@
 self.assert = window.chai.assert;
 self.expect = window.chai.expect;
 
+// Workaround for https://github.com/karma-runner/karma-mocha/issues/227
+let unhandledError = null;
+
 window.addEventListener('error', e => {
   // For uncaught error mochajs doesn't print the full stack trace.
   // We should print it ourselves.
+  console.error('Uncaught error:');
   console.error(e.error.stack.toString());
+  unhandledError = e;
 });
 
 let originalOnBeforeUnload;
@@ -50,6 +55,9 @@
 suiteTeardown(() => {
   // This suiteTeardown() method is called only once after all tests
   window.onbeforeunload = originalOnBeforeUnload;
+  if (unhandledError) {
+    throw unhandledError;
+  }
 });
 
 // Tests can use fake timers (sandbox.useFakeTimers)
diff --git a/polygerrit-ui/app/types/common.ts b/polygerrit-ui/app/types/common.ts
index eec0fc3..e7539b5 100644
--- a/polygerrit-ui/app/types/common.ts
+++ b/polygerrit-ui/app/types/common.ts
@@ -793,16 +793,19 @@
   new_value: string;
 }
 
+export type SchemesInfoMap = {[name: string]: DownloadSchemeInfo};
+
 /**
  * The DownloadInfo entity contains information about supported download
  * options.
  * https://gerrit-review.googlesource.com/Documentation/rest-api-config.html
  */
 export interface DownloadInfo {
-  schemes: string;
+  schemes: SchemesInfoMap;
   archives: string;
 }
 
+export type CloneCommandMap = {[name: string]: string};
 /**
  * The DownloadSchemeInfo entity contains information about a supported download
  * scheme and its commands.
@@ -813,7 +816,7 @@
   is_auth_required: boolean;
   is_auth_supported: boolean;
   commands: string;
-  clone_commands: string;
+  clone_commands: CloneCommandMap;
 }
 
 /**
@@ -1347,14 +1350,6 @@
   reject_empty_commit?: InheritedBooleanInfo;
 }
 
-export type PluginParameterToConfigParameterInfoMap = {
-  [parameterName: string]: ConfigParameterInfo;
-};
-
-export type PluginNameToPluginParametersMap = {
-  [pluginName: string]: PluginParameterToConfigParameterInfoMap;
-};
-
 /**
  * The ProjectAccessInfo entity contains information about the access rights for a project
  * https://gerrit-review.googlesource.com/Documentation/rest-api-access.html#project-access-info
@@ -1447,23 +1442,31 @@
   use_signed_off_by?: InheritedBooleanInfoConfiguredValue;
   create_new_change_for_all_not_in_target?: InheritedBooleanInfoConfiguredValue;
   require_change_id?: InheritedBooleanInfoConfiguredValue;
+  enable_signed_push?: InheritedBooleanInfoConfiguredValue;
+  require_signed_push?: InheritedBooleanInfoConfiguredValue;
+  private_by_default?: InheritedBooleanInfoConfiguredValue;
+  work_in_progress_by_default?: InheritedBooleanInfoConfiguredValue;
+  enable_reviewer_by_email?: InheritedBooleanInfoConfiguredValue;
+  match_author_to_committer_date?: InheritedBooleanInfoConfiguredValue;
   reject_implicit_merges?: InheritedBooleanInfoConfiguredValue;
+  reject_empty_commit?: InheritedBooleanInfoConfiguredValue;
   max_object_size_limit?: MaxObjectSizeLimitInfo;
   submit_type?: SubmitType;
   state?: ProjectState;
-  plugin_config_values?: PluginConfigValues;
-  reject_empty_commit?: InheritedBooleanInfoConfiguredValue;
+  plugin_config_values?: PluginNameToPluginParametersMap;
   commentlinks?: ConfigInfoCommentLinks;
 }
-
 /**
  * Plugin configuration values as map which maps the plugin name to a map of parameter names to values
  * https://gerrit-review.googlesource.com/Documentation/rest-api-projects.html#config-input
  */
-export type PluginConfigValues = {
-  [pluginName: string]: ParameterNameToValueMap;
+export type PluginNameToPluginParametersMap = {
+  [pluginName: string]: PluginParameterToConfigParameterInfoMap;
 };
-export type ParameterNameToValueMap = {[parameterName: string]: string};
+
+export type PluginParameterToConfigParameterInfoMap = {
+  [parameterName: string]: ConfigParameterInfo;
+};
 
 export type ConfigInfoCommentLinks = {
   [commentLinkName: string]: CommentLinkInfo;
@@ -1490,7 +1493,6 @@
   enable_signed_push?: InheritedBooleanInfoConfiguredValue;
   require_signed_push?: InheritedBooleanInfoConfiguredValue;
   max_object_size_limit?: string;
-  plugin_config_values?: PluginConfigValues;
   reject_empty_commit?: InheritedBooleanInfoConfiguredValue;
 }
 
@@ -2008,3 +2010,18 @@
   title: string;
   url: string;
 }
+
+/**
+ * The TagInfo entity contains information about a tag.
+ * https://gerrit-review.googlesource.com/Documentation/rest-api-projects.html#tag-info
+ **/
+export interface TagInfo {
+  ref: GitRef;
+  revision: string;
+  object?: string;
+  message?: string;
+  tagger?: GitPersonInfo;
+  created?: string;
+  can_delete: boolean;
+  web_links?: WebLinkInfo[];
+}