Merge "Reload repo and group list after creating a repo or group"
diff --git a/.gitignore b/.gitignore
index e544356..319b3cf 100644
--- a/.gitignore
+++ b/.gitignore
@@ -31,3 +31,4 @@
 /test_site
 /tools/format
 /.vscode
+/.ijwb
diff --git a/BUILD b/BUILD
index d924417..a104dd5 100644
--- a/BUILD
+++ b/BUILD
@@ -2,10 +2,6 @@
 
 load("//tools/bzl:genrule2.bzl", "genrule2")
 load("//tools/bzl:pkg_war.bzl", "pkg_war")
-load(
-    "@bazel_tools//tools/jdk:default_java_toolchain.bzl",
-    "default_java_toolchain",
-)
 
 config_setting(
     name = "java9",
@@ -15,42 +11,12 @@
 )
 
 config_setting(
-    name = "java10",
+    name = "java_next",
     values = {
-        "java_toolchain": ":toolchain_vanilla",
+        "java_toolchain": "@bazel_tools//tools/jdk:toolchain_vanilla",
     },
 )
 
-# TODO(davido): Switch to consuming it from @bazel_tool//tools/jdk:absolute_javabase
-# when new Bazel version is released with this change included:
-# https://github.com/bazelbuild/bazel/issues/6012
-# https://github.com/bazelbuild/bazel/commit/0173bdbf7bdd1874379d4dd3eb70d5321e0f1816
-# As the interim use a hack that works around it by putting the variable reference
-# behind a select
-config_setting(
-    name = "use_absolute_javabase",
-    values = {"define": "USE_ABSOLUTE_JAVABASE=true"},
-)
-
-java_runtime(
-    name = "absolute_javabase",
-    java_home = select({
-        ":use_absolute_javabase": "$(ABSOLUTE_JAVABASE)",
-        "//conditions:default": "",
-    }),
-    visibility = ["//visibility:public"],
-)
-
-# TODO(davido): Switch to consuming it from @bazel_tool//tools/jdk:toolchain_vanilla
-# when my change is included in released Bazel version:
-# https://github.com/bazelbuild/bazel/commit/0bef68e054eccecd690e5d9f46db8a0c4b2d887a
-default_java_toolchain(
-    name = "toolchain_vanilla",
-    forcibly_disable_header_compilation = True,
-    javabuilder = ["@bazel_tools//tools/jdk:VanillaJavaBuilder_deploy.jar"],
-    jvm_opts = [],
-)
-
 genrule(
     name = "gen_version",
     outs = ["version.txt"],
diff --git a/Documentation/dev-bazel.txt b/Documentation/dev-bazel.txt
index a68c3ac..aa8609a 100644
--- a/Documentation/dev-bazel.txt
+++ b/Documentation/dev-bazel.txt
@@ -6,7 +6,7 @@
 To build Gerrit from source, you need:
 
 * A Linux or macOS system (Windows is not supported at this time)
-* A JDK for Java 8|9|10
+* A JDK for Java 8|9|10|11|...
 * Python 2 or 3
 * Node.js
 * link:https://www.bazel.io/versions/master/docs/install.html[Bazel]
@@ -14,27 +14,55 @@
 * zip, unzip
 * gcc
 
-[[Java 10 support]]
-Java 10 is supported through vanilla java toolchain
+[[Java 10 and newer version support]]
+Java 10 (and newer is) supported through vanilla java toolchain
 link:https://docs.bazel.build/versions/master/toolchains.html[Bazel option].
-To build Gerrit with Java 10, specify vanilla java toolchain and provide
-path to Java 10 home:
+To build Gerrit with Java 10 and newer, specify vanilla java toolchain and
+provide the path to JDK home:
 
 ```
-  $ bazel build --host_javabase=:absolute_javabase \
+  $ bazel build \
     --define=ABSOLUTE_JAVABASE=<path-to-java-10> \
-    --define=USE_ABSOLUTE_JAVABASE=true \
-    --host_java_toolchain=//:toolchain_vanilla \
-    --java_toolchain=//:toolchain_vanilla \
+    --host_javabase=@bazel_tools//tools/jdk:absolute_javabase \
+    --host_java_toolchain=@bazel_tools//tools/jdk:toolchain_vanilla \
+    --java_toolchain=@bazel_tools//tools/jdk:toolchain_vanilla \
     :release
 ```
 
-Note that the following options must be added to `container.javaOptions`
-in `$gerrit_site/etc/gerrit.config` to run Gerrit with Java 10:
+To run the tests, `--javabase` option must be passed as well, because
+bazel test runs the test using the target javabase:
+
+```
+  $ bazel test \
+    --define=ABSOLUTE_JAVABASE=<path-to-java-10> \
+    --javabase=@bazel_tools//tools/jdk:absolute_javabase \
+    --host_javabase=@bazel_tools//tools/jdk:absolute_javabase \
+    --host_java_toolchain=@bazel_tools//tools/jdk:toolchain_vanilla \
+    --java_toolchain=@bazel_tools//tools/jdk:toolchain_vanilla \
+    //...
+```
+
+To avoid passing all those options on every Bazel build invocation,
+they could be added to ~/.bazelrc resource file:
+
+```
+$ cat << EOF > ~/.bazelrc
+> build --define=ABSOLUTE_JAVABASE=<path-to-java-10>
+> build --javabase=@bazel_tools//tools/jdk:absolute_javabase
+> build --host_javabase=@bazel_tools//tools/jdk:absolute_javabase
+> build --host_java_toolchain=@bazel_tools//tools/jdk:toolchain_vanilla
+> build --java_toolchain=@bazel_tools//tools/jdk:toolchain_vanilla
+> EOF
+```
+
+Now, invoking Bazel with just `bazel build :release` would include
+all those options.
+
+Note that the follow option must be added to `container.javaOptions`
+in `$gerrit_site/etc/gerrit.config` to run Gerrit with Java 10|11|...:
 
 ```
 [container]
-  javaOptions = --add-modules java.activation
   javaOptions = --add-opens=jdk.management/com.sun.management.internal=ALL-UNNAMED
 ```
 
@@ -51,13 +79,12 @@
       :release
 ```
 
-Note that the following option must be added to `container.javaOptions`
+Note that the follow option must be added to `container.javaOptions`
 in `$gerrit_site/etc/gerrit.config` to run Gerrit with Java 9:
 
 ```
 [container]
-  javaOptions = --add-modules java.activation \
-      --add-opens=jdk.management/com.sun.management.internal=ALL-UNNAMED
+  javaOptions = --add-opens=jdk.management/com.sun.management.internal=ALL-UNNAMED
 ```
 
 [[build]]
diff --git a/Documentation/replace_macros.py b/Documentation/replace_macros.py
index 6f90697..309a135 100755
--- a/Documentation/replace_macros.py
+++ b/Documentation/replace_macros.py
@@ -89,7 +89,7 @@
 </button>
 <script type="text/javascript">
 var f = function() {
-  window.location = '../#/Documentation/' +
+  window.location = '../#/Documentation/q/' +
     encodeURIComponent(document.getElementById("docSearch").value);
 }
 document.getElementById("searchBox").onclick = f;
diff --git a/WORKSPACE b/WORKSPACE
index a5dbbaf..a98c42e 100644
--- a/WORKSPACE
+++ b/WORKSPACE
@@ -1127,6 +1127,12 @@
     sha1 = "65bd0cacc9c79a21c6ed8e9f588577cd3c2f85b9",
 )
 
+maven_jar(
+    name = "javax-activation",
+    artifact = "javax.activation:activation:1.1.1",
+    sha1 = "485de3a253e23f645037828c07f1d7f1af40763a",
+)
+
 load("//tools/bzl:js.bzl", "bower_archive", "npm_binary")
 
 # NPM binaries bundled along with their dependencies.
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/Dispatcher.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/Dispatcher.java
index c9346f4..4b84864 100644
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/Dispatcher.java
+++ b/gerrit-gwtui/src/main/java/com/google/gerrit/client/Dispatcher.java
@@ -236,7 +236,7 @@
     if (matchPrefix(QUERY, token)) {
       query(token);
 
-    } else if (matchPrefix("/Documentation/", token)) {
+    } else if (matchPrefix("/Documentation/q/", token)) {
       docSearch(token);
 
     } else if (matchPrefix("/c/", token)) {
diff --git a/java/com/google/gerrit/acceptance/AbstractDaemonTest.java b/java/com/google/gerrit/acceptance/AbstractDaemonTest.java
index d168919..0107bae 100644
--- a/java/com/google/gerrit/acceptance/AbstractDaemonTest.java
+++ b/java/com/google/gerrit/acceptance/AbstractDaemonTest.java
@@ -38,6 +38,7 @@
 import com.google.common.primitives.Chars;
 import com.google.gerrit.acceptance.AcceptanceTestRequestScope.Context;
 import com.google.gerrit.acceptance.testsuite.account.TestSshKeys;
+import com.google.gerrit.acceptance.testsuite.group.GroupOperations;
 import com.google.gerrit.common.Nullable;
 import com.google.gerrit.common.data.AccessSection;
 import com.google.gerrit.common.data.ContributorAgreement;
@@ -55,7 +56,6 @@
 import com.google.gerrit.extensions.api.changes.RevisionApi;
 import com.google.gerrit.extensions.api.changes.SubmittedTogetherInfo;
 import com.google.gerrit.extensions.api.groups.GroupApi;
-import com.google.gerrit.extensions.api.groups.GroupInput;
 import com.google.gerrit.extensions.api.projects.BranchApi;
 import com.google.gerrit.extensions.api.projects.BranchInput;
 import com.google.gerrit.extensions.api.projects.ProjectInput;
@@ -264,6 +264,7 @@
   @Inject protected ChangeNotes.Factory notesFactory;
   @Inject protected BatchAbandon batchAbandon;
   @Inject protected TestSshKeys sshKeys;
+  @Inject protected GroupOperations groupOperations;
 
   protected EventRecorder eventRecorder;
   protected GerritServer server;
@@ -1193,27 +1194,7 @@
     return changeResourceFactory.create(notes.get(0), atrScope.get().getUser());
   }
 
-  protected String createGroup(String name) throws Exception {
-    return createGroup(name, "Administrators");
-  }
-
-  protected String createGroupWithRealName(String name) throws Exception {
-    GroupInput in = new GroupInput();
-    in.name = name;
-    in.ownerId = "Administrators";
-    gApi.groups().create(in);
-    return name;
-  }
-
-  protected String createGroup(String name, String owner) throws Exception {
-    name = name(name);
-    GroupInput in = new GroupInput();
-    in.name = name;
-    in.ownerId = owner;
-    gApi.groups().create(in);
-    return name;
-  }
-
+  @Nullable
   protected RevCommit getHead(Repository repo, String name) throws Exception {
     try (RevWalk rw = new RevWalk(repo)) {
       Ref r = repo.exactRef(name);
@@ -1225,16 +1206,19 @@
     return getHead(repo, "HEAD");
   }
 
+  @Nullable
   protected RevCommit getRemoteHead(Project.NameKey project, String branch) throws Exception {
     try (Repository repo = repoManager.openRepository(project)) {
       return getHead(repo, branch.startsWith(Constants.R_REFS) ? branch : "refs/heads/" + branch);
     }
   }
 
+  @Nullable
   protected RevCommit getRemoteHead(String project, String branch) throws Exception {
     return getRemoteHead(new Project.NameKey(project), branch);
   }
 
+  @Nullable
   protected RevCommit getRemoteHead() throws Exception {
     return getRemoteHead(project, "master");
   }
@@ -1248,7 +1232,8 @@
   protected ContributorAgreement configureContributorAgreement(boolean autoVerify)
       throws Exception {
     ContributorAgreement ca;
-    String g = createGroup(autoVerify ? "cla-test-group" : "cla-test-no-auto-verify-group");
+    String name = autoVerify ? "cla-test-group" : "cla-test-no-auto-verify-group";
+    String g = groupOperations.newGroup().name(name).create().get();
     GroupApi groupApi = gApi.groups().id(g);
     groupApi.description("CLA test group");
     InternalGroup caGroup = group(new AccountGroup.UUID(groupApi.detail().id));
diff --git a/java/com/google/gerrit/extensions/api/changes/ChangeApi.java b/java/com/google/gerrit/extensions/api/changes/ChangeApi.java
index dfa2128..eb27260 100644
--- a/java/com/google/gerrit/extensions/api/changes/ChangeApi.java
+++ b/java/com/google/gerrit/extensions/api/changes/ChangeApi.java
@@ -176,7 +176,7 @@
 
   /** Publishes a draft change. */
   @Deprecated
-  default void publish() throws RestApiException {
+  default void publish() {
     throw new UnsupportedOperationException("draft workflow is discontinued");
   }
 
diff --git a/java/com/google/gerrit/extensions/api/changes/RevisionApi.java b/java/com/google/gerrit/extensions/api/changes/RevisionApi.java
index 14dc589..a6df45f 100644
--- a/java/com/google/gerrit/extensions/api/changes/RevisionApi.java
+++ b/java/com/google/gerrit/extensions/api/changes/RevisionApi.java
@@ -35,7 +35,7 @@
 
 public interface RevisionApi {
   @Deprecated
-  default void delete() throws RestApiException {
+  default void delete() {
     throw new UnsupportedOperationException("draft workflow is discontinued");
   }
 
@@ -59,7 +59,7 @@
   BinaryResult submitPreview(String format) throws RestApiException;
 
   @Deprecated
-  default void publish() throws RestApiException {
+  default void publish() {
     throw new UnsupportedOperationException("draft workflow is discontinued");
   }
 
diff --git a/java/com/google/gerrit/extensions/api/projects/RefInfo.java b/java/com/google/gerrit/extensions/api/projects/RefInfo.java
index c573600..d5695fd 100644
--- a/java/com/google/gerrit/extensions/api/projects/RefInfo.java
+++ b/java/com/google/gerrit/extensions/api/projects/RefInfo.java
@@ -14,8 +14,15 @@
 
 package com.google.gerrit.extensions.api.projects;
 
+import com.google.common.base.MoreObjects;
+
 public class RefInfo {
   public String ref;
   public String revision;
   public Boolean canDelete;
+
+  @Override
+  public String toString() {
+    return MoreObjects.toStringHelper(this).add("ref", ref).add("revision", revision).toString();
+  }
 }
diff --git a/java/com/google/gerrit/httpd/raw/StaticModule.java b/java/com/google/gerrit/httpd/raw/StaticModule.java
index 124ad1c..062d776 100644
--- a/java/com/google/gerrit/httpd/raw/StaticModule.java
+++ b/java/com/google/gerrit/httpd/raw/StaticModule.java
@@ -71,7 +71,15 @@
    */
   public static final ImmutableList<String> POLYGERRIT_INDEX_PATHS =
       ImmutableList.of(
-          "/", "/c/*", "/p/*", "/q/*", "/x/*", "/admin/*", "/dashboard/*", "/settings/*");
+          "/",
+          "/c/*",
+          "/p/*",
+          "/q/*",
+          "/x/*",
+          "/admin/*",
+          "/dashboard/*",
+          "/settings/*",
+          "/Documentation/q/*");
   // TODO(dborowitz): These fragments conflict with the REST API
   // namespace, so they will need to use a different path.
   // "/groups/*",
diff --git a/java/com/google/gerrit/index/project/ProjectSchemaDefinitions.java b/java/com/google/gerrit/index/project/ProjectSchemaDefinitions.java
index 6229041..cefc13c 100644
--- a/java/com/google/gerrit/index/project/ProjectSchemaDefinitions.java
+++ b/java/com/google/gerrit/index/project/ProjectSchemaDefinitions.java
@@ -38,7 +38,9 @@
 
   public static final ProjectSchemaDefinitions INSTANCE = new ProjectSchemaDefinitions();
 
+  public static final String NAME = "projects";
+
   private ProjectSchemaDefinitions() {
-    super("projects", ProjectData.class);
+    super(NAME, ProjectData.class);
   }
 }
diff --git a/java/com/google/gerrit/launcher/GerritLauncher.java b/java/com/google/gerrit/launcher/GerritLauncher.java
index 0d26fe7..5406d52 100644
--- a/java/com/google/gerrit/launcher/GerritLauncher.java
+++ b/java/com/google/gerrit/launcher/GerritLauncher.java
@@ -78,7 +78,7 @@
    * @throws Exception if any error occurs.
    */
   public static int mainImpl(String[] argv) throws Exception {
-    if (argv.length == 0) {
+    if (argv.length == 0 || "-h".equals(argv[0]) || "--help".equals(argv[0])) {
       File me;
       try {
         me = getDistributionArchive();
diff --git a/java/com/google/gerrit/pgm/BUILD b/java/com/google/gerrit/pgm/BUILD
index b34aec0..0bebad4 100644
--- a/java/com/google/gerrit/pgm/BUILD
+++ b/java/com/google/gerrit/pgm/BUILD
@@ -24,6 +24,7 @@
         "//java/com/google/gerrit/httpd/auth/oauth",
         "//java/com/google/gerrit/httpd/auth/openid",
         "//java/com/google/gerrit/index",
+        "//java/com/google/gerrit/index/project",
         "//java/com/google/gerrit/launcher",
         "//java/com/google/gerrit/lifecycle",
         "//java/com/google/gerrit/lucene",
diff --git a/java/com/google/gerrit/pgm/Init.java b/java/com/google/gerrit/pgm/Init.java
index a93e64c..1739de9 100644
--- a/java/com/google/gerrit/pgm/Init.java
+++ b/java/com/google/gerrit/pgm/Init.java
@@ -14,11 +14,15 @@
 
 package com.google.gerrit.pgm;
 
+import static java.util.stream.Collectors.joining;
+
 import com.google.common.base.Joiner;
+import com.google.common.collect.ImmutableList;
 import com.google.common.collect.Sets;
 import com.google.gerrit.common.IoUtil;
 import com.google.gerrit.common.PageLinks;
 import com.google.gerrit.common.PluginData;
+import com.google.gerrit.index.project.ProjectSchemaDefinitions;
 import com.google.gerrit.pgm.init.BaseInit;
 import com.google.gerrit.pgm.init.Browser;
 import com.google.gerrit.pgm.init.InitPlugins;
@@ -55,6 +59,9 @@
   @Option(name = "--no-auto-start", usage = "Don't automatically start daemon after init")
   private boolean noAutoStart;
 
+  @Option(name = "--no-reindex", usage = "Don't automatically reindex any entities")
+  private boolean noReindex;
+
   @Option(name = "--skip-plugins", usage = "Don't install plugins")
   private boolean skipPlugins;
 
@@ -137,6 +144,7 @@
         });
     modules.add(new GerritServerConfigModule());
     Guice.createInjector(modules).injectMembers(this);
+    reindexProjects();
     start(run);
   }
 
@@ -194,7 +202,6 @@
     if (run.flags.autoStart) {
       if (HostPlatform.isWin32()) {
         System.err.println("Automatic startup not supported on Win32.");
-
       } else {
         startDaemon(run);
         if (!run.ui.isBatch()) {
@@ -249,6 +256,25 @@
     }
   }
 
+  private void reindexProjects() throws Exception {
+    if (noReindex) {
+      return;
+    }
+    // Reindex all projects, so that we bootstrap the project index for new installations
+    List<String> reindexArgs =
+        ImmutableList.of(
+            "--site-path",
+            getSitePath().toString(),
+            "--threads",
+            Integer.toString(1),
+            "--index",
+            ProjectSchemaDefinitions.NAME);
+    getConsoleUI().message("Init complete, reindexing projects with:");
+    getConsoleUI().message(" reindex " + reindexArgs.stream().collect(joining(" ")));
+    Reindex reindexPgm = new Reindex();
+    reindexPgm.main(reindexArgs.stream().toArray(String[]::new));
+  }
+
   private static boolean nullOrEmpty(List<?> list) {
     return list == null || list.isEmpty();
   }
diff --git a/java/com/google/gerrit/server/plugincontext/PluginSetEntryContext.java b/java/com/google/gerrit/server/plugincontext/PluginSetEntryContext.java
index afffbef..2268c07 100644
--- a/java/com/google/gerrit/server/plugincontext/PluginSetEntryContext.java
+++ b/java/com/google/gerrit/server/plugincontext/PluginSetEntryContext.java
@@ -103,8 +103,9 @@
    *
    * <p>Should only be used in exceptional cases to get direct access to the extension
    * implementation. If possible the extension should be invoked through {@link
-   * #run(ExtensionImplConsumer)}, {@link #run(ExtensionImplConsumer, Class)}, {@link
-   * #call(ExtensionImplFunction)} and {@link #call(CheckedExtensionImplFunction, Class)}.
+   * #run(PluginContext.ExtensionImplConsumer)}, {@link #run(PluginContext.ExtensionImplConsumer,
+   * java.lang.Class)}, {@link #call(PluginContext.ExtensionImplFunction)} and {@link
+   * #call(PluginContext.CheckedExtensionImplFunction, java.lang.Class)}.
    *
    * @return the implementation of this extension
    */
diff --git a/java/com/google/gerrit/server/project/CreateProjectArgs.java b/java/com/google/gerrit/server/project/CreateProjectArgs.java
index a68bd84..df31c19 100644
--- a/java/com/google/gerrit/server/project/CreateProjectArgs.java
+++ b/java/com/google/gerrit/server/project/CreateProjectArgs.java
@@ -49,6 +49,7 @@
     enableSignedPush = InheritableBoolean.INHERIT;
     requireSignedPush = InheritableBoolean.INHERIT;
     submitType = SubmitType.MERGE_IF_NECESSARY;
+    rejectEmptyCommit = InheritableBoolean.INHERIT;
   }
 
   public Project.NameKey getProject() {
diff --git a/java/com/google/gerrit/server/restapi/change/Rebase.java b/java/com/google/gerrit/server/restapi/change/Rebase.java
index 2e2f565..eed0896 100644
--- a/java/com/google/gerrit/server/restapi/change/Rebase.java
+++ b/java/com/google/gerrit/server/restapi/change/Rebase.java
@@ -161,7 +161,8 @@
 
     Base base = rebaseUtil.parseBase(rsrc, str);
     if (base == null) {
-      throw new ResourceConflictException("base revision is missing: " + str);
+      throw new ResourceConflictException(
+          "base revision is missing from the destination branch: " + str);
     }
     PatchSet.Id baseId = base.patchSet().getId();
     if (change.getId().equals(baseId.getParentKey())) {
diff --git a/java/com/google/gerrit/server/restapi/project/CreateProject.java b/java/com/google/gerrit/server/restapi/project/CreateProject.java
index 7773914..60a24d8 100644
--- a/java/com/google/gerrit/server/restapi/project/CreateProject.java
+++ b/java/com/google/gerrit/server/restapi/project/CreateProject.java
@@ -109,7 +109,7 @@
   private final MetaDataUpdate.User metaDataUpdateFactory;
   private final GitReferenceUpdated referenceUpdated;
   private final RepositoryConfig repositoryCfg;
-  private final PersonIdent serverIdent;
+  private final Provider<PersonIdent> serverIdent;
   private final Provider<IdentifiedUser> identifiedUser;
   private final Provider<PutConfig> putConfig;
   private final AllProjectsName allProjects;
@@ -131,7 +131,7 @@
       MetaDataUpdate.User metaDataUpdateFactory,
       GitReferenceUpdated referenceUpdated,
       RepositoryConfig repositoryCfg,
-      @GerritPersonIdent PersonIdent serverIdent,
+      @GerritPersonIdent Provider<PersonIdent> serverIdent,
       Provider<IdentifiedUser> identifiedUser,
       Provider<PutConfig> putConfig,
       AllProjectsName allProjects,
@@ -357,7 +357,7 @@
       CommitBuilder cb = new CommitBuilder();
       cb.setTreeId(oi.insert(Constants.OBJ_TREE, new byte[] {}));
       cb.setAuthor(metaDataUpdateFactory.getUserPersonIdent());
-      cb.setCommitter(serverIdent);
+      cb.setCommitter(serverIdent.get());
       cb.setMessage("Initial empty repository\n");
 
       ObjectId id = oi.insert(cb);
diff --git a/javatests/com/google/gerrit/acceptance/api/accounts/AccountIT.java b/javatests/com/google/gerrit/acceptance/api/accounts/AccountIT.java
index 5e46a03..9886db5 100644
--- a/javatests/com/google/gerrit/acceptance/api/accounts/AccountIT.java
+++ b/javatests/com/google/gerrit/acceptance/api/accounts/AccountIT.java
@@ -56,6 +56,7 @@
 import com.google.gerrit.acceptance.UseSsh;
 import com.google.gerrit.acceptance.testsuite.account.AccountOperations;
 import com.google.gerrit.acceptance.testsuite.account.TestSshKeys;
+import com.google.gerrit.acceptance.testsuite.group.GroupOperations;
 import com.google.gerrit.common.Nullable;
 import com.google.gerrit.common.data.AccessSection;
 import com.google.gerrit.common.data.GlobalCapability;
@@ -97,6 +98,7 @@
 import com.google.gerrit.gpg.testing.TestKey;
 import com.google.gerrit.mail.Address;
 import com.google.gerrit.reviewdb.client.Account;
+import com.google.gerrit.reviewdb.client.AccountGroup;
 import com.google.gerrit.reviewdb.client.Branch;
 import com.google.gerrit.reviewdb.client.Change;
 import com.google.gerrit.reviewdb.client.Project;
@@ -233,6 +235,8 @@
 
   @Inject private AccountManager accountManager;
 
+  @Inject protected GroupOperations groupOperations;
+
   private AccountIndexedCounter accountIndexedCounter;
   private RegistrationHandle accountIndexEventCounterHandle;
   private RefUpdateCounter refUpdateCounter;
@@ -2208,7 +2212,9 @@
   public void allGroupsForAUserAccountCanBeRetrieved() throws Exception {
     String username = name("user1");
     accountOperations.newAccount().username(username).create();
-    String group = createGroup("group");
+    AccountGroup.UUID groupID = groupOperations.newGroup().name("group").create();
+    String group = groupOperations.group(groupID).get().name();
+
     gApi.groups().id(group).addMembers(username);
 
     List<GroupInfo> allGroups = gApi.accounts().id(username).getGroups();
diff --git a/javatests/com/google/gerrit/acceptance/api/change/ChangeIT.java b/javatests/com/google/gerrit/acceptance/api/change/ChangeIT.java
index 30aef73..744d1e9 100644
--- a/javatests/com/google/gerrit/acceptance/api/change/ChangeIT.java
+++ b/javatests/com/google/gerrit/acceptance/api/change/ChangeIT.java
@@ -68,6 +68,7 @@
 import com.google.gerrit.acceptance.PushOneCommit;
 import com.google.gerrit.acceptance.TestProjectInput;
 import com.google.gerrit.acceptance.testsuite.account.AccountOperations;
+import com.google.gerrit.acceptance.testsuite.group.GroupOperations;
 import com.google.gerrit.common.FooterConstants;
 import com.google.gerrit.common.data.LabelFunction;
 import com.google.gerrit.common.data.LabelType;
@@ -194,6 +195,8 @@
   @Inject private ChangeIndexCollection changeIndexCollection;
   @Inject private IndexConfig indexConfig;
 
+  @Inject protected GroupOperations groupOperations;
+
   private ChangeIndexedCounter changeIndexedCounter;
   private RegistrationHandle changeIndexedCounterHandle;
 
@@ -1823,7 +1826,7 @@
             .preferredEmail(email)
             .fullname(fullname)
             .create();
-    String testGroup = createGroupWithRealName("ab");
+    String testGroup = groupOperations.newGroup().name("ab").create().get();
     GroupApi groupApi = gApi.groups().id(testGroup);
     groupApi.description("test group");
     groupApi.addMembers(user.fullName);
@@ -1884,7 +1887,7 @@
             .fullname(myGroupUserFullname)
             .create();
 
-    String testGroup = createGroupWithRealName("kobe");
+    String testGroup = groupOperations.newGroup().name("kobe").create().get();
     GroupApi groupApi = gApi.groups().id(testGroup);
     groupApi.description("test group");
     groupApi.addMembers(myGroupUserFullname);
diff --git a/javatests/com/google/gerrit/acceptance/api/group/GroupsConsistencyIT.java b/javatests/com/google/gerrit/acceptance/api/group/GroupsConsistencyIT.java
index 87a566e..491cb3a 100644
--- a/javatests/com/google/gerrit/acceptance/api/group/GroupsConsistencyIT.java
+++ b/javatests/com/google/gerrit/acceptance/api/group/GroupsConsistencyIT.java
@@ -20,6 +20,7 @@
 import com.google.gerrit.acceptance.AbstractDaemonTest;
 import com.google.gerrit.acceptance.NoHttpd;
 import com.google.gerrit.acceptance.Sandboxed;
+import com.google.gerrit.acceptance.testsuite.group.GroupOperations;
 import com.google.gerrit.common.data.GlobalCapability;
 import com.google.gerrit.extensions.api.config.ConsistencyCheckInfo;
 import com.google.gerrit.extensions.api.config.ConsistencyCheckInfo.ConsistencyProblemInfo;
@@ -30,6 +31,7 @@
 import com.google.gerrit.server.group.db.GroupConfig;
 import com.google.gerrit.server.group.db.GroupNameNotes;
 import com.google.gerrit.server.group.db.testing.GroupTestUtil;
+import com.google.inject.Inject;
 import java.util.List;
 import org.eclipse.jgit.lib.Config;
 import org.eclipse.jgit.lib.RefRename;
@@ -47,6 +49,8 @@
 @Sandboxed
 @NoHttpd
 public class GroupsConsistencyIT extends AbstractDaemonTest {
+
+  @Inject protected GroupOperations groupOperations;
   private GroupInfo gAdmin;
   private GroupInfo g1;
   private GroupInfo g2;
@@ -57,8 +61,8 @@
   public void basicSetup() throws Exception {
     allowGlobalCapabilities(REGISTERED_USERS, GlobalCapability.ACCESS_DATABASE);
 
-    String name1 = createGroup("g1");
-    String name2 = createGroup("g2");
+    String name1 = groupOperations.newGroup().name("g1").create().get();
+    String name2 = groupOperations.newGroup().name("g2").create().get();
 
     gApi.groups().id(name1).addMembers(user.fullName);
     gApi.groups().id(name2).addMembers(admin.fullName);
@@ -218,7 +222,7 @@
   @Test
   public void cyclicSubgroup() throws Exception {
     updateGroupFile(RefNames.refsGroups(new AccountGroup.UUID(g1.id)), "subgroups", g1.id + "\n");
-    assertWarning("cyclic");
+    assertWarning("cycle");
   }
 
   private void assertError(String msg) throws Exception {
diff --git a/javatests/com/google/gerrit/acceptance/api/group/GroupsIT.java b/javatests/com/google/gerrit/acceptance/api/group/GroupsIT.java
index ade0f3c..4a4ee2a 100644
--- a/javatests/com/google/gerrit/acceptance/api/group/GroupsIT.java
+++ b/javatests/com/google/gerrit/acceptance/api/group/GroupsIT.java
@@ -144,6 +144,23 @@
     }
   }
 
+  // Creates a group, but with uniquified name.
+  protected String createGroup(String name) throws Exception {
+    // TODO(hanwen): rewrite this test in terms of UUID. This requires redoing the assertion helpers
+    // too.
+    AccountGroup.UUID g = groupOperations.newGroup().ownerGroupUuid(adminGroupUuid()).create();
+    return groupRef(g).getName();
+  }
+
+  protected String createGroup(String name, String owner) throws Exception {
+    name = name(name);
+    GroupInput in = new GroupInput();
+    in.name = name;
+    in.ownerId = owner;
+    gApi.groups().create(in);
+    return name;
+  }
+
   @Override
   protected ProjectResetter.Config resetProjects() {
     // Don't reset All-Users since deleting users makes groups inconsistent (e.g. groups would
diff --git a/javatests/com/google/gerrit/acceptance/pgm/BUILD b/javatests/com/google/gerrit/acceptance/pgm/BUILD
index a91b815..e0d2f86 100644
--- a/javatests/com/google/gerrit/acceptance/pgm/BUILD
+++ b/javatests/com/google/gerrit/acceptance/pgm/BUILD
@@ -13,6 +13,8 @@
     vm_args = ["-Xmx512m"],
     deps = [
         ":util",
+        "//java/com/google/gerrit/index",
+        "//java/com/google/gerrit/index/project",
         "//java/com/google/gerrit/server/schema",
     ],
 )
diff --git a/javatests/com/google/gerrit/acceptance/pgm/InitIT.java b/javatests/com/google/gerrit/acceptance/pgm/InitIT.java
new file mode 100644
index 0000000..a573e35
--- /dev/null
+++ b/javatests/com/google/gerrit/acceptance/pgm/InitIT.java
@@ -0,0 +1,51 @@
+// 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.acceptance.pgm;
+
+import static com.google.common.truth.Truth.assertThat;
+
+import com.google.common.collect.ImmutableSet;
+import com.google.gerrit.acceptance.NoHttpd;
+import com.google.gerrit.acceptance.StandaloneSiteTest;
+import com.google.gerrit.index.IndexConfig;
+import com.google.gerrit.index.QueryOptions;
+import com.google.gerrit.index.project.ProjectData;
+import com.google.gerrit.index.project.ProjectIndexCollection;
+import com.google.gerrit.reviewdb.client.Project;
+import com.google.gerrit.server.config.AllProjectsName;
+import com.google.gerrit.server.config.AllUsersName;
+import java.util.Optional;
+import org.junit.Test;
+
+@NoHttpd
+public class InitIT extends StandaloneSiteTest {
+
+  @Test
+  public void indexesAllProjectsAndAllUsers() throws Exception {
+    runGerrit("init", "-d", sitePaths.site_path.toString(), "--show-stack-trace");
+    try (ServerContext ctx = startServer()) {
+      ProjectIndexCollection projectIndex =
+          ctx.getInjector().getInstance(ProjectIndexCollection.class);
+      Project.NameKey allProjects = ctx.getInjector().getInstance(AllProjectsName.class);
+      Project.NameKey allUsers = ctx.getInjector().getInstance(AllUsersName.class);
+      QueryOptions opts =
+          QueryOptions.create(IndexConfig.createDefault(), 0, 1, ImmutableSet.of("name"));
+      Optional<ProjectData> allProjectsData = projectIndex.getSearchIndex().get(allProjects, opts);
+      assertThat(allProjectsData.isPresent()).isTrue();
+      Optional<ProjectData> allUsersData = projectIndex.getSearchIndex().get(allUsers, opts);
+      assertThat(allUsersData.isPresent()).isTrue();
+    }
+  }
+}
diff --git a/javatests/com/google/gerrit/acceptance/rest/change/ChangeReviewersIT.java b/javatests/com/google/gerrit/acceptance/rest/change/ChangeReviewersIT.java
index d1f4d84..6a9a27c 100644
--- a/javatests/com/google/gerrit/acceptance/rest/change/ChangeReviewersIT.java
+++ b/javatests/com/google/gerrit/acceptance/rest/change/ChangeReviewersIT.java
@@ -31,6 +31,7 @@
 import com.google.gerrit.acceptance.RestResponse;
 import com.google.gerrit.acceptance.Sandboxed;
 import com.google.gerrit.acceptance.TestAccount;
+import com.google.gerrit.acceptance.testsuite.group.GroupOperations;
 import com.google.gerrit.common.data.Permission;
 import com.google.gerrit.extensions.api.changes.AddReviewerInput;
 import com.google.gerrit.extensions.api.changes.AddReviewerResult;
@@ -52,6 +53,7 @@
 import com.google.gerrit.server.change.ReviewerAdder;
 import com.google.gerrit.testing.FakeEmailSender.Message;
 import com.google.gson.stream.JsonReader;
+import com.google.inject.Inject;
 import java.util.ArrayList;
 import java.util.Collection;
 import java.util.Collections;
@@ -62,12 +64,15 @@
 import org.junit.Test;
 
 public class ChangeReviewersIT extends AbstractDaemonTest {
+
+  @Inject protected GroupOperations groupOperations;
+
   @Test
   public void addGroupAsReviewer() throws Exception {
     // Set up two groups, one that is too large too add as reviewer, and one
     // that is too large to add without confirmation.
-    String largeGroup = createGroup("largeGroup");
-    String mediumGroup = createGroup("mediumGroup");
+    String largeGroup = groupOperations.newGroup().name("largeGroup").create().get();
+    String mediumGroup = groupOperations.newGroup().name("mediumGroup").create().get();
 
     int largeGroupSize = ReviewerAdder.DEFAULT_MAX_REVIEWERS + 1;
     int mediumGroupSize = ReviewerAdder.DEFAULT_MAX_REVIEWERS_WITHOUT_CHECK + 1;
@@ -170,7 +175,7 @@
     PushOneCommit.Result r = createChange();
     String changeId = r.getChangeId();
     AddReviewerInput in = new AddReviewerInput();
-    in.reviewer = createGroup("cc1");
+    in.reviewer = groupOperations.newGroup().name("cc1").create().get();
     in.state = CC;
     gApi.groups()
         .id(in.reviewer)
@@ -209,7 +214,7 @@
     result = addReviewer(changeId, reviewer.username);
     assertThat(result.error).isNull();
     sender.clear();
-    in.reviewer = createGroup("cc2");
+    in.reviewer = groupOperations.newGroup().name("cc2").create().get();
     gApi.groups().id(in.reviewer).addMembers(usernames.toArray(new String[usernames.size()]));
     gApi.groups().id(in.reviewer).addMembers(reviewer.username);
     result = addReviewer(changeId, in);
@@ -479,8 +484,8 @@
       usernames.add(u.username);
     }
 
-    String largeGroup = createGroup("largeGroup");
-    String mediumGroup = createGroup("mediumGroup");
+    String largeGroup = groupOperations.newGroup().name("largeGroup").create().get();
+    String mediumGroup = groupOperations.newGroup().name("mediumGroup").create().get();
     gApi.groups().id(largeGroup).addMembers(usernames.toArray(new String[largeGroupSize]));
     gApi.groups()
         .id(mediumGroup)
@@ -622,8 +627,8 @@
         accountCreator.create(name("user2"), emailPrefix + "user2@example.com", "User2");
     TestAccount user3 =
         accountCreator.create(name("user3"), emailPrefix + "user3@example.com", "User3");
-    String group1 = createGroup("group1");
-    String group2 = createGroup("group2");
+    String group1 = groupOperations.newGroup().name("group1").create().get();
+    String group2 = groupOperations.newGroup().name("group2").create().get();
     gApi.groups().id(group1).addMembers(user1.username, user2.username);
     gApi.groups().id(group2).addMembers(user2.username, user3.username);
 
diff --git a/javatests/com/google/gerrit/acceptance/rest/change/IndexChangeIT.java b/javatests/com/google/gerrit/acceptance/rest/change/IndexChangeIT.java
index 6555fe8..bb114e7 100644
--- a/javatests/com/google/gerrit/acceptance/rest/change/IndexChangeIT.java
+++ b/javatests/com/google/gerrit/acceptance/rest/change/IndexChangeIT.java
@@ -20,17 +20,22 @@
 import com.google.gerrit.acceptance.AbstractDaemonTest;
 import com.google.gerrit.acceptance.PushOneCommit;
 import com.google.gerrit.acceptance.TestAccount;
+import com.google.gerrit.acceptance.testsuite.group.GroupOperations;
 import com.google.gerrit.common.data.Permission;
 import com.google.gerrit.extensions.common.ChangeInfo;
 import com.google.gerrit.reviewdb.client.AccountGroup;
 import com.google.gerrit.reviewdb.client.Project;
 import com.google.gerrit.server.project.testing.Util;
+import com.google.inject.Inject;
 import java.util.List;
 import org.eclipse.jgit.internal.storage.dfs.InMemoryRepository;
 import org.eclipse.jgit.junit.TestRepository;
 import org.junit.Test;
 
 public class IndexChangeIT extends AbstractDaemonTest {
+
+  @Inject protected GroupOperations groupOperations;
+
   @Test
   public void indexChange() throws Exception {
     String changeId = createChange().getChangeId();
@@ -48,7 +53,8 @@
   public void indexChangeAfterOwnerLosesVisibility() throws Exception {
     // Create a test group with 2 users as members
     TestAccount user2 = accountCreator.user2();
-    String group = createGroup("test");
+    AccountGroup.UUID groupId = groupOperations.newGroup().name("test").create();
+    String group = groupOperations.group(groupId).get().name();
     gApi.groups().id(group).addMembers("admin", "user", user2.username);
 
     // Create a project and restrict its visibility to the group
diff --git a/lib/greenmail/BUILD b/lib/greenmail/BUILD
index 55eb9f3..b09f27b 100644
--- a/lib/greenmail/BUILD
+++ b/lib/greenmail/BUILD
@@ -1,8 +1,23 @@
 package(default_visibility = ["//visibility:public"])
 
+POST_JDK8_DEPS = [":javax-activation"]
+
+java_library(
+    name = "javax-activation",
+    testonly = 1,
+    data = ["//lib:LICENSE-DO_NOT_DISTRIBUTE"],
+    exports = ["@javax-activation//jar"],
+)
+
 java_library(
     name = "greenmail",
+    testonly = 1,
     data = ["//lib:LICENSE-Apache2.0"],
     visibility = ["//visibility:public"],
     exports = ["@greenmail//jar"],
+    runtime_deps = select({
+        "//:java9": POST_JDK8_DEPS,
+        "//:java_next": POST_JDK8_DEPS,
+        "//conditions:default": [],
+    }),
 )
diff --git a/polygerrit-ui/app/behaviors/keyboard-shortcut-behavior/keyboard-shortcut-behavior.html b/polygerrit-ui/app/behaviors/keyboard-shortcut-behavior/keyboard-shortcut-behavior.html
index 0a685da..af982cf 100644
--- a/polygerrit-ui/app/behaviors/keyboard-shortcut-behavior/keyboard-shortcut-behavior.html
+++ b/polygerrit-ui/app/behaviors/keyboard-shortcut-behavior/keyboard-shortcut-behavior.html
@@ -142,6 +142,7 @@
     UP_TO_CHANGE: 'UP_TO_CHANGE',
     TOGGLE_DIFF_MODE: 'TOGGLE_DIFF_MODE',
     REFRESH_CHANGE: 'REFRESH_CHANGE',
+    EDIT_TOPIC: 'EDIT_TOPIC',
 
     NEXT_LINE: 'NEXT_LINE',
     PREV_LINE: 'PREV_LINE',
@@ -164,6 +165,7 @@
     PREV_FILE: 'PREV_FILE',
     NEXT_FILE_WITH_COMMENTS: 'NEXT_FILE_WITH_COMMENTS',
     PREV_FILE_WITH_COMMENTS: 'PREV_FILE_WITH_COMMENTS',
+    NEXT_UNREVIEWED_FILE: 'NEXT_UNREVIEWED_FILE',
     CURSOR_NEXT_FILE: 'CURSOR_NEXT_FILE',
     CURSOR_PREV_FILE: 'CURSOR_PREV_FILE',
     OPEN_FILE: 'OPEN_FILE',
@@ -223,6 +225,8 @@
       'Refresh list of changes');
   _describe(Shortcut.TOGGLE_CHANGE_STAR, ShortcutSection.ACTIONS,
       'Star/unstar change');
+  _describe(Shortcut.EDIT_TOPIC, ShortcutSection.ACTIONS,
+      'Add a change topic');
 
   _describe(Shortcut.NEXT_LINE, ShortcutSection.DIFFS, 'Go to next line');
   _describe(Shortcut.PREV_LINE, ShortcutSection.DIFFS, 'Go to previous line');
@@ -252,6 +256,8 @@
       'Mark/unmark file as reviewed');
   _describe(Shortcut.TOGGLE_DIFF_MODE, ShortcutSection.DIFFS,
       'Toggle unified/side-by-side diff');
+  _describe(Shortcut.NEXT_UNREVIEWED_FILE, ShortcutSection.DIFFS,
+      'Mark file as reviewed and go to next unreviewed file');
 
   _describe(Shortcut.NEXT_FILE, ShortcutSection.NAVIGATION, 'Select next file');
   _describe(Shortcut.PREV_FILE, ShortcutSection.NAVIGATION,
diff --git a/polygerrit-ui/app/elements/admin/gr-create-change-dialog/gr-create-change-dialog.html b/polygerrit-ui/app/elements/admin/gr-create-change-dialog/gr-create-change-dialog.html
index ad12a44..987b63d 100644
--- a/polygerrit-ui/app/elements/admin/gr-create-change-dialog/gr-create-change-dialog.html
+++ b/polygerrit-ui/app/elements/admin/gr-create-change-dialog/gr-create-change-dialog.html
@@ -55,7 +55,7 @@
           padding: 0 .15em;
         }
       }
-      .hideBranch {
+      .hide {
         display: none;
       }
     </style>
@@ -108,7 +108,7 @@
             </iron-autogrow-textarea>
           </span>
         </section>
-        <section>
+        <section class$="[[_computePrivateSectionClass(_privateChangesEnabled)]]">
           <label
               class="title"
               for="privateChangeCheckBox">Private change</label>
diff --git a/polygerrit-ui/app/elements/admin/gr-create-change-dialog/gr-create-change-dialog.js b/polygerrit-ui/app/elements/admin/gr-create-change-dialog/gr-create-change-dialog.js
index 826a6dc..8e15755 100644
--- a/polygerrit-ui/app/elements/admin/gr-create-change-dialog/gr-create-change-dialog.js
+++ b/polygerrit-ui/app/elements/admin/gr-create-change-dialog/gr-create-change-dialog.js
@@ -44,6 +44,7 @@
         notify: true,
         value: false,
       },
+      _privateChangesEnabled: Boolean,
     },
 
     behaviors: [
@@ -52,10 +53,23 @@
     ],
 
     attached() {
-      if (!this.repoName) { return; }
-      this.$.restAPI.getProjectConfig(this.repoName).then(config => {
-        this.privateByDefault = config.private_by_default;
-      });
+      if (!this.repoName) { return Promise.resolve(); }
+
+      const promises = [];
+
+      promises.push(this.$.restAPI.getProjectConfig(this.repoName)
+          .then(config => {
+            this.privateByDefault = config.private_by_default;
+          }));
+
+      promises.push(this.$.restAPI.getConfig().then(config => {
+        if (!config) { return; }
+
+        this._privateConfig = config && config.change &&
+            config.change.disable_private_changes;
+      }));
+
+      return Promise.all(promises);
     },
 
     observers: [
@@ -63,7 +77,7 @@
     ],
 
     _computeBranchClass(baseChange) {
-      return baseChange ? 'hideBranch' : '';
+      return baseChange ? 'hide' : '';
     },
 
     _allowCreate(branch, subject) {
@@ -120,5 +134,9 @@
         return false;
       }
     },
+
+    _computePrivateSectionClass(config) {
+      return config ? 'hide' : '';
+    },
   });
 })();
diff --git a/polygerrit-ui/app/elements/admin/gr-create-change-dialog/gr-create-change-dialog_test.html b/polygerrit-ui/app/elements/admin/gr-create-change-dialog/gr-create-change-dialog_test.html
index 08c569c..aa4da68 100644
--- a/polygerrit-ui/app/elements/admin/gr-create-change-dialog/gr-create-change-dialog_test.html
+++ b/polygerrit-ui/app/elements/admin/gr-create-change-dialog/gr-create-change-dialog_test.html
@@ -158,5 +158,15 @@
         done();
       });
     });
+
+    test('_computeBranchClass', () => {
+      assert.equal(element._computeBranchClass(true), 'hide');
+      assert.equal(element._computeBranchClass(false), '');
+    });
+
+    test('_computePrivateSectionClass', () => {
+      assert.equal(element._computePrivateSectionClass(true), 'hide');
+      assert.equal(element._computePrivateSectionClass(false), '');
+    });
   });
 </script>
diff --git a/polygerrit-ui/app/elements/admin/gr-create-repo-dialog/gr-create-repo-dialog.html b/polygerrit-ui/app/elements/admin/gr-create-repo-dialog/gr-create-repo-dialog.html
index d55d7e9..f43a3e2 100644
--- a/polygerrit-ui/app/elements/admin/gr-create-repo-dialog/gr-create-repo-dialog.html
+++ b/polygerrit-ui/app/elements/admin/gr-create-repo-dialog/gr-create-repo-dialog.html
@@ -71,6 +71,17 @@
           </span>
         </section>
         <section>
+          <span class="title">Owner</span>
+          <span class="value">
+            <gr-autocomplete
+                id="ownerInput"
+                text="{{_repoOwner}}"
+                value="{{_repoOwnerId}}"
+                query="[[_queryGroups]]">
+            </gr-autocomplete>
+          </span>
+        </section>
+        <section>
           <span class="title">Create initial empty commit</span>
           <span class="value">
             <gr-select
diff --git a/polygerrit-ui/app/elements/admin/gr-create-repo-dialog/gr-create-repo-dialog.js b/polygerrit-ui/app/elements/admin/gr-create-repo-dialog/gr-create-repo-dialog.js
index 9dde290..bb2b5f2 100644
--- a/polygerrit-ui/app/elements/admin/gr-create-repo-dialog/gr-create-repo-dialog.js
+++ b/polygerrit-ui/app/elements/admin/gr-create-repo-dialog/gr-create-repo-dialog.js
@@ -43,6 +43,11 @@
         type: Boolean,
         value: false,
       },
+      _repoOwner: String,
+      _repoOwnerId: {
+        type: String,
+        observer: '_repoOwnerIdUpdate',
+      },
 
       _query: {
         type: Function,
@@ -50,6 +55,12 @@
           return this._getRepoSuggestions.bind(this);
         },
       },
+      _queryGroups: {
+        type: Function,
+        value() {
+          return this._getGroupSuggestions.bind(this);
+        },
+      },
     },
 
     observers: [
@@ -70,6 +81,14 @@
       this.hasNewRepoName = !!name;
     },
 
+    _repoOwnerIdUpdate(id) {
+      if (id) {
+        this.set('_repoConfig.owners', [id]);
+      } else {
+        this.set('_repoConfig.owners', undefined);
+      }
+    },
+
     handleCreateRepo() {
       return this.$.restAPI.createRepo(this._repoConfig)
           .then(repoRegistered => {
@@ -94,5 +113,20 @@
             return repos;
           });
     },
+
+    _getGroupSuggestions(input) {
+      return this.$.restAPI.getSuggestedGroups(input)
+          .then(response => {
+            const groups = [];
+            for (const key in response) {
+              if (!response.hasOwnProperty(key)) { continue; }
+              groups.push({
+                name: key,
+                value: decodeURIComponent(response[key].id),
+              });
+            }
+            return groups;
+          });
+    },
   });
 })();
diff --git a/polygerrit-ui/app/elements/admin/gr-create-repo-dialog/gr-create-repo-dialog_test.html b/polygerrit-ui/app/elements/admin/gr-create-repo-dialog/gr-create-repo-dialog_test.html
index e70c11a..6bc3522 100644
--- a/polygerrit-ui/app/elements/admin/gr-create-repo-dialog/gr-create-repo-dialog_test.html
+++ b/polygerrit-ui/app/elements/admin/gr-create-repo-dialog/gr-create-repo-dialog_test.html
@@ -60,6 +60,7 @@
         create_empty_commit: true,
         parent: 'All-Project',
         permissions_only: false,
+        owners: ['testId'],
       };
 
       const saveStub = sandbox.stub(element.$.restAPI,
@@ -76,8 +77,12 @@
         permissions_only: false,
       };
 
+      element._repoOwner = 'test';
+      element._repoOwnerId = 'testId';
+
       element.$.repoNameInput.bindValue = configInputObj.name;
       element.$.rightsInheritFromInput.bindValue = configInputObj.parent;
+      element.$.ownerInput.text = configInputObj.owners[0];
       element.$.initalCommit.bindValue =
           configInputObj.create_empty_commit;
       element.$.parentRepo.bindValue =
@@ -92,5 +97,10 @@
         done();
       });
     });
+
+    test('testing observer of _repoOwner', () => {
+      element._repoOwnerId = 'test-5';
+      assert.deepEqual(element._repoConfig.owners, ['test-5']);
+    });
   });
 </script>
diff --git a/polygerrit-ui/app/elements/admin/gr-repo-access/gr-repo-access.js b/polygerrit-ui/app/elements/admin/gr-repo-access/gr-repo-access.js
index 799b831..fe043d5 100644
--- a/polygerrit-ui/app/elements/admin/gr-repo-access/gr-repo-access.js
+++ b/polygerrit-ui/app/elements/admin/gr-repo-access/gr-repo-access.js
@@ -133,6 +133,8 @@
      * @return {!Promise}
      */
     _repoChanged(repo) {
+      this._loading = true;
+
       if (!repo) { return Promise.resolve(); }
 
       return this._reload(repo);
diff --git a/polygerrit-ui/app/elements/admin/gr-repo-dashboards/gr-repo-dashboards.html b/polygerrit-ui/app/elements/admin/gr-repo-dashboards/gr-repo-dashboards.html
index 36a7d76..1d49db9 100644
--- a/polygerrit-ui/app/elements/admin/gr-repo-dashboards/gr-repo-dashboards.html
+++ b/polygerrit-ui/app/elements/admin/gr-repo-dashboards/gr-repo-dashboards.html
@@ -53,7 +53,7 @@
           </tr>
           <template is="dom-repeat" items="[[item.dashboards]]">
             <tr class="table">
-              <td class="name"><a href$="[[_getUrl(item.project, item.sections)]]">[[item.path]]</a></td>
+              <td class="name"><a href$="[[_getUrl(item.project, item.id)]]">[[item.path]]</a></td>
               <td class="title">[[item.title]]</td>
               <td class="desc">[[item.description]]</td>
               <td class="inherited">[[_computeInheritedFrom(item.project, item.defining_project)]]</td>
diff --git a/polygerrit-ui/app/elements/admin/gr-repo-dashboards/gr-repo-dashboards.js b/polygerrit-ui/app/elements/admin/gr-repo-dashboards/gr-repo-dashboards.js
index c0fc0cb..7dea7f4 100644
--- a/polygerrit-ui/app/elements/admin/gr-repo-dashboards/gr-repo-dashboards.js
+++ b/polygerrit-ui/app/elements/admin/gr-repo-dashboards/gr-repo-dashboards.js
@@ -43,24 +43,24 @@
       this.$.restAPI.getRepoDashboards(this.repo, errFn).then(res => {
         if (!res) { return Promise.resolve(); }
 
-        // Flatten 2 dimenional array, and sort by id.
+        // Group by ref and sort by id.
         const dashboards = res.concat.apply([], res).sort((a, b) =>
-            a.id > b.id);
-        const customList = dashboards.filter(a => a.ref === 'custom');
-        const defaultList = dashboards.filter(a => a.ref === 'default');
+            a.id < b.id ? -1 : 1);
+        const dashboardsByRef = {};
+        dashboards.forEach(d => {
+          if (!dashboardsByRef[d.ref]) {
+            dashboardsByRef[d.ref] = [];
+          }
+          dashboardsByRef[d.ref].push(d);
+        });
+
         const dashboardBuilder = [];
-        if (customList.length) {
+        Object.keys(dashboardsByRef).sort().forEach(ref => {
           dashboardBuilder.push({
-            section: 'Custom',
-            dashboards: customList,
+            section: ref,
+            dashboards: dashboardsByRef[ref],
           });
-        }
-        if (defaultList.length) {
-          dashboardBuilder.push({
-            section: 'Default',
-            dashboards: defaultList,
-          });
-        }
+        });
 
         this._dashboards = dashboardBuilder;
         this._loading = false;
@@ -68,10 +68,10 @@
       });
     },
 
-    _getUrl(project, sections) {
-      if (!project || !sections) { return ''; }
+    _getUrl(project, id) {
+      if (!project || !id) { return ''; }
 
-      return Gerrit.Nav.getUrlForCustomDashboard(project, sections);
+      return Gerrit.Nav.getUrlForRepoDashboard(project, id);
     },
 
     _computeLoadingClass(loading) {
diff --git a/polygerrit-ui/app/elements/admin/gr-repo-dashboards/gr-repo-dashboards_test.html b/polygerrit-ui/app/elements/admin/gr-repo-dashboards/gr-repo-dashboards_test.html
index 4d86a0c..94bf5e0 100644
--- a/polygerrit-ui/app/elements/admin/gr-repo-dashboards/gr-repo-dashboards_test.html
+++ b/polygerrit-ui/app/elements/admin/gr-repo-dashboards/gr-repo-dashboards_test.html
@@ -46,54 +46,61 @@
       sandbox.restore();
     });
 
-    suite('with default only', () => {
+    suite('dashboard table', () => {
       setup(() => {
         sandbox.stub(element.$.restAPI, 'getRepoDashboards').returns(
             Promise.resolve([
-              [
-                {
-                  id: 'default:contributor',
-                  project: 'gerrit',
-                  defining_project: 'gerrit',
-                  ref: 'default',
-                  path: 'contributor',
-                  description: 'Own contributions.',
-                  foreach: 'owner:self',
-                  url: '/dashboard/?params',
-                  title: 'Contributor Dashboard',
-                  sections: [
-                    {
-                      name: 'Mine To Rebase',
-                      query: 'is:open -is:mergeable',
-                    },
-                    {
-                      name: 'My Recently Merged',
-                      query: 'is:merged limit:10',
-                    },
-                  ],
-                },
-              ],
-              [
-                {
-                  id: 'default:open',
-                  project: 'gerrit',
-                  defining_project: 'Public-Projects',
-                  ref: 'default',
-                  path: 'open',
-                  description: 'Recent open changes.',
-                  url: '/dashboard/?params',
-                  title: 'Open Changes',
-                  sections: [
-                    {
-                      name: 'Open Changes',
-                      query: 'status:open project:${project} -age:7w',
-                    },
-                  ],
-                },
-              ],
+              {
+                id: 'default:contributor',
+                project: 'gerrit',
+                defining_project: 'gerrit',
+                ref: 'default',
+                path: 'contributor',
+                description: 'Own contributions.',
+                foreach: 'owner:self',
+                url: '/dashboard/?params',
+                title: 'Contributor Dashboard',
+                sections: [
+                  {
+                    name: 'Mine To Rebase',
+                    query: 'is:open -is:mergeable',
+                  },
+                  {
+                    name: 'My Recently Merged',
+                    query: 'is:merged limit:10',
+                  },
+                ],
+              },
+              {
+                id: 'custom:custom2',
+                project: 'gerrit',
+                defining_project: 'Public-Projects',
+                ref: 'custom',
+                path: 'open',
+                description: 'Recent open changes.',
+                url: '/dashboard/?params',
+                title: 'Open Changes',
+                sections: [
+                  {
+                    name: 'Open Changes',
+                    query: 'status:open project:${project} -age:7w',
+                  },
+                ],
+              },
+              {
+                id: 'default:abc',
+                project: 'gerrit',
+                ref: 'default',
+              },
+              {
+                id: 'custom:custom1',
+                project: 'gerrit',
+                ref: 'custom',
+              },
             ]));
       });
-      test('loading', done => {
+
+      test('loading, sections, and ordering', done => {
         assert.isTrue(element._loading);
         assert.notEqual(getComputedStyle(element.$.loadingContainer).display,
             'none');
@@ -101,143 +108,20 @@
             'none');
         element.repo = 'test';
         flush(() => {
-          assert.equal(element._dashboards.length, 1);
-          assert.equal(element._dashboards[0].section, 'Default');
-          assert.equal(element._dashboards[0].dashboards.length, 2);
           assert.equal(getComputedStyle(element.$.loadingContainer).display,
               'none');
           assert.notEqual(getComputedStyle(element.$.dashboards).display,
               'none');
-          done();
-        });
-      });
 
-      test('dispatched command-tap on button tap', done => {
-        element.repo = 'test';
-        flush(() => {
-          assert.equal(element._dashboards.length, 1);
-          assert.equal(element._dashboards[0].section, 'Default');
-          assert.equal(element._dashboards[0].dashboards.length, 2);
-          done();
-        });
-      });
-    });
-
-    suite('with custom only', () => {
-      setup(() => {
-        sandbox.stub(element.$.restAPI, 'getRepoDashboards').returns(
-            Promise.resolve([
-              [
-                {
-                  id: 'custom:custom1',
-                  project: 'gerrit',
-                  defining_project: 'gerrit',
-                  ref: 'custom',
-                  path: 'contributor',
-                  description: 'Own contributions.',
-                  foreach: 'owner:self',
-                  url: '/dashboard/?params',
-                  title: 'Contributor Dashboard',
-                  sections: [
-                    {
-                      name: 'Mine To Rebase',
-                      query: 'is:open -is:mergeable',
-                    },
-                    {
-                      name: 'My Recently Merged',
-                      query: 'is:merged limit:10',
-                    },
-                  ],
-                },
-              ],
-              [
-                {
-                  id: 'custom:custom2',
-                  project: 'gerrit',
-                  defining_project: 'Public-Projects',
-                  ref: 'custom',
-                  path: 'open',
-                  description: 'Recent open changes.',
-                  url: '/dashboard/?params',
-                  title: 'Open Changes',
-                  sections: [
-                    {
-                      name: 'Open Changes',
-                      query: 'status:open project:${project} -age:7w',
-                    },
-                  ],
-                },
-              ],
-            ]));
-      });
-
-      test('dispatched command-tap on button tap', done => {
-        element.repo = 'test';
-        flush(() => {
-          assert.equal(element._dashboards.length, 1);
-          assert.equal(element._dashboards[0].section, 'Custom');
-          assert.equal(element._dashboards[0].dashboards.length, 2);
-          done();
-        });
-      });
-    });
-
-    suite('with custom and default', () => {
-      setup(() => {
-        sandbox.stub(element.$.restAPI, 'getRepoDashboards').returns(
-            Promise.resolve([
-              [
-                {
-                  id: 'default:contributor',
-                  project: 'gerrit',
-                  defining_project: 'gerrit',
-                  ref: 'default',
-                  path: 'contributor',
-                  description: 'Own contributions.',
-                  foreach: 'owner:self',
-                  url: '/dashboard/?params',
-                  title: 'Contributor Dashboard',
-                  sections: [
-                    {
-                      name: 'Mine To Rebase',
-                      query: 'is:open -is:mergeable',
-                    },
-                    {
-                      name: 'My Recently Merged',
-                      query: 'is:merged limit:10',
-                    },
-                  ],
-                },
-              ],
-              [
-                {
-                  id: 'custom:custom2',
-                  project: 'gerrit',
-                  defining_project: 'Public-Projects',
-                  ref: 'custom',
-                  path: 'open',
-                  description: 'Recent open changes.',
-                  url: '/dashboard/?params',
-                  title: 'Open Changes',
-                  sections: [
-                    {
-                      name: 'Open Changes',
-                      query: 'status:open project:${project} -age:7w',
-                    },
-                  ],
-                },
-              ],
-            ]));
-      });
-
-      test('dispatched command-tap on button tap', done => {
-        element.repo = 'test';
-        flush(() => {
           assert.equal(element._dashboards.length, 2);
-          assert.equal(element._dashboards[0].section, 'Custom');
-          assert.equal(element._dashboards[1].section, 'Default');
-          assert.equal(element._dashboards[0].dashboards.length, 1);
-          assert.equal(element._dashboards[1].dashboards.length, 1);
+          assert.equal(element._dashboards[0].section, 'custom');
+          assert.equal(element._dashboards[1].section, 'default');
+
+          const dashboards = element._dashboards[0].dashboards;
+          assert.equal(dashboards.length, 2);
+          assert.equal(dashboards[0].id, 'custom:custom1');
+          assert.equal(dashboards[1].id, 'custom:custom2');
+
           done();
         });
       });
@@ -245,7 +129,7 @@
 
     suite('test url', () => {
       test('_getUrl', () => {
-        sandbox.stub(Gerrit.Nav, 'getUrlForCustomDashboard',
+        sandbox.stub(Gerrit.Nav, 'getUrlForRepoDashboard',
             () => '/r/dashboard/test');
 
         assert.equal(element._getUrl('/dashboard/test', {}), '/r/dashboard/test');
diff --git a/polygerrit-ui/app/elements/admin/gr-repo-list/gr-repo-list.html b/polygerrit-ui/app/elements/admin/gr-repo-list/gr-repo-list.html
index 7e1c385..0490db2 100644
--- a/polygerrit-ui/app/elements/admin/gr-repo-list/gr-repo-list.html
+++ b/polygerrit-ui/app/elements/admin/gr-repo-list/gr-repo-list.html
@@ -43,6 +43,7 @@
         <tr class="headerRow">
           <th class="name topHeader">Repository Name</th>
           <th class="description topHeader">Repository Description</th>
+          <th class="changesLink topHeader">Changes</th>
           <th class="repositoryBrowser topHeader">Repository Browser</th>
           <th class="readOnly topHeader">Read only</th>
         </tr>
@@ -56,6 +57,7 @@
                 <a href$="[[_computeRepoUrl(item.name)]]">[[item.name]]</a>
               </td>
               <td class="description">[[item.description]]</td>
+              <td class="changesLink"><a href$="[[_computeChangesLink(item.name)]]">(view all)</a></td>
               <td class="repositoryBrowser">
                 <template is="dom-repeat"
                     items="[[_computeWeblink(item)]]" as="link">
diff --git a/polygerrit-ui/app/elements/admin/gr-repo-list/gr-repo-list.js b/polygerrit-ui/app/elements/admin/gr-repo-list/gr-repo-list.js
index bf1f8809..116f084 100644
--- a/polygerrit-ui/app/elements/admin/gr-repo-list/gr-repo-list.js
+++ b/polygerrit-ui/app/elements/admin/gr-repo-list/gr-repo-list.js
@@ -102,6 +102,10 @@
       return this.getUrl(this._path + '/', name);
     },
 
+    _computeChangesLink(name) {
+      return Gerrit.Nav.getUrlForProjectChanges(name);
+    },
+
     _getCreateRepoCapability() {
       return this.$.restAPI.getAccount().then(account => {
         if (!account) { return; }
diff --git a/polygerrit-ui/app/elements/admin/gr-repo/gr-repo.html b/polygerrit-ui/app/elements/admin/gr-repo/gr-repo.html
index eabe7a3..5fa8187 100644
--- a/polygerrit-ui/app/elements/admin/gr-repo/gr-repo.html
+++ b/polygerrit-ui/app/elements/admin/gr-repo/gr-repo.html
@@ -55,7 +55,17 @@
     </style>
     <style include="gr-form-styles"></style>
     <main class="gr-form-styles read-only">
-      <h1 id="Title">[[repo]]</h1>
+      <style include="shared-styles"></style>
+      <style include="dashboard-header-styles"></style>
+      <div class="info">
+        <h1 id="Title" class$="name">
+          [[repo]]
+          <hr/>
+        </h1>
+        <div>
+          <a href$="[[_computeChangesUrl(repo)]]">(view changes)</a>
+        </div>
+      </div>
       <div id="loading" class$="[[_computeLoadingClass(_loading)]]">Loading...</div>
       <div id="loadedContent" class$="[[_computeLoadingClass(_loading)]]">
         <div id="downloadContent" class$="[[_computeDownloadClass(_schemes)]]">
diff --git a/polygerrit-ui/app/elements/admin/gr-repo/gr-repo.js b/polygerrit-ui/app/elements/admin/gr-repo/gr-repo.js
index 94b9e3f..6443095 100644
--- a/polygerrit-ui/app/elements/admin/gr-repo/gr-repo.js
+++ b/polygerrit-ui/app/elements/admin/gr-repo/gr-repo.js
@@ -318,5 +318,9 @@
     _computeRepositoriesClass(config) {
       return config ? 'showConfig': '';
     },
+
+    _computeChangesUrl(name) {
+      return Gerrit.Nav.getUrlForProjectChanges(name);
+    },
   });
 })();
diff --git a/polygerrit-ui/app/elements/change-list/gr-dashboard-view/gr-dashboard-view.html b/polygerrit-ui/app/elements/change-list/gr-dashboard-view/gr-dashboard-view.html
index 99aa265..b0ba8a2b 100644
--- a/polygerrit-ui/app/elements/change-list/gr-dashboard-view/gr-dashboard-view.html
+++ b/polygerrit-ui/app/elements/change-list/gr-dashboard-view/gr-dashboard-view.html
@@ -87,7 +87,7 @@
     <div hidden$="[[_loading]]" hidden>
       <gr-user-header
           user-id="[[params.user]]"
-          class$="[[_computeUserHeaderClass(params.user)]]"></gr-user-header>
+          class$="[[_computeUserHeaderClass(params)]]"></gr-user-header>
       <gr-change-list
           show-star
           show-reviewed-state
diff --git a/polygerrit-ui/app/elements/change-list/gr-dashboard-view/gr-dashboard-view.js b/polygerrit-ui/app/elements/change-list/gr-dashboard-view/gr-dashboard-view.js
index 3fc4089..0625bbe 100644
--- a/polygerrit-ui/app/elements/change-list/gr-dashboard-view/gr-dashboard-view.js
+++ b/polygerrit-ui/app/elements/change-list/gr-dashboard-view/gr-dashboard-view.js
@@ -37,7 +37,7 @@
       /** @type {{ selectedChangeIndex: number }} */
       viewState: Object,
 
-      /** @type {{ user: string }} */
+      /** @type {{ project: string, user: string }} */
       params: {
         type: Object,
       },
@@ -217,8 +217,12 @@
           });
     },
 
-    _computeUserHeaderClass(userParam) {
-      return userParam === 'self' ? 'hide' : '';
+    _computeUserHeaderClass(params) {
+      if (!params || !!params.project || !params.user
+          || params.user === 'self') {
+        return 'hide';
+      }
+      return '';
     },
 
     _handleToggleStar(e) {
diff --git a/polygerrit-ui/app/elements/change-list/gr-dashboard-view/gr-dashboard-view_test.html b/polygerrit-ui/app/elements/change-list/gr-dashboard-view/gr-dashboard-view_test.html
index 78fdee6..618ec65 100644
--- a/polygerrit-ui/app/elements/change-list/gr-dashboard-view/gr-dashboard-view_test.html
+++ b/polygerrit-ui/app/elements/change-list/gr-dashboard-view/gr-dashboard-view_test.html
@@ -314,10 +314,13 @@
     });
 
     test('_computeUserHeaderClass', () => {
-      assert.equal(element._computeUserHeaderClass(undefined), '');
-      assert.equal(element._computeUserHeaderClass(''), '');
-      assert.equal(element._computeUserHeaderClass('self'), 'hide');
-      assert.equal(element._computeUserHeaderClass('user'), '');
+      assert.equal(element._computeUserHeaderClass(undefined), 'hide');
+      assert.equal(element._computeUserHeaderClass({}), 'hide');
+      assert.equal(element._computeUserHeaderClass({user: 'self'}), 'hide');
+      assert.equal(element._computeUserHeaderClass({user: 'user'}), '');
+      assert.equal(
+          element._computeUserHeaderClass({project: 'p', user: 'user'}),
+          'hide');
     });
 
     test('404 page', done => {
diff --git a/polygerrit-ui/app/elements/change/gr-change-metadata/gr-change-metadata.html b/polygerrit-ui/app/elements/change/gr-change-metadata/gr-change-metadata.html
index e521576..bec08a8 100644
--- a/polygerrit-ui/app/elements/change/gr-change-metadata/gr-change-metadata.html
+++ b/polygerrit-ui/app/elements/change/gr-change-metadata/gr-change-metadata.html
@@ -27,6 +27,7 @@
 <link rel="import" href="../../shared/gr-account-link/gr-account-link.html">
 <link rel="import" href="../../shared/gr-date-formatter/gr-date-formatter.html">
 <link rel="import" href="../../shared/gr-editable-label/gr-editable-label.html">
+<link rel="import" href="../../shared/gr-icons/gr-icons.html">
 <link rel="import" href="../../shared/gr-limited-text/gr-limited-text.html">
 <link rel="import" href="../../shared/gr-linked-chip/gr-linked-chip.html">
 <link rel="import" href="../../shared/gr-tooltip-content/gr-tooltip-content.html">
@@ -114,6 +115,19 @@
       #parentNotCurrentMessage {
         display: none;
       }
+      .icon {
+        margin: -.25em 0;
+      }
+      .icon.help,
+      .icon.notTrusted {
+        color: #FFA62F;
+      }
+      .icon.invalid {
+        color: var(--vote-text-color-disliked);
+      }
+      .icon.trusted {
+        color: var(--vote-text-color-recommended);
+      }
       .parentList.notCurrent.nonMerge #parentNotCurrentMessage {
         --arrow-color: #ffa62f;
         display: inline-block;
@@ -137,13 +151,40 @@
         <span class="title">Owner</span>
         <span class="value">
           <gr-account-link account="[[change.owner]]"></gr-account-link>
+          <template is="dom-if" if="[[_pushCertificateValidation]]">
+            <gr-tooltip-content
+                has-tooltip
+                title$="[[_pushCertificateValidation.message]]">
+              <iron-icon
+                  class$="icon [[_pushCertificateValidation.class]]"
+                  icon="[[_pushCertificateValidation.icon]]">
+              </iron-icon>
+            </gr-tooltip-content>
+          </template>
         </span>
       </section>
-      <section class$="[[_computeShowUploaderHide(change)]]">
+      <section class$="[[_computeShowRoleClass(change, _CHANGE_ROLE.UPLOADER)]]">
         <span class="title">Uploader</span>
         <span class="value">
           <gr-account-link
-              account="[[_computeShowUploader(change)]]"></gr-account-link>
+              account="[[_getNonOwnerRole(change, _CHANGE_ROLE.UPLOADER)]]"
+              ></gr-account-link>
+        </span>
+      </section>
+      <section class$="[[_computeShowRoleClass(change, _CHANGE_ROLE.AUTHOR)]]">
+        <span class="title">Author</span>
+        <span class="value">
+          <gr-account-link
+              account="[[_getNonOwnerRole(change, _CHANGE_ROLE.AUTHOR)]]"
+              ></gr-account-link>
+        </span>
+      </section>
+      <section class$="[[_computeShowRoleClass(change, _CHANGE_ROLE.COMMITTER)]]">
+        <span class="title">Committer</span>
+        <span class="value">
+          <gr-account-link
+              account="[[_getNonOwnerRole(change, _CHANGE_ROLE.COMMITTER)]]"
+              ></gr-account-link>
         </span>
       </section>
       <section class="assignee">
@@ -244,6 +285,7 @@
               is="dom-if"
               if="[[_showAddTopic(change.*, _settingTopic)]]">
             <gr-editable-label
+                class="topicEditableLabel"
                 label-text="Add a topic"
                 value="[[change.topic]]"
                 max-length="1024"
diff --git a/polygerrit-ui/app/elements/change/gr-change-metadata/gr-change-metadata.js b/polygerrit-ui/app/elements/change/gr-change-metadata/gr-change-metadata.js
index 8b119d8..d3fc7e0 100644
--- a/polygerrit-ui/app/elements/change/gr-change-metadata/gr-change-metadata.js
+++ b/polygerrit-ui/app/elements/change/gr-change-metadata/gr-change-metadata.js
@@ -17,6 +17,17 @@
 (function() {
   'use strict';
 
+  const Defs = {};
+
+  /**
+   * @typedef {{
+   *    message: string,
+   *    icon: string,
+   *    class: string,
+   *  }}
+   */
+  Defs.PushCertificateValidation;
+
   const HASHTAG_ADD_MESSAGE = 'Add Hashtag';
 
   const SubmitTypeLabel = {
@@ -30,6 +41,24 @@
 
   const NOT_CURRENT_MESSAGE = 'Not current - rebase possible';
 
+  /**
+   * @enum {string}
+   */
+  const CertificateStatus = {
+    /**
+     * This certificate status is bad.
+     */
+    BAD: 'BAD',
+    /**
+     * This certificate status is OK.
+     */
+    OK: 'OK',
+    /**
+     * This certificate status is TRUSTED.
+     */
+    TRUSTED: 'TRUSTED',
+  };
+
   Polymer({
     is: 'gr-change-metadata',
 
@@ -76,6 +105,13 @@
         type: Boolean,
         computed: '_computeShowReviewersByState(serverConfig)',
       },
+      /**
+       * @type {Defs.PushCertificateValidation}
+       */
+      _pushCertificateValidation: {
+        type: Object,
+        computed: '_computePushCertificateValidation(serverConfig, change)',
+      },
       _showRequirements: {
         type: Boolean,
         computed: '_computeShowRequirements(change)',
@@ -97,6 +133,18 @@
         type: Array,
         computed: '_computeParents(change)',
       },
+
+      /** @type {?} */
+      _CHANGE_ROLE: {
+        type: Object,
+        readOnly: true,
+        value: {
+          OWNER: 'owner',
+          UPLOADER: 'uploader',
+          AUTHOR: 'author',
+          COMMITTER: 'committer',
+        },
+      },
     },
 
     behaviors: [
@@ -248,6 +296,59 @@
       return hasRequirements || hasLabels || !!change.work_in_progress;
     },
 
+    /**
+     * @return {?Defs.PushCertificateValidation} object representing data for
+     *     the push validation.
+     */
+    _computePushCertificateValidation(serverConfig, change) {
+      if (!serverConfig || !serverConfig.receive ||
+          !serverConfig.receive.enable_signed_push) {
+        return null;
+      }
+      const rev = change.revisions[change.current_revision];
+      if (!rev.push_certificate || !rev.push_certificate.key) {
+        return {
+          class: 'help',
+          icon: 'gr-icons:help',
+          message: 'This patch set was created without a push certificate',
+        };
+      }
+
+      const key = rev.push_certificate.key;
+      switch (key.status) {
+        case CertificateStatus.BAD:
+          return {
+            class: 'invalid',
+            icon: 'gr-icons:close',
+            message: this._problems('Push certificate is invalid', key),
+          };
+        case CertificateStatus.OK:
+          return {
+            class: 'notTrusted',
+            icon: 'gr-icons:info',
+            message: this._problems(
+                'Push certificate is valid, but key is not trusted', key),
+          };
+        case CertificateStatus.TRUSTED:
+          return {
+            class: 'trusted',
+            icon: 'gr-icons:check',
+            message: this._problems(
+                'Push certificate is valid and key is trusted', key),
+          };
+        default:
+          throw new Error(`unknown certificate status: ${key.status}`);
+      }
+    },
+
+    _problems(msg, key) {
+      if (!key || !key.problems || key.problems.length === 0) {
+        return msg;
+      }
+
+      return [msg + ':'].concat(key.problems).join('\n');
+    },
+
     _computeProjectURL(project) {
       return Gerrit.Nav.getUrlForProjectChanges(project);
     },
@@ -299,24 +400,45 @@
       return !!change.work_in_progress;
     },
 
-    _computeShowUploaderHide(change) {
-      return this._computeShowUploader(change) ? '' : 'hideDisplay';
+    _computeShowRoleClass(change, role) {
+      return this._getNonOwnerRole(change, role) ? '' : 'hideDisplay';
     },
 
-    _computeShowUploader(change) {
+    /**
+     * Get the user with the specified role on the change. Returns null if the
+     * user with that role is the same as the owner.
+     * @param {!Object} change
+     * @param {string} role One of the values from _CHANGE_ROLE
+     * @return {Object|null} either an accound or null.
+     */
+    _getNonOwnerRole(change, role) {
       if (!change.current_revision ||
           !change.revisions[change.current_revision]) {
         return null;
       }
 
       const rev = change.revisions[change.current_revision];
+      if (!rev) { return null; }
 
-      if (!rev || !rev.uploader ||
-        change.owner._account_id === rev.uploader._account_id) {
-        return null;
+      if (role === this._CHANGE_ROLE.UPLOADER &&
+          rev.uploader &&
+          change.owner._account_id !== rev.uploader._account_id) {
+        return rev.uploader;
       }
 
-      return rev.uploader;
+      if (role === this._CHANGE_ROLE.AUTHOR &&
+          rev.commit && rev.commit.author &&
+          change.owner.email !== rev.commit.author.email) {
+        return rev.commit.author;
+      }
+
+      if (role === this._CHANGE_ROLE.COMMITTER &&
+          rev.commit && rev.commit.committer &&
+          change.owner.email !== rev.commit.committer.email) {
+        return rev.commit.committer;
+      }
+
+      return null;
     },
 
     _computeParents(change) {
@@ -343,5 +465,12 @@
     _computeIsMutable(account) {
       return !!Object.keys(account).length;
     },
+
+    editTopic() {
+      if (this._topicReadOnly || this.change.topic) { return; }
+      // Cannot use `this.$.ID` syntax because the element exists inside of a
+      // dom-if.
+      this.$$('.topicEditableLabel').open();
+    },
   });
 })();
diff --git a/polygerrit-ui/app/elements/change/gr-change-metadata/gr-change-metadata_test.html b/polygerrit-ui/app/elements/change/gr-change-metadata/gr-change-metadata_test.html
index 17c3d70..c5a569e 100644
--- a/polygerrit-ui/app/elements/change/gr-change-metadata/gr-change-metadata_test.html
+++ b/polygerrit-ui/app/elements/change/gr-change-metadata/gr-change-metadata_test.html
@@ -185,7 +185,138 @@
       assert.equal(element._computeWebLinks(element.commitInfo).length, 1);
     });
 
-    test('_computeShowUploader test for uploader', () => {
+    suite('_getNonOwnerRole', () => {
+      let change;
+
+      setup(() => {
+        change = {
+          owner: {
+            email: 'abc@def',
+            _account_id: 1019328,
+          },
+          revisions: {
+            rev1: {
+              _number: 1,
+              uploader: {
+                email: 'ghi@def',
+                _account_id: 1011123,
+              },
+              commit: {
+                author: {email: 'jkl@def'},
+                committer: {email: 'ghi@def'},
+              },
+            },
+          },
+          current_revision: 'rev1',
+        };
+      });
+
+      suite('role=uploader', () => {
+        test('_getNonOwnerRole for uploader', () => {
+          assert.deepEqual(
+              element._getNonOwnerRole(change, element._CHANGE_ROLE.UPLOADER),
+              {email: 'ghi@def', _account_id: 1011123});
+        });
+
+        test('_getNonOwnerRole that it does not return uploader', () => {
+          // Set the uploader email to be the same as the owner.
+          change.revisions.rev1.uploader._account_id = 1019328;
+          assert.isNull(element._getNonOwnerRole(change,
+              element._CHANGE_ROLE.UPLOADER));
+        });
+
+        test('_getNonOwnerRole null for uploader with no current rev', () => {
+          delete change.current_revision;
+          assert.isNull(element._getNonOwnerRole(change,
+              element._CHANGE_ROLE.UPLOADER));
+        });
+
+        test('_computeShowRoleClass show uploader', () => {
+          assert.equal(element._computeShowRoleClass(
+              change, element._CHANGE_ROLE.UPLOADER), '');
+        });
+
+        test('_computeShowRoleClass hide uploader', () => {
+          // Set the uploader email to be the same as the owner.
+          change.revisions.rev1.uploader._account_id = 1019328;
+          assert.equal(element._computeShowRoleClass(change,
+              element._CHANGE_ROLE.UPLOADER), 'hideDisplay');
+        });
+      });
+
+      suite('role=committer', () => {
+        test('_getNonOwnerRole for committer', () => {
+          assert.deepEqual(
+              element._getNonOwnerRole(change, element._CHANGE_ROLE.COMMITTER),
+              {email: 'ghi@def'});
+        });
+
+        test('_getNonOwnerRole that it does not return committer', () => {
+          // Set the committer email to be the same as the owner.
+          change.revisions.rev1.commit.committer.email = 'abc@def';
+          assert.isNull(element._getNonOwnerRole(change,
+              element._CHANGE_ROLE.COMMITTER));
+        });
+
+        test('_getNonOwnerRole null for committer with no current rev', () => {
+          delete change.current_revision;
+          assert.isNull(element._getNonOwnerRole(change,
+              element._CHANGE_ROLE.COMMITTER));
+        });
+
+        test('_getNonOwnerRole null for committer with no commit', () => {
+          delete change.revisions.rev1.commit;
+          assert.isNull(element._getNonOwnerRole(change,
+              element._CHANGE_ROLE.COMMITTER));
+        });
+
+        test('_getNonOwnerRole null for committer with no committer', () => {
+          delete change.revisions.rev1.commit.committer;
+          assert.isNull(element._getNonOwnerRole(change,
+              element._CHANGE_ROLE.COMMITTER));
+        });
+      });
+
+      suite('role=author', () => {
+        test('_getNonOwnerRole for author', () => {
+          assert.deepEqual(
+              element._getNonOwnerRole(change, element._CHANGE_ROLE.AUTHOR),
+              {email: 'jkl@def'});
+        });
+
+        test('_getNonOwnerRole that it does not return author', () => {
+          // Set the author email to be the same as the owner.
+          change.revisions.rev1.commit.author.email = 'abc@def';
+          assert.isNull(element._getNonOwnerRole(change,
+              element._CHANGE_ROLE.AUTHOR));
+        });
+
+        test('_getNonOwnerRole null for author with no current rev', () => {
+          delete change.current_revision;
+          assert.isNull(element._getNonOwnerRole(change,
+              element._CHANGE_ROLE.AUTHOR));
+        });
+
+        test('_getNonOwnerRole null for author with no commit', () => {
+          delete change.revisions.rev1.commit;
+          assert.isNull(element._getNonOwnerRole(change,
+              element._CHANGE_ROLE.AUTHOR));
+        });
+
+        test('_getNonOwnerRole null for author with no author', () => {
+          delete change.revisions.rev1.commit.author;
+          assert.isNull(element._getNonOwnerRole(change,
+              element._CHANGE_ROLE.AUTHOR));
+        });
+      });
+    });
+
+    test('Push Certificate Validation test BAD', () => {
+      const serverConfig = {
+        receive: {
+          enable_signed_push: true,
+        },
+      };
       const change = {
         change_id: 'Iad9dc96274af6946f3632be53b106ef80f7ba6ca',
         owner: {
@@ -194,8 +325,13 @@
         revisions: {
           rev1: {
             _number: 1,
-            uploader: {
-              _account_id: 1011123,
+            push_certificate: {
+              key: {
+                status: 'BAD',
+                problems: [
+                  'No public keys found for key ID E5E20E52',
+                ],
+              },
             },
           },
         },
@@ -204,54 +340,21 @@
         labels: {},
         mergeable: true,
       };
-      assert.deepEqual(element._computeShowUploader(change),
-          {_account_id: 1011123});
+      const result =
+          element._computePushCertificateValidation(serverConfig, change);
+      assert.equal(result.message,
+          'Push certificate is invalid:\n' +
+          'No public keys found for key ID E5E20E52');
+      assert.equal(result.icon, 'gr-icons:close');
+      assert.equal(result.class, 'invalid');
     });
 
-    test('_computeShowUploader test that it does not return uploader', () => {
-      const change = {
-        change_id: 'Iad9dc96274af6946f3632be53b106ef80f7ba6ca',
-        owner: {
-          _account_id: 1011123,
+    test('Push Certificate Validation test TRUSTED', () => {
+      const serverConfig = {
+        receive: {
+          enable_signed_push: true,
         },
-        revisions: {
-          rev1: {
-            _number: 1,
-            uploader: {
-              _account_id: 1011123,
-            },
-          },
-        },
-        current_revision: 'rev1',
-        status: 'NEW',
-        labels: {},
-        mergeable: true,
       };
-      assert.isNotOk(element._computeShowUploader(change));
-    });
-
-    test('no current_revision makes _computeShowUploader return null', () => {
-      const change = {
-        change_id: 'Iad9dc96274af6946f3632be53b106ef80f7ba6ca',
-        owner: {
-          _account_id: 1011123,
-        },
-        revisions: {
-          rev1: {
-            _number: 1,
-            uploader: {
-              _account_id: 1011123,
-            },
-          },
-        },
-        status: 'NEW',
-        labels: {},
-        mergeable: true,
-      };
-      assert.isNotOk(element._computeShowUploader(change));
-    });
-
-    test('_computeShowUploaderHide test for string which equals true', () => {
       const change = {
         change_id: 'Iad9dc96274af6946f3632be53b106ef80f7ba6ca',
         owner: {
@@ -260,8 +363,10 @@
         revisions: {
           rev1: {
             _number: 1,
-            uploader: {
-              _account_id: 1011123,
+            push_certificate: {
+              key: {
+                status: 'TRUSTED',
+              },
             },
           },
         },
@@ -270,21 +375,28 @@
         labels: {},
         mergeable: true,
       };
-      assert.equal(element._computeShowUploaderHide(change), '');
+      const result =
+          element._computePushCertificateValidation(serverConfig, change);
+      assert.equal(result.message,
+          'Push certificate is valid and key is trusted');
+      assert.equal(result.icon, 'gr-icons:check');
+      assert.equal(result.class, 'trusted');
     });
 
-    test('_computeShowUploaderHide test for hideDisplay', () => {
+    test('Push Certificate Validation is missing test', () => {
+      const serverConfig = {
+        receive: {
+          enable_signed_push: true,
+        },
+      };
       const change = {
         change_id: 'Iad9dc96274af6946f3632be53b106ef80f7ba6ca',
         owner: {
-          _account_id: 1011123,
+          _account_id: 1019328,
         },
         revisions: {
           rev1: {
             _number: 1,
-            uploader: {
-              _account_id: 1011123,
-            },
           },
         },
         current_revision: 'rev1',
@@ -292,8 +404,12 @@
         labels: {},
         mergeable: true,
       };
-      assert.equal(
-          element._computeShowUploaderHide(change), 'hideDisplay');
+      const result =
+          element._computePushCertificateValidation(serverConfig, change);
+      assert.equal(result.message,
+          'This patch set was created without a push certificate');
+      assert.equal(result.icon, 'gr-icons:help');
+      assert.equal(result.class, 'help');
     });
 
     test('_computeParents', () => {
@@ -587,6 +703,20 @@
       });
     });
 
+    test('editTopic', () => {
+      element.account = {test: true};
+      element.change = {actions: {topic: {enabled: true}}};
+      flushAsynchronousOperations();
+
+      const label = element.$$('.topicEditableLabel');
+      assert.ok(label);
+      sandbox.stub(label, 'open');
+      element.editTopic();
+      flushAsynchronousOperations();
+
+      assert.isTrue(label.open.called);
+    });
+
     suite('plugin endpoints', () => {
       test('endpoint params', done => {
         element.change = {labels: {}};
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
index 24453be..bb51fcc 100644
--- 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
@@ -291,6 +291,7 @@
         [this.Shortcut.EXPAND_ALL_MESSAGES]: '_handleExpandAllMessages',
         [this.Shortcut.COLLAPSE_ALL_MESSAGES]: '_handleCollapseAllMessages',
         [this.Shortcut.OPEN_DIFF_PREFS]: '_handleOpenDiffPrefsShortcut',
+        [this.Shortcut.EDIT_TOPIC]: '_handleEditTopic',
       };
     },
 
@@ -461,9 +462,9 @@
     },
 
     _handleCommentSave(e) {
-      if (!e.target.comment.__draft) { return; }
+      const draft = e.detail.comment;
+      if (!draft.__draft) { return; }
 
-      const draft = e.target.comment;
       draft.patch_set = draft.patch_set || this._patchRange.patchNum;
 
       // The use of path-based notification helpers (set, push) can’t be used
@@ -493,9 +494,9 @@
     },
 
     _handleCommentDiscard(e) {
-      if (!e.target.comment.__draft) { return; }
+      const draft = e.detail.comment;
+      if (!draft.__draft) { return; }
 
-      const draft = e.target.comment;
       if (!this._diffDrafts[draft.path]) {
         return;
       }
@@ -944,6 +945,14 @@
       this.$.downloadOverlay.open();
     },
 
+    _handleEditTopic(e) {
+      if (this.shouldSuppressKeyboardShortcut(e) ||
+          this.modifierPressed(e)) { return; }
+
+      e.preventDefault();
+      this.$.metadata.editTopic();
+    },
+
     _handleRefreshChange(e) {
       if (this.shouldSuppressKeyboardShortcut(e)) { return; }
       e.preventDefault();
diff --git a/polygerrit-ui/app/elements/change/gr-change-view/gr-change-view_test.html b/polygerrit-ui/app/elements/change/gr-change-view/gr-change-view_test.html
index df06e55..a88142e 100644
--- a/polygerrit-ui/app/elements/change/gr-change-view/gr-change-view_test.html
+++ b/polygerrit-ui/app/elements/change/gr-change-view/gr-change-view_test.html
@@ -54,6 +54,7 @@
     kb.bindShortcut(kb.Shortcut.EXPAND_ALL_MESSAGES, 'x');
     kb.bindShortcut(kb.Shortcut.COLLAPSE_ALL_MESSAGES, 'z');
     kb.bindShortcut(kb.Shortcut.OPEN_DIFF_PREFS, ',');
+    kb.bindShortcut(kb.Shortcut.EDIT_TOPIC, 't');
 
     let element;
     let sandbox;
@@ -109,6 +110,12 @@
     });
 
     suite('keyboard shortcuts', () => {
+      test('t to add topic', () => {
+        const editStub = sandbox.stub(element.$.metadata, 'editTopic');
+        MockInteractions.pressAndReleaseKeyOn(element, 83, null, 't');
+        assert(editStub.called);
+      });
+
       test('S should toggle the CL star', () => {
         const starStub = sandbox.stub(element.$.changeStar, 'toggleStar');
         MockInteractions.pressAndReleaseKeyOn(element, 83, null, 's');
@@ -592,6 +599,7 @@
       };
       element._change = {
         change_id: 'Iad9dc96274af6946f3632be53b106ef80f7ba6ca',
+        owner: {email: 'abc@def'},
         revisions: {
           rev2: {_number: 2, commit: {parents: []}},
           rev1: {_number: 1, commit: {parents: []}},
@@ -658,12 +666,12 @@
         path: '/foo/bar.txt',
         text: 'hello',
       };
-      element._handleCommentSave({target: {comment: draft}});
+      element._handleCommentSave({detail: {comment: draft}});
       draft.patch_set = 2;
       assert.deepEqual(element._diffDrafts, {'/foo/bar.txt': [draft]});
       draft.patch_set = null;
       draft.text = 'hello, there';
-      element._handleCommentSave({target: {comment: draft}});
+      element._handleCommentSave({detail: {comment: draft}});
       draft.patch_set = 2;
       assert.deepEqual(element._diffDrafts, {'/foo/bar.txt': [draft]});
       const draft2 = {
@@ -672,14 +680,14 @@
         path: '/foo/bar.txt',
         text: 'hola',
       };
-      element._handleCommentSave({target: {comment: draft2}});
+      element._handleCommentSave({detail: {comment: draft2}});
       draft2.patch_set = 2;
       assert.deepEqual(element._diffDrafts, {'/foo/bar.txt': [draft, draft2]});
       draft.patch_set = null;
-      element._handleCommentDiscard({target: {comment: draft}});
+      element._handleCommentDiscard({detail: {comment: draft}});
       draft.patch_set = 2;
       assert.deepEqual(element._diffDrafts, {'/foo/bar.txt': [draft2]});
-      element._handleCommentDiscard({target: {comment: draft2}});
+      element._handleCommentDiscard({detail: {comment: draft2}});
       assert.deepEqual(element._diffDrafts, {});
     });
 
diff --git a/polygerrit-ui/app/elements/change/gr-related-changes-list/gr-related-changes-list.js b/polygerrit-ui/app/elements/change/gr-related-changes-list/gr-related-changes-list.js
index f2cfe87..39f4d8d 100644
--- a/polygerrit-ui/app/elements/change/gr-related-changes-list/gr-related-changes-list.js
+++ b/polygerrit-ui/app/elements/change/gr-related-changes-list/gr-related-changes-list.js
@@ -82,7 +82,7 @@
     ],
 
     observers: [
-      '_resultsChanged(_relatedResponse.changes, _submittedTogether, ' +
+      '_resultsChanged(_relatedResponse, _submittedTogether, ' +
           '_conflicts, _cherryPicks, _sameTopic)',
     ],
 
diff --git a/polygerrit-ui/app/elements/core/gr-navigation/gr-navigation.html b/polygerrit-ui/app/elements/core/gr-navigation/gr-navigation.html
index 5be259c..b1433a3 100644
--- a/polygerrit-ui/app/elements/core/gr-navigation/gr-navigation.html
+++ b/polygerrit-ui/app/elements/core/gr-navigation/gr-navigation.html
@@ -166,6 +166,7 @@
         CHANGE: 'change',
         DASHBOARD: 'dashboard',
         DIFF: 'diff',
+        DOCUMENTATION_SEARCH: 'documentation-search',
         EDIT: 'edit',
         GROUP: 'group',
         PLUGIN_SCREEN: 'plugin-screen',
@@ -521,14 +522,15 @@
 
       /**
        * @param {string} repo The name of the repo.
-       * @param {!Array} sections The sections to display in the dashboard
+       * @param {string} dashboard The ID of the dashboard, in the form of
+       *     '<ref>:<path>'.
        * @return {string}
        */
-      getUrlForCustomDashboard(repo, sections) {
+      getUrlForRepoDashboard(repo, dashboard) {
         return this._getUrlFor({
-          repo,
           view: Gerrit.Nav.View.DASHBOARD,
-          sections,
+          repo,
+          dashboard,
         });
       },
 
diff --git a/polygerrit-ui/app/elements/core/gr-router/gr-router.js b/polygerrit-ui/app/elements/core/gr-router/gr-router.js
index 2ea7e37..8ae30ad 100644
--- a/polygerrit-ui/app/elements/core/gr-router/gr-router.js
+++ b/polygerrit-ui/app/elements/core/gr-router/gr-router.js
@@ -148,6 +148,10 @@
     IMPROPERLY_ENCODED_PLUS: /^\/c\/(.+)\/\ \/(.+)$/,
 
     PLUGIN_SCREEN: /^\/x\/([\w-]+)\/([\w-]+)\/?/,
+
+    DOCUMENTATION_SEARCH_FILTER: '/Documentation/q/filter::filter',
+    DOCUMENTATION_SEARCH: /^\/Documentation\/q\/(.*)$/,
+    DOCUMENTATION: /^\/Documentation(\/)?(.+)?/,
   };
 
   /**
@@ -320,14 +324,10 @@
       if (!weblinks || !weblinks.length) return [];
       return weblinks.filter(weblink => !this._isDirectCommit(weblink)).map(
           ({name, url}) => {
-            if (url.startsWith('https:') || url.startsWith('http:')) {
-              return {name, url};
-            } else {
-              return {
-                name,
-                url: `../../${url}`,
-              };
+            if (!url.startsWith('https:') && !url.startsWith('http:')) {
+              url = this.getBaseUrl() + (url.startsWith('/') ? '' : '/') + url;
             }
+            return {name, url};
           });
     },
 
@@ -423,7 +423,8 @@
         return `/dashboard/${user}?${queryParams.join('&')}`;
       } else if (repoName) {
         // Project dashboard.
-        return `/p/${repoName}/+/dashboard/${params.dashboard}`;
+        const encodedRepo = this.encodeURL(repoName, true);
+        return `/p/${encodedRepo}/+/dashboard/${params.dashboard}`;
       } else {
         // User dashboard.
         return `/dashboard/${params.user || 'self'}`;
@@ -848,6 +849,17 @@
 
       this._mapRoute(RoutePattern.PLUGIN_SCREEN, '_handlePluginScreen');
 
+      this._mapRoute(RoutePattern.DOCUMENTATION_SEARCH_FILTER,
+          '_handleDocumentationSearchRoute');
+
+      // redirects /Documentation/q/* to /Documentation/q/filter:*
+      this._mapRoute(RoutePattern.DOCUMENTATION_SEARCH,
+          '_handleDocumentationSearchRedirectRoute');
+
+      // Makes sure /Documentation/* links work (doin't return 404)
+      this._mapRoute(RoutePattern.DOCUMENTATION,
+          '_handleDocumentationRedirectRoute');
+
       // Note: this route should appear last so it only catches URLs unmatched
       // by other patterns.
       this._mapRoute(RoutePattern.DEFAULT, '_handleDefaultRoute');
@@ -1425,6 +1437,27 @@
       this._setParams({view, plugin, screen});
     },
 
+    _handleDocumentationSearchRoute(data) {
+      this._setParams({
+        view: Gerrit.Nav.View.DOCUMENTATION_SEARCH,
+        filter: data.params.filter || null,
+      });
+    },
+
+    _handleDocumentationSearchRedirectRoute(data) {
+      this._redirect('/Documentation/q/filter:' +
+          encodeURIComponent(data.params[0]));
+    },
+
+    _handleDocumentationRedirectRoute(data) {
+      if (data.params[1]) {
+        location.reload();
+      } else {
+        // Redirect /Documentation to /Documentation/index.html
+        this._redirect('/Documentation/index.html');
+      }
+    },
+
     /**
      * Catchall route for when no other route is matched.
      */
diff --git a/polygerrit-ui/app/elements/core/gr-router/gr-router_test.html b/polygerrit-ui/app/elements/core/gr-router/gr-router_test.html
index 7d4f9ba..584fb35 100644
--- a/polygerrit-ui/app/elements/core/gr-router/gr-router_test.html
+++ b/polygerrit-ui/app/elements/core/gr-router/gr-router_test.html
@@ -44,6 +44,23 @@
 
     teardown(() => { sandbox.restore(); });
 
+    test('_getChangeWeblinks', () => {
+      sandbox.stub(element, '_isDirectCommit').returns(false);
+      sandbox.stub(element, 'getBaseUrl').returns('base');
+      const link = {name: 'test', url: 'test/url'};
+      const mapLinksToConfig = weblink => ({options: {weblinks: [weblink]}});
+      assert.deepEqual(element._getChangeWeblinks(mapLinksToConfig(link))[0],
+          {name: 'test', url: 'base/test/url'});
+
+      link.url = '/' + link.url;
+      assert.deepEqual(element._getChangeWeblinks(mapLinksToConfig(link))[0],
+          {name: 'test', url: 'base/test/url'});
+
+      link.url = 'https:/' + link.url;
+      assert.deepEqual(element._getChangeWeblinks(mapLinksToConfig(link))[0],
+          {name: 'test', url: 'https://test/url'});
+    });
+
     test('_getHashFromCanonicalPath', () => {
       let url = '/foo/bar';
       let hash = element._getHashFromCanonicalPath(url);
@@ -157,6 +174,9 @@
         '_handleDefaultRoute',
         '_handleChangeLegacyRoute',
         '_handleDiffLegacyRoute',
+        '_handleDocumentationRedirectRoute',
+        '_handleDocumentationSearchRoute',
+        '_handleDocumentationSearchRedirectRoute',
         '_handleLegacyLinenum',
         '_handleImproperlyEncodedPlusRoute',
         '_handlePassThroughRoute',
diff --git a/polygerrit-ui/app/elements/diff/gr-diff-builder/gr-diff-builder-binary.js b/polygerrit-ui/app/elements/diff/gr-diff-builder/gr-diff-builder-binary.js
index b2fc64c..a9242be 100644
--- a/polygerrit-ui/app/elements/diff/gr-diff-builder/gr-diff-builder-binary.js
+++ b/polygerrit-ui/app/elements/diff/gr-diff-builder/gr-diff-builder-binary.js
@@ -14,14 +14,16 @@
  * See the License for the specific language governing permissions and
  * limitations under the License.
  */
-(function(window, GrDiffBuilderSideBySide) {
+(function(window, GrDiffBuilder) {
   'use strict';
 
   // Prevent redefinition.
   if (window.GrDiffBuilderBinary) { return; }
 
-  function GrDiffBuilderBinary(diff, comments, prefs, outputEl) {
-    GrDiffBuilder.call(this, diff, comments, null, prefs, outputEl);
+  function GrDiffBuilderBinary(diff, patchRange, commentThreadEls, prefs,
+      outputEl) {
+    GrDiffBuilder.call(this, diff, patchRange, commentThreadEls, prefs,
+        outputEl);
   }
 
   GrDiffBuilderBinary.prototype = Object.create(GrDiffBuilder.prototype);
@@ -43,4 +45,4 @@
   };
 
   window.GrDiffBuilderBinary = GrDiffBuilderBinary;
-})(window, GrDiffBuilderSideBySide);
+})(window, GrDiffBuilder);
diff --git a/polygerrit-ui/app/elements/diff/gr-diff-builder/gr-diff-builder-image.js b/polygerrit-ui/app/elements/diff/gr-diff-builder/gr-diff-builder-image.js
index 88ff79b..c52a504 100644
--- a/polygerrit-ui/app/elements/diff/gr-diff-builder/gr-diff-builder-image.js
+++ b/polygerrit-ui/app/elements/diff/gr-diff-builder/gr-diff-builder-image.js
@@ -22,9 +22,9 @@
 
   const IMAGE_MIME_PATTERN = /^image\/(bmp|gif|jpeg|jpg|png|tiff|webp)$/;
 
-  function GrDiffBuilderImage(diff, comments, createThreadGroupFn, prefs,
+  function GrDiffBuilderImage(diff, patchRange, commentThreadEls, prefs,
       outputEl, baseImage, revisionImage) {
-    GrDiffBuilderSideBySide.call(this, diff, comments, createThreadGroupFn,
+    GrDiffBuilderSideBySide.call(this, diff, patchRange, commentThreadEls,
         prefs, outputEl, []);
     this._baseImage = baseImage;
     this._revisionImage = revisionImage;
diff --git a/polygerrit-ui/app/elements/diff/gr-diff-builder/gr-diff-builder-side-by-side.js b/polygerrit-ui/app/elements/diff/gr-diff-builder/gr-diff-builder-side-by-side.js
index fafae63..da085c2 100644
--- a/polygerrit-ui/app/elements/diff/gr-diff-builder/gr-diff-builder-side-by-side.js
+++ b/polygerrit-ui/app/elements/diff/gr-diff-builder/gr-diff-builder-side-by-side.js
@@ -20,9 +20,9 @@
   // Prevent redefinition.
   if (window.GrDiffBuilderSideBySide) { return; }
 
-  function GrDiffBuilderSideBySide(diff, comments, createThreadGroupFn, prefs,
-      outputEl, layers) {
-    GrDiffBuilder.call(this, diff, comments, createThreadGroupFn, prefs,
+  function GrDiffBuilderSideBySide(diff, patchRange, commentThreadEls,
+      prefs, outputEl, layers) {
+    GrDiffBuilder.call(this, diff, patchRange, commentThreadEls, prefs,
         outputEl, layers);
   }
   GrDiffBuilderSideBySide.prototype = Object.create(GrDiffBuilder.prototype);
diff --git a/polygerrit-ui/app/elements/diff/gr-diff-builder/gr-diff-builder-unified.js b/polygerrit-ui/app/elements/diff/gr-diff-builder/gr-diff-builder-unified.js
index 9a04b1f..0657ee4 100644
--- a/polygerrit-ui/app/elements/diff/gr-diff-builder/gr-diff-builder-unified.js
+++ b/polygerrit-ui/app/elements/diff/gr-diff-builder/gr-diff-builder-unified.js
@@ -20,9 +20,9 @@
   // Prevent redefinition.
   if (window.GrDiffBuilderUnified) { return; }
 
-  function GrDiffBuilderUnified(diff, comments, createThreadGroupFn, prefs,
+  function GrDiffBuilderUnified(diff, patchRange, commentThreadEls, prefs,
       outputEl, layers) {
-    GrDiffBuilder.call(this, diff, comments, createThreadGroupFn, prefs,
+    GrDiffBuilder.call(this, diff, patchRange, commentThreadEls, prefs,
         outputEl, layers);
   }
   GrDiffBuilderUnified.prototype = Object.create(GrDiffBuilder.prototype);
diff --git a/polygerrit-ui/app/elements/diff/gr-diff-builder/gr-diff-builder.html b/polygerrit-ui/app/elements/diff/gr-diff-builder/gr-diff-builder.html
index 0997fee..e77eb57 100644
--- a/polygerrit-ui/app/elements/diff/gr-diff-builder/gr-diff-builder.html
+++ b/polygerrit-ui/app/elements/diff/gr-diff-builder/gr-diff-builder.html
@@ -16,9 +16,9 @@
 -->
 <link rel="import" href="../../../bower_components/polymer/polymer.html">
 <link rel="import" href="../../core/gr-reporting/gr-reporting.html">
-<link rel="import" href="../gr-diff-comment-thread/gr-diff-comment-thread.html">
-<link rel="import" href="../gr-diff-comment-thread-group/gr-diff-comment-thread-group.html">
+<link rel="import" href="../../shared/gr-js-api-interface/gr-js-api-interface.html">
 <link rel="import" href="../gr-diff-processor/gr-diff-processor.html">
+<link rel="import" href="../gr-diff-comment-thread/gr-diff-comment-thread.html">
 <link rel="import" href="../gr-ranged-comment-layer/gr-ranged-comment-layer.html">
 <link rel="import" href="../gr-syntax-layer/gr-syntax-layer.html">
 
@@ -29,7 +29,7 @@
     </div>
     <gr-ranged-comment-layer
         id="rangeLayer"
-        comments="[[comments]]"></gr-ranged-comment-layer>
+        comment-ranges="[[commentRanges]]"></gr-ranged-comment-layer>
     <gr-syntax-layer
         id="syntaxLayer"
         diff="[[diff]]"></gr-syntax-layer>
@@ -109,31 +109,35 @@
           changeNum: String,
           patchNum: String,
           viewMode: String,
-          comments: Object,
           isImageDiff: Boolean,
           baseImage: Object,
           revisionImage: Object,
+          parentIndex: Number,
+          path: String,
           projectName: String,
           /**
            * @type {Defs.LineOfInterest|null}
            */
           lineOfInterest: Object,
 
-          /**
-           * @type {function(number, booleam, !string)}
-           */
-          createCommentFn: Function,
-
           _builder: Object,
           _groups: Array,
           _layers: Array,
           _showTabs: Boolean,
+          /** @type {!Array<!Gerrit.HoveredRange>} */
+          commentRanges: {
+            type: Array,
+          },
         },
 
         get diffElement() {
           return this.queryEffectiveChildren('#diffTable');
         },
 
+        get _commentThreadElements() {
+          return this.queryAllEffectiveChildren('.comment-thread');
+        },
+
         observers: [
           '_groupsChanged(_groups.splices)',
         ],
@@ -155,10 +159,6 @@
           }
 
           this._layers = layers;
-
-          this.async(() => {
-            this._preRenderThread();
-          });
         },
 
         render(comments, prefs) {
@@ -169,7 +169,8 @@
           // Stop the processor and syntax layer (if they're running).
           this.cancel();
 
-          this._builder = this._getDiffBuilder(this.diff, comments, prefs);
+          this._builder = this._getDiffBuilder(
+              this.diff, comments.meta.patchRange, prefs);
 
           this.$.processor.context = prefs.context;
           this.$.processor.keyLocations = this._getKeyLocations(comments,
@@ -293,7 +294,7 @@
           throw Error(`Invalid preference value: ${pref}`);
         },
 
-        _getDiffBuilder(diff, comments, prefs) {
+        _getDiffBuilder(diff, patchRange, prefs) {
           if (isNaN(prefs.tab_size) || prefs.tab_size <= 0) {
             this._handlePreferenceError('tab size');
             return;
@@ -305,20 +306,22 @@
           }
 
           let builder = null;
-          const createFn = this.createCommentFn;
           if (this.isImageDiff) {
-            builder = new GrDiffBuilderImage(diff, comments, createFn, prefs,
-              this.diffElement, this.baseImage, this.revisionImage);
+            builder = new GrDiffBuilderImage(diff, patchRange,
+              this._commentThreadElements, prefs, this.diffElement,
+              this.baseImage, this.revisionImage);
           } else if (diff.binary) {
             // If the diff is binary, but not an image.
-            return new GrDiffBuilderBinary(diff, comments, prefs,
-                this.diffElement);
+            return new GrDiffBuilderBinary(diff, patchRange,
+                this._commentThreadElements, prefs, this.diffElement);
           } else if (this.viewMode === DiffViewMode.SIDE_BY_SIDE) {
-            builder = new GrDiffBuilderSideBySide(diff, comments, createFn,
-                prefs, this.diffElement, this._layers);
+            builder = new GrDiffBuilderSideBySide(diff, patchRange,
+                this._commentThreadElements, prefs, this.diffElement,
+                this._layers);
           } else if (this.viewMode === DiffViewMode.UNIFIED) {
-            builder = new GrDiffBuilderUnified(diff, comments, createFn, prefs,
-                this.diffElement, this._layers);
+            builder = new GrDiffBuilderUnified(diff, patchRange,
+                this._commentThreadElements, prefs, this.diffElement,
+                this._layers);
           }
           if (!builder) {
             throw Error('Unsupported diff view mode: ' + this.viewMode);
@@ -445,25 +448,6 @@
         },
 
         /**
-         * In pages with large diffs, creating the first comment thread can be
-         * slow because nested Polymer elements (particularly
-         * iron-autogrow-textarea) add style elements to the document head,
-         * which, in turn, triggers a reflow on the page. Create a hidden
-         * thread, attach it to the page, and remove it so the stylesheet will
-         * already exist and the user's comment will be quick to load.
-         * @see https://gerrit-review.googlesource.com/c/82213/
-         */
-        _preRenderThread() {
-          const thread = document.createElement('gr-diff-comment-thread');
-          thread.setAttribute('hidden', true);
-          thread.addDraft();
-          const parent = Polymer.dom(this.root);
-          parent.appendChild(thread);
-          Polymer.dom.flush();
-          parent.removeChild(thread);
-        },
-
-        /**
          * @return {boolean} whether any of the lines in _groups are longer
          * than SYNTAX_MAX_LINE_LENGTH.
          */
diff --git a/polygerrit-ui/app/elements/diff/gr-diff-builder/gr-diff-builder.js b/polygerrit-ui/app/elements/diff/gr-diff-builder/gr-diff-builder.js
index be53fda..6ea48ac 100644
--- a/polygerrit-ui/app/elements/diff/gr-diff-builder/gr-diff-builder.js
+++ b/polygerrit-ui/app/elements/diff/gr-diff-builder/gr-diff-builder.js
@@ -20,6 +20,60 @@
   // Prevent redefinition.
   if (window.GrDiffBuilder) { return; }
 
+  /** @enum {string} */
+  Gerrit.DiffSide = {
+    LEFT: 'left',
+    RIGHT: 'right',
+    BOTH: 'both',
+  };
+
+  /**
+   * @param {!Array<!HTMLElement>} threadEls
+   * @param {!{beforeNumber: (number|string|undefined),
+   *           afterNumber: (number|string|undefined)}}
+   *     lineInfo
+   * @param {!Gerrit.DiffSide=} side The side (LEFT, RIGHT, BOTH) for
+   *     which to return the threads (default: BOTH).
+   * @return {!Array<!HTMLElement>} The thread elements matching the given
+   *     location.
+   */
+  Gerrit.filterThreadElsForLocation = function(
+      threadEls, lineInfo, side = Gerrit.DiffSide.BOTH) {
+    function matchesLeftLine(threadEl) {
+      return threadEl.getAttribute('comment-side') ==
+          Gerrit.DiffSide.LEFT &&
+          threadEl.getAttribute('line-num') == lineInfo.beforeNumber;
+    }
+    function matchesRightLine(threadEl) {
+      return threadEl.getAttribute('comment-side') ==
+          Gerrit.DiffSide.RIGHT &&
+          threadEl.getAttribute('line-num') == lineInfo.afterNumber;
+    }
+    function matchesFileComment(threadEl) {
+      return (side === Gerrit.DiffSide.BOTH ||
+              threadEl.getAttribute('comment-side') == side) &&
+            // line/range comments have 1-based line set, if line is falsy it's
+            // a file comment
+            !threadEl.getAttribute('line-num');
+    }
+
+    // Select the appropriate matchers for the desired side and line
+    // If side is BOTH, we want both the left and right matcher.
+    const matchers = [];
+    if (side !== Gerrit.DiffSide.RIGHT) {
+      matchers.push(matchesLeftLine);
+    }
+    if (side !== Gerrit.DiffSide.LEFT) {
+      matchers.push(matchesRightLine);
+    }
+    if (lineInfo.afterNumber === 'FILE' ||
+        lineInfo.beforeNumber === 'FILE') {
+      matchers.push(matchesFileComment);
+    }
+    return threadEls.filter(threadEl =>
+        matchers.some(matcher => matcher(threadEl)));
+  };
+
   /**
    * In JS, unicode code points above 0xFFFF occupy two elements of a string.
    * For example '𐀏'.length is 2. An occurence of such a code point is called a
@@ -42,11 +96,10 @@
    */
   const REGEX_TAB_OR_SURROGATE_PAIR = /\t|[\uD800-\uDBFF][\uDC00-\uDFFF]/;
 
-  function GrDiffBuilder(diff, comments, createThreadGroupFn, prefs, outputEl,
-      layers) {
+  function GrDiffBuilder(diff, patchRange, commentThreadEls, prefs,
+      outputEl, layers) {
     this._diff = diff;
-    this._comments = comments;
-    this._createThreadGroupFn = createThreadGroupFn;
+    this._patchRange = patchRange;
     this._prefs = prefs;
     this._outputEl = outputEl;
     this.groups = [];
@@ -68,15 +121,7 @@
       }
     }
 
-    const allComments = [];
-    for (const side of [GrDiffBuilder.Side.LEFT, GrDiffBuilder.Side.RIGHT]) {
-      // This is needed by the threading.
-      for (const comment of this._comments[side]) {
-        comment.__commentSide = side;
-      }
-      allComments.push(...this._comments[side]);
-    }
-    this._threads = this._createThreads(allComments);
+    this._threadEls = commentThreadEls;
   }
 
   GrDiffBuilder.GroupType = {
@@ -330,150 +375,26 @@
   };
 
   /**
-   * @param {!Array<Object>} threads
    * @param {!GrDiffLine} line
    * @param {!GrDiffBuilder.Side=} side The side (LEFT, RIGHT, BOTH) for which
-   *     to return the threads (default: BOTH).
-   */
-  GrDiffBuilder.prototype._filterThreadsForLine = function(
-      threads, line, side = GrDiffBuilder.Side.BOTH) {
-    function matchesLeftLine(thread) {
-      return thread.commentSide == GrDiffBuilder.Side.LEFT &&
-          thread.comments[0].line == line.beforeNumber;
-    }
-    function matchesRightLine(thread) {
-      return thread.commentSide == GrDiffBuilder.Side.RIGHT &&
-          thread.comments[0].line == line.afterNumber;
-    }
-    function matchesFileComment(thread) {
-      return (side === GrDiffBuilder.Side.BOTH ||
-              thread.commentSide == side) &&
-             // line/range comments have 1-based line set, if line is falsy it's
-             // a file comment
-             !thread.comments[0].line;
-    }
-
-    // Select the appropriate matchers for the desired side and line
-    // If side is BOTH, we want both the left and right matcher.
-    const matchers = [];
-    if (side !== GrDiffBuilder.Side.RIGHT) {
-      matchers.push(matchesLeftLine);
-    }
-    if (side !== GrDiffBuilder.Side.LEFT) {
-      matchers.push(matchesRightLine);
-    }
-    if (line.afterNumber === GrDiffLine.FILE ||
-        line.beforeNumber === GrDiffLine.FILE) {
-      matchers.push(matchesFileComment);
-    }
-
-    return threads.filter(thread => matchers.find(matcher => matcher(thread)));
-  };
-
-  /**
-   * @param {Array<Object>} comments
-   */
-  GrDiffBuilder.prototype._createThreads = function(comments) {
-    const sortedComments = comments.slice(0).sort((a, b) => {
-      if (b.__draft && !a.__draft ) { return 0; }
-      if (a.__draft && !b.__draft ) { return 1; }
-      return util.parseDate(a.updated) - util.parseDate(b.updated);
-    });
-
-    const threads = [];
-    for (const comment of sortedComments) {
-      // If the comment is in reply to another comment, find that comment's
-      // thread and append to it.
-      if (comment.in_reply_to) {
-        const thread = threads.find(thread =>
-            thread.comments.some(c => c.id === comment.in_reply_to));
-        if (thread) {
-          thread.comments.push(comment);
-          continue;
-        }
-      }
-
-      // Otherwise, this comment starts its own thread.
-      const newThread = {
-        start_datetime: comment.updated,
-        comments: [comment],
-        commentSide: comment.__commentSide,
-        patchNum: comment.patch_set,
-        rootId: comment.id || comment.__draftID,
-      };
-      if (comment.range) {
-        newThread.range = Object.assign({}, comment.range);
-      }
-      threads.push(newThread);
-    }
-    return threads;
-  };
-
-  /**
-   * Returns the patch number that new comment threads should be attached to.
-   *
-   * @param {GrDiffLine} line The line new thread will be attached to.
-   * @param {string=} opt_side Set to LEFT to force adding it to the LEFT side -
-   *     will be ignored if the left is a parent or a merge parent
-   * @return {number} Patch set to attach the new thread to
-   */
-  GrDiffBuilder.prototype._determinePatchNumForNewThreads = function(
-      patchRange, line, opt_side) {
-    if ((line.type === GrDiffLine.Type.REMOVE ||
-         opt_side === GrDiffBuilder.Side.LEFT) &&
-        patchRange.basePatchNum !== 'PARENT' &&
-        !Gerrit.PatchSetBehavior.isMergeParent(patchRange.basePatchNum)) {
-      return patchRange.basePatchNum;
-    } else {
-      return patchRange.patchNum;
-    }
-  };
-
-  /**
-   * Returns whether the comments on the given line are on a (merge) parent.
-   *
-   * @param {string} firstCommentSide
-   * @param {{basePatchNum: number, patchNum: number}} patchRange
-   * @param {GrDiffLine} line The line the comments are on.
-   * @param {string=} opt_side
-   * @return {boolean} True iff the comments on the given line are on a (merge)
-   *    parent.
-   */
-  GrDiffBuilder.prototype._determineIsOnParent = function(
-      firstCommentSide, patchRange, line, opt_side) {
-    return ((line.type === GrDiffLine.Type.REMOVE ||
-             opt_side === GrDiffBuilder.Side.LEFT) &&
-            (patchRange.basePatchNum === 'PARENT' ||
-             Gerrit.PatchSetBehavior.isMergeParent(
-                 patchRange.basePatchNum))) ||
-          firstCommentSide === 'PARENT';
-  };
-
-  /**
-   * @param {!GrDiffLine} line
-   * @param {!GrDiffBuilder.Side=} side The side (LEFT, RIGHT, BOTH) for which to return
-   *     the thread group (default: BOTH).
+   *     to return the thread group (default: BOTH).
    * @return {!Object}
    */
   GrDiffBuilder.prototype._commentThreadGroupForLine = function(
-      line, side = GrDiffBuilder.Side.BOTH) {
-    const threads =
-        this._filterThreadsForLine(this._threads, line, side);
-    if (!threads || threads.length === 0) {
+      line, commentSide = GrDiffBuilder.Side.BOTH) {
+    const threadElsForGroup =
+        Gerrit.filterThreadElsForLocation(this._threadEls, line, commentSide);
+    if (!threadElsForGroup || threadElsForGroup.length === 0) {
       return null;
     }
 
-    const patchRange = this._comments.meta.patchRange;
-    const patchNumForNewThread = this._determinePatchNumForNewThreads(
-        patchRange, line, side);
-    const isOnParent = this._determineIsOnParent(
-        threads[0].side, patchRange, line, side);
-
-    const threadGroupEl = this._createThreadGroupFn(
-        patchNumForNewThread, isOnParent, side);
-    threadGroupEl.threads = threads;
-    if (side) {
-      threadGroupEl.setAttribute('data-side', side);
+    const threadGroupEl = document.createElement('div');
+    threadGroupEl.className = 'thread-group';
+    for (const threadEl of threadElsForGroup) {
+      Polymer.dom(threadGroupEl).appendChild(threadEl);
+    }
+    if (commentSide) {
+      threadGroupEl.setAttribute('data-side', commentSide);
     }
     return threadGroupEl;
   };
diff --git a/polygerrit-ui/app/elements/diff/gr-diff-builder/gr-diff-builder_test.html b/polygerrit-ui/app/elements/diff/gr-diff-builder/gr-diff-builder_test.html
index 80b45a4..c277f34 100644
--- a/polygerrit-ui/app/elements/diff/gr-diff-builder/gr-diff-builder_test.html
+++ b/polygerrit-ui/app/elements/diff/gr-diff-builder/gr-diff-builder_test.html
@@ -60,7 +60,6 @@
     let prefs;
     let element;
     let builder;
-    let createThreadGroupFn;
     let sandbox;
     const LINE_FEED_HTML = '<span class="style-scope gr-diff br"></span>';
 
@@ -76,142 +75,69 @@
         show_tabs: true,
         tab_size: 4,
       };
-      createThreadGroupFn = sinon.spy(() => ({
-        setAttribute: sinon.spy(),
-      }));
       builder = new GrDiffBuilder(
-          {content: []}, {left: [], right: []}, createThreadGroupFn, prefs);
+          {content: []}, {left: [], right: []}, [], prefs);
     });
 
     teardown(() => { sandbox.restore(); });
 
-    test('_createThreads', () => {
-      const comments = [
-        {
-          id: 'sallys_confession',
-          message: 'i like you, jack',
-          updated: '2015-12-23 15:00:20.396000000',
-          __commentSide: 'left',
-        }, {
-          id: 'jacks_reply',
-          message: 'i like you, too',
-          updated: '2015-12-24 15:01:20.396000000',
-          __commentSide: 'left',
-          in_reply_to: 'sallys_confession',
-        },
-        {
-          id: 'new_draft',
-          message: 'i do not like either of you',
-          __commentSide: 'left',
-          __draft: true,
-          updated: '2015-12-20 15:01:20.396000000',
-        },
-      ];
+    test('filterThreadElsForLocation with no threads', () => {
+      const line = {beforeNumber: 3, afterNumber: 5};
 
-      const expectedThreadGroups = [
-        {
-          start_datetime: '2015-12-23 15:00:20.396000000',
-          commentSide: 'left',
-          comments: [{
-            id: 'sallys_confession',
-            message: 'i like you, jack',
-            updated: '2015-12-23 15:00:20.396000000',
-            __commentSide: 'left',
-          }, {
-            id: 'jacks_reply',
-            message: 'i like you, too',
-            updated: '2015-12-24 15:01:20.396000000',
-            __commentSide: 'left',
-            in_reply_to: 'sallys_confession',
-          }],
-          patchNum: undefined,
-          rootId: 'sallys_confession',
-        },
-        {
-          start_datetime: '2015-12-20 15:01:20.396000000',
-          commentSide: 'left',
-          comments: [
-            {
-              id: 'new_draft',
-              message: 'i do not like either of you',
-              __commentSide: 'left',
-              __draft: true,
-              updated: '2015-12-20 15:01:20.396000000',
-            },
-          ],
-          patchNum: undefined,
-          rootId: 'new_draft',
-        },
-      ];
-
-      assert.deepEqual(
-          builder._createThreads(comments),
-          expectedThreadGroups);
+      const threads = [];
+      assert.deepEqual(Gerrit.filterThreadElsForLocation(threads, line), []);
+      assert.deepEqual(Gerrit.filterThreadElsForLocation(threads, line,
+          Gerrit.DiffSide.LEFT), []);
+      assert.deepEqual(Gerrit.filterThreadElsForLocation(threads, line,
+          Gerrit.DiffSide.RIGHT), []);
     });
 
-    test('_createThreads inherits patchNum amd range', () => {
-      const comments = [{
-        id: 'betsys_confession',
-        message: 'i like you, jack',
-        updated: '2015-12-24 15:00:10.396000000',
-        range: {
-          start_line: 1,
-          start_character: 1,
-          end_line: 1,
-          end_character: 2,
-        },
-        patch_set: 5,
-        __commentSide: 'left',
-      }];
+    test('filterThreadElsForLocation for line comments', () => {
+      const line = {beforeNumber: 3, afterNumber: 5};
 
-      expectedThreadGroups = [
-        {
-          start_datetime: '2015-12-24 15:00:10.396000000',
-          commentSide: 'left',
-          comments: [{
-            id: 'betsys_confession',
-            message: 'i like you, jack',
-            updated: '2015-12-24 15:00:10.396000000',
-            range: {
-              start_line: 1,
-              start_character: 1,
-              end_line: 1,
-              end_character: 2,
-            },
-            patch_set: 5,
-            __commentSide: 'left',
-          }],
-          patchNum: 5,
-          rootId: 'betsys_confession',
-          range: {
-            start_line: 1,
-            start_character: 1,
-            end_line: 1,
-            end_character: 2,
-          },
-        },
-      ];
+      const l3 = document.createElement('div');
+      l3.setAttribute('line-num', 3);
+      l3.setAttribute('comment-side', 'left');
 
-      assert.deepEqual(
-          builder._createThreads(comments),
-          expectedThreadGroups);
+      const l5 = document.createElement('div');
+      l5.setAttribute('line-num', 5);
+      l5.setAttribute('comment-side', 'left');
+
+      const r3 = document.createElement('div');
+      r3.setAttribute('line-num', 3);
+      r3.setAttribute('comment-side', 'right');
+
+      const r5 = document.createElement('div');
+      r5.setAttribute('line-num', 5);
+      r5.setAttribute('comment-side', 'right');
+
+      const threadEls = [l3, l5, r3, r5];
+      assert.deepEqual(Gerrit.filterThreadElsForLocation(threadEls, line),
+          [l3, r5]);
+      assert.deepEqual(Gerrit.filterThreadElsForLocation(threadEls, line,
+          Gerrit.DiffSide.LEFT), [l3]);
+      assert.deepEqual(Gerrit.filterThreadElsForLocation(threadEls, line,
+          Gerrit.DiffSide.RIGHT), [r5]);
     });
 
-    test('multiple comments at same location but not threaded', () => {
-      const comments = [
-        {
-          id: 'sallys_confession',
-          message: 'i like you, jack',
-          updated: '2015-12-23 15:00:20.396000000',
-          __commentSide: 'left',
-        }, {
-          id: 'jacks_reply',
-          message: 'i like you, too',
-          updated: '2015-12-24 15:01:20.396000000',
-          __commentSide: 'left',
-        },
-      ];
-      assert.equal(builder._createThreads(comments).length, 2);
+    test('filterThreadElsForLocation for file comments', () => {
+      const line = {beforeNumber: 'FILE', afterNumber: 'FILE'};
+
+      const l = document.createElement('div');
+      l.setAttribute('comment-side', 'left');
+
+      const r = document.createElement('div');
+      r.setAttribute('comment-side', 'right');
+
+      const threadEls = [l, r];
+      assert.deepEqual(Gerrit.filterThreadElsForLocation(threadEls, line),
+          [l, r]);
+      assert.deepEqual(Gerrit.filterThreadElsForLocation(threadEls, line,
+          Gerrit.DiffSide.BOTH), [l, r]);
+      assert.deepEqual(Gerrit.filterThreadElsForLocation(threadEls, line,
+          Gerrit.DiffSide.LEFT), [l]);
+      assert.deepEqual(Gerrit.filterThreadElsForLocation(threadEls, line,
+          Gerrit.DiffSide.RIGHT), [r]);
     });
 
     test('_createElement classStr applies all classes', () => {
@@ -387,177 +313,83 @@
       }
     });
 
-    test('_filterThreadsForLine with no threads', () => {
-      const line = new GrDiffLine(GrDiffLine.Type.BOTH);
-      line.beforeNumber = 3;
-      line.afterNumber = 5;
-
-      const threads = [];
-      assert.deepEqual(
-          builder._filterThreadsForLine(threads, line), []);
-      assert.deepEqual(builder._filterThreadsForLine(threads, line,
-          GrDiffBuilder.Side.LEFT), []);
-      assert.deepEqual(builder._filterThreadsForLine(threads, line,
-          GrDiffBuilder.Side.RIGHT), []);
-    });
-
-    test('_filterThreadsForLine for line comments', () => {
-      const line = new GrDiffLine(GrDiffLine.Type.BOTH);
-      line.beforeNumber = 3;
-      line.afterNumber = 5;
-
-      const l3 = {
-        comments: [{id: 'l3', line: 3}],
-        range: {end_line: 3},
-        commentSide: 'left',
-      };
-      const l5 = {
-        comments: [{id: 'l5', line: 5}],
-        range: {end_line: 5},
-        commentSide: 'left',
-      };
-      const r3 = {
-        comments: [{id: 'r3', line: 3}],
-        range: {end_line: 3},
-        commentSide: 'right',
-      };
-      const r5 = {
-        comments: [{id: 'r5', line: 5}],
-        range: {end_line: 5},
-        commentSide: 'right',
-      };
-
-      const threads = [l3, l5, r3, r5];
-      assert.deepEqual(builder._filterThreadsForLine(threads, line),
-          [l3, r5]);
-      assert.deepEqual(builder._filterThreadsForLine(threads, line,
-          GrDiffBuilder.Side.LEFT), [l3]);
-      assert.deepEqual(builder._filterThreadsForLine(threads, line,
-          GrDiffBuilder.Side.RIGHT), [r5]);
-    });
-
-    test('_filterThreadsForLine for file comments', () => {
-      const line = new GrDiffLine(GrDiffLine.Type.BOTH);
-      line.beforeNumber = GrDiffLine.FILE;
-      line.afterNumber = GrDiffLine.FILE;
-
-      const l = {
-        comments: [{id: 'l', line: undefined}],
-        commentSide: 'left',
-      };
-      const r = {
-        comments: [{id: 'r', line: undefined}],
-        commentSide: 'right',
-      };
-
-      const threads = [l, r];
-      assert.deepEqual(builder._filterThreadsForLine(threads, line),
-          [l, r]);
-      assert.deepEqual(builder._filterThreadsForLine(threads, line,
-          GrDiffBuilder.Side.BOTH), [l, r]);
-      assert.deepEqual(builder._filterThreadsForLine(threads, line,
-          GrDiffBuilder.Side.LEFT), [l]);
-      assert.deepEqual(builder._filterThreadsForLine(threads, line,
-          GrDiffBuilder.Side.RIGHT), [r]);
-    });
-
     test('comment thread group creation', () => {
-      const l3 = {id: 'l3', line: 3, updated: '2016-08-09 00:42:32.000000000',
-        __commentSide: 'left'};
-      const l5 = {id: 'l5', line: 5, updated: '2016-08-09 00:42:32.000000000',
-        __commentSide: 'left'};
-      const r5 = {id: 'r5', line: 5, updated: '2016-08-09 00:42:32.000000000',
-        __commentSide: 'right'};
+      const l3 = document.createElement('div');
+      l3.className = 'comment-thread';
+      l3.setAttribute('comment-side', 'left');
+      l3.setAttribute('line-num', 3);
+
+      const l5 = document.createElement('div');
+      l5.className = 'comment-thread';
+      l5.setAttribute('comment-side', 'left');
+      l5.setAttribute('line-num', 5);
+
+      const r5 = document.createElement('div');
+      r5.className = 'comment-thread';
+      r5.setAttribute('comment-side', 'right');
+      r5.setAttribute('line-num', 5);
 
       builder = new GrDiffBuilder(
-          {content: []}, {
-            meta: {
-              changeNum: '42',
-              patchRange: {
-                basePatchNum: 'PARENT',
-                patchNum: '3',
-              },
-              path: '/path/to/foo',
-              projectConfig: {foo: 'bar'},
-            },
-            left: [l3, l5],
-            right: [r5],
-          }, createThreadGroupFn, prefs);
+          {content: []}, {basePatchNum: 'PARENT', patchNum: '3'}, [l3, l5, r5],
+          prefs);
 
-      function threadForComment(c, patchNum) {
-        return {
-          commentSide: c.__commentSide,
-          comments: [c],
-          patchNum,
-          rootId: c.id,
-          start_datetime: c.updated,
-        };
-      }
-
-      function checkThreadGroupProps(threadGroupEl, patchNum, isOnParent,
-          comments) {
-        assert.equal(createThreadGroupFn.lastCall.args[0], patchNum);
-        assert.equal(createThreadGroupFn.lastCall.args[1], isOnParent);
-        assert.deepEqual(
-            threadGroupEl.threads,
-            comments.map(c => threadForComment(c, undefined)));
+      function checkThreadGroupProps(threadGroupEl,
+          expectedThreadEls) {
+        const threadEls = Polymer.dom(threadGroupEl).queryDistributedElements(
+            '.comment-thread');
+        assert.equal(threadEls.length, expectedThreadEls.length);
+        for (let i=0; i<expectedThreadEls.length; i++) {
+          assert.equal(threadEls[i], expectedThreadEls[i]);
+        }
       }
 
       let line = new GrDiffLine(GrDiffLine.Type.BOTH);
       line.beforeNumber = 5;
       line.afterNumber = 5;
       let threadGroupEl = builder._commentThreadGroupForLine(line);
-      assert.isTrue(createThreadGroupFn.calledOnce);
-      checkThreadGroupProps(threadGroupEl, '3', false, [l5, r5]);
+      checkThreadGroupProps(threadGroupEl, [l5, r5]);
 
       threadGroupEl =
           builder._commentThreadGroupForLine(line, GrDiffBuilder.Side.RIGHT);
-      assert.isTrue(createThreadGroupFn.calledTwice);
-      checkThreadGroupProps(threadGroupEl, '3', false, [r5]);
+      checkThreadGroupProps(threadGroupEl, [r5]);
 
       threadGroupEl =
           builder._commentThreadGroupForLine(line, GrDiffBuilder.Side.LEFT);
-      assert.isTrue(createThreadGroupFn.calledThrice);
-      checkThreadGroupProps(threadGroupEl, '3', true, [l5]);
+      checkThreadGroupProps(threadGroupEl, [l5]);
 
-      builder._comments.meta.patchRange.basePatchNum = '1';
+      builder._patchRange.basePatchNum = '1';
 
       threadGroupEl = builder._commentThreadGroupForLine(line);
-      assert.equal(createThreadGroupFn.callCount, 4);
-      checkThreadGroupProps(threadGroupEl, '3', false, [l5, r5]);
+      checkThreadGroupProps(threadGroupEl, [l5, r5]);
 
       threadEl =
           builder._commentThreadGroupForLine(line, GrDiffBuilder.Side.LEFT);
-      assert.equal(createThreadGroupFn.callCount, 5);
-      checkThreadGroupProps(threadEl, '1', false, [l5]);
+      checkThreadGroupProps(threadEl, [l5]);
 
       threadGroupEl =
           builder._commentThreadGroupForLine(line, GrDiffBuilder.Side.RIGHT);
-      assert.equal(createThreadGroupFn.callCount, 6);
-      checkThreadGroupProps(threadGroupEl, '3', false, [r5]);
+      checkThreadGroupProps(threadGroupEl, [r5]);
 
-      builder._comments.meta.patchRange.basePatchNum = 'PARENT';
+      builder._patchRange.basePatchNum = 'PARENT';
 
       line = new GrDiffLine(GrDiffLine.Type.REMOVE);
       line.beforeNumber = 5;
       line.afterNumber = 5;
       threadGroupEl = builder._commentThreadGroupForLine(line);
-      assert.equal(createThreadGroupFn.callCount, 7);
-      checkThreadGroupProps(threadGroupEl, '3', true, [l5, r5]);
+      checkThreadGroupProps(threadGroupEl, [l5, r5]);
 
       line = new GrDiffLine(GrDiffLine.Type.ADD);
       line.beforeNumber = 3;
       line.afterNumber = 5;
       threadGroupEl = builder._commentThreadGroupForLine(line);
-      assert.equal(createThreadGroupFn.callCount, 8);
-      checkThreadGroupProps(threadGroupEl, '3', false, [l3, r5]);
+      checkThreadGroupProps(threadGroupEl, [l3, r5]);
     });
 
 
     test('_handlePreferenceError called with invalid preference', () => {
       sandbox.stub(element, '_handlePreferenceError');
       const prefs = {tab_size: 0};
-      element._getDiffBuilder(element.diff, element.comments, prefs);
+      element._getDiffBuilder(element.diff, undefined, prefs);
       assert.isTrue(element._handlePreferenceError.lastCall
           .calledWithExactly('tab size'));
     });
@@ -584,16 +416,18 @@
       });
 
       const lineOfInterest = {number: 789, leftSide: true};
-      assert.deepEqual(element._getKeyLocations(comments, lineOfInterest), {
-        left: {FILE: true, 123: true, 789: true},
-        right: {456: true},
-      });
+      assert.deepEqual(
+          element._getKeyLocations(comments, lineOfInterest), {
+            left: {FILE: true, 123: true, 789: true},
+            right: {456: true},
+          });
 
       delete lineOfInterest.leftSide;
-      assert.deepEqual(element._getKeyLocations(comments, lineOfInterest), {
-        left: {FILE: true, 123: true},
-        right: {456: true, 789: true},
-      });
+      assert.deepEqual(
+          element._getKeyLocations(comments, lineOfInterest), {
+            left: {FILE: true, 123: true},
+            right: {456: true, 789: true},
+          });
     });
 
     suite('_isTotal', () => {
@@ -1035,7 +869,7 @@
         processStub = sandbox.stub(element.$.processor, 'process')
             .returns(Promise.resolve());
         sandbox.stub(element, '_anyLineTooLong').returns(true);
-        comments = {left: [], right: []};
+        comments = {left: [], right: [], meta: {patchRange: undefined}};
         prefs = {
           line_length: 10,
           show_tabs: true,
@@ -1083,6 +917,7 @@
     suite('rendering', () => {
       let content;
       let outputEl;
+      let comments;
 
       setup(done => {
         const prefs = {
@@ -1110,9 +945,10 @@
         });
         element = fixture('basic');
         outputEl = element.queryEffectiveChildren('#diffTable');
+        comments = {left: [], right: [], meta: {patchRange: undefined}};
         sandbox.stub(element, '_getDiffBuilder', () => {
           const builder = new GrDiffBuilder(
-              {content}, {left: [], right: []}, null, prefs, outputEl);
+              {content}, undefined, [], prefs, outputEl);
           sandbox.stub(builder, 'addColumns');
           builder.buildSectionElement = function(group) {
             const section = document.createElement('stub');
@@ -1124,7 +960,7 @@
           return builder;
         });
         element.diff = {content};
-        element.render({left: [], right: []}, prefs).then(done);
+        element.render(comments, prefs).then(done);
       });
 
       test('reporting', done => {
@@ -1149,7 +985,7 @@
       });
 
       test('addColumns is called', done => {
-        element.render({left: [], right: []}, {}).then(done);
+        element.render(comments, {}).then(done);
         assert.isTrue(element._builder.addColumns.called);
       });
 
@@ -1173,7 +1009,7 @@
 
       test('render-start and render are fired', done => {
         const dispatchEventStub = sandbox.stub(element, 'dispatchEvent');
-        element.render({left: [], right: []}, {}).then(() => {
+        element.render(comments, {}).then(() => {
           const firedEventTypes = dispatchEventStub.getCalls()
               .map(c => { return c.args[0].type; });
           assert.include(firedEventTypes, 'render-start');
@@ -1201,7 +1037,7 @@
           context: -1,
           syntax_highlighting: true,
         };
-        element.render({left: [], right: []}, prefs);
+        element.render(comments, prefs);
       });
 
       test('cancel', () => {
@@ -1218,6 +1054,7 @@
       let builder;
       let diff;
       let prefs;
+      let comments;
 
       setup(done => {
         element = fixture('mock-diff');
@@ -1229,8 +1066,9 @@
           show_tabs: true,
           tab_size: 4,
         };
+        comments = {left: [], right: [], meta: {patchRange: undefined}};
 
-        element.render({left: [], right: []}, prefs).then(() => {
+        element.render(comments, prefs).then(() => {
           builder = element._builder;
           done();
         });
@@ -1340,7 +1178,7 @@
       test('_getNextContentOnSide unified left', done => {
         // Re-render as unified:
         element.viewMode = 'UNIFIED_DIFF';
-        element.render({left: [], right: []}, prefs).then(() => {
+        element.render(comments, prefs).then(() => {
           builder = element._builder;
 
           const startElem = builder.getContentByLine(5, 'left',
@@ -1360,7 +1198,7 @@
       test('_getNextContentOnSide unified right', done => {
         // Re-render as unified:
         element.viewMode = 'UNIFIED_DIFF';
-        element.render({left: [], right: []}, prefs).then(() => {
+        element.render(comments, prefs).then(() => {
           builder = element._builder;
 
           const startElem = builder.getContentByLine(5, 'right',
diff --git a/polygerrit-ui/app/elements/diff/gr-diff-comment-thread-group/gr-diff-comment-thread-group.html b/polygerrit-ui/app/elements/diff/gr-diff-comment-thread-group/gr-diff-comment-thread-group.html
deleted file mode 100644
index 58b7c32..0000000
--- a/polygerrit-ui/app/elements/diff/gr-diff-comment-thread-group/gr-diff-comment-thread-group.html
+++ /dev/null
@@ -1,50 +0,0 @@
-<!--
-@license
-Copyright (C) 2017 The Android Open Source Project
-
-Licensed under the Apache License, Version 2.0 (the "License");
-you may not use this file except in compliance with the License.
-You may obtain a copy of the License at
-
-http://www.apache.org/licenses/LICENSE-2.0
-
-Unless required by applicable law or agreed to in writing, software
-distributed under the License is distributed on an "AS IS" BASIS,
-WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-See the License for the specific language governing permissions and
-limitations under the License.
--->
-
-<link rel="import" href="../../../bower_components/polymer/polymer.html">
-<link rel="import" href="../gr-diff-comment-thread/gr-diff-comment-thread.html">
-<link rel="import" href="../../../styles/shared-styles.html">
-
-<dom-module id="gr-diff-comment-thread-group">
-  <template>
-    <style include="shared-styles">
-      :host {
-        display: block;
-        max-width: var(--content-width, 80ch);
-        white-space: normal;
-      }
-      gr-diff-comment-thread + gr-diff-comment-thread {
-        margin-top: .2em;
-      }
-    </style>
-    <template is="dom-repeat" items="[[threads]]" as="thread">
-      <gr-diff-comment-thread
-          comments="[[thread.comments]]"
-          comment-side="[[thread.commentSide]]"
-          is-on-parent="[[isOnParent]]"
-          parent-index="[[parentIndex]]"
-          change-num="[[changeNum]]"
-          patch-num="[[thread.patchNum]]"
-          root-id="{{thread.rootId}}"
-          path="[[path]]"
-          project-name="[[projectName]]"
-          range="[[thread.range]]"
-          on-thread-discard="_handleThreadDiscard"></gr-diff-comment-thread>
-    </template>
-  </template>
-  <script src="gr-diff-comment-thread-group.js"></script>
-</dom-module>
diff --git a/polygerrit-ui/app/elements/diff/gr-diff-comment-thread-group/gr-diff-comment-thread-group.js b/polygerrit-ui/app/elements/diff/gr-diff-comment-thread-group/gr-diff-comment-thread-group.js
deleted file mode 100644
index 23d0a58..0000000
--- a/polygerrit-ui/app/elements/diff/gr-diff-comment-thread-group/gr-diff-comment-thread-group.js
+++ /dev/null
@@ -1,108 +0,0 @@
-/**
- * @license
- * Copyright (C) 2017 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-(function() {
-  'use strict';
-
-  Polymer({
-    is: 'gr-diff-comment-thread-group',
-
-    properties: {
-      changeNum: String,
-      projectName: String,
-      patchForNewThreads: String,
-      isOnParent: {
-        type: Boolean,
-        value: false,
-      },
-      parentIndex: {
-        type: Number,
-        value: null,
-      },
-      threads: {
-        type: Array,
-        value() { return []; },
-      },
-    },
-
-    get threadEls() {
-      return Polymer.dom(this.root).querySelectorAll('gr-diff-comment-thread');
-    },
-
-    /**
-     * Adds a new thread. Range is optional because a comment can be
-     * added to a line without a range selected.
-     *
-     * @param {!Object} opt_range
-     */
-    addNewThread(commentSide, opt_range) {
-      this.push('threads', {
-        comments: [],
-        commentSide,
-        patchNum: this.patchForNewThreads,
-        range: opt_range,
-      });
-    },
-
-    removeThread(rootId) {
-      for (let i = 0; i < this.threads.length; i++) {
-        if (this.threads[i].rootId === rootId) {
-          this.splice('threads', i, 1);
-          return;
-        }
-      }
-    },
-
-    /**
-     * Fetch the thread group at the given range, or the range-less thread
-     * on the line if no range is provided, lineNum, and side.
-     *
-     * @param {string} side
-     * @param {!Object=} opt_range
-     * @return {!Object|undefined}
-     */
-    getThread(side, opt_range) {
-      const threads = [].filter.call(this.threadEls,
-          thread => this._rangesEqual(thread.range, opt_range))
-          .filter(thread => thread.commentSide === side);
-      if (threads.length === 1) {
-        return threads[0];
-      }
-    },
-
-    _handleThreadDiscard(e) {
-      this.removeThread(e.detail.rootId);
-    },
-
-    /**
-     * Compare two ranges. Either argument may be falsy, but will only return
-     * true if both are falsy or if neither are falsy and have the same position
-     * values.
-     *
-     * @param {Object=} a range 1
-     * @param {Object=} b range 2
-     * @return {boolean}
-     */
-    _rangesEqual(a, b) {
-      if (!a && !b) { return true; }
-      if (!a || !b) { return false; }
-      return a.startLine === b.startLine &&
-          a.startChar === b.startChar &&
-          a.endLine === b.endLine &&
-          a.endChar === b.endChar;
-    },
-  });
-})();
diff --git a/polygerrit-ui/app/elements/diff/gr-diff-comment-thread-group/gr-diff-comment-thread-group_test.html b/polygerrit-ui/app/elements/diff/gr-diff-comment-thread-group/gr-diff-comment-thread-group_test.html
deleted file mode 100644
index 81181b1..0000000
--- a/polygerrit-ui/app/elements/diff/gr-diff-comment-thread-group/gr-diff-comment-thread-group_test.html
+++ /dev/null
@@ -1,168 +0,0 @@
-<!DOCTYPE html>
-<!--
-@license
-Copyright (C) 2017 The Android Open Source Project
-
-Licensed under the Apache License, Version 2.0 (the "License");
-you may not use this file except in compliance with the License.
-You may obtain a copy of the License at
-
-http://www.apache.org/licenses/LICENSE-2.0
-
-Unless required by applicable law or agreed to in writing, software
-distributed under the License is distributed on an "AS IS" BASIS,
-WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-See the License for the specific language governing permissions and
-limitations under the License.
--->
-
-<meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
-<title>gr-diff-comment-thread-group</title>
-
-<script src="../../../bower_components/webcomponentsjs/webcomponents-lite.min.js"></script>
-<script src="../../../bower_components/web-component-tester/browser.js"></script>
-<link rel="import" href="../../../test/common-test-setup.html"/>
-<script src="../../../scripts/util.js"></script>
-
-<link rel="import" href="gr-diff-comment-thread-group.html">
-
-<script>void(0);</script>
-
-<test-fixture id="basic">
-  <template>
-    <gr-diff-comment-thread-group></gr-diff-comment-thread-group>
-  </template>
-</test-fixture>
-
-<script>
-  suite('gr-diff-comment-thread-group tests', () => {
-    let element;
-    let sandbox;
-
-    setup(() => {
-      sandbox = sinon.sandbox.create();
-      stub('gr-rest-api-interface', {
-        getLoggedIn() { return Promise.resolve(false); },
-      });
-      element = fixture('basic');
-    });
-
-    teardown(() => {
-      sandbox.restore();
-    });
-
-    test('getThread', () => {
-      const range = {
-        start_line: 1,
-        start_character: 1,
-        end_line: 1,
-        end_character: 2,
-      };
-      element.threads = [
-        {
-          rootId: 'sallys_confession',
-          commentSide: 'left',
-          comments: [
-            {
-              id: 'sallys_confession',
-              message: 'i like you, jack',
-              updated: '2015-12-23 15:00:20.396000000',
-              __commentSide: 'left',
-            }, {
-              id: 'jacks_reply',
-              message: 'i like you, too',
-              updated: '2015-12-24 15:01:20.396000000',
-              __commentSide: 'left',
-              in_reply_to: 'sallys_confession',
-            }, {
-              id: 'new_draft',
-              message: 'i do not like either of you',
-              __commentSide: 'left',
-              __draft: true,
-              in_reply_to: 'sallys_confession',
-              updated: '2015-12-20 15:01:20.396000000',
-            },
-          ],
-        },
-        {
-          rootId: 'right_side_comment',
-          commentSide: 'right',
-          comments: [
-            {
-              id: 'right_side_comment',
-              message: 'right side comment',
-              __commentSide: 'right',
-              __draft: true,
-              updated: '2015-12-20 15:01:20.396000000',
-            },
-          ],
-        }, {
-          rootId: 'betsys_confession',
-          commentSide: 'left',
-          range,
-          comments: [
-            {
-              id: 'betsys_confession',
-              message: 'i like you more, jack',
-              updated: '2015-12-24 15:00:10.396000000',
-              range,
-              __commentSide: 'left',
-            },
-          ],
-        },
-      ];
-
-      flushAsynchronousOperations();
-      assert.deepEqual(element.getThread('right').rootId, 'right_side_comment');
-      assert.deepEqual(element.getThread('right').comments.length, 1);
-      assert.deepEqual(element.getThread('left').rootId, 'sallys_confession');
-      assert.deepEqual(element.getThread('left').comments.length, 3);
-      assert.deepEqual(element.getThread('left', range).rootId,
-          'betsys_confession');
-      assert.deepEqual(element.getThread('left', range).comments.length, 1);
-    });
-
-    test('addNewThread', () => {
-      const commentSide = 'left';
-      const range = {startLine: 1, endLine: 2, startChar: 3, endChar: 4};
-      element.patchForNewThreads = 5;
-      element.addNewThread(commentSide, range);
-      assert.equal(element.threads.length, 1);
-      assert.equal(element.threads[0].comments.length, 0);
-      assert.equal(element.threads[0].commentSide, commentSide);
-      assert.equal(element.threads[0].patchNum, 5);
-      assert.equal(element.threads[0].range.startLine, range.startLine);
-      assert.equal(element.threads[0].range.endLine, range.endLine);
-      assert.equal(element.threads[0].range.startChar, range.startChar);
-      assert.equal(element.threads[0].range.endChar, range.endChar);
-    });
-
-    test('removeThread', () => {
-      element.threads = [
-        {rootId: 4711},
-        {rootId: 42},
-      ];
-      element.removeThread(4711);
-      assert.equal(element.threads.length, 1);
-      assert.equal(element.threads[0].rootId, 42);
-    });
-
-    test('_rangesEqual', () => {
-      const range1 =
-          {startLine: 123, startChar: 345, endLine: 234, endChar: 456};
-      const range2 =
-          {startLine: 1, startChar: 2, endLine: 3, endChar: 4};
-
-      assert.isTrue(element._rangesEqual(null, null));
-      assert.isTrue(element._rangesEqual(null, undefined));
-      assert.isTrue(element._rangesEqual(undefined, null));
-      assert.isTrue(element._rangesEqual(undefined, undefined));
-
-      assert.isFalse(element._rangesEqual(range1, null));
-      assert.isFalse(element._rangesEqual(null, range1));
-      assert.isFalse(element._rangesEqual(range1, range2));
-
-      assert.isTrue(element._rangesEqual(range1, Object.assign({}, range1)));
-    });
-  });
-</script>
diff --git a/polygerrit-ui/app/elements/diff/gr-diff-comment-thread/gr-diff-comment-thread.js b/polygerrit-ui/app/elements/diff/gr-diff-comment-thread/gr-diff-comment-thread.js
index f3e3249..a2439d7 100644
--- a/polygerrit-ui/app/elements/diff/gr-diff-comment-thread/gr-diff-comment-thread.js
+++ b/polygerrit-ui/app/elements/diff/gr-diff-comment-thread/gr-diff-comment-thread.js
@@ -48,11 +48,12 @@
       *     version is the one whose line number column is further to the left.
       *
       * range:
-      *     The range of text that the comment refers to (startLine, startChar,
-      *     endLine, endChar), serialized as JSON. If set, range's startLine
-      *     will have the same value as line-num. Line numbers are 1-based,
-      *     char numbers are 0-based. The start position (startLine, startChar)
-      *     is inclusive, and the end position (endLine, endChar) is exclusive.
+      *     The range of text that the comment refers to (start_line,
+      *     start_character, end_line, end_character), serialized as JSON. If
+      *     set, range's end_line will have the same value as line-num. Line
+      *     numbers are 1-based, char numbers are 0-based. The start position
+      *     (start_line, start_character) is inclusive, and the end position
+      *     (end_line, end_character) is exclusive.
       */
     properties: {
       changeNum: String,
@@ -61,8 +62,8 @@
         value() { return []; },
       },
       /**
-       * @type {?{startLine: number, startChar: number, endLine: number,
-       *          endChar: number}}
+       * @type {?{start_line: number, start_character: number, end_line: number,
+       *          end_character: number}}
        */
       range: {
         type: Object,
@@ -390,12 +391,7 @@
         d.line = opt_lineNum;
       }
       if (opt_range) {
-        d.range = {
-          start_line: opt_range.startLine,
-          start_character: opt_range.startChar,
-          end_line: opt_range.endLine,
-          end_character: opt_range.endChar,
-        };
+        d.range = opt_range;
       }
       if (this.parentIndex) {
         d.parent = this.parentIndex;
diff --git a/polygerrit-ui/app/elements/diff/gr-diff-comment-thread/gr-diff-comment-thread_test.html b/polygerrit-ui/app/elements/diff/gr-diff-comment-thread/gr-diff-comment-thread_test.html
index 58648bf..1881497 100644
--- a/polygerrit-ui/app/elements/diff/gr-diff-comment-thread/gr-diff-comment-thread_test.html
+++ b/polygerrit-ui/app/elements/diff/gr-diff-comment-thread/gr-diff-comment-thread_test.html
@@ -724,15 +724,25 @@
     });
 
     test('reflects range to JSON serialized attribute if set', () => {
-      element.range = {startLine: 4, endLine: 5, startChar: 6, endChar: 7};
+      element.range = {
+        start_line: 4,
+        end_line: 5,
+        start_character: 6,
+        end_character: 7,
+      };
 
       assert.deepEqual(
           JSON.parse(element.getAttribute('range')),
-          {startLine: 4, endLine: 5, startChar: 6, endChar: 7});
+          {start_line: 4, end_line: 5, start_character: 6, end_character: 7});
     });
 
     test('removes range attribute if range is unset', () => {
-      element.range = {startLine: 4, endLine: 5, startChar: 6, endChar: 7};
+      element.range = {
+        start_line: 4,
+        end_line: 5,
+        start_character: 6,
+        end_character: 7,
+      };
       element.range = undefined;
 
       assert.notOk(element.hasAttribute('range'));
diff --git a/polygerrit-ui/app/elements/diff/gr-diff-cursor/gr-diff-cursor_test.html b/polygerrit-ui/app/elements/diff/gr-diff-cursor/gr-diff-cursor_test.html
index 9668a54..f111378 100644
--- a/polygerrit-ui/app/elements/diff/gr-diff-cursor/gr-diff-cursor_test.html
+++ b/polygerrit-ui/app/elements/diff/gr-diff-cursor/gr-diff-cursor_test.html
@@ -60,7 +60,11 @@
 
       diffElement.loggedIn = false;
       diffElement.patchRange = {basePatchNum: 1, patchNum: 2};
-      diffElement.comments = {left: [], right: []};
+      diffElement.comments = {
+        left: [],
+        right: [],
+        meta: {patchRange: undefined},
+      };
       const setupDone = () => {
         cursorElement._updateStops();
         cursorElement.moveToFirstChunk();
diff --git a/polygerrit-ui/app/elements/diff/gr-diff-highlight/gr-diff-highlight.js b/polygerrit-ui/app/elements/diff/gr-diff-highlight/gr-diff-highlight.js
index 577eec6..85ba202 100644
--- a/polygerrit-ui/app/elements/diff/gr-diff-highlight/gr-diff-highlight.js
+++ b/polygerrit-ui/app/elements/diff/gr-diff-highlight/gr-diff-highlight.js
@@ -21,7 +21,11 @@
     is: 'gr-diff-highlight',
 
     properties: {
-      comments: Object,
+      /** @type {!Array<!Gerrit.HoveredRange>} */
+      commentRanges: {
+        type: Array,
+        notify: true,
+      },
       loggedIn: Boolean,
       /**
        * querySelector can return null, so needs to be nullable.
@@ -71,35 +75,44 @@
     },
 
     _handleCommentMouseOver(e) {
-      const comment = e.detail.comment;
-      if (!comment.range) { return; }
-      const lineEl = this.diffBuilder.getLineElByChild(e.target);
-      const side = this.diffBuilder.getSideByLineEl(lineEl);
-      const index = this._indexOfComment(side, comment);
+      const threadEl = Polymer.dom(e).localTarget;
+      const index = this._indexForThreadEl(threadEl);
+
       if (index !== undefined) {
-        this.set(['comments', side, index, '__hovering'], true);
+        this.set(['commentRanges', index, 'hovering'], true);
       }
     },
 
     _handleCommentMouseOut(e) {
-      const comment = e.detail.comment;
-      if (!comment.range) { return; }
-      const lineEl = this.diffBuilder.getLineElByChild(e.target);
-      const side = this.diffBuilder.getSideByLineEl(lineEl);
-      const index = this._indexOfComment(side, comment);
+      const threadEl = Polymer.dom(e).localTarget;
+      const index = this._indexForThreadEl(threadEl);
+
       if (index !== undefined) {
-        this.set(['comments', side, index, '__hovering'], false);
+        this.set(['commentRanges', index, 'hovering'], false);
       }
     },
 
-    _indexOfComment(side, comment) {
-      const idProp = comment.id ? 'id' : '__draftID';
-      for (let i = 0; i < this.comments[side].length; i++) {
-        if (comment[idProp] &&
-            this.comments[side][i][idProp] === comment[idProp]) {
-          return i;
-        }
+    _indexForThreadEl(threadEl) {
+      const side = threadEl.getAttribute('comment-side');
+      const range = JSON.parse(threadEl.getAttribute('range'));
+
+      if (!range) return undefined;
+
+      return this._indexOfCommentRange(side, range);
+    },
+
+    _indexOfCommentRange(side, range) {
+      function rangesEqual(a, b) {
+        if (!a && !b) { return true; }
+        if (!a || !b) { return false; }
+        return a.start_line === b.start_line &&
+            a.start_character === b.start_character &&
+            a.end_line === b.end_line &&
+            a.end_character === b.end_character;
       }
+
+      return this.commentRanges.findIndex(commentRange =>
+          commentRange.side === side && rangesEqual(commentRange.range, range));
     },
 
     /**
@@ -295,10 +308,10 @@
       const root = Polymer.dom(this.root);
       root.insertBefore(actionBox, root.firstElementChild);
       actionBox.range = {
-        startLine: start.line,
-        startChar: start.column,
-        endLine: end.line,
-        endChar: end.column,
+        start_line: start.line,
+        start_character: start.column,
+        end_line: end.line,
+        end_character: end.column,
       };
       actionBox.side = start.side;
       if (start.line === end.line) {
diff --git a/polygerrit-ui/app/elements/diff/gr-diff-highlight/gr-diff-highlight_test.html b/polygerrit-ui/app/elements/diff/gr-diff-highlight/gr-diff-highlight_test.html
index 98d55c0..23de407 100644
--- a/polygerrit-ui/app/elements/diff/gr-diff-highlight/gr-diff-highlight_test.html
+++ b/polygerrit-ui/app/elements/diff/gr-diff-highlight/gr-diff-highlight_test.html
@@ -205,7 +205,7 @@
 
       test('comment-mouse-over from ranged comment causes set', () => {
         sandbox.stub(element, 'set');
-        sandbox.stub(element, '_indexOfComment').returns(0);
+        sandbox.stub(element, '_indexForThreadEl').returns(0);
         element.fire('comment-mouse-over', {comment: {range: {}}});
         assert.isTrue(element.set.called);
       });
@@ -318,10 +318,10 @@
         const actionBox = element.$$('gr-selection-action-box');
         assert.isTrue(element.isRangeSelected());
         assert.deepEqual(getActionRange(), {
-          startLine: 138,
-          startChar: 5,
-          endLine: 138,
-          endChar: 12,
+          start_line: 138,
+          start_character: 5,
+          end_line: 138,
+          end_character: 12,
         });
         assert.equal(getActionSide(), 'left');
         assert.notOk(actionBox.positionBelow);
@@ -337,10 +337,10 @@
         const actionBox = element.$$('gr-selection-action-box');
 
         assert.deepEqual(getActionRange(), {
-          startLine: 119,
-          startChar: 10,
-          endLine: 120,
-          endChar: 36,
+          start_line: 119,
+          start_character: 10,
+          end_line: 120,
+          end_character: 36,
         });
         assert.equal(getActionSide(), 'right');
         assert.notOk(actionBox.positionBelow);
@@ -370,10 +370,10 @@
         element._handleSelection();
         assert.isTrue(element.isRangeSelected());
         assert.deepEqual(getActionRange(), {
-          startLine: 119,
-          startChar: 10,
-          endLine: 120,
-          endChar: 36,
+          start_line: 119,
+          start_character: 10,
+          end_line: 120,
+          end_character: 36,
         });
       });
 
@@ -383,10 +383,10 @@
         emulateSelection(startContent.firstChild, 10, endContent.firstChild, 2);
         assert.isTrue(element.isRangeSelected());
         assert.deepEqual(getActionRange(), {
-          startLine: 119,
-          startChar: 10,
-          endLine: 120,
-          endChar: 2,
+          start_line: 119,
+          start_character: 10,
+          end_line: 120,
+          end_character: 2,
         });
         assert.equal(getActionSide(), 'right');
       });
@@ -404,10 +404,10 @@
         emulateSelection(hl.firstChild, 2, hl.nextSibling, 7);
         assert.isTrue(element.isRangeSelected());
         assert.deepEqual(getActionRange(), {
-          startLine: 140,
-          startChar: 8,
-          endLine: 140,
-          endChar: 23,
+          start_line: 140,
+          start_character: 8,
+          end_line: 140,
+          end_character: 23,
         });
         assert.equal(getActionSide(), 'left');
       });
@@ -418,10 +418,10 @@
         emulateSelection(hl.previousSibling, 2, hl.firstChild, 3);
         assert.isTrue(element.isRangeSelected());
         assert.deepEqual(getActionRange(), {
-          startLine: 140,
-          startChar: 18,
-          endLine: 140,
-          endChar: 27,
+          start_line: 140,
+          start_character: 18,
+          end_line: 140,
+          end_character: 27,
         });
       });
 
@@ -431,10 +431,10 @@
         emulateSelection(content.firstChild, 2, hl.firstChild, 2);
         assert.isTrue(element.isRangeSelected());
         assert.deepEqual(getActionRange(), {
-          startLine: 140,
-          startChar: 2,
-          endLine: 140,
-          endChar: 61,
+          start_line: 140,
+          start_character: 2,
+          end_line: 140,
+          end_character: 61,
         });
         assert.equal(getActionSide(), 'left');
       });
@@ -470,10 +470,10 @@
         emulateSelection(comment.firstChild, 2, endContent.firstChild, 4);
         assert.isTrue(element.isRangeSelected());
         assert.deepEqual(getActionRange(), {
-          startLine: 140,
-          startChar: 83,
-          endLine: 141,
-          endChar: 4,
+          start_line: 140,
+          start_character: 83,
+          end_line: 141,
+          end_character: 4,
         });
         assert.equal(getActionSide(), 'left');
       });
@@ -485,10 +485,10 @@
         emulateSelection(content.firstChild, 4, comment.firstChild, 1);
         assert.isTrue(element.isRangeSelected());
         assert.deepEqual(getActionRange(), {
-          startLine: 140,
-          startChar: 4,
-          endLine: 140,
-          endChar: 83,
+          start_line: 140,
+          start_character: 4,
+          end_line: 140,
+          end_character: 83,
         });
         assert.equal(getActionSide(), 'left');
       });
@@ -517,10 +517,10 @@
         emulateSelection(startContent.firstChild, 3, endContent.firstChild, 14);
         assert.isTrue(element.isRangeSelected());
         assert.deepEqual(getActionRange(), {
-          startLine: 130,
-          startChar: 3,
-          endLine: 146,
-          endChar: 14,
+          start_line: 130,
+          start_character: 3,
+          end_line: 146,
+          end_character: 14,
         });
         assert.equal(getActionSide(), 'right');
       });
@@ -531,10 +531,10 @@
             content.firstChild, 1, content.querySelector('span'), 0);
         assert.isTrue(element.isRangeSelected());
         assert.deepEqual(getActionRange(), {
-          startLine: 140,
-          startChar: 1,
-          endLine: 140,
-          endChar: 51,
+          start_line: 140,
+          start_character: 1,
+          end_line: 140,
+          end_character: 51,
         });
         assert.equal(getActionSide(), 'left');
       });
@@ -546,10 +546,10 @@
             content.querySelectorAll('span')[1].nextSibling, 1);
         assert.isTrue(element.isRangeSelected());
         assert.deepEqual(getActionRange(), {
-          startLine: 140,
-          startChar: 51,
-          endLine: 140,
-          endChar: 71,
+          start_line: 140,
+          start_character: 51,
+          end_line: 140,
+          end_character: 71,
         });
         assert.equal(getActionSide(), 'left');
       });
@@ -582,10 +582,10 @@
         emulateSelection(startContent.firstChild, 0, endContent.firstChild, 0);
         assert.isTrue(element.isRangeSelected());
         assert.deepEqual(getActionRange(), {
-          startLine: 119,
-          startChar: 0,
-          endLine: 119,
-          endChar: element._getLength(startContent),
+          start_line: 119,
+          start_character: 0,
+          end_line: 119,
+          end_character: element._getLength(startContent),
         });
         assert.equal(getActionSide(), 'right');
       });
@@ -597,10 +597,10 @@
             endContent.parentElement.previousElementSibling, 0);
         assert.isTrue(element.isRangeSelected());
         assert.deepEqual(getActionRange(), {
-          startLine: 146,
-          startChar: 0,
-          endLine: 146,
-          endChar: 84,
+          start_line: 146,
+          start_character: 0,
+          end_line: 146,
+          end_character: 84,
         });
         assert.equal(getActionSide(), 'right');
       });
diff --git a/polygerrit-ui/app/elements/diff/gr-diff-host/gr-diff-host.html b/polygerrit-ui/app/elements/diff/gr-diff-host/gr-diff-host.html
index a5f5fd9..d335e7a 100644
--- a/polygerrit-ui/app/elements/diff/gr-diff-host/gr-diff-host.html
+++ b/polygerrit-ui/app/elements/diff/gr-diff-host/gr-diff-host.html
@@ -19,6 +19,7 @@
 <link rel="import" href="../../../behaviors/gr-patch-set-behavior/gr-patch-set-behavior.html">
 <link rel="import" href="../../core/gr-reporting/gr-reporting.html">
 <link rel="import" href="../../shared/gr-rest-api-interface/gr-rest-api-interface.html">
+<link rel="import" href="../gr-diff-comment-thread/gr-diff-comment-thread.html">
 <link rel="import" href="../gr-diff/gr-diff.html">
 
 <dom-module id="gr-diff-host">
@@ -46,8 +47,7 @@
         base-image="[[_baseImage]]"
         revision-image=[[_revisionImage]]
         blame="[[_blame]]"
-        diff="[[_diff]]"
-        parent-index="[[_parentIndex]]"></gr-diff>
+        diff="[[_diff]]"></gr-diff>
     <gr-rest-api-interface id="restAPI"></gr-rest-api-interface>
     <gr-reporting id="reporting" category="diff"></gr-reporting>
   </template>
diff --git a/polygerrit-ui/app/elements/diff/gr-diff-host/gr-diff-host.js b/polygerrit-ui/app/elements/diff/gr-diff-host/gr-diff-host.js
index 056ab60..814c7268 100644
--- a/polygerrit-ui/app/elements/diff/gr-diff-host/gr-diff-host.js
+++ b/polygerrit-ui/app/elements/diff/gr-diff-host/gr-diff-host.js
@@ -46,12 +46,29 @@
   }
 
   /**
+   * Compare two ranges. Either argument may be falsy, but will only return
+   * true if both are falsy or if neither are falsy and have the same position
+   * values.
+   *
+   * @param {Gerrit.Range=} a range 1
+   * @param {Gerrit.Range=} b range 2
+   * @return {boolean}
+   */
+  function rangesEqual(a, b) {
+    if (!a && !b) { return true; }
+    if (!a || !b) { return false; }
+    return a.start_line === b.start_line &&
+        a.start_character === b.start_character &&
+        a.end_line === b.end_line &&
+        a.end_character === b.end_character;
+  }
+
+  /**
    * Wrapper around gr-diff.
    *
    * Webcomponent fetching diffs and related data from restAPI and passing them
    * to the presentational gr-diff for rendering.
    */
-  // TODO(oler): Move all calls to restAPI from gr-diff here.
   Polymer({
     is: 'gr-diff-host',
 
@@ -108,7 +125,10 @@
         type: Boolean,
         value: false,
       },
-      comments: Object,
+      comments: {
+        type: Object,
+        observer: '_commentsChanged',
+      },
       lineWrapping: {
         type: Boolean,
         value: false,
@@ -176,6 +196,11 @@
         type: Number,
         computed: '_computeParentIndex(patchRange.*)',
       },
+
+      _threadEls: {
+        type: Array,
+        value: [],
+      },
     },
 
     behaviors: [
@@ -310,7 +335,7 @@
      * @return {!Array<!HTMLElement>}
      */
     getThreadEls() {
-      return this.$.diff.getThreadEls();
+      return this._threadEls;
     },
 
     /** @param {HTMLElement} el */
@@ -437,6 +462,70 @@
       return isImageDiff(diff);
     },
 
+
+    _commentsChanged(newComments) {
+      const allComments = [];
+      for (const side of [GrDiffBuilder.Side.LEFT, GrDiffBuilder.Side.RIGHT]) {
+        // This is needed by the threading.
+        for (const comment of newComments[side]) {
+          comment.__commentSide = side;
+        }
+        allComments.push(...newComments[side]);
+      }
+      // Currently, the only way this is ever changed here is when the initial
+      // comments are loaded, so it's okay performance wise to clear the threads
+      // and recreate them. If this changes in future, we might want to reuse
+      // some DOM nodes here.
+      this._clearThreads();
+      const threads = this._createThreads(allComments);
+      for (const thread of threads) {
+        const threadEl = this._createThreadElement(thread);
+        this._attachThreadElement(threadEl);
+      }
+    },
+
+    /**
+     * @param {!Array<!Object>} comments
+     * @return {!Array<!Object>} Threads for the given comments.
+     */
+    _createThreads(comments) {
+      const sortedComments = comments.slice(0).sort((a, b) => {
+        if (b.__draft && !a.__draft ) { return 0; }
+        if (a.__draft && !b.__draft ) { return 1; }
+        return util.parseDate(a.updated) - util.parseDate(b.updated);
+      });
+
+      const threads = [];
+      for (const comment of sortedComments) {
+        // If the comment is in reply to another comment, find that comment's
+        // thread and append to it.
+        if (comment.in_reply_to) {
+          const thread = threads.find(thread =>
+              thread.comments.some(c => c.id === comment.in_reply_to));
+          if (thread) {
+            thread.comments.push(comment);
+            continue;
+          }
+        }
+
+        // Otherwise, this comment starts its own thread.
+        const newThread = {
+          start_datetime: comment.updated,
+          comments: [comment],
+          commentSide: comment.__commentSide,
+          patchNum: comment.patch_set,
+          rootId: comment.id || comment.__draftID,
+          lineNum: comment.line,
+          isOnParent: comment.side === 'PARENT',
+        };
+        if (comment.range) {
+          newThread.range = Object.assign({}, comment.range);
+        }
+        threads.push(newThread);
+      }
+      return threads;
+    },
+
     /**
      * @param {Object} blame
      * @return {boolean}
@@ -456,32 +545,117 @@
 
     /** @param {CustomEvent} e */
     _handleCreateComment(e) {
-      const {threadGroupEl, lineNum, side, range} = e.detail;
-      const threadEl = this._getOrCreateThread(threadGroupEl, side, range);
+      const {lineNum, side, patchNum, isOnParent, range} = e.detail;
+      const threadEl = this._getOrCreateThread(patchNum, lineNum, side, range,
+          isOnParent);
       threadEl.addOrEditDraft(lineNum, range);
+
       this.$.reporting.recordDraftInteraction();
     },
 
     /**
-     * Gets or creates a comment thread from a specific thread group.
-     * May include a range, if the comment is a range comment.
+     * Gets or creates a comment thread at a given location.
+     * May provide a range, to get/create a range comment.
      *
-     * @param {!Object} threadGroupEl
+     * @param {string} patchNum
+     * @param {?number} lineNum
      * @param {string} commentSide
-     * @param {!Object=} range
+     * @param {Gerrit.Range|undefined} range
+     * @param {boolean} isOnParent
      * @return {!Object}
      */
-    _getOrCreateThread(threadGroupEl, commentSide, range=undefined) {
-      let threadEl = threadGroupEl.getThread(commentSide, range);
-
+    _getOrCreateThread(patchNum, lineNum, commentSide, range, isOnParent) {
+      let threadEl = this._getThreadEl(lineNum, commentSide, range);
       if (!threadEl) {
-        threadGroupEl.addNewThread(commentSide, range);
-        Polymer.dom.flush();
-        threadEl = threadGroupEl.getThread(commentSide, range);
+        threadEl = this._createThreadElement({
+          comments: [],
+          commentSide,
+          patchNum,
+          lineNum,
+          range,
+          isOnParent,
+        });
+        this._attachThreadElement(threadEl);
       }
       return threadEl;
     },
 
+    _attachThreadElement(threadEl) {
+      this._threadEls.push(threadEl);
+      Polymer.dom(this.$.diff).appendChild(threadEl);
+    },
+
+    _clearThreads() {
+      for (const threadEl of this._threadEls) {
+        const parent = Polymer.dom(threadEl).parentNode;
+        Polymer.dom(parent).removeChild(threadEl);
+      }
+      this._threadEls = [];
+    },
+
+    _createThreadElement(thread) {
+      const threadEl = document.createElement('gr-diff-comment-thread');
+      threadEl.className = 'comment-thread';
+      threadEl.comments = thread.comments;
+      threadEl.commentSide = thread.commentSide;
+      threadEl.isOnParent = !!thread.isOnParent;
+      threadEl.parentIndex = this._parentIndex;
+      threadEl.changeNum = this.changeNum;
+      threadEl.patchNum = thread.patchNum;
+      threadEl.lineNum = thread.lineNum;
+      const rootIdChangedListener = changeEvent => {
+        thread.rootId = changeEvent.detail.value;
+      };
+      threadEl.addEventListener('root-id-changed', rootIdChangedListener);
+      threadEl.path = this.path;
+      threadEl.projectName = this.projectName;
+      threadEl.range = thread.range;
+      const threadDiscardListener = e => {
+        const threadEl = /** @type {!Node} */ (e.currentTarget);
+
+        const parent = Polymer.dom(threadEl).parentNode;
+        Polymer.dom(parent).removeChild(threadEl);
+
+        const i = this._threadEls.findIndex(
+            threadEl => threadEl.rootId == e.detail.rootId);
+        this._threadEls.splice(i, 1);
+
+        threadEl.removeEventListener('root-id-changed', rootIdChangedListener);
+        threadEl.removeEventListener('thread-discard', threadDiscardListener);
+      };
+      threadEl.addEventListener('thread-discard', threadDiscardListener);
+      return threadEl;
+    },
+
+    /**
+     * Gets a comment thread element at a given location.
+     * May provide a range, to get a range comment.
+     *
+     * @param {?number} lineNum
+     * @param {string} commentSide
+     * @param {!Gerrit.Range=} range
+     * @return {?Node}
+     */
+    _getThreadEl(lineNum, commentSide, range=undefined) {
+      let line;
+      if (commentSide === GrDiffBuilder.Side.LEFT) {
+        line = {beforeNumber: lineNum};
+      } else if (commentSide === GrDiffBuilder.Side.RIGHT) {
+        line = {afterNumber: lineNum};
+      } else {
+        throw new Error(`Unknown side: ${commentSide}`);
+      }
+      function matchesRange(threadEl) {
+        const threadRange = /** @type {!Gerrit.Range} */(
+            JSON.parse(threadEl.getAttribute('range')));
+        return rangesEqual(threadRange, range);
+      }
+
+      const filteredThreadEls = Gerrit.filterThreadElsForLocation(
+          this._threadEls, line, commentSide).filter(matchesRange);
+      return filteredThreadEls.length ? filteredThreadEls[0] : null;
+    },
+
     /**
      * Take a diff that was loaded with a ignore-whitespace other than
      * IGNORE_NONE, and convert delta chunks labeled as common into shared
diff --git a/polygerrit-ui/app/elements/diff/gr-diff-host/gr-diff-host_test.html b/polygerrit-ui/app/elements/diff/gr-diff-host/gr-diff-host_test.html
index 24afe87..423bdc6 100644
--- a/polygerrit-ui/app/elements/diff/gr-diff-host/gr-diff-host_test.html
+++ b/polygerrit-ui/app/elements/diff/gr-diff-host/gr-diff-host_test.html
@@ -46,12 +46,41 @@
         async getLoggedIn() { return getLoggedIn; },
       });
       element = fixture('basic');
+      // For reasons beyond me, fixture reuses elements, cleans out some
+      // stuff but not that list.
+      element._threadEls = [];
     });
 
     teardown(() => {
       sandbox.restore();
     });
 
+    test('thread-discard handling', () => {
+      const threads = [
+        {comments: [{id: 4711}]},
+        {comments: [{id: 42}]},
+      ];
+      element._parentIndex = 1;
+      element.changeNum = '2';
+      element.path = 'some/path';
+      element.projectName = 'Some project';
+      const threadEls = threads.map(
+          thread => element._createThreadElement(thread));
+      assert.equal(threadEls.length, 2);
+      assert.equal(threadEls[0].rootId, 4711);
+      assert.equal(threadEls[1].rootId, 42);
+      for (const threadEl of threadEls) {
+        Polymer.dom(element).appendChild(threadEl);
+      }
+
+      threadEls[0].dispatchEvent(
+          new CustomEvent('thread-discard', {detail: {rootId: 4711}}));
+      const attachedThreads = element.queryAllEffectiveChildren(
+          'gr-diff-comment-thread');
+      assert.equal(attachedThreads.length, 1);
+      assert.equal(attachedThreads[0].rootId, 42);
+    });
+
     test('reload() cancels before network resolves', () => {
       const cancelStub = sandbox.stub(element.$.diff, 'cancel');
 
@@ -182,7 +211,11 @@
               });
 
           element.patchRange = {basePatchNum: 'PARENT', patchNum: 1};
-          element.comments = {left: [], right: []};
+          element.comments = {
+            left: [],
+            right: [],
+            meta: {patchRange: element.patchRange},
+          };
         });
 
         test('renders image diffs with same file name', done => {
@@ -556,13 +589,10 @@
       });
     });
 
-    test('delegates getThreadEls()', () => {
+    test('getThreadEls() returns _threadEls', () => {
       const returnValue = [document.createElement('b')];
-      const stub = sandbox.stub(element.$.diff, 'getThreadEls')
-          .returns(returnValue);
+      element._threadEls = returnValue;
       assert.equal(element.getThreadEls(), returnValue);
-      assert.isTrue(stub.calledOnce);
-      assert.equal(stub.lastCall.args.length, 0);
     });
 
     test('delegates addDraftAtLine(el)', () => {
@@ -771,27 +801,188 @@
       });
     });
 
+    test('_createThreads', () => {
+      const comments = [
+        {
+          id: 'sallys_confession',
+          message: 'i like you, jack',
+          updated: '2015-12-23 15:00:20.396000000',
+          line: 1,
+          __commentSide: 'left',
+        }, {
+          id: 'jacks_reply',
+          message: 'i like you, too',
+          updated: '2015-12-24 15:01:20.396000000',
+          __commentSide: 'left',
+          line: 1,
+          in_reply_to: 'sallys_confession',
+        },
+        {
+          id: 'new_draft',
+          message: 'i do not like either of you',
+          __commentSide: 'left',
+          __draft: true,
+          updated: '2015-12-20 15:01:20.396000000',
+        },
+      ];
+
+      const actualThreads = element._createThreads(comments);
+
+      assert.equal(actualThreads.length, 2);
+
+      assert.equal(
+          actualThreads[0].start_datetime, '2015-12-23 15:00:20.396000000');
+      assert.equal(actualThreads[0].commentSide, 'left');
+      assert.equal(actualThreads[0].comments.length, 2);
+      assert.deepEqual(actualThreads[0].comments[0], comments[0]);
+      assert.deepEqual(actualThreads[0].comments[1], comments[1]);
+      assert.equal(actualThreads[0].patchNum, undefined);
+      assert.equal(actualThreads[0].rootId, 'sallys_confession');
+      assert.equal(actualThreads[0].lineNum, 1);
+
+      assert.equal(
+          actualThreads[1].start_datetime, '2015-12-20 15:01:20.396000000');
+      assert.equal(actualThreads[1].commentSide, 'left');
+      assert.equal(actualThreads[1].comments.length, 1);
+      assert.deepEqual(actualThreads[1].comments[0], comments[2]);
+      assert.equal(actualThreads[1].patchNum, undefined);
+      assert.equal(actualThreads[1].rootId, 'new_draft');
+      assert.equal(actualThreads[1].lineNum, undefined);
+    });
+
+    test('_createThreads inherits patchNum and range', () => {
+      const comments = [{
+        id: 'betsys_confession',
+        message: 'i like you, jack',
+        updated: '2015-12-24 15:00:10.396000000',
+        range: {
+          start_line: 1,
+          start_character: 1,
+          end_line: 1,
+          end_character: 2,
+        },
+        patch_set: 5,
+        __commentSide: 'left',
+        line: 1,
+      }];
+
+      expectedThreads = [
+        {
+          start_datetime: '2015-12-24 15:00:10.396000000',
+          commentSide: 'left',
+          comments: [{
+            id: 'betsys_confession',
+            message: 'i like you, jack',
+            updated: '2015-12-24 15:00:10.396000000',
+            range: {
+              start_line: 1,
+              start_character: 1,
+              end_line: 1,
+              end_character: 2,
+            },
+            patch_set: 5,
+            __commentSide: 'left',
+            line: 1,
+          }],
+          patchNum: 5,
+          rootId: 'betsys_confession',
+          range: {
+            start_line: 1,
+            start_character: 1,
+            end_line: 1,
+            end_character: 2,
+          },
+          lineNum: 1,
+          isOnParent: false,
+        },
+      ];
+
+      assert.deepEqual(
+          element._createThreads(comments),
+          expectedThreads);
+    });
+
+    test('_createThreads does not thread unrelated comments at same location',
+        () => {
+          const comments = [
+            {
+              id: 'sallys_confession',
+              message: 'i like you, jack',
+              updated: '2015-12-23 15:00:20.396000000',
+              __commentSide: 'left',
+            }, {
+              id: 'jacks_reply',
+              message: 'i like you, too',
+              updated: '2015-12-24 15:01:20.396000000',
+              __commentSide: 'left',
+            },
+          ];
+          assert.equal(element._createThreads(comments).length, 2);
+        });
+
+    test('_createThreads derives isOnParent using  side from first comment',
+        () => {
+          const comments = [
+            {
+              id: 'sallys_confession',
+              message: 'i like you, jack',
+              updated: '2015-12-23 15:00:20.396000000',
+              // line: 1,
+              // __commentSide: 'left',
+            }, {
+              id: 'jacks_reply',
+              message: 'i like you, too',
+              updated: '2015-12-24 15:01:20.396000000',
+              // __commentSide: 'left',
+              // line: 1,
+              in_reply_to: 'sallys_confession',
+            },
+          ];
+
+          assert.equal(element._createThreads(comments)[0].isOnParent, false);
+
+          comments[0].side = 'REVISION';
+          assert.equal(element._createThreads(comments)[0].isOnParent, false);
+
+          comments[0].side = 'PARENT';
+          assert.equal(element._createThreads(comments)[0].isOnParent, true);
+        });
+
     test('_getOrCreateThread', () => {
-      const threadGroupEl =
-          document.createElement('gr-diff-comment-thread-group');
       const commentSide = 'left';
 
-      assert.isOk(element._getOrCreateThread(threadGroupEl,
-          commentSide));
+      assert.isOk(element._getOrCreateThread('2', 3,
+          commentSide, undefined, false));
+
+      let threads = Polymer.dom(element.$.diff)
+          .queryDistributedElements('gr-diff-comment-thread');
+
+      assert.equal(threads.length, 1);
+      assert.equal(threads[0].commentSide, commentSide);
+      assert.equal(threads[0].range, undefined);
+      assert.equal(threads[0].isOnParent, false);
+      assert.equal(threads[0].patchNum, 2);
+
 
       // Try to fetch a thread with a different range.
       range = {
-        startLine: 1,
-        startChar: 1,
-        endLine: 1,
-        endChar: 3,
+        start_line: 1,
+        start_character: 1,
+        end_line: 1,
+        end_character: 3,
       };
 
       assert.isOk(element._getOrCreateThread(
-          threadGroupEl, commentSide, range));
-      const threadCount = Polymer.dom(threadGroupEl.root).
-            querySelectorAll('gr-diff-comment-thread').length;
-      assert.equal(threadCount, 2);
+          '3', 1, commentSide, range, true));
+
+      threads = Polymer.dom(element.$.diff)
+          .queryDistributedElements('gr-diff-comment-thread');
+
+      assert.equal(threads.length, 2);
+      assert.equal(threads[1].commentSide, commentSide);
+      assert.equal(threads[1].range, range);
+      assert.equal(threads[1].isOnParent, true);
+      assert.equal(threads[1].patchNum, 3);
     });
 
     suite('_translateChunksToIgnore', () => {
diff --git a/polygerrit-ui/app/elements/diff/gr-diff-view/gr-diff-view.html b/polygerrit-ui/app/elements/diff/gr-diff-view/gr-diff-view.html
index 8acbd5d..b3210cc 100644
--- a/polygerrit-ui/app/elements/diff/gr-diff-view/gr-diff-view.html
+++ b/polygerrit-ui/app/elements/diff/gr-diff-view/gr-diff-view.html
@@ -332,7 +332,6 @@
         patch-range="[[_patchRange]]"
         path="[[_path]]"
         prefs="[[_prefs]]"
-        project-config="[[_projectConfig]]"
         project-name="[[_change.project]]"
         view-mode="[[_diffMode]]"
         is-blame-loaded="{{_isBlameLoaded}}"
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
index 59c5b1f..fd54af4 100644
--- 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
@@ -169,6 +169,10 @@
         type: Object,
         computed: '_getRevisionInfo(_change)',
       },
+      _reviewedFiles: {
+        type: Object,
+        value: () => new Set(),
+      },
     },
 
     behaviors: [
@@ -215,6 +219,7 @@
         [this.Shortcut.TOGGLE_DIFF_MODE]: '_handleToggleDiffMode',
         [this.Shortcut.TOGGLE_FILE_REVIEWED]: '_handleToggleFileReviewed',
         [this.Shortcut.EXPAND_ALL_DIFF_CONTEXT]: '_handleExpandAllDiffContext',
+        [this.Shortcut.NEXT_UNREVIEWED_FILE]: '_handleNextUnreviewedFile',
 
         // Final two are actually handled by gr-diff-comment-thread.
         [this.Shortcut.EXPAND_ALL_COMMENT_THREADS]: null,
@@ -564,10 +569,18 @@
       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.$.restAPI.getReviewedFiles(changeNum, patchNum)
-          .then(files => files.includes(path));
+      return this._getReviewedFiles(changeNum, patchNum)
+          .then(files => files.has(path));
     },
 
     _paramsChanged(value) {
@@ -1025,5 +1038,15 @@
     _computeDiffPrefsDisabled(disableDiffPrefs, loggedIn) {
       return disableDiffPrefs || !loggedIn;
     },
+
+    _handleNextUnreviewedFile(e) {
+      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);
+    },
   });
 })();
diff --git a/polygerrit-ui/app/elements/diff/gr-diff-view/gr-diff-view_test.html b/polygerrit-ui/app/elements/diff/gr-diff-view/gr-diff-view_test.html
index 3a5ca51..0274330 100644
--- a/polygerrit-ui/app/elements/diff/gr-diff-view/gr-diff-view_test.html
+++ b/polygerrit-ui/app/elements/diff/gr-diff-view/gr-diff-view_test.html
@@ -67,6 +67,7 @@
     kb.bindShortcut(kb.Shortcut.EXPAND_ALL_DIFF_CONTEXT, 'shift+x');
     kb.bindShortcut(kb.Shortcut.EXPAND_ALL_COMMENT_THREADS, 'e');
     kb.bindShortcut(kb.Shortcut.COLLAPSE_ALL_COMMENT_THREADS, 'shift+e');
+    kb.bindShortcut(kb.Shortcut.NEXT_UNREVIEWED_FILE, 'shift+m');
 
     let element;
     let sandbox;
@@ -1127,5 +1128,22 @@
       assert.isTrue(setStub.calledOnce);
       assert.isTrue(setStub.calledWith(101, 'test-project'));
     });
+
+    test('shift+m navigates to next unreviewed file', () => {
+      element._fileList = ['file1', 'file2', 'file3'];
+      element._reviewedFiles = new Set(['file1', 'file2']);
+      element._path = 'file1';
+      const reviewedStub = sandbox.stub(element, '_setReviewed');
+      const navStub = sandbox.stub(element, '_navToFile');
+      MockInteractions.pressAndReleaseKeyOn(element, 77, 'shift', 'm');
+      flushAsynchronousOperations();
+
+      assert.isTrue(reviewedStub.lastCall.args[0]);
+      assert.deepEqual(navStub.lastCall.args, [
+        'file1',
+        ['file1', 'file3'],
+        1,
+      ]);
+    });
   });
 </script>
diff --git a/polygerrit-ui/app/elements/diff/gr-diff/gr-diff.html b/polygerrit-ui/app/elements/diff/gr-diff/gr-diff.html
index adb4dd6..862db10 100644
--- a/polygerrit-ui/app/elements/diff/gr-diff/gr-diff.html
+++ b/polygerrit-ui/app/elements/diff/gr-diff/gr-diff.html
@@ -20,7 +20,6 @@
 <link rel="import" href="../../../styles/shared-styles.html">
 <link rel="import" href="../../shared/gr-button/gr-button.html">
 <link rel="import" href="../gr-diff-builder/gr-diff-builder.html">
-<link rel="import" href="../gr-diff-comment-thread/gr-diff-comment-thread.html">
 <link rel="import" href="../gr-diff-highlight/gr-diff-highlight.html">
 <link rel="import" href="../gr-diff-selection/gr-diff-selection.html">
 <link rel="import" href="../gr-syntax-themes/gr-syntax-theme.html">
@@ -36,6 +35,11 @@
       :host(.no-left) .sideBySide ::content .right:not([data-value]) + td {
         display: none;
       }
+      .thread-group, ::slotted(*) .thread-group {
+        display: block;
+        max-width: var(--content-width, 80ch);
+        white-space: normal;
+      }
       .diffContainer {
         display: flex;
         font-family: var(--monospace-font-family);
@@ -276,10 +280,10 @@
         <gr-diff-highlight
             id="highlights"
             logged-in="[[loggedIn]]"
-            comments="{{comments}}">
+            comment-ranges="{{_commentRanges}}">
           <gr-diff-builder
               id="diffBuilder"
-              comments="[[comments]]"
+              comment-ranges="[[_commentRanges]]"
               project-name="[[projectName]]"
               diff="[[diff]]"
               diff-path="[[path]]"
@@ -290,8 +294,8 @@
               is-image-diff="[[isImageDiff]]"
               base-image="[[baseImage]]"
               revision-image="[[revisionImage]]"
-              create-comment-fn="[[_createThreadGroupFn]]"
               line-of-interest="[[lineOfInterest]]">
+            <slot></slot>
             <table
                 id="diffTable"
                 class$="[[_diffTableClass]]"
diff --git a/polygerrit-ui/app/elements/diff/gr-diff/gr-diff.js b/polygerrit-ui/app/elements/diff/gr-diff/gr-diff.js
index 6016a5a..f87e46f 100644
--- a/polygerrit-ui/app/elements/diff/gr-diff/gr-diff.js
+++ b/polygerrit-ui/app/elements/diff/gr-diff/gr-diff.js
@@ -39,6 +39,15 @@
   const FULL_CONTEXT = -1;
   const LIMITED_CONTEXT = 10;
 
+  /** @typedef {{start_line: number, start_character: number,
+   *             end_line: number, end_character: number}} */
+  Gerrit.Range;
+
+  function isThreadEl(node) {
+    return node.nodeType === Node.ELEMENT_NODE &&
+        node.classList.contains('comment-thread');
+  }
+
   Polymer({
     is: 'gr-diff',
 
@@ -96,6 +105,11 @@
         type: Object,
         value: {left: [], right: []},
       },
+      /** @type {!Array<!Gerrit.HoveredRange>} */
+      _commentRanges: {
+        type: Array,
+        value: [],
+      },
       lineWrapping: {
         type: Boolean,
         value: false,
@@ -179,17 +193,22 @@
         computed: '_computeNewlineWarning(diff)',
       },
 
-      /**
-       * @type {function(number, boolean, !string)}
-       */
-      _createThreadGroupFn: {
-        type: Function,
-        value() {
-          return this._createCommentThreadGroup.bind(this);
-        },
-      },
-
       _diffLength: Number,
+
+      /**
+       * Observes comment nodes added or removed after the initial render.
+       * Can be used to unregister when the entire diff is (re-)rendered or upon
+       * detachment.
+       * @type {?PolymerDomApi.ObserveHandle}
+       */
+      _incrementalNodeObserver: Object,
+
+      /**
+       * Observes comment nodes added or removed at any point.
+       * Can be used to unregister upon detachment.
+       * @type {?PolymerDomApi.ObserveHandle}
+       */
+      _nodeObserver: Object,
     },
 
     behaviors: [
@@ -201,6 +220,37 @@
       'comment-update': '_handleCommentUpdate',
       'comment-save': '_handleCommentSave',
       'create-range-comment': '_handleCreateRangeComment',
+      'render-content': '_handleRenderContent',
+    },
+
+    attached() {
+      this._updateRangesWhenNodesChange();
+    },
+
+    detached() {
+      this._unobserveIncrementalNodes();
+      this._unobserveNodes();
+    },
+
+    _updateRangesWhenNodesChange() {
+      function commentRangeFromThreadEl(threadEl) {
+        const side = threadEl.getAttribute('comment-side');
+        const range = JSON.parse(threadEl.getAttribute('range'));
+        return {side, range, hovering: false};
+      }
+
+      this._nodeObserver = Polymer.dom(this).observeNodes(info => {
+        const addedThreadEls = info.addedNodes.filter(isThreadEl);
+        const addedCommentRanges = addedThreadEls
+            .map(commentRangeFromThreadEl)
+            .filter(({range}) => range);
+        this.push('_commentRanges', ...addedCommentRanges);
+        // In principal we should also handle removed nodes, but I have not
+        // figured out how to do that yet without also catching all the removals
+        // caused by further redistribution. Right now, comments are never
+        // removed by no longer slotting them in, so I decided to not handle
+        // this situation until it occurs.
+      });
     },
 
     /** Cancel any remaining diff builder rendering work. */
@@ -240,17 +290,6 @@
           {bubbles: true}));
     },
 
-    /** @return {!Array<!HTMLElement>} */
-    getThreadEls() {
-      let threads = [];
-      const threadGroupEls = Polymer.dom(this.root)
-          .querySelectorAll('gr-diff-comment-thread-group');
-      for (const threadGroupEl of threadGroupEls) {
-        threads = threads.concat(threadGroupEl.threadEls);
-      }
-      return threads;
-    },
-
     /** @return {string} */
     _computeContainerClass(loggedIn, viewMode, displayLine) {
       const classes = ['diffContainer'];
@@ -318,7 +357,7 @@
     _handleCreateRangeComment(e) {
       const range = e.detail.range;
       const side = e.detail.side;
-      const lineNum = range.endLine;
+      const lineNum = range.end_line;
       const lineEl = this.$.diffBuilder.getLineElByNumber(lineNum, side);
 
       if (this._isValidElForComment(lineEl)) {
@@ -361,66 +400,44 @@
       const contentEl = contentText.parentElement;
       side = side ||
           this._getCommentSideByLineAndContent(lineEl, contentEl);
-      const patchNum = this._getPatchNumByLineAndContent(lineEl, contentEl);
+      const patchForNewThreads = this._getPatchNumByLineAndContent(
+          lineEl, contentEl);
       const isOnParent =
-        this._getIsParentCommentByLineAndContent(lineEl, contentEl);
-      const threadGroupEl = this._getOrCreateThreadGroup(contentEl, patchNum,
-          side, isOnParent);
+          this._getIsParentCommentByLineAndContent(lineEl, contentEl);
       this.dispatchEvent(new CustomEvent('create-comment', {
         bubbles: true,
         detail: {
-          threadGroupEl,
           lineNum,
           side,
+          patchNum: patchForNewThreads,
+          isOnParent,
           range,
         },
       }));
     },
 
     _getThreadGroupForLine(contentEl) {
-      return contentEl.querySelector('gr-diff-comment-thread-group');
+      return contentEl.querySelector('.thread-group');
     },
 
     /**
      * Gets or creates a comment thread group for a specific line and side on a
      * diff.
      * @param {!Object} contentEl
-     * @param {number} patchNum
-     * @param {string} commentSide
-     * @param {boolean} isOnParent
-     * @return {!Object}
+     * @return {!Node}
      */
-    _getOrCreateThreadGroup(contentEl, patchNum, commentSide, isOnParent) {
+    _getOrCreateThreadGroup(contentEl) {
       // Check if thread group exists.
       let threadGroupEl = this._getThreadGroupForLine(contentEl);
       if (!threadGroupEl) {
-        threadGroupEl = this._createCommentThreadGroup(patchNum, isOnParent,
-            commentSide);
+        threadGroupEl = document.createElement('div');
+        threadGroupEl.className = 'thread-group';
         contentEl.appendChild(threadGroupEl);
       }
       return threadGroupEl;
     },
 
     /**
-     * @param {number} patchNum
-     * @param {boolean} isOnParent
-     * @param {!string} commentSide
-     * @return {!Object}
-     */
-    _createCommentThreadGroup(patchNum, isOnParent, commentSide) {
-      const threadGroupEl =
-          document.createElement('gr-diff-comment-thread-group');
-      threadGroupEl.changeNum = this.changeNum;
-      threadGroupEl.commentSide = commentSide;
-      threadGroupEl.patchForNewThreads = patchNum;
-      threadGroupEl.path = this.path;
-      threadGroupEl.isOnParent = isOnParent;
-      threadGroupEl.projectName = this.projectName;
-      threadGroupEl.parentIndex = this._parentIndex;
-      return threadGroupEl;
-    },
-
-    /**
      * The value to be used for the patch number of new comments created at the
      * given line and content elements.
      *
@@ -608,6 +625,7 @@
     },
 
     _renderDiffTable() {
+      this._unobserveIncrementalNodes();
       if (!this.prefs) {
         this.dispatchEvent(new CustomEvent('render', {bubbles: true}));
         return;
@@ -624,6 +642,39 @@
       this.$.diffBuilder.render(this.comments, this._getBypassPrefs());
     },
 
+    _handleRenderContent() {
+      this._incrementalNodeObserver = Polymer.dom(this).observeNodes(info => {
+        const addedThreadEls = info.addedNodes.filter(isThreadEl);
+        // In principal we should also handle removed nodes, but I have not
+        // figured out how to do that yet without also catching all the removals
+        // caused by further redistribution. Right now, comments are never
+        // removed by no longer slotting them in, so I decided to not handle
+        // this situation until it occurs.
+        for (const threadEl of addedThreadEls) {
+          const lineNumString = threadEl.getAttribute('line-num') || 'FILE';
+          const commentSide = threadEl.getAttribute('comment-side');
+          const lineEl = this.$.diffBuilder.getLineElByNumber(
+              lineNumString, commentSide);
+          const contentText = this.$.diffBuilder.getContentByLineEl(lineEl);
+          const contentEl = contentText.parentElement;
+          const threadGroupEl = this._getOrCreateThreadGroup(contentEl);
+          Polymer.dom(threadGroupEl).appendChild(threadEl);
+        }
+      });
+    },
+
+    _unobserveIncrementalNodes() {
+      if (this._incrementalNodeObserver) {
+        Polymer.dom(this).unobserveNodes(this._incrementalNodeObserver);
+      }
+    },
+
+    _unobserveNodes() {
+      if (this._nodeObserver) {
+        Polymer.dom(this).unobserveNodes(this._nodeObserver);
+      }
+    },
+
     /**
      * Get the preferences object including the safety bypass context (if any).
      */
@@ -635,6 +686,7 @@
     },
 
     clearDiffContent() {
+      this._unobserveIncrementalNodes();
       this.$.diffTable.innerHTML = null;
     },
 
diff --git a/polygerrit-ui/app/elements/diff/gr-diff/gr-diff_test.html b/polygerrit-ui/app/elements/diff/gr-diff/gr-diff_test.html
index 07584c7..4befd2f 100644
--- a/polygerrit-ui/app/elements/diff/gr-diff/gr-diff_test.html
+++ b/polygerrit-ui/app/elements/diff/gr-diff/gr-diff_test.html
@@ -295,9 +295,6 @@
 
       test('thread groups', () => {
         const contentEl = document.createElement('div');
-        const commentSide = 'left';
-        const patchNum = 1;
-        const side = 'PARENT';
 
         element.changeNum = 123;
         element.patchRange = {basePatchNum: 1, patchNum: 2};
@@ -312,15 +309,13 @@
         assert.isNotOk(element._getThreadGroupForLine(contentEl));
 
         // A thread group gets created.
-        const threadGroupEl = element._getOrCreateThreadGroup(contentEl,
-            patchNum, commentSide, side);
+        const threadGroupEl = element._getOrCreateThreadGroup(contentEl);
         assert.isOk(threadGroupEl);
 
         // The new thread group can be fetched.
         assert.isOk(element._getThreadGroupForLine(contentEl));
 
-        assert.equal(contentEl.querySelectorAll(
-            'gr-diff-comment-thread-group').length, 1);
+        assert.equal(contentEl.querySelectorAll('.thread-group').length, 1);
       });
 
       suite('image diffs', () => {
@@ -339,7 +334,11 @@
           };
 
           element.patchRange = {basePatchNum: 'PARENT', patchNum: 1};
-          element.comments = {left: [], right: []};
+          element.comments = {
+            left: [],
+            right: [],
+            meta: {patchRange: undefined},
+          };
           element.isImageDiff = true;
           element.prefs = {
             auto_hide_diff_table_header: true,
@@ -668,6 +667,7 @@
           element.comments = {
             left: [],
             right: [],
+            meta: {patchRange: undefined},
           };
           element.prefs = {
             context: 10,
diff --git a/polygerrit-ui/app/elements/diff/gr-ranged-comment-layer/gr-ranged-comment-layer.js b/polygerrit-ui/app/elements/diff/gr-ranged-comment-layer/gr-ranged-comment-layer.js
index db14fc8..fa488f0 100644
--- a/polygerrit-ui/app/elements/diff/gr-ranged-comment-layer/gr-ranged-comment-layer.js
+++ b/polygerrit-ui/app/elements/diff/gr-ranged-comment-layer/gr-ranged-comment-layer.js
@@ -17,31 +17,34 @@
 (function() {
   'use strict';
 
-  const HOVER_PATH_PATTERN = /^comments\.(left|right)\.\#(\d+)\.__hovering$/;
-  const SPLICE_PATH_PATTERN = /^comments\.(left|right)\.splices$/;
+  const HOVER_PATH_PATTERN = /^commentRanges\.\#(\d+)\.hovering$/;
 
   const RANGE_HIGHLIGHT = 'range';
   const HOVER_HIGHLIGHT = 'rangeHighlight';
 
   const NORMALIZE_RANGE_EVENT = 'normalize-range';
 
+  /** @typedef {{side: string, range: Gerrit.Range, hovering: boolean}} */
+  Gerrit.HoveredRange;
+
   Polymer({
     is: 'gr-ranged-comment-layer',
 
     properties: {
-      comments: Object,
+      /** @type {!Array<!Gerrit.HoveredRange>} */
+      commentRanges: Array,
       _listeners: {
         type: Array,
         value() { return []; },
       },
-      _commentMap: {
+      _rangesMap: {
         type: Object,
-        value() { return {left: [], right: []}; },
+        value() { return {left: {}, right: {}}; },
       },
     },
 
     observers: [
-      '_handleCommentChange(comments.*)',
+      '_handleCommentRangesChange(commentRanges.*)',
     ],
 
     /**
@@ -93,97 +96,78 @@
     },
 
     /**
-     * Handle change in the comments by updating the comment maps and by
+     * Handle change in the ranges by updating the ranges maps and by
      * emitting appropriate update notifications.
      * @param {Object} record The change record.
      */
-    _handleCommentChange(record) {
-      if (!record.path) { return; }
+    _handleCommentRangesChange(record) {
+      if (!record) return;
 
       // If the entire set of comments was changed.
-      if (record.path === 'comments') {
-        this._commentMap.left = this._computeCommentMap(this.comments.left);
-        this._commentMap.right = this._computeCommentMap(this.comments.right);
-        return;
+      if (record.path === 'commentRanges') {
+        this._rangesMap = {left: {}, right: {}};
+        for (const {side, range, hovering} of record.value) {
+          this._updateRangesMap(
+              side, range, hovering, (forLine, start, end, hovering) => {
+                forLine.push({start, end, hovering});
+              });
+        }
       }
 
       // If the change only changed the `hovering` property of a comment.
-      let match = record.path.match(HOVER_PATH_PATTERN);
-      let side;
-
+      const match = record.path.match(HOVER_PATH_PATTERN);
       if (match) {
-        side = match[1];
-        const index = match[2];
-        const comment = this.comments[side][index];
-        if (comment && comment.range) {
-          this._commentMap[side] = this._computeCommentMap(this.comments[side]);
-          this._notifyUpdateRange(
-              comment.range.start_line, comment.range.end_line, side);
-        }
-        return;
+        const commentRangesIndex = match[1];
+        const {side, range, hovering} = this.commentRanges[commentRangesIndex];
+        this._updateRangesMap(
+            side, range, hovering, (forLine, start, end, hovering) => {
+              const index = forLine.findIndex(lineRange =>
+                  lineRange.start === start && lineRange.end === end);
+              forLine[index].hovering = hovering;
+            });
       }
 
       // If comments were spliced in or out.
-      match = record.path.match(SPLICE_PATH_PATTERN);
-      if (match) {
-        side = match[1];
-        this._commentMap[side] = this._computeCommentMap(this.comments[side]);
-        this._handleCommentSplice(record.value, side);
+      if (record.path === 'commentRanges.splices') {
+        for (const indexSplice of record.value.indexSplices) {
+          const removed = indexSplice.removed;
+          for (const {side, range, hovering} of removed) {
+            this._updateRangesMap(
+                side, range, hovering, (forLine, start, end) => {
+                  const index = forLine.findIndex(lineRange =>
+                      lineRange.start === start && lineRange.end === end);
+                  forLine.splice(index, 1);
+                });
+          }
+          const added = indexSplice.object.slice(
+              indexSplice.index, indexSplice.index + indexSplice.addedCount);
+          for (const {side, range, hovering} of added) {
+            this._updateRangesMap(
+                side, range, hovering, (forLine, start, end, hovering) => {
+                  forLine.push({start, end, hovering});
+                });
+          }
+        }
       }
     },
 
-    /**
-     * Take a list of comments and return a sparse list mapping line numbers to
-     * partial ranges. Uses an end-character-index of -1 to indicate the end of
-     * the line.
-     * @param {?} commentList The list of comments.
-     *    Getting this param to match closure requirements caused problems.
-     * @return {!Object} The sparse list.
-     */
-    _computeCommentMap(commentList) {
-      const result = {};
-      for (const comment of commentList) {
-        if (!comment.range) { continue; }
-        const range = comment.range;
-        for (let line = range.start_line; line <= range.end_line; line++) {
-          if (!result[line]) { result[line] = []; }
-          result[line].push({
-            comment,
-            start: line === range.start_line ? range.start_character : 0,
-            end: line === range.end_line ? range.end_character : -1,
-          });
-        }
+    _updateRangesMap(side, range, hovering, operation) {
+      const forSide = this._rangesMap[side] || (this._rangesMap[side] = {});
+      for (let line = range.start_line; line <= range.end_line; line++) {
+        const forLine = forSide[line] || (forSide[line] = []);
+        const start = line === range.start_line ? range.start_character : 0;
+        const end = line === range.end_line ? range.end_character : -1;
+        operation(forLine, start, end, hovering);
       }
-      return result;
-    },
-
-    /**
-     * Translate a splice record into range update notifications.
-     */
-    _handleCommentSplice(record, side) {
-      if (!record || !record.indexSplices) { return; }
-
-      for (const splice of record.indexSplices) {
-        const ranges = splice.removed.length ?
-            splice.removed.map(c => { return c.range; }) :
-            [splice.object[splice.index].range];
-        for (const range of ranges) {
-          if (!range) { continue; }
-          this._notifyUpdateRange(range.start_line, range.end_line, side);
-        }
-      }
+      this._notifyUpdateRange(range.start_line, range.end_line, side);
     },
 
     _getRangesForLine(line, side) {
       const lineNum = side === 'left' ? line.beforeNumber : line.afterNumber;
-      const ranges = this.get(['_commentMap', side, lineNum]) || [];
+      const ranges = this.get(['_rangesMap', side, lineNum]) || [];
       return ranges
           .map(range => {
-            range = {
-              start: range.start,
-              end: range.end === -1 ? line.text.length : range.end,
-              hovering: !!range.comment.__hovering,
-            };
+            range.end = range.end === -1 ? line.text.length : range.end;
 
             // Normalize invalid ranges where the start is after the end but the
             // start still makes sense. Set the end to the end of the line.
diff --git a/polygerrit-ui/app/elements/diff/gr-ranged-comment-layer/gr-ranged-comment-layer_test.html b/polygerrit-ui/app/elements/diff/gr-ranged-comment-layer/gr-ranged-comment-layer_test.html
index c541e26..c198ace 100644
--- a/polygerrit-ui/app/elements/diff/gr-ranged-comment-layer/gr-ranged-comment-layer_test.html
+++ b/polygerrit-ui/app/elements/diff/gr-ranged-comment-layer/gr-ranged-comment-layer_test.html
@@ -40,62 +40,48 @@
     let sandbox;
 
     setup(() => {
-      const initialComments = {
-        left: [
-          {
-            id: '12345',
-            line: 39,
-            message: 'range comment',
-            range: {
-              end_character: 9,
-              end_line: 39,
-              start_character: 6,
-              start_line: 36,
-            },
-          }, {
-            id: '23456',
-            line: 100,
-            message: 'non range comment',
+      const initialCommentRanges = [
+        {
+          side: 'left',
+          range: {
+            end_character: 9,
+            end_line: 39,
+            start_character: 6,
+            start_line: 36,
           },
-        ],
-        right: [
-          {
-            id: '34567',
-            line: 10,
-            message: 'range comment',
-            range: {
-              end_character: 22,
-              end_line: 12,
-              start_character: 10,
-              start_line: 10,
-            },
-          }, {
-            id: '45678',
-            line: 100,
-            message: 'single line range comment',
-            range: {
-              end_character: 15,
-              end_line: 100,
-              start_character: 5,
-              start_line: 100,
-            },
-          }, {
-            id: '8675309',
-            line: 55,
-            message: 'nonsense range',
-            range: {
-              end_character: 2,
-              end_line: 55,
-              start_character: 32,
-              start_line: 55,
-            },
+        },
+        {
+          side: 'right',
+          range: {
+            end_character: 22,
+            end_line: 12,
+            start_character: 10,
+            start_line: 10,
           },
-        ],
-      };
+        },
+        {
+          side: 'right',
+          range: {
+            end_character: 15,
+            end_line: 100,
+            start_character: 5,
+            start_line: 100,
+          },
+        },
+        {
+          side: 'right',
+          range: {
+            end_character: 2,
+            end_line: 55,
+            start_character: 32,
+            start_line: 55,
+          },
+        },
+      ];
 
       sandbox = sinon.sandbox.create();
       element = fixture('basic');
-      element.comments = initialComments;
+      element.commentRanges = initialCommentRanges;
     });
 
     teardown(() => {
@@ -149,7 +135,7 @@
       test('type=Remove has-comment hovering', () => {
         line.type = GrDiffLine.Type.REMOVE;
         line.beforeNumber = 36;
-        element.set(['comments', 'left', 0, '__hovering'], true);
+        element.set(['commentRanges', 0, 'hovering'], true);
 
         const expectedStart = 6;
         const expectedLength = line.text.length - expectedStart;
@@ -210,29 +196,18 @@
       });
     });
 
-    test('_handleCommentChange overwrite', () => {
-      const handlerSpy = sandbox.spy(element, '_handleCommentChange');
-      const mapSpy = sandbox.spy(element, '_computeCommentMap');
+    test('_handleCommentRangesChange overwrite', () => {
+      element.set('commentRanges', []);
 
-      element.set('comments', {left: [], right: []});
-
-      assert.isTrue(handlerSpy.called);
-      assert.equal(mapSpy.callCount, 2);
-
-      assert.equal(Object.keys(element._commentMap.left).length, 0);
-      assert.equal(Object.keys(element._commentMap.right).length, 0);
+      assert.equal(Object.keys(element._rangesMap.left).length, 0);
+      assert.equal(Object.keys(element._rangesMap.right).length, 0);
     });
 
-    test('_handleCommentChange hovering', () => {
-      const handlerSpy = sandbox.spy(element, '_handleCommentChange');
-      const mapSpy = sandbox.spy(element, '_computeCommentMap');
+    test('_handleCommentRangesChange hovering', () => {
       const notifyStub = sinon.stub();
       element.addListener(notifyStub);
 
-      element.set(['comments', 'right', 0, '__hovering'], true);
-
-      assert.isTrue(handlerSpy.called);
-      assert.isTrue(mapSpy.called);
+      element.set(['commentRanges', 1, 'hovering'], true);
 
       assert.isTrue(notifyStub.called);
       const lastCall = notifyStub.lastCall;
@@ -241,16 +216,11 @@
       assert.equal(lastCall.args[2], 'right');
     });
 
-    test('_handleCommentChange splice out', () => {
-      const handlerSpy = sandbox.spy(element, '_handleCommentChange');
-      const mapSpy = sandbox.spy(element, '_computeCommentMap');
+    test('_handleCommentRangesChange splice out', () => {
       const notifyStub = sinon.stub();
       element.addListener(notifyStub);
 
-      element.splice('comments.right', 0, 1);
-
-      assert.isTrue(handlerSpy.called);
-      assert.isTrue(mapSpy.called);
+      element.splice('commentRanges', 1, 1);
 
       assert.isTrue(notifyStub.called);
       const lastCall = notifyStub.lastCall;
@@ -259,16 +229,12 @@
       assert.equal(lastCall.args[2], 'right');
     });
 
-    test('_handleCommentChange splice in', () => {
-      const handlerSpy = sandbox.spy(element, '_handleCommentChange');
-      const mapSpy = sandbox.spy(element, '_computeCommentMap');
+    test('_handleCommentRangesChange splice in', () => {
       const notifyStub = sinon.stub();
       element.addListener(notifyStub);
 
-      element.splice('comments.left', element.comments.left.length, 0, {
-        id: '56123',
-        line: 250,
-        message: 'new range comment',
+      element.splice('commentRanges', 1, 0, {
+        side: 'left',
         range: {
           end_character: 15,
           end_line: 275,
@@ -277,9 +243,6 @@
         },
       });
 
-      assert.isTrue(handlerSpy.called);
-      assert.isTrue(mapSpy.called);
-
       assert.isTrue(notifyStub.called);
       const lastCall = notifyStub.lastCall;
       assert.equal(lastCall.args[0], 250);
@@ -291,48 +254,48 @@
       // There is only one ranged comment on the left, but it spans ll.36-39.
       const leftKeys = [];
       for (let i = 36; i <= 39; i++) { leftKeys.push('' + i); }
-      assert.deepEqual(Object.keys(element._commentMap.left).sort(),
+      assert.deepEqual(Object.keys(element._rangesMap.left).sort(),
           leftKeys.sort());
 
-      assert.equal(element._commentMap.left[36].length, 1);
-      assert.equal(element._commentMap.left[36][0].start, 6);
-      assert.equal(element._commentMap.left[36][0].end, -1);
+      assert.equal(element._rangesMap.left[36].length, 1);
+      assert.equal(element._rangesMap.left[36][0].start, 6);
+      assert.equal(element._rangesMap.left[36][0].end, -1);
 
-      assert.equal(element._commentMap.left[37].length, 1);
-      assert.equal(element._commentMap.left[37][0].start, 0);
-      assert.equal(element._commentMap.left[37][0].end, -1);
+      assert.equal(element._rangesMap.left[37].length, 1);
+      assert.equal(element._rangesMap.left[37][0].start, 0);
+      assert.equal(element._rangesMap.left[37][0].end, -1);
 
-      assert.equal(element._commentMap.left[38].length, 1);
-      assert.equal(element._commentMap.left[38][0].start, 0);
-      assert.equal(element._commentMap.left[38][0].end, -1);
+      assert.equal(element._rangesMap.left[38].length, 1);
+      assert.equal(element._rangesMap.left[38][0].start, 0);
+      assert.equal(element._rangesMap.left[38][0].end, -1);
 
-      assert.equal(element._commentMap.left[39].length, 1);
-      assert.equal(element._commentMap.left[39][0].start, 0);
-      assert.equal(element._commentMap.left[39][0].end, 9);
+      assert.equal(element._rangesMap.left[39].length, 1);
+      assert.equal(element._rangesMap.left[39][0].start, 0);
+      assert.equal(element._rangesMap.left[39][0].end, 9);
 
       // The right has two ranged comments, one spanning ll.10-12 and the other
       // on line 100.
       const rightKeys = [];
       for (let i = 10; i <= 12; i++) { rightKeys.push('' + i); }
       rightKeys.push('55', '100');
-      assert.deepEqual(Object.keys(element._commentMap.right).sort(),
+      assert.deepEqual(Object.keys(element._rangesMap.right).sort(),
           rightKeys.sort());
 
-      assert.equal(element._commentMap.right[10].length, 1);
-      assert.equal(element._commentMap.right[10][0].start, 10);
-      assert.equal(element._commentMap.right[10][0].end, -1);
+      assert.equal(element._rangesMap.right[10].length, 1);
+      assert.equal(element._rangesMap.right[10][0].start, 10);
+      assert.equal(element._rangesMap.right[10][0].end, -1);
 
-      assert.equal(element._commentMap.right[11].length, 1);
-      assert.equal(element._commentMap.right[11][0].start, 0);
-      assert.equal(element._commentMap.right[11][0].end, -1);
+      assert.equal(element._rangesMap.right[11].length, 1);
+      assert.equal(element._rangesMap.right[11][0].start, 0);
+      assert.equal(element._rangesMap.right[11][0].end, -1);
 
-      assert.equal(element._commentMap.right[12].length, 1);
-      assert.equal(element._commentMap.right[12][0].start, 0);
-      assert.equal(element._commentMap.right[12][0].end, 22);
+      assert.equal(element._rangesMap.right[12].length, 1);
+      assert.equal(element._rangesMap.right[12][0].start, 0);
+      assert.equal(element._rangesMap.right[12][0].end, 22);
 
-      assert.equal(element._commentMap.right[100].length, 1);
-      assert.equal(element._commentMap.right[100][0].start, 5);
-      assert.equal(element._commentMap.right[100][0].end, 15);
+      assert.equal(element._rangesMap.right[100].length, 1);
+      assert.equal(element._rangesMap.right[100][0].start, 5);
+      assert.equal(element._rangesMap.right[100][0].end, 15);
     });
 
     test('_getRangesForLine normalizes invalid ranges', () => {
diff --git a/polygerrit-ui/app/elements/diff/gr-selection-action-box/gr-selection-action-box.js b/polygerrit-ui/app/elements/diff/gr-selection-action-box/gr-selection-action-box.js
index 0f84877..fa5c810 100644
--- a/polygerrit-ui/app/elements/diff/gr-selection-action-box/gr-selection-action-box.js
+++ b/polygerrit-ui/app/elements/diff/gr-selection-action-box/gr-selection-action-box.js
@@ -34,10 +34,10 @@
       range: {
         type: Object,
         value: {
-          startLine: NaN,
-          startChar: NaN,
-          endLine: NaN,
-          endChar: NaN,
+          start_line: NaN,
+          start_character: NaN,
+          end_line: NaN,
+          end_character: NaN,
         },
       },
       positionBelow: Boolean,
diff --git a/polygerrit-ui/app/elements/diff/gr-selection-action-box/gr-selection-action-box_test.html b/polygerrit-ui/app/elements/diff/gr-selection-action-box/gr-selection-action-box_test.html
index 4f1065a..dece366 100644
--- a/polygerrit-ui/app/elements/diff/gr-selection-action-box/gr-selection-action-box_test.html
+++ b/polygerrit-ui/app/elements/diff/gr-selection-action-box/gr-selection-action-box_test.html
@@ -89,10 +89,10 @@
     test('event fired contains playload', () => {
       const side = 'left';
       const range = {
-        startLine: 1,
-        startChar: 11,
-        endLine: 2,
-        endChar: 42,
+        start_line: 1,
+        start_character: 11,
+        end_line: 2,
+        end_character: 42,
       };
       element.side = 'left';
       element.range = range;
diff --git a/polygerrit-ui/app/elements/documentation/gr-documentation-search/gr-documentation-search.html b/polygerrit-ui/app/elements/documentation/gr-documentation-search/gr-documentation-search.html
new file mode 100644
index 0000000..720f353
--- /dev/null
+++ b/polygerrit-ui/app/elements/documentation/gr-documentation-search/gr-documentation-search.html
@@ -0,0 +1,62 @@
+<!--
+@license
+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.
+-->
+<link rel="import" href="../../../bower_components/polymer/polymer.html">
+
+<link rel="import" href="../../../behaviors/base-url-behavior/base-url-behavior.html">
+<link rel="import" href="../../../behaviors/gr-list-view-behavior/gr-list-view-behavior.html">
+<link rel="import" href="../../../bower_components/iron-input/iron-input.html">
+<link rel="import" href="../../../styles/gr-table-styles.html">
+<link rel="import" href="../../../styles/shared-styles.html">
+<link rel="import" href="../../shared/gr-list-view/gr-list-view.html">
+<link rel="import" href="../../shared/gr-rest-api-interface/gr-rest-api-interface.html">
+
+<dom-module id="gr-documentation-search">
+  <template>
+    <style include="shared-styles"></style>
+    <style include="gr-table-styles"></style>
+    <gr-list-view
+        filter="[[_filter]]"
+        items=false
+        offset=0
+        loading="[[_loading]]"
+        path="[[_path]]">
+      <table id="list" class="genericList">
+        <tr class="headerRow">
+          <th class="name topHeader">Name</th>
+          <th class="name topHeader"></th>
+          <th class="name topHeader"></th>
+        </tr>
+        <tr id="loading" class$="loadingMsg [[computeLoadingClass(_loading)]]">
+          <td>Loading...</td>
+        </tr>
+        <tbody class$="[[computeLoadingClass(_loading)]]">
+          <template is="dom-repeat" items="[[_documentationSearches]]">
+            <tr class="table">
+              <td class="name">
+                <a href$="[[_computeSearchUrl(item.url)]]">[[item.title]]</a>
+              </td>
+              <td></td>
+              <td></td>
+            </tr>
+          </template>
+        </tbody>
+      </table>
+    </gr-list-view>
+    <gr-rest-api-interface id="restAPI"></gr-rest-api-interface>
+  </template>
+  <script src="gr-documentation-search.js"></script>
+</dom-module>
diff --git a/polygerrit-ui/app/elements/documentation/gr-documentation-search/gr-documentation-search.js b/polygerrit-ui/app/elements/documentation/gr-documentation-search/gr-documentation-search.js
new file mode 100644
index 0000000..f850b9d
--- /dev/null
+++ b/polygerrit-ui/app/elements/documentation/gr-documentation-search/gr-documentation-search.js
@@ -0,0 +1,81 @@
+/**
+ * @license
+ * 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.
+ */
+(function() {
+  'use strict';
+
+  Polymer({
+    is: 'gr-documentation-search',
+
+    properties: {
+      /**
+       * URL params passed from the router.
+       */
+      params: {
+        type: Object,
+        observer: '_paramsChanged',
+      },
+
+      _path: {
+        type: String,
+        readOnly: true,
+        value: '/Documentation',
+      },
+      _documentationSearches: Array,
+
+      _loading: {
+        type: Boolean,
+        value: true,
+      },
+      _filter: {
+        type: String,
+        value: '',
+      },
+    },
+
+    behaviors: [
+      Gerrit.ListViewBehavior,
+    ],
+
+    attached() {
+      this.dispatchEvent(
+          new CustomEvent('title-change', {title: 'Documentation Search'}));
+    },
+
+    _paramsChanged(params) {
+      this._loading = true;
+      this._filter = this.getFilterValue(params);
+
+      return this._getDocumentationSearches(this._filter);
+    },
+
+    _getDocumentationSearches(filter) {
+      this._documentationSearches = [];
+      return this.$.restAPI.getDocumentationSearches(filter)
+          .then(searches => {
+            // Late response.
+            if (filter !== this._filter || !searches) { return; }
+            this._documentationSearches = searches;
+            this._loading = false;
+          });
+    },
+
+    _computeSearchUrl(url) {
+      if (!url) { return ''; }
+      return this.getBaseUrl() + '/' + url;
+    },
+  });
+})();
diff --git a/polygerrit-ui/app/elements/documentation/gr-documentation-search/gr-documentation-search_test.html b/polygerrit-ui/app/elements/documentation/gr-documentation-search/gr-documentation-search_test.html
new file mode 100644
index 0000000..84addb0
--- /dev/null
+++ b/polygerrit-ui/app/elements/documentation/gr-documentation-search/gr-documentation-search_test.html
@@ -0,0 +1,120 @@
+<!DOCTYPE html>
+<!--
+@license
+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.
+-->
+
+<meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
+<title>gr-documentation-search</title>
+
+<script src="../../../bower_components/page/page.js"></script>
+<script src="../../../bower_components/webcomponentsjs/webcomponents-lite.min.js"></script>
+<script src="../../../bower_components/web-component-tester/browser.js"></script>
+<link rel="import" href="../../../test/common-test-setup.html"/>
+<link rel="import" href="gr-documentation-search.html">
+
+<script>void(0);</script>
+
+<test-fixture id="basic">
+  <template>
+    <gr-documentation-search></gr-documentation-search>
+  </template>
+</test-fixture>
+
+<script>
+  let counter;
+  const documentationGenerator = () => {
+    return {
+      title: `Gerrit Code Review - REST API Developers Notes${++counter}`,
+      url: 'Documentation/dev-rest-api.html',
+    };
+  };
+
+  suite('gr-documentation-search tests', () => {
+    let element;
+    let documentationSearches;
+    let sandbox;
+    let value;
+
+    setup(() => {
+      sandbox = sinon.sandbox.create();
+      sandbox.stub(page, 'show');
+      element = fixture('basic');
+      counter = 0;
+    });
+
+    teardown(() => {
+      sandbox.restore();
+    });
+
+    suite('list with searches for documentation', () => {
+      setup(done => {
+        documentationSearches = _.times(26, documentationGenerator);
+        stub('gr-rest-api-interface', {
+          getDocumentationSearches() {
+            return Promise.resolve(documentationSearches);
+          },
+        });
+        element._paramsChanged(value).then(() => { flush(done); });
+      });
+
+      test('test for test repo in the list', done => {
+        flush(() => {
+          assert.equal(element._documentationSearches[0].title,
+              'Gerrit Code Review - REST API Developers Notes1');
+          assert.equal(element._documentationSearches[0].url,
+              'Documentation/dev-rest-api.html');
+          done();
+        });
+      });
+    });
+
+    suite('filter', () => {
+      setup(() => {
+        documentationSearches = _.times(25, documentationGenerator);
+        documentationSearchesFiltered = _.times(1, documentationSearches);
+      });
+
+      test('_paramsChanged', done => {
+        sandbox.stub(element.$.restAPI, 'getDocumentationSearches', () => {
+          return Promise.resolve(documentationSearches);
+        });
+        const value = {
+          filter: 'test',
+        };
+        element._paramsChanged(value).then(() => {
+          assert.isTrue(element.$.restAPI.getDocumentationSearches.lastCall
+              .calledWithExactly('test'));
+          done();
+        });
+      });
+    });
+
+    suite('loading', () => {
+      test('correct contents are displayed', () => {
+        assert.isTrue(element._loading);
+        assert.equal(element.computeLoadingClass(element._loading), 'loading');
+        assert.equal(getComputedStyle(element.$.loading).display, 'block');
+
+        element._loading = false;
+        element._repos = _.times(25, documentationGenerator);
+
+        flushAsynchronousOperations();
+        assert.equal(element.computeLoadingClass(element._loading), '');
+        assert.equal(getComputedStyle(element.$.loading).display, 'none');
+      });
+    });
+  });
+</script>
diff --git a/polygerrit-ui/app/elements/gr-app.html b/polygerrit-ui/app/elements/gr-app.html
index 787a1c6..b34cd0a 100644
--- a/polygerrit-ui/app/elements/gr-app.html
+++ b/polygerrit-ui/app/elements/gr-app.html
@@ -48,6 +48,7 @@
 <link rel="import" href="../styles/shared-styles.html">
 <link rel="import" href="../styles/themes/app-theme.html">
 <link rel="import" href="./admin/gr-admin-view/gr-admin-view.html">
+<link rel="import" href="./documentation/gr-documentation-search/gr-documentation-search.html">
 <link rel="import" href="./change-list/gr-change-list-view/gr-change-list-view.html">
 <link rel="import" href="./change-list/gr-dashboard-view/gr-dashboard-view.html">
 <link rel="import" href="./change/gr-change-view/gr-change-view.html">
@@ -198,6 +199,11 @@
       <template is="dom-if" if="[[_showCLAView]]" restamp="true">
         <gr-cla-view></gr-cla-view>
       </template>
+      <template is="dom-if" if="[[_showDocumentationSearch]]" restamp="true">
+        <gr-documentation-search
+            params="[[params]]">
+        </gr-documentation-search>
+      </template>
       <div id="errorView" class="errorView">
         <div class="errorEmoji">[[_lastError.emoji]]</div>
         <div class="errorText">[[_lastError.text]]</div>
diff --git a/polygerrit-ui/app/elements/gr-app.js b/polygerrit-ui/app/elements/gr-app.js
index b0cc514..321dc58 100644
--- a/polygerrit-ui/app/elements/gr-app.js
+++ b/polygerrit-ui/app/elements/gr-app.js
@@ -72,6 +72,7 @@
       _showCLAView: Boolean,
       _showEditorView: Boolean,
       _showPluginScreen: Boolean,
+      _showDocumentationSearch: Boolean,
       /** @type {?} */
       _viewState: Object,
       /** @type {?} */
@@ -202,6 +203,8 @@
           this.Shortcut.TOGGLE_CHANGE_STAR, 's');
       this.bindShortcut(
           this.Shortcut.REFRESH_CHANGE_LIST, 'shift+r');
+      this.bindShortcut(
+          this.Shortcut.EDIT_TOPIC, 't');
 
       this.bindShortcut(
           this.Shortcut.OPEN_REPLY_DIALOG, 'a');
@@ -272,6 +275,8 @@
       this.bindShortcut(
           this.Shortcut.TOGGLE_FILE_REVIEWED, 'r');
       this.bindShortcut(
+          this.Shortcut.NEXT_UNREVIEWED_FILE, 'shift+m');
+      this.bindShortcut(
           this.Shortcut.TOGGLE_ALL_INLINE_DIFFS, 'shift+i:keyup');
       this.bindShortcut(
           this.Shortcut.TOGGLE_INLINE_DIFF, 'i:keyup');
@@ -315,6 +320,8 @@
       if (isPluginScreen) {
         this.async(() => this.set('_showPluginScreen', true), 1);
       }
+      this.set('_showDocumentationSearch',
+          view === Gerrit.Nav.View.DOCUMENTATION_SEARCH);
       if (this.params.justRegistered) {
         this.$.registrationOverlay.open();
         this.$.registrationDialog.loadData().then(() => {
diff --git a/polygerrit-ui/app/elements/settings/gr-edit-preferences/gr-edit-preferences.html b/polygerrit-ui/app/elements/settings/gr-edit-preferences/gr-edit-preferences.html
index 782100e..b3e6990 100644
--- a/polygerrit-ui/app/elements/settings/gr-edit-preferences/gr-edit-preferences.html
+++ b/polygerrit-ui/app/elements/settings/gr-edit-preferences/gr-edit-preferences.html
@@ -35,6 +35,7 @@
               prevent-invalid-input
               allowed-pattern="[0-9]"
               bind-value="{{editPrefs.tab_size}}"
+              on-keypress="_handleEditPrefsChanged"
               on-change="_handleEditPrefsChanged">
         </span>
       </section>
@@ -47,6 +48,7 @@
               prevent-invalid-input
               allowed-pattern="[0-9]"
               bind-value="{{editPrefs.line_length}}"
+              on-keypress="_handleEditPrefsChanged"
               on-change="_handleEditPrefsChanged">
         </span>
       </section>
@@ -59,6 +61,7 @@
               prevent-invalid-input
               allowed-pattern="[0-9]"
               bind-value="{{editPrefs.indent_unit}}"
+              on-keypress="_handleEditPrefsChanged"
               on-change="_handleEditPrefsChanged">
         </span>
       </section>
diff --git a/polygerrit-ui/app/elements/shared/gr-dropdown/gr-dropdown.js b/polygerrit-ui/app/elements/shared/gr-dropdown/gr-dropdown.js
index dcb428f..3d9d36b 100644
--- a/polygerrit-ui/app/elements/shared/gr-dropdown/gr-dropdown.js
+++ b/polygerrit-ui/app/elements/shared/gr-dropdown/gr-dropdown.js
@@ -229,7 +229,7 @@
       if (typeof link.url === 'undefined') {
         return '';
       }
-      if (link.target) {
+      if (link.target || !link.url.startsWith('/')) {
         return link.url;
       }
       return this._computeRelativeURL(link.url);
diff --git a/polygerrit-ui/app/elements/shared/gr-dropdown/gr-dropdown_test.html b/polygerrit-ui/app/elements/shared/gr-dropdown/gr-dropdown_test.html
index 456f235..7bb4dce 100644
--- a/polygerrit-ui/app/elements/shared/gr-dropdown/gr-dropdown_test.html
+++ b/polygerrit-ui/app/elements/shared/gr-dropdown/gr-dropdown_test.html
@@ -73,6 +73,12 @@
 
     test('link URLs', () => {
       assert.equal(
+          element._computeLinkURL({url: 'http://example.com/test'}),
+          'http://example.com/test');
+      assert.equal(
+          element._computeLinkURL({url: 'https://example.com/test'}),
+          'https://example.com/test');
+      assert.equal(
           element._computeLinkURL({url: '/test'}),
           '//' + window.location.host + '/test');
       assert.equal(
diff --git a/polygerrit-ui/app/elements/shared/gr-editable-label/gr-editable-label.js b/polygerrit-ui/app/elements/shared/gr-editable-label/gr-editable-label.js
index b7d65d3..3514492 100644
--- a/polygerrit-ui/app/elements/shared/gr-editable-label/gr-editable-label.js
+++ b/polygerrit-ui/app/elements/shared/gr-editable-label/gr-editable-label.js
@@ -99,6 +99,12 @@
       });
     },
 
+    open() {
+      return this._open().then(() => {
+        this.$.input.$.input.focus();
+      });
+    },
+
     _open(...args) {
       this.$.dropdown.open();
       this._inputText = this.value;
diff --git a/polygerrit-ui/app/elements/shared/gr-icons/gr-icons.html b/polygerrit-ui/app/elements/shared/gr-icons/gr-icons.html
index 4f80475..4ea8cc7 100644
--- a/polygerrit-ui/app/elements/shared/gr-icons/gr-icons.html
+++ b/polygerrit-ui/app/elements/shared/gr-icons/gr-icons.html
@@ -48,6 +48,10 @@
       <g id="publishEdit"><path d="M5 4v2h14V4H5zm0 10h4v6h6v-6h4l-7-7-7 7z"/></g>
       <!-- This SVG is a copy from iron-icons https://github.com/PolymerElements/iron-icons/blob/master/editor-icons.html -->
       <g id="delete"><path d="M6 19c0 1.1.9 2 2 2h8c1.1 0 2-.9 2-2V7H6v12zM19 4h-3.5l-1-1h-5l-1 1H5v2h14V4z"/></g>
+      <!-- This SVG is a copy from iron-icons https://github.com/PolymerElements/iron-icons/blob/master/iron-icons.js -->
+      <g id="help"><path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm1 17h-2v-2h2v2zm2.07-7.75l-.9.92C13.45 12.9 13 13.5 13 15h-2v-.5c0-1.1.45-2.1 1.17-2.83l1.24-1.26c.37-.36.59-.86.59-1.41 0-1.1-.9-2-2-2s-2 .9-2 2H8c0-2.21 1.79-4 4-4s4 1.79 4 4c0 .88-.36 1.68-.93 2.25z"></path></g>
+      <!-- This SVG is a copy from iron-icons https://github.com/PolymerElements/iron-icons/blob/master/iron-icons.js -->
+      <g id="info"><path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm1 15h-2v-6h2v6zm0-8h-2V7h2v2z"></path></g>
       <!-- This SVG is a copy from material.io https://material.io/icons/#ic_hourglass_full-->
       <g id="hourglass"><path d="M6 2v6h.01L6 8.01 10 12l-4 4 .01.01H6V22h12v-5.99h-.01L18 16l-4-4 4-3.99-.01-.01H18V2H6z"/><path d="M0 0h24v24H0V0z" fill="none"/></g>
       <!-- This is a custom PolyGerrit SVG -->
diff --git a/polygerrit-ui/app/elements/shared/gr-label-info/gr-label-info.js b/polygerrit-ui/app/elements/shared/gr-label-info/gr-label-info.js
index 2fe5f7b1..40edc28 100644
--- a/polygerrit-ui/app/elements/shared/gr-label-info/gr-label-info.js
+++ b/polygerrit-ui/app/elements/shared/gr-label-info/gr-label-info.js
@@ -127,27 +127,8 @@
           this.$.restAPI.deleteVote(this.change._number, accountID, this.label)
           .then(response => {
             target.disabled = false;
-            if (!response.ok) { return response; }
-
-            const label = this.change.labels[this.label];
-            const labels = label.all || [];
-            let wasChanged = false;
-            for (let i = 0; i < labels.length; i++) {
-              if (labels[i]._account_id === accountID) {
-                for (const key in label) {
-                  if (label.hasOwnProperty(key) &&
-                      label[key]._account_id === accountID) {
-                    // Remove special label field, keeping change label values
-                    // in sync with the backend.
-                    this.change.labels[this.label][key] = null;
-                  }
-                }
-                this.change.labels[this.label].all.splice(i, 1);
-                wasChanged = true;
-                break;
-              }
-            }
-            if (wasChanged) { this.notifySplices('change.labels'); }
+            if (!response.ok) { return; }
+            Gerrit.Nav.navigateToChange(this.change);
           }).catch(err => {
             target.disabled = false;
             return;
diff --git a/polygerrit-ui/app/elements/shared/gr-rest-api-interface/gr-rest-api-interface.js b/polygerrit-ui/app/elements/shared/gr-rest-api-interface/gr-rest-api-interface.js
index be8b763..d9b0cbf 100644
--- a/polygerrit-ui/app/elements/shared/gr-rest-api-interface/gr-rest-api-interface.js
+++ b/polygerrit-ui/app/elements/shared/gr-rest-api-interface/gr-rest-api-interface.js
@@ -1321,21 +1321,27 @@
      * @param {function()=} opt_cancelCondition
      */
     getChangeDetail(changeNum, opt_errFn, opt_cancelCondition) {
-      const options = this.listChangesOptionsToHex(
-          this.ListChangesOption.ALL_COMMITS,
-          this.ListChangesOption.ALL_REVISIONS,
-          this.ListChangesOption.CHANGE_ACTIONS,
-          this.ListChangesOption.CURRENT_ACTIONS,
-          this.ListChangesOption.DETAILED_LABELS,
-          this.ListChangesOption.DOWNLOAD_COMMANDS,
-          this.ListChangesOption.MESSAGES,
-          this.ListChangesOption.SUBMITTABLE,
-          this.ListChangesOption.WEB_LINKS,
-          this.ListChangesOption.SKIP_MERGEABLE
-      );
-      return this._getChangeDetail(
-          changeNum, options, opt_errFn, opt_cancelCondition)
-          .then(GrReviewerUpdatesParser.parse);
+      const options = [
+        this.ListChangesOption.ALL_COMMITS,
+        this.ListChangesOption.ALL_REVISIONS,
+        this.ListChangesOption.CHANGE_ACTIONS,
+        this.ListChangesOption.CURRENT_ACTIONS,
+        this.ListChangesOption.DETAILED_LABELS,
+        this.ListChangesOption.DOWNLOAD_COMMANDS,
+        this.ListChangesOption.MESSAGES,
+        this.ListChangesOption.SUBMITTABLE,
+        this.ListChangesOption.WEB_LINKS,
+        this.ListChangesOption.SKIP_MERGEABLE,
+      ];
+      return this.getConfig(false).then(config => {
+        if (config.receive && config.receive.enable_signed_push) {
+          options.push(this.ListChangesOption.PUSH_CERTIFICATES);
+        }
+        const optionsHex = this.listChangesOptionsToHex(...options);
+        return this._getChangeDetail(
+            changeNum, optionsHex, opt_errFn, opt_cancelCondition)
+            .then(GrReviewerUpdatesParser.parse);
+      });
     },
 
     /**
@@ -3035,6 +3041,22 @@
       });
     },
 
+    /**
+     * @param {string} filter
+     * @return {!Promise<?Object>}
+     */
+    getDocumentationSearches(filter) {
+      filter = filter.trim();
+      const encodedFilter = encodeURIComponent(filter);
+
+      // TODO(kaspern): Rename rest api from /projects/ to /repos/ once backend
+      // supports it.
+      return this._fetchSharedCacheURL({
+        url: `/Documentation/?q=${encodedFilter}`,
+        anonymizedUrl: '/Documentation/?*',
+      });
+    },
+
     getMergeable(changeNum) {
       return this._getChangeURLAndFetch({
         changeNum,
diff --git a/polygerrit-ui/app/elements/shared/gr-rest-api-interface/gr-rest-api-interface_test.html b/polygerrit-ui/app/elements/shared/gr-rest-api-interface/gr-rest-api-interface_test.html
index b2bf42b..667f24c 100644
--- a/polygerrit-ui/app/elements/shared/gr-rest-api-interface/gr-rest-api-interface_test.html
+++ b/polygerrit-ui/app/elements/shared/gr-rest-api-interface/gr-rest-api-interface_test.html
@@ -734,15 +734,6 @@
       assert.deepEqual(element._send.lastCall.args[0].body, {token: 'foo'});
     });
 
-    test('GrReviewerUpdatesParser.parse is used', () => {
-      sandbox.stub(GrReviewerUpdatesParser, 'parse').returns(
-          Promise.resolve('foo'));
-      return element.getChangeDetail(42).then(result => {
-        assert.isTrue(GrReviewerUpdatesParser.parse.calledOnce);
-        assert.equal(result, 'foo');
-      });
-    });
-
     test('setAccountStatus', () => {
       sandbox.stub(element, '_send').returns(Promise.resolve('OOO'));
       element._cache.set('/accounts/self/detail', {});
@@ -1114,7 +1105,49 @@
       });
     });
 
-    suite('_getChangeDetail', () => {
+    suite('getChangeDetail', () => {
+      suite('change detail options', () => {
+        let toHexStub;
+
+        setup(() => {
+          toHexStub = sandbox.stub(element, 'listChangesOptionsToHex',
+              options => 'deadbeef');
+          sandbox.stub(element, '_getChangeDetail',
+              async (changeNum, options) => ({changeNum, options}));
+        });
+
+        test('signed pushes disabled', async () => {
+          const {PUSH_CERTIFICATES} = element.ListChangesOption;
+          sandbox.stub(element, 'getConfig', async () => ({}));
+          const {changeNum, options} = await element.getChangeDetail(123);
+          assert.strictEqual(123, changeNum);
+          assert.strictEqual('deadbeef', options);
+          assert.isTrue(toHexStub.calledOnce);
+          assert.isFalse(toHexStub.lastCall.args.includes(PUSH_CERTIFICATES));
+        });
+
+        test('signed pushes enabled', async () => {
+          const {PUSH_CERTIFICATES} = element.ListChangesOption;
+          sandbox.stub(element, 'getConfig', async () => {
+            return {receive: {enable_signed_push: true}};
+          });
+          const {changeNum, options} = await element.getChangeDetail(123);
+          assert.strictEqual(123, changeNum);
+          assert.strictEqual('deadbeef', options);
+          assert.isTrue(toHexStub.calledOnce);
+          assert.isTrue(toHexStub.lastCall.args.includes(PUSH_CERTIFICATES));
+        });
+      });
+
+      test('GrReviewerUpdatesParser.parse is used', () => {
+        sandbox.stub(GrReviewerUpdatesParser, 'parse').returns(
+            Promise.resolve('foo'));
+        return element.getChangeDetail(42).then(result => {
+          assert.isTrue(GrReviewerUpdatesParser.parse.calledOnce);
+          assert.equal(result, 'foo');
+        });
+      });
+
       test('_getChangeDetail passes params to ETags decorator', () => {
         const changeNum = 4321;
         element._projectLookup[changeNum] = 'test';
diff --git a/polygerrit-ui/app/test/index.html b/polygerrit-ui/app/test/index.html
index 5b9ae15..20a4a1e 100644
--- a/polygerrit-ui/app/test/index.html
+++ b/polygerrit-ui/app/test/index.html
@@ -103,7 +103,6 @@
     'core/gr-smart-search/gr-smart-search_test.html',
     'diff/gr-comment-api/gr-comment-api_test.html',
     'diff/gr-diff-builder/gr-diff-builder_test.html',
-    'diff/gr-diff-comment-thread-group/gr-diff-comment-thread-group_test.html',
     'diff/gr-diff-comment-thread/gr-diff-comment-thread_test.html',
     'diff/gr-diff-comment/gr-diff-comment_test.html',
     'diff/gr-diff-cursor/gr-diff-cursor_test.html',
@@ -120,6 +119,7 @@
     'diff/gr-ranged-comment-layer/gr-ranged-comment-layer_test.html',
     'diff/gr-selection-action-box/gr-selection-action-box_test.html',
     'diff/gr-syntax-layer/gr-syntax-layer_test.html',
+    'documentation/gr-documentation-search/gr-documentation-search_test.html',
     'edit/gr-default-editor/gr-default-editor_test.html',
     'edit/gr-edit-controls/gr-edit-controls_test.html',
     'edit/gr-edit-file-controls/gr-edit-file-controls_test.html',
diff --git a/tools/bzl/junit.bzl b/tools/bzl/junit.bzl
index 08d5045..ccde467 100644
--- a/tools/bzl/junit.bzl
+++ b/tools/bzl/junit.bzl
@@ -68,7 +68,6 @@
     # Enforce JDK 8 compatibility on Java 9, see
     # https://docs.oracle.com/javase/9/intl/internationalization-enhancements-jdk-9.htm#JSINT-GUID-AF5AECA7-07C1-4E7D-BC10-BC7E73DC6C7F
     "-Djava.locale.providers=COMPAT,CLDR,SPI",
-    "--add-modules java.activation",
     "--add-opens=jdk.management/com.sun.management.internal=ALL-UNNAMED",
 ]
 
@@ -82,7 +81,7 @@
     jvm_flags = kwargs.get("jvm_flags", [])
     jvm_flags = jvm_flags + select({
         "//:java9": POST_JDK8_OPTS,
-        "//:java10": POST_JDK8_OPTS,
+        "//:java_next": POST_JDK8_OPTS,
         "//conditions:default": [],
     })
     native.java_test(