Merge "Fix repositioning the reply dialog when the attention section expands"
diff --git a/java/com/google/gerrit/httpd/restapi/RestApiServlet.java b/java/com/google/gerrit/httpd/restapi/RestApiServlet.java
index a1197ef..404f5e4 100644
--- a/java/com/google/gerrit/httpd/restapi/RestApiServlet.java
+++ b/java/com/google/gerrit/httpd/restapi/RestApiServlet.java
@@ -108,6 +108,7 @@
 import com.google.gerrit.server.RequestInfo;
 import com.google.gerrit.server.RequestListener;
 import com.google.gerrit.server.audit.ExtendedHttpAuditEvent;
+import com.google.gerrit.server.cache.PerThreadCache;
 import com.google.gerrit.server.change.ChangeFinder;
 import com.google.gerrit.server.config.GerritServerConfig;
 import com.google.gerrit.server.group.GroupAuditService;
@@ -327,7 +328,7 @@
     try (TraceContext traceContext = enableTracing(req, res)) {
       List<IdString> path = splitPath(req);
 
-      try {
+      try (PerThreadCache ignored = PerThreadCache.create()) {
         RequestInfo requestInfo = createRequestInfo(traceContext, requestUri(req), path);
         globals.requestListeners.runEach(l -> l.onRequest(requestInfo));
 
diff --git a/java/com/google/gerrit/server/AnonymousUser.java b/java/com/google/gerrit/server/AnonymousUser.java
index c96d61a..91d2d05 100644
--- a/java/com/google/gerrit/server/AnonymousUser.java
+++ b/java/com/google/gerrit/server/AnonymousUser.java
@@ -27,6 +27,12 @@
   }
 
   @Override
+  public Object getCacheKey() {
+    // Treat all anonymous users as a single user
+    return "anonymous";
+  }
+
+  @Override
   public String toString() {
     return "ANONYMOUS";
   }
diff --git a/java/com/google/gerrit/server/CurrentUser.java b/java/com/google/gerrit/server/CurrentUser.java
index 43d3c7b..825b34f 100644
--- a/java/com/google/gerrit/server/CurrentUser.java
+++ b/java/com/google/gerrit/server/CurrentUser.java
@@ -91,6 +91,12 @@
    */
   public abstract GroupMembership getEffectiveGroups();
 
+  /**
+   * Returns a unique identifier for this user that is intended to be used as a cache key. Returned
+   * object should to implement {@code equals()} and {@code hashCode()} for effective caching.
+   */
+  public abstract Object getCacheKey();
+
   /** Unique name of the user on this server, if one has been assigned. */
   public Optional<String> getUserName() {
     return Optional.empty();
diff --git a/java/com/google/gerrit/server/IdentifiedUser.java b/java/com/google/gerrit/server/IdentifiedUser.java
index ec2eb81..75c7cda 100644
--- a/java/com/google/gerrit/server/IdentifiedUser.java
+++ b/java/com/google/gerrit/server/IdentifiedUser.java
@@ -413,6 +413,11 @@
     return effectiveGroups;
   }
 
+  @Override
+  public Object getCacheKey() {
+    return getAccountId();
+  }
+
   public PersonIdent newRefLogIdent() {
     return newRefLogIdent(new Date(), TimeZone.getDefault());
   }
diff --git a/java/com/google/gerrit/server/InternalUser.java b/java/com/google/gerrit/server/InternalUser.java
index 821a0c6..381819d 100644
--- a/java/com/google/gerrit/server/InternalUser.java
+++ b/java/com/google/gerrit/server/InternalUser.java
@@ -36,6 +36,11 @@
   }
 
   @Override
+  public String getCacheKey() {
+    return "internal";
+  }
+
+  @Override
   public boolean isInternalUser() {
     return true;
   }
diff --git a/java/com/google/gerrit/server/PeerDaemonUser.java b/java/com/google/gerrit/server/PeerDaemonUser.java
index 8a8b67a..b27e05c 100644
--- a/java/com/google/gerrit/server/PeerDaemonUser.java
+++ b/java/com/google/gerrit/server/PeerDaemonUser.java
@@ -40,6 +40,11 @@
     return GroupMembership.EMPTY;
   }
 
+  @Override
+  public Object getCacheKey() {
+    return getRemoteAddress();
+  }
+
   public SocketAddress getRemoteAddress() {
     return peer;
   }
diff --git a/java/com/google/gerrit/server/cache/PerThreadCache.java b/java/com/google/gerrit/server/cache/PerThreadCache.java
new file mode 100644
index 0000000..b4f79d1
--- /dev/null
+++ b/java/com/google/gerrit/server/cache/PerThreadCache.java
@@ -0,0 +1,146 @@
+// Copyright (C) 2018 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.cache;
+
+import static com.google.common.base.Preconditions.checkState;
+
+import com.google.common.base.Objects;
+import com.google.common.collect.ImmutableList;
+import com.google.common.collect.Maps;
+import com.google.gerrit.common.Nullable;
+import java.util.Map;
+import java.util.function.Supplier;
+
+/**
+ * Caches object instances for a request as {@link ThreadLocal} in the serving thread.
+ *
+ * <p>This class is intended to cache objects that have a high instantiation cost, are specific to
+ * the current request and potentially need to be instantiated multiple times while serving a
+ * request.
+ *
+ * <p>This is different from the key-value storage in {@code CurrentUser}: {@code CurrentUser}
+ * offers a key-value storage by providing thread-safe {@code get} and {@code put} methods. Once the
+ * value is retrieved through {@code get} there is not thread-safety anymore - apart from the
+ * retrieved object guarantees. Depending on the implementation of {@code CurrentUser}, it might be
+ * shared between the request serving thread as well as sub- or background treads.
+ *
+ * <p>In comparison to that, this class guarantees thread safety even on non-thread-safe objects as
+ * its cache is tied to the serving thread only. While allowing to cache non-thread-safe objects, it
+ * has the downside of not sharing any objects with background threads or executors.
+ *
+ * <p>Lastly, this class offers a cache, that requires callers to also provide a {@code Supplier} in
+ * case the object is not present in the cache, while {@code CurrentUser} provides a storage where
+ * just retrieving stored values is a valid operation.
+ *
+ * <p>To prevent OOM errors on requests that would cache a lot of objects, this class enforces an
+ * internal limit after which no new elements are cached. All {@code get} calls are served by
+ * invoking the {@code Supplier} after that.
+ */
+public class PerThreadCache implements AutoCloseable {
+  private static final ThreadLocal<PerThreadCache> CACHE = new ThreadLocal<>();
+  /**
+   * Cache at maximum 25 values per thread. This value was chosen arbitrarily. Some endpoints (like
+   * ListProjects) break the assumption that the data cached in a request is limited. To prevent
+   * this class from accumulating an unbound number of objects, we enforce this limit.
+   */
+  private static final int PER_THREAD_CACHE_SIZE = 25;
+
+  /**
+   * Unique key for key-value mappings stored in PerThreadCache. The key is based on the value's
+   * class and a list of identifiers that in combination uniquely set the object apart form others
+   * of the same class.
+   */
+  public static final class Key<T> {
+    private final Class<T> clazz;
+    private final ImmutableList<Object> identifiers;
+
+    /**
+     * Returns a key based on the value's class and an identifier that uniquely identify the value.
+     * The identifier needs to implement {@code equals()} and {@hashCode()}.
+     */
+    public static <T> Key<T> create(Class<T> clazz, Object identifier) {
+      return new Key<>(clazz, ImmutableList.of(identifier));
+    }
+
+    /**
+     * Returns a key based on the value's class and a set of identifiers that uniquely identify the
+     * value. Identifiers need to implement {@code equals()} and {@hashCode()}.
+     */
+    public static <T> Key<T> create(Class<T> clazz, Object... identifiers) {
+      return new Key<>(clazz, ImmutableList.copyOf(identifiers));
+    }
+
+    private Key(Class<T> clazz, ImmutableList<Object> identifiers) {
+      this.clazz = clazz;
+      this.identifiers = identifiers;
+    }
+
+    @Override
+    public int hashCode() {
+      return Objects.hashCode(clazz, identifiers);
+    }
+
+    @Override
+    public boolean equals(Object o) {
+      if (!(o instanceof Key)) {
+        return false;
+      }
+      Key<?> other = (Key<?>) o;
+      return this.clazz == other.clazz && this.identifiers.equals(other.identifiers);
+    }
+  }
+
+  public static PerThreadCache create() {
+    checkState(CACHE.get() == null, "called create() twice on the same request");
+    PerThreadCache cache = new PerThreadCache();
+    CACHE.set(cache);
+    return cache;
+  }
+
+  @Nullable
+  public static PerThreadCache get() {
+    return CACHE.get();
+  }
+
+  public static <T> T getOrCompute(Key<T> key, Supplier<T> loader) {
+    PerThreadCache cache = get();
+    return cache != null ? cache.get(key, loader) : loader.get();
+  }
+
+  private final Map<Key<?>, Object> cache = Maps.newHashMapWithExpectedSize(PER_THREAD_CACHE_SIZE);
+
+  private PerThreadCache() {}
+
+  /**
+   * Returns an instance of {@code T} that was either loaded from the cache or obtained from the
+   * provided {@link Supplier}.
+   */
+  public <T> T get(Key<T> key, Supplier<T> loader) {
+    @SuppressWarnings("unchecked")
+    T value = (T) cache.get(key);
+    if (value == null) {
+      value = loader.get();
+      if (cache.size() < PER_THREAD_CACHE_SIZE) {
+        cache.put(key, value);
+      }
+    }
+    return value;
+  }
+
+  @Override
+  public void close() {
+    CACHE.remove();
+  }
+}
diff --git a/java/com/google/gerrit/server/permissions/DefaultPermissionBackend.java b/java/com/google/gerrit/server/permissions/DefaultPermissionBackend.java
index 49df653..d10c139 100644
--- a/java/com/google/gerrit/server/permissions/DefaultPermissionBackend.java
+++ b/java/com/google/gerrit/server/permissions/DefaultPermissionBackend.java
@@ -34,6 +34,7 @@
 import com.google.gerrit.server.IdentifiedUser;
 import com.google.gerrit.server.PeerDaemonUser;
 import com.google.gerrit.server.account.CapabilityCollection;
+import com.google.gerrit.server.cache.PerThreadCache;
 import com.google.gerrit.server.project.ProjectCache;
 import com.google.gerrit.server.project.ProjectState;
 import com.google.inject.Inject;
@@ -80,8 +81,15 @@
 
   @Override
   public WithUser absentUser(Account.Id id) {
-    IdentifiedUser identifiedUser = identifiedUserFactory.create(requireNonNull(id, "user"));
-    return new WithUserImpl(identifiedUser);
+    requireNonNull(id, "user");
+    CurrentUser user = currentUser.get();
+    if (user.isIdentifiedUser() && id.equals(user.asIdentifiedUser().getAccountId())) {
+      // What looked liked an absent user is actually the current caller. Use the per-request
+      // singleton IdentifiedUser instead of constructing a new object to leverage caching in member
+      // variables of IdentifiedUser.
+      return new WithUserImpl(user.asIdentifiedUser());
+    }
+    return new WithUserImpl(identifiedUserFactory.create(requireNonNull(id, "user")));
   }
 
   @Override
@@ -101,7 +109,11 @@
     public ForProject project(Project.NameKey project) {
       try {
         ProjectState state = projectCache.get(project).orElseThrow(illegalState(project));
-        return projectControlFactory.create(user, state).asForProject();
+        ProjectControl control =
+            PerThreadCache.getOrCompute(
+                PerThreadCache.Key.create(ProjectControl.class, project, user.getCacheKey()),
+                () -> projectControlFactory.create(user, state));
+        return control.asForProject();
       } catch (Exception e) {
         Throwable cause = e.getCause() != null ? e.getCause() : e;
         return FailedPermissionBackend.project(
diff --git a/java/com/google/gerrit/server/query/change/GroupBackedUser.java b/java/com/google/gerrit/server/query/change/GroupBackedUser.java
index dac555d..3960813 100644
--- a/java/com/google/gerrit/server/query/change/GroupBackedUser.java
+++ b/java/com/google/gerrit/server/query/change/GroupBackedUser.java
@@ -64,4 +64,9 @@
   public String getLoggableName() {
     return "GroupBackedUser with memberships: " + groups.getKnownGroups();
   }
+
+  @Override
+  public Object getCacheKey() {
+    return groups.getKnownGroups();
+  }
 }
diff --git a/javatests/com/google/gerrit/acceptance/server/permissions/BUILD b/javatests/com/google/gerrit/acceptance/server/permissions/BUILD
new file mode 100644
index 0000000..e89e8d1
--- /dev/null
+++ b/javatests/com/google/gerrit/acceptance/server/permissions/BUILD
@@ -0,0 +1,7 @@
+load("//javatests/com/google/gerrit/acceptance:tests.bzl", "acceptance_tests")
+
+acceptance_tests(
+    srcs = glob(["*IT.java"]),
+    group = "server_permissions",
+    labels = ["server"],
+)
diff --git a/javatests/com/google/gerrit/acceptance/server/permissions/GroupBackedUserPermissionIT.java b/javatests/com/google/gerrit/acceptance/server/permissions/GroupBackedUserPermissionIT.java
new file mode 100644
index 0000000..d68d681
--- /dev/null
+++ b/javatests/com/google/gerrit/acceptance/server/permissions/GroupBackedUserPermissionIT.java
@@ -0,0 +1,184 @@
+// Copyright (C) 2020 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.acceptance.server.permissions;
+
+import static com.google.common.collect.ImmutableList.toImmutableList;
+import static com.google.common.truth.Truth.assertThat;
+import static com.google.gerrit.acceptance.testsuite.project.TestProjectUpdate.allow;
+import static com.google.gerrit.acceptance.testsuite.project.TestProjectUpdate.permissionKey;
+import static com.google.gerrit.server.group.SystemGroupBackend.ANONYMOUS_USERS;
+import static com.google.gerrit.server.group.SystemGroupBackend.REGISTERED_USERS;
+import static com.google.gerrit.testing.GerritJUnit.assertThrows;
+
+import com.google.common.collect.ImmutableList;
+import com.google.common.collect.ImmutableSet;
+import com.google.gerrit.acceptance.AbstractDaemonTest;
+import com.google.gerrit.acceptance.testsuite.change.ChangeOperations;
+import com.google.gerrit.acceptance.testsuite.project.ProjectOperations;
+import com.google.gerrit.entities.AccountGroup;
+import com.google.gerrit.entities.Change;
+import com.google.gerrit.entities.PatchSet;
+import com.google.gerrit.entities.Permission;
+import com.google.gerrit.entities.RefNames;
+import com.google.gerrit.extensions.registration.DynamicSet;
+import com.google.gerrit.extensions.restapi.AuthException;
+import com.google.gerrit.server.CurrentUser;
+import com.google.gerrit.server.account.GroupBackend;
+import com.google.gerrit.server.group.testing.TestGroupBackend;
+import com.google.gerrit.server.notedb.ChangeNotes;
+import com.google.gerrit.server.permissions.ChangePermission;
+import com.google.gerrit.server.permissions.PermissionBackend;
+import com.google.gerrit.server.query.change.GroupBackedUser;
+import com.google.inject.AbstractModule;
+import com.google.inject.Module;
+import javax.inject.Inject;
+import org.eclipse.jgit.lib.Ref;
+import org.eclipse.jgit.lib.Repository;
+import org.junit.Before;
+import org.junit.Test;
+
+/** Tests that permission logic used by {@link GroupBackedUser} works as expected. */
+public class GroupBackedUserPermissionIT extends AbstractDaemonTest {
+  @Inject private ChangeOperations changeOperations;
+  @Inject private ProjectOperations projectOperations;
+  @Inject private PermissionBackend permissionBackend;
+  @Inject private ChangeNotes.Factory changeNotesFactory;
+
+  private final TestGroupBackend testGroupBackend = new TestGroupBackend();
+  private final AccountGroup.UUID externalGroup = AccountGroup.uuid("testbackend:test");
+
+  @Before
+  public void setUp() {
+    // Allow only read on refs/heads/master by default
+    projectOperations
+        .project(allProjects)
+        .forUpdate()
+        .remove(permissionKey(Permission.READ).ref("refs/*").group(ANONYMOUS_USERS))
+        .add(allow(Permission.READ).ref("refs/heads/master").group(ANONYMOUS_USERS))
+        .update();
+  }
+
+  @Override
+  public Module createModule() {
+    /** Binding a {@link TestGroupBackend} to test adding external groups * */
+    return new AbstractModule() {
+      @Override
+      protected void configure() {
+        DynamicSet.bind(binder(), GroupBackend.class).toInstance(testGroupBackend);
+      }
+    };
+  }
+
+  @Test
+  public void defaultRefFilter_changeVisibilityIsAgnosticOfProvidedGroups() throws Exception {
+    GroupBackedUser user =
+        new GroupBackedUser(ImmutableSet.of(ANONYMOUS_USERS, REGISTERED_USERS, externalGroup));
+    Change.Id changeOnMaster = changeOperations.newChange().project(project).create();
+    Change.Id changeOnRefsMetaConfig =
+        changeOperations.newChange().project(project).branch("refs/meta/config").create();
+    // Check that only the change on the default branch is visible
+    assertThat(getVisibleRefNames(user))
+        .containsExactly(
+            "HEAD",
+            "refs/heads/master",
+            RefNames.changeMetaRef(changeOnMaster),
+            RefNames.patchSetRef(PatchSet.id(changeOnMaster, 1)));
+    // Grant access
+    projectOperations
+        .project(project)
+        .forUpdate()
+        .add(allow(Permission.READ).ref("refs/meta/config").group(externalGroup))
+        .update();
+    // Check that both changes are visible now
+    assertThat(getVisibleRefNames(user))
+        .containsExactly(
+            "HEAD",
+            "refs/heads/master",
+            "refs/meta/config",
+            RefNames.changeMetaRef(changeOnMaster),
+            RefNames.patchSetRef(PatchSet.id(changeOnMaster, 1)),
+            RefNames.changeMetaRef(changeOnRefsMetaConfig),
+            RefNames.patchSetRef(PatchSet.id(changeOnRefsMetaConfig, 1)));
+  }
+
+  @Test
+  public void defaultRefFilter_refVisibilityIsAgnosticOfProvidedGroups() throws Exception {
+    GroupBackedUser user =
+        new GroupBackedUser(ImmutableSet.of(ANONYMOUS_USERS, REGISTERED_USERS, externalGroup));
+    // Check that refs/meta/config isn't visible by default
+    assertThat(getVisibleRefNames(user)).containsExactly("HEAD", "refs/heads/master");
+    // Grant access
+    projectOperations
+        .project(project)
+        .forUpdate()
+        .add(allow(Permission.READ).ref("refs/meta/config").group(externalGroup))
+        .update();
+    // Check that refs/meta/config became visible
+    assertThat(getVisibleRefNames(user))
+        .containsExactly("HEAD", "refs/heads/master", "refs/meta/config");
+  }
+
+  @Test
+  public void changeVisibility_changeOnInvisibleBranchNotVisible() throws Exception {
+    // Create a change that is not visible to members of 'externalGroup'
+    Change.Id invisibleChange =
+        changeOperations.newChange().project(project).branch("refs/meta/config").create();
+    GroupBackedUser user =
+        new GroupBackedUser(ImmutableSet.of(ANONYMOUS_USERS, REGISTERED_USERS, externalGroup));
+    AuthException thrown =
+        assertThrows(
+            AuthException.class,
+            () ->
+                permissionBackend
+                    .user(user)
+                    .change(changeNotesFactory.create(project, invisibleChange))
+                    .check(ChangePermission.READ));
+    assertThat(thrown).hasMessageThat().isEqualTo("read not permitted");
+  }
+
+  @Test
+  public void changeVisibility_changeOnBranchVisibleToAnonymousIsVisible() throws Exception {
+    Change.Id changeId = changeOperations.newChange().project(project).create();
+    GroupBackedUser user =
+        new GroupBackedUser(ImmutableSet.of(ANONYMOUS_USERS, REGISTERED_USERS, externalGroup));
+    permissionBackend
+        .user(user)
+        .change(changeNotesFactory.create(project, changeId))
+        .check(ChangePermission.READ);
+  }
+
+  @Test
+  public void changeVisibility_changeOnBranchVisibleToRegisteredUsersIsVisible() throws Exception {
+    Change.Id changeId = changeOperations.newChange().project(project).create();
+    GroupBackedUser user =
+        new GroupBackedUser(ImmutableSet.of(ANONYMOUS_USERS, REGISTERED_USERS, externalGroup));
+    blockAnonymousRead();
+    permissionBackend
+        .user(user)
+        .change(changeNotesFactory.create(project, changeId))
+        .check(ChangePermission.READ);
+  }
+
+  private ImmutableList<String> getVisibleRefNames(CurrentUser user) throws Exception {
+    try (Repository repo = repoManager.openRepository(project)) {
+      return permissionBackend.user(user).project(project)
+          .filter(
+              repo.getRefDatabase().getRefs(), repo, PermissionBackend.RefFilterOptions.defaults())
+          .stream()
+          .map(Ref::getName)
+          .collect(toImmutableList());
+    }
+  }
+}
diff --git a/javatests/com/google/gerrit/server/cache/PerThreadCacheTest.java b/javatests/com/google/gerrit/server/cache/PerThreadCacheTest.java
new file mode 100644
index 0000000..5d420d3
--- /dev/null
+++ b/javatests/com/google/gerrit/server/cache/PerThreadCacheTest.java
@@ -0,0 +1,103 @@
+// Copyright (C) 2018 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.cache;
+
+import static com.google.common.truth.Truth.assertThat;
+import static com.google.gerrit.testing.GerritJUnit.assertThrows;
+
+import java.util.function.Supplier;
+import org.junit.Test;
+
+public class PerThreadCacheTest {
+
+  @SuppressWarnings("TruthIncompatibleType")
+  @Test
+  public void key_respectsClass() {
+    assertThat(PerThreadCache.Key.create(String.class))
+        .isEqualTo(PerThreadCache.Key.create(String.class));
+    assertThat(PerThreadCache.Key.create(String.class))
+        .isNotEqualTo(
+            /* expected: Key<String>, actual: Key<Integer> */ PerThreadCache.Key.create(
+                Integer.class));
+  }
+
+  @Test
+  public void key_respectsIdentifiers() {
+    assertThat(PerThreadCache.Key.create(String.class, "id1"))
+        .isEqualTo(PerThreadCache.Key.create(String.class, "id1"));
+    assertThat(PerThreadCache.Key.create(String.class, "id1"))
+        .isNotEqualTo(PerThreadCache.Key.create(String.class, "id2"));
+  }
+
+  @Test
+  public void endToEndCache() {
+    try (PerThreadCache ignored = PerThreadCache.create()) {
+      PerThreadCache cache = PerThreadCache.get();
+      PerThreadCache.Key<String> key1 = PerThreadCache.Key.create(String.class);
+
+      String value1 = cache.get(key1, () -> "value1");
+      assertThat(value1).isEqualTo("value1");
+
+      Supplier<String> neverCalled =
+          () -> {
+            throw new IllegalStateException("this method must not be called");
+          };
+      assertThat(cache.get(key1, neverCalled)).isEqualTo("value1");
+    }
+  }
+
+  @Test
+  public void cleanUp() {
+    PerThreadCache.Key<String> key = PerThreadCache.Key.create(String.class);
+    try (PerThreadCache ignored = PerThreadCache.create()) {
+      PerThreadCache cache = PerThreadCache.get();
+      String value1 = cache.get(key, () -> "value1");
+      assertThat(value1).isEqualTo("value1");
+    }
+
+    // Create a second cache and assert that it is not connected to the first one.
+    // This ensures that the cleanup is actually working.
+    try (PerThreadCache ignored = PerThreadCache.create()) {
+      PerThreadCache cache = PerThreadCache.get();
+      String value1 = cache.get(key, () -> "value2");
+      assertThat(value1).isEqualTo("value2");
+    }
+  }
+
+  @Test
+  public void doubleInstantiationFails() {
+    try (PerThreadCache ignored = PerThreadCache.create()) {
+      IllegalStateException thrown =
+          assertThrows(IllegalStateException.class, () -> PerThreadCache.create());
+      assertThat(thrown).hasMessageThat().contains("called create() twice on the same request");
+    }
+  }
+
+  @Test
+  public void enforceMaxSize() {
+    try (PerThreadCache cache = PerThreadCache.create()) {
+      // Fill the cache
+      for (int i = 0; i < 50; i++) {
+        PerThreadCache.Key<String> key = PerThreadCache.Key.create(String.class, i);
+        cache.get(key, () -> "cached value");
+      }
+      // Assert that the value was not persisted
+      PerThreadCache.Key<String> key = PerThreadCache.Key.create(String.class, 1000);
+      cache.get(key, () -> "new value");
+      String value = cache.get(key, () -> "directly served");
+      assertThat(value).isEqualTo("directly served");
+    }
+  }
+}
diff --git a/javatests/com/google/gerrit/server/extensions/webui/UiActionsTest.java b/javatests/com/google/gerrit/server/extensions/webui/UiActionsTest.java
index 1cdca1b..de23ef4 100644
--- a/javatests/com/google/gerrit/server/extensions/webui/UiActionsTest.java
+++ b/javatests/com/google/gerrit/server/extensions/webui/UiActionsTest.java
@@ -101,6 +101,11 @@
         }
 
         @Override
+        public Object getCacheKey() {
+          return new Object();
+        }
+
+        @Override
         public boolean isIdentifiedUser() {
           return true;
         }
diff --git a/javatests/com/google/gerrit/server/permissions/RefControlTest.java b/javatests/com/google/gerrit/server/permissions/RefControlTest.java
index 64f9392..81cb732 100644
--- a/javatests/com/google/gerrit/server/permissions/RefControlTest.java
+++ b/javatests/com/google/gerrit/server/permissions/RefControlTest.java
@@ -1198,6 +1198,11 @@
     }
 
     @Override
+    public Object getCacheKey() {
+      return new Object();
+    }
+
+    @Override
     public Optional<String> getUserName() {
       return Optional.ofNullable(username);
     }
diff --git a/plugins/plugin-manager b/plugins/plugin-manager
index d6a3381..00e5794 160000
--- a/plugins/plugin-manager
+++ b/plugins/plugin-manager
@@ -1 +1 @@
-Subproject commit d6a33818440eb20aca64a761f79652525b3eb060
+Subproject commit 00e57948f4f112c226028bc5c8d8fe60f770038f
diff --git a/plugins/replication b/plugins/replication
index 63fb2b4..a6a6ec5 160000
--- a/plugins/replication
+++ b/plugins/replication
@@ -1 +1 @@
-Subproject commit 63fb2b4ba85380d798acbdc076e8673353507569
+Subproject commit a6a6ec5982e41a0ee9bfe24a46be96d4f13fcaaa
diff --git a/polygerrit-ui/app/constants/constants.ts b/polygerrit-ui/app/constants/constants.ts
index 8e8eaf3..807adef 100644
--- a/polygerrit-ui/app/constants/constants.ts
+++ b/polygerrit-ui/app/constants/constants.ts
@@ -190,6 +190,17 @@
   INHERIT = 'INHERIT',
 }
 
+/**
+ * https://gerrit-review.googlesource.com/Documentation/rest-api-changes.html#mergeable-info
+ */
+export enum MergeStrategy {
+  RECURSIVE = 'recursive',
+  RESOLVE = 'resolve',
+  SIMPLE_TWO_WAY_IN_CORE = 'simple-two-way-in-core',
+  OURS = 'ours',
+  THEIRS = 'theirs',
+}
+
 /*
  * Enum for possible configured value in InheritedBooleanInfo.
  * https://gerrit-review.googlesource.com/Documentation/rest-api-projects.html#inherited-boolean-info
diff --git a/polygerrit-ui/app/elements/change/gr-change-view/gr-change-view.js b/polygerrit-ui/app/elements/change/gr-change-view/gr-change-view.js
deleted file mode 100644
index 20fe1a6..0000000
--- a/polygerrit-ui/app/elements/change/gr-change-view/gr-change-view.js
+++ /dev/null
@@ -1,2353 +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 '@polymer/paper-tabs/paper-tabs.js';
-import '../../../styles/shared-styles.js';
-import '../../diff/gr-comment-api/gr-comment-api.js';
-import '../../plugins/gr-endpoint-decorator/gr-endpoint-decorator.js';
-import '../../plugins/gr-endpoint-param/gr-endpoint-param.js';
-import '../../shared/gr-account-link/gr-account-link.js';
-import '../../shared/gr-button/gr-button.js';
-import '../../shared/gr-change-star/gr-change-star.js';
-import '../../shared/gr-change-status/gr-change-status.js';
-import '../../shared/gr-date-formatter/gr-date-formatter.js';
-import '../../shared/gr-editable-content/gr-editable-content.js';
-import '../../shared/gr-js-api-interface/gr-js-api-interface.js';
-import '../../shared/gr-linked-text/gr-linked-text.js';
-import '../../shared/gr-overlay/gr-overlay.js';
-import '../../shared/gr-rest-api-interface/gr-rest-api-interface.js';
-import '../../shared/gr-tooltip-content/gr-tooltip-content.js';
-import '../../shared/revision-info/revision-info.js';
-import '../gr-change-actions/gr-change-actions.js';
-import '../gr-change-metadata/gr-change-metadata.js';
-import '../../shared/gr-icons/gr-icons.js';
-import '../gr-commit-info/gr-commit-info.js';
-import '../gr-download-dialog/gr-download-dialog.js';
-import '../gr-file-list-header/gr-file-list-header.js';
-import '../gr-included-in-dialog/gr-included-in-dialog.js';
-import '../gr-messages-list/gr-messages-list.js';
-import '../gr-related-changes-list/gr-related-changes-list.js';
-import '../../diff/gr-apply-fix-dialog/gr-apply-fix-dialog.js';
-import '../gr-reply-dialog/gr-reply-dialog.js';
-import '../gr-thread-list/gr-thread-list.js';
-import '../gr-upload-help-dialog/gr-upload-help-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-change-view_html.js';
-import {KeyboardShortcutMixin, Shortcut} from '../../../mixins/keyboard-shortcut-mixin/keyboard-shortcut-mixin.js';
-import {GrEditConstants} from '../../edit/gr-edit-constants.js';
-import {GrCountStringFormatter} from '../../shared/gr-count-string-formatter/gr-count-string-formatter.js';
-import {getComputedStyleValue} from '../../../utils/dom-util.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 {RevisionInfo} from '../../shared/revision-info/revision-info.js';
-import {PrimaryTab, SecondaryTab} from '../../../constants/constants.js';
-import {NO_ROBOT_COMMENTS_THREADS_MSG} from '../../../constants/messages.js';
-import {appContext} from '../../../services/app-context.js';
-import {ChangeStatus} from '../../../constants/constants.js';
-import {
-  computeAllPatchSets,
-  computeLatestPatchNum,
-  fetchChangeUpdates,
-  hasEditBasedOnCurrentPatchSet,
-  hasEditPatchsetLoaded,
-  patchNumEquals,
-  SPECIAL_PATCH_SET_NUM,
-} from '../../../utils/patch-set-util.js';
-import {changeStatuses, changeStatusString} from '../../../utils/change-util.js';
-import {EventType} from '../../plugins/gr-plugin-types.js';
-import {DEFAULT_NUM_FILES_SHOWN} from '../gr-file-list/gr-file-list.js';
-
-const CHANGE_ID_ERROR = {
-  MISMATCH: 'mismatch',
-  MISSING: 'missing',
-};
-const CHANGE_ID_REGEX_PATTERN =
-  /^(Change-Id\:\s|Link:.*\/id\/)(I[0-9a-f]{8,40})/gm;
-
-const MIN_LINES_FOR_COMMIT_COLLAPSE = 30;
-
-const REVIEWERS_REGEX = /^(R|CC)=/gm;
-const MIN_CHECK_INTERVAL_SECS = 0;
-
-// These are the same as the breakpoint set in CSS. Make sure both are changed
-// together.
-const BREAKPOINT_RELATED_SMALL = '50em';
-const BREAKPOINT_RELATED_MED = '75em';
-
-// In the event that the related changes medium width calculation is too close
-// to zero, provide some height.
-const MINIMUM_RELATED_MAX_HEIGHT = 100;
-
-const SMALL_RELATED_HEIGHT = 400;
-
-const REPLY_REFIT_DEBOUNCE_INTERVAL_MS = 500;
-
-const TRAILING_WHITESPACE_REGEX = /[ \t]+$/gm;
-
-const MSG_PREFIX = '#message-';
-
-const ReloadToastMessage = {
-  NEWER_REVISION: 'A newer patch set has been uploaded',
-  RESTORED: 'This change has been restored',
-  ABANDONED: 'This change has been abandoned',
-  MERGED: 'This change has been merged',
-  NEW_MESSAGE: 'There are new messages on this change',
-};
-
-const DiffViewMode = {
-  SIDE_BY_SIDE: 'SIDE_BY_SIDE',
-  UNIFIED: 'UNIFIED_DIFF',
-};
-
-const CHANGE_DATA_TIMING_LABEL = 'ChangeDataLoaded';
-const CHANGE_RELOAD_TIMING_LABEL = 'ChangeReloaded';
-const SEND_REPLY_TIMING_LABEL = 'SendReply';
-// Making the tab names more unique in case a plugin adds one with same name
-const ROBOT_COMMENTS_LIMIT = 10;
-
-// types used in this file
-/**
- * Type for the custom event to switch tab.
- *
- * @typedef {Object} SwitchTabEventDetail
- * @property {?string} tab - name of the tab to set as active, from custom event
- * @property {?boolean} scrollIntoView - scroll into the tab afterwards, from custom event
- * @property {?number} value - index of tab to set as active, from paper-tabs event
- */
-
-/**
- * @extends PolymerElement
- */
-class GrChangeView extends KeyboardShortcutMixin(
-    GestureEventListeners(LegacyElementMixin(PolymerElement))) {
-  static get template() { return htmlTemplate; }
-
-  static get is() { return 'gr-change-view'; }
-  /**
-   * Fired when the title of the page should change.
-   *
-   * @event title-change
-   */
-
-  /**
-   * Fired if an error occurs when fetching the change data.
-   *
-   * @event page-error
-   */
-
-  /**
-   * Fired if being logged in is required.
-   *
-   * @event show-auth-required
-   */
-
-  static get properties() {
-    return {
-    /**
-     * URL params passed from the router.
-     */
-      params: {
-        type: Object,
-        observer: '_paramsChanged',
-      },
-      /** @type {?} */
-      viewState: {
-        type: Object,
-        notify: true,
-        value() { return {}; },
-        observer: '_viewStateChanged',
-      },
-      backPage: String,
-      hasParent: Boolean,
-      keyEventTarget: {
-        type: Object,
-        value() { return document.body; },
-      },
-      disableEdit: {
-        type: Boolean,
-        value: false,
-      },
-      disableDiffPrefs: {
-        type: Boolean,
-        value: false,
-      },
-      _diffPrefsDisabled: {
-        type: Boolean,
-        computed: '_computeDiffPrefsDisabled(disableDiffPrefs, _loggedIn)',
-      },
-      _commentThreads: Array,
-      // TODO(taoalpha): Consider replacing diffDrafts
-      // with _draftCommentThreads everywhere, currently only
-      // replaced in reply-dialoig
-      _draftCommentThreads: {
-        type: Array,
-      },
-      _robotCommentThreads: {
-        type: Array,
-        computed: '_computeRobotCommentThreads(_commentThreads,'
-          + ' _currentRobotCommentsPatchSet, _showAllRobotComments)',
-      },
-      /** @type {?} */
-      _serverConfig: {
-        type: Object,
-        observer: '_startUpdateCheckTimer',
-      },
-      _diffPrefs: Object,
-      _numFilesShown: {
-        type: Number,
-        value: DEFAULT_NUM_FILES_SHOWN,
-        observer: '_numFilesShownChanged',
-      },
-      _account: {
-        type: Object,
-        value: {},
-      },
-      _prefs: Object,
-      /** @type {?} */
-      _changeComments: Object,
-      _canStartReview: {
-        type: Boolean,
-        computed: '_computeCanStartReview(_change)',
-      },
-      /** @type {?} */
-      _change: {
-        type: Object,
-        observer: '_changeChanged',
-      },
-      _revisionInfo: {
-        type: Object,
-        computed: '_getRevisionInfo(_change)',
-      },
-      /** @type {?} */
-      _commitInfo: Object,
-      _currentRevision: {
-        type: Object,
-        computed: '_computeCurrentRevision(_change.current_revision, ' +
-          '_change.revisions)',
-        observer: '_handleCurrentRevisionUpdate',
-      },
-      _files: Object,
-      _changeNum: String,
-      _diffDrafts: {
-        type: Object,
-        value() { return {}; },
-      },
-      _editingCommitMessage: {
-        type: Boolean,
-        value: false,
-      },
-      _hideEditCommitMessage: {
-        type: Boolean,
-        computed: '_computeHideEditCommitMessage(_loggedIn, ' +
-            '_editingCommitMessage, _change, _editMode, _commitCollapsed, ' +
-            '_commitCollapsible)',
-      },
-      _diffAgainst: String,
-      /** @type {?string} */
-      _latestCommitMessage: {
-        type: String,
-        value: '',
-      },
-      _constants: {
-        type: Object,
-        value: {
-          SecondaryTab,
-          PrimaryTab,
-        },
-      },
-      _messages: {
-        type: Object,
-        value: {
-          NO_ROBOT_COMMENTS_THREADS_MSG,
-        },
-      },
-      _lineHeight: Number,
-      _changeIdCommitMessageError: {
-        type: String,
-        computed:
-        '_computeChangeIdCommitMessageError(_latestCommitMessage, _change)',
-      },
-      /** @type {?} */
-      _patchRange: {
-        type: Object,
-      },
-      _filesExpanded: String,
-      _basePatchNum: String,
-      _selectedRevision: Object,
-      _currentRevisionActions: Object,
-      _allPatchSets: {
-        type: Array,
-        computed: '_computeAllPatchSets(_change, _change.revisions.*)',
-      },
-      _loggedIn: {
-        type: Boolean,
-        value: false,
-      },
-      _loading: Boolean,
-      /** @type {?} */
-      _projectConfig: Object,
-      _replyButtonLabel: {
-        type: String,
-        value: 'Reply',
-        computed: '_computeReplyButtonLabel(_diffDrafts.*, _canStartReview)',
-      },
-      _selectedPatchSet: String,
-      _shownFileCount: Number,
-      _initialLoadComplete: {
-        type: Boolean,
-        value: false,
-      },
-      _replyDisabled: {
-        type: Boolean,
-        value: true,
-        computed: '_computeReplyDisabled(_serverConfig)',
-      },
-      _changeStatus: {
-        type: String,
-        computed: '_changeStatusString(_change)',
-      },
-      _changeStatuses: {
-        type: String,
-        computed:
-        '_computeChangeStatusChips(_change, _mergeable, _submitEnabled)',
-      },
-      /** If false, then the "Show more" button was used to expand. */
-      _commitCollapsed: {
-        type: Boolean,
-        value: true,
-      },
-      /** Is the "Show more/less" button visible? */
-      _commitCollapsible: {
-        type: Boolean,
-        computed: '_computeCommitCollapsible(_latestCommitMessage)',
-      },
-      _relatedChangesCollapsed: {
-        type: Boolean,
-        value: true,
-      },
-      /** @type {?number} */
-      _updateCheckTimerHandle: Number,
-      _editMode: {
-        type: Boolean,
-        computed: '_computeEditMode(_patchRange.*, params.*)',
-      },
-      _showRelatedToggle: {
-        type: Boolean,
-        value: false,
-        observer: '_updateToggleContainerClass',
-      },
-      _parentIsCurrent: {
-        type: Boolean,
-        computed: '_isParentCurrent(_currentRevisionActions)',
-      },
-      _submitEnabled: {
-        type: Boolean,
-        computed: '_isSubmitEnabled(_currentRevisionActions)',
-      },
-
-      /** @type {?} */
-      _mergeable: {
-        type: Boolean,
-        value: undefined,
-      },
-      _showFileTabContent: {
-        type: Boolean,
-        value: true,
-      },
-      /** @type {Array<string>} */
-      _dynamicTabHeaderEndpoints: {
-        type: Array,
-      },
-      /** @type {Array<string>} */
-      _dynamicTabContentEndpoints: {
-        type: Array,
-      },
-      // The dynamic content of the plugin added tab
-      _selectedTabPluginEndpoint: {
-        type: String,
-      },
-      // The dynamic heading of the plugin added tab
-      _selectedTabPluginHeader: {
-        type: String,
-      },
-      _robotCommentsPatchSetDropdownItems: {
-        type: Array,
-        value() { return []; },
-        computed: '_computeRobotCommentsPatchSetDropdownItems(_change, ' +
-          '_commentThreads)',
-      },
-      _currentRobotCommentsPatchSet: {
-        type: Number,
-      },
-
-      /**
-       * @type {Array<string>} this is a two-element tuple to always
-       * hold the current active tab for both primary and secondary tabs
-       */
-      _activeTabs: {
-        type: Array,
-        value: [PrimaryTab.FILES, SecondaryTab.CHANGE_LOG],
-      },
-      _showAllRobotComments: {
-        type: Boolean,
-        value: false,
-      },
-      _showRobotCommentsButton: {
-        type: Boolean,
-        value: false,
-      },
-    };
-  }
-
-  static get observers() {
-    return [
-      '_labelsChanged(_change.labels.*)',
-      '_paramsAndChangeChanged(params, _change)',
-      '_patchNumChanged(_patchRange.patchNum)',
-    ];
-  }
-
-  keyboardShortcuts() {
-    return {
-      [Shortcut.SEND_REPLY]: null, // DOC_ONLY binding
-      [Shortcut.EMOJI_DROPDOWN]: null, // DOC_ONLY binding
-      [Shortcut.REFRESH_CHANGE]: '_handleRefreshChange',
-      [Shortcut.OPEN_REPLY_DIALOG]: '_handleOpenReplyDialog',
-      [Shortcut.OPEN_DOWNLOAD_DIALOG]:
-          '_handleOpenDownloadDialogShortcut',
-      [Shortcut.TOGGLE_DIFF_MODE]: '_handleToggleDiffMode',
-      [Shortcut.TOGGLE_CHANGE_STAR]: '_throttledToggleChangeStar',
-      [Shortcut.UP_TO_DASHBOARD]: '_handleUpToDashboard',
-      [Shortcut.EXPAND_ALL_MESSAGES]: '_handleExpandAllMessages',
-      [Shortcut.COLLAPSE_ALL_MESSAGES]: '_handleCollapseAllMessages',
-      [Shortcut.EXPAND_ALL_DIFF_CONTEXT]: '_expandAllDiffs',
-      [Shortcut.OPEN_DIFF_PREFS]: '_handleOpenDiffPrefsShortcut',
-      [Shortcut.EDIT_TOPIC]: '_handleEditTopic',
-      [Shortcut.DIFF_AGAINST_BASE]: '_handleDiffAgainstBase',
-      [Shortcut.DIFF_AGAINST_LATEST]: '_handleDiffAgainstLatest',
-      [Shortcut.DIFF_BASE_AGAINST_LEFT]: '_handleDiffBaseAgainstLeft',
-      [Shortcut.DIFF_RIGHT_AGAINST_LATEST]:
-        '_handleDiffRightAgainstLatest',
-      [Shortcut.DIFF_BASE_AGAINST_LATEST]:
-        '_handleDiffBaseAgainstLatest',
-    };
-  }
-
-  constructor() {
-    super();
-    this.reporting = appContext.reportingService;
-  }
-
-  connectedCallback() {
-    super.connectedCallback();
-    this._throttledToggleChangeStar = this._throttleWrap(e =>
-      this._handleToggleChangeStar(e));
-  }
-
-  /** @override */
-  created() {
-    super.created();
-
-    this.addEventListener('topic-changed',
-        () => this._handleTopicChanged());
-
-    this.addEventListener(
-        // When an overlay is opened in a mobile viewport, the overlay has a full
-        // screen view. When it has a full screen view, we do not want the
-        // background to be scrollable. This will eliminate background scroll by
-        // hiding most of the contents on the screen upon opening, and showing
-        // again upon closing.
-        'fullscreen-overlay-opened',
-        () => this._handleHideBackgroundContent());
-
-    this.addEventListener('fullscreen-overlay-closed',
-        () => this._handleShowBackgroundContent());
-
-    this.addEventListener('diff-comments-modified',
-        () => this._handleReloadCommentThreads());
-
-    this.addEventListener('open-reply-dialog',
-        e => this._openReplyDialog());
-  }
-
-  /** @override */
-  attached() {
-    super.attached();
-    this._getServerConfig().then(config => {
-      this._serverConfig = config;
-    });
-
-    this._getLoggedIn().then(loggedIn => {
-      this._loggedIn = loggedIn;
-      if (loggedIn) {
-        this.$.restAPI.getAccount().then(acct => {
-          this._account = acct;
-        });
-      }
-      this._setDiffViewMode();
-    });
-
-    getPluginLoader().awaitPluginsLoaded()
-        .then(() => {
-          this._dynamicTabHeaderEndpoints =
-            getPluginEndpoints().getDynamicEndpoints('change-view-tab-header');
-          this._dynamicTabContentEndpoints =
-            getPluginEndpoints().getDynamicEndpoints('change-view-tab-content');
-          if (this._dynamicTabContentEndpoints.length !==
-          this._dynamicTabHeaderEndpoints.length) {
-            console.warn('Different number of tab headers and tab content.');
-          }
-        })
-        .then(() => this._initActiveTabs(this.params));
-
-    this.addEventListener('comment-save', e => this._handleCommentSave(e));
-    this.addEventListener('comment-refresh', e => this._reloadDrafts(e));
-    this.addEventListener('comment-discard',
-        e => this._handleCommentDiscard(e));
-    this.addEventListener('change-message-deleted',
-        () => this._reload());
-    this.addEventListener('editable-content-save',
-        e => this._handleCommitMessageSave(e));
-    this.addEventListener('editable-content-cancel',
-        e => this._handleCommitMessageCancel(e));
-    this.addEventListener('open-fix-preview',
-        e => this._onOpenFixPreview(e));
-    this.addEventListener('close-fix-preview',
-        e => this._onCloseFixPreview(e));
-    this.listen(window, 'scroll', '_handleScroll');
-    this.listen(document, 'visibilitychange', '_handleVisibilityChange');
-
-    this.addEventListener('show-primary-tab',
-        e => this._setActivePrimaryTab(e));
-    this.addEventListener('show-secondary-tab',
-        e => this._setActiveSecondaryTab(e));
-    this.addEventListener('reload', e => {
-      e.stopPropagation();
-      this._reload(/* opt_isLocationChange= */false,
-          /* opt_clearPatchset= */e.detail && e.detail.clearPatchset);
-    });
-  }
-
-  /** @override */
-  detached() {
-    super.detached();
-    this.unlisten(window, 'scroll', '_handleScroll');
-    this.unlisten(document, 'visibilitychange', '_handleVisibilityChange');
-
-    if (this._updateCheckTimerHandle) {
-      this._cancelUpdateCheckTimer();
-    }
-  }
-
-  get messagesList() {
-    return this.shadowRoot.querySelector('gr-messages-list');
-  }
-
-  get threadList() {
-    return this.shadowRoot.querySelector('gr-thread-list');
-  }
-
-  _changeStatusString(change) {
-    return changeStatusString(change);
-  }
-
-  /**
-   * @param {boolean=} opt_reset
-   */
-  _setDiffViewMode(opt_reset) {
-    if (!opt_reset && this.viewState.diffViewMode) { return; }
-
-    return this._getPreferences()
-        .then( prefs => {
-          if (!this.viewState.diffMode) {
-            this.set('viewState.diffMode', prefs.default_diff_view);
-          }
-        })
-        .then(() => {
-          if (!this.viewState.diffMode) {
-            this.set('viewState.diffMode', 'SIDE_BY_SIDE');
-          }
-        });
-  }
-
-  _onOpenFixPreview(e) {
-    this.$.applyFixDialog.open(e);
-  }
-
-  _onCloseFixPreview(e) {
-    this._reload();
-  }
-
-  _handleToggleDiffMode(e) {
-    if (this.shouldSuppressKeyboardShortcut(e) ||
-        this.modifierPressed(e)) { return; }
-
-    e.preventDefault();
-    if (this.viewState.diffMode === DiffViewMode.SIDE_BY_SIDE) {
-      this.$.fileListHeader.setDiffViewMode(DiffViewMode.UNIFIED);
-    } else {
-      this.$.fileListHeader.setDiffViewMode(DiffViewMode.SIDE_BY_SIDE);
-    }
-  }
-
-  _isTabActive(tab, activeTabs) {
-    return activeTabs.includes(tab);
-  }
-
-  /**
-   * Actual implementation of switching a tab
-   *
-   * @param {!HTMLElement} paperTabs - the parent tabs container
-   * @param {!SwitchTabEventDetail} activeDetails
-   */
-  _setActiveTab(paperTabs, activeDetails) {
-    const {activeTabName, activeTabIndex, scrollIntoView} = activeDetails;
-    const tabs = paperTabs.querySelectorAll('paper-tab');
-    let activeIndex = -1;
-    if (activeTabIndex !== undefined) {
-      activeIndex = activeTabIndex;
-    } else {
-      for (let i = 0; i <= tabs.length; i++) {
-        const tab = tabs[i];
-        if (tab.dataset['name'] === activeTabName) {
-          activeIndex = i;
-          break;
-        }
-      }
-    }
-    if (activeIndex === -1) {
-      console.warn('tab not found with given info', activeDetails);
-      return;
-    }
-    const tabName = tabs[activeIndex].dataset['name'];
-    if (scrollIntoView) {
-      paperTabs.scrollIntoView();
-    }
-    if (paperTabs.selected !== activeIndex) {
-      paperTabs.selected = activeIndex;
-      this.reporting.reportInteraction('show-tab', {tabName});
-    }
-    return tabName;
-  }
-
-  /**
-   * Changes active primary tab.
-   *
-   * @param {CustomEvent<SwitchTabEventDetail>} e
-   */
-  _setActivePrimaryTab(e) {
-    const primaryTabs = this.shadowRoot.querySelector('#primaryTabs');
-    const activeTabName = this._setActiveTab(primaryTabs, {
-      activeTabName: e.detail.tab,
-      activeTabIndex: e.detail.value,
-      scrollIntoView: e.detail.scrollIntoView,
-    });
-    if (activeTabName) {
-      this._activeTabs = [activeTabName, this._activeTabs[1]];
-
-      // update plugin endpoint if its a plugin tab
-      const pluginIndex = (this._dynamicTabHeaderEndpoints || []).indexOf(
-          activeTabName);
-      if (pluginIndex !== -1) {
-        this._selectedTabPluginEndpoint = this._dynamicTabContentEndpoints[
-            pluginIndex];
-        this._selectedTabPluginHeader = this._dynamicTabHeaderEndpoints[
-            pluginIndex];
-      } else {
-        this._selectedTabPluginEndpoint = '';
-        this._selectedTabPluginHeader = '';
-      }
-    }
-  }
-
-  /**
-   * Changes active secondary tab.
-   *
-   * @param {CustomEvent<SwitchTabEventDetail>} e
-   */
-  _setActiveSecondaryTab(e) {
-    const secondaryTabs = this.shadowRoot.querySelector('#secondaryTabs');
-    const activeTabName = this._setActiveTab(secondaryTabs, {
-      activeTabName: e.detail.tab,
-      activeTabIndex: e.detail.value,
-      scrollIntoView: e.detail.scrollIntoView,
-    });
-    if (activeTabName) {
-      this._activeTabs = [this._activeTabs[0], activeTabName];
-    }
-  }
-
-  _handleEditCommitMessage() {
-    this._editingCommitMessage = true;
-    this.$.commitMessageEditor.focusTextarea();
-  }
-
-  _handleCommitMessageSave(e) {
-    // Trim trailing whitespace from each line.
-    const message = e.detail.content.replace(TRAILING_WHITESPACE_REGEX, '');
-
-    this.$.jsAPI.handleCommitMessage(this._change, message);
-
-    this.$.commitMessageEditor.disabled = true;
-    this.$.restAPI.putChangeCommitMessage(
-        this._changeNum, message)
-        .then(resp => {
-          this.$.commitMessageEditor.disabled = false;
-          if (!resp.ok) { return; }
-
-          this._latestCommitMessage = this._prepareCommitMsgForLinkify(
-              message);
-          this._editingCommitMessage = false;
-          this._reloadWindow();
-        })
-        .catch(err => {
-          this.$.commitMessageEditor.disabled = false;
-        });
-  }
-
-  _reloadWindow() {
-    window.location.reload();
-  }
-
-  _handleCommitMessageCancel(e) {
-    this._editingCommitMessage = false;
-  }
-
-  _computeChangeStatusChips(change, mergeable, submitEnabled) {
-    // Polymer 2: check for undefined
-    if ([
-      change,
-      mergeable,
-    ].includes(undefined)) {
-      // To keep consistent with Polymer 1, we are returning undefined
-      // if not all dependencies are defined
-      return undefined;
-    }
-
-    // Show no chips until mergeability is loaded.
-    if (mergeable === null) {
-      return [];
-    }
-
-    const options = {
-      includeDerived: true,
-      mergeable: !!mergeable,
-      submitEnabled: !!submitEnabled,
-    };
-    return changeStatuses(change, options);
-  }
-
-  _computeHideEditCommitMessage(
-      loggedIn, editing, change, editMode, collapsed, collapsible) {
-    if (!loggedIn || editing ||
-        (change && change.status === ChangeStatus.MERGED) ||
-        editMode ||
-        (collapsed && collapsible)) {
-      return true;
-    }
-
-    return false;
-  }
-
-  _robotCommentCountPerPatchSet(threads) {
-    return threads.reduce((robotCommentCountMap, thread) => {
-      const comments = thread.comments;
-      const robotCommentsCount = comments.reduce((acc, comment) =>
-        (comment.robot_id ? acc + 1 : acc), 0);
-      robotCommentCountMap[comments[0].patch_set] =
-          (robotCommentCountMap[comments[0].patch_set] || 0) +
-        robotCommentsCount;
-      return robotCommentCountMap;
-    }, {});
-  }
-
-  _computeText(patch, commentThreads) {
-    const commentCount = this._robotCommentCountPerPatchSet(commentThreads);
-    const commentCnt = commentCount[patch._number] || 0;
-    if (commentCnt === 0) return `Patchset ${patch._number}`;
-    const findingsText = commentCnt === 1 ? 'finding' : 'findings';
-    return `Patchset ${patch._number}`
-            + ` (${commentCnt} ${findingsText})`;
-  }
-
-  _computeRobotCommentsPatchSetDropdownItems(change, commentThreads) {
-    if (!change || !commentThreads || !change.revisions) return [];
-
-    return Object.values(change.revisions)
-        .filter(patch => patch._number !== 'edit')
-        .map(patch => {
-          return {
-            text: this._computeText(patch, commentThreads),
-            value: patch._number,
-          };
-        })
-        .sort((a, b) => b.value - a.value);
-  }
-
-  _handleCurrentRevisionUpdate(currentRevision) {
-    this._currentRobotCommentsPatchSet = currentRevision._number;
-  }
-
-  _handleRobotCommentPatchSetChanged(e) {
-    const patchSet = parseInt(e.detail.value);
-    if (patchSet === this._currentRobotCommentsPatchSet) return;
-    this._currentRobotCommentsPatchSet = patchSet;
-  }
-
-  _computeShowText(showAllRobotComments) {
-    return showAllRobotComments ? 'Show Less' : 'Show more';
-  }
-
-  _toggleShowRobotComments() {
-    this._showAllRobotComments = !this._showAllRobotComments;
-  }
-
-  _computeRobotCommentThreads(commentThreads, currentRobotCommentsPatchSet,
-      showAllRobotComments) {
-    if (!commentThreads || !currentRobotCommentsPatchSet) return [];
-    const threads = commentThreads.filter(thread => {
-      const comments = thread.comments || [];
-      return comments.length && comments[0].robot_id && (comments[0].patch_set
-        === currentRobotCommentsPatchSet);
-    });
-    this._showRobotCommentsButton = threads.length > ROBOT_COMMENTS_LIMIT;
-    return threads.slice(0, showAllRobotComments ? undefined :
-      ROBOT_COMMENTS_LIMIT);
-  }
-
-  _handleReloadCommentThreads() {
-    // Get any new drafts that have been saved in the diff view and show
-    // in the comment thread view.
-    this._reloadDrafts().then(() => {
-      this._commentThreads = this._changeComments.getAllThreadsForChange();
-      flush();
-    });
-  }
-
-  _handleReloadDiffComments(e) {
-    // Keeps the file list counts updated.
-    this._reloadDrafts().then(() => {
-      // Get any new drafts that have been saved in the thread view and show
-      // in the diff view.
-      this.$.fileList.reloadCommentsForThreadWithRootId(e.detail.rootId,
-          e.detail.path);
-      flush();
-    });
-  }
-
-  _computeTotalCommentCounts(unresolvedCount, changeComments) {
-    if (!changeComments) return undefined;
-    const draftCount = changeComments.computeDraftCount();
-    const unresolvedString = GrCountStringFormatter.computeString(
-        unresolvedCount, 'unresolved');
-    const draftString = GrCountStringFormatter.computePluralString(
-        draftCount, 'draft');
-
-    return unresolvedString +
-        // Add a comma and space if both unresolved and draft comments exist.
-        (unresolvedString && draftString ? ', ' : '') +
-        draftString;
-  }
-
-  _handleCommentSave(e) {
-    const draft = e.detail.comment;
-    if (!draft.__draft) { return; }
-
-    draft.patch_set = draft.patch_set || this._patchRange.patchNum;
-
-    // The use of path-based notification helpers (set, push) can’t be used
-    // because the paths could contain dots in them. A new object must be
-    // created to satisfy Polymer’s dirty checking.
-    // https://github.com/Polymer/polymer/issues/3127
-    const diffDrafts = {...this._diffDrafts};
-    if (!diffDrafts[draft.path]) {
-      diffDrafts[draft.path] = [draft];
-      this._diffDrafts = diffDrafts;
-      return;
-    }
-    for (let i = 0; i < this._diffDrafts[draft.path].length; i++) {
-      if (this._diffDrafts[draft.path][i].id === draft.id) {
-        diffDrafts[draft.path][i] = draft;
-        this._diffDrafts = diffDrafts;
-        return;
-      }
-    }
-    diffDrafts[draft.path].push(draft);
-    diffDrafts[draft.path].sort((c1, c2) =>
-      // No line number means that it’s a file comment. Sort it above the
-      // others.
-      (c1.line || -1) - (c2.line || -1)
-    );
-    this._diffDrafts = diffDrafts;
-  }
-
-  _handleCommentDiscard(e) {
-    const draft = e.detail.comment;
-    if (!draft.__draft) { return; }
-
-    if (!this._diffDrafts[draft.path]) {
-      return;
-    }
-    let index = -1;
-    for (let i = 0; i < this._diffDrafts[draft.path].length; i++) {
-      if (this._diffDrafts[draft.path][i].id === draft.id) {
-        index = i;
-        break;
-      }
-    }
-    if (index === -1) {
-      // It may be a draft that hasn’t been added to _diffDrafts since it was
-      // never saved.
-      return;
-    }
-
-    draft.patch_set = draft.patch_set || this._patchRange.patchNum;
-
-    // The use of path-based notification helpers (set, push) can’t be used
-    // because the paths could contain dots in them. A new object must be
-    // created to satisfy Polymer’s dirty checking.
-    // https://github.com/Polymer/polymer/issues/3127
-    const diffDrafts = {...this._diffDrafts};
-    diffDrafts[draft.path].splice(index, 1);
-    if (diffDrafts[draft.path].length === 0) {
-      delete diffDrafts[draft.path];
-    }
-    this._diffDrafts = diffDrafts;
-  }
-
-  _handleReplyTap(e) {
-    e.preventDefault();
-    this._openReplyDialog(this.$.replyDialog.FocusTarget.ANY);
-  }
-
-  _handleOpenDiffPrefs() {
-    this.$.fileList.openDiffPrefs();
-  }
-
-  _handleOpenIncludedInDialog() {
-    this.$.includedInDialog.loadData().then(() => {
-      flush();
-      this.$.includedInOverlay.refit();
-    });
-    this.$.includedInOverlay.open();
-  }
-
-  _handleIncludedInDialogClose(e) {
-    this.$.includedInOverlay.close();
-  }
-
-  _handleOpenDownloadDialog() {
-    this.$.downloadOverlay.open().then(() => {
-      this.$.downloadOverlay
-          .setFocusStops(this.$.downloadDialog.getFocusStops());
-      this.$.downloadDialog.focus();
-    });
-  }
-
-  _handleDownloadDialogClose(e) {
-    this.$.downloadOverlay.close();
-  }
-
-  _handleOpenUploadHelpDialog(e) {
-    this.$.uploadHelpOverlay.open();
-  }
-
-  _handleCloseUploadHelpDialog(e) {
-    this.$.uploadHelpOverlay.close();
-  }
-
-  _handleMessageReply(e) {
-    const msg = e.detail.message.message;
-    const quoteStr = msg.split('\n').map(
-        line => '> ' + line)
-        .join('\n') + '\n\n';
-    this.$.replyDialog.quote = quoteStr;
-    this._openReplyDialog(this.$.replyDialog.FocusTarget.BODY);
-  }
-
-  _handleHideBackgroundContent() {
-    this.$.mainContent.classList.add('overlayOpen');
-  }
-
-  _handleShowBackgroundContent() {
-    this.$.mainContent.classList.remove('overlayOpen');
-  }
-
-  _handleReplySent(e) {
-    this.addEventListener('change-details-loaded',
-        () => {
-          this.reporting.timeEnd(SEND_REPLY_TIMING_LABEL);
-        }, {once: true});
-    this.$.replyOverlay.close();
-    this._reload();
-  }
-
-  _handleReplyCancel(e) {
-    this.$.replyOverlay.close();
-  }
-
-  _handleReplyAutogrow(e) {
-    // If the textarea resizes, we need to re-fit the overlay.
-    this.debounce('reply-overlay-refit', () => {
-      this.$.replyOverlay.refit();
-    }, REPLY_REFIT_DEBOUNCE_INTERVAL_MS);
-  }
-
-  _handleShowReplyDialog(e) {
-    let target = this.$.replyDialog.FocusTarget.REVIEWERS;
-    if (e.detail.value && e.detail.value.ccsOnly) {
-      target = this.$.replyDialog.FocusTarget.CCS;
-    }
-    this._openReplyDialog(target);
-  }
-
-  _handleScroll() {
-    this.debounce('scroll', () => {
-      this.viewState.scrollTop = document.body.scrollTop;
-    }, 150);
-  }
-
-  _setShownFiles(e) {
-    this._shownFileCount = e.detail.length;
-  }
-
-  _expandAllDiffs(e) {
-    if (this.shouldSuppressKeyboardShortcut(e)) { return; }
-    this.$.fileList.expandAllDiffs();
-  }
-
-  _collapseAllDiffs() {
-    this.$.fileList.collapseAllDiffs();
-  }
-
-  _paramsChanged(value) {
-    if (value.view !== GerritNav.View.CHANGE) {
-      this._initialLoadComplete = false;
-      return;
-    }
-
-    if (value.changeNum && value.project) {
-      this.$.restAPI.setInProjectLookup(value.changeNum, value.project);
-    }
-
-    const patchChanged = this._patchRange &&
-        (value.patchNum !== undefined && value.basePatchNum !== undefined) &&
-        (this._patchRange.patchNum !== value.patchNum ||
-        this._patchRange.basePatchNum !== value.basePatchNum);
-    const changeChanged = this._changeNum !== value.changeNum;
-
-    const patchRange = {
-      patchNum: value.patchNum,
-      basePatchNum: value.basePatchNum || 'PARENT',
-    };
-    // TODO(TS): remove once proper type for patchRange is defined
-    if (!isNaN(Number(patchRange.patchNum))) {
-      patchRange.patchNum = Number(patchRange.patchNum);
-    }
-    if (!isNaN(Number(patchRange.basePatchNum))) {
-      patchRange.basePatchNum = Number(patchRange.basePatchNum);
-    }
-
-    this.$.fileList.collapseAllDiffs();
-    this._patchRange = patchRange;
-
-    // If the change has already been loaded and the parameter change is only
-    // in the patch range, then don't do a full reload.
-    if (!changeChanged && patchChanged) {
-      if (patchRange.patchNum == null) {
-        patchRange.patchNum = computeLatestPatchNum(this._allPatchSets);
-      }
-      this._reloadPatchNumDependentResources().then(() => {
-        this._sendShowChangeEvent();
-      });
-      return;
-    }
-
-    this._initialLoadComplete = false;
-    this._changeNum = value.changeNum;
-    this.$.relatedChanges.clear();
-
-    this._reload(true).then(() => {
-      this._performPostLoadTasks();
-    });
-
-    getPluginLoader().awaitPluginsLoaded()
-        .then(() => {
-          this._initActiveTabs(value);
-        });
-  }
-
-  _initActiveTabs(params = {}) {
-    let primaryTab = PrimaryTab.FILES;
-    if (params.queryMap && params.queryMap.has('tab')) {
-      primaryTab = params.queryMap.get('tab');
-    }
-    this._setActivePrimaryTab({
-      detail: {
-        tab: primaryTab,
-      },
-    });
-    this._setActiveSecondaryTab({
-      detail: {
-        tab: SecondaryTab.CHANGE_LOG,
-      },
-    });
-  }
-
-  _sendShowChangeEvent() {
-    this.$.jsAPI.handleEvent(EventType.SHOW_CHANGE, {
-      change: this._change,
-      patchNum: this._patchRange.patchNum,
-      info: {mergeable: this._mergeable},
-    });
-  }
-
-  _performPostLoadTasks() {
-    this._maybeShowReplyDialog();
-    this._maybeShowRevertDialog();
-    this._maybeShowDownloadDialog();
-
-    this._sendShowChangeEvent();
-
-    this.async(() => {
-      if (this.viewState.scrollTop) {
-        document.documentElement.scrollTop =
-            document.body.scrollTop = this.viewState.scrollTop;
-      } else {
-        this._maybeScrollToMessage(window.location.hash);
-      }
-      this._initialLoadComplete = true;
-    });
-  }
-
-  _paramsAndChangeChanged(value, change) {
-    // Polymer 2: check for undefined
-    if ([value, change].includes(undefined)) {
-      return;
-    }
-
-    // If the change number or patch range is different, then reset the
-    // selected file index.
-    const patchRangeState = this.viewState.patchRange;
-    if (this.viewState.changeNum !== this._changeNum ||
-        !patchRangeState ||
-        patchRangeState.basePatchNum !== this._patchRange.basePatchNum ||
-        patchRangeState.patchNum !== this._patchRange.patchNum) {
-      this._resetFileListViewState();
-    }
-  }
-
-  _viewStateChanged(viewState) {
-    this._numFilesShown = viewState.numFilesShown ?
-      viewState.numFilesShown : DEFAULT_NUM_FILES_SHOWN;
-  }
-
-  _numFilesShownChanged(numFilesShown) {
-    this.viewState.numFilesShown = numFilesShown;
-  }
-
-  _handleMessageAnchorTap(e) {
-    const hash = MSG_PREFIX + e.detail.id;
-    const url = GerritNav.getUrlForChange(this._change,
-        this._patchRange.patchNum, this._patchRange.basePatchNum,
-        this._editMode, hash);
-    history.replaceState(null, '', url);
-  }
-
-  _maybeScrollToMessage(hash) {
-    if (hash.startsWith(MSG_PREFIX)) {
-      this.messagesList.scrollToMessage(hash.substr(MSG_PREFIX.length));
-    }
-  }
-
-  _getLocationSearch() {
-    // Not inlining to make it easier to test.
-    return window.location.search;
-  }
-
-  _getUrlParameter(param) {
-    const pageURL = this._getLocationSearch().substring(1);
-    const vars = pageURL.split('&');
-    for (let i = 0; i < vars.length; i++) {
-      const name = vars[i].split('=');
-      if (name[0] == param) {
-        return name[0];
-      }
-    }
-    return null;
-  }
-
-  _maybeShowRevertDialog() {
-    getPluginLoader().awaitPluginsLoaded()
-        .then(() => this._getLoggedIn())
-        .then(loggedIn => {
-          if (!loggedIn || !this._change ||
-              this._change.status !== ChangeStatus.MERGED) {
-          // Do not display dialog if not logged-in or the change is not
-          // merged.
-            return;
-          }
-          if (this._getUrlParameter('revert')) {
-            this.$.actions.showRevertDialog();
-          }
-        });
-  }
-
-  _maybeShowReplyDialog() {
-    this._getLoggedIn().then(loggedIn => {
-      if (!loggedIn) { return; }
-
-      if (this.viewState.showReplyDialog) {
-        this._openReplyDialog(this.$.replyDialog.FocusTarget.ANY);
-        // TODO(kaspern@): Find a better signal for when to call center.
-        this.async(() => { this.$.replyOverlay.center(); }, 100);
-        this.async(() => { this.$.replyOverlay.center(); }, 1000);
-        this.set('viewState.showReplyDialog', false);
-      }
-    });
-  }
-
-  _maybeShowDownloadDialog() {
-    if (this.viewState.showDownloadDialog) {
-      this._handleOpenDownloadDialog();
-      this.set('viewState.showDownloadDialog', false);
-    }
-  }
-
-  _resetFileListViewState() {
-    this.set('viewState.selectedFileIndex', 0);
-    this.set('viewState.scrollTop', 0);
-    if (!!this.viewState.changeNum &&
-        this.viewState.changeNum !== this._changeNum) {
-      // Reset the diff mode to null when navigating from one change to
-      // another, so that the user's preference is restored.
-      this._setDiffViewMode(true);
-      this.set('_numFilesShown', DEFAULT_NUM_FILES_SHOWN);
-    }
-    this.set('viewState.changeNum', this._changeNum);
-    this.set('viewState.patchRange', this._patchRange);
-  }
-
-  _changeChanged(change) {
-    if (!change || !this._patchRange || !this._allPatchSets) { return; }
-
-    // We get the parent first so we keep the original value for basePatchNum
-    // and not the updated value.
-    const parent = this._getBasePatchNum(change, this._patchRange);
-
-    this.set('_patchRange.patchNum', this._patchRange.patchNum ||
-            computeLatestPatchNum(this._allPatchSets));
-
-    this.set('_patchRange.basePatchNum', parent);
-
-    const title = change.subject + ' (' + change.change_id.substr(0, 9) + ')';
-    this.dispatchEvent(new CustomEvent('title-change', {
-      detail: {title},
-      composed: true, bubbles: true,
-    }));
-  }
-
-  /**
-   * Gets base patch number, if it is a parent try and decide from
-   * preference whether to default to `auto merge`, `Parent 1` or `PARENT`.
-   *
-   * @param {Object} change
-   * @param {Object} patchRange
-   * @return {number|string}
-   */
-  _getBasePatchNum(change, patchRange) {
-    if (patchRange.basePatchNum &&
-        patchRange.basePatchNum !== 'PARENT') {
-      return patchRange.basePatchNum;
-    }
-
-    const revisionInfo = this._getRevisionInfo(change);
-    if (!revisionInfo) return 'PARENT';
-
-    const parentCounts = revisionInfo.getParentCountMap();
-    // check that there is at least 2 parents otherwise fall back to 1,
-    // which means there is only one parent.
-    const parentCount = parentCounts.hasOwnProperty(1) ?
-      parentCounts[1] : 1;
-
-    const preferFirst = this._prefs &&
-        this._prefs.default_base_for_merges === 'FIRST_PARENT';
-
-    if (parentCount > 1 && preferFirst && !patchRange.patchNum) {
-      return -1;
-    }
-
-    return 'PARENT';
-  }
-
-  _computeChangeUrl(change) {
-    return GerritNav.getUrlForChange(change);
-  }
-
-  _computeShowCommitInfo(changeStatus, current_revision) {
-    return changeStatus === 'Merged' && current_revision;
-  }
-
-  _computeMergedCommitInfo(current_revision, revisions) {
-    const rev = revisions[current_revision];
-    if (!rev || !rev.commit) { return {}; }
-    // CommitInfo.commit is optional. Set commit in all cases to avoid error
-    // in <gr-commit-info>. @see Issue 5337
-    if (!rev.commit.commit) { rev.commit.commit = current_revision; }
-    return rev.commit;
-  }
-
-  _computeChangeIdClass(displayChangeId) {
-    return displayChangeId === CHANGE_ID_ERROR.MISMATCH ? 'warning' : '';
-  }
-
-  _computeTitleAttributeWarning(displayChangeId) {
-    if (displayChangeId === CHANGE_ID_ERROR.MISMATCH) {
-      return 'Change-Id mismatch';
-    } else if (displayChangeId === CHANGE_ID_ERROR.MISSING) {
-      return 'No Change-Id in commit message';
-    }
-  }
-
-  _computeChangeIdCommitMessageError(commitMessage, change) {
-    // Polymer 2: check for undefined
-    if ([commitMessage, change].includes(undefined)) {
-      return undefined;
-    }
-
-    if (!commitMessage) { return CHANGE_ID_ERROR.MISSING; }
-
-    // Find the last match in the commit message:
-    let changeId;
-    let changeIdArr;
-
-    while (changeIdArr = CHANGE_ID_REGEX_PATTERN.exec(commitMessage)) {
-      changeId = changeIdArr[2];
-    }
-
-    if (changeId) {
-      // A change-id is detected in the commit message.
-
-      if (changeId === change.change_id) {
-        // The change-id found matches the real change-id.
-        return null;
-      }
-      // The change-id found does not match the change-id.
-      return CHANGE_ID_ERROR.MISMATCH;
-    }
-    // There is no change-id in the commit message.
-    return CHANGE_ID_ERROR.MISSING;
-  }
-
-  _computeLabelNames(labels) {
-    return Object.keys(labels).sort();
-  }
-
-  _computeLabelValues(labelName, labels) {
-    const result = [];
-    const t = labels[labelName];
-    if (!t) { return result; }
-    const approvals = t.all || [];
-    for (const label of approvals) {
-      if (label.value && label.value != labels[labelName].default_value) {
-        let labelClassName;
-        let labelValPrefix = '';
-        if (label.value > 0) {
-          labelValPrefix = '+';
-          labelClassName = 'approved';
-        } else if (label.value < 0) {
-          labelClassName = 'notApproved';
-        }
-        result.push({
-          value: labelValPrefix + label.value,
-          className: labelClassName,
-          account: label,
-        });
-      }
-    }
-    return result;
-  }
-
-  _computeReplyButtonLabel(changeRecord, canStartReview) {
-    // Polymer 2: check for undefined
-    if ([changeRecord, canStartReview].includes(undefined)) {
-      return 'Reply';
-    }
-
-    const drafts = (changeRecord && changeRecord.base) || {};
-    const draftCount = Object.keys(drafts)
-        .reduce((count, file) => count + drafts[file].length, 0);
-
-    let label = canStartReview ? 'Start Review' : 'Reply';
-    if (draftCount > 0) {
-      label += ' (' + draftCount + ')';
-    }
-    return label;
-  }
-
-  _handleOpenReplyDialog(e) {
-    if (this.shouldSuppressKeyboardShortcut(e) ||
-        this.modifierPressed(e)) {
-      return;
-    }
-    this._getLoggedIn().then(isLoggedIn => {
-      if (!isLoggedIn) {
-        this.dispatchEvent(new CustomEvent('show-auth-required', {
-          composed: true, bubbles: true,
-        }));
-        return;
-      }
-
-      e.preventDefault();
-      this._openReplyDialog(this.$.replyDialog.FocusTarget.ANY);
-    });
-  }
-
-  _handleOpenDownloadDialogShortcut(e) {
-    if (this.shouldSuppressKeyboardShortcut(e) ||
-        this.modifierPressed(e)) { return; }
-
-    e.preventDefault();
-    this._handleOpenDownloadDialog();
-  }
-
-  _handleEditTopic(e) {
-    if (this.shouldSuppressKeyboardShortcut(e) ||
-        this.modifierPressed(e)) { return; }
-
-    e.preventDefault();
-    this.$.metadata.editTopic();
-  }
-
-  _handleDiffAgainstBase(e) {
-    if (this.shouldSuppressKeyboardShortcut(e)) { return; }
-    if (patchNumEquals(this._patchRange.basePatchNum,
-        SPECIAL_PATCH_SET_NUM.PARENT)) {
-      this.dispatchEvent(new CustomEvent('show-alert', {
-        detail: {
-          message: 'Base is already selected.',
-        },
-        composed: true, bubbles: true,
-      }));
-      return;
-    }
-    GerritNav.navigateToChange(this._change, this._patchRange.patchNum);
-  }
-
-  _handleDiffBaseAgainstLeft(e) {
-    if (this.shouldSuppressKeyboardShortcut(e)) { return; }
-    if (patchNumEquals(this._patchRange.basePatchNum,
-        SPECIAL_PATCH_SET_NUM.PARENT)) {
-      this.dispatchEvent(new CustomEvent('show-alert', {
-        detail: {
-          message: 'Left is already base.',
-        },
-        composed: true, bubbles: true,
-      }));
-      return;
-    }
-    GerritNav.navigateToChange(this._change, this._patchRange.basePatchNum);
-  }
-
-  _handleDiffAgainstLatest(e) {
-    if (this.shouldSuppressKeyboardShortcut(e)) { return; }
-    const latestPatchNum = computeLatestPatchNum(this._allPatchSets);
-    if (patchNumEquals(this._patchRange.patchNum, latestPatchNum)) {
-      this.dispatchEvent(new CustomEvent('show-alert', {
-        detail: {
-          message: 'Latest is already selected.',
-        },
-        composed: true, bubbles: true,
-      }));
-      return;
-    }
-    GerritNav.navigateToChange(this._change, latestPatchNum,
-        this._patchRange.basePatchNum);
-  }
-
-  _handleDiffRightAgainstLatest(e) {
-    if (this.shouldSuppressKeyboardShortcut(e)) { return; }
-    const latestPatchNum = computeLatestPatchNum(this._allPatchSets);
-    if (patchNumEquals(this._patchRange.patchNum, latestPatchNum)) {
-      this.dispatchEvent(new CustomEvent('show-alert', {
-        detail: {
-          message: 'Right is already latest.',
-        },
-        composed: true, bubbles: true,
-      }));
-      return;
-    }
-    GerritNav.navigateToChange(this._change, latestPatchNum,
-        this._patchRange.patchNum);
-  }
-
-  _handleDiffBaseAgainstLatest(e) {
-    if (this.shouldSuppressKeyboardShortcut(e)) { return; }
-    const latestPatchNum = computeLatestPatchNum(this._allPatchSets);
-    if (patchNumEquals(this._patchRange.patchNum, latestPatchNum) &&
-      patchNumEquals(this._patchRange.basePatchNum,
-          SPECIAL_PATCH_SET_NUM.PARENT)) {
-      this.dispatchEvent(new CustomEvent('show-alert', {
-        detail: {
-          message: 'Already diffing base against latest.',
-        },
-        composed: true, bubbles: true,
-      }));
-      return;
-    }
-    GerritNav.navigateToChange(this._change, latestPatchNum);
-  }
-
-  _handleRefreshChange(e) {
-    if (this.shouldSuppressKeyboardShortcut(e)) { return; }
-    e.preventDefault();
-    this._reload(/* opt_isLocationChange= */false,
-        /* opt_clearPatchset= */true);
-  }
-
-  _handleToggleChangeStar(e) {
-    if (this.shouldSuppressKeyboardShortcut(e) ||
-        this.modifierPressed(e)) { return; }
-    e.preventDefault();
-    this.$.changeStar.toggleStar();
-  }
-
-  _handleUpToDashboard(e) {
-    if (this.shouldSuppressKeyboardShortcut(e) ||
-        this.modifierPressed(e)) { return; }
-
-    e.preventDefault();
-    this._determinePageBack();
-  }
-
-  _handleExpandAllMessages(e) {
-    if (this.shouldSuppressKeyboardShortcut(e) ||
-        this.modifierPressed(e)) { return; }
-
-    e.preventDefault();
-    this.messagesList.handleExpandCollapse(true);
-  }
-
-  _handleCollapseAllMessages(e) {
-    if (this.shouldSuppressKeyboardShortcut(e) ||
-        this.modifierPressed(e)) { return; }
-
-    e.preventDefault();
-    this.messagesList.handleExpandCollapse(false);
-  }
-
-  _handleOpenDiffPrefsShortcut(e) {
-    if (this.shouldSuppressKeyboardShortcut(e) ||
-        this.modifierPressed(e)) { return; }
-
-    if (this._diffPrefsDisabled) { return; }
-
-    e.preventDefault();
-    this.$.fileList.openDiffPrefs();
-  }
-
-  _determinePageBack() {
-    // Default backPage to root if user came to change view page
-    // via an email link, etc.
-    GerritNav.navigateToRelativeUrl(this.backPage ||
-         GerritNav.getUrlForRoot());
-  }
-
-  _handleLabelRemoved(splices, path) {
-    for (const splice of splices) {
-      for (const removed of splice.removed) {
-        const changePath = path.split('.');
-        const labelPath = changePath.splice(0, changePath.length - 2);
-        const labelDict = this.get(labelPath);
-        if (labelDict.approved &&
-            labelDict.approved._account_id === removed._account_id) {
-          this._reload();
-          return;
-        }
-      }
-    }
-  }
-
-  _labelsChanged(changeRecord) {
-    if (!changeRecord) { return; }
-    if (changeRecord.value && changeRecord.value.indexSplices) {
-      this._handleLabelRemoved(changeRecord.value.indexSplices,
-          changeRecord.path);
-    }
-    this.$.jsAPI.handleEvent(EventType.LABEL_CHANGE, {
-      change: this._change,
-    });
-  }
-
-  /**
-   * @param {string=} opt_section
-   */
-  _openReplyDialog(opt_section) {
-    this.$.replyOverlay.open().finally(() => {
-      // the following code should be executed no matter open succeed or not
-      this._resetReplyOverlayFocusStops();
-      this.$.replyDialog.open(opt_section);
-      flush();
-      this.$.replyOverlay.center();
-    });
-  }
-
-  _handleGetChangeDetailError(response) {
-    this.dispatchEvent(new CustomEvent('page-error', {
-      detail: {response},
-      composed: true, bubbles: true,
-    }));
-  }
-
-  _getLoggedIn() {
-    return this.$.restAPI.getLoggedIn();
-  }
-
-  _getServerConfig() {
-    return this.$.restAPI.getConfig();
-  }
-
-  _getProjectConfig() {
-    if (!this._change) return;
-    return this.$.restAPI.getProjectConfig(this._change.project).then(
-        config => {
-          this._projectConfig = config;
-        });
-  }
-
-  _getPreferences() {
-    return this.$.restAPI.getPreferences();
-  }
-
-  _prepareCommitMsgForLinkify(msg) {
-    // TODO(wyatta) switch linkify sequence, see issue 5526.
-    // This is a zero-with space. It is added to prevent the linkify library
-    // from including R= or CC= as part of the email address.
-    return msg.replace(REVIEWERS_REGEX, '$1=\u200B');
-  }
-
-  /**
-   * Utility function to make the necessary modifications to a change in the
-   * case an edit exists.
-   *
-   * @param {!Object} change
-   * @param {?Object} edit
-   */
-  _processEdit(change, edit) {
-    if (!edit) { return; }
-    change.revisions[edit.commit.commit] = {
-      _number: SPECIAL_PATCH_SET_NUM.EDIT,
-      basePatchNum: edit.base_patch_set_number,
-      commit: edit.commit,
-      fetch: edit.fetch,
-    };
-    // If the edit is based on the most recent patchset, load it by
-    // default, unless another patch set to load was specified in the URL.
-    if (!this._patchRange.patchNum &&
-        change.current_revision === edit.base_revision) {
-      change.current_revision = edit.commit.commit;
-      this.set('_patchRange.patchNum', SPECIAL_PATCH_SET_NUM.EDIT);
-      // Because edits are fibbed as revisions and added to the revisions
-      // array, and revision actions are always derived from the 'latest'
-      // patch set, we must copy over actions from the patch set base.
-      // Context: Issue 7243
-      change.revisions[edit.commit.commit].actions =
-          change.revisions[edit.base_revision].actions;
-    }
-  }
-
-  _getChangeDetail() {
-    const detailCompletes = this.$.restAPI.getChangeDetail(
-        this._changeNum, r => this._handleGetChangeDetailError(r));
-    const editCompletes = this._getEdit();
-    const prefCompletes = this._getPreferences();
-
-    return Promise.all([detailCompletes, editCompletes, prefCompletes])
-        .then(([change, edit, prefs]) => {
-          this._prefs = prefs;
-
-          if (!change) {
-            return '';
-          }
-          this._processEdit(change, edit);
-          // Issue 4190: Coalesce missing topics to null.
-          if (!change.topic) { change.topic = null; }
-          if (!change.reviewer_updates) {
-            change.reviewer_updates = null;
-          }
-          const latestRevisionSha = this._getLatestRevisionSHA(change);
-          const currentRevision = change.revisions[latestRevisionSha];
-          if (currentRevision.commit && currentRevision.commit.message) {
-            this._latestCommitMessage = this._prepareCommitMsgForLinkify(
-                currentRevision.commit.message);
-          } else {
-            this._latestCommitMessage = null;
-          }
-
-          const lineHeight = getComputedStyle(this).lineHeight;
-
-          // Slice returns a number as a string, convert to an int.
-          this._lineHeight =
-              parseInt(lineHeight.slice(0, lineHeight.length - 2), 10);
-
-          this._change = change;
-          if (!this._patchRange || !this._patchRange.patchNum ||
-              patchNumEquals(this._patchRange.patchNum,
-                  currentRevision._number)) {
-            // CommitInfo.commit is optional, and may need patching.
-            if (!currentRevision.commit.commit) {
-              currentRevision.commit.commit = latestRevisionSha;
-            }
-            this._commitInfo = currentRevision.commit;
-            this._selectedRevision = currentRevision;
-            // TODO: Fetch and process files.
-          } else {
-            this._selectedRevision =
-              Object.values(this._change.revisions).find(
-                  revision => {
-                    // edit patchset is a special one
-                    const thePatchNum = this._patchRange.patchNum;
-                    if (thePatchNum === 'edit') {
-                      return revision._number === thePatchNum;
-                    }
-                    return revision._number === parseInt(thePatchNum, 10);
-                  });
-          }
-        });
-  }
-
-  _isSubmitEnabled(revisionActions) {
-    return !!(revisionActions && revisionActions.submit &&
-      revisionActions.submit.enabled);
-  }
-
-  _isParentCurrent(revisionActions) {
-    if (revisionActions && revisionActions.rebase) {
-      return !revisionActions.rebase.enabled;
-    } else {
-      return true;
-    }
-  }
-
-  _getEdit() {
-    return this.$.restAPI.getChangeEdit(this._changeNum, true);
-  }
-
-  _getLatestCommitMessage() {
-    return this.$.restAPI.getChangeCommitInfo(this._changeNum,
-        computeLatestPatchNum(this._allPatchSets)).then(commitInfo => {
-      if (!commitInfo) return Promise.resolve();
-      this._latestCommitMessage =
-                  this._prepareCommitMsgForLinkify(commitInfo.message);
-    });
-  }
-
-  _getLatestRevisionSHA(change) {
-    if (change.current_revision) {
-      return change.current_revision;
-    }
-    // current_revision may not be present in the case where the latest rev is
-    // a draft and the user doesn’t have permission to view that rev.
-    let latestRev = null;
-    let latestPatchNum = -1;
-    for (const rev in change.revisions) {
-      if (!change.revisions.hasOwnProperty(rev)) { continue; }
-
-      if (change.revisions[rev]._number > latestPatchNum) {
-        latestRev = rev;
-        latestPatchNum = change.revisions[rev]._number;
-      }
-    }
-    return latestRev;
-  }
-
-  _getCommitInfo() {
-    return this.$.restAPI.getChangeCommitInfo(
-        this._changeNum, this._patchRange.patchNum).then(
-        commitInfo => {
-          this._commitInfo = commitInfo;
-        });
-  }
-
-  _reloadDraftsWithCallback(e) {
-    return this._reloadDrafts().then(() => e.detail.resolve());
-  }
-
-  /**
-   * Fetches a new changeComment object, and data for all types of comments
-   * (comments, robot comments, draft comments) is requested.
-   */
-  _reloadComments() {
-    // We are resetting all comment related properties, because we want to avoid
-    // a new change being loaded and then paired with outdated comments.
-    this._changeComments = undefined;
-    this._commentThreads = undefined;
-    this._diffDrafts = undefined;
-    this._draftCommentThreads = undefined;
-    this._robotCommentThreads = undefined;
-    return this.$.commentAPI.loadAll(this._changeNum)
-        .then(comments => this._recomputeComments(comments));
-  }
-
-  /**
-   * Fetches a new changeComment object, but only updated data for drafts is
-   * requested.
-   *
-   * TODO(taoalpha): clean up this and _reloadComments, as single comment
-   * can be a thread so it does not make sense to only update drafts
-   * without updating threads
-   */
-  _reloadDrafts() {
-    return this.$.commentAPI.reloadDrafts(this._changeNum)
-        .then(comments => this._recomputeComments(comments));
-  }
-
-  _recomputeComments(comments) {
-    this._changeComments = comments;
-    this._diffDrafts = {...this._changeComments.drafts};
-    this._commentThreads = this._changeComments.getAllThreadsForChange();
-    this._draftCommentThreads = this._commentThreads
-        .filter(thread => thread.comments[thread.comments.length - 1].__draft)
-        .map(thread => {
-          const copiedThread = {...thread};
-          // Make a hardcopy of all comments and collapse all but last one
-          const commentsInThread = copiedThread.comments = thread.comments
-              .map(comment => { return {...comment, collapsed: true}; });
-          commentsInThread[commentsInThread.length - 1].collapsed = false;
-          return copiedThread;
-        });
-  }
-
-  /**
-   * Reload the change.
-   *
-   * @param {boolean=} opt_isLocationChange Reloads the related changes
-   *     when true and ends reporting events that started on location change.
-   * @param {boolean=} opt_clearPatchset Reloads the related changes
-   *     ignoring any patchset choice made.
-   * @return {Promise} A promise that resolves when the core data has loaded.
-   *     Some non-core data loading may still be in-flight when the core data
-   *     promise resolves.
-   */
-  _reload(opt_isLocationChange, opt_clearPatchset) {
-    if (opt_clearPatchset) {
-      GerritNav.navigateToChange(this._change);
-      return;
-    }
-    this._loading = true;
-    this._relatedChangesCollapsed = true;
-    this.reporting.time(CHANGE_RELOAD_TIMING_LABEL);
-    this.reporting.time(CHANGE_DATA_TIMING_LABEL);
-
-    // Array to house all promises related to data requests.
-    const allDataPromises = [];
-
-    // Resolves when the change detail and the edit patch set (if available)
-    // are loaded.
-    const detailCompletes = this._getChangeDetail();
-    allDataPromises.push(detailCompletes);
-
-    // Resolves when the loading flag is set to false, meaning that some
-    // change content may start appearing.
-    const loadingFlagSet = detailCompletes
-        .then(() => {
-          this._loading = false;
-          this.dispatchEvent(new CustomEvent('change-details-loaded',
-              {bubbles: true, composed: true}));
-        })
-        .then(() => {
-          this.reporting.timeEnd(CHANGE_RELOAD_TIMING_LABEL);
-          if (opt_isLocationChange) {
-            this.reporting.changeDisplayed();
-          }
-        });
-
-    // Resolves when the project config has loaded.
-    const projectConfigLoaded = detailCompletes
-        .then(() => this._getProjectConfig());
-    allDataPromises.push(projectConfigLoaded);
-
-    // Resolves when change comments have loaded (comments, drafts and robot
-    // comments).
-    const commentsLoaded = this._reloadComments();
-    allDataPromises.push(commentsLoaded);
-
-    let coreDataPromise;
-
-    // If the patch number is specified
-    if (this._patchRange && this._patchRange.patchNum) {
-      // Because a specific patchset is specified, reload the resources that
-      // are keyed by patch number or patch range.
-      const patchResourcesLoaded = this._reloadPatchNumDependentResources();
-      allDataPromises.push(patchResourcesLoaded);
-
-      // Promise resolves when the change detail and patch dependent resources
-      // have loaded.
-      const detailAndPatchResourcesLoaded =
-          Promise.all([patchResourcesLoaded, loadingFlagSet]);
-
-      // Promise resolves when mergeability information has loaded.
-      const mergeabilityLoaded = detailAndPatchResourcesLoaded
-          .then(() => this._getMergeability());
-      allDataPromises.push(mergeabilityLoaded);
-
-      // Promise resovles when the change actions have loaded.
-      const actionsLoaded = detailAndPatchResourcesLoaded
-          .then(() => this.$.actions.reload());
-      allDataPromises.push(actionsLoaded);
-
-      // The core data is loaded when both mergeability and actions are known.
-      coreDataPromise = Promise.all([mergeabilityLoaded, actionsLoaded]);
-    } else {
-      // Resolves when the file list has loaded.
-      const fileListReload = loadingFlagSet
-          .then(() => this.$.fileList.reload());
-      allDataPromises.push(fileListReload);
-
-      const latestCommitMessageLoaded = loadingFlagSet.then(() => {
-        // If the latest commit message is known, there is nothing to do.
-        if (this._latestCommitMessage) { return Promise.resolve(); }
-        return this._getLatestCommitMessage();
-      });
-      allDataPromises.push(latestCommitMessageLoaded);
-
-      // Promise resolves when mergeability information has loaded.
-      const mergeabilityLoaded = loadingFlagSet
-          .then(() => this._getMergeability());
-      allDataPromises.push(mergeabilityLoaded);
-
-      // Core data is loaded when mergeability has been loaded.
-      coreDataPromise = mergeabilityLoaded;
-    }
-
-    if (opt_isLocationChange) {
-      this._editingCommitMessage = false;
-      const relatedChangesLoaded = coreDataPromise
-          .then(() => this.$.relatedChanges.reload());
-      allDataPromises.push(relatedChangesLoaded);
-    }
-
-    Promise.all(allDataPromises).then(() => {
-      this.reporting.timeEnd(CHANGE_DATA_TIMING_LABEL);
-      if (opt_isLocationChange) {
-        this.reporting.changeFullyLoaded();
-      }
-    });
-
-    return coreDataPromise;
-  }
-
-  /**
-   * Kicks off requests for resources that rely on the patch range
-   * (`this._patchRange`) being defined.
-   */
-  _reloadPatchNumDependentResources() {
-    return Promise.all([
-      this._getCommitInfo(),
-      this.$.fileList.reload(),
-    ]);
-  }
-
-  _getMergeability() {
-    if (!this._change) {
-      this._mergeable = null;
-      return Promise.resolve();
-    }
-    // If the change is closed, it is not mergeable. Note: already merged
-    // changes are obviously not mergeable, but the mergeability API will not
-    // answer for abandoned changes.
-    if (this._change.status === ChangeStatus.MERGED ||
-        this._change.status === ChangeStatus.ABANDONED) {
-      this._mergeable = false;
-      return Promise.resolve();
-    }
-
-    this._mergeable = null;
-    return this.$.restAPI.getMergeable(this._changeNum).then(m => {
-      this._mergeable = m.mergeable;
-    });
-  }
-
-  _computeCanStartReview(change) {
-    return !!(change.actions && change.actions.ready &&
-      change.actions.ready.enabled);
-  }
-
-  _computeReplyDisabled() { return false; }
-
-  _computeChangePermalinkAriaLabel(changeNum) {
-    return 'Change ' + changeNum;
-  }
-
-  _computeCommitMessageCollapsed(collapsed, collapsible) {
-    return collapsible && collapsed;
-  }
-
-  _computeRelatedChangesClass(collapsed) {
-    return collapsed ? 'collapsed' : '';
-  }
-
-  _computeCollapseText(collapsed) {
-    // Symbols are up and down triangles.
-    return collapsed ? '\u25bc Show more' : '\u25b2 Show less';
-  }
-
-  /**
-   * Returns the text to be copied when
-   * click the copy icon next to change subject
-   *
-   * @param {!Object} change
-   */
-  _computeCopyTextForTitle(change) {
-    return `${change._number}: ${change.subject} | ` +
-     `${location.protocol}//${location.host}` +
-       `${this._computeChangeUrl(change)}`;
-  }
-
-  _toggleCommitCollapsed() {
-    this._commitCollapsed = !this._commitCollapsed;
-    if (this._commitCollapsed) {
-      window.scrollTo(0, 0);
-    }
-  }
-
-  _toggleRelatedChangesCollapsed() {
-    this._relatedChangesCollapsed = !this._relatedChangesCollapsed;
-    if (this._relatedChangesCollapsed) {
-      window.scrollTo(0, 0);
-    }
-  }
-
-  _computeCommitCollapsible(commitMessage) {
-    if (!commitMessage) { return false; }
-    return commitMessage.split('\n').length >= MIN_LINES_FOR_COMMIT_COLLAPSE;
-  }
-
-  _getOffsetHeight(element) {
-    return element.offsetHeight;
-  }
-
-  _getScrollHeight(element) {
-    return element.scrollHeight;
-  }
-
-  /**
-   * Get the line height of an element to the nearest integer.
-   */
-  _getLineHeight(element) {
-    const lineHeightStr = getComputedStyle(element).lineHeight;
-    return Math.round(lineHeightStr.slice(0, lineHeightStr.length - 2));
-  }
-
-  /**
-   * New max height for the related changes section, shorter than the existing
-   * change info height.
-   */
-  _updateRelatedChangeMaxHeight() {
-    // Takes into account approximate height for the expand button and
-    // bottom margin.
-    const EXTRA_HEIGHT = 30;
-    let newHeight;
-
-    if (window.matchMedia(`(max-width: ${BREAKPOINT_RELATED_SMALL})`)
-        .matches) {
-      // In a small (mobile) view, give the relation chain some space.
-      newHeight = SMALL_RELATED_HEIGHT;
-    } else if (window.matchMedia(`(max-width: ${BREAKPOINT_RELATED_MED})`)
-        .matches) {
-      // Since related changes are below the commit message, but still next to
-      // metadata, the height should be the height of the metadata minus the
-      // height of the commit message to reduce jank. However, if that doesn't
-      // result in enough space, instead use the MINIMUM_RELATED_MAX_HEIGHT.
-      // Note: extraHeight is to take into account margin/padding.
-      const medRelatedHeight = Math.max(
-          this._getOffsetHeight(this.$.mainChangeInfo) -
-          this._getOffsetHeight(this.$.commitMessage) - 2 * EXTRA_HEIGHT,
-          MINIMUM_RELATED_MAX_HEIGHT);
-      newHeight = medRelatedHeight;
-    } else {
-      if (this._commitCollapsible) {
-        // Make sure the content is lined up if both areas have buttons. If
-        // the commit message is not collapsed, instead use the change info
-        // height.
-        newHeight = this._getOffsetHeight(this.$.commitMessage);
-      } else {
-        newHeight = this._getOffsetHeight(this.$.commitAndRelated) -
-            EXTRA_HEIGHT;
-      }
-    }
-    const stylesToUpdate = {};
-
-    // Get the line height of related changes, and convert it to the nearest
-    // integer.
-    const lineHeight = this._getLineHeight(this.$.relatedChanges);
-
-    // Figure out a new height that is divisible by the rounded line height.
-    const remainder = newHeight % lineHeight;
-    newHeight = newHeight - remainder;
-
-    stylesToUpdate['--relation-chain-max-height'] = newHeight + 'px';
-
-    // Update the max-height of the relation chain to this new height.
-    if (this._commitCollapsible) {
-      stylesToUpdate['--related-change-btn-top-padding'] = remainder + 'px';
-    }
-
-    this.updateStyles(stylesToUpdate);
-  }
-
-  _computeShowRelatedToggle() {
-    // Make sure the max height has been applied, since there is now content
-    // to populate.
-    if (!getComputedStyleValue('--relation-chain-max-height', this)) {
-      this._updateRelatedChangeMaxHeight();
-    }
-    // Prevents showMore from showing when click on related change, since the
-    // line height would be positive, but related changes height is 0.
-    if (!this._getScrollHeight(this.$.relatedChanges)) {
-      return this._showRelatedToggle = false;
-    }
-
-    if (this._getScrollHeight(this.$.relatedChanges) >
-        (this._getOffsetHeight(this.$.relatedChanges) +
-        this._getLineHeight(this.$.relatedChanges))) {
-      return this._showRelatedToggle = true;
-    }
-    this._showRelatedToggle = false;
-  }
-
-  _updateToggleContainerClass(showRelatedToggle) {
-    if (showRelatedToggle) {
-      this.$.relatedChangesToggle.classList.add('showToggle');
-    } else {
-      this.$.relatedChangesToggle.classList.remove('showToggle');
-    }
-  }
-
-  _startUpdateCheckTimer() {
-    if (!this._serverConfig ||
-        !this._serverConfig.change ||
-        this._serverConfig.change.update_delay === undefined ||
-        this._serverConfig.change.update_delay <= MIN_CHECK_INTERVAL_SECS) {
-      return;
-    }
-
-    this._updateCheckTimerHandle = this.async(() => {
-      const change = this._change;
-      fetchChangeUpdates(change, this.$.restAPI).then(result => {
-        let toastMessage = null;
-        if (!result.isLatest) {
-          toastMessage = ReloadToastMessage.NEWER_REVISION;
-        } else if (result.newStatus === ChangeStatus.MERGED) {
-          toastMessage = ReloadToastMessage.MERGED;
-        } else if (result.newStatus === ChangeStatus.ABANDONED) {
-          toastMessage = ReloadToastMessage.ABANDONED;
-        } else if (result.newStatus === ChangeStatus.NEW) {
-          toastMessage = ReloadToastMessage.RESTORED;
-        } else if (result.newMessages) {
-          toastMessage = ReloadToastMessage.NEW_MESSAGE;
-        }
-
-        // We have to make sure that the update is still relevant for the user.
-        // Since starting to fetch the change update the user may have sent a
-        // reply, or the change might have been reloaded, or it could be in the
-        // process of being reloaded.
-        const changeWasReloaded = change !== this._change;
-        if (!toastMessage || this._loading || changeWasReloaded) {
-          this._startUpdateCheckTimer();
-          return;
-        }
-
-        this._cancelUpdateCheckTimer();
-        this.dispatchEvent(new CustomEvent('show-alert', {
-          detail: {
-            message: toastMessage,
-            // Persist this alert.
-            dismissOnNavigation: true,
-            action: 'Reload',
-            callback: () => {
-              this._reload(/* opt_isLocationChange= */false,
-                  /* opt_clearPatchset= */true);
-            },
-          },
-          composed: true, bubbles: true,
-        }));
-      });
-    }, this._serverConfig.change.update_delay * 1000);
-  }
-
-  _cancelUpdateCheckTimer() {
-    if (this._updateCheckTimerHandle) {
-      this.cancelAsync(this._updateCheckTimerHandle);
-    }
-    this._updateCheckTimerHandle = null;
-  }
-
-  _handleVisibilityChange() {
-    if (document.hidden && this._updateCheckTimerHandle) {
-      this._cancelUpdateCheckTimer();
-    } else if (!this._updateCheckTimerHandle) {
-      this._startUpdateCheckTimer();
-    }
-  }
-
-  _handleTopicChanged() {
-    this.$.relatedChanges.reload();
-  }
-
-  _computeHeaderClass(editMode) {
-    const classes = ['header'];
-    if (editMode) { classes.push('editMode'); }
-    return classes.join(' ');
-  }
-
-  _computeEditMode(patchRangeRecord, paramsRecord) {
-    if ([patchRangeRecord, paramsRecord].includes(undefined)) {
-      return undefined;
-    }
-
-    if (paramsRecord.base && paramsRecord.base.edit) { return true; }
-
-    const patchRange = patchRangeRecord.base || {};
-    return patchNumEquals(patchRange.patchNum, SPECIAL_PATCH_SET_NUM.EDIT);
-  }
-
-  _handleFileActionTap(e) {
-    e.preventDefault();
-    const controls = this.$.fileListHeader
-        .shadowRoot.querySelector('#editControls');
-    const path = e.detail.path;
-    switch (e.detail.action) {
-      case GrEditConstants.Actions.DELETE.id:
-        controls.openDeleteDialog(path);
-        break;
-      case GrEditConstants.Actions.OPEN.id:
-        GerritNav.navigateToRelativeUrl(
-            GerritNav.getEditUrlForDiff(this._change, path,
-                this._patchRange.patchNum));
-        break;
-      case GrEditConstants.Actions.RENAME.id:
-        controls.openRenameDialog(path);
-        break;
-      case GrEditConstants.Actions.RESTORE.id:
-        controls.openRestoreDialog(path);
-        break;
-    }
-  }
-
-  _computeCommitMessageKey(number, revision) {
-    return `c${number}_rev${revision}`;
-  }
-
-  _patchNumChanged(patchNumStr) {
-    if (!this._selectedRevision) {
-      return;
-    }
-
-    let patchNum = parseInt(patchNumStr, 10);
-    if (patchNumStr === 'edit') {
-      patchNum = patchNumStr;
-    }
-
-    if (patchNum === this._selectedRevision._number) {
-      return;
-    }
-    this._selectedRevision = Object.values(this._change.revisions).find(
-        revision => revision._number === patchNum);
-  }
-
-  /**
-   * If an edit exists already, load it. Otherwise, toggle edit mode via the
-   * navigation API.
-   */
-  _handleEditTap() {
-    const editInfo = Object.values(this._change.revisions).find(info =>
-      info._number === SPECIAL_PATCH_SET_NUM.EDIT);
-
-    if (editInfo) {
-      GerritNav.navigateToChange(this._change, SPECIAL_PATCH_SET_NUM.EDIT);
-      return;
-    }
-
-    // Avoid putting patch set in the URL unless a non-latest patch set is
-    // selected.
-    let patchNum;
-    if (!patchNumEquals(this._patchRange.patchNum,
-        computeLatestPatchNum(this._allPatchSets))) {
-      patchNum = this._patchRange.patchNum;
-    }
-    GerritNav.navigateToChange(this._change, patchNum, null, true);
-  }
-
-  _handleStopEditTap() {
-    GerritNav.navigateToChange(this._change, this._patchRange.patchNum);
-  }
-
-  _resetReplyOverlayFocusStops() {
-    this.$.replyOverlay.setFocusStops(this.$.replyDialog.getFocusStops());
-  }
-
-  _handleToggleStar(e) {
-    this.$.restAPI.saveChangeStarred(e.detail.change._number,
-        e.detail.starred);
-  }
-
-  _getRevisionInfo(change) {
-    return new RevisionInfo(change);
-  }
-
-  _computeCurrentRevision(currentRevision, revisions) {
-    return currentRevision && revisions && revisions[currentRevision];
-  }
-
-  _computeDiffPrefsDisabled(disableDiffPrefs, loggedIn) {
-    return disableDiffPrefs || !loggedIn;
-  }
-
-  /**
-   * Wrapper for using in the element template and computed properties
-   */
-  _computeLatestPatchNum(allPatchSets) {
-    return computeLatestPatchNum(allPatchSets);
-  }
-
-  /**
-   * Wrapper for using in the element template and computed properties
-   */
-  _hasEditBasedOnCurrentPatchSet(allPatchSets) {
-    return hasEditBasedOnCurrentPatchSet(allPatchSets);
-  }
-
-  /**
-   * Wrapper for using in the element template and computed properties
-   */
-  _hasEditPatchsetLoaded(patchRangeRecord) {
-    return hasEditPatchsetLoaded(patchRangeRecord);
-  }
-
-  /**
-   * Wrapper for using in the element template and computed properties
-   */
-  _computeAllPatchSets(change) {
-    return computeAllPatchSets(change);
-  }
-}
-
-customElements.define(GrChangeView.is, GrChangeView);
diff --git a/polygerrit-ui/app/elements/change/gr-change-view/gr-change-view.ts b/polygerrit-ui/app/elements/change/gr-change-view/gr-change-view.ts
new file mode 100644
index 0000000..68fb622
--- /dev/null
+++ b/polygerrit-ui/app/elements/change/gr-change-view/gr-change-view.ts
@@ -0,0 +1,2788 @@
+/**
+ * @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 '@polymer/paper-tabs/paper-tabs';
+import '../../../styles/shared-styles';
+import '../../diff/gr-comment-api/gr-comment-api';
+import '../../plugins/gr-endpoint-decorator/gr-endpoint-decorator';
+import '../../plugins/gr-endpoint-param/gr-endpoint-param';
+import '../../shared/gr-account-link/gr-account-link';
+import '../../shared/gr-button/gr-button';
+import '../../shared/gr-change-star/gr-change-star';
+import '../../shared/gr-change-status/gr-change-status';
+import '../../shared/gr-date-formatter/gr-date-formatter';
+import '../../shared/gr-editable-content/gr-editable-content';
+import '../../shared/gr-js-api-interface/gr-js-api-interface';
+import '../../shared/gr-linked-text/gr-linked-text';
+import '../../shared/gr-overlay/gr-overlay';
+import '../../shared/gr-rest-api-interface/gr-rest-api-interface';
+import '../../shared/gr-tooltip-content/gr-tooltip-content';
+import '../gr-change-actions/gr-change-actions';
+import '../gr-change-metadata/gr-change-metadata';
+import '../../shared/gr-icons/gr-icons';
+import '../gr-commit-info/gr-commit-info';
+import '../gr-download-dialog/gr-download-dialog';
+import '../gr-file-list-header/gr-file-list-header';
+import '../gr-included-in-dialog/gr-included-in-dialog';
+import '../gr-messages-list/gr-messages-list';
+import '../gr-related-changes-list/gr-related-changes-list';
+import '../../diff/gr-apply-fix-dialog/gr-apply-fix-dialog';
+import '../gr-reply-dialog/gr-reply-dialog';
+import '../gr-thread-list/gr-thread-list';
+import '../gr-upload-help-dialog/gr-upload-help-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-change-view_html';
+import {
+  KeyboardShortcutMixin,
+  Shortcut,
+  CustomKeyboardEvent,
+} from '../../../mixins/keyboard-shortcut-mixin/keyboard-shortcut-mixin';
+import {GrEditConstants} from '../../edit/gr-edit-constants';
+import {GrCountStringFormatter} from '../../shared/gr-count-string-formatter/gr-count-string-formatter';
+import {getComputedStyleValue} from '../../../utils/dom-util';
+import {GerritNav, GerritView} 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 {RevisionInfo as RevisionInfoClass} from '../../shared/revision-info/revision-info';
+import {PrimaryTab, SecondaryTab} from '../../../constants/constants';
+import {NO_ROBOT_COMMENTS_THREADS_MSG} from '../../../constants/messages';
+import {appContext} from '../../../services/app-context';
+import {ChangeStatus} from '../../../constants/constants';
+import {
+  computeAllPatchSets,
+  computeLatestPatchNum,
+  fetchChangeUpdates,
+  hasEditBasedOnCurrentPatchSet,
+  hasEditPatchsetLoaded,
+  patchNumEquals,
+  PatchSet,
+} from '../../../utils/patch-set-util';
+import {changeStatuses, changeStatusString} from '../../../utils/change-util';
+import {EventType} from '../../plugins/gr-plugin-types';
+import {customElement, property, observe} from '@polymer/decorators';
+import {RestApiService} from '../../../services/services/gr-rest-api/gr-rest-api';
+import {GrJsApiInterface} from '../../shared/gr-js-api-interface/gr-js-api-interface-element';
+import {GrApplyFixDialog} from '../../diff/gr-apply-fix-dialog/gr-apply-fix-dialog';
+import {GrFileListHeader} from '../gr-file-list-header/gr-file-list-header';
+import {GrEditableContent} from '../../shared/gr-editable-content/gr-editable-content';
+import {GrOverlay} from '../../shared/gr-overlay/gr-overlay';
+import {GrRelatedChangesList} from '../gr-related-changes-list/gr-related-changes-list';
+import {GrChangeStar} from '../../shared/gr-change-star/gr-change-star';
+import {GrChangeActions} from '../gr-change-actions/gr-change-actions';
+import {
+  AccountDetailInfo,
+  ChangeInfo,
+  NumericChangeId,
+  PatchRange,
+  ActionNameToActionInfoMap,
+  CommitId,
+  PatchSetNum,
+  ParentPatchSetNum,
+  EditPatchSetNum,
+  ServerInfo,
+  ConfigInfo,
+  PreferencesInfo,
+  CommitInfo,
+  DiffPreferencesInfo,
+  RevisionInfo,
+  EditInfo,
+  LabelNameToInfoMap,
+  UrlEncodedCommentId,
+  QuickLabelInfo,
+  ApprovalInfo,
+  ElementPropertyDeepChange,
+} from '../../../types/common';
+import {GrReplyDialog, FocusTarget} from '../gr-reply-dialog/gr-reply-dialog';
+import {GrIncludedInDialog} from '../gr-included-in-dialog/gr-included-in-dialog';
+import {CommentEventDetail} from '../../shared/gr-comment/gr-comment';
+import {GrDownloadDialog} from '../gr-download-dialog/gr-download-dialog';
+import {GrChangeMetadata} from '../gr-change-metadata/gr-change-metadata';
+import {
+  GrCommentApi,
+  ChangeComments,
+} from '../../diff/gr-comment-api/gr-comment-api';
+import {hasOwnProperty} from '../../../utils/common-util';
+import {GrEditControls} from '../../edit/gr-edit-controls/gr-edit-controls';
+import {
+  CommentThread,
+  UIDraft,
+  DraftInfo,
+  isDraftThread,
+  isRobot,
+} from '../../../utils/comment-util';
+import {
+  PolymerDeepPropertyChange,
+  PolymerSpliceChange,
+  PolymerSplice,
+} from '@polymer/polymer/interfaces';
+import {AppElementChangeViewParams} from '../../gr-app-types';
+import {DropdownLink} from '../../shared/gr-dropdown/gr-dropdown';
+import {PaperTabsElement} from '@polymer/paper-tabs/paper-tabs';
+import {ParsedChangeInfo} from '../../shared/gr-rest-api-interface/gr-reviewer-updates-parser';
+import {
+  GrFileList,
+  DEFAULT_NUM_FILES_SHOWN,
+} from '../gr-file-list/gr-file-list';
+import {isPolymerSpliceChange} from '../../../types/types';
+
+const CHANGE_ID_ERROR = {
+  MISMATCH: 'mismatch',
+  MISSING: 'missing',
+};
+const CHANGE_ID_REGEX_PATTERN = /^(Change-Id:\s|Link:.*\/id\/)(I[0-9a-f]{8,40})/gm;
+
+const MIN_LINES_FOR_COMMIT_COLLAPSE = 30;
+
+const REVIEWERS_REGEX = /^(R|CC)=/gm;
+const MIN_CHECK_INTERVAL_SECS = 0;
+
+// These are the same as the breakpoint set in CSS. Make sure both are changed
+// together.
+const BREAKPOINT_RELATED_SMALL = '50em';
+const BREAKPOINT_RELATED_MED = '75em';
+
+// In the event that the related changes medium width calculation is too close
+// to zero, provide some height.
+const MINIMUM_RELATED_MAX_HEIGHT = 100;
+
+const SMALL_RELATED_HEIGHT = 400;
+
+const REPLY_REFIT_DEBOUNCE_INTERVAL_MS = 500;
+
+const TRAILING_WHITESPACE_REGEX = /[ \t]+$/gm;
+
+const MSG_PREFIX = '#message-';
+
+const ReloadToastMessage = {
+  NEWER_REVISION: 'A newer patch set has been uploaded',
+  RESTORED: 'This change has been restored',
+  ABANDONED: 'This change has been abandoned',
+  MERGED: 'This change has been merged',
+  NEW_MESSAGE: 'There are new messages on this change',
+};
+
+enum DiffViewMode {
+  SIDE_BY_SIDE = 'SIDE_BY_SIDE',
+  UNIFIED = 'UNIFIED_DIFF',
+}
+
+const CHANGE_DATA_TIMING_LABEL = 'ChangeDataLoaded';
+const CHANGE_RELOAD_TIMING_LABEL = 'ChangeReloaded';
+const SEND_REPLY_TIMING_LABEL = 'SendReply';
+// Making the tab names more unique in case a plugin adds one with same name
+const ROBOT_COMMENTS_LIMIT = 10;
+
+// Type for the custom event to switch tab.
+interface SwitchTabEventDetail {
+  // name of the tab to set as active, from custom event
+  tab?: string;
+  // index of tab to set as active, from paper-tabs event
+  value?: number;
+  // scroll into the tab afterwards, from custom event
+  scrollIntoView?: boolean;
+}
+
+export interface ChangeViewState {
+  diffMode?: DiffViewMode;
+  scrollTop?: number;
+  showDownloadDialog?: boolean;
+  showReplyDialog?: boolean;
+  changeNum?: NumericChangeId;
+  numFilesShown?: number;
+  patchRange?: PatchRange;
+  diffViewMode?: boolean;
+}
+
+export interface GrChangeView {
+  $: {
+    restAPI: RestApiService & Element;
+    jsAPI: GrJsApiInterface;
+    commentAPI: GrCommentApi;
+    applyFixDialog: GrApplyFixDialog;
+    fileList: GrFileList & Element;
+    fileListHeader: GrFileListHeader;
+    commitMessageEditor: GrEditableContent;
+    includedInOverlay: GrOverlay;
+    includedInDialog: GrIncludedInDialog;
+    downloadOverlay: GrOverlay;
+    downloadDialog: GrDownloadDialog;
+    uploadHelpOverlay: GrOverlay;
+    replyOverlay: GrOverlay;
+    replyDialog: GrReplyDialog;
+    mainContent: HTMLDivElement;
+    relatedChanges: GrRelatedChangesList;
+    changeStar: GrChangeStar;
+    actions: GrChangeActions;
+    commitMessage: HTMLDivElement;
+    commitAndRelated: HTMLDivElement;
+    metadata: GrChangeMetadata;
+    relatedChangesToggle: HTMLDivElement;
+    mainChangeInfo: HTMLDivElement;
+  };
+}
+@customElement('gr-change-view')
+export class GrChangeView extends KeyboardShortcutMixin(
+  GestureEventListeners(LegacyElementMixin(PolymerElement))
+) {
+  static get template() {
+    return htmlTemplate;
+  }
+
+  /**
+   * Fired when the title of the page should change.
+   *
+   * @event title-change
+   */
+
+  /**
+   * Fired if an error occurs when fetching the change data.
+   *
+   * @event page-error
+   */
+
+  /**
+   * Fired if being logged in is required.
+   *
+   * @event show-auth-required
+   */
+
+  reporting = appContext.reportingService;
+
+  /**
+   * URL params passed from the router.
+   */
+  @property({type: Object, observer: '_paramsChanged'})
+  params?: AppElementChangeViewParams;
+
+  @property({type: Object, notify: true, observer: '_viewStateChanged'})
+  viewState: ChangeViewState = {};
+
+  @property({type: String})
+  backPage?: string;
+
+  @property({type: Boolean})
+  hasParent?: boolean;
+
+  @property({type: Object})
+  keyEventTarget = document.body;
+
+  @property({type: Boolean})
+  disableEdit = false;
+
+  @property({type: Boolean})
+  disableDiffPrefs = false;
+
+  @property({
+    type: Boolean,
+    computed: '_computeDiffPrefsDisabled(disableDiffPrefs, _loggedIn)',
+  })
+  _diffPrefsDisabled?: boolean;
+
+  @property({type: Array})
+  _commentThreads?: CommentThread[];
+
+  // TODO(taoalpha): Consider replacing diffDrafts
+  // with _draftCommentThreads everywhere, currently only
+  // replaced in reply-dialog
+  @property({type: Array})
+  _draftCommentThreads?: CommentThread[];
+
+  @property({
+    type: Array,
+    computed:
+      '_computeRobotCommentThreads(_commentThreads,' +
+      ' _currentRobotCommentsPatchSet, _showAllRobotComments)',
+  })
+  _robotCommentThreads?: CommentThread[];
+
+  @property({type: Object, observer: '_startUpdateCheckTimer'})
+  _serverConfig?: ServerInfo;
+
+  @property({type: Object})
+  _diffPrefs?: DiffPreferencesInfo;
+
+  @property({type: Number, observer: '_numFilesShownChanged'})
+  _numFilesShown = DEFAULT_NUM_FILES_SHOWN;
+
+  @property({type: Object})
+  _account?: AccountDetailInfo;
+
+  @property({type: Object})
+  _prefs?: PreferencesInfo;
+
+  @property({type: Object})
+  _changeComments?: ChangeComments;
+
+  @property({type: Boolean, computed: '_computeCanStartReview(_change)'})
+  _canStartReview?: boolean;
+
+  @property({type: Object, observer: '_changeChanged'})
+  _change?: ChangeInfo | ParsedChangeInfo;
+
+  @property({type: Object, computed: '_getRevisionInfo(_change)'})
+  _revisionInfo?: RevisionInfoClass;
+
+  @property({type: Object})
+  _commitInfo?: CommitInfo;
+
+  @property({
+    type: Object,
+    computed:
+      '_computeCurrentRevision(_change.current_revision, ' +
+      '_change.revisions)',
+    observer: '_handleCurrentRevisionUpdate',
+  })
+  _currentRevision?: RevisionInfo;
+
+  @property({type: String})
+  _changeNum?: NumericChangeId;
+
+  @property({type: Object})
+  _diffDrafts?: {[path: string]: UIDraft[]} = {};
+
+  @property({type: Boolean})
+  _editingCommitMessage = false;
+
+  @property({
+    type: Boolean,
+    computed:
+      '_computeHideEditCommitMessage(_loggedIn, ' +
+      '_editingCommitMessage, _change, _editMode, _commitCollapsed, ' +
+      '_commitCollapsible)',
+  })
+  _hideEditCommitMessage?: boolean;
+
+  @property({type: String})
+  _diffAgainst?: string;
+
+  @property({type: String})
+  _latestCommitMessage: string | null = '';
+
+  @property({type: Object})
+  _constants = {
+    SecondaryTab,
+    PrimaryTab,
+  };
+
+  @property({type: Object})
+  _messages = NO_ROBOT_COMMENTS_THREADS_MSG;
+
+  @property({type: Number})
+  _lineHeight?: number;
+
+  @property({
+    type: String,
+    computed:
+      '_computeChangeIdCommitMessageError(_latestCommitMessage, _change)',
+  })
+  _changeIdCommitMessageError?: string;
+
+  @property({type: Object})
+  _patchRange?: PatchRange;
+
+  @property({type: String})
+  _filesExpanded?: string;
+
+  @property({type: String})
+  _basePatchNum?: string;
+
+  @property({type: Object})
+  _selectedRevision?: RevisionInfo;
+
+  @property({type: Object})
+  _currentRevisionActions?: ActionNameToActionInfoMap;
+
+  @property({
+    type: Array,
+    computed: '_computeAllPatchSets(_change, _change.revisions.*)',
+  })
+  _allPatchSets?: PatchSet[];
+
+  @property({type: Boolean})
+  _loggedIn = false;
+
+  @property({type: Boolean})
+  _loading?: boolean;
+
+  @property({type: Object})
+  _projectConfig?: ConfigInfo;
+
+  @property({
+    type: String,
+    computed: '_computeReplyButtonLabel(_diffDrafts.*, _canStartReview)',
+  })
+  _replyButtonLabel = 'Reply';
+
+  @property({type: String})
+  _selectedPatchSet?: string;
+
+  @property({type: Number})
+  _shownFileCount?: number;
+
+  @property({type: Boolean})
+  _initialLoadComplete = false;
+
+  @property({type: Boolean})
+  _replyDisabled = true;
+
+  @property({type: String, computed: '_changeStatusString(_change)'})
+  _changeStatus?: string;
+
+  @property({
+    type: String,
+    computed: '_computeChangeStatusChips(_change, _mergeable, _submitEnabled)',
+  })
+  _changeStatuses?: string;
+
+  /** If false, then the "Show more" button was used to expand. */
+  @property({type: Boolean})
+  _commitCollapsed = true;
+
+  /** Is the "Show more/less" button visible? */
+  @property({
+    type: Boolean,
+    computed: '_computeCommitCollapsible(_latestCommitMessage)',
+  })
+  _commitCollapsible?: boolean;
+
+  @property({type: Boolean})
+  _relatedChangesCollapsed = true;
+
+  @property({type: Number})
+  _updateCheckTimerHandle?: number | null;
+
+  @property({
+    type: Boolean,
+    computed: '_computeEditMode(_patchRange.*, params.*)',
+  })
+  _editMode?: boolean;
+
+  @property({type: Boolean, observer: '_updateToggleContainerClass'})
+  _showRelatedToggle = false;
+
+  @property({
+    type: Boolean,
+    computed: '_isParentCurrent(_currentRevisionActions)',
+  })
+  _parentIsCurrent?: boolean;
+
+  @property({
+    type: Boolean,
+    computed: '_isSubmitEnabled(_currentRevisionActions)',
+  })
+  _submitEnabled?: boolean;
+
+  @property({type: Boolean})
+  _mergeable: boolean | null = null;
+
+  @property({type: Boolean})
+  _showFileTabContent = true;
+
+  @property({type: Array})
+  _dynamicTabHeaderEndpoints: string[] = [];
+
+  @property({type: Array})
+  _dynamicTabContentEndpoints: string[] = [];
+
+  @property({type: String})
+  // The dynamic content of the plugin added tab
+  _selectedTabPluginEndpoint?: string;
+
+  @property({type: String})
+  // The dynamic heading of the plugin added tab
+  _selectedTabPluginHeader?: string;
+
+  @property({
+    type: Array,
+    computed:
+      '_computeRobotCommentsPatchSetDropdownItems(_change, _commentThreads)',
+  })
+  _robotCommentsPatchSetDropdownItems: DropdownLink[] = [];
+
+  @property({type: Number})
+  _currentRobotCommentsPatchSet?: PatchSetNum;
+
+  /**
+   * this is a two-element tuple to always
+   * hold the current active tab for both primary and secondary tabs
+   */
+  @property({type: Array})
+  _activeTabs: string[] = [PrimaryTab.FILES, SecondaryTab.CHANGE_LOG];
+
+  @property({type: Boolean})
+  _showAllRobotComments = false;
+
+  @property({type: Boolean})
+  _showRobotCommentsButton = false;
+
+  _throttledToggleChangeStar?: EventListener;
+
+  keyboardShortcuts() {
+    return {
+      [Shortcut.SEND_REPLY]: null, // DOC_ONLY binding
+      [Shortcut.EMOJI_DROPDOWN]: null, // DOC_ONLY binding
+      [Shortcut.REFRESH_CHANGE]: '_handleRefreshChange',
+      [Shortcut.OPEN_REPLY_DIALOG]: '_handleOpenReplyDialog',
+      [Shortcut.OPEN_DOWNLOAD_DIALOG]: '_handleOpenDownloadDialogShortcut',
+      [Shortcut.TOGGLE_DIFF_MODE]: '_handleToggleDiffMode',
+      [Shortcut.TOGGLE_CHANGE_STAR]: '_throttledToggleChangeStar',
+      [Shortcut.UP_TO_DASHBOARD]: '_handleUpToDashboard',
+      [Shortcut.EXPAND_ALL_MESSAGES]: '_handleExpandAllMessages',
+      [Shortcut.COLLAPSE_ALL_MESSAGES]: '_handleCollapseAllMessages',
+      [Shortcut.EXPAND_ALL_DIFF_CONTEXT]: '_expandAllDiffs',
+      [Shortcut.OPEN_DIFF_PREFS]: '_handleOpenDiffPrefsShortcut',
+      [Shortcut.EDIT_TOPIC]: '_handleEditTopic',
+      [Shortcut.DIFF_AGAINST_BASE]: '_handleDiffAgainstBase',
+      [Shortcut.DIFF_AGAINST_LATEST]: '_handleDiffAgainstLatest',
+      [Shortcut.DIFF_BASE_AGAINST_LEFT]: '_handleDiffBaseAgainstLeft',
+      [Shortcut.DIFF_RIGHT_AGAINST_LATEST]: '_handleDiffRightAgainstLatest',
+      [Shortcut.DIFF_BASE_AGAINST_LATEST]: '_handleDiffBaseAgainstLatest',
+    };
+  }
+
+  /** @override */
+  connectedCallback() {
+    super.connectedCallback();
+    this._throttledToggleChangeStar = this._throttleWrap(e =>
+      this._handleToggleChangeStar(e as CustomKeyboardEvent)
+    );
+  }
+
+  /** @override */
+  created() {
+    super.created();
+
+    this.addEventListener('topic-changed', () => this._handleTopicChanged());
+
+    this.addEventListener(
+      // When an overlay is opened in a mobile viewport, the overlay has a full
+      // screen view. When it has a full screen view, we do not want the
+      // background to be scrollable. This will eliminate background scroll by
+      // hiding most of the contents on the screen upon opening, and showing
+      // again upon closing.
+      'fullscreen-overlay-opened',
+      () => this._handleHideBackgroundContent()
+    );
+
+    this.addEventListener('fullscreen-overlay-closed', () =>
+      this._handleShowBackgroundContent()
+    );
+
+    this.addEventListener('diff-comments-modified', () =>
+      this._handleReloadCommentThreads()
+    );
+
+    this.addEventListener('open-reply-dialog', () => this._openReplyDialog());
+  }
+
+  /** @override */
+  attached() {
+    super.attached();
+    this._getServerConfig().then(config => {
+      this._serverConfig = config;
+      this._replyDisabled = false;
+    });
+
+    this._getLoggedIn().then(loggedIn => {
+      this._loggedIn = loggedIn;
+      if (loggedIn) {
+        this.$.restAPI.getAccount().then(acct => {
+          this._account = acct;
+        });
+      }
+      this._setDiffViewMode();
+    });
+
+    getPluginLoader()
+      .awaitPluginsLoaded()
+      .then(() => {
+        this._dynamicTabHeaderEndpoints = getPluginEndpoints().getDynamicEndpoints(
+          'change-view-tab-header'
+        );
+        this._dynamicTabContentEndpoints = getPluginEndpoints().getDynamicEndpoints(
+          'change-view-tab-content'
+        );
+        if (
+          this._dynamicTabContentEndpoints.length !==
+          this._dynamicTabHeaderEndpoints.length
+        ) {
+          console.warn('Different number of tab headers and tab content.');
+        }
+      })
+      .then(() => this._initActiveTabs(this.params));
+
+    this.addEventListener('comment-save', e => this._handleCommentSave(e));
+    this.addEventListener('comment-refresh', () => this._reloadDrafts());
+    this.addEventListener('comment-discard', e =>
+      this._handleCommentDiscard(e)
+    );
+    this.addEventListener('change-message-deleted', () => this._reload());
+    this.addEventListener('editable-content-save', e =>
+      this._handleCommitMessageSave(e as CustomEvent<{content: string}>)
+    );
+    this.addEventListener('editable-content-cancel', () =>
+      this._handleCommitMessageCancel()
+    );
+    this.addEventListener('open-fix-preview', e =>
+      this._onOpenFixPreview(e as CustomEvent<CommentEventDetail>)
+    );
+    this.addEventListener('close-fix-preview', () => this._onCloseFixPreview());
+    this.listen(window, 'scroll', '_handleScroll');
+    this.listen(document, 'visibilitychange', '_handleVisibilityChange');
+
+    this.addEventListener('show-primary-tab', e =>
+      this._setActivePrimaryTab(e as CustomEvent<SwitchTabEventDetail>)
+    );
+    this.addEventListener('show-secondary-tab', e =>
+      this._setActiveSecondaryTab(e as CustomEvent<SwitchTabEventDetail>)
+    );
+    this.addEventListener('reload', e => {
+      e.stopPropagation();
+      const evt = e as CustomEvent<{clearPatchset: boolean}>;
+      this._reload(
+        /* isLocationChange= */ false,
+        /* clearPatchset= */ evt.detail && evt.detail.clearPatchset
+      );
+    });
+  }
+
+  /** @override */
+  detached() {
+    super.detached();
+    this.unlisten(window, 'scroll', '_handleScroll');
+    this.unlisten(document, 'visibilitychange', '_handleVisibilityChange');
+
+    if (this._updateCheckTimerHandle) {
+      this._cancelUpdateCheckTimer();
+    }
+  }
+
+  get messagesList() {
+    return this.shadowRoot!.querySelector('gr-messages-list');
+  }
+
+  get threadList() {
+    return this.shadowRoot!.querySelector('gr-thread-list');
+  }
+
+  _changeStatusString(change: ChangeInfo) {
+    return changeStatusString(change);
+  }
+
+  _setDiffViewMode(opt_reset?: boolean) {
+    if (!opt_reset && this.viewState.diffViewMode) {
+      return;
+    }
+
+    return this._getPreferences()
+      .then(prefs => {
+        if (!this.viewState.diffMode && prefs) {
+          this.set('viewState.diffMode', prefs.default_diff_view);
+        }
+      })
+      .then(() => {
+        if (!this.viewState.diffMode) {
+          this.set('viewState.diffMode', 'SIDE_BY_SIDE');
+        }
+      });
+  }
+
+  _onOpenFixPreview(e: CustomEvent<CommentEventDetail>) {
+    this.$.applyFixDialog.open(e);
+  }
+
+  _onCloseFixPreview() {
+    this._reload();
+  }
+
+  _handleToggleDiffMode(e: CustomKeyboardEvent) {
+    if (this.shouldSuppressKeyboardShortcut(e) || this.modifierPressed(e)) {
+      return;
+    }
+
+    e.preventDefault();
+    if (this.viewState.diffMode === DiffViewMode.SIDE_BY_SIDE) {
+      this.$.fileListHeader.setDiffViewMode(DiffViewMode.UNIFIED);
+    } else {
+      this.$.fileListHeader.setDiffViewMode(DiffViewMode.SIDE_BY_SIDE);
+    }
+  }
+
+  _isTabActive(tab: string, activeTabs: string[]) {
+    return activeTabs.includes(tab);
+  }
+
+  /**
+   * Actual implementation of switching a tab
+   *
+   * @param paperTabs - the parent tabs container
+   */
+  _setActiveTab(
+    paperTabs: PaperTabsElement,
+    activeDetails: {
+      activeTabName?: string;
+      activeTabIndex?: number;
+      scrollIntoView?: boolean;
+    }
+  ) {
+    const {activeTabName, activeTabIndex, scrollIntoView} = activeDetails;
+    const tabs = paperTabs.querySelectorAll('paper-tab') as NodeListOf<
+      HTMLElement
+    >;
+    let activeIndex = -1;
+    if (activeTabIndex !== undefined) {
+      activeIndex = activeTabIndex;
+    } else {
+      for (let i = 0; i <= tabs.length; i++) {
+        const tab = tabs[i];
+        if (tab.dataset['name'] === activeTabName) {
+          activeIndex = i;
+          break;
+        }
+      }
+    }
+    if (activeIndex === -1) {
+      console.warn('tab not found with given info', activeDetails);
+      return;
+    }
+    const tabName = tabs[activeIndex].dataset['name'];
+    if (scrollIntoView) {
+      paperTabs.scrollIntoView();
+    }
+    if (paperTabs.selected !== activeIndex) {
+      paperTabs.selected = activeIndex;
+      this.reporting.reportInteraction('show-tab', {tabName});
+    }
+    return tabName;
+  }
+
+  /**
+   * Changes active primary tab.
+   */
+  _setActivePrimaryTab(e: CustomEvent<SwitchTabEventDetail>) {
+    const primaryTabs = this.shadowRoot!.querySelector(
+      '#primaryTabs'
+    ) as PaperTabsElement;
+    const activeTabName = this._setActiveTab(primaryTabs, {
+      activeTabName: e.detail.tab,
+      activeTabIndex: e.detail.value,
+      scrollIntoView: e.detail.scrollIntoView,
+    });
+    if (activeTabName) {
+      this._activeTabs = [activeTabName, this._activeTabs[1]];
+
+      // update plugin endpoint if its a plugin tab
+      const pluginIndex = (this._dynamicTabHeaderEndpoints || []).indexOf(
+        activeTabName
+      );
+      if (pluginIndex !== -1) {
+        this._selectedTabPluginEndpoint = this._dynamicTabContentEndpoints[
+          pluginIndex
+        ];
+        this._selectedTabPluginHeader = this._dynamicTabHeaderEndpoints[
+          pluginIndex
+        ];
+      } else {
+        this._selectedTabPluginEndpoint = '';
+        this._selectedTabPluginHeader = '';
+      }
+    }
+  }
+
+  /**
+   * Changes active secondary tab.
+   */
+  _setActiveSecondaryTab(e: CustomEvent<SwitchTabEventDetail>) {
+    const secondaryTabs = this.shadowRoot!.querySelector(
+      '#secondaryTabs'
+    ) as PaperTabsElement;
+    const activeTabName = this._setActiveTab(secondaryTabs, {
+      activeTabName: e.detail.tab,
+      activeTabIndex: e.detail.value,
+      scrollIntoView: e.detail.scrollIntoView,
+    });
+    if (activeTabName) {
+      this._activeTabs = [this._activeTabs[0], activeTabName];
+    }
+  }
+
+  _handleEditCommitMessage() {
+    this._editingCommitMessage = true;
+    this.$.commitMessageEditor.focusTextarea();
+  }
+
+  _handleCommitMessageSave(e: CustomEvent<{content: string}>) {
+    if (!this._change) throw new Error('missing required change property');
+    if (!this._changeNum)
+      throw new Error('missing required changeNum property');
+    // Trim trailing whitespace from each line.
+    const message = e.detail.content.replace(TRAILING_WHITESPACE_REGEX, '');
+
+    this.$.jsAPI.handleCommitMessage(this._change, message);
+
+    this.$.commitMessageEditor.disabled = true;
+    this.$.restAPI
+      .putChangeCommitMessage(this._changeNum, message)
+      .then(resp => {
+        this.$.commitMessageEditor.disabled = false;
+        if (!resp.ok) {
+          return;
+        }
+
+        this._latestCommitMessage = this._prepareCommitMsgForLinkify(message);
+        this._editingCommitMessage = false;
+        this._reloadWindow();
+      })
+      .catch(() => {
+        this.$.commitMessageEditor.disabled = false;
+      });
+  }
+
+  _reloadWindow() {
+    window.location.reload();
+  }
+
+  _handleCommitMessageCancel() {
+    this._editingCommitMessage = false;
+  }
+
+  _computeChangeStatusChips(
+    change: ChangeInfo | undefined,
+    mergeable: boolean | null,
+    submitEnabled?: boolean
+  ) {
+    if (!change) {
+      return undefined;
+    }
+
+    // Show no chips until mergeability is loaded.
+    if (mergeable === null) {
+      return [];
+    }
+
+    const options = {
+      includeDerived: true,
+      mergeable: !!mergeable,
+      submitEnabled: !!submitEnabled,
+    };
+    return changeStatuses(change, options);
+  }
+
+  _computeHideEditCommitMessage(
+    loggedIn: boolean,
+    editing: boolean,
+    change: ChangeInfo,
+    editMode: boolean,
+    collapsed: boolean,
+    collapsible: boolean
+  ) {
+    if (
+      !loggedIn ||
+      editing ||
+      (change && change.status === ChangeStatus.MERGED) ||
+      editMode ||
+      (collapsed && collapsible)
+    ) {
+      return true;
+    }
+
+    return false;
+  }
+
+  _robotCommentCountPerPatchSet(threads: CommentThread[]) {
+    return threads.reduce((robotCommentCountMap, thread) => {
+      const comments = thread.comments;
+      const robotCommentsCount = comments.reduce(
+        (acc, comment) => (isRobot(comment) ? acc + 1 : acc),
+        0
+      );
+      if (comments[0].patch_set)
+        robotCommentCountMap[`${comments[0].patch_set}`] =
+          (robotCommentCountMap[`${comments[0].patch_set}`] || 0) +
+          robotCommentsCount;
+      return robotCommentCountMap;
+    }, {} as {[patchset: string]: number});
+  }
+
+  _computeText(patch: RevisionInfo, commentThreads: CommentThread[]) {
+    const commentCount = this._robotCommentCountPerPatchSet(commentThreads);
+    const commentCnt = commentCount[patch._number] || 0;
+    if (commentCnt === 0) return `Patchset ${patch._number}`;
+    const findingsText = commentCnt === 1 ? 'finding' : 'findings';
+    return `Patchset ${patch._number} (${commentCnt} ${findingsText})`;
+  }
+
+  _computeRobotCommentsPatchSetDropdownItems(
+    change: ChangeInfo,
+    commentThreads: CommentThread[]
+  ) {
+    if (!change || !commentThreads || !change.revisions) return [];
+
+    return Object.values(change.revisions)
+      .filter(patch => patch._number !== 'edit')
+      .map(patch => {
+        return {
+          text: this._computeText(patch, commentThreads),
+          value: patch._number,
+        };
+      })
+      .sort((a, b) => (b.value as number) - (a.value as number));
+  }
+
+  _handleCurrentRevisionUpdate(currentRevision: RevisionInfo) {
+    this._currentRobotCommentsPatchSet = currentRevision._number;
+  }
+
+  _handleRobotCommentPatchSetChanged(e: CustomEvent<{value: string}>) {
+    const patchSet = parseInt(e.detail.value) as PatchSetNum;
+    if (patchSet === this._currentRobotCommentsPatchSet) return;
+    this._currentRobotCommentsPatchSet = patchSet;
+  }
+
+  _computeShowText(showAllRobotComments: boolean) {
+    return showAllRobotComments ? 'Show Less' : 'Show more';
+  }
+
+  _toggleShowRobotComments() {
+    this._showAllRobotComments = !this._showAllRobotComments;
+  }
+
+  _computeRobotCommentThreads(
+    commentThreads: CommentThread[],
+    currentRobotCommentsPatchSet: PatchSetNum,
+    showAllRobotComments: boolean
+  ) {
+    if (!commentThreads || !currentRobotCommentsPatchSet) return [];
+    const threads = commentThreads.filter(thread => {
+      const comments = thread.comments || [];
+      return (
+        comments.length &&
+        isRobot(comments[0]) &&
+        comments[0].patch_set === currentRobotCommentsPatchSet
+      );
+    });
+    this._showRobotCommentsButton = threads.length > ROBOT_COMMENTS_LIMIT;
+    return threads.slice(
+      0,
+      showAllRobotComments ? undefined : ROBOT_COMMENTS_LIMIT
+    );
+  }
+
+  _handleReloadCommentThreads() {
+    // Get any new drafts that have been saved in the diff view and show
+    // in the comment thread view.
+    this._reloadDrafts().then(() => {
+      this._commentThreads = this._changeComments?.getAllThreadsForChange();
+      flush();
+    });
+  }
+
+  _handleReloadDiffComments(
+    e: CustomEvent<{rootId: UrlEncodedCommentId; path: string}>
+  ) {
+    // Keeps the file list counts updated.
+    this._reloadDrafts().then(() => {
+      // Get any new drafts that have been saved in the thread view and show
+      // in the diff view.
+      this.$.fileList.reloadCommentsForThreadWithRootId(
+        e.detail.rootId,
+        e.detail.path
+      );
+      flush();
+    });
+  }
+
+  _computeTotalCommentCounts(
+    unresolvedCount: number,
+    changeComments: ChangeComments
+  ) {
+    if (!changeComments) return undefined;
+    const draftCount = changeComments.computeDraftCount();
+    const unresolvedString = GrCountStringFormatter.computeString(
+      unresolvedCount,
+      'unresolved'
+    );
+    const draftString = GrCountStringFormatter.computePluralString(
+      draftCount,
+      'draft'
+    );
+
+    return (
+      unresolvedString +
+      // Add a comma and space if both unresolved and draft comments exist.
+      (unresolvedString && draftString ? ', ' : '') +
+      draftString
+    );
+  }
+
+  _handleCommentSave(e: CustomEvent<{comment: DraftInfo}>) {
+    const draft = e.detail.comment;
+    if (!draft.__draft || !draft.path) return;
+    if (!this._patchRange)
+      throw new Error('missing required _patchRange property');
+
+    draft.patch_set = draft.patch_set || this._patchRange.patchNum;
+
+    // The use of path-based notification helpers (set, push) can’t be used
+    // because the paths could contain dots in them. A new object must be
+    // created to satisfy Polymer’s dirty checking.
+    // https://github.com/Polymer/polymer/issues/3127
+    const diffDrafts = {...this._diffDrafts};
+    if (!diffDrafts[draft.path]) {
+      diffDrafts[draft.path] = [draft];
+      this._diffDrafts = diffDrafts;
+      return;
+    }
+    for (let i = 0; i < diffDrafts[draft.path].length; i++) {
+      if (diffDrafts[draft.path][i].id === draft.id) {
+        diffDrafts[draft.path][i] = draft;
+        this._diffDrafts = diffDrafts;
+        return;
+      }
+    }
+    diffDrafts[draft.path].push(draft);
+    diffDrafts[draft.path].sort(
+      (c1, c2) =>
+        // No line number means that it’s a file comment. Sort it above the
+        // others.
+        (c1.line || -1) - (c2.line || -1)
+    );
+    this._diffDrafts = diffDrafts;
+  }
+
+  _handleCommentDiscard(e: CustomEvent<{comment: DraftInfo}>) {
+    const draft = e.detail.comment;
+    if (!draft.__draft || !draft.path) {
+      return;
+    }
+
+    if (!this._diffDrafts || !this._diffDrafts[draft.path]) {
+      return;
+    }
+    let index = -1;
+    for (let i = 0; i < this._diffDrafts[draft.path].length; i++) {
+      if (this._diffDrafts[draft.path][i].id === draft.id) {
+        index = i;
+        break;
+      }
+    }
+    if (index === -1) {
+      // It may be a draft that hasn’t been added to _diffDrafts since it was
+      // never saved.
+      return;
+    }
+
+    if (!this._patchRange)
+      throw new Error('missing required _patchRange property');
+    draft.patch_set = draft.patch_set || this._patchRange.patchNum;
+
+    // The use of path-based notification helpers (set, push) can’t be used
+    // because the paths could contain dots in them. A new object must be
+    // created to satisfy Polymer’s dirty checking.
+    // https://github.com/Polymer/polymer/issues/3127
+    const diffDrafts = {...this._diffDrafts};
+    diffDrafts[draft.path].splice(index, 1);
+    if (diffDrafts[draft.path].length === 0) {
+      delete diffDrafts[draft.path];
+    }
+    this._diffDrafts = diffDrafts;
+  }
+
+  _handleReplyTap(e: MouseEvent) {
+    e.preventDefault();
+    this._openReplyDialog(this.$.replyDialog.FocusTarget.ANY);
+  }
+
+  _handleOpenDiffPrefs() {
+    this.$.fileList.openDiffPrefs();
+  }
+
+  _handleOpenIncludedInDialog() {
+    this.$.includedInDialog.loadData().then(() => {
+      flush();
+      this.$.includedInOverlay.refit();
+    });
+    this.$.includedInOverlay.open();
+  }
+
+  _handleIncludedInDialogClose() {
+    this.$.includedInOverlay.close();
+  }
+
+  _handleOpenDownloadDialog() {
+    this.$.downloadOverlay.open().then(() => {
+      this.$.downloadOverlay.setFocusStops(
+        this.$.downloadDialog.getFocusStops()
+      );
+      this.$.downloadDialog.focus();
+    });
+  }
+
+  _handleDownloadDialogClose() {
+    this.$.downloadOverlay.close();
+  }
+
+  _handleOpenUploadHelpDialog() {
+    this.$.uploadHelpOverlay.open();
+  }
+
+  _handleCloseUploadHelpDialog() {
+    this.$.uploadHelpOverlay.close();
+  }
+
+  _handleMessageReply(e: CustomEvent<{message: {message: string}}>) {
+    const msg: string = e.detail.message.message;
+    const quoteStr =
+      msg
+        .split('\n')
+        .map(line => '> ' + line)
+        .join('\n') + '\n\n';
+    this.$.replyDialog.quote = quoteStr;
+    this._openReplyDialog(this.$.replyDialog.FocusTarget.BODY);
+  }
+
+  _handleHideBackgroundContent() {
+    this.$.mainContent.classList.add('overlayOpen');
+  }
+
+  _handleShowBackgroundContent() {
+    this.$.mainContent.classList.remove('overlayOpen');
+  }
+
+  _handleReplySent() {
+    this.addEventListener(
+      'change-details-loaded',
+      () => {
+        this.reporting.timeEnd(SEND_REPLY_TIMING_LABEL);
+      },
+      {once: true}
+    );
+    this.$.replyOverlay.close();
+    this._reload();
+  }
+
+  _handleReplyCancel() {
+    this.$.replyOverlay.close();
+  }
+
+  _handleReplyAutogrow() {
+    // If the textarea resizes, we need to re-fit the overlay.
+    this.debounce(
+      'reply-overlay-refit',
+      () => {
+        this.$.replyOverlay.refit();
+      },
+      REPLY_REFIT_DEBOUNCE_INTERVAL_MS
+    );
+  }
+
+  _handleShowReplyDialog(e: CustomEvent<{value: {ccsOnly: boolean}}>) {
+    let target = this.$.replyDialog.FocusTarget.REVIEWERS;
+    if (e.detail.value && e.detail.value.ccsOnly) {
+      target = this.$.replyDialog.FocusTarget.CCS;
+    }
+    this._openReplyDialog(target);
+  }
+
+  _handleScroll() {
+    this.debounce(
+      'scroll',
+      () => {
+        this.viewState.scrollTop = document.body.scrollTop;
+      },
+      150
+    );
+  }
+
+  _setShownFiles(e: CustomEvent<{length: number}>) {
+    this._shownFileCount = e.detail.length;
+  }
+
+  _expandAllDiffs(e: CustomKeyboardEvent) {
+    if (this.shouldSuppressKeyboardShortcut(e)) {
+      return;
+    }
+    this.$.fileList.expandAllDiffs();
+  }
+
+  _collapseAllDiffs() {
+    this.$.fileList.collapseAllDiffs();
+  }
+
+  _paramsChanged(value: AppElementChangeViewParams) {
+    if (value.view !== GerritView.CHANGE) {
+      this._initialLoadComplete = false;
+      return;
+    }
+
+    if (value.changeNum && value.project) {
+      this.$.restAPI.setInProjectLookup(value.changeNum, value.project);
+    }
+
+    const patchChanged =
+      this._patchRange &&
+      value.patchNum !== undefined &&
+      value.basePatchNum !== undefined &&
+      (this._patchRange.patchNum !== value.patchNum ||
+        this._patchRange.basePatchNum !== value.basePatchNum);
+    const changeChanged = this._changeNum !== value.changeNum;
+
+    const patchRange = {
+      patchNum: value.patchNum,
+      basePatchNum: value.basePatchNum || 'PARENT',
+    };
+    // TODO(TS): remove once proper type for patchRange is defined
+    if (!isNaN(Number(patchRange.patchNum))) {
+      patchRange.patchNum = Number(patchRange.patchNum) as PatchSetNum;
+    }
+    if (!isNaN(Number(patchRange.basePatchNum))) {
+      patchRange.basePatchNum = Number(patchRange.basePatchNum) as PatchSetNum;
+    }
+
+    this.$.fileList.collapseAllDiffs();
+    // TODO(TS): change patchRange to PatchRange.
+    this._patchRange = patchRange as PatchRange;
+
+    // If the change has already been loaded and the parameter change is only
+    // in the patch range, then don't do a full reload.
+    if (!changeChanged && patchChanged) {
+      if (!patchRange.patchNum) {
+        patchRange.patchNum = computeLatestPatchNum(this._allPatchSets);
+      }
+      this._reloadPatchNumDependentResources().then(() => {
+        this._sendShowChangeEvent();
+      });
+      return;
+    }
+
+    this._initialLoadComplete = false;
+    this._changeNum = value.changeNum;
+    this.$.relatedChanges.clear();
+
+    this._reload(true).then(() => {
+      this._performPostLoadTasks();
+    });
+
+    getPluginLoader()
+      .awaitPluginsLoaded()
+      .then(() => {
+        this._initActiveTabs(value);
+      });
+  }
+
+  _initActiveTabs(params?: AppElementChangeViewParams) {
+    let primaryTab = PrimaryTab.FILES;
+    if (params && params.queryMap && params.queryMap.has('tab')) {
+      primaryTab = params.queryMap.get('tab') as PrimaryTab;
+    }
+    this._setActivePrimaryTab(
+      new CustomEvent('initActiveTab', {
+        detail: {
+          tab: primaryTab,
+        },
+      })
+    );
+    this._setActiveSecondaryTab(
+      new CustomEvent('initActiveTab', {
+        detail: {
+          tab: SecondaryTab.CHANGE_LOG,
+        },
+      })
+    );
+  }
+
+  _sendShowChangeEvent() {
+    if (!this._patchRange)
+      throw new Error('missing required _patchRange property');
+    this.$.jsAPI.handleEvent(EventType.SHOW_CHANGE, {
+      change: this._change,
+      patchNum: this._patchRange.patchNum,
+      info: {mergeable: this._mergeable},
+    });
+  }
+
+  _performPostLoadTasks() {
+    this._maybeShowReplyDialog();
+    this._maybeShowRevertDialog();
+    this._maybeShowDownloadDialog();
+
+    this._sendShowChangeEvent();
+
+    this.async(() => {
+      if (this.viewState.scrollTop) {
+        document.documentElement.scrollTop = document.body.scrollTop = this.viewState.scrollTop;
+      } else {
+        this._maybeScrollToMessage(window.location.hash);
+      }
+      this._initialLoadComplete = true;
+    });
+  }
+
+  @observe('params', '_change')
+  _paramsAndChangeChanged(
+    value?: AppElementChangeViewParams,
+    change?: ChangeInfo
+  ) {
+    // Polymer 2: check for undefined
+    if (!value || !change) {
+      return;
+    }
+
+    if (!this._patchRange)
+      throw new Error('missing required _patchRange property');
+    // If the change number or patch range is different, then reset the
+    // selected file index.
+    const patchRangeState = this.viewState.patchRange;
+    if (
+      this.viewState.changeNum !== this._changeNum ||
+      !patchRangeState ||
+      patchRangeState.basePatchNum !== this._patchRange.basePatchNum ||
+      patchRangeState.patchNum !== this._patchRange.patchNum
+    ) {
+      this._resetFileListViewState();
+    }
+  }
+
+  _viewStateChanged(viewState: ChangeViewState) {
+    this._numFilesShown = viewState.numFilesShown
+      ? viewState.numFilesShown
+      : DEFAULT_NUM_FILES_SHOWN;
+  }
+
+  _numFilesShownChanged(numFilesShown: number) {
+    this.viewState.numFilesShown = numFilesShown;
+  }
+
+  _handleMessageAnchorTap(e: CustomEvent<{id: string}>) {
+    if (!this._change) throw new Error('missing required change property');
+    if (!this._patchRange)
+      throw new Error('missing required _patchRange property');
+    const hash = MSG_PREFIX + e.detail.id;
+    const url = GerritNav.getUrlForChange(
+      this._change,
+      this._patchRange.patchNum,
+      this._patchRange.basePatchNum,
+      this._editMode,
+      hash
+    );
+    history.replaceState(null, '', url);
+  }
+
+  _maybeScrollToMessage(hash: string) {
+    if (hash.startsWith(MSG_PREFIX) && this.messagesList) {
+      this.messagesList.scrollToMessage(hash.substr(MSG_PREFIX.length));
+    }
+  }
+
+  _getLocationSearch() {
+    // Not inlining to make it easier to test.
+    return window.location.search;
+  }
+
+  _getUrlParameter(param: string) {
+    const pageURL = this._getLocationSearch().substring(1);
+    const vars = pageURL.split('&');
+    for (let i = 0; i < vars.length; i++) {
+      const name = vars[i].split('=');
+      if (name[0] === param) {
+        return name[0];
+      }
+    }
+    return null;
+  }
+
+  _maybeShowRevertDialog() {
+    getPluginLoader()
+      .awaitPluginsLoaded()
+      .then(() => this._getLoggedIn())
+      .then(loggedIn => {
+        if (
+          !loggedIn ||
+          !this._change ||
+          this._change.status !== ChangeStatus.MERGED
+        ) {
+          // Do not display dialog if not logged-in or the change is not
+          // merged.
+          return;
+        }
+        if (this._getUrlParameter('revert')) {
+          this.$.actions.showRevertDialog();
+        }
+      });
+  }
+
+  _maybeShowReplyDialog() {
+    this._getLoggedIn().then(loggedIn => {
+      if (!loggedIn) {
+        return;
+      }
+
+      if (this.viewState.showReplyDialog) {
+        this._openReplyDialog(this.$.replyDialog.FocusTarget.ANY);
+        // TODO(kaspern@): Find a better signal for when to call center.
+        this.async(() => {
+          this.$.replyOverlay.center();
+        }, 100);
+        this.async(() => {
+          this.$.replyOverlay.center();
+        }, 1000);
+        this.set('viewState.showReplyDialog', false);
+      }
+    });
+  }
+
+  _maybeShowDownloadDialog() {
+    if (this.viewState.showDownloadDialog) {
+      this._handleOpenDownloadDialog();
+      this.set('viewState.showDownloadDialog', false);
+    }
+  }
+
+  _resetFileListViewState() {
+    this.set('viewState.selectedFileIndex', 0);
+    this.set('viewState.scrollTop', 0);
+    if (
+      !!this.viewState.changeNum &&
+      this.viewState.changeNum !== this._changeNum
+    ) {
+      // Reset the diff mode to null when navigating from one change to
+      // another, so that the user's preference is restored.
+      this._setDiffViewMode(true);
+      this.set('_numFilesShown', DEFAULT_NUM_FILES_SHOWN);
+    }
+    this.set('viewState.changeNum', this._changeNum);
+    this.set('viewState.patchRange', this._patchRange);
+  }
+
+  _changeChanged(change?: ChangeInfo | ParsedChangeInfo) {
+    if (!change || !this._patchRange || !this._allPatchSets) {
+      return;
+    }
+
+    // We get the parent first so we keep the original value for basePatchNum
+    // and not the updated value.
+    const parent = this._getBasePatchNum(change, this._patchRange);
+
+    this.set(
+      '_patchRange.patchNum',
+      this._patchRange.patchNum || computeLatestPatchNum(this._allPatchSets)
+    );
+
+    this.set('_patchRange.basePatchNum', parent);
+
+    const title = change.subject + ' (' + change.change_id.substr(0, 9) + ')';
+    this.dispatchEvent(
+      new CustomEvent('title-change', {
+        detail: {title},
+        composed: true,
+        bubbles: true,
+      })
+    );
+  }
+
+  /**
+   * Gets base patch number, if it is a parent try and decide from
+   * preference whether to default to `auto merge`, `Parent 1` or `PARENT`.
+   */
+  _getBasePatchNum(
+    change: ChangeInfo | ParsedChangeInfo,
+    patchRange: PatchRange
+  ) {
+    if (patchRange.basePatchNum && patchRange.basePatchNum !== 'PARENT') {
+      return patchRange.basePatchNum;
+    }
+
+    const revisionInfo = this._getRevisionInfo(change);
+    if (!revisionInfo) return 'PARENT';
+
+    const parentCounts = revisionInfo.getParentCountMap();
+    // check that there is at least 2 parents otherwise fall back to 1,
+    // which means there is only one parent.
+    const parentCount = hasOwnProperty(parentCounts, 1) ? parentCounts[1] : 1;
+
+    const preferFirst =
+      this._prefs && this._prefs.default_base_for_merges === 'FIRST_PARENT';
+
+    if (parentCount > 1 && preferFirst && !patchRange.patchNum) {
+      return -1;
+    }
+
+    return 'PARENT';
+  }
+
+  _computeChangeUrl(change: ChangeInfo) {
+    return GerritNav.getUrlForChange(change);
+  }
+
+  _computeShowCommitInfo(changeStatus: string, current_revision: RevisionInfo) {
+    return changeStatus === 'Merged' && current_revision;
+  }
+
+  _computeMergedCommitInfo(
+    current_revision: CommitId,
+    revisions: {[revisionId: string]: RevisionInfo}
+  ) {
+    const rev = revisions[current_revision];
+    if (!rev || !rev.commit) {
+      return {};
+    }
+    // CommitInfo.commit is optional. Set commit in all cases to avoid error
+    // in <gr-commit-info>. @see Issue 5337
+    if (!rev.commit.commit) {
+      rev.commit.commit = current_revision;
+    }
+    return rev.commit;
+  }
+
+  _computeChangeIdClass(displayChangeId: string) {
+    return displayChangeId === CHANGE_ID_ERROR.MISMATCH ? 'warning' : '';
+  }
+
+  _computeTitleAttributeWarning(displayChangeId: string) {
+    if (displayChangeId === CHANGE_ID_ERROR.MISMATCH) {
+      return 'Change-Id mismatch';
+    } else if (displayChangeId === CHANGE_ID_ERROR.MISSING) {
+      return 'No Change-Id in commit message';
+    }
+    return undefined;
+  }
+
+  _computeChangeIdCommitMessageError(
+    commitMessage?: string,
+    change?: ChangeInfo
+  ) {
+    if (change === undefined) {
+      return undefined;
+    }
+
+    if (!commitMessage) {
+      return CHANGE_ID_ERROR.MISSING;
+    }
+
+    // Find the last match in the commit message:
+    let changeId;
+    let changeIdArr;
+
+    while ((changeIdArr = CHANGE_ID_REGEX_PATTERN.exec(commitMessage))) {
+      changeId = changeIdArr[2];
+    }
+
+    if (changeId) {
+      // A change-id is detected in the commit message.
+
+      if (changeId === change.change_id) {
+        // The change-id found matches the real change-id.
+        return null;
+      }
+      // The change-id found does not match the change-id.
+      return CHANGE_ID_ERROR.MISMATCH;
+    }
+    // There is no change-id in the commit message.
+    return CHANGE_ID_ERROR.MISSING;
+  }
+
+  _computeReplyButtonLabel(
+    changeRecord?: ElementPropertyDeepChange<
+      GrChangeView,
+      '_diffDrafts'
+    > | null,
+    canStartReview?: PolymerDeepPropertyChange<boolean, boolean>
+  ) {
+    if (changeRecord === undefined || canStartReview === undefined) {
+      return 'Reply';
+    }
+
+    const drafts = (changeRecord && changeRecord.base) || {};
+    const draftCount = Object.keys(drafts).reduce(
+      (count, file) => count + drafts[file].length,
+      0
+    );
+
+    let label = canStartReview ? 'Start Review' : 'Reply';
+    if (draftCount > 0) {
+      label += ` (${draftCount})`;
+    }
+    return label;
+  }
+
+  _handleOpenReplyDialog(e: CustomKeyboardEvent) {
+    if (this.shouldSuppressKeyboardShortcut(e) || this.modifierPressed(e)) {
+      return;
+    }
+    this._getLoggedIn().then(isLoggedIn => {
+      if (!isLoggedIn) {
+        this.dispatchEvent(
+          new CustomEvent('show-auth-required', {
+            composed: true,
+            bubbles: true,
+          })
+        );
+        return;
+      }
+
+      e.preventDefault();
+      this._openReplyDialog(this.$.replyDialog.FocusTarget.ANY);
+    });
+  }
+
+  _handleOpenDownloadDialogShortcut(e: CustomKeyboardEvent) {
+    if (this.shouldSuppressKeyboardShortcut(e) || this.modifierPressed(e)) {
+      return;
+    }
+
+    e.preventDefault();
+    this._handleOpenDownloadDialog();
+  }
+
+  _handleEditTopic(e: CustomKeyboardEvent) {
+    if (this.shouldSuppressKeyboardShortcut(e) || this.modifierPressed(e)) {
+      return;
+    }
+
+    e.preventDefault();
+    this.$.metadata.editTopic();
+  }
+
+  _handleDiffAgainstBase(e: CustomKeyboardEvent) {
+    if (this.shouldSuppressKeyboardShortcut(e)) {
+      return;
+    }
+    if (!this._change) throw new Error('missing required change property');
+    if (!this._patchRange)
+      throw new Error('missing required _patchRange property');
+    if (patchNumEquals(this._patchRange.basePatchNum, ParentPatchSetNum)) {
+      this.dispatchEvent(
+        new CustomEvent('show-alert', {
+          detail: {
+            message: 'Base is already selected.',
+          },
+          composed: true,
+          bubbles: true,
+        })
+      );
+      return;
+    }
+    GerritNav.navigateToChange(this._change, this._patchRange.patchNum);
+  }
+
+  _handleDiffBaseAgainstLeft(e: CustomKeyboardEvent) {
+    if (this.shouldSuppressKeyboardShortcut(e)) {
+      return;
+    }
+    if (!this._change) throw new Error('missing required change property');
+    if (!this._patchRange)
+      throw new Error('missing required _patchRange property');
+    if (patchNumEquals(this._patchRange.basePatchNum, ParentPatchSetNum)) {
+      this.dispatchEvent(
+        new CustomEvent('show-alert', {
+          detail: {
+            message: 'Left is already base.',
+          },
+          composed: true,
+          bubbles: true,
+        })
+      );
+      return;
+    }
+    GerritNav.navigateToChange(this._change, this._patchRange.basePatchNum);
+  }
+
+  _handleDiffAgainstLatest(e: CustomKeyboardEvent) {
+    if (this.shouldSuppressKeyboardShortcut(e)) {
+      return;
+    }
+    if (!this._change) throw new Error('missing required change property');
+    if (!this._patchRange)
+      throw new Error('missing required _patchRange property');
+    const latestPatchNum = computeLatestPatchNum(this._allPatchSets);
+    if (patchNumEquals(this._patchRange.patchNum, latestPatchNum)) {
+      this.dispatchEvent(
+        new CustomEvent('show-alert', {
+          detail: {
+            message: 'Latest is already selected.',
+          },
+          composed: true,
+          bubbles: true,
+        })
+      );
+      return;
+    }
+    GerritNav.navigateToChange(
+      this._change,
+      latestPatchNum,
+      this._patchRange.basePatchNum
+    );
+  }
+
+  _handleDiffRightAgainstLatest(e: CustomKeyboardEvent) {
+    if (this.shouldSuppressKeyboardShortcut(e)) {
+      return;
+    }
+    if (!this._change) throw new Error('missing required change property');
+    const latestPatchNum = computeLatestPatchNum(this._allPatchSets);
+    if (!this._patchRange)
+      throw new Error('missing required _patchRange property');
+    if (patchNumEquals(this._patchRange.patchNum, latestPatchNum)) {
+      this.dispatchEvent(
+        new CustomEvent('show-alert', {
+          detail: {
+            message: 'Right is already latest.',
+          },
+          composed: true,
+          bubbles: true,
+        })
+      );
+      return;
+    }
+    GerritNav.navigateToChange(
+      this._change,
+      latestPatchNum,
+      this._patchRange.patchNum
+    );
+  }
+
+  _handleDiffBaseAgainstLatest(e: CustomKeyboardEvent) {
+    if (this.shouldSuppressKeyboardShortcut(e)) {
+      return;
+    }
+    if (!this._change) throw new Error('missing required change property');
+    if (!this._patchRange)
+      throw new Error('missing required _patchRange property');
+    const latestPatchNum = computeLatestPatchNum(this._allPatchSets);
+    if (
+      patchNumEquals(this._patchRange.patchNum, latestPatchNum) &&
+      patchNumEquals(this._patchRange.basePatchNum, ParentPatchSetNum)
+    ) {
+      this.dispatchEvent(
+        new CustomEvent('show-alert', {
+          detail: {
+            message: 'Already diffing base against latest.',
+          },
+          composed: true,
+          bubbles: true,
+        })
+      );
+      return;
+    }
+    GerritNav.navigateToChange(this._change, latestPatchNum);
+  }
+
+  _handleRefreshChange(e: CustomKeyboardEvent) {
+    if (this.shouldSuppressKeyboardShortcut(e)) {
+      return;
+    }
+    e.preventDefault();
+    this._reload(/* isLocationChange= */ false, /* clearPatchset= */ true);
+  }
+
+  _handleToggleChangeStar(e: CustomKeyboardEvent) {
+    if (this.shouldSuppressKeyboardShortcut(e) || this.modifierPressed(e)) {
+      return;
+    }
+    e.preventDefault();
+    this.$.changeStar.toggleStar();
+  }
+
+  _handleUpToDashboard(e: CustomKeyboardEvent) {
+    if (this.shouldSuppressKeyboardShortcut(e) || this.modifierPressed(e)) {
+      return;
+    }
+
+    e.preventDefault();
+    this._determinePageBack();
+  }
+
+  _handleExpandAllMessages(e: CustomKeyboardEvent) {
+    if (this.shouldSuppressKeyboardShortcut(e) || this.modifierPressed(e)) {
+      return;
+    }
+
+    e.preventDefault();
+    if (this.messagesList) {
+      this.messagesList.handleExpandCollapse(true);
+    }
+  }
+
+  _handleCollapseAllMessages(e: CustomKeyboardEvent) {
+    if (this.shouldSuppressKeyboardShortcut(e) || this.modifierPressed(e)) {
+      return;
+    }
+
+    e.preventDefault();
+    if (this.messagesList) {
+      this.messagesList.handleExpandCollapse(false);
+    }
+  }
+
+  _handleOpenDiffPrefsShortcut(e: CustomKeyboardEvent) {
+    if (this.shouldSuppressKeyboardShortcut(e) || this.modifierPressed(e)) {
+      return;
+    }
+
+    if (this._diffPrefsDisabled) {
+      return;
+    }
+
+    e.preventDefault();
+    this.$.fileList.openDiffPrefs();
+  }
+
+  _determinePageBack() {
+    // Default backPage to root if user came to change view page
+    // via an email link, etc.
+    GerritNav.navigateToRelativeUrl(this.backPage || GerritNav.getUrlForRoot());
+  }
+
+  _handleLabelRemoved(
+    splices: Array<PolymerSplice<ApprovalInfo[]>>,
+    path: string
+  ) {
+    for (const splice of splices) {
+      for (const removed of splice.removed) {
+        const changePath = path.split('.');
+        const labelPath = changePath.splice(0, changePath.length - 2);
+        const labelDict = this.get(labelPath) as QuickLabelInfo;
+        if (
+          labelDict.approved &&
+          labelDict.approved._account_id === removed._account_id
+        ) {
+          this._reload();
+          return;
+        }
+      }
+    }
+  }
+
+  @observe('_change.labels.*')
+  _labelsChanged(
+    changeRecord: PolymerDeepPropertyChange<
+      LabelNameToInfoMap,
+      PolymerSpliceChange<ApprovalInfo[]>
+    >
+  ) {
+    if (!changeRecord) {
+      return;
+    }
+    if (changeRecord.value && isPolymerSpliceChange(changeRecord.value)) {
+      this._handleLabelRemoved(
+        changeRecord.value.indexSplices,
+        changeRecord.path
+      );
+    }
+    this.$.jsAPI.handleEvent(EventType.LABEL_CHANGE, {
+      change: this._change,
+    });
+  }
+
+  _openReplyDialog(section?: FocusTarget) {
+    this.$.replyOverlay.open().finally(() => {
+      // the following code should be executed no matter open succeed or not
+      this._resetReplyOverlayFocusStops();
+      this.$.replyDialog.open(section);
+      flush();
+      this.$.replyOverlay.center();
+    });
+  }
+
+  _handleGetChangeDetailError(response?: Response | null) {
+    this.dispatchEvent(
+      new CustomEvent('page-error', {
+        detail: {response},
+        composed: true,
+        bubbles: true,
+      })
+    );
+  }
+
+  _getLoggedIn() {
+    return this.$.restAPI.getLoggedIn();
+  }
+
+  _getServerConfig() {
+    return this.$.restAPI.getConfig();
+  }
+
+  _getProjectConfig() {
+    if (!this._change) throw new Error('missing required change property');
+    return this.$.restAPI
+      .getProjectConfig(this._change.project)
+      .then(config => {
+        this._projectConfig = config;
+      });
+  }
+
+  _getPreferences() {
+    return this.$.restAPI.getPreferences();
+  }
+
+  _prepareCommitMsgForLinkify(msg: string) {
+    // TODO(wyatta) switch linkify sequence, see issue 5526.
+    // This is a zero-with space. It is added to prevent the linkify library
+    // from including R= or CC= as part of the email address.
+    return msg.replace(REVIEWERS_REGEX, '$1=\u200B');
+  }
+
+  /**
+   * Utility function to make the necessary modifications to a change in the
+   * case an edit exists.
+   */
+  _processEdit(change: ParsedChangeInfo, edit?: EditInfo | false) {
+    if (!edit) return;
+    if (!this._patchRange)
+      throw new Error('missing required _patchRange property');
+    if (!edit.commit.commit) throw new Error('undefined edit.commit.commit');
+    const changeWithEdit = change;
+    if (changeWithEdit.revisions)
+      changeWithEdit.revisions[edit.commit.commit] = {
+        _number: EditPatchSetNum,
+        basePatchNum: edit.base_patch_set_number,
+        commit: edit.commit,
+        fetch: edit.fetch,
+      } as RevisionInfo;
+
+    // If the edit is based on the most recent patchset, load it by
+    // default, unless another patch set to load was specified in the URL.
+    if (
+      !this._patchRange.patchNum &&
+      changeWithEdit.current_revision === edit.base_revision
+    ) {
+      changeWithEdit.current_revision = edit.commit.commit;
+      this.set('_patchRange.patchNum', EditPatchSetNum);
+      // Because edits are fibbed as revisions and added to the revisions
+      // array, and revision actions are always derived from the 'latest'
+      // patch set, we must copy over actions from the patch set base.
+      // Context: Issue 7243
+      if (changeWithEdit.revisions) {
+        changeWithEdit.revisions[edit.commit.commit].actions =
+          changeWithEdit.revisions[edit.base_revision].actions;
+      }
+    }
+  }
+
+  _getChangeDetail() {
+    if (!this._changeNum)
+      throw new Error('missing required changeNum property');
+    const detailCompletes = this.$.restAPI.getChangeDetail(this._changeNum, r =>
+      this._handleGetChangeDetailError(r)
+    );
+    const editCompletes = this._getEdit();
+    const prefCompletes = this._getPreferences();
+
+    return Promise.all([detailCompletes, editCompletes, prefCompletes]).then(
+      ([change, edit, prefs]) => {
+        this._prefs = prefs;
+
+        if (!change) {
+          return false;
+        }
+        this._processEdit(change, edit);
+        // Issue 4190: Coalesce missing topics to null.
+        // TODO(TS): code needs second thought,
+        // it might be that nulls were assigned to trigger some bindings
+        if (!change.topic) {
+          change.topic = (null as unknown) as undefined;
+        }
+        if (!change.reviewer_updates) {
+          change.reviewer_updates = (null as unknown) as undefined;
+        }
+        const latestRevisionSha = this._getLatestRevisionSHA(change);
+        if (!latestRevisionSha)
+          throw new Error('Could not find latest Revision Sha');
+        const currentRevision = change.revisions[latestRevisionSha];
+        if (currentRevision.commit && currentRevision.commit.message) {
+          this._latestCommitMessage = this._prepareCommitMsgForLinkify(
+            currentRevision.commit.message
+          );
+        } else {
+          this._latestCommitMessage = null;
+        }
+
+        const lineHeight = getComputedStyle(this).lineHeight;
+
+        // Slice returns a number as a string, convert to an int.
+        this._lineHeight = parseInt(
+          lineHeight.slice(0, lineHeight.length - 2),
+          10
+        );
+
+        this._change = change;
+        if (
+          !this._patchRange ||
+          !this._patchRange.patchNum ||
+          patchNumEquals(this._patchRange.patchNum, currentRevision._number)
+        ) {
+          // CommitInfo.commit is optional, and may need patching.
+          if (currentRevision.commit && !currentRevision.commit.commit) {
+            currentRevision.commit.commit = latestRevisionSha as CommitId;
+          }
+          this._commitInfo = currentRevision.commit;
+          this._selectedRevision = currentRevision;
+          // TODO: Fetch and process files.
+        } else {
+          if (!this._change?.revisions || !this._patchRange) return false;
+          this._selectedRevision = Object.values(this._change.revisions).find(
+            revision => {
+              // edit patchset is a special one
+              const thePatchNum = this._patchRange!.patchNum;
+              if (thePatchNum === 'edit') {
+                return revision._number === thePatchNum;
+              }
+              return revision._number === parseInt(`${thePatchNum}`, 10);
+            }
+          );
+        }
+        return false;
+      }
+    );
+  }
+
+  _isSubmitEnabled(revisionActions: ActionNameToActionInfoMap) {
+    return !!(
+      revisionActions &&
+      revisionActions.submit &&
+      revisionActions.submit.enabled
+    );
+  }
+
+  _isParentCurrent(revisionActions: ActionNameToActionInfoMap) {
+    if (revisionActions && revisionActions.rebase) {
+      return !revisionActions.rebase.enabled;
+    } else {
+      return true;
+    }
+  }
+
+  _getEdit() {
+    if (!this._changeNum)
+      return Promise.reject(new Error('missing required changeNum property'));
+    return this.$.restAPI.getChangeEdit(this._changeNum, true);
+  }
+
+  _getLatestCommitMessage() {
+    if (!this._changeNum)
+      throw new Error('missing required changeNum property');
+    const lastpatchNum = computeLatestPatchNum(this._allPatchSets);
+    if (lastpatchNum === undefined)
+      throw new Error('missing lastPatchNum property');
+    return this.$.restAPI
+      .getChangeCommitInfo(this._changeNum, lastpatchNum)
+      .then(commitInfo => {
+        if (!commitInfo) return;
+        this._latestCommitMessage = this._prepareCommitMsgForLinkify(
+          commitInfo.message
+        );
+      });
+  }
+
+  _getLatestRevisionSHA(change: ChangeInfo | ParsedChangeInfo) {
+    if (change.current_revision) {
+      return change.current_revision;
+    }
+    // current_revision may not be present in the case where the latest rev is
+    // a draft and the user doesn’t have permission to view that rev.
+    let latestRev = null;
+    let latestPatchNum = -1 as PatchSetNum;
+    for (const rev in change.revisions) {
+      if (!hasOwnProperty(change.revisions, rev)) {
+        continue;
+      }
+
+      if (change.revisions[rev]._number > latestPatchNum) {
+        latestRev = rev;
+        latestPatchNum = change.revisions[rev]._number;
+      }
+    }
+    return latestRev;
+  }
+
+  _getCommitInfo() {
+    if (!this._changeNum)
+      throw new Error('missing required changeNum property');
+    if (!this._patchRange)
+      throw new Error('missing required _patchRange property');
+    if (this._patchRange.patchNum === undefined)
+      throw new Error('missing required patchNum property');
+    return this.$.restAPI
+      .getChangeCommitInfo(this._changeNum, this._patchRange.patchNum)
+      .then(commitInfo => {
+        this._commitInfo = commitInfo;
+      });
+  }
+
+  _reloadDraftsWithCallback(e: CustomEvent<{resolve: () => void}>) {
+    return this._reloadDrafts().then(() => e.detail.resolve());
+  }
+
+  /**
+   * Fetches a new changeComment object, and data for all types of comments
+   * (comments, robot comments, draft comments) is requested.
+   */
+  _reloadComments() {
+    // We are resetting all comment related properties, because we want to avoid
+    // a new change being loaded and then paired with outdated comments.
+    this._changeComments = undefined;
+    this._commentThreads = undefined;
+    this._diffDrafts = undefined;
+    this._draftCommentThreads = undefined;
+    this._robotCommentThreads = undefined;
+    if (!this._changeNum)
+      throw new Error('missing required changeNum property');
+    return this.$.commentAPI
+      .loadAll(this._changeNum)
+      .then(comments => this._recomputeComments(comments));
+  }
+
+  /**
+   * Fetches a new changeComment object, but only updated data for drafts is
+   * requested.
+   *
+   * TODO(taoalpha): clean up this and _reloadComments, as single comment
+   * can be a thread so it does not make sense to only update drafts
+   * without updating threads
+   */
+  _reloadDrafts() {
+    if (!this._changeNum)
+      throw new Error('missing required changeNum property');
+    return this.$.commentAPI
+      .reloadDrafts(this._changeNum)
+      .then(comments => this._recomputeComments(comments));
+  }
+
+  _recomputeComments(comments: ChangeComments) {
+    this._changeComments = comments;
+    this._diffDrafts = {...this._changeComments.drafts};
+    this._commentThreads = this._changeComments.getAllThreadsForChange();
+    this._draftCommentThreads = this._commentThreads
+      .filter(isDraftThread)
+      .map(thread => {
+        const copiedThread = {...thread};
+        // Make a hardcopy of all comments and collapse all but last one
+        const commentsInThread = (copiedThread.comments = thread.comments.map(
+          comment => {
+            return {...comment, collapsed: true as boolean};
+          }
+        ));
+        commentsInThread[commentsInThread.length - 1].collapsed = false;
+        return copiedThread;
+      });
+  }
+
+  /**
+   * Reload the change.
+   *
+   * @param isLocationChange Reloads the related changes
+   * when true and ends reporting events that started on location change.
+   * @param clearPatchset Reloads the related changes
+   * ignoring any patchset choice made.
+   * @return A promise that resolves when the core data has loaded.
+   * Some non-core data loading may still be in-flight when the core data
+   * promise resolves.
+   */
+  _reload(isLocationChange?: boolean, clearPatchset?: boolean) {
+    if (clearPatchset && this._change) {
+      GerritNav.navigateToChange(this._change);
+      return Promise.resolve([]);
+    }
+    this._loading = true;
+    this._relatedChangesCollapsed = true;
+    this.reporting.time(CHANGE_RELOAD_TIMING_LABEL);
+    this.reporting.time(CHANGE_DATA_TIMING_LABEL);
+
+    // Array to house all promises related to data requests.
+    const allDataPromises: Promise<unknown>[] = [];
+
+    // Resolves when the change detail and the edit patch set (if available)
+    // are loaded.
+    const detailCompletes = this._getChangeDetail();
+    allDataPromises.push(detailCompletes);
+
+    // Resolves when the loading flag is set to false, meaning that some
+    // change content may start appearing.
+    const loadingFlagSet = detailCompletes
+      .then(() => {
+        this._loading = false;
+        this.dispatchEvent(
+          new CustomEvent('change-details-loaded', {
+            bubbles: true,
+            composed: true,
+          })
+        );
+      })
+      .then(() => {
+        this.reporting.timeEnd(CHANGE_RELOAD_TIMING_LABEL);
+        if (isLocationChange) {
+          this.reporting.changeDisplayed();
+        }
+      });
+
+    // Resolves when the project config has loaded.
+    const projectConfigLoaded = detailCompletes.then(() =>
+      this._getProjectConfig()
+    );
+    allDataPromises.push(projectConfigLoaded);
+
+    // Resolves when change comments have loaded (comments, drafts and robot
+    // comments).
+    const commentsLoaded = this._reloadComments();
+    allDataPromises.push(commentsLoaded);
+
+    let coreDataPromise;
+
+    // If the patch number is specified
+    if (this._patchRange && this._patchRange.patchNum) {
+      // Because a specific patchset is specified, reload the resources that
+      // are keyed by patch number or patch range.
+      const patchResourcesLoaded = this._reloadPatchNumDependentResources();
+      allDataPromises.push(patchResourcesLoaded);
+
+      // Promise resolves when the change detail and patch dependent resources
+      // have loaded.
+      const detailAndPatchResourcesLoaded = Promise.all([
+        patchResourcesLoaded,
+        loadingFlagSet,
+      ]);
+
+      // Promise resolves when mergeability information has loaded.
+      const mergeabilityLoaded = detailAndPatchResourcesLoaded.then(() =>
+        this._getMergeability()
+      );
+      allDataPromises.push(mergeabilityLoaded);
+
+      // Promise resovles when the change actions have loaded.
+      const actionsLoaded = detailAndPatchResourcesLoaded.then(() =>
+        this.$.actions.reload()
+      );
+      allDataPromises.push(actionsLoaded);
+
+      // The core data is loaded when both mergeability and actions are known.
+      coreDataPromise = Promise.all([mergeabilityLoaded, actionsLoaded]);
+    } else {
+      // Resolves when the file list has loaded.
+      const fileListReload = loadingFlagSet.then(() =>
+        this.$.fileList.reload()
+      );
+      allDataPromises.push(fileListReload);
+
+      const latestCommitMessageLoaded = loadingFlagSet.then(() => {
+        // If the latest commit message is known, there is nothing to do.
+        if (this._latestCommitMessage) {
+          return Promise.resolve();
+        }
+        return this._getLatestCommitMessage();
+      });
+      allDataPromises.push(latestCommitMessageLoaded);
+
+      // Promise resolves when mergeability information has loaded.
+      const mergeabilityLoaded = loadingFlagSet.then(() =>
+        this._getMergeability()
+      );
+      allDataPromises.push(mergeabilityLoaded);
+
+      // Core data is loaded when mergeability has been loaded.
+      coreDataPromise = Promise.all([mergeabilityLoaded]);
+    }
+
+    if (isLocationChange) {
+      this._editingCommitMessage = false;
+      const relatedChangesLoaded = coreDataPromise.then(() =>
+        this.$.relatedChanges.reload()
+      );
+      allDataPromises.push(relatedChangesLoaded);
+    }
+
+    Promise.all(allDataPromises).then(() => {
+      this.reporting.timeEnd(CHANGE_DATA_TIMING_LABEL);
+      if (isLocationChange) {
+        this.reporting.changeFullyLoaded();
+      }
+    });
+
+    return coreDataPromise;
+  }
+
+  /**
+   * Kicks off requests for resources that rely on the patch range
+   * (`this._patchRange`) being defined.
+   */
+  _reloadPatchNumDependentResources() {
+    return Promise.all([this._getCommitInfo(), this.$.fileList.reload()]);
+  }
+
+  _getMergeability() {
+    if (!this._change) {
+      this._mergeable = null;
+      return Promise.resolve();
+    }
+    // If the change is closed, it is not mergeable. Note: already merged
+    // changes are obviously not mergeable, but the mergeability API will not
+    // answer for abandoned changes.
+    if (
+      this._change.status === ChangeStatus.MERGED ||
+      this._change.status === ChangeStatus.ABANDONED
+    ) {
+      this._mergeable = false;
+      return Promise.resolve();
+    }
+
+    if (!this._changeNum) {
+      return Promise.reject(new Error('missing required changeNum property'));
+    }
+
+    this._mergeable = null;
+    return this.$.restAPI.getMergeable(this._changeNum).then(mergableInfo => {
+      if (mergableInfo) {
+        this._mergeable = mergableInfo.mergeable;
+      }
+    });
+  }
+
+  _computeCanStartReview(change: ChangeInfo) {
+    return !!(
+      change.actions &&
+      change.actions.ready &&
+      change.actions.ready.enabled
+    );
+  }
+
+  _computeReplyDisabled() {
+    return false;
+  }
+
+  _computeChangePermalinkAriaLabel(changeNum: NumericChangeId) {
+    return `Change ${changeNum}`;
+  }
+
+  _computeCommitMessageCollapsed(collapsed?: boolean, collapsible?: boolean) {
+    return collapsible && collapsed;
+  }
+
+  _computeRelatedChangesClass(collapsed: boolean) {
+    return collapsed ? 'collapsed' : '';
+  }
+
+  _computeCollapseText(collapsed: boolean) {
+    // Symbols are up and down triangles.
+    return collapsed ? '\u25bc Show more' : '\u25b2 Show less';
+  }
+
+  /**
+   * Returns the text to be copied when
+   * click the copy icon next to change subject
+   */
+  _computeCopyTextForTitle(change: ChangeInfo) {
+    return (
+      `${change._number}: ${change.subject} | ` +
+      `${location.protocol}//${location.host}` +
+      `${this._computeChangeUrl(change)}`
+    );
+  }
+
+  _toggleCommitCollapsed() {
+    this._commitCollapsed = !this._commitCollapsed;
+    if (this._commitCollapsed) {
+      window.scrollTo(0, 0);
+    }
+  }
+
+  _toggleRelatedChangesCollapsed() {
+    this._relatedChangesCollapsed = !this._relatedChangesCollapsed;
+    if (this._relatedChangesCollapsed) {
+      window.scrollTo(0, 0);
+    }
+  }
+
+  _computeCommitCollapsible(commitMessage?: string) {
+    if (!commitMessage) {
+      return false;
+    }
+    return commitMessage.split('\n').length >= MIN_LINES_FOR_COMMIT_COLLAPSE;
+  }
+
+  _getOffsetHeight(element: HTMLElement) {
+    return element.offsetHeight;
+  }
+
+  _getScrollHeight(element: HTMLElement) {
+    return element.scrollHeight;
+  }
+
+  /**
+   * Get the line height of an element to the nearest integer.
+   */
+  _getLineHeight(element: Element) {
+    const lineHeightStr = getComputedStyle(element).lineHeight;
+    return Math.round(Number(lineHeightStr.slice(0, lineHeightStr.length - 2)));
+  }
+
+  /**
+   * New max height for the related changes section, shorter than the existing
+   * change info height.
+   */
+  _updateRelatedChangeMaxHeight() {
+    // Takes into account approximate height for the expand button and
+    // bottom margin.
+    const EXTRA_HEIGHT = 30;
+    let newHeight;
+
+    if (window.matchMedia(`(max-width: ${BREAKPOINT_RELATED_SMALL})`).matches) {
+      // In a small (mobile) view, give the relation chain some space.
+      newHeight = SMALL_RELATED_HEIGHT;
+    } else if (
+      window.matchMedia(`(max-width: ${BREAKPOINT_RELATED_MED})`).matches
+    ) {
+      // Since related changes are below the commit message, but still next to
+      // metadata, the height should be the height of the metadata minus the
+      // height of the commit message to reduce jank. However, if that doesn't
+      // result in enough space, instead use the MINIMUM_RELATED_MAX_HEIGHT.
+      // Note: extraHeight is to take into account margin/padding.
+      const medRelatedHeight = Math.max(
+        this._getOffsetHeight(this.$.mainChangeInfo) -
+          this._getOffsetHeight(this.$.commitMessage) -
+          2 * EXTRA_HEIGHT,
+        MINIMUM_RELATED_MAX_HEIGHT
+      );
+      newHeight = medRelatedHeight;
+    } else {
+      if (this._commitCollapsible) {
+        // Make sure the content is lined up if both areas have buttons. If
+        // the commit message is not collapsed, instead use the change info
+        // height.
+        newHeight = this._getOffsetHeight(this.$.commitMessage);
+      } else {
+        newHeight =
+          this._getOffsetHeight(this.$.commitAndRelated) - EXTRA_HEIGHT;
+      }
+    }
+    const stylesToUpdate: {[key: string]: string} = {};
+
+    // Get the line height of related changes, and convert it to the nearest
+    // integer.
+    const lineHeight = this._getLineHeight(this.$.relatedChanges);
+
+    // Figure out a new height that is divisible by the rounded line height.
+    const remainder = newHeight % lineHeight;
+    newHeight = newHeight - remainder;
+
+    stylesToUpdate['--relation-chain-max-height'] = `${newHeight}px`;
+
+    // Update the max-height of the relation chain to this new height.
+    if (this._commitCollapsible) {
+      stylesToUpdate['--related-change-btn-top-padding'] = `${remainder}px`;
+    }
+
+    this.updateStyles(stylesToUpdate);
+  }
+
+  _computeShowRelatedToggle() {
+    // Make sure the max height has been applied, since there is now content
+    // to populate.
+    if (!getComputedStyleValue('--relation-chain-max-height', this)) {
+      this._updateRelatedChangeMaxHeight();
+    }
+    // Prevents showMore from showing when click on related change, since the
+    // line height would be positive, but related changes height is 0.
+    if (!this._getScrollHeight(this.$.relatedChanges)) {
+      return (this._showRelatedToggle = false);
+    }
+
+    if (
+      this._getScrollHeight(this.$.relatedChanges) >
+      this._getOffsetHeight(this.$.relatedChanges) +
+        this._getLineHeight(this.$.relatedChanges)
+    ) {
+      return (this._showRelatedToggle = true);
+    }
+    return (this._showRelatedToggle = false);
+  }
+
+  _updateToggleContainerClass(showRelatedToggle: boolean) {
+    if (showRelatedToggle) {
+      this.$.relatedChangesToggle.classList.add('showToggle');
+    } else {
+      this.$.relatedChangesToggle.classList.remove('showToggle');
+    }
+  }
+
+  _startUpdateCheckTimer() {
+    if (
+      !this._serverConfig ||
+      !this._serverConfig.change ||
+      this._serverConfig.change.update_delay === undefined ||
+      this._serverConfig.change.update_delay <= MIN_CHECK_INTERVAL_SECS
+    ) {
+      return;
+    }
+
+    this._updateCheckTimerHandle = this.async(() => {
+      if (!this._change) throw new Error('missing required change property');
+      const change = this._change;
+      fetchChangeUpdates(change, this.$.restAPI).then(result => {
+        let toastMessage = null;
+        if (!result.isLatest) {
+          toastMessage = ReloadToastMessage.NEWER_REVISION;
+        } else if (result.newStatus === ChangeStatus.MERGED) {
+          toastMessage = ReloadToastMessage.MERGED;
+        } else if (result.newStatus === ChangeStatus.ABANDONED) {
+          toastMessage = ReloadToastMessage.ABANDONED;
+        } else if (result.newStatus === ChangeStatus.NEW) {
+          toastMessage = ReloadToastMessage.RESTORED;
+        } else if (result.newMessages) {
+          toastMessage = ReloadToastMessage.NEW_MESSAGE;
+        }
+
+        // We have to make sure that the update is still relevant for the user.
+        // Since starting to fetch the change update the user may have sent a
+        // reply, or the change might have been reloaded, or it could be in the
+        // process of being reloaded.
+        const changeWasReloaded = change !== this._change;
+        if (!toastMessage || this._loading || changeWasReloaded) {
+          this._startUpdateCheckTimer();
+          return;
+        }
+
+        this._cancelUpdateCheckTimer();
+        this.dispatchEvent(
+          new CustomEvent('show-alert', {
+            detail: {
+              message: toastMessage,
+              // Persist this alert.
+              dismissOnNavigation: true,
+              action: 'Reload',
+              callback: () => {
+                this._reload(
+                  /* isLocationChange= */ false,
+                  /* clearPatchset= */ true
+                );
+              },
+            },
+            composed: true,
+            bubbles: true,
+          })
+        );
+      });
+    }, this._serverConfig.change.update_delay * 1000);
+  }
+
+  _cancelUpdateCheckTimer() {
+    if (this._updateCheckTimerHandle) {
+      this.cancelAsync(this._updateCheckTimerHandle);
+    }
+    this._updateCheckTimerHandle = null;
+  }
+
+  _handleVisibilityChange() {
+    if (document.hidden && this._updateCheckTimerHandle) {
+      this._cancelUpdateCheckTimer();
+    } else if (!this._updateCheckTimerHandle) {
+      this._startUpdateCheckTimer();
+    }
+  }
+
+  _handleTopicChanged() {
+    this.$.relatedChanges.reload();
+  }
+
+  _computeHeaderClass(editMode: boolean) {
+    const classes = ['header'];
+    if (editMode) {
+      classes.push('editMode');
+    }
+    return classes.join(' ');
+  }
+
+  _computeEditMode(
+    patchRangeRecord: PolymerDeepPropertyChange<PatchRange, PatchRange>,
+    paramsRecord: PolymerDeepPropertyChange<
+      AppElementChangeViewParams,
+      AppElementChangeViewParams
+    >
+  ) {
+    if (!patchRangeRecord || !paramsRecord) {
+      return undefined;
+    }
+
+    if (paramsRecord.base && paramsRecord.base.edit) {
+      return true;
+    }
+
+    const patchRange = patchRangeRecord.base || {};
+    return patchNumEquals(patchRange.patchNum, EditPatchSetNum);
+  }
+
+  _handleFileActionTap(e: CustomEvent<{path: string; action: string}>) {
+    e.preventDefault();
+    const controls = this.$.fileListHeader.shadowRoot!.querySelector(
+      '#editControls'
+    ) as GrEditControls | null;
+    if (!controls) throw new Error('Missing edit controls');
+    if (!this._change) throw new Error('missing required change property');
+    if (!this._patchRange)
+      throw new Error('missing required _patchRange property');
+    const path = e.detail.path;
+    switch (e.detail.action) {
+      case GrEditConstants.Actions.DELETE.id:
+        controls.openDeleteDialog(path);
+        break;
+      case GrEditConstants.Actions.OPEN.id:
+        GerritNav.navigateToRelativeUrl(
+          GerritNav.getEditUrlForDiff(
+            this._change,
+            path,
+            this._patchRange.patchNum
+          )
+        );
+        break;
+      case GrEditConstants.Actions.RENAME.id:
+        controls.openRenameDialog(path);
+        break;
+      case GrEditConstants.Actions.RESTORE.id:
+        controls.openRestoreDialog(path);
+        break;
+    }
+  }
+
+  _computeCommitMessageKey(number: NumericChangeId, revision: CommitId) {
+    return `c${number}_rev${revision}`;
+  }
+
+  @observe('_patchRange.patchNum')
+  _patchNumChanged(patchNumStr: PatchSetNum) {
+    if (!this._selectedRevision) {
+      return;
+    }
+    if (!this._change) throw new Error('missing required change property');
+
+    let patchNum: PatchSetNum;
+    if (patchNumStr === 'edit') {
+      patchNum = EditPatchSetNum;
+    } else {
+      patchNum = parseInt(`${patchNumStr}`, 10) as PatchSetNum;
+    }
+
+    if (patchNum === this._selectedRevision._number) {
+      return;
+    }
+    if (this._change.revisions)
+      this._selectedRevision = Object.values(this._change.revisions).find(
+        revision => revision._number === patchNum
+      );
+  }
+
+  /**
+   * If an edit exists already, load it. Otherwise, toggle edit mode via the
+   * navigation API.
+   */
+  _handleEditTap() {
+    if (!this._change || !this._change.revisions)
+      throw new Error('missing required change property');
+    const editInfo = Object.values(this._change.revisions).find(
+      info => info._number === EditPatchSetNum
+    );
+
+    if (editInfo) {
+      GerritNav.navigateToChange(this._change, EditPatchSetNum);
+      return;
+    }
+
+    // Avoid putting patch set in the URL unless a non-latest patch set is
+    // selected.
+    if (!this._patchRange)
+      throw new Error('missing required _patchRange property');
+    let patchNum;
+    if (
+      !patchNumEquals(
+        this._patchRange.patchNum,
+        computeLatestPatchNum(this._allPatchSets)
+      )
+    ) {
+      patchNum = this._patchRange.patchNum;
+    }
+    GerritNav.navigateToChange(this._change, patchNum, undefined, true);
+  }
+
+  _handleStopEditTap() {
+    if (!this._change) throw new Error('missing required change property');
+    if (!this._patchRange)
+      throw new Error('missing required _patchRange property');
+    GerritNav.navigateToChange(this._change, this._patchRange.patchNum);
+  }
+
+  _resetReplyOverlayFocusStops() {
+    this.$.replyOverlay.setFocusStops(this.$.replyDialog.getFocusStops());
+  }
+
+  _handleToggleStar(e: CustomEvent<{change: ChangeInfo; starred: boolean}>) {
+    this.$.restAPI.saveChangeStarred(e.detail.change._number, e.detail.starred);
+  }
+
+  _getRevisionInfo(change: ChangeInfo | ParsedChangeInfo) {
+    return new RevisionInfoClass(change);
+  }
+
+  _computeCurrentRevision(
+    currentRevision: CommitId,
+    revisions: {[revisionId: string]: RevisionInfo}
+  ) {
+    return currentRevision && revisions && revisions[currentRevision];
+  }
+
+  _computeDiffPrefsDisabled(disableDiffPrefs: boolean, loggedIn: boolean) {
+    return disableDiffPrefs || !loggedIn;
+  }
+
+  /**
+   * Wrapper for using in the element template and computed properties
+   */
+  _computeLatestPatchNum(allPatchSets: PatchSet[]) {
+    return computeLatestPatchNum(allPatchSets);
+  }
+
+  /**
+   * Wrapper for using in the element template and computed properties
+   */
+  _hasEditBasedOnCurrentPatchSet(allPatchSets: PatchSet[]) {
+    return hasEditBasedOnCurrentPatchSet(allPatchSets);
+  }
+
+  /**
+   * Wrapper for using in the element template and computed properties
+   */
+  _hasEditPatchsetLoaded(
+    patchRangeRecord: PolymerDeepPropertyChange<PatchRange, PatchRange>
+  ) {
+    const patchRange = patchRangeRecord.base;
+    if (!patchRange) {
+      return false;
+    }
+    return hasEditPatchsetLoaded(patchRange);
+  }
+
+  /**
+   * Wrapper for using in the element template and computed properties
+   */
+  _computeAllPatchSets(change: ChangeInfo) {
+    return computeAllPatchSets(change);
+  }
+}
+
+declare global {
+  interface HTMLElementTagNameMap {
+    'gr-change-view': GrChangeView;
+  }
+}
diff --git a/polygerrit-ui/app/elements/change/gr-change-view/gr-change-view_test.js b/polygerrit-ui/app/elements/change/gr-change-view/gr-change-view_test.js
index 6170bea..4fcbc78 100644
--- a/polygerrit-ui/app/elements/change/gr-change-view/gr-change-view_test.js
+++ b/polygerrit-ui/app/elements/change/gr-change-view/gr-change-view_test.js
@@ -300,6 +300,7 @@
       _fetchSharedCacheURL() { return Promise.resolve({}); },
     });
     element = fixture.instantiate();
+    element._changeNum = '1';
     sinon.stub(element.$.actions, 'reload').returns(Promise.resolve());
     getPluginLoader().loadPlugins([]);
     pluginApi.install(
@@ -333,6 +334,11 @@
       basePatchNum: 'PARENT',
       patchNum: 1,
     };
+    element._change = {
+      _number: '1',
+      project: '',
+      change_id: '1',
+    };
     const getUrlStub = sinon.stub(GerritNav, 'getUrlForChange');
     const replaceStateStub = sinon.stub(history, 'replaceState');
     element._handleMessageAnchorTap({detail: {id: 'a12345'}});
@@ -414,6 +420,7 @@
 
   suite('plugins adding to file tab', () => {
     setup(done => {
+      element._changeNum = '1';
       // Resolving it here instead of during setup() as other tests depend
       // on flush() not being called during setup.
       flush(() => done());
@@ -459,6 +466,7 @@
       queryMap.set('tab', PrimaryTab.FINDINGS);
       // view is required
       element.params = {
+        changeNum: '1',
         view: GerritNav.View.CHANGE,
         ...element.params, queryMap};
       flush(() => {
@@ -473,6 +481,7 @@
       queryMap.set('tab', 'random');
       // view is required
       element.params = {
+        changeNum: '1',
         view: GerritNav.View.CHANGE,
         ...element.params, queryMap};
       flush(() => {
@@ -783,6 +792,7 @@
             getAllThreadsForChange: () => ([]),
             computeDraftCount: () => 1,
           }));
+      element._changeNum = '1';
     });
 
     test('drafts are reloaded when reload-drafts fired', done => {
@@ -1415,6 +1425,7 @@
   });
 
   test('_handleCommitMessageSave trims trailing whitespace', () => {
+    element._change = {};
     const putStub = sinon.stub(element.$.restAPI, 'putChangeCommitMessage')
         .returns(Promise.resolve({}));
 
@@ -1609,14 +1620,16 @@
   });
 
   test('_openReplyDialog called with `ANY` when coming from tap event',
-      () => {
-        const openStub = sinon.stub(element, '_openReplyDialog');
-        element._serverConfig = {};
-        MockInteractions.tap(element.$.replyBtn);
-        assert(openStub.lastCall.calledWithExactly(
-            element.$.replyDialog.FocusTarget.ANY),
-        '_openReplyDialog should have been passed ANY');
-        assert.equal(openStub.callCount, 1);
+      done => {
+        flush(() => {
+          const openStub = sinon.stub(element, '_openReplyDialog');
+          MockInteractions.tap(element.$.replyBtn);
+          assert(openStub.lastCall.calledWithExactly(
+              element.$.replyDialog.FocusTarget.ANY),
+          '_openReplyDialog should have been passed ANY');
+          assert.equal(openStub.callCount, 1);
+          done();
+        });
       });
 
   test('_openReplyDialog called with `BODY` when coming from message reply' +
@@ -1806,10 +1819,13 @@
     });
   });
 
-  test('reply button is disabled until server config is loaded', () => {
+  test('reply button is disabled until server config is loaded', done => {
     assert.isTrue(element._replyDisabled);
-    element._serverConfig = {};
-    assert.isFalse(element._replyDisabled);
+    // fetches the server config on attached
+    flush(() => {
+      assert.isFalse(element._replyDisabled);
+      done();
+    });
   });
 
   suite('commit message expand/collapse', () => {
@@ -2189,6 +2205,11 @@
       basePatchNum: 'PARENT',
       patchNum: 1,
     };
+    element._change = {
+      _number: '1',
+      project: '',
+      change_id: '1',
+    };
     const fileList = element.$.fileList;
     const Actions = GrEditConstants.Actions;
     element.$.fileListHeader.editMode = true;
@@ -2371,6 +2392,11 @@
   });
 
   test('_handleStopEditTap', done => {
+    element._change = {
+      _number: '1',
+      project: '',
+      change_id: '1',
+    };
     sinon.stub(element.$.metadata, '_computeLabelNames');
     navigateToChangeStub.restore();
     sinon.stub(GerritNav, 'navigateToChange').callsFake((...args) => {
diff --git a/polygerrit-ui/app/elements/change/gr-included-in-dialog/gr-included-in-dialog.ts b/polygerrit-ui/app/elements/change/gr-included-in-dialog/gr-included-in-dialog.ts
index f5e3588..1957f5c 100644
--- a/polygerrit-ui/app/elements/change/gr-included-in-dialog/gr-included-in-dialog.ts
+++ b/polygerrit-ui/app/elements/change/gr-included-in-dialog/gr-included-in-dialog.ts
@@ -65,7 +65,7 @@
 
   loadData() {
     if (!this.changeNum) {
-      return;
+      return Promise.reject(new Error('missing required property changeNum'));
     }
     this._filterText = '';
     return this.$.restAPI.getChangeIncludedIn(this.changeNum).then(configs => {
diff --git a/polygerrit-ui/app/elements/change/gr-reply-dialog/gr-reply-dialog.ts b/polygerrit-ui/app/elements/change/gr-reply-dialog/gr-reply-dialog.ts
index 7fc5973..7c71eaa 100644
--- a/polygerrit-ui/app/elements/change/gr-reply-dialog/gr-reply-dialog.ts
+++ b/polygerrit-ui/app/elements/change/gr-reply-dialog/gr-reply-dialog.ts
@@ -113,7 +113,7 @@
 
 const STORAGE_DEBOUNCE_INTERVAL_MS = 400;
 
-enum FocusTarget {
+export enum FocusTarget {
   ANY = 'any',
   BODY = 'body',
   CCS = 'cc',
diff --git a/polygerrit-ui/app/elements/change/gr-reviewer-list/gr-reviewer-list.ts b/polygerrit-ui/app/elements/change/gr-reviewer-list/gr-reviewer-list.ts
index d7a1f17..5ca7d887 100644
--- a/polygerrit-ui/app/elements/change/gr-reviewer-list/gr-reviewer-list.ts
+++ b/polygerrit-ui/app/elements/change/gr-reviewer-list/gr-reviewer-list.ts
@@ -35,12 +35,14 @@
   Reviewers,
   AccountId,
   DetailedLabelInfo,
+  EmailAddress,
 } from '../../../types/common';
 import {PolymerDeepPropertyChange} from '@polymer/polymer/interfaces';
 import {GrAccountChip} from '../../shared/gr-account-chip/gr-account-chip';
 import {RestApiService} from '../../../services/services/gr-rest-api/gr-rest-api';
 import {hasOwnProperty} from '../../../utils/common-util';
 import {isRemovableReviewer} from '../../../utils/change-util';
+import {ReviewerState} from '../../../constants/constants';
 
 export interface GrReviewerList {
   $: {
@@ -262,7 +264,7 @@
     if (!target.account || !this.change) {
       return;
     }
-    const accountID = target.account._account_id;
+    const accountID = target.account._account_id || target.account.email;
     this.disabled = true;
     if (!accountID) return;
     this._xhrPromise = this._removeReviewer(accountID)
@@ -272,12 +274,15 @@
           return response;
         }
         if (!this.change || !this.change.reviewers) return;
-        const reviewers: {[type: string]: AccountInfo[] | undefined} = this
-          .change!.reviewers;
-        for (const type of ['REVIEWER', 'CC']) {
-          reviewers[type] = reviewers[type] || [];
-          for (let i = 0; i < reviewers[type]!.length; i++) {
-            if (reviewers[type]![i]._account_id === accountID) {
+        const reviewers = this.change.reviewers;
+        for (const type of [ReviewerState.REVIEWER, ReviewerState.CC]) {
+          const reviewerStateByType = reviewers[type] || [];
+          reviewers[type] = reviewerStateByType;
+          for (let i = 0; i < reviewerStateByType.length; i++) {
+            if (
+              reviewerStateByType[i]._account_id === accountID ||
+              reviewerStateByType[i].email === accountID
+            ) {
               this.splice('change.reviewers.' + type, i, 1);
               break;
             }
@@ -316,7 +321,7 @@
     this._displayedReviewers = this._reviewers;
   }
 
-  _removeReviewer(id: AccountId): Promise<Response | undefined> {
+  _removeReviewer(id: AccountId | EmailAddress): Promise<Response | undefined> {
     if (!this.change) return Promise.resolve(undefined);
     return this.$.restAPI.removeChangeReviewer(this.change._number, id);
   }
diff --git a/polygerrit-ui/app/elements/change/gr-reviewer-list/gr-reviewer-list_test.js b/polygerrit-ui/app/elements/change/gr-reviewer-list/gr-reviewer-list_test.js
index ad9af30..d29abfc 100644
--- a/polygerrit-ui/app/elements/change/gr-reviewer-list/gr-reviewer-list_test.js
+++ b/polygerrit-ui/app/elements/change/gr-reviewer-list/gr-reviewer-list_test.js
@@ -116,6 +116,101 @@
     }
   });
 
+  suite('_handleRemove', () => {
+    let removeReviewerStub;
+    let reviewersChangedSpy;
+
+    const reviewerWithId = {
+      _account_id: 2,
+      name: 'Some name',
+    };
+
+    const reviewerWithIdAndEmail = {
+      _account_id: 4,
+      name: 'Some other name',
+      email: 'example@',
+    };
+
+    const reviewerWithEmailOnly = {
+      email: 'example2@example',
+    };
+
+    let chips;
+
+    setup(() => {
+      removeReviewerStub = sinon
+          .stub(element, '_removeReviewer')
+          .returns(Promise.resolve(new Response({status: 200})));
+      element.mutable = true;
+
+      const allReviewers = [
+        reviewerWithId,
+        reviewerWithIdAndEmail,
+        reviewerWithEmailOnly,
+      ];
+
+      element.change = {
+        owner: {
+          _account_id: 1,
+        },
+        reviewers: {
+          REVIEWER: allReviewers,
+        },
+        removable_reviewers: allReviewers,
+      };
+      flush();
+      chips = Array.from(element.root.querySelectorAll('gr-account-chip'));
+      assert.equal(chips.length, allReviewers.length);
+      reviewersChangedSpy = sinon.spy(element, '_reviewersChanged');
+    });
+
+    test('_handleRemove for account with accountId only', async () => {
+      const accountChip = chips.find(chip =>
+        chip.account._account_id === reviewerWithId._account_id
+      );
+      accountChip._handleRemoveTap(new MouseEvent('click'));
+      await flush();
+      assert.isTrue(removeReviewerStub.calledOnce);
+      assert.isTrue(removeReviewerStub.calledWith(reviewerWithId._account_id));
+      assert.isTrue(reviewersChangedSpy.called);
+      expect(element.change.reviewers.REVIEWER).to.have.deep.members([
+        reviewerWithIdAndEmail,
+        reviewerWithEmailOnly,
+      ]);
+    });
+
+    test('_handleRemove for account with accountId and email', async () => {
+      const accountChip = chips.find(chip =>
+        chip.account._account_id === reviewerWithIdAndEmail._account_id
+      );
+      accountChip._handleRemoveTap(new MouseEvent('click'));
+      await flush();
+      assert.isTrue(removeReviewerStub.calledOnce);
+      assert.isTrue(
+          removeReviewerStub.calledWith(reviewerWithIdAndEmail._account_id));
+      assert.isTrue(reviewersChangedSpy.called);
+      expect(element.change.reviewers.REVIEWER).to.have.deep.members([
+        reviewerWithId,
+        reviewerWithEmailOnly,
+      ]);
+    });
+
+    test('_handleRemove for account with email only', async () => {
+      const accountChip = chips.find(
+          chip => chip.account.email === reviewerWithEmailOnly.email
+      );
+      accountChip._handleRemoveTap(new MouseEvent('click'));
+      await flush();
+      assert.isTrue(removeReviewerStub.calledOnce);
+      assert.isTrue(removeReviewerStub.calledWith(reviewerWithEmailOnly.email));
+      assert.isTrue(reviewersChangedSpy.called);
+      expect(element.change.reviewers.REVIEWER).to.have.deep.members([
+        reviewerWithId,
+        reviewerWithIdAndEmail,
+      ]);
+    });
+  });
+
   test('tracking reviewers and ccs', () => {
     let counter = 0;
     function makeAccount() {
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 33c1346..38c3c9c 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
@@ -131,15 +131,20 @@
     return this._robotComments;
   }
 
-  findCommentById(commentId: UrlEncodedCommentId): Comment | undefined {
-    const findComment = (comments: {[path: string]: CommentBasics[]}) => {
+  findCommentById(commentId?: UrlEncodedCommentId): UIComment | undefined {
+    if (!commentId) return undefined;
+    const findComment = (comments: {[path: string]: UIComment[]}) => {
       let comment;
       for (const path of Object.keys(comments)) {
         comment = comment || comments[path].find(c => c.id === commentId);
       }
       return comment;
     };
-    return findComment(this._comments) || findComment(this._robotComments);
+    return (
+      findComment(this._comments) ||
+      findComment(this._robotComments) ||
+      findComment(this._drafts)
+    );
   }
 
   /**
@@ -580,14 +585,14 @@
 export const _testOnly_findCommentById =
   ChangeComments.prototype.findCommentById;
 
-interface GrCommentApi {
+export interface GrCommentApi {
   $: {
     restAPI: RestApiService & Element;
   };
 }
 
 @customElement('gr-comment-api')
-class GrCommentApi extends GestureEventListeners(
+export class GrCommentApi extends GestureEventListeners(
   LegacyElementMixin(PolymerElement)
 ) {
   static get template() {
diff --git a/polygerrit-ui/app/elements/diff/gr-diff-host/gr-diff-host.ts b/polygerrit-ui/app/elements/diff/gr-diff-host/gr-diff-host.ts
index e7c9a4a..4f94550 100644
--- a/polygerrit-ui/app/elements/diff/gr-diff-host/gr-diff-host.ts
+++ b/polygerrit-ui/app/elements/diff/gr-diff-host/gr-diff-host.ts
@@ -34,7 +34,6 @@
 import {
   Comment,
   isDraft,
-  PatchSetFile,
   sortComments,
   UIComment,
 } from '../../../utils/comment-util';
@@ -70,6 +69,7 @@
 import {FilesWebLinks} from '../gr-patch-range-select/gr-patch-range-select';
 import {LineNumber} from '../gr-diff/gr-diff-line';
 import {GrCommentThread} from '../../shared/gr-comment-thread/gr-comment-thread';
+import {PatchSetFile} from '../../../types/types';
 
 const MSG_EMPTY_BLAME = 'No blame information for this diff.';
 
diff --git a/polygerrit-ui/app/elements/diff/gr-diff-view/gr-diff-view.js b/polygerrit-ui/app/elements/diff/gr-diff-view/gr-diff-view.js
deleted file mode 100644
index fa31d09..0000000
--- a/polygerrit-ui/app/elements/diff/gr-diff-view/gr-diff-view.js
+++ /dev/null
@@ -1,1593 +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 '@polymer/iron-dropdown/iron-dropdown.js';
-import '@polymer/iron-input/iron-input.js';
-import '../../../styles/shared-styles.js';
-import '../../shared/gr-button/gr-button.js';
-import '../../shared/gr-dropdown/gr-dropdown.js';
-import '../../shared/gr-dropdown-list/gr-dropdown-list.js';
-import '../../shared/gr-icons/gr-icons.js';
-import '../../shared/gr-rest-api-interface/gr-rest-api-interface.js';
-import '../../shared/gr-select/gr-select.js';
-import '../../shared/revision-info/revision-info.js';
-import '../gr-comment-api/gr-comment-api.js';
-import '../gr-diff-cursor/gr-diff-cursor.js';
-import '../gr-apply-fix-dialog/gr-apply-fix-dialog.js';
-import '../gr-diff-host/gr-diff-host.js';
-import '../gr-diff-mode-selector/gr-diff-mode-selector.js';
-import '../gr-diff-preferences-dialog/gr-diff-preferences-dialog.js';
-import '../gr-patch-range-select/gr-patch-range-select.js';
-import {dom} 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-diff-view_html.js';
-import {KeyboardShortcutMixin, Shortcut} from '../../../mixins/keyboard-shortcut-mixin/keyboard-shortcut-mixin.js';
-import {GrCountStringFormatter} from '../../shared/gr-count-string-formatter/gr-count-string-formatter.js';
-import {GerritNav} from '../../core/gr-navigation/gr-navigation.js';
-import {RevisionInfo} from '../../shared/revision-info/revision-info.js';
-import {appContext} from '../../../services/app-context.js';
-import {
-  computeAllPatchSets,
-  computeLatestPatchNum,
-  patchNumEquals,
-  SPECIAL_PATCH_SET_NUM,
-} from '../../../utils/patch-set-util.js';
-import {
-  addUnmodifiedFiles, computeDisplayPath, computeTruncatedPath,
-  isMagicPath, specialFilePathCompare,
-} from '../../../utils/path-list-util.js';
-import {changeBaseURL, changeIsOpen} from '../../../utils/change-util.js';
-import {Side} from '../../../constants/constants.js';
-
-const ERR_REVIEW_STATUS = 'Couldn’t change file review status.';
-const MSG_LOADING_BLAME = 'Loading blame...';
-const MSG_LOADED_BLAME = 'Blame loaded';
-
-const PARENT = 'PARENT';
-
-const DiffSides = {
-  LEFT: 'left',
-  RIGHT: 'right',
-};
-
-const DiffViewMode = {
-  SIDE_BY_SIDE: 'SIDE_BY_SIDE',
-  UNIFIED: 'UNIFIED_DIFF',
-};
-
-/**
- * @extends PolymerElement
- */
-class GrDiffView extends KeyboardShortcutMixin(
-    GestureEventListeners(LegacyElementMixin(PolymerElement))) {
-  static get template() { return htmlTemplate; }
-
-  static get is() { return 'gr-diff-view'; }
-  /**
-   * Fired when the title of the page should change.
-   *
-   * @event title-change
-   */
-
-  /**
-   * Fired when user tries to navigate away while comments are pending save.
-   *
-   * @event show-alert
-   */
-
-  static get properties() {
-    return {
-    /**
-     * URL params passed from the router.
-     */
-      params: {
-        type: Object,
-        observer: '_paramsChanged',
-      },
-      keyEventTarget: {
-        type: Object,
-        value() { return document.body; },
-      },
-      /**
-       * @type {{ diffMode: (string|undefined) }}
-       */
-      changeViewState: {
-        type: Object,
-        notify: true,
-        value() { return {}; },
-        observer: '_changeViewStateChanged',
-      },
-      disableDiffPrefs: {
-        type: Boolean,
-        value: false,
-      },
-      _diffPrefsDisabled: {
-        type: Boolean,
-        computed: '_computeDiffPrefsDisabled(disableDiffPrefs, _loggedIn)',
-      },
-      /** @type {?} */
-      _patchRange: Object,
-      /** @type {?} */
-      _commitRange: Object,
-      /**
-       * @type {{
-       *  subject: string,
-       *  project: string,
-       *  revisions: string,
-       * }}
-       */
-      _change: Object,
-      /** @type {?} */
-      _changeComments: Object,
-      _changeNum: String,
-      /**
-       * This is a DiffInfo object.
-       * This is retrieved and owned by a child component.
-       */
-      _diff: Object,
-      // An array specifically formatted to be used in a gr-dropdown-list
-      // element for selected a file to view.
-      _formattedFiles: {
-        type: Array,
-        computed: '_formatFilesForDropdown(_files, ' +
-          '_patchRange.patchNum, _changeComments)',
-      },
-      // An sorted array of files, as returned by the rest API.
-      _fileList: {
-        type: Array,
-        computed: '_getSortedFileList(_files)',
-      },
-      /**
-       * Contains information about files as returned by the rest API.
-       *
-       * @type {{ sortedFileList: Array<string>, changeFilesByPath: Object }}
-       */
-      _files: {
-        type: Object,
-        value() { return {sortedFileList: [], changeFilesByPath: {}}; },
-      },
-
-      /** @type {Gerrit.FileRange} */
-      _file: {
-        type: Object,
-        computed: '_getCurrentFile(_files, _path)',
-      },
-
-      _path: {
-        type: String,
-        observer: '_pathChanged',
-      },
-      _fileNum: {
-        type: Number,
-        computed: '_computeFileNum(_path, _formattedFiles)',
-      },
-      _loggedIn: {
-        type: Boolean,
-        value: false,
-      },
-      _loading: {
-        type: Boolean,
-        value: true,
-      },
-      _prefs: Object,
-      _projectConfig: Object,
-      _userPrefs: Object,
-      _diffMode: {
-        type: String,
-        computed: '_getDiffViewMode(changeViewState.diffMode, _userPrefs)',
-      },
-      _isImageDiff: Boolean,
-      // The return type is FilesWebLinks from gr-patch-range-select.
-      _filesWeblinks: Object,
-
-      /**
-       * Map of paths in the current change and patch range that have comments
-       * or drafts or robot comments.
-       */
-      _commentMap: Object,
-
-      _commentsForDiff: Object,
-
-      /**
-       * Object to contain the path of the next and previous file in the current
-       * change and patch range that has comments.
-       */
-      _commentSkips: {
-        type: Object,
-        computed: '_computeCommentSkips(_commentMap, _fileList, _path)',
-      },
-      _editMode: {
-        type: Boolean,
-        computed: '_computeEditMode(_patchRange.*)',
-      },
-      _isBlameLoaded: Boolean,
-      _isBlameLoading: {
-        type: Boolean,
-        value: false,
-      },
-      _allPatchSets: {
-        type: Array,
-        computed: '_computeAllPatchSets(_change, _change.revisions.*)',
-      },
-      _revisionInfo: {
-        type: Object,
-        computed: '_getRevisionInfo(_change)',
-      },
-      _reviewedFiles: {
-        type: Object,
-        value: () => new Set(),
-      },
-      // line number on the diff which should be scrolled to upon loading
-      _focusLineNum: Number,
-    };
-  }
-
-  static get observers() {
-    return [
-      '_getProjectConfig(_change.project)',
-      '_getFiles(_changeNum, _patchRange.*, _changeComments)',
-      '_setReviewedObserver(_loggedIn, params.*, _prefs, _patchRange.*)',
-      '_recomputeComments(_files.changeFilesByPath,' +
-      '_path, _patchRange, _projectConfig)',
-    ];
-  }
-
-  get keyBindings() {
-    return {
-      esc: '_handleEscKey',
-    };
-  }
-
-  keyboardShortcuts() {
-    return {
-      [Shortcut.LEFT_PANE]: '_handleLeftPane',
-      [Shortcut.RIGHT_PANE]: '_handleRightPane',
-      [Shortcut.NEXT_LINE]: '_handleNextLineOrFileWithComments',
-      [Shortcut.PREV_LINE]: '_handlePrevLineOrFileWithComments',
-      [Shortcut.VISIBLE_LINE]: '_handleVisibleLine',
-      [Shortcut.NEXT_FILE_WITH_COMMENTS]:
-          '_handleNextLineOrFileWithComments',
-      [Shortcut.PREV_FILE_WITH_COMMENTS]:
-          '_handlePrevLineOrFileWithComments',
-      [Shortcut.NEW_COMMENT]: '_handleNewComment',
-      [Shortcut.SAVE_COMMENT]: null, // DOC_ONLY binding
-      [Shortcut.NEXT_FILE]: '_handleNextFile',
-      [Shortcut.PREV_FILE]: '_handlePrevFile',
-      [Shortcut.NEXT_CHUNK]: '_handleNextChunkOrCommentThread',
-      [Shortcut.NEXT_COMMENT_THREAD]: '_handleNextChunkOrCommentThread',
-      [Shortcut.PREV_CHUNK]: '_handlePrevChunkOrCommentThread',
-      [Shortcut.PREV_COMMENT_THREAD]: '_handlePrevChunkOrCommentThread',
-      [Shortcut.OPEN_REPLY_DIALOG]:
-          '_handleOpenReplyDialogOrToggleLeftPane',
-      [Shortcut.TOGGLE_LEFT_PANE]:
-          '_handleOpenReplyDialogOrToggleLeftPane',
-      [Shortcut.OPEN_DOWNLOAD_DIALOG]:
-          '_handleOpenDownloadDialog',
-      [Shortcut.UP_TO_CHANGE]: '_handleUpToChange',
-      [Shortcut.OPEN_DIFF_PREFS]: '_handleCommaKey',
-      [Shortcut.TOGGLE_DIFF_MODE]: '_handleToggleDiffMode',
-      [Shortcut.TOGGLE_FILE_REVIEWED]: '_throttledToggleFileReviewed',
-      [Shortcut.EXPAND_ALL_DIFF_CONTEXT]: '_handleExpandAllDiffContext',
-      [Shortcut.NEXT_UNREVIEWED_FILE]: '_handleNextUnreviewedFile',
-      [Shortcut.TOGGLE_BLAME]: '_handleToggleBlame',
-      [Shortcut.TOGGLE_HIDE_ALL_COMMENT_THREADS]:
-          '_handleToggleHideAllCommentThreads',
-      [Shortcut.DIFF_AGAINST_BASE]: '_handleDiffAgainstBase',
-      [Shortcut.DIFF_AGAINST_LATEST]: '_handleDiffAgainstLatest',
-      [Shortcut.DIFF_BASE_AGAINST_LEFT]: '_handleDiffBaseAgainstLeft',
-      [Shortcut.DIFF_RIGHT_AGAINST_LATEST]:
-        '_handleDiffRightAgainstLatest',
-      [Shortcut.DIFF_BASE_AGAINST_LATEST]:
-        '_handleDiffBaseAgainstLatest',
-
-      // Final two are actually handled by gr-comment-thread.
-      [Shortcut.EXPAND_ALL_COMMENT_THREADS]: null,
-      [Shortcut.COLLAPSE_ALL_COMMENT_THREADS]: null,
-    };
-  }
-
-  constructor() {
-    super();
-    this.reporting = appContext.reportingService;
-    this.flagsService = appContext.flagsService;
-  }
-
-  connectedCallback() {
-    super.connectedCallback();
-    this._throttledToggleFileReviewed = this._throttleWrap(e =>
-      this._handleToggleFileReviewed(e));
-  }
-
-  /** @override */
-  attached() {
-    super.attached();
-    this._getLoggedIn().then(loggedIn => {
-      this._loggedIn = loggedIn;
-    });
-
-    this.addEventListener('open-fix-preview',
-        e => this._onOpenFixPreview(e));
-    this.$.cursor.push('diffs', this.$.diffHost);
-    this._onRenderHandler = () => {
-      this.$.cursor.reInitCursor();
-    };
-    this.$.diffHost.addEventListener('render', this._onRenderHandler);
-  }
-
-  detached() {
-    this.$.diffHost.removeEventListener('render', this._onRenderHandler);
-  }
-
-  _getLoggedIn() {
-    return this.$.restAPI.getLoggedIn();
-  }
-
-  _getProjectConfig(project) {
-    if (!project) return;
-    return this.$.restAPI.getProjectConfig(project).then(
-        config => {
-          this._projectConfig = config;
-        });
-  }
-
-  _getChangeDetail(changeNum) {
-    return this.$.restAPI.getDiffChangeDetail(changeNum).then(change => {
-      this._change = change;
-      return change;
-    });
-  }
-
-  _getChangeEdit(changeNum) {
-    return this.$.restAPI.getChangeEdit(this._changeNum);
-  }
-
-  _getSortedFileList(files) {
-    if (!files) return [];
-    return files.sortedFileList;
-  }
-
-  /**
-   * @param {!Object} files
-   * @param {string} path
-   * @returns {!Gerrit.FileRange}
-   */
-  _getCurrentFile(files, path) {
-    if ([files, path].includes(undefined)) return;
-    const fileInfo = files.changeFilesByPath[path];
-    const fileRange = {path};
-    if (fileInfo && fileInfo.old_path) {
-      fileRange.basePath = fileInfo.old_path;
-    }
-    return fileRange;
-  }
-
-  _getFiles(changeNum, patchRangeRecord, changeComments) {
-    // Polymer 2: check for undefined
-    if ([changeNum, patchRangeRecord, patchRangeRecord.base, changeComments]
-        .some(arg => arg === undefined)) {
-      return Promise.resolve();
-    }
-
-    if (!patchRangeRecord.base.patchNum) {
-      return Promise.resolve();
-    }
-
-    const patchRange = patchRangeRecord.base;
-    return this.$.restAPI.getChangeFiles(
-        changeNum, patchRange).then(changeFiles => {
-      if (!changeFiles) return;
-      const commentedPaths = changeComments.getPaths(patchRange);
-      const files = {...changeFiles};
-      addUnmodifiedFiles(files, commentedPaths);
-      this._files = {
-        sortedFileList: Object.keys(files).sort(specialFilePathCompare),
-        changeFilesByPath: files,
-      };
-    });
-  }
-
-  _getDiffPreferences() {
-    return this.$.restAPI.getDiffPreferences().then(prefs => {
-      this._prefs = prefs;
-    });
-  }
-
-  _getPreferences() {
-    return this.$.restAPI.getPreferences();
-  }
-
-  _getWindowWidth() {
-    return window.innerWidth;
-  }
-
-  _handleReviewedChange(e) {
-    this._setReviewed(dom(e).rootTarget.checked);
-  }
-
-  _setReviewed(reviewed) {
-    if (this._editMode) { return; }
-    this.$.reviewed.checked = reviewed;
-    if (!this._patchRange.patchNum) return;
-    this._saveReviewedState(reviewed).catch(err => {
-      this.dispatchEvent(new CustomEvent('show-alert', {
-        detail: {message: ERR_REVIEW_STATUS},
-        composed: true, bubbles: true,
-      }));
-      throw err;
-    });
-  }
-
-  _saveReviewedState(reviewed) {
-    return this.$.restAPI.saveFileReviewed(this._changeNum,
-        this._patchRange.patchNum, this._path, reviewed);
-  }
-
-  _handleToggleFileReviewed(e) {
-    if (this.shouldSuppressKeyboardShortcut(e) ||
-        this.modifierPressed(e)) { return; }
-
-    e.preventDefault();
-    this._setReviewed(!this.$.reviewed.checked);
-  }
-
-  _handleEscKey(e) {
-    if (this.shouldSuppressKeyboardShortcut(e) ||
-        this.modifierPressed(e)) { return; }
-
-    e.preventDefault();
-    this.$.diffHost.displayLine = false;
-  }
-
-  _handleLeftPane(e) {
-    if (this.shouldSuppressKeyboardShortcut(e)) { return; }
-
-    e.preventDefault();
-    this.$.cursor.moveLeft();
-  }
-
-  _handleRightPane(e) {
-    if (this.shouldSuppressKeyboardShortcut(e)) { return; }
-
-    e.preventDefault();
-    this.$.cursor.moveRight();
-  }
-
-  _handlePrevLineOrFileWithComments(e) {
-    if (this.shouldSuppressKeyboardShortcut(e)) { return; }
-    if (e.detail.keyboardEvent.shiftKey &&
-        e.detail.keyboardEvent.keyCode === 75) { // 'K'
-      this._moveToPreviousFileWithComment();
-      return;
-    }
-    if (this.modifierPressed(e)) { return; }
-
-    e.preventDefault();
-    this.$.diffHost.displayLine = true;
-    this.$.cursor.moveUp();
-  }
-
-  _handleVisibleLine(e) {
-    if (this.shouldSuppressKeyboardShortcut(e)) { return; }
-
-    e.preventDefault();
-    this.$.cursor.moveToVisibleArea();
-  }
-
-  _onOpenFixPreview(e) {
-    this.$.applyFixDialog.open(e);
-  }
-
-  _handleNextLineOrFileWithComments(e) {
-    if (this.shouldSuppressKeyboardShortcut(e)) { return; }
-    if (e.detail.keyboardEvent.shiftKey &&
-        e.detail.keyboardEvent.keyCode === 74) { // 'J'
-      this._moveToNextFileWithComment();
-      return;
-    }
-    if (this.modifierPressed(e)) { return; }
-
-    e.preventDefault();
-    this.$.diffHost.displayLine = true;
-    this.$.cursor.moveDown();
-  }
-
-  _moveToPreviousFileWithComment() {
-    if (!this._commentSkips) { return; }
-
-    // If there is no previous diff with comments, then return to the change
-    // view.
-    if (!this._commentSkips.previous) {
-      this._navToChangeView();
-      return;
-    }
-
-    GerritNav.navigateToDiff(this._change, this._commentSkips.previous,
-        this._patchRange.patchNum, this._patchRange.basePatchNum);
-  }
-
-  _moveToNextFileWithComment() {
-    if (!this._commentSkips) { return; }
-
-    // If there is no next diff with comments, then return to the change view.
-    if (!this._commentSkips.next) {
-      this._navToChangeView();
-      return;
-    }
-
-    GerritNav.navigateToDiff(this._change, this._commentSkips.next,
-        this._patchRange.patchNum, this._patchRange.basePatchNum);
-  }
-
-  _handleNewComment(e) {
-    if (this.shouldSuppressKeyboardShortcut(e) ||
-        this.modifierPressed(e)) { return; }
-    e.preventDefault();
-    this.$.cursor.createCommentInPlace();
-  }
-
-  _handlePrevFile(e) {
-    // Check for meta key to avoid overriding native chrome shortcut.
-    if (this.shouldSuppressKeyboardShortcut(e) ||
-        this.getKeyboardEvent(e).metaKey) { return; }
-
-    e.preventDefault();
-    this._navToFile(this._path, this._fileList, -1);
-  }
-
-  _handleNextFile(e) {
-    // Check for meta key to avoid overriding native chrome shortcut.
-    if (this.shouldSuppressKeyboardShortcut(e) ||
-        this.getKeyboardEvent(e).metaKey) { return; }
-
-    e.preventDefault();
-    this._navToFile(this._path, this._fileList, 1);
-  }
-
-  _handleNextChunkOrCommentThread(e) {
-    if (this.shouldSuppressKeyboardShortcut(e)) { return; }
-
-    e.preventDefault();
-    if (e.detail.keyboardEvent.shiftKey) {
-      this.$.cursor.moveToNextCommentThread();
-    } else {
-      if (this.modifierPressed(e)) { return; }
-      // navigate to next file if key is not being held down
-      this.$.cursor.moveToNextChunk(/* opt_clipToTop = */false,
-          /* opt_navigateToNextFile = */!e.detail.keyboardEvent.repeat);
-    }
-  }
-
-  _handlePrevChunkOrCommentThread(e) {
-    if (this.shouldSuppressKeyboardShortcut(e)) { return; }
-
-    e.preventDefault();
-    if (e.detail.keyboardEvent.shiftKey) {
-      this.$.cursor.moveToPreviousCommentThread();
-    } else {
-      if (this.modifierPressed(e)) { return; }
-      this.$.cursor.moveToPreviousChunk();
-    }
-  }
-
-  _handleOpenReplyDialogOrToggleLeftPane(e) {
-    if (this.shouldSuppressKeyboardShortcut(e)) { return; }
-
-    if (e.detail.keyboardEvent.shiftKey) { // Hide left diff.
-      e.preventDefault();
-      this.$.diffHost.toggleLeftDiff();
-      return;
-    }
-
-    if (this.modifierPressed(e)) { return; }
-
-    if (!this._loggedIn) { return; }
-
-    this.set('changeViewState.showReplyDialog', true);
-    e.preventDefault();
-    this._navToChangeView();
-  }
-
-  _handleOpenDownloadDialog(e) {
-    if (this.shouldSuppressKeyboardShortcut(e)) { return; }
-    if (this.modifierPressed(e)) { return; }
-    this.set('changeViewState.showDownloadDialog', true);
-    e.preventDefault();
-    this._navToChangeView();
-  }
-
-  _handleUpToChange(e) {
-    if (this.shouldSuppressKeyboardShortcut(e) ||
-        this.modifierPressed(e)) { return; }
-
-    e.preventDefault();
-    this._navToChangeView();
-  }
-
-  _handleCommaKey(e) {
-    if (this.shouldSuppressKeyboardShortcut(e) ||
-        this.modifierPressed(e)) { return; }
-    if (this._diffPrefsDisabled) { return; }
-
-    e.preventDefault();
-    this.$.diffPreferencesDialog.open();
-  }
-
-  _handleToggleDiffMode(e) {
-    if (this.shouldSuppressKeyboardShortcut(e) ||
-        this.modifierPressed(e)) { return; }
-
-    e.preventDefault();
-    if (this._getDiffViewMode() === DiffViewMode.SIDE_BY_SIDE) {
-      this.$.modeSelect.setMode(DiffViewMode.UNIFIED);
-    } else {
-      this.$.modeSelect.setMode(DiffViewMode.SIDE_BY_SIDE);
-    }
-  }
-
-  _navToChangeView() {
-    if (!this._changeNum || !this._patchRange.patchNum) { return; }
-    this._navigateToChange(
-        this._change,
-        this._patchRange,
-        this._change && this._change.revisions);
-  }
-
-  _navToFile(path, fileList, direction) {
-    const newPath = this._getNavLinkPath(path, fileList, direction);
-    if (!newPath) { return; }
-
-    if (newPath.up) {
-      this._navigateToChange(
-          this._change,
-          this._patchRange,
-          this._change && this._change.revisions);
-      return;
-    }
-
-    GerritNav.navigateToDiff(this._change, newPath.path,
-        this._patchRange.patchNum, this._patchRange.basePatchNum);
-  }
-
-  /**
-   * @param {?string} path The path of the current file being shown.
-   * @param {!Array<string>} fileList The list of files in this change and
-   *     patch range.
-   * @param {number} direction Either 1 (next file) or -1 (prev file).
-   * @param {(number|boolean)} opt_noUp Whether to return to the change view
-   *     when advancing the file goes outside the bounds of fileList.
-   *
-   * @return {?string} The next URL when proceeding in the specified
-   *     direction.
-   */
-  _computeNavLinkURL(change, path, fileList, direction, opt_noUp) {
-    const newPath = this._getNavLinkPath(path, fileList, direction, opt_noUp);
-    if (!newPath) { return null; }
-
-    if (newPath.up) {
-      return this._getChangePath(
-          this._change,
-          this._patchRange,
-          this._change && this._change.revisions);
-    }
-    return this._getDiffUrl(this._change, this._patchRange, newPath.path);
-  }
-
-  _goToEditFile() {
-    // TODO(taoalpha): add a shortcut for editing
-    const cursorAddress = this.$.cursor.getAddress();
-    const editUrl = GerritNav.getEditUrlForDiff(
-        this._change,
-        this._path,
-        this._patchRange.patchNum,
-        cursorAddress && cursorAddress.number
-    );
-    return GerritNav.navigateToRelativeUrl(editUrl);
-  }
-
-  /**
-   * Gives an object representing the target of navigating either left or
-   * right through the change. The resulting object will have one of the
-   * following forms:
-   *   * {path: "<target file path>"} - When another file path should be the
-   *     result of the navigation.
-   *   * {up: true} - When the result of navigating should go back to the
-   *     change view.
-   *   * null - When no navigation is possible for the given direction.
-   *
-   * @param {?string} path The path of the current file being shown.
-   * @param {!Array<string>} fileList The list of files in this change and
-   *     patch range.
-   * @param {number} direction Either 1 (next file) or -1 (prev file).
-   * @param {?number|boolean=} opt_noUp Whether to return to the change view
-   *     when advancing the file goes outside the bounds of fileList.
-   * @return {?Object}
-   */
-  _getNavLinkPath(path, fileList, direction, opt_noUp) {
-    if (!path || !fileList || fileList.length === 0) { return null; }
-
-    let idx = fileList.indexOf(path);
-    if (idx === -1) {
-      const file = direction > 0 ?
-        fileList[0] :
-        fileList[fileList.length - 1];
-      return {path: file};
-    }
-
-    idx += direction;
-    // Redirect to the change view if opt_noUp isn’t truthy and idx falls
-    // outside the bounds of [0, fileList.length).
-    if (idx < 0 || idx > fileList.length - 1) {
-      if (opt_noUp) { return null; }
-      return {up: true};
-    }
-
-    return {path: fileList[idx]};
-  }
-
-  _getReviewedFiles(changeNum, patchNum) {
-    return this.$.restAPI.getReviewedFiles(changeNum, patchNum)
-        .then(files => {
-          this._reviewedFiles = new Set(files);
-          return this._reviewedFiles;
-        });
-  }
-
-  _getReviewedStatus(editMode, changeNum, patchNum, path) {
-    if (editMode) { return Promise.resolve(false); }
-    return this._getReviewedFiles(changeNum, patchNum)
-        .then(files => files.has(path));
-  }
-
-  _initLineOfInterestAndCursor(leftSide) {
-    this.$.diffHost.lineOfInterest =
-      this._getLineOfInterest({
-        leftSide,
-      });
-    this._initCursor({
-      leftSide,
-    });
-  }
-
-  _displayDiffBaseAgainstLeftToast() {
-    this.dispatchEvent(new CustomEvent('show-alert', {
-      detail: {
-        // \u2190 = ←
-        message: `Patchset ${this._patchRange.basePatchNum} vs ` +
-          `${this._patchRange.patchNum} selected. Press v + \u2190 to view `
-          + `Base vs ${this._patchRange.basePatchNum}`,
-      },
-      composed: true, bubbles: true,
-    }));
-  }
-
-  _displayDiffAgainstLatestToast(latestPatchNum) {
-    const leftPatchset = patchNumEquals(
-        this._patchRange.basePatchNum, 'PARENT')
-      ? 'Base' : `Patchset ${this._patchRange.basePatchNum}`;
-    this.dispatchEvent(new CustomEvent('show-alert', {
-      detail: {
-        // \u2191 = ↑
-        message: `${leftPatchset} vs
-            ${this._patchRange.patchNum} selected\n. Press v + \u2191 to view
-            ${leftPatchset} vs Patchset ${latestPatchNum}`,
-      },
-      composed: true, bubbles: true,
-    }));
-  }
-
-  _displayToasts() {
-    if (!patchNumEquals(this._patchRange.basePatchNum, 'PARENT')) {
-      this._displayDiffBaseAgainstLeftToast();
-      return;
-    }
-    const latestPatchNum = computeLatestPatchNum(this._allPatchSets);
-    if (!patchNumEquals(this._patchRange.patchNum, latestPatchNum)) {
-      this._displayDiffAgainstLatestToast(latestPatchNum);
-      return;
-    }
-  }
-
-  _initCommitRange() {
-    let commit;
-    let baseCommit;
-    if (!this._patchRange || !this._patchRange.patchNum) return;
-    for (const commitSha in this._change.revisions) {
-      if (!this._change.revisions.hasOwnProperty(commitSha)) continue;
-      const revision = this._change.revisions[commitSha];
-      const patchNum = revision._number.toString();
-      if (patchNum === this._patchRange.patchNum) {
-        commit = commitSha;
-        const commitObj = revision.commit || {};
-        const parents = commitObj.parents || [];
-        if (this._patchRange.basePatchNum === PARENT && parents.length) {
-          baseCommit = parents[parents.length - 1].commit;
-        }
-      } else if (patchNum === this._patchRange.basePatchNum) {
-        baseCommit = commitSha;
-      }
-    }
-    this._commitRange = {commit, baseCommit};
-  }
-
-  _initPatchRange() {
-    let leftSide;
-    if (this.params.commentId) {
-      const comment = this._changeComments.findCommentById(
-          this.params.commentId);
-      if (!comment) {
-        this.dispatchEvent(new CustomEvent('show-alert', {
-          detail: {
-            message: 'comment not found',
-          },
-          composed: true, bubbles: true,
-        }));
-        GerritNav.navigateToChange(this._change);
-        return;
-      }
-      this._path = comment.path;
-      const latestPatchNum = computeLatestPatchNum(this._allPatchSets);
-      if (patchNumEquals(latestPatchNum, comment.patch_set)) {
-        this._patchRange = {
-          patchNum: latestPatchNum,
-          basePatchNum: PARENT,
-        };
-        leftSide = comment.__commentSide === 'left';
-      } else {
-        this._patchRange = {
-          patchNum: latestPatchNum,
-          basePatchNum: comment.patch_set,
-        };
-        // comment is now on the left side since we are showing
-        // comment.patch_set vs latest
-        leftSide = true;
-      }
-      this._focusLineNum = comment.line;
-    } else {
-      if (this.params.path) {
-        this._path = this.params.path;
-      }
-      if (this.params.patchNum) {
-        this._patchRange = {
-          patchNum: this.params.patchNum,
-          basePatchNum: this.params.basePatchNum || PARENT,
-        };
-      }
-      if (this.params.lineNum) {
-        this._focusLineNum = this.params.lineNum;
-        leftSide = this.params.leftSide;
-      }
-    }
-    this._initLineOfInterestAndCursor(leftSide);
-    this._commentMap = this._getPaths(this._patchRange);
-
-    this._commentsForDiff = this._getCommentsForPath(this._path,
-        this._patchRange, this._projectConfig);
-  }
-
-  _isFileUnchanged(diff) {
-    if (!diff || !diff.content) return false;
-    return !diff.content.some(content =>
-      (content.a && !content.common) ||
-        (content.b && !content.common)
-    );
-  }
-
-  _paramsChanged(value) {
-    if (value.view !== GerritNav.View.DIFF) { return; }
-
-    this._change = undefined;
-    this._files = undefined;
-    this._path = undefined;
-    this._patchRange = undefined;
-    this._commitRange = undefined;
-    this._changeComments = undefined;
-    this._focusLineNum = undefined;
-
-    if (value.changeNum && value.project) {
-      this.$.restAPI.setInProjectLookup(value.changeNum, value.project);
-    }
-
-    this._changeNum = value.changeNum;
-    this.classList.remove('hideComments');
-
-    // When navigating away from the page, there is a possibility that the
-    // patch number is no longer a part of the URL (say when navigating to
-    // the top-level change info view) and therefore undefined in `params`.
-    // If route is of type /comment/<commentId>/ then no patchNum is present
-    if (!value.patchNum && !value.commentLink) {
-      console.warn('invalid url, no patchNum found');
-      return;
-    }
-
-    const promises = [];
-
-    promises.push(this._getDiffPreferences());
-
-    promises.push(this._getPreferences().then(prefs => {
-      this._userPrefs = prefs;
-    }));
-
-    promises.push(this._getChangeDetail(this._changeNum));
-    promises.push(this._loadComments());
-
-    promises.push(this._getChangeEdit(this._changeNum));
-
-    this.$.diffHost.cancel();
-    this.$.diffHost.clearDiffContent();
-    this._loading = true;
-    return Promise.all(promises)
-        .then(r => {
-          this._loading = false;
-          this._initPatchRange();
-          this._initCommitRange();
-          this.$.diffHost.comments = this._commentsForDiff;
-          const edit = r[4];
-          if (edit) {
-            this.set('_change.revisions.' + edit.commit.commit, {
-              _number: SPECIAL_PATCH_SET_NUM.EDIT,
-              basePatchNum: edit.base_patch_set_number,
-              commit: edit.commit,
-            });
-          }
-          return this.$.diffHost.reload(true);
-        })
-        .then(() => {
-          this.reporting.diffViewFullyLoaded();
-          // If diff view displayed has not ended yet, it ends here.
-          this.reporting.diffViewDisplayed();
-        })
-        .then(() => {
-          const fileUnchanged = this._isFileUnchanged(this._diff);
-          if (fileUnchanged && value.commentLink) {
-            this.dispatchEvent(new CustomEvent('show-alert', {
-              detail: {
-                message: `File is unchanged between Patchset
-                  ${this._patchRange.basePatchNum} and
-                  ${this._patchRange.patchNum}. Showing diff of Base vs
-                  ${this._patchRange.basePatchNum}`,
-              },
-              composed: true, bubbles: true,
-            }));
-            GerritNav.navigateToDiff(
-                this._change, this._path, this._patchRange.basePatchNum,
-                'PARENT', this._focusLineNum);
-            return;
-          }
-          if (value.commentLink) {
-            this._displayToasts();
-          }
-          // If the blame was loaded for a previous file and user navigates to
-          // another file, then we load the blame for this file too
-          if (this._isBlameLoaded) this._loadBlame();
-        });
-  }
-
-  _changeViewStateChanged(changeViewState) {
-    if (changeViewState.diffMode === null) {
-      // If screen size is small, always default to unified view.
-      this.$.restAPI.getPreferences().then(prefs => {
-        this.set('changeViewState.diffMode', prefs.default_diff_view);
-      });
-    }
-  }
-
-  _setReviewedObserver(_loggedIn, paramsRecord, _prefs, patchRangeRecord) {
-    // Polymer 2: check for undefined
-    if ([_loggedIn, paramsRecord, _prefs, patchRangeRecord,
-      patchRangeRecord.base].includes(
-        undefined)) {
-      return;
-    }
-    const patchRange = patchRangeRecord.base;
-    const params = paramsRecord.base || {};
-    if (!_loggedIn) { return; }
-
-    if (_prefs.manual_review) {
-      // Checkbox state needs to be set explicitly only when manual_review
-      // is specified.
-
-      if (patchRange.patchNum) {
-        this._getReviewedStatus(this.editMode, this._changeNum,
-            patchRange.patchNum, this._path).then(status => {
-          this.$.reviewed.checked = status;
-        });
-      }
-      return;
-    }
-
-    if (params.view === GerritNav.View.DIFF) {
-      this._setReviewed(true);
-    }
-  }
-
-  /**
-   * If the params specify a diff address then configure the diff cursor.
-   */
-  _initCursor(params) {
-    if (this._focusLineNum === undefined) { return; }
-    if (params.leftSide) {
-      this.$.cursor.side = DiffSides.LEFT;
-    } else {
-      this.$.cursor.side = DiffSides.RIGHT;
-    }
-    this.$.cursor.initialLineNumber = this._focusLineNum;
-  }
-
-  _getLineOfInterest(params) {
-    // If there is a line number specified, pass it along to the diff so that
-    // it will not get collapsed.
-    if (!this._focusLineNum) { return null; }
-    return {number: this._focusLineNum, leftSide: params.leftSide};
-  }
-
-  _pathChanged(path) {
-    if (path) {
-      this.dispatchEvent(new CustomEvent('title-change', {
-        detail: {title: computeTruncatedPath(path)},
-        composed: true, bubbles: true,
-      }));
-    }
-
-    if (!this._fileList || this._fileList.length == 0) { return; }
-
-    this.set('changeViewState.selectedFileIndex',
-        this._fileList.indexOf(path));
-  }
-
-  _getDiffUrl(change, patchRange, path) {
-    if ([change, patchRange, path].includes(undefined)) {
-      return '';
-    }
-    return GerritNav.getUrlForDiff(change, path, patchRange.patchNum,
-        patchRange.basePatchNum);
-  }
-
-  _patchRangeStr(patchRange) {
-    let patchStr = patchRange.patchNum;
-    if (patchRange.basePatchNum != null &&
-        patchRange.basePatchNum != PARENT) {
-      patchStr = patchRange.basePatchNum + '..' + patchRange.patchNum;
-    }
-    return patchStr;
-  }
-
-  /**
-   * When the latest patch of the change is selected (and there is no base
-   * patch) then the patch range need not appear in the URL. Return a patch
-   * range object with undefined values when a range is not needed.
-   *
-   * @param {!Object} patchRange
-   * @param {!Object} revisions
-   * @return {!Object}
-   */
-  _getChangeUrlRange(patchRange, revisions) {
-    let patchNum = undefined;
-    let basePatchNum = undefined;
-    let latestPatchNum = -1;
-    for (const rev of Object.values(revisions || {})) {
-      latestPatchNum = Math.max(latestPatchNum, rev._number);
-    }
-    if (patchRange.basePatchNum !== PARENT ||
-        parseInt(patchRange.patchNum, 10) !== latestPatchNum) {
-      patchNum = patchRange.patchNum;
-      basePatchNum = patchRange.basePatchNum;
-    }
-    return {patchNum, basePatchNum};
-  }
-
-  _getChangePath(change, patchRange, revisions) {
-    if ([change, patchRange].includes(undefined)) {
-      return '';
-    }
-    const range = this._getChangeUrlRange(patchRange, revisions);
-    return GerritNav.getUrlForChange(change, range.patchNum,
-        range.basePatchNum);
-  }
-
-  _navigateToChange(change, patchRange, revisions) {
-    const range = this._getChangeUrlRange(patchRange, revisions);
-    GerritNav.navigateToChange(change, range.patchNum, range.basePatchNum);
-  }
-
-  _computeChangePath(change, patchRangeRecord, revisions) {
-    return this._getChangePath(change, patchRangeRecord.base, revisions);
-  }
-
-  _formatFilesForDropdown(files, patchNum, changeComments) {
-    // Polymer 2: check for undefined
-    if ([
-      files,
-      patchNum,
-      changeComments,
-    ].includes(undefined)) {
-      return;
-    }
-
-    if (!files) { return; }
-    const dropdownContent = [];
-    for (const path of files.sortedFileList) {
-      dropdownContent.push({
-        text: computeDisplayPath(path),
-        mobileText: computeTruncatedPath(path),
-        value: path,
-        bottomText: this._computeCommentString(changeComments, patchNum,
-            path, files.changeFilesByPath[path]),
-      });
-    }
-    return dropdownContent;
-  }
-
-  _computeCommentString(changeComments, patchNum, path, changeFileInfo) {
-    const unresolvedCount = changeComments.computeUnresolvedNum({patchNum,
-      path});
-    const commentCount = changeComments.computeCommentCount({patchNum, path});
-    const commentString = GrCountStringFormatter.computePluralString(
-        commentCount, 'comment');
-    const unresolvedString = GrCountStringFormatter.computeString(
-        unresolvedCount, 'unresolved');
-
-    const unmodifiedString = changeFileInfo.status === 'U' ? 'no changes': '';
-
-    return [
-      unmodifiedString,
-      commentString,
-      unresolvedString]
-        .filter(v => v && v.length > 0).join(', ');
-  }
-
-  _computePrefsButtonHidden(prefs, prefsDisabled) {
-    return prefsDisabled || !prefs;
-  }
-
-  _handleFileChange(e) {
-    // This is when it gets set initially.
-    const path = e.detail.value;
-    if (path === this._path) {
-      return;
-    }
-
-    GerritNav.navigateToDiff(this._change, path, this._patchRange.patchNum,
-        this._patchRange.basePatchNum);
-  }
-
-  _handleFileTap(e) {
-    // async is needed so that that the click event is fired before the
-    // dropdown closes (This was a bug for touch devices).
-    this.async(() => {
-      this.$.dropdown.close();
-    }, 1);
-  }
-
-  _handlePatchChange(e) {
-    const {basePatchNum, patchNum} = e.detail;
-    if (patchNumEquals(basePatchNum, this._patchRange.basePatchNum) &&
-        patchNumEquals(patchNum, this._patchRange.patchNum)) { return; }
-    GerritNav.navigateToDiff(
-        this._change, this._path, patchNum, basePatchNum);
-  }
-
-  _handlePrefsTap(e) {
-    e.preventDefault();
-    this.$.diffPreferencesDialog.open();
-  }
-
-  /**
-   * _getDiffViewMode: Get the diff view (side-by-side or unified) based on
-   * the current state.
-   *
-   * The expected behavior is to use the mode specified in the user's
-   * preferences unless they have manually chosen the alternative view or they
-   * are on a mobile device. If the user navigates up to the change view, it
-   * should clear this choice and revert to the preference the next time a
-   * diff is viewed.
-   *
-   * Use side-by-side if the user is not logged in.
-   *
-   * @return {string}
-   */
-  _getDiffViewMode() {
-    if (this.changeViewState.diffMode) {
-      return this.changeViewState.diffMode;
-    } else if (this._userPrefs) {
-      this.set('changeViewState.diffMode', this._userPrefs.default_diff_view);
-      return this._userPrefs.default_diff_view;
-    } else {
-      return 'SIDE_BY_SIDE';
-    }
-  }
-
-  _computeModeSelectHideClass(_diff) {
-    return (!_diff || _diff.binary) ? 'hide' : '';
-  }
-
-  _onLineSelected(e, detail) {
-    if (!this._change) { return; }
-    const number = detail.number;
-    // for on-comment-anchor-tap side can be PARENT/REVISIONS
-    // for on-line-selected side can be left/right
-    const leftSide = detail.side === Side.LEFT || detail.side === 'PARENT';
-    const url = GerritNav.getUrlForDiffById(this._changeNum,
-        this._change.project, this._path, this._patchRange.patchNum,
-        this._patchRange.basePatchNum, number, leftSide);
-    history.replaceState(null, '', url);
-  }
-
-  _computeDownloadDropdownLinks(
-      project, changeNum, patchRange, path, diff) {
-    if (!patchRange || !patchRange.patchNum) { return []; }
-
-    const links = [
-      {
-        url: this._computeDownloadPatchLink(
-            project, changeNum, patchRange, path),
-        name: 'Patch',
-      },
-    ];
-
-    if (diff && diff.meta_a) {
-      let leftPath = path;
-      if (diff.change_type === 'RENAMED') {
-        leftPath = diff.meta_a.name;
-      }
-      links.push(
-          {
-            url: this._computeDownloadFileLink(
-                project, changeNum, patchRange, leftPath, true),
-            name: 'Left Content',
-          }
-      );
-    }
-
-    if (diff && diff.meta_b) {
-      links.push(
-          {
-            url: this._computeDownloadFileLink(
-                project, changeNum, patchRange, path, false),
-            name: 'Right Content',
-          }
-      );
-    }
-
-    return links;
-  }
-
-  _computeDownloadFileLink(
-      project, changeNum, patchRange, path, isBase) {
-    let patchNum = patchRange.patchNum;
-
-    const comparedAgainsParent = patchRange.basePatchNum === 'PARENT';
-
-    if (isBase && !comparedAgainsParent) {
-      patchNum = patchRange.basePatchNum;
-    }
-
-    let url = changeBaseURL(project, changeNum, patchNum) +
-        `/files/${encodeURIComponent(path)}/download`;
-
-    if (isBase && comparedAgainsParent) {
-      url += '?parent=1';
-    }
-
-    return url;
-  }
-
-  _computeDownloadPatchLink(project, changeNum, patchRange, path) {
-    let url = changeBaseURL(project, changeNum, patchRange.patchNum);
-    url += '/patch?zip&path=' + encodeURIComponent(path);
-    return url;
-  }
-
-  _loadComments() {
-    return this.$.commentAPI.loadAll(this._changeNum).then(comments => {
-      this._changeComments = comments;
-    });
-  }
-
-  _recomputeComments(files, path, patchRange, projectConfig) {
-    // Polymer 2: check for undefined
-    if ([
-      files,
-      path,
-      patchRange,
-      projectConfig,
-    ].includes(undefined)) {
-      return undefined;
-    }
-
-    const file = files[path];
-    if (file && file.old_path) {
-      this._commentsForDiff = this._changeComments.getCommentsBySideForFile(
-          {path, basePath: file.old_path},
-          patchRange,
-          projectConfig);
-
-      this.$.diffHost.comments = this._commentsForDiff;
-    }
-  }
-
-  _getPaths(patchRange) {
-    return this._changeComments.getPaths(patchRange);
-  }
-
-  _getCommentsForPath(path, patchRange, projectConfig) {
-    return this._changeComments.getCommentsBySideForPath(path, patchRange,
-        projectConfig);
-  }
-
-  _getDiffDrafts() {
-    return this.$.restAPI.getDiffDrafts(this._changeNum);
-  }
-
-  _computeCommentSkips(commentMap, fileList, path) {
-    // Polymer 2: check for undefined
-    if ([
-      commentMap,
-      fileList,
-      path,
-    ].includes(undefined)) {
-      return undefined;
-    }
-
-    const skips = {previous: null, next: null};
-    if (!fileList.length) { return skips; }
-    const pathIndex = fileList.indexOf(path);
-
-    // Scan backward for the previous file.
-    for (let i = pathIndex - 1; i >= 0; i--) {
-      if (commentMap[fileList[i]]) {
-        skips.previous = fileList[i];
-        break;
-      }
-    }
-
-    // Scan forward for the next file.
-    for (let i = pathIndex + 1; i < fileList.length; i++) {
-      if (commentMap[fileList[i]]) {
-        skips.next = fileList[i];
-        break;
-      }
-    }
-
-    return skips;
-  }
-
-  _computeContainerClass(editMode) {
-    return editMode ? 'editMode' : '';
-  }
-
-  /**
-   * @param {!Object} patchRangeRecord
-   */
-  _computeEditMode(patchRangeRecord) {
-    const patchRange = patchRangeRecord.base || {};
-    return patchNumEquals(patchRange.patchNum, SPECIAL_PATCH_SET_NUM.EDIT);
-  }
-
-  _computeBlameToggleLabel(loaded, loading) {
-    if (loaded) { return 'Hide blame'; }
-    return 'Show blame';
-  }
-
-  _loadBlame() {
-    this._isBlameLoading = true;
-    this.dispatchEvent(new CustomEvent('show-alert', {
-      detail: {message: MSG_LOADING_BLAME},
-      composed: true, bubbles: true,
-    }));
-    this.$.diffHost.loadBlame()
-        .then(() => {
-          this._isBlameLoading = false;
-          this.dispatchEvent(new CustomEvent('show-alert', {
-            detail: {message: MSG_LOADED_BLAME},
-            composed: true, bubbles: true,
-          }));
-        })
-        .catch(() => {
-          this._isBlameLoading = false;
-        });
-  }
-
-  /**
-   * Load and display blame information if it has not already been loaded.
-   * Otherwise hide it.
-   */
-  _toggleBlame() {
-    if (this._isBlameLoaded) {
-      this.$.diffHost.clearBlame();
-      return;
-    }
-    this._loadBlame();
-  }
-
-  _handleToggleBlame(e) {
-    if (this.shouldSuppressKeyboardShortcut(e) ||
-      this.modifierPressed(e)) { return; }
-    this._toggleBlame();
-  }
-
-  _handleToggleHideAllCommentThreads(e) {
-    if (this.shouldSuppressKeyboardShortcut(e) ||
-      this.modifierPressed(e)) { return; }
-    this.toggleClass('hideComments');
-  }
-
-  _handleDiffAgainstBase(e) {
-    if (this.shouldSuppressKeyboardShortcut(e)) { return; }
-    if (patchNumEquals(this._patchRange.basePatchNum,
-        SPECIAL_PATCH_SET_NUM.PARENT)) {
-      this.dispatchEvent(new CustomEvent('show-alert', {
-        detail: {
-          message: 'Base is already selected.',
-        },
-        composed: true, bubbles: true,
-      }));
-      return;
-    }
-    GerritNav.navigateToDiff(
-        this._change, this._path, this._patchRange.patchNum);
-  }
-
-  _handleDiffBaseAgainstLeft(e) {
-    if (this.shouldSuppressKeyboardShortcut(e)) { return; }
-    if (patchNumEquals(this._patchRange.basePatchNum,
-        SPECIAL_PATCH_SET_NUM.PARENT)) {
-      this.dispatchEvent(new CustomEvent('show-alert', {
-        detail: {
-          message: 'Left is already base.',
-        },
-        composed: true, bubbles: true,
-      }));
-      return;
-    }
-    GerritNav.navigateToDiff(this._change, this._path,
-        this._patchRange.basePatchNum);
-  }
-
-  _handleDiffAgainstLatest(e) {
-    if (this.shouldSuppressKeyboardShortcut(e)) { return; }
-
-    const latestPatchNum = computeLatestPatchNum(this._allPatchSets);
-    if (patchNumEquals(this._patchRange.patchNum, latestPatchNum)) {
-      this.dispatchEvent(new CustomEvent('show-alert', {
-        detail: {
-          message: 'Latest is already selected.',
-        },
-        composed: true, bubbles: true,
-      }));
-      return;
-    }
-
-    GerritNav.navigateToDiff(
-        this._change, this._path, latestPatchNum,
-        this._patchRange.basePatchNum);
-  }
-
-  _handleDiffRightAgainstLatest(e) {
-    if (this.shouldSuppressKeyboardShortcut(e)) { return; }
-    const latestPatchNum = computeLatestPatchNum(this._allPatchSets);
-    if (patchNumEquals(this._patchRange.patchNum, latestPatchNum)) {
-      this.dispatchEvent(new CustomEvent('show-alert', {
-        detail: {
-          message: 'Right is already latest.',
-        },
-        composed: true, bubbles: true,
-      }));
-      return;
-    }
-    GerritNav.navigateToDiff(this._change, this._path, latestPatchNum,
-        this._patchRange.patchNum);
-  }
-
-  _handleDiffBaseAgainstLatest(e) {
-    if (this.shouldSuppressKeyboardShortcut(e)) { return; }
-    const latestPatchNum = computeLatestPatchNum(this._allPatchSets);
-    if (patchNumEquals(this._patchRange.patchNum, latestPatchNum) &&
-      patchNumEquals(this._patchRange.basePatchNum,
-          SPECIAL_PATCH_SET_NUM.PARENT)) {
-      this.dispatchEvent(new CustomEvent('show-alert', {
-        detail: {
-          message: 'Already diffing base against latest.',
-        },
-        composed: true, bubbles: true,
-      }));
-      return;
-    }
-    GerritNav.navigateToDiff(this._change, this._path, latestPatchNum);
-  }
-
-  _computeBlameLoaderClass(isImageDiff, path) {
-    return !isMagicPath(path) && !isImageDiff ? 'show' : '';
-  }
-
-  _getRevisionInfo(change) {
-    return new RevisionInfo(change);
-  }
-
-  _computeFileNum(file, files) {
-    // Polymer 2: check for undefined
-    if ([file, files].includes(undefined)) {
-      return undefined;
-    }
-
-    return files.findIndex(({value}) => value === file) + 1;
-  }
-
-  /**
-   * @param {number} fileNum
-   * @param {!Array<string>} files
-   * @return {string}
-   */
-  _computeFileNumClass(fileNum, files) {
-    if (files && fileNum > 0) {
-      return 'show';
-    }
-    return '';
-  }
-
-  _handleExpandAllDiffContext(e) {
-    if (this.shouldSuppressKeyboardShortcut(e)) { return; }
-    this.$.diffHost.expandAllContext();
-  }
-
-  _computeDiffPrefsDisabled(disableDiffPrefs, loggedIn) {
-    return disableDiffPrefs || !loggedIn;
-  }
-
-  _handleNextUnreviewedFile(e) {
-    if (this.shouldSuppressKeyboardShortcut(e)) { return; }
-    this._setReviewed(true);
-    // Ensure that the currently viewed file always appears in unreviewedFiles
-    // so we resolve the right "next" file.
-    const unreviewedFiles = this._fileList
-        .filter(file =>
-          (file === this._path || !this._reviewedFiles.has(file)));
-    this._navToFile(this._path, unreviewedFiles, 1);
-  }
-
-  _handleReloadingDiffPreference() {
-    this._getDiffPreferences();
-  }
-
-  _computeCanEdit(loggedIn, changeChangeRecord) {
-    if ([changeChangeRecord, changeChangeRecord.base]
-        .some(arg => arg === undefined)) {
-      return false;
-    }
-    return loggedIn && changeIsOpen(changeChangeRecord.base);
-  }
-
-  _computeIsLoggedIn(loggedIn) {
-    return loggedIn ? true : false;
-  }
-
-  /**
-   * Wrapper for using in the element template and computed properties
-   */
-  _computeAllPatchSets(change) {
-    return computeAllPatchSets(change);
-  }
-
-  /**
-   * Wrapper for using in the element template and computed properties
-   */
-  _computeDisplayPath(path) {
-    return computeDisplayPath(path);
-  }
-}
-
-customElements.define(GrDiffView.is, GrDiffView);
diff --git a/polygerrit-ui/app/elements/diff/gr-diff-view/gr-diff-view.ts b/polygerrit-ui/app/elements/diff/gr-diff-view/gr-diff-view.ts
new file mode 100644
index 0000000..155da60
--- /dev/null
+++ b/polygerrit-ui/app/elements/diff/gr-diff-view/gr-diff-view.ts
@@ -0,0 +1,1942 @@
+/**
+ * @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 '@polymer/iron-dropdown/iron-dropdown';
+import '@polymer/iron-input/iron-input';
+import '../../../styles/shared-styles';
+import '../../shared/gr-button/gr-button';
+import '../../shared/gr-dropdown/gr-dropdown';
+import '../../shared/gr-dropdown-list/gr-dropdown-list';
+import '../../shared/gr-icons/gr-icons';
+import '../../shared/gr-rest-api-interface/gr-rest-api-interface';
+import '../../shared/gr-select/gr-select';
+import '../../shared/revision-info/revision-info';
+import '../gr-comment-api/gr-comment-api';
+import '../gr-diff-cursor/gr-diff-cursor';
+import '../gr-apply-fix-dialog/gr-apply-fix-dialog';
+import '../gr-diff-host/gr-diff-host';
+import '../gr-diff-mode-selector/gr-diff-mode-selector';
+import '../gr-diff-preferences-dialog/gr-diff-preferences-dialog';
+import '../gr-patch-range-select/gr-patch-range-select';
+import {dom, EventApi} 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-diff-view_html';
+import {
+  CustomKeyboardEvent,
+  KeyboardShortcutMixin,
+  Shortcut,
+} from '../../../mixins/keyboard-shortcut-mixin/keyboard-shortcut-mixin';
+import {GrCountStringFormatter} from '../../shared/gr-count-string-formatter/gr-count-string-formatter';
+import {GerritNav, GerritView} from '../../core/gr-navigation/gr-navigation';
+import {appContext} from '../../../services/app-context';
+import {
+  computeAllPatchSets,
+  computeLatestPatchNum,
+  patchNumEquals,
+  PatchSet,
+} from '../../../utils/patch-set-util';
+import {
+  addUnmodifiedFiles,
+  computeDisplayPath,
+  computeTruncatedPath,
+  isMagicPath,
+  specialFilePathCompare,
+} from '../../../utils/path-list-util';
+import {changeBaseURL, changeIsOpen} from '../../../utils/change-util';
+import {customElement, observe, property} from '@polymer/decorators';
+import {RestApiService} from '../../../services/services/gr-rest-api/gr-rest-api';
+import {GrDiffHost} from '../gr-diff-host/gr-diff-host';
+import {
+  DropdownItem,
+  GrDropdownList,
+} from '../../shared/gr-dropdown-list/gr-dropdown-list';
+import {GrOverlay} from '../../shared/gr-overlay/gr-overlay';
+import {
+  ChangeComments,
+  GrCommentApi,
+  TwoSidesComments,
+} from '../gr-comment-api/gr-comment-api';
+import {GrDiffModeSelector} from '../gr-diff-mode-selector/gr-diff-mode-selector';
+import {
+  ChangeInfo,
+  CommitId,
+  ConfigInfo,
+  DiffInfo,
+  DiffPreferencesInfo,
+  EditInfo,
+  EditPatchSetNum,
+  ElementPropertyDeepChange,
+  FileInfo,
+  NumericChangeId,
+  ParentPatchSetNum,
+  PatchRange,
+  PatchSetNum,
+  PreferencesInfo,
+  RepoName,
+  RevisionInfo,
+} from '../../../types/common';
+import {ChangeViewState, CommitRange, FileRange} from '../../../types/types';
+import {FilesWebLinks} from '../gr-patch-range-select/gr-patch-range-select';
+import {PolymerDeepPropertyChange} from '@polymer/polymer/interfaces';
+import {GrDiffCursor} from '../gr-diff-cursor/gr-diff-cursor';
+import {CommentSide, DiffViewMode, Side} from '../../../constants/constants';
+import {hasOwnProperty} from '../../../utils/common-util';
+import {GrApplyFixDialog} from '../gr-apply-fix-dialog/gr-apply-fix-dialog';
+import {LineOfInterest} from '../gr-diff/gr-diff';
+import {CommentEventDetail} from '../../shared/gr-comment/gr-comment';
+import {RevisionInfo as RevisionInfoObj} from '../../shared/revision-info/revision-info';
+import {CommentMap} from '../../../utils/comment-util';
+import {AppElementParams} from '../../gr-app-types';
+import {FetchParams} from '../../shared/gr-rest-api-interface/gr-rest-apis/gr-rest-api-helper';
+
+const ERR_REVIEW_STATUS = 'Couldn’t change file review status.';
+const MSG_LOADING_BLAME = 'Loading blame...';
+const MSG_LOADED_BLAME = 'Blame loaded';
+
+interface Files {
+  sortedFileList: string[];
+  changeFilesByPath: {[path: string]: FileInfo};
+}
+
+interface CommentSkips {
+  previous: string | null;
+  next: string | null;
+}
+
+export interface GrDiffView {
+  $: {
+    restAPI: RestApiService & Element;
+    commentAPI: GrCommentApi;
+    cursor: GrDiffCursor;
+    diffHost: GrDiffHost;
+    reviewed: HTMLInputElement;
+    dropdown: GrDropdownList;
+    diffPreferencesDialog: GrOverlay;
+    applyFixDialog: GrApplyFixDialog;
+    modeSelect: GrDiffModeSelector;
+  };
+}
+
+@customElement('gr-diff-view')
+export class GrDiffView extends KeyboardShortcutMixin(
+  GestureEventListeners(LegacyElementMixin(PolymerElement))
+) {
+  static get template() {
+    return htmlTemplate;
+  }
+
+  /**
+   * Fired when the title of the page should change.
+   *
+   * @event title-change
+   */
+
+  /**
+   * Fired when user tries to navigate away while comments are pending save.
+   *
+   * @event show-alert
+   */
+
+  @property({type: Object, observer: '_paramsChanged'})
+  params?: AppElementParams;
+
+  @property({type: Object})
+  keyEventTarget: HTMLElement = document.body;
+
+  @property({type: Object, notify: true, observer: '_changeViewStateChanged'})
+  changeViewState: Partial<ChangeViewState> = {};
+
+  @property({type: Boolean})
+  disableDiffPrefs = false;
+
+  @property({
+    type: Boolean,
+    computed: '_computeDiffPrefsDisabled(disableDiffPrefs, _loggedIn)',
+  })
+  _diffPrefsDisabled?: boolean;
+
+  @property({type: Object})
+  _patchRange?: PatchRange;
+
+  @property({type: Object})
+  _commitRange?: CommitRange;
+
+  @property({type: Object})
+  _change?: ChangeInfo;
+
+  @property({type: Object})
+  _changeComments?: ChangeComments;
+
+  @property({type: String})
+  _changeNum?: NumericChangeId;
+
+  @property({type: Object})
+  _diff?: DiffInfo;
+
+  @property({
+    type: Array,
+    computed:
+      '_formatFilesForDropdown(_files, ' +
+      '_patchRange.patchNum, _changeComments)',
+  })
+  _formattedFiles?: DropdownItem[];
+
+  @property({type: Array, computed: '_getSortedFileList(_files)'})
+  _fileList?: string[];
+
+  @property({type: Object})
+  _files: Files = {sortedFileList: [], changeFilesByPath: {}};
+
+  @property({type: Object, computed: '_getCurrentFile(_files, _path)'})
+  _file?: FileInfo;
+
+  @property({type: String, observer: '_pathChanged'})
+  _path?: string;
+
+  @property({type: Number, computed: '_computeFileNum(_path, _formattedFiles)'})
+  _fileNum?: number;
+
+  @property({type: Boolean})
+  _loggedIn = false;
+
+  @property({type: Boolean})
+  _loading = true;
+
+  @property({type: Object})
+  _prefs?: DiffPreferencesInfo;
+
+  @property({type: Object})
+  _projectConfig?: ConfigInfo;
+
+  @property({type: Object})
+  _userPrefs?: PreferencesInfo;
+
+  @property({
+    type: String,
+    computed: '_getDiffViewMode(changeViewState.diffMode, _userPrefs)',
+  })
+  _diffMode?: string;
+
+  @property({type: Boolean})
+  _isImageDiff?: boolean;
+
+  @property({type: Object})
+  _filesWeblinks?: FilesWebLinks;
+
+  @property({type: Object})
+  _commentMap?: CommentMap;
+
+  @property({type: Object})
+  _commentsForDiff?: TwoSidesComments;
+
+  @property({
+    type: Object,
+    computed: '_computeCommentSkips(_commentMap, _fileList, _path)',
+  })
+  _commentSkips?: CommentSkips;
+
+  @property({type: Boolean, computed: '_computeEditMode(_patchRange.*)'})
+  _editMode?: boolean;
+
+  @property({type: Boolean})
+  _isBlameLoaded?: boolean;
+
+  @property({type: Boolean})
+  _isBlameLoading = false;
+
+  @property({
+    type: Array,
+    computed: '_computeAllPatchSets(_change, _change.revisions.*)',
+  })
+  _allPatchSets?: PatchSet[] = [];
+
+  @property({type: Object, computed: '_getRevisionInfo(_change)'})
+  _revisionInfo?: RevisionInfoObj;
+
+  @property({type: Object})
+  _reviewedFiles = new Set<string>();
+
+  @property({type: Number})
+  _focusLineNum?: number;
+
+  get keyBindings() {
+    return {
+      esc: '_handleEscKey',
+    };
+  }
+
+  keyboardShortcuts() {
+    return {
+      [Shortcut.LEFT_PANE]: '_handleLeftPane',
+      [Shortcut.RIGHT_PANE]: '_handleRightPane',
+      [Shortcut.NEXT_LINE]: '_handleNextLineOrFileWithComments',
+      [Shortcut.PREV_LINE]: '_handlePrevLineOrFileWithComments',
+      [Shortcut.VISIBLE_LINE]: '_handleVisibleLine',
+      [Shortcut.NEXT_FILE_WITH_COMMENTS]: '_handleNextLineOrFileWithComments',
+      [Shortcut.PREV_FILE_WITH_COMMENTS]: '_handlePrevLineOrFileWithComments',
+      [Shortcut.NEW_COMMENT]: '_handleNewComment',
+      [Shortcut.SAVE_COMMENT]: null, // DOC_ONLY binding
+      [Shortcut.NEXT_FILE]: '_handleNextFile',
+      [Shortcut.PREV_FILE]: '_handlePrevFile',
+      [Shortcut.NEXT_CHUNK]: '_handleNextChunkOrCommentThread',
+      [Shortcut.NEXT_COMMENT_THREAD]: '_handleNextChunkOrCommentThread',
+      [Shortcut.PREV_CHUNK]: '_handlePrevChunkOrCommentThread',
+      [Shortcut.PREV_COMMENT_THREAD]: '_handlePrevChunkOrCommentThread',
+      [Shortcut.OPEN_REPLY_DIALOG]: '_handleOpenReplyDialogOrToggleLeftPane',
+      [Shortcut.TOGGLE_LEFT_PANE]: '_handleOpenReplyDialogOrToggleLeftPane',
+      [Shortcut.OPEN_DOWNLOAD_DIALOG]: '_handleOpenDownloadDialog',
+      [Shortcut.UP_TO_CHANGE]: '_handleUpToChange',
+      [Shortcut.OPEN_DIFF_PREFS]: '_handleCommaKey',
+      [Shortcut.TOGGLE_DIFF_MODE]: '_handleToggleDiffMode',
+      [Shortcut.TOGGLE_FILE_REVIEWED]: '_throttledToggleFileReviewed',
+      [Shortcut.EXPAND_ALL_DIFF_CONTEXT]: '_handleExpandAllDiffContext',
+      [Shortcut.NEXT_UNREVIEWED_FILE]: '_handleNextUnreviewedFile',
+      [Shortcut.TOGGLE_BLAME]: '_handleToggleBlame',
+      [Shortcut.TOGGLE_HIDE_ALL_COMMENT_THREADS]:
+        '_handleToggleHideAllCommentThreads',
+      [Shortcut.DIFF_AGAINST_BASE]: '_handleDiffAgainstBase',
+      [Shortcut.DIFF_AGAINST_LATEST]: '_handleDiffAgainstLatest',
+      [Shortcut.DIFF_BASE_AGAINST_LEFT]: '_handleDiffBaseAgainstLeft',
+      [Shortcut.DIFF_RIGHT_AGAINST_LATEST]: '_handleDiffRightAgainstLatest',
+      [Shortcut.DIFF_BASE_AGAINST_LATEST]: '_handleDiffBaseAgainstLatest',
+
+      // Final two are actually handled by gr-comment-thread.
+      [Shortcut.EXPAND_ALL_COMMENT_THREADS]: null,
+      [Shortcut.COLLAPSE_ALL_COMMENT_THREADS]: null,
+    };
+  }
+
+  reporting = appContext.reportingService;
+
+  flagsService = appContext.flagsService;
+
+  _throttledToggleFileReviewed?: EventListener;
+
+  _onRenderHandler?: EventListener;
+
+  /** @override */
+  connectedCallback() {
+    super.connectedCallback();
+    this._throttledToggleFileReviewed = this._throttleWrap(e =>
+      this._handleToggleFileReviewed(e as CustomKeyboardEvent)
+    );
+  }
+
+  /** @override */
+  attached() {
+    super.attached();
+    this._getLoggedIn().then(loggedIn => {
+      this._loggedIn = loggedIn;
+    });
+
+    this.addEventListener('open-fix-preview', e =>
+      this._onOpenFixPreview(e as CustomEvent<CommentEventDetail>)
+    );
+    this.$.cursor.push('diffs', this.$.diffHost);
+    this._onRenderHandler = (_: Event) => {
+      this.$.cursor.reInitCursor();
+    };
+    this.$.diffHost.addEventListener('render', this._onRenderHandler);
+  }
+
+  /** @override */
+  detached() {
+    if (this._onRenderHandler) {
+      this.$.diffHost.removeEventListener('render', this._onRenderHandler);
+    }
+  }
+
+  _getLoggedIn() {
+    return this.$.restAPI.getLoggedIn();
+  }
+
+  @observe('_change.project')
+  _getProjectConfig(project?: RepoName) {
+    if (!project) return;
+    return this.$.restAPI.getProjectConfig(project).then(config => {
+      this._projectConfig = config;
+    });
+  }
+
+  _getChangeDetail(changeNum: NumericChangeId) {
+    return this.$.restAPI.getDiffChangeDetail(changeNum).then(change => {
+      if (!change) throw new Error('Missing "change" in API response.');
+      this._change = change;
+      return change;
+    });
+  }
+
+  _getChangeEdit() {
+    if (!this._changeNum) throw new Error('Missing this._changeNum');
+    return this.$.restAPI.getChangeEdit(this._changeNum);
+  }
+
+  _getSortedFileList(files?: Files) {
+    if (!files) return [];
+    return files.sortedFileList;
+  }
+
+  _getCurrentFile(files?: Files, path?: string) {
+    if (!files || !path) return;
+    const fileInfo = files.changeFilesByPath[path];
+    const fileRange: FileRange = {path};
+    if (fileInfo && fileInfo.old_path) {
+      fileRange.basePath = fileInfo.old_path;
+    }
+    return fileRange;
+  }
+
+  @observe('_changeNum', '_patchRange.*', '_changeComments')
+  _getFiles(
+    changeNum: NumericChangeId,
+    patchRangeRecord: PolymerDeepPropertyChange<PatchRange, PatchRange>,
+    changeComments: ChangeComments
+  ) {
+    // Polymer 2: check for undefined
+    if (
+      [changeNum, patchRangeRecord, patchRangeRecord.base, changeComments].some(
+        arg => arg === undefined
+      )
+    ) {
+      return Promise.resolve();
+    }
+
+    if (!patchRangeRecord.base.patchNum) {
+      return Promise.resolve();
+    }
+
+    const patchRange = patchRangeRecord.base;
+    return this.$.restAPI
+      .getChangeFiles(changeNum, patchRange)
+      .then(changeFiles => {
+        if (!changeFiles) return;
+        const commentedPaths = changeComments.getPaths(patchRange);
+        const files = {...changeFiles};
+        addUnmodifiedFiles(files, commentedPaths);
+        this._files = {
+          sortedFileList: Object.keys(files).sort(specialFilePathCompare),
+          changeFilesByPath: files,
+        };
+      });
+  }
+
+  _getDiffPreferences() {
+    return this.$.restAPI.getDiffPreferences().then(prefs => {
+      this._prefs = prefs;
+    });
+  }
+
+  _getPreferences() {
+    return this.$.restAPI.getPreferences();
+  }
+
+  _getWindowWidth() {
+    return window.innerWidth;
+  }
+
+  _handleReviewedChange(e: Event) {
+    this._setReviewed(
+      ((dom(e) as EventApi).rootTarget as HTMLInputElement).checked
+    );
+  }
+
+  _setReviewed(reviewed: boolean) {
+    if (this._editMode) return;
+    this.$.reviewed.checked = reviewed;
+    if (!this._patchRange?.patchNum) return;
+    this._saveReviewedState(reviewed).catch(err => {
+      this.dispatchEvent(
+        new CustomEvent('show-alert', {
+          detail: {message: ERR_REVIEW_STATUS},
+          composed: true,
+          bubbles: true,
+        })
+      );
+      throw err;
+    });
+  }
+
+  _saveReviewedState(reviewed: boolean): Promise<Response | undefined> {
+    if (!this._changeNum) return Promise.resolve(undefined);
+    if (!this._patchRange?.patchNum) return Promise.resolve(undefined);
+    if (!this._path) return Promise.resolve(undefined);
+    return this.$.restAPI.saveFileReviewed(
+      this._changeNum,
+      this._patchRange?.patchNum,
+      this._path,
+      reviewed
+    );
+  }
+
+  _handleToggleFileReviewed(e: CustomKeyboardEvent) {
+    if (this.shouldSuppressKeyboardShortcut(e)) return;
+    if (this.modifierPressed(e)) return;
+
+    e.preventDefault();
+    this._setReviewed(!this.$.reviewed.checked);
+  }
+
+  _handleEscKey(e: CustomKeyboardEvent) {
+    if (this.shouldSuppressKeyboardShortcut(e)) return;
+    if (this.modifierPressed(e)) return;
+
+    e.preventDefault();
+    this.$.diffHost.displayLine = false;
+  }
+
+  _handleLeftPane(e: CustomKeyboardEvent) {
+    if (this.shouldSuppressKeyboardShortcut(e)) return;
+
+    e.preventDefault();
+    this.$.cursor.moveLeft();
+  }
+
+  _handleRightPane(e: CustomKeyboardEvent) {
+    if (this.shouldSuppressKeyboardShortcut(e)) return;
+
+    e.preventDefault();
+    this.$.cursor.moveRight();
+  }
+
+  _handlePrevLineOrFileWithComments(e: CustomKeyboardEvent) {
+    if (this.shouldSuppressKeyboardShortcut(e)) return;
+
+    if (
+      e.detail.keyboardEvent?.shiftKey &&
+      e.detail.keyboardEvent?.keyCode === 75
+    ) {
+      // 'K'
+      this._moveToPreviousFileWithComment();
+      return;
+    }
+    if (this.modifierPressed(e)) {
+      return;
+    }
+
+    e.preventDefault();
+    this.$.diffHost.displayLine = true;
+    this.$.cursor.moveUp();
+  }
+
+  _handleVisibleLine(e: CustomKeyboardEvent) {
+    if (this.shouldSuppressKeyboardShortcut(e)) return;
+
+    e.preventDefault();
+    this.$.cursor.moveToVisibleArea();
+  }
+
+  _onOpenFixPreview(e: CustomEvent<CommentEventDetail>) {
+    this.$.applyFixDialog.open(e);
+  }
+
+  _handleNextLineOrFileWithComments(e: CustomKeyboardEvent) {
+    if (this.shouldSuppressKeyboardShortcut(e)) return;
+
+    if (
+      e.detail.keyboardEvent?.shiftKey &&
+      e.detail.keyboardEvent?.keyCode === 74
+    ) {
+      // 'J'
+      this._moveToNextFileWithComment();
+      return;
+    }
+    if (this.modifierPressed(e)) {
+      return;
+    }
+
+    e.preventDefault();
+    this.$.diffHost.displayLine = true;
+    this.$.cursor.moveDown();
+  }
+
+  _moveToPreviousFileWithComment() {
+    if (!this._commentSkips) return;
+    if (!this._change) return;
+    if (!this._patchRange?.patchNum) return;
+
+    // If there is no previous diff with comments, then return to the change
+    // view.
+    if (!this._commentSkips.previous) {
+      this._navToChangeView();
+      return;
+    }
+
+    GerritNav.navigateToDiff(
+      this._change,
+      this._commentSkips.previous,
+      this._patchRange.patchNum,
+      this._patchRange.basePatchNum
+    );
+  }
+
+  _moveToNextFileWithComment() {
+    if (!this._commentSkips) return;
+    if (!this._change) return;
+    if (!this._patchRange?.patchNum) return;
+
+    // If there is no next diff with comments, then return to the change view.
+    if (!this._commentSkips.next) {
+      this._navToChangeView();
+      return;
+    }
+
+    GerritNav.navigateToDiff(
+      this._change,
+      this._commentSkips.next,
+      this._patchRange.patchNum,
+      this._patchRange.basePatchNum
+    );
+  }
+
+  _handleNewComment(e: CustomKeyboardEvent) {
+    if (this.shouldSuppressKeyboardShortcut(e)) return;
+    if (this.modifierPressed(e)) return;
+
+    e.preventDefault();
+    this.$.cursor.createCommentInPlace();
+  }
+
+  _handlePrevFile(e: CustomKeyboardEvent) {
+    if (this.shouldSuppressKeyboardShortcut(e)) return;
+    // Check for meta key to avoid overriding native chrome shortcut.
+    if (this.getKeyboardEvent(e).metaKey) return;
+    if (!this._path) return;
+    if (!this._fileList) return;
+
+    e.preventDefault();
+    this._navToFile(this._path, this._fileList, -1);
+  }
+
+  _handleNextFile(e: CustomKeyboardEvent) {
+    if (this.shouldSuppressKeyboardShortcut(e)) return;
+    // Check for meta key to avoid overriding native chrome shortcut.
+    if (this.getKeyboardEvent(e).metaKey) return;
+    if (!this._path) return;
+    if (!this._fileList) return;
+
+    e.preventDefault();
+    this._navToFile(this._path, this._fileList, 1);
+  }
+
+  _handleNextChunkOrCommentThread(e: CustomKeyboardEvent) {
+    if (this.shouldSuppressKeyboardShortcut(e)) return;
+
+    e.preventDefault();
+    if (e.detail.keyboardEvent?.shiftKey) {
+      this.$.cursor.moveToNextCommentThread();
+    } else {
+      if (this.modifierPressed(e)) return;
+      // navigate to next file if key is not being held down
+      this.$.cursor.moveToNextChunk(
+        /* opt_clipToTop = */ false,
+        /* opt_navigateToNextFile = */ !e.detail.keyboardEvent?.repeat
+      );
+    }
+  }
+
+  _handlePrevChunkOrCommentThread(e: CustomKeyboardEvent) {
+    if (this.shouldSuppressKeyboardShortcut(e)) return;
+
+    e.preventDefault();
+    if (e.detail.keyboardEvent?.shiftKey) {
+      this.$.cursor.moveToPreviousCommentThread();
+    } else {
+      if (this.modifierPressed(e)) return;
+      this.$.cursor.moveToPreviousChunk();
+    }
+  }
+
+  _handleOpenReplyDialogOrToggleLeftPane(e: CustomKeyboardEvent) {
+    if (this.shouldSuppressKeyboardShortcut(e)) return;
+
+    if (e.detail.keyboardEvent?.shiftKey) {
+      // Hide left diff.
+      e.preventDefault();
+      this.$.diffHost.toggleLeftDiff();
+      return;
+    }
+
+    if (this.modifierPressed(e)) return;
+    if (!this._loggedIn) return;
+
+    this.set('changeViewState.showReplyDialog', true);
+    e.preventDefault();
+    this._navToChangeView();
+  }
+
+  _handleOpenDownloadDialog(e: CustomKeyboardEvent) {
+    if (this.shouldSuppressKeyboardShortcut(e)) return;
+    if (this.modifierPressed(e)) return;
+
+    this.set('changeViewState.showDownloadDialog', true);
+    e.preventDefault();
+    this._navToChangeView();
+  }
+
+  _handleUpToChange(e: CustomKeyboardEvent) {
+    if (this.shouldSuppressKeyboardShortcut(e)) return;
+    if (this.modifierPressed(e)) return;
+
+    e.preventDefault();
+    this._navToChangeView();
+  }
+
+  _handleCommaKey(e: CustomKeyboardEvent) {
+    if (this.shouldSuppressKeyboardShortcut(e)) return;
+    if (this.modifierPressed(e)) return;
+    if (this._diffPrefsDisabled) return;
+
+    e.preventDefault();
+    this.$.diffPreferencesDialog.open();
+  }
+
+  _handleToggleDiffMode(e: CustomKeyboardEvent) {
+    if (this.shouldSuppressKeyboardShortcut(e)) return;
+    if (this.modifierPressed(e)) return;
+
+    e.preventDefault();
+    if (this._getDiffViewMode() === DiffViewMode.SIDE_BY_SIDE) {
+      this.$.modeSelect.setMode(DiffViewMode.UNIFIED);
+    } else {
+      this.$.modeSelect.setMode(DiffViewMode.SIDE_BY_SIDE);
+    }
+  }
+
+  _navToChangeView() {
+    if (!this._changeNum || !this._patchRange?.patchNum) {
+      return;
+    }
+    this._navigateToChange(
+      this._change,
+      this._patchRange,
+      this._change && this._change.revisions
+    );
+  }
+
+  _navToFile(path: string, fileList: string[], direction: -1 | 1) {
+    const newPath = this._getNavLinkPath(path, fileList, direction);
+    if (!newPath?.path) return;
+    if (!this._change) return;
+    if (!this._patchRange) return;
+
+    if (newPath.up) {
+      this._navigateToChange(
+        this._change,
+        this._patchRange,
+        this._change && this._change.revisions
+      );
+      return;
+    }
+
+    GerritNav.navigateToDiff(
+      this._change,
+      newPath.path,
+      this._patchRange.patchNum,
+      this._patchRange.basePatchNum
+    );
+  }
+
+  /**
+   * @param path The path of the current file being shown.
+   * @param fileList The list of files in this change and
+   * patch range.
+   * @param direction Either 1 (next file) or -1 (prev file).
+   * @param opt_noUp Whether to return to the change view
+   * when advancing the file goes outside the bounds of fileList.
+   * @return The next URL when proceeding in the specified
+   * direction.
+   */
+  _computeNavLinkURL(
+    change?: ChangeInfo,
+    path?: string,
+    fileList?: string[],
+    direction?: -1 | 1,
+    opt_noUp?: boolean
+  ) {
+    if (!change) return null;
+    if (!path) return null;
+    if (!fileList) return null;
+    if (!direction) return null;
+
+    const newPath = this._getNavLinkPath(path, fileList, direction, opt_noUp);
+    if (!newPath) {
+      return null;
+    }
+
+    if (newPath.up) {
+      return this._getChangePath(
+        this._change,
+        this._patchRange,
+        this._change && this._change.revisions
+      );
+    }
+    return this._getDiffUrl(this._change, this._patchRange, newPath.path);
+  }
+
+  _goToEditFile() {
+    if (!this._change) return;
+    if (!this._path) return;
+    if (!this._patchRange) return;
+
+    // TODO(taoalpha): add a shortcut for editing
+    const cursorAddress = this.$.cursor.getAddress();
+    const editUrl = GerritNav.getEditUrlForDiff(
+      this._change,
+      this._path,
+      this._patchRange.patchNum,
+      cursorAddress?.number
+    );
+    GerritNav.navigateToRelativeUrl(editUrl);
+  }
+
+  /**
+   * Gives an object representing the target of navigating either left or
+   * right through the change. The resulting object will have one of the
+   * following forms:
+   * * {path: "<target file path>"} - When another file path should be the
+   * result of the navigation.
+   * * {up: true} - When the result of navigating should go back to the
+   * change view.
+   * * null - When no navigation is possible for the given direction.
+   *
+   * @param path The path of the current file being shown.
+   * @param fileList The list of files in this change and
+   * patch range.
+   * @param direction Either 1 (next file) or -1 (prev file).
+   * @param opt_noUp Whether to return to the change view
+   * when advancing the file goes outside the bounds of fileList.
+   */
+  _getNavLinkPath(
+    path: string,
+    fileList: string[],
+    direction: -1 | 1,
+    opt_noUp?: boolean
+  ) {
+    if (!path || !fileList || fileList.length === 0) {
+      return null;
+    }
+
+    let idx = fileList.indexOf(path);
+    if (idx === -1) {
+      const file = direction > 0 ? fileList[0] : fileList[fileList.length - 1];
+      return {path: file};
+    }
+
+    idx += direction;
+    // Redirect to the change view if opt_noUp isn’t truthy and idx falls
+    // outside the bounds of [0, fileList.length).
+    if (idx < 0 || idx > fileList.length - 1) {
+      if (opt_noUp) {
+        return null;
+      }
+      return {up: true};
+    }
+
+    return {path: fileList[idx]};
+  }
+
+  _getReviewedFiles(
+    changeNum?: NumericChangeId,
+    patchNum?: PatchSetNum
+  ): Promise<Set<string>> {
+    if (!changeNum || !patchNum) return Promise.resolve(new Set<string>());
+    return this.$.restAPI.getReviewedFiles(changeNum, patchNum).then(files => {
+      this._reviewedFiles = new Set(files);
+      return this._reviewedFiles;
+    });
+  }
+
+  _getReviewedStatus(
+    editMode?: boolean,
+    changeNum?: NumericChangeId,
+    patchNum?: PatchSetNum,
+    path?: string
+  ) {
+    if (editMode || !path) {
+      return Promise.resolve(false);
+    }
+    return this._getReviewedFiles(changeNum, patchNum).then(files =>
+      files.has(path)
+    );
+  }
+
+  _initLineOfInterestAndCursor(leftSide: boolean) {
+    this.$.diffHost.lineOfInterest = this._getLineOfInterest({
+      leftSide,
+    });
+    this._initCursor({
+      leftSide,
+    });
+  }
+
+  _displayDiffBaseAgainstLeftToast() {
+    if (!this._patchRange) return;
+    this.dispatchEvent(
+      new CustomEvent('show-alert', {
+        detail: {
+          // \u2190 = ←
+          message:
+            `Patchset ${this._patchRange.basePatchNum} vs ` +
+            `${this._patchRange.patchNum} selected. Press v + \u2190 to view ` +
+            `Base vs ${this._patchRange.basePatchNum}`,
+        },
+        composed: true,
+        bubbles: true,
+      })
+    );
+  }
+
+  _displayDiffAgainstLatestToast(latestPatchNum?: PatchSetNum) {
+    if (!this._patchRange) return;
+    const leftPatchset = patchNumEquals(
+      this._patchRange.basePatchNum,
+      ParentPatchSetNum
+    )
+      ? 'Base'
+      : `Patchset ${this._patchRange.basePatchNum}`;
+    this.dispatchEvent(
+      new CustomEvent('show-alert', {
+        detail: {
+          // \u2191 = ↑
+          message: `${leftPatchset} vs
+            ${this._patchRange.patchNum} selected\n. Press v + \u2191 to view
+            ${leftPatchset} vs Patchset ${latestPatchNum}`,
+        },
+        composed: true,
+        bubbles: true,
+      })
+    );
+  }
+
+  _displayToasts() {
+    if (!this._patchRange) return;
+    if (!patchNumEquals(this._patchRange.basePatchNum, ParentPatchSetNum)) {
+      this._displayDiffBaseAgainstLeftToast();
+      return;
+    }
+    const latestPatchNum = computeLatestPatchNum(this._allPatchSets);
+    if (!patchNumEquals(this._patchRange.patchNum, latestPatchNum)) {
+      this._displayDiffAgainstLatestToast(latestPatchNum);
+      return;
+    }
+  }
+
+  _initCommitRange() {
+    let commit: CommitId | undefined;
+    let baseCommit: CommitId | undefined;
+    if (!this._change) return;
+    if (!this._patchRange || !this._patchRange.patchNum) return;
+    for (const commitSha in this._change.revisions) {
+      if (!hasOwnProperty(this._change.revisions, commitSha)) continue;
+      const revision = this._change.revisions[commitSha];
+      const patchNum = revision._number.toString();
+      if (patchNum === this._patchRange.patchNum) {
+        commit = commitSha as CommitId;
+        const commitObj = revision.commit;
+        const parents = commitObj?.parents || [];
+        if (
+          this._patchRange.basePatchNum === ParentPatchSetNum &&
+          parents.length
+        ) {
+          baseCommit = parents[parents.length - 1].commit;
+        }
+      } else if (patchNum === this._patchRange.basePatchNum) {
+        baseCommit = commitSha as CommitId;
+      }
+    }
+    this._commitRange = commit && baseCommit ? {commit, baseCommit} : undefined;
+  }
+
+  _initPatchRange() {
+    let leftSide = false;
+    if (!this._change) return;
+    if (this.params?.view !== GerritView.DIFF) return;
+    if (this.params?.commentId) {
+      const comment = this._changeComments?.findCommentById(
+        this.params.commentId
+      );
+      if (!comment) {
+        this.dispatchEvent(
+          new CustomEvent('show-alert', {
+            detail: {
+              message: 'comment not found',
+            },
+            composed: true,
+            bubbles: true,
+          })
+        );
+        GerritNav.navigateToChange(this._change);
+        return;
+      }
+      this._path = comment.path;
+      const latestPatchNum = computeLatestPatchNum(this._allPatchSets);
+      if (!comment.patch_set) throw new Error('Missing comment.patch_set');
+      if (!latestPatchNum) throw new Error('Missing _allPatchSets');
+      if (patchNumEquals(latestPatchNum, comment.patch_set)) {
+        this._patchRange = {
+          patchNum: latestPatchNum,
+          basePatchNum: ParentPatchSetNum,
+        };
+        leftSide = comment.__commentSide === 'left';
+      } else {
+        this._patchRange = {
+          patchNum: latestPatchNum,
+          basePatchNum: comment.patch_set,
+        };
+        // comment is now on the left side since we are showing
+        // comment.patch_set vs latest
+        leftSide = true;
+      }
+      this._focusLineNum = comment.line;
+    } else {
+      if (this.params.path) {
+        this._path = this.params.path;
+      }
+      if (this.params.patchNum) {
+        this._patchRange = {
+          patchNum: this.params.patchNum,
+          basePatchNum: this.params.basePatchNum || ParentPatchSetNum,
+        };
+      }
+      if (this.params.lineNum) {
+        this._focusLineNum = this.params.lineNum;
+        leftSide = !!this.params.leftSide;
+      }
+    }
+    if (!this._patchRange) throw new Error('Failed to initialize patchRange.');
+    this._initLineOfInterestAndCursor(leftSide);
+    this._commentMap = this._getPaths(this._patchRange);
+
+    this._commentsForDiff = this._getCommentsForPath(
+      this._path,
+      this._patchRange,
+      this._projectConfig
+    );
+  }
+
+  _isFileUnchanged(diff: DiffInfo) {
+    if (!diff || !diff.content) return false;
+    return !diff.content.some(
+      content =>
+        (content.a && !content.common) || (content.b && !content.common)
+    );
+  }
+
+  _paramsChanged(value: AppElementParams) {
+    if (value.view !== GerritView.DIFF) {
+      return;
+    }
+
+    this._change = undefined;
+    this._files = {sortedFileList: [], changeFilesByPath: {}};
+    this._path = undefined;
+    this._patchRange = undefined;
+    this._commitRange = undefined;
+    this._changeComments = undefined;
+    this._focusLineNum = undefined;
+
+    if (value.changeNum && value.project) {
+      this.$.restAPI.setInProjectLookup(value.changeNum, value.project);
+    }
+
+    this._changeNum = value.changeNum;
+    this.classList.remove('hideComments');
+
+    // When navigating away from the page, there is a possibility that the
+    // patch number is no longer a part of the URL (say when navigating to
+    // the top-level change info view) and therefore undefined in `params`.
+    // If route is of type /comment/<commentId>/ then no patchNum is present
+    if (!value.patchNum && !value.commentLink) {
+      console.warn('invalid url, no patchNum found');
+      return;
+    }
+
+    const promises: Promise<unknown>[] = [];
+
+    promises.push(this._getDiffPreferences());
+
+    promises.push(
+      this._getPreferences().then(prefs => {
+        this._userPrefs = prefs;
+      })
+    );
+
+    promises.push(this._getChangeDetail(this._changeNum));
+    promises.push(this._loadComments());
+
+    promises.push(this._getChangeEdit());
+
+    this.$.diffHost.cancel();
+    this.$.diffHost.clearDiffContent();
+    this._loading = true;
+    return Promise.all(promises)
+      .then(r => {
+        this._loading = false;
+        this._initPatchRange();
+        this._initCommitRange();
+        this.$.diffHost.comments = this._commentsForDiff;
+        const edit = r[4] as EditInfo | undefined;
+        if (edit) {
+          this.set(`_change.revisions.${edit.commit.commit}`, {
+            _number: EditPatchSetNum,
+            basePatchNum: edit.base_patch_set_number,
+            commit: edit.commit,
+          });
+        }
+        return this.$.diffHost.reload(true);
+      })
+      .then(() => {
+        this.reporting.diffViewFullyLoaded();
+        // If diff view displayed has not ended yet, it ends here.
+        this.reporting.diffViewDisplayed();
+      })
+      .then(() => {
+        if (!this._diff) throw new Error('Missing this._diff');
+        const fileUnchanged = this._isFileUnchanged(this._diff);
+        if (fileUnchanged && value.commentLink) {
+          if (!this._change) throw new Error('Missing this._change');
+          if (!this._path) throw new Error('Missing this._path');
+          if (!this._patchRange) throw new Error('Missing this._patchRange');
+
+          this.dispatchEvent(
+            new CustomEvent('show-alert', {
+              detail: {
+                message: `File is unchanged between Patchset
+                  ${this._patchRange.basePatchNum} and
+                  ${this._patchRange.patchNum}. Showing diff of Base vs
+                  ${this._patchRange.basePatchNum}`,
+              },
+              composed: true,
+              bubbles: true,
+            })
+          );
+          GerritNav.navigateToDiff(
+            this._change,
+            this._path,
+            this._patchRange.basePatchNum,
+            ParentPatchSetNum,
+            this._focusLineNum
+          );
+          return;
+        }
+        if (value.commentLink) {
+          this._displayToasts();
+        }
+        // If the blame was loaded for a previous file and user navigates to
+        // another file, then we load the blame for this file too
+        if (this._isBlameLoaded) this._loadBlame();
+      });
+  }
+
+  _changeViewStateChanged(changeViewState: Partial<ChangeViewState>) {
+    if (changeViewState.diffMode === null) {
+      // If screen size is small, always default to unified view.
+      this.$.restAPI.getPreferences().then(prefs => {
+        if (prefs) {
+          this.set('changeViewState.diffMode', prefs.default_diff_view);
+        }
+      });
+    }
+  }
+
+  @observe('_loggedIn', 'params.*', '_prefs', '_patchRange.*')
+  _setReviewedObserver(
+    _loggedIn?: boolean,
+    paramsRecord?: ElementPropertyDeepChange<GrDiffView, 'params'>,
+    _prefs?: DiffPreferencesInfo,
+    patchRangeRecord?: ElementPropertyDeepChange<GrDiffView, '_patchRange'>
+  ) {
+    if (_loggedIn === undefined) return;
+    if (paramsRecord === undefined) return;
+    if (_prefs === undefined) return;
+    if (patchRangeRecord === undefined) return;
+    if (patchRangeRecord.base === undefined) return;
+
+    const patchRange = patchRangeRecord.base;
+    if (!_loggedIn) {
+      return;
+    }
+
+    if (_prefs.manual_review) {
+      // Checkbox state needs to be set explicitly only when manual_review
+      // is specified.
+
+      if (patchRange.patchNum) {
+        this._getReviewedStatus(
+          this._editMode,
+          this._changeNum,
+          patchRange.patchNum,
+          this._path
+        ).then((status: boolean) => {
+          this.$.reviewed.checked = status;
+        });
+      }
+      return;
+    }
+
+    if (paramsRecord.base?.view === GerritNav.View.DIFF) {
+      this._setReviewed(true);
+    }
+  }
+
+  /**
+   * If the params specify a diff address then configure the diff cursor.
+   */
+  _initCursor(params: FetchParams) {
+    if (this._focusLineNum === undefined) {
+      return;
+    }
+    if (params.leftSide) {
+      this.$.cursor.side = Side.LEFT;
+    } else {
+      this.$.cursor.side = Side.RIGHT;
+    }
+    this.$.cursor.initialLineNumber = this._focusLineNum;
+  }
+
+  _getLineOfInterest(params: FetchParams): LineOfInterest | undefined {
+    // If there is a line number specified, pass it along to the diff so that
+    // it will not get collapsed.
+    if (!this._focusLineNum) {
+      return undefined;
+    }
+
+    return {number: this._focusLineNum, leftSide: !!params.leftSide};
+  }
+
+  _pathChanged(path: string) {
+    if (path) {
+      this.dispatchEvent(
+        new CustomEvent('title-change', {
+          detail: {title: computeTruncatedPath(path)},
+          composed: true,
+          bubbles: true,
+        })
+      );
+    }
+
+    if (!this._fileList || this._fileList.length === 0) return;
+
+    this.set('changeViewState.selectedFileIndex', this._fileList.indexOf(path));
+  }
+
+  _getDiffUrl(change?: ChangeInfo, patchRange?: PatchRange, path?: string) {
+    if (!change || !patchRange || !path) return '';
+    return GerritNav.getUrlForDiff(
+      change,
+      path,
+      patchRange.patchNum,
+      patchRange.basePatchNum
+    );
+  }
+
+  _patchRangeStr(patchRange: PatchRange) {
+    let patchStr = `${patchRange.patchNum}`;
+    if (
+      patchRange.basePatchNum &&
+      patchRange.basePatchNum !== ParentPatchSetNum
+    ) {
+      patchStr = `${patchRange.basePatchNum}..${patchRange.patchNum}`;
+    }
+    return patchStr;
+  }
+
+  /**
+   * When the latest patch of the change is selected (and there is no base
+   * patch) then the patch range need not appear in the URL. Return a patch
+   * range object with undefined values when a range is not needed.
+   */
+  _getChangeUrlRange(
+    patchRange?: PatchRange,
+    revisions?: {[revisionId: string]: RevisionInfo}
+  ) {
+    let patchNum = undefined;
+    let basePatchNum = undefined;
+    let latestPatchNum = -1;
+    for (const rev of Object.values(revisions || {})) {
+      if (typeof rev._number === 'number') {
+        latestPatchNum = Math.max(latestPatchNum, rev._number);
+      }
+    }
+    if (!patchRange) return {patchNum, basePatchNum};
+    if (
+      patchRange.basePatchNum !== ParentPatchSetNum ||
+      !patchNumEquals(patchRange.patchNum, latestPatchNum as PatchSetNum)
+    ) {
+      patchNum = patchRange.patchNum;
+      basePatchNum = patchRange.basePatchNum;
+    }
+    return {patchNum, basePatchNum};
+  }
+
+  _getChangePath(
+    change?: ChangeInfo,
+    patchRange?: PatchRange,
+    revisions?: {[revisionId: string]: RevisionInfo}
+  ) {
+    if (!change) return '';
+    if (!patchRange) return '';
+
+    const range = this._getChangeUrlRange(patchRange, revisions);
+    return GerritNav.getUrlForChange(
+      change,
+      range.patchNum,
+      range.basePatchNum
+    );
+  }
+
+  _navigateToChange(
+    change?: ChangeInfo,
+    patchRange?: PatchRange,
+    revisions?: {[revisionId: string]: RevisionInfo}
+  ) {
+    if (!change) return;
+    const range = this._getChangeUrlRange(patchRange, revisions);
+    GerritNav.navigateToChange(change, range.patchNum, range.basePatchNum);
+  }
+
+  _computeChangePath(
+    change?: ChangeInfo,
+    patchRangeRecord?: PolymerDeepPropertyChange<PatchRange, PatchRange>,
+    revisions?: {[revisionId: string]: RevisionInfo}
+  ) {
+    if (!patchRangeRecord) return '';
+    return this._getChangePath(change, patchRangeRecord.base, revisions);
+  }
+
+  _formatFilesForDropdown(
+    files?: Files,
+    patchNum?: PatchSetNum,
+    changeComments?: ChangeComments
+  ): DropdownItem[] {
+    if (!files) return [];
+    if (!patchNum) return [];
+    if (!changeComments) return [];
+
+    const dropdownContent: DropdownItem[] = [];
+    for (const path of files.sortedFileList) {
+      dropdownContent.push({
+        text: computeDisplayPath(path),
+        mobileText: computeTruncatedPath(path),
+        value: path,
+        bottomText: this._computeCommentString(
+          changeComments,
+          patchNum,
+          path,
+          files.changeFilesByPath[path]
+        ),
+      });
+    }
+    return dropdownContent;
+  }
+
+  _computeCommentString(
+    changeComments?: ChangeComments,
+    patchNum?: PatchSetNum,
+    path?: string,
+    changeFileInfo?: FileInfo
+  ) {
+    if (!changeComments) return '';
+    if (!path) return '';
+    if (!changeFileInfo) return '';
+
+    const unresolvedCount = changeComments.computeUnresolvedNum({
+      patchNum,
+      path,
+    });
+    const commentCount = changeComments.computeCommentCount({patchNum, path});
+    const commentString = GrCountStringFormatter.computePluralString(
+      commentCount,
+      'comment'
+    );
+    const unresolvedString = GrCountStringFormatter.computeString(
+      unresolvedCount,
+      'unresolved'
+    );
+
+    const unmodifiedString = changeFileInfo.status === 'U' ? 'no changes' : '';
+
+    return [unmodifiedString, commentString, unresolvedString]
+      .filter(v => v && v.length > 0)
+      .join(', ');
+  }
+
+  _computePrefsButtonHidden(
+    prefs?: DiffPreferencesInfo,
+    prefsDisabled?: boolean
+  ) {
+    return prefsDisabled || !prefs;
+  }
+
+  _handleFileChange(e: CustomEvent) {
+    if (!this._change) return;
+    if (!this._patchRange) return;
+
+    // This is when it gets set initially.
+    const path = e.detail.value;
+    if (path === this._path) {
+      return;
+    }
+
+    GerritNav.navigateToDiff(
+      this._change,
+      path,
+      this._patchRange.patchNum,
+      this._patchRange.basePatchNum
+    );
+  }
+
+  _handlePatchChange(e: CustomEvent) {
+    if (!this._change) return;
+    if (!this._path) return;
+    if (!this._patchRange) return;
+
+    const {basePatchNum, patchNum} = e.detail;
+    if (
+      patchNumEquals(basePatchNum, this._patchRange.basePatchNum) &&
+      patchNumEquals(patchNum, this._patchRange.patchNum)
+    ) {
+      return;
+    }
+    GerritNav.navigateToDiff(this._change, this._path, patchNum, basePatchNum);
+  }
+
+  _handlePrefsTap(e: Event) {
+    e.preventDefault();
+    this.$.diffPreferencesDialog.open();
+  }
+
+  /**
+   * _getDiffViewMode: Get the diff view (side-by-side or unified) based on
+   * the current state.
+   *
+   * The expected behavior is to use the mode specified in the user's
+   * preferences unless they have manually chosen the alternative view or they
+   * are on a mobile device. If the user navigates up to the change view, it
+   * should clear this choice and revert to the preference the next time a
+   * diff is viewed.
+   *
+   * Use side-by-side if the user is not logged in.
+   */
+  _getDiffViewMode() {
+    if (this.changeViewState.diffMode) {
+      return this.changeViewState.diffMode;
+    } else if (this._userPrefs) {
+      this.set('changeViewState.diffMode', this._userPrefs.default_diff_view);
+      return this._userPrefs.default_diff_view;
+    } else {
+      return 'SIDE_BY_SIDE';
+    }
+  }
+
+  _computeModeSelectHideClass(diff?: DiffInfo) {
+    return !diff || diff.binary ? 'hide' : '';
+  }
+
+  _onLineSelected(
+    _: Event,
+    detail: {side: Side | CommentSide; number: number}
+  ) {
+    if (!this._change) return;
+    if (!this._path) return;
+    if (!this._changeNum) return;
+    if (!this._patchRange) return;
+
+    const number = detail.number;
+    // for on-comment-anchor-tap side can be PARENT/REVISIONS
+    // for on-line-selected side can be left/right
+    const leftSide =
+      detail.side === Side.LEFT || detail.side === CommentSide.PARENT;
+    const url = GerritNav.getUrlForDiffById(
+      this._changeNum,
+      this._change.project,
+      this._path,
+      this._patchRange.patchNum,
+      this._patchRange.basePatchNum,
+      number,
+      leftSide
+    );
+    history.replaceState(null, '', url);
+  }
+
+  _computeDownloadDropdownLinks(
+    project?: RepoName,
+    changeNum?: NumericChangeId,
+    patchRange?: PatchRange,
+    path?: string,
+    diff?: DiffInfo
+  ) {
+    if (!project) return [];
+    if (!changeNum) return [];
+    if (!patchRange || !patchRange.patchNum) return [];
+    if (!path) return [];
+
+    const links = [
+      {
+        url: this._computeDownloadPatchLink(
+          project,
+          changeNum,
+          patchRange,
+          path
+        ),
+        name: 'Patch',
+      },
+    ];
+
+    if (diff && diff.meta_a) {
+      let leftPath = path;
+      if (diff.change_type === 'RENAMED') {
+        leftPath = diff.meta_a.name;
+      }
+      links.push({
+        url: this._computeDownloadFileLink(
+          project,
+          changeNum,
+          patchRange,
+          leftPath,
+          true
+        ),
+        name: 'Left Content',
+      });
+    }
+
+    if (diff && diff.meta_b) {
+      links.push({
+        url: this._computeDownloadFileLink(
+          project,
+          changeNum,
+          patchRange,
+          path,
+          false
+        ),
+        name: 'Right Content',
+      });
+    }
+
+    return links;
+  }
+
+  _computeDownloadFileLink(
+    project: RepoName,
+    changeNum: NumericChangeId,
+    patchRange: PatchRange,
+    path: string,
+    isBase?: boolean
+  ) {
+    let patchNum = patchRange.patchNum;
+
+    const comparedAgainsParent = patchRange.basePatchNum === 'PARENT';
+
+    if (isBase && !comparedAgainsParent) {
+      patchNum = patchRange.basePatchNum;
+    }
+
+    let url =
+      changeBaseURL(project, changeNum, patchNum) +
+      `/files/${encodeURIComponent(path)}/download`;
+
+    if (isBase && comparedAgainsParent) {
+      url += '?parent=1';
+    }
+
+    return url;
+  }
+
+  _computeDownloadPatchLink(
+    project: RepoName,
+    changeNum: NumericChangeId,
+    patchRange: PatchRange,
+    path: string
+  ) {
+    let url = changeBaseURL(project, changeNum, patchRange.patchNum);
+    url += '/patch?zip&path=' + encodeURIComponent(path);
+    return url;
+  }
+
+  _loadComments() {
+    if (!this._changeNum) throw new Error('Missing this._changeNum');
+    return this.$.commentAPI.loadAll(this._changeNum).then(comments => {
+      this._changeComments = comments;
+    });
+  }
+
+  @observe('_files.changeFilesByPath', '_path', '_patchRange', '_projectConfig')
+  _recomputeComments(
+    files?: {[path: string]: FileInfo},
+    path?: string,
+    patchRange?: PatchRange,
+    projectConfig?: ConfigInfo
+  ) {
+    if (!files) return;
+    if (!path) return;
+    if (!patchRange) return;
+    if (!projectConfig) return;
+    if (!this._changeComments) return;
+
+    const file = files[path];
+    if (file && file.old_path) {
+      this._commentsForDiff = this._changeComments.getCommentsBySideForFile(
+        {path, basePath: file.old_path},
+        patchRange,
+        projectConfig
+      );
+
+      this.$.diffHost.comments = this._commentsForDiff;
+    }
+  }
+
+  _getPaths(patchRange: PatchRange) {
+    if (!this._changeComments) return {};
+    return this._changeComments.getPaths(patchRange);
+  }
+
+  _getCommentsForPath(
+    path?: string,
+    patchRange?: PatchRange,
+    projectConfig?: ConfigInfo
+  ) {
+    if (!path) return undefined;
+    if (!patchRange) return undefined;
+    if (!this._changeComments) return undefined;
+
+    return this._changeComments.getCommentsBySideForPath(
+      path,
+      patchRange,
+      projectConfig
+    );
+  }
+
+  _getDiffDrafts() {
+    if (!this._changeNum) throw new Error('Missing this._changeNum');
+
+    return this.$.restAPI.getDiffDrafts(this._changeNum);
+  }
+
+  _computeCommentSkips(
+    commentMap?: CommentMap,
+    fileList?: string[],
+    path?: string
+  ) {
+    if (!commentMap) return undefined;
+    if (!fileList) return undefined;
+    if (!path) return undefined;
+
+    const skips: CommentSkips = {previous: null, next: null};
+    if (!fileList.length) {
+      return skips;
+    }
+    const pathIndex = fileList.indexOf(path);
+
+    // Scan backward for the previous file.
+    for (let i = pathIndex - 1; i >= 0; i--) {
+      if (commentMap[fileList[i]]) {
+        skips.previous = fileList[i];
+        break;
+      }
+    }
+
+    // Scan forward for the next file.
+    for (let i = pathIndex + 1; i < fileList.length; i++) {
+      if (commentMap[fileList[i]]) {
+        skips.next = fileList[i];
+        break;
+      }
+    }
+
+    return skips;
+  }
+
+  _computeContainerClass(editMode: boolean) {
+    return editMode ? 'editMode' : '';
+  }
+
+  _computeEditMode(
+    patchRangeRecord: PolymerDeepPropertyChange<PatchRange, PatchRange>
+  ) {
+    const patchRange = patchRangeRecord.base || {};
+    return patchNumEquals(patchRange.patchNum, EditPatchSetNum);
+  }
+
+  _computeBlameToggleLabel(loaded?: boolean, loading?: boolean) {
+    return loaded && !loading ? 'Hide blame' : 'Show blame';
+  }
+
+  _loadBlame() {
+    this._isBlameLoading = true;
+    this.dispatchEvent(
+      new CustomEvent('show-alert', {
+        detail: {message: MSG_LOADING_BLAME},
+        composed: true,
+        bubbles: true,
+      })
+    );
+    this.$.diffHost
+      .loadBlame()
+      .then(() => {
+        this._isBlameLoading = false;
+        this.dispatchEvent(
+          new CustomEvent('show-alert', {
+            detail: {message: MSG_LOADED_BLAME},
+            composed: true,
+            bubbles: true,
+          })
+        );
+      })
+      .catch(() => {
+        this._isBlameLoading = false;
+      });
+  }
+
+  /**
+   * Load and display blame information if it has not already been loaded.
+   * Otherwise hide it.
+   */
+  _toggleBlame() {
+    if (this._isBlameLoaded) {
+      this.$.diffHost.clearBlame();
+      return;
+    }
+    this._loadBlame();
+  }
+
+  _handleToggleBlame(e: CustomKeyboardEvent) {
+    if (this.shouldSuppressKeyboardShortcut(e)) return;
+    if (this.modifierPressed(e)) return;
+
+    this._toggleBlame();
+  }
+
+  _handleToggleHideAllCommentThreads(e: CustomKeyboardEvent) {
+    if (this.shouldSuppressKeyboardShortcut(e)) return;
+    if (this.modifierPressed(e)) return;
+
+    this.toggleClass('hideComments');
+  }
+
+  _handleDiffAgainstBase(e: CustomKeyboardEvent) {
+    if (this.shouldSuppressKeyboardShortcut(e)) return;
+    if (!this._change) return;
+    if (!this._path) return;
+    if (!this._patchRange) return;
+
+    if (patchNumEquals(this._patchRange.basePatchNum, ParentPatchSetNum)) {
+      this.dispatchEvent(
+        new CustomEvent('show-alert', {
+          detail: {
+            message: 'Base is already selected.',
+          },
+          composed: true,
+          bubbles: true,
+        })
+      );
+      return;
+    }
+    GerritNav.navigateToDiff(
+      this._change,
+      this._path,
+      this._patchRange.patchNum
+    );
+  }
+
+  _handleDiffBaseAgainstLeft(e: CustomKeyboardEvent) {
+    if (this.shouldSuppressKeyboardShortcut(e)) return;
+    if (!this._change) return;
+    if (!this._path) return;
+    if (!this._patchRange) return;
+
+    if (patchNumEquals(this._patchRange.basePatchNum, ParentPatchSetNum)) {
+      this.dispatchEvent(
+        new CustomEvent('show-alert', {
+          detail: {
+            message: 'Left is already base.',
+          },
+          composed: true,
+          bubbles: true,
+        })
+      );
+      return;
+    }
+    GerritNav.navigateToDiff(
+      this._change,
+      this._path,
+      this._patchRange.basePatchNum
+    );
+  }
+
+  _handleDiffAgainstLatest(e: CustomKeyboardEvent) {
+    if (this.shouldSuppressKeyboardShortcut(e)) return;
+    if (!this._change) return;
+    if (!this._path) return;
+    if (!this._patchRange) return;
+
+    const latestPatchNum = computeLatestPatchNum(this._allPatchSets);
+    if (patchNumEquals(this._patchRange.patchNum, latestPatchNum)) {
+      this.dispatchEvent(
+        new CustomEvent('show-alert', {
+          detail: {
+            message: 'Latest is already selected.',
+          },
+          composed: true,
+          bubbles: true,
+        })
+      );
+      return;
+    }
+
+    GerritNav.navigateToDiff(
+      this._change,
+      this._path,
+      latestPatchNum,
+      this._patchRange.basePatchNum
+    );
+  }
+
+  _handleDiffRightAgainstLatest(e: CustomKeyboardEvent) {
+    if (this.shouldSuppressKeyboardShortcut(e)) return;
+    if (!this._change) return;
+    if (!this._path) return;
+    if (!this._patchRange) return;
+
+    const latestPatchNum = computeLatestPatchNum(this._allPatchSets);
+    if (patchNumEquals(this._patchRange.patchNum, latestPatchNum)) {
+      this.dispatchEvent(
+        new CustomEvent('show-alert', {
+          detail: {
+            message: 'Right is already latest.',
+          },
+          composed: true,
+          bubbles: true,
+        })
+      );
+      return;
+    }
+    GerritNav.navigateToDiff(
+      this._change,
+      this._path,
+      latestPatchNum,
+      this._patchRange.patchNum
+    );
+  }
+
+  _handleDiffBaseAgainstLatest(e: CustomKeyboardEvent) {
+    if (this.shouldSuppressKeyboardShortcut(e)) return;
+    if (!this._change) return;
+    if (!this._path) return;
+    if (!this._patchRange) return;
+
+    const latestPatchNum = computeLatestPatchNum(this._allPatchSets);
+    if (
+      patchNumEquals(this._patchRange.patchNum, latestPatchNum) &&
+      patchNumEquals(this._patchRange.basePatchNum, ParentPatchSetNum)
+    ) {
+      this.dispatchEvent(
+        new CustomEvent('show-alert', {
+          detail: {
+            message: 'Already diffing base against latest.',
+          },
+          composed: true,
+          bubbles: true,
+        })
+      );
+      return;
+    }
+    GerritNav.navigateToDiff(this._change, this._path, latestPatchNum);
+  }
+
+  _computeBlameLoaderClass(isImageDiff?: boolean, path?: string) {
+    return !isMagicPath(path) && !isImageDiff ? 'show' : '';
+  }
+
+  _getRevisionInfo(change: ChangeInfo) {
+    return new RevisionInfoObj(change);
+  }
+
+  _computeFileNum(file?: string, files?: DropdownItem[]) {
+    if (!file || !files) return undefined;
+
+    return files.findIndex(({value}) => value === file) + 1;
+  }
+
+  _computeFileNumClass(fileNum?: number, files?: DropdownItem[]) {
+    if (files && fileNum && fileNum > 0) {
+      return 'show';
+    }
+    return '';
+  }
+
+  _handleExpandAllDiffContext(e: CustomKeyboardEvent) {
+    if (this.shouldSuppressKeyboardShortcut(e)) return;
+
+    this.$.diffHost.expandAllContext();
+  }
+
+  _computeDiffPrefsDisabled(disableDiffPrefs?: boolean, loggedIn?: boolean) {
+    return disableDiffPrefs || !loggedIn;
+  }
+
+  _handleNextUnreviewedFile(e: CustomKeyboardEvent) {
+    if (this.shouldSuppressKeyboardShortcut(e)) return;
+    if (!this._path) return;
+    if (!this._fileList) return;
+    if (!this._reviewedFiles) return;
+
+    this._setReviewed(true);
+    // Ensure that the currently viewed file always appears in unreviewedFiles
+    // so we resolve the right "next" file.
+    const unreviewedFiles = this._fileList.filter(
+      file => file === this._path || !this._reviewedFiles.has(file)
+    );
+    this._navToFile(this._path, unreviewedFiles, 1);
+  }
+
+  _handleReloadingDiffPreference() {
+    this._getDiffPreferences();
+  }
+
+  _computeCanEdit(
+    loggedIn?: boolean,
+    changeChangeRecord?: PolymerDeepPropertyChange<ChangeInfo, ChangeInfo>
+  ) {
+    if (!changeChangeRecord?.base) return false;
+    return loggedIn && changeIsOpen(changeChangeRecord.base);
+  }
+
+  _computeIsLoggedIn(loggedIn: boolean) {
+    return loggedIn ? true : false;
+  }
+
+  /**
+   * Wrapper for using in the element template and computed properties
+   */
+  _computeAllPatchSets(change: ChangeInfo) {
+    return computeAllPatchSets(change);
+  }
+
+  /**
+   * Wrapper for using in the element template and computed properties
+   */
+  _computeDisplayPath(path: string) {
+    return computeDisplayPath(path);
+  }
+}
+
+declare global {
+  interface HTMLElementTagNameMap {
+    'gr-diff-view': GrDiffView;
+  }
+}
diff --git a/polygerrit-ui/app/elements/diff/gr-diff-view/gr-diff-view_test.js b/polygerrit-ui/app/elements/diff/gr-diff-view/gr-diff-view_test.js
index b63af54..7d18527 100644
--- a/polygerrit-ui/app/elements/diff/gr-diff-view/gr-diff-view_test.js
+++ b/polygerrit-ui/app/elements/diff/gr-diff-view/gr-diff-view_test.js
@@ -24,6 +24,7 @@
 import {Shortcut} from '../../../mixins/keyboard-shortcut-mixin/keyboard-shortcut-mixin.js';
 import {_testOnly_findCommentById} from '../gr-comment-api/gr-comment-api.js';
 import {appContext} from '../../../services/app-context.js';
+import {GerritView} from '../../core/gr-navigation/gr-navigation.js';
 
 const basicFixture = fixtureFromElement('gr-diff-view');
 
@@ -82,7 +83,7 @@
       };
     }
 
-    setup(() => {
+    setup(async () => {
       clock = sinon.useFakeTimers();
       sinon.stub(appContext.flagsService, 'isEnabled').returns(true);
       stub('gr-rest-api-interface', {
@@ -118,6 +119,14 @@
         },
       });
       element = basicFixture.instantiate();
+      element._changeNum = '42';
+      element._path = 'some/path.txt';
+      element._change = {};
+      element._diff = {content: []};
+      element._patchRange = {
+        patchNum: 77,
+        basePatchNum: 'PARENT',
+      };
       sinon.stub(element.$.commentAPI, 'loadAll').returns(Promise.resolve({
         _comments: {'/COMMIT_MSG': [{id: 'c1', line: 10, patch_set: 2,
           __commentSide: 'left', path: '/COMMIT_MSG'}]},
@@ -127,7 +136,8 @@
         getCommentsBySideForPath: () => {},
         findCommentById: _testOnly_findCommentById,
       }));
-      return element._loadComments();
+      await element._loadComments();
+      await flush();
     });
 
     teardown(() => {
@@ -269,13 +279,22 @@
       assert.equal(element._isFileUnchanged(diff), true);
     });
 
-    test('diff toast to go to latest is shown and not base', () => {
+    test('diff toast to go to latest is shown and not base', async () => {
       sinon.stub(element.reporting, 'diffViewDisplayed');
       sinon.stub(element, '_loadBlame');
       sinon.stub(element.$.diffHost, 'reload').returns(Promise.resolve());
       sinon.spy(element, '_paramsChanged');
-      sinon.stub(element, '_getChangeDetail').returns(Promise.resolve(
-          generateChange({revisionsCount: 11})));
+      element.$.restAPI.getDiffChangeDetail.restore();
+      sinon.stub(element.$.restAPI, 'getDiffChangeDetail')
+          .returns(
+              Promise.resolve(generateChange({revisionsCount: 11})));
+      element._patchRange = {
+        patchNum: '2',
+        basePatchNum: '1',
+      };
+      sinon.stub(element, '_isFileUnchanged').returns(false);
+      const toastStub =
+          sinon.stub(element, '_displayDiffBaseAgainstLeftToast');
       element.params = {
         view: GerritNav.View.DIFF,
         changeNum: '42',
@@ -283,12 +302,8 @@
         commentId: 'c1',
         commentLink: true,
       };
-      element._change = generateChange({revisionsCount: 11});
-      const toastStub =
-        sinon.stub(element, '_displayDiffBaseAgainstLeftToast');
-      return element._paramsChanged.returnValues[0].then(() => {
-        assert.isTrue(toastStub.called);
-      });
+      await element._paramsChanged.returnValues[0];
+      assert.isTrue(toastStub.called);
     });
 
     test('toggle left diff with a hotkey', () => {
@@ -1251,7 +1266,7 @@
     });
 
     test('_getLineOfInterest', () => {
-      assert.isNull(element._getLineOfInterest({}));
+      assert.isUndefined(element._getLineOfInterest({}));
 
       element._focusLineNum = 12;
       let result = element._getLineOfInterest({});
@@ -1334,10 +1349,20 @@
     });
 
     suite('_initPatchRange', () => {
+      setup(async () => {
+        // const changeDetail = generateChange({revisionsCount: 5});
+        // sinon.stub(element.$.restAPI, 'getDiffChangeDetail')
+        //     .returns(Promise.resolve(changeDetail));
+        element.params = {
+          view: GerritView.DIFF,
+          changeNum: '42',
+          patchNum: '3',
+        };
+        await flush();
+      });
       test('empty', () => {
         sinon.stub(element, '_getCommentsForPath');
         sinon.stub(element, '_getPaths').returns(new Map());
-        element.params = {};
         element._initPatchRange();
         assert.equal(Object.keys(element._commentMap).length, 0);
       });
@@ -1354,7 +1379,6 @@
           basePatchNum: '3',
           patchNum: '5',
         };
-        element.params = {};
         element._initPatchRange();
         assert.deepEqual(Object.keys(element._commentMap),
             ['path/to/file/one.cpp', 'path-to/file/two.py']);
@@ -1523,6 +1547,9 @@
           .then(reviewed => assert.isFalse(reviewed)));
 
       promises.push(element._getReviewedStatus(false, null, null, 'path')
+          .then(reviewed => assert.isFalse(reviewed)));
+
+      promises.push(element._getReviewedStatus(false, 3, 5, 'path')
           .then(reviewed => assert.isTrue(reviewed)));
 
       return Promise.all(promises);
@@ -1613,6 +1640,7 @@
         patchNum: 1,
         basePatchNum: 'PARENT',
       };
+      element._change = generateChange({revisionsCount: 1});
       flush();
       assert.isTrue(GerritNav.navigateToDiff.notCalled);
 
@@ -1754,6 +1782,7 @@
         getReviewedFiles() { return Promise.resolve([]); },
       });
       element = basicFixture.instantiate();
+      element._changeNum = '42';
       return element._loadComments();
     });
 
diff --git a/polygerrit-ui/app/elements/gr-app-types.ts b/polygerrit-ui/app/elements/gr-app-types.ts
index aee5bcb..29eef48 100644
--- a/polygerrit-ui/app/elements/gr-app-types.ts
+++ b/polygerrit-ui/app/elements/gr-app-types.ts
@@ -20,7 +20,14 @@
   GroupDetailView,
   RepoDetailView,
 } from './core/gr-navigation/gr-navigation';
-import {DashboardId, GroupId, RepoName} from '../types/common';
+import {
+  DashboardId,
+  GroupId,
+  NumericChangeId,
+  PatchSetNum,
+  RepoName,
+  UrlEncodedCommentId,
+} from '../types/common';
 
 export interface AppElement extends HTMLElement {
   params: AppElementParams | GenerateUrlParameters;
@@ -86,6 +93,28 @@
   view: GerritView.AGREEMENTS;
 }
 
+export interface AppElementDiffViewParam {
+  view: GerritView.DIFF;
+  changeNum: NumericChangeId;
+  project?: RepoName;
+  commentId?: UrlEncodedCommentId;
+  path?: string;
+  patchNum?: PatchSetNum;
+  basePatchNum?: PatchSetNum;
+  lineNum: number;
+  leftSide?: boolean;
+  commentLink?: boolean;
+}
+export interface AppElementChangeViewParams {
+  view: GerritView.CHANGE;
+  changeNum: NumericChangeId;
+  project: RepoName;
+  edit?: boolean;
+  patchNum?: PatchSetNum;
+  basePatchNum?: PatchSetNum;
+  queryMap?: Map<string, string> | URLSearchParams;
+}
+
 export interface AppElementJustRegisteredParams {
   // We use params.view === ... as a type guard.
   // The view?: never tells to the compiler that
@@ -100,10 +129,12 @@
   | AppElementDashboardParams
   | AppElementGroupParams
   | AppElementAdminParams
+  | AppElementChangeViewParams
   | AppElementRepoParams
   | AppElementDocSearchParams
   | AppElementPluginScreenParams
   | AppElementSearchParam
   | AppElementSettingsParam
   | AppElementAgreementParam
-  | AppElementJustRegisteredParams;
+  | AppElementJustRegisteredParams
+  | AppElementDiffViewParam;
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 90dac88..78b6cda 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
@@ -29,6 +29,15 @@
   removeScrollLock,
 } from '@polymer/iron-overlay-behavior/iron-scroll-manager';
 
+interface ShowAlertEventDetail {
+  message: string;
+  dismissOnNavigation?: boolean;
+}
+
+interface ReloadEventDetail {
+  clearPatchset?: boolean;
+}
+
 const HOVER_CLASS = 'hovered';
 const HIDE_CLASS = 'hide';
 
@@ -193,6 +202,19 @@
        * Hovercard elements are created outside of <gr-app>, so if you want to fire
        * events, then you probably want to do that through the target element.
        */
+
+      dispatchEventThroughTarget(eventName: string): void;
+
+      dispatchEventThroughTarget(
+        eventName: 'show-alert',
+        detail: ShowAlertEventDetail
+      ): void;
+
+      dispatchEventThroughTarget(
+        eventName: 'reload',
+        detail: ReloadEventDetail
+      ): void;
+
       dispatchEventThroughTarget(eventName: string, detail?: unknown) {
         if (!detail) detail = {};
         if (this._target)
diff --git a/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-js-api-interface-element.ts b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-js-api-interface-element.ts
index 8c26d4a..626c2dc 100644
--- a/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-js-api-interface-element.ts
+++ b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-js-api-interface-element.ts
@@ -35,6 +35,7 @@
 } from './gr-js-api-types';
 import {EventType, TargetElement} from '../../plugins/gr-plugin-types';
 import {DiffLayer, HighlightJS} from '../../../types/types';
+import {ParsedChangeInfo} from '../gr-rest-api-interface/gr-reviewer-updates-parser';
 
 const elements: {[key: string]: HTMLElement} = {};
 const eventCallbacks: {[key: string]: EventCallback[]} = {};
@@ -177,7 +178,7 @@
     }
   }
 
-  handleCommitMessage(change: ChangeInfo, msg: string) {
+  handleCommitMessage(change: ChangeInfo | ParsedChangeInfo, msg: string) {
     for (const cb of this._getEventCallbacks(EventType.COMMIT_MSG_EDIT)) {
       try {
         cb(change, msg);
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 15cdac4..75af8a4 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
@@ -139,6 +139,7 @@
   GroupName,
   Hashtag,
   TopMenuEntryInfo,
+  MergeableInfo,
 } from '../../../types/common';
 import {
   CancelConditionCallback,
@@ -1551,7 +1552,7 @@
       endpoint: '/commit?links',
       patchNum,
       reportEndpointAsIs: true,
-    });
+    }) as Promise<CommitInfo | undefined>;
   }
 
   getChangeFiles(
@@ -3582,7 +3583,7 @@
       changeNum,
       endpoint: '/revisions/current/mergeable',
       reportEndpointAsIs: true,
-    });
+    }) as Promise<MergeableInfo | undefined>;
   }
 
   deleteDraftComments(query: string): Promise<Response> {
diff --git a/polygerrit-ui/app/elements/shared/revision-info/revision-info.ts b/polygerrit-ui/app/elements/shared/revision-info/revision-info.ts
index 180fb2e..fadbfa7 100644
--- a/polygerrit-ui/app/elements/shared/revision-info/revision-info.ts
+++ b/polygerrit-ui/app/elements/shared/revision-info/revision-info.ts
@@ -17,6 +17,7 @@
 
 import {patchNumEquals} from '../../../utils/patch-set-util';
 import {ChangeInfo, PatchSetNum} from '../../../types/common';
+import {ParsedChangeInfo} from '../gr-rest-api-interface/gr-reviewer-updates-parser';
 
 type RevNumberToParentCountMap = {[revNumber: number]: number};
 
@@ -26,7 +27,7 @@
    * @param change A change object resulting from a change detail
    *     call that includes revision information.
    */
-  constructor(private change: ChangeInfo) {}
+  constructor(private change: ChangeInfo | ParsedChangeInfo) {}
 
   /**
    * Get the largest number of parents of the commit in any revision. For
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 b255ea5..d915873 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
@@ -544,6 +544,7 @@
   readonly metaKey: boolean;
   readonly shiftKey: boolean;
   readonly keyCode: number;
+  readonly repeat: boolean;
 }
 function getKeyboardEvent(e: CustomKeyboardEvent): CustomKeyboardEvent {
   const event = dom(e.detail ? e.detail.keyboardEvent : e);
@@ -1091,6 +1092,8 @@
   getKeyboardEvent(e: CustomKeyboardEvent): CustomKeyboardEvent;
   addKeyboardShortcutDirectoryListener(listener: ShortcutListener): void;
   removeKeyboardShortcutDirectoryListener(listener: ShortcutListener): void;
+  // TODO(TS): Remove underscore. Apparently not a private method.
+  _throttleWrap(eventListener: EventListener): EventListener;
 }
 
 export function _testOnly_getShortcutManagerInstance() {
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 6b93082..950619b 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
@@ -98,6 +98,8 @@
   Hashtag,
   FileNameToFileInfoMap,
   TopMenuEntryInfo,
+  MergeableInfo,
+  CommitInfo,
 } from '../../../types/common';
 import {ParsedChangeInfo} from '../../../elements/shared/gr-rest-api-interface/gr-reviewer-updates-parser';
 import {HttpMethod, IgnoreWhitespaceType} from '../../../constants/constants';
@@ -216,7 +218,7 @@
 
   getChangeDetail(
     changeNum: number | string,
-    opt_errFn?: Function,
+    opt_errFn?: ErrorCallback,
     opt_cancelCondition?: Function
   ): Promise<ParsedChangeInfo | null | undefined>;
 
@@ -818,6 +820,11 @@
     topic: string | null
   ): Promise<string>;
 
+  getChangeFiles(
+    changeNum: NumericChangeId,
+    patchRange: PatchRange
+  ): Promise<FileNameToFileInfoMap | undefined>;
+
   getChangeOrEditFiles(
     changeNum: NumericChangeId,
     patchRange: PatchRange
@@ -844,4 +851,17 @@
   ): Promise<Response | undefined>;
 
   getTopMenus(errFn?: ErrorCallback): Promise<TopMenuEntryInfo[] | undefined>;
+
+  setInProjectLookup(changeNum: NumericChangeId, project: RepoName): void;
+  getMergeable(changeNum: NumericChangeId): Promise<MergeableInfo | undefined>;
+
+  putChangeCommitMessage(
+    changeNum: NumericChangeId,
+    message: string
+  ): Promise<Response>;
+
+  getChangeCommitInfo(
+    changeNum: NumericChangeId,
+    patchNum: PatchSetNum
+  ): Promise<CommitInfo | undefined>;
 }
diff --git a/polygerrit-ui/app/test/common-test-setup.js b/polygerrit-ui/app/test/common-test-setup.js
index 500187a..eead4f8 100644
--- a/polygerrit-ui/app/test/common-test-setup.js
+++ b/polygerrit-ui/app/test/common-test-setup.js
@@ -14,7 +14,8 @@
  * See the License for the specific language governing permissions and
  * limitations under the License.
  */
-
+// This should be the first import to install handler before any other code
+import './source-map-support-install.js';
 // TODO(dmfilippov): remove bundled-polymer.js imports when the following issue
 // https://github.com/Polymer/polymer-resin/issues/9 is resolved.
 import '../scripts/bundled-polymer.js';
diff --git a/polygerrit-ui/app/test/source-map-support-install.js b/polygerrit-ui/app/test/source-map-support-install.js
new file mode 100644
index 0000000..a8f147382
--- /dev/null
+++ b/polygerrit-ui/app/test/source-map-support-install.js
@@ -0,0 +1,20 @@
+/**
+ * @license
+ * Copyright (C) 2020 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+// The karma.conf.js file loads required module before any other modules
+// The source-map-support.js can't be imported with import ... statement
+window.sourceMapSupport.install();
diff --git a/polygerrit-ui/app/types/common.ts b/polygerrit-ui/app/types/common.ts
index 5bcf0b8..81eae16 100644
--- a/polygerrit-ui/app/types/common.ts
+++ b/polygerrit-ui/app/types/common.ts
@@ -44,6 +44,7 @@
   NotifyType,
   EmailFormat,
   AuthType,
+  MergeStrategy,
 } from '../constants/constants';
 import {PolymerDeepPropertyChange} from '@polymer/polymer/interfaces';
 
@@ -804,7 +805,7 @@
   large_change: string;
   reply_label: string;
   reply_tooltip: string;
-  update_delay: string;
+  update_delay: number;
   submit_whole_topic: boolean;
   disable_private_changes: boolean;
   mergeability_computation_behavior: string;
@@ -1158,6 +1159,7 @@
  * https://gerrit-review.googlesource.com/Documentation/rest-api-changes.html#comment-info
  */
 export interface CommentInfo {
+  // TODO(TS): Make this required.
   patch_set?: PatchSetNum;
   id: UrlEncodedCommentId;
   path?: string;
@@ -1441,7 +1443,7 @@
 
 /**
  * The ConfigInfo entity contains information about the effective
- * projectconfiguration.
+ * project configuration.
  * https://gerrit-review.googlesource.com/Documentation/rest-api-projects.html#config-info
  */
 export interface ConfigInfo {
@@ -1468,7 +1470,8 @@
 }
 
 /**
- * The ProjectAccessInfo entity contains information about the access rights for a project
+ * 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
  */
 export interface ProjectAccessInfo {
@@ -2233,3 +2236,16 @@
   topic?: TopicName;
   allow_empty?: boolean;
 }
+
+/**
+ * https://gerrit-review.googlesource.com/Documentation/rest-api-changes.html#mergeable-info
+ */
+export interface MergeableInfo {
+  submit_type: SubmitType;
+  strategy?: MergeStrategy;
+  mergeable: boolean;
+  commit_merged?: boolean;
+  content_merged?: boolean;
+  conflicts?: string[];
+  mergeable_into?: string[];
+}
diff --git a/polygerrit-ui/app/types/types.ts b/polygerrit-ui/app/types/types.ts
index 3768391..3bb8e37 100644
--- a/polygerrit-ui/app/types/types.ts
+++ b/polygerrit-ui/app/types/types.ts
@@ -14,12 +14,13 @@
  * See the License for the specific language governing permissions and
  * limitations under the License.
  */
-import {Side} from '../constants/constants';
+import {DiffViewMode, Side} from '../constants/constants';
 import {IronA11yAnnouncer} from '@polymer/iron-a11y-announcer/iron-a11y-announcer';
 import {GrDiffLine} from '../elements/diff/gr-diff/gr-diff-line';
 import {FlattenedNodesObserver} from '@polymer/polymer/lib/utils/flattened-nodes-observer';
 import {PaperInputElement} from '@polymer/paper-input/paper-input';
-import {CommitId} from './common';
+import {CommitId, NumericChangeId, PatchRange, PatchSetNum} from './common';
+import {PolymerSpliceChange} from '@polymer/polymer/interfaces';
 
 export function notUndefined<T>(x: T | undefined): x is T {
   return x !== undefined;
@@ -165,3 +166,58 @@
   addListener?(listener: DiffLayerListener): void;
   removeListener?(listener: DiffLayerListener): void;
 }
+
+export interface ChangeViewState {
+  changeNum: NumericChangeId | null;
+  patchRange: PatchRange | null;
+  selectedFileIndex: number;
+  showReplyDialog: boolean;
+  showDownloadDialog: boolean;
+  diffMode: DiffViewMode | null;
+  numFilesShown: number | null;
+  scrollTop: number;
+}
+
+export interface ChangeListViewState {
+  query: string | null;
+  offset: number;
+  selectedChangeIndex: number;
+}
+
+export interface DashboardViewState {
+  selectedChangeIndex: number;
+}
+
+export interface ViewState {
+  changeView: ChangeViewState;
+  changeListView: ChangeListViewState;
+  dashboardView: DashboardViewState;
+}
+
+export interface PatchSetFile {
+  path: string;
+  basePath?: string;
+  patchNum?: PatchSetNum;
+}
+
+export interface PatchNumOnly {
+  patchNum: PatchSetNum;
+}
+
+export function isPatchSetFile(
+  x: PatchSetFile | PatchNumOnly
+): x is PatchSetFile {
+  return !!(x as PatchSetFile).path;
+}
+
+export interface FileRange {
+  basePath?: string;
+  path: string;
+}
+
+export function isPolymerSpliceChange<
+  T,
+  U extends Array<{} | null | undefined>
+>(x: T | PolymerSpliceChange<U>): x is PolymerSpliceChange<U> {
+  return (x as PolymerSpliceChange<U>).indexSplices !== undefined;
+}
diff --git a/polygerrit-ui/app/utils/patch-set-util.ts b/polygerrit-ui/app/utils/patch-set-util.ts
index 962278d..48ef367 100644
--- a/polygerrit-ui/app/utils/patch-set-util.ts
+++ b/polygerrit-ui/app/utils/patch-set-util.ts
@@ -57,10 +57,6 @@
   basePatchNum?: PatchSetNum;
 }
 
-interface PatchRangeRecord {
-  base: PatchRange;
-}
-
 /**
  * As patchNum can be either a string (e.g. 'edit', 'PARENT') OR a number,
  * this function checks for patchNum equality.
@@ -246,7 +242,9 @@
 
 export const _testOnly_computeWipForPatchSets = _computeWipForPatchSets;
 
-export function computeLatestPatchNum(allPatchSets?: PatchSet[]) {
+export function computeLatestPatchNum(
+  allPatchSets?: PatchSet[]
+): PatchSetNum | undefined {
   if (!allPatchSets || !allPatchSets.length) {
     return undefined;
   }
@@ -263,11 +261,7 @@
   return allPatchSets[0].num === EditPatchSetNum;
 }
 
-export function hasEditPatchsetLoaded(patchRangeRecord: PatchRangeRecord) {
-  const patchRange = patchRangeRecord.base;
-  if (!patchRange) {
-    return false;
-  }
+export function hasEditPatchsetLoaded(patchRange: PatchRange) {
   return (
     patchRange.patchNum === EditPatchSetNum ||
     patchRange.basePatchNum === EditPatchSetNum
@@ -283,7 +277,7 @@
  *     meantime. The promise is rejected on network error.
  */
 export function fetchChangeUpdates(
-  change: ChangeInfo,
+  change: ChangeInfo | ParsedChangeInfo,
   restAPI: RestApiService
 ) {
   const knownLatest = computeLatestPatchNum(computeAllPatchSets(change));
diff --git a/polygerrit-ui/app/utils/path-list-util.ts b/polygerrit-ui/app/utils/path-list-util.ts
index 008abd2..dda6031 100644
--- a/polygerrit-ui/app/utils/path-list-util.ts
+++ b/polygerrit-ui/app/utils/path-list-util.ts
@@ -90,7 +90,7 @@
   return path;
 }
 
-export function isMagicPath(path: string) {
+export function isMagicPath(path?: string) {
   return (
     !!path &&
     (path === SpecialFilePath.COMMIT_MESSAGE ||
diff --git a/polygerrit-ui/grep-patch-karma.js b/polygerrit-ui/grep-patch-karma.js
new file mode 100644
index 0000000..adf5171
--- /dev/null
+++ b/polygerrit-ui/grep-patch-karma.js
@@ -0,0 +1,47 @@
+/**
+ * @license
+ * Copyright (C) 2020 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+// The IntelliJ (and probably other IDEs) passes test names as a regexp in
+// the format:
+// --grep=/some regexp.../
+// But mochajs doesn't expect the '/' characters before and after the regexp.
+// The code below patches input args and removes '/' if they exists.
+function installPatch(karma) {
+  const originalKarmaStart = karma.start;
+
+  karma.start = function(config, ...args) {
+    const regexpGrepPrefix = '--grep=/';
+    const regexpGrepSuffix = '/';
+    if (config && config.args) {
+      for (let i = 0; i < config.args.length; i++) {
+        const arg = config.args[i];
+        if (arg.startsWith(regexpGrepPrefix) && arg.endsWith(regexpGrepSuffix)) {
+          const regexpText = arg.slice(regexpGrepPrefix.length, -regexpGrepPrefix.length);
+          config.args[i] = '--grep=' + regexpText;
+        }
+      }
+    }
+    originalKarmaStart.apply(this, [config, ...args]);
+  }
+
+}
+
+const karma = window.__karma__;
+if (karma && karma.start && !karma.__grep_patch_installed__) {
+  karma.__grep_patch_installed__ = true;
+  installPatch(karma);
+}
diff --git a/polygerrit-ui/karma.conf.js b/polygerrit-ui/karma.conf.js
index 879a5c8..fb87675 100644
--- a/polygerrit-ui/karma.conf.js
+++ b/polygerrit-ui/karma.conf.js
@@ -43,6 +43,20 @@
   }
 }
 
+function runInIde() {
+  // A simple detection of IDE.
+  // Default browserNoActivityTimeout is 30 seconds. An IDE usually
+  // runs karma in background and send commands when a user wants to
+  // execute test. If interval between user executed tests is bigger than
+  // browserNoActivityTimeout, the IDE reports error and doesn't restart
+  // server.
+  // We want to increase browserNoActivityTimeout when tests run in IDE.
+  // Wd don't want to increase it in other cases, oterhise hanging tests
+  // can slow down CI.
+  return !runUnderBazel &&
+      process.argv.some(arg => arg.toLowerCase().contains('intellij'));
+}
+
 module.exports = function(config) {
   const localDirName = path.resolve(__dirname, '../.ts-out/polygerrit-ui/app');
   const rootDir = runUnderBazel ?
@@ -58,7 +72,10 @@
   const testFilesPattern = (typeof config.testFiles == 'string') ?
       testFilesLocationPattern + config.testFiles :
       testFilesLocationPattern + '*_test.js';
+  // Special patch for grep parameters (see details in the grep-patch-karam.js)
+  const additionalFiles = runUnderBazel ? [] : ['polygerrit-ui/grep-patch-karma.js'];
   config.set({
+    browserNoActivityTimeout: runInIde ? 60 * 60 * 1000 : 30 * 1000,
     // base path that will be used to resolve all patterns (eg. files, exclude)
     basePath: '../',
     plugins: [
@@ -76,6 +93,8 @@
 
     // list of files / patterns to load in the browser
     files: [
+      ...additionalFiles,
+      getUiDevNpmFilePath('source-map-support/browser-source-map-support.js'),
       getUiDevNpmFilePath('accessibility-developer-tools/dist/js/axs_testing.js'),
       getUiDevNpmFilePath('sinon/pkg/sinon.js'),
       { pattern: testFilesPattern, type: 'module' },
diff --git a/polygerrit-ui/package.json b/polygerrit-ui/package.json
index 7de55aa..91b8579 100644
--- a/polygerrit-ui/package.json
+++ b/polygerrit-ui/package.json
@@ -15,7 +15,8 @@
     "karma-mocha-reporter": "^2.2.5",
     "lodash": "^4.17.15",
     "mocha": "7.2.0",
-    "sinon": "^9.0.2"
+    "sinon": "^9.0.2",
+    "source-map-support": "^0.5.19"
   },
   "license": "Apache-2.0",
   "private": true
diff --git a/polygerrit-ui/yarn.lock b/polygerrit-ui/yarn.lock
index dfc5a43..a70ded8 100644
--- a/polygerrit-ui/yarn.lock
+++ b/polygerrit-ui/yarn.lock
@@ -3741,7 +3741,7 @@
     socket.io-client "2.1.1"
     socket.io-parser "~3.2.0"
 
-source-map-support@~0.5.12:
+source-map-support@^0.5.19, source-map-support@~0.5.12:
   version "0.5.19"
   resolved "https://registry.yarnpkg.com/source-map-support/-/source-map-support-0.5.19.tgz#a98b62f86dcaf4f67399648c085291ab9e8fed61"
   integrity sha512-Wonm7zOCIJzBGQdB+thsPar0kYuCIzYvxZwlBa87yi/Mdjv7Tip2cyVbLj5o0cFPN4EVkuTwb3GDDyUx2DGnGw==