Merge "Disable hover style for buttons on mobile devices"
diff --git a/Documentation/dev-e2e-tests.txt b/Documentation/dev-e2e-tests.txt
index 86221eb..628a0ec 100644
--- a/Documentation/dev-e2e-tests.txt
+++ b/Documentation/dev-e2e-tests.txt
@@ -19,7 +19,7 @@
 
 Gatling is written in Scala, but the abstraction provided by the Gatling DSL makes the scenarios
 implementation easy even without any Scala knowledge. The
-link:https://gitenterprise.me/2019/12/20/stress-your-gerrit-with-gatling/[Stress your Gerrit with Gatling]
+link:https://gitenterprise.me/2019/12/20/stress-your-gerrit-with-gatling/[Stress your Gerrit with Gatling,role=external,window=_blank]
 blog post has more introductory information.
 
 Examples of scenarios can be found in the `e2e-tests` directory. The files in that directory
diff --git a/e2e-tests/load-tests/src/test/scala/com/google/gerrit/scenarios/CloneUsingBothProtocols.scala b/e2e-tests/load-tests/src/test/scala/com/google/gerrit/scenarios/CloneUsingBothProtocols.scala
index 9e2aca0..c5a7cba 100644
--- a/e2e-tests/load-tests/src/test/scala/com/google/gerrit/scenarios/CloneUsingBothProtocols.scala
+++ b/e2e-tests/load-tests/src/test/scala/com/google/gerrit/scenarios/CloneUsingBothProtocols.scala
@@ -14,29 +14,12 @@
 
 package com.google.gerrit.scenarios
 
-import java.io._
-
-import com.github.barbasa.gatling.git.protocol.GitProtocol
-import com.github.barbasa.gatling.git.request.builder.GitRequestBuilder
-import com.github.barbasa.gatling.git.{GatlingGitConfiguration, GitRequestSession}
 import io.gatling.core.Predef._
-import io.gatling.core.feeder.FileBasedFeederBuilder
 import io.gatling.core.structure.ScenarioBuilder
-import org.apache.commons.io.FileUtils
-import org.eclipse.jgit.hooks._
 
 import scala.concurrent.duration._
 
-class CloneUsingBothProtocols extends Simulation {
-
-  implicit val conf: GatlingGitConfiguration = GatlingGitConfiguration()
-  implicit val postMessageHook: Option[String] = Some(s"hooks/${CommitMsgHook.NAME}")
-
-  private val name: String = this.getClass.getSimpleName
-  private val file = s"data/$name.json"
-  private val data: FileBasedFeederBuilder[Any]#F = jsonFile(file).circular
-  private val request = new GitRequestBuilder(GitRequestSession("${cmd}", "${url}"))
-  private val protocol: GitProtocol = GitProtocol()
+class CloneUsingBothProtocols extends GitSimulation {
 
   private val test: ScenarioBuilder = scenario(name)
       .feed(data)
@@ -46,16 +29,4 @@
     test.inject(
       constantUsersPerSec(1) during (2 seconds)
     )).protocols(protocol)
-
-  after {
-    Thread.sleep(5000)
-    val path = conf.tmpBasePath
-    try {
-      FileUtils.deleteDirectory(new File(path))
-    } catch {
-      case e: IOException =>
-        System.err.println("Unable to delete temporary directory " + path)
-        e.printStackTrace()
-    }
-  }
 }
diff --git a/e2e-tests/load-tests/src/test/scala/com/google/gerrit/scenarios/GitSimulation.scala b/e2e-tests/load-tests/src/test/scala/com/google/gerrit/scenarios/GitSimulation.scala
new file mode 100644
index 0000000..4d5130f
--- /dev/null
+++ b/e2e-tests/load-tests/src/test/scala/com/google/gerrit/scenarios/GitSimulation.scala
@@ -0,0 +1,48 @@
+// Copyright (C) 2020 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.scenarios
+
+import java.io.{File, IOException}
+
+import com.github.barbasa.gatling.git.protocol.GitProtocol
+import com.github.barbasa.gatling.git.request.builder.GitRequestBuilder
+import com.github.barbasa.gatling.git.{GatlingGitConfiguration, GitRequestSession}
+import io.gatling.core.Predef._
+import io.gatling.core.feeder.FileBasedFeederBuilder
+import org.apache.commons.io.FileUtils
+import org.eclipse.jgit.hooks.CommitMsgHook
+
+class GitSimulation extends Simulation {
+
+  implicit val conf: GatlingGitConfiguration = GatlingGitConfiguration()
+  implicit val postMessageHook: Option[String] = Some(s"hooks/${CommitMsgHook.NAME}")
+
+  protected val name: String = this.getClass.getSimpleName
+  protected val data: FileBasedFeederBuilder[Any]#F = jsonFile(s"data/$name.json").circular
+  protected val request = new GitRequestBuilder(GitRequestSession("${cmd}", "${url}"))
+  protected val protocol: GitProtocol = GitProtocol()
+
+  after {
+    Thread.sleep(5000)
+    val path = conf.tmpBasePath
+    try {
+      FileUtils.deleteDirectory(new File(path))
+    } catch {
+      case e: IOException =>
+        System.err.println("Unable to delete temporary directory " + path)
+        e.printStackTrace()
+    }
+  }
+}
diff --git a/e2e-tests/load-tests/src/test/scala/com/google/gerrit/scenarios/ReplayRecordsFromFeeder.scala b/e2e-tests/load-tests/src/test/scala/com/google/gerrit/scenarios/ReplayRecordsFromFeeder.scala
index 5a3bb99..82342be 100644
--- a/e2e-tests/load-tests/src/test/scala/com/google/gerrit/scenarios/ReplayRecordsFromFeeder.scala
+++ b/e2e-tests/load-tests/src/test/scala/com/google/gerrit/scenarios/ReplayRecordsFromFeeder.scala
@@ -14,29 +14,12 @@
 
 package com.google.gerrit.scenarios
 
-import java.io._
-
-import com.github.barbasa.gatling.git.protocol.GitProtocol
-import com.github.barbasa.gatling.git.request.builder.GitRequestBuilder
-import com.github.barbasa.gatling.git.{GatlingGitConfiguration, GitRequestSession}
 import io.gatling.core.Predef._
-import io.gatling.core.feeder.FileBasedFeederBuilder
 import io.gatling.core.structure.ScenarioBuilder
-import org.apache.commons.io.FileUtils
-import org.eclipse.jgit.hooks._
 
 import scala.concurrent.duration._
 
-class ReplayRecordsFromFeeder extends Simulation {
-
-  implicit val conf: GatlingGitConfiguration = GatlingGitConfiguration()
-  implicit val postMessageHook: Option[String] = Some(s"hooks/${CommitMsgHook.NAME}")
-
-  private val name: String = this.getClass.getSimpleName
-  private val file = s"data/$name.json"
-  private val data: FileBasedFeederBuilder[Any]#F = jsonFile(file).circular
-  private val request = new GitRequestBuilder(GitRequestSession("${cmd}", "${url}"))
-  private val protocol: GitProtocol = GitProtocol()
+class ReplayRecordsFromFeeder extends GitSimulation {
 
   private val test: ScenarioBuilder = scenario(name)
       .repeat(10000) {
@@ -53,16 +36,4 @@
       constantUsersPerSec(20) during (15 seconds) randomized
     )).protocols(protocol)
       .maxDuration(60 seconds)
-
-  after {
-    Thread.sleep(5000)
-    val path = conf.tmpBasePath
-    try {
-      FileUtils.deleteDirectory(new File(path))
-    } catch {
-      case e: IOException =>
-        System.err.println("Unable to delete temporary directory " + path)
-        e.printStackTrace()
-    }
-  }
 }
diff --git a/java/com/google/gerrit/extensions/client/ListOption.java b/java/com/google/gerrit/extensions/client/ListOption.java
index e694c0e..4dea42f 100644
--- a/java/com/google/gerrit/extensions/client/ListOption.java
+++ b/java/com/google/gerrit/extensions/client/ListOption.java
@@ -16,6 +16,7 @@
 
 import java.lang.reflect.InvocationTargetException;
 import java.util.EnumSet;
+import java.util.Set;
 
 /** Enum that can be expressed as a bitset in query parameters. */
 public interface ListOption {
@@ -46,4 +47,13 @@
     }
     return r;
   }
+
+  static String toHex(Set<ListChangesOption> options) {
+    int v = 0;
+    for (ListChangesOption option : options) {
+      v |= 1 << option.getValue();
+    }
+
+    return Integer.toHexString(v);
+  }
 }
diff --git a/java/com/google/gerrit/httpd/raw/IndexHtmlUtil.java b/java/com/google/gerrit/httpd/raw/IndexHtmlUtil.java
index b1d4ac6..6a66ba3 100644
--- a/java/com/google/gerrit/httpd/raw/IndexHtmlUtil.java
+++ b/java/com/google/gerrit/httpd/raw/IndexHtmlUtil.java
@@ -18,15 +18,20 @@
 
 import com.google.common.base.Strings;
 import com.google.common.collect.ImmutableMap;
+import com.google.common.collect.ImmutableSet;
 import com.google.common.flogger.FluentLogger;
+import com.google.common.primitives.Ints;
 import com.google.gerrit.common.Nullable;
 import com.google.gerrit.common.UsedAt;
 import com.google.gerrit.common.UsedAt.Project;
 import com.google.gerrit.extensions.api.GerritApi;
 import com.google.gerrit.extensions.api.accounts.AccountApi;
 import com.google.gerrit.extensions.api.config.Server;
+import com.google.gerrit.extensions.client.ListChangesOption;
+import com.google.gerrit.extensions.client.ListOption;
 import com.google.gerrit.extensions.restapi.AuthException;
 import com.google.gerrit.extensions.restapi.RestApiException;
+import com.google.gerrit.extensions.restapi.Url;
 import com.google.gerrit.json.OutputFormat;
 import com.google.gson.Gson;
 import com.google.template.soy.data.SanitizedContent;
@@ -34,11 +39,46 @@
 import java.net.URISyntaxException;
 import java.util.HashMap;
 import java.util.Map;
+import java.util.Set;
 import java.util.function.Function;
+import java.util.regex.Matcher;
+import java.util.regex.Pattern;
 
 /** Helper for generating parts of {@code index.html}. */
 public class IndexHtmlUtil {
   private static final FluentLogger logger = FluentLogger.forEnclosingClass();
+  static final String changeCanonicalUrl = ".*/c/(?<project>.+)/\\+/(?<changeNum>\\d+)";
+  static final String basePatchNumUrlPart = "(/(-?\\d+|edit)(\\.\\.(\\d+|edit))?)";
+  static final Pattern changeUrlPattern =
+      Pattern.compile(changeCanonicalUrl + basePatchNumUrlPart + "?" + "/?$");
+  static final Pattern diffUrlPattern =
+      Pattern.compile(changeCanonicalUrl + basePatchNumUrlPart + "(/(.+))" + "/?$");
+
+  public static String getDefaultChangeDetailHex() {
+    Set<ListChangesOption> options =
+        ImmutableSet.of(
+            ListChangesOption.ALL_COMMITS,
+            ListChangesOption.ALL_REVISIONS,
+            ListChangesOption.CHANGE_ACTIONS,
+            ListChangesOption.DETAILED_LABELS,
+            ListChangesOption.DOWNLOAD_COMMANDS,
+            ListChangesOption.MESSAGES,
+            ListChangesOption.SUBMITTABLE,
+            ListChangesOption.WEB_LINKS,
+            ListChangesOption.SKIP_DIFFSTAT);
+
+    return ListOption.toHex(options);
+  }
+
+  public static String getDefaultDiffDetailHex() {
+    Set<ListChangesOption> options =
+        ImmutableSet.of(
+            ListChangesOption.ALL_COMMITS,
+            ListChangesOption.ALL_REVISIONS,
+            ListChangesOption.SKIP_DIFFSTAT);
+
+    return ListOption.toHex(options);
+  }
 
   /**
    * Returns both static and dynamic parameters of {@code index.html}. The result is to be used when
@@ -50,12 +90,18 @@
       String cdnPath,
       String faviconPath,
       Map<String, String[]> urlParameterMap,
-      Function<String, SanitizedContent> urlInScriptTagOrdainer)
+      Function<String, SanitizedContent> urlInScriptTagOrdainer,
+      String requestedURL)
       throws URISyntaxException, RestApiException {
     return ImmutableMap.<String, Object>builder()
         .putAll(
             staticTemplateData(
-                canonicalURL, cdnPath, faviconPath, urlParameterMap, urlInScriptTagOrdainer))
+                canonicalURL,
+                cdnPath,
+                faviconPath,
+                urlParameterMap,
+                urlInScriptTagOrdainer,
+                requestedURL))
         .putAll(dynamicTemplateData(gerritApi))
         .build();
   }
@@ -98,7 +144,8 @@
       String cdnPath,
       String faviconPath,
       Map<String, String[]> urlParameterMap,
-      Function<String, SanitizedContent> urlInScriptTagOrdainer)
+      Function<String, SanitizedContent> urlInScriptTagOrdainer,
+      String requestedURL)
       throws URISyntaxException {
     String canonicalPath = computeCanonicalPath(canonicalURL);
 
@@ -133,9 +180,39 @@
     if (urlParameterMap.containsKey("gf")) {
       data.put("useGoogleFonts", "true");
     }
+
+    if (urlParameterMap.containsKey("pl") && requestedURL != null) {
+      data.put("defaultChangeDetailHex", getDefaultChangeDetailHex());
+      data.put("defaultDiffDetailHex", getDefaultDiffDetailHex());
+
+      String changeRequestsPath = computeChangeRequestsPath(requestedURL, changeUrlPattern);
+      if (changeRequestsPath != null) {
+        data.put("preloadChangePage", "true");
+      } else {
+        changeRequestsPath = computeChangeRequestsPath(requestedURL, diffUrlPattern);
+        data.put("preloadDiffPage", "true");
+      }
+
+      if (changeRequestsPath != null) {
+        data.put("changeRequestsPath", changeRequestsPath);
+      }
+    }
+
     return data.build();
   }
 
+  static String computeChangeRequestsPath(String requestedURL, Pattern pattern) {
+    Matcher matcher = pattern.matcher(requestedURL);
+    if (matcher.matches()) {
+      Integer changeId = Ints.tryParse(matcher.group("changeNum"));
+      if (changeId != null) {
+        return "changes/" + Url.encode(matcher.group("project")) + "~" + changeId;
+      }
+    }
+
+    return null;
+  }
+
   private static String computeCanonicalPath(@Nullable String canonicalURL)
       throws URISyntaxException {
     if (Strings.isNullOrEmpty(canonicalURL)) {
diff --git a/java/com/google/gerrit/httpd/raw/IndexServlet.java b/java/com/google/gerrit/httpd/raw/IndexServlet.java
index a0b41b21..97d2270 100644
--- a/java/com/google/gerrit/httpd/raw/IndexServlet.java
+++ b/java/com/google/gerrit/httpd/raw/IndexServlet.java
@@ -70,10 +70,11 @@
     SoySauce.Renderer renderer;
     try {
       Map<String, String[]> parameterMap = req.getParameterMap();
+      String requestUrl = req.getRequestURL() == null ? null : req.getRequestURL().toString();
       // TODO(hiesel): Remove URL ordainer as parameter once Soy is consistent
       ImmutableMap<String, Object> templateData =
           IndexHtmlUtil.templateData(
-              gerritApi, canonicalUrl, cdnPath, faviconPath, parameterMap, urlOrdainer);
+              gerritApi, canonicalUrl, cdnPath, faviconPath, parameterMap, urlOrdainer, requestUrl);
       renderer = soySauce.renderTemplate("com.google.gerrit.httpd.raw.Index").setData(templateData);
     } catch (URISyntaxException | RestApiException e) {
       throw new IOException(e);
diff --git a/javatests/com/google/gerrit/acceptance/api/change/ChangeIT.java b/javatests/com/google/gerrit/acceptance/api/change/ChangeIT.java
index ae5066b..0eefe02 100644
--- a/javatests/com/google/gerrit/acceptance/api/change/ChangeIT.java
+++ b/javatests/com/google/gerrit/acceptance/api/change/ChangeIT.java
@@ -4637,7 +4637,6 @@
             ListChangesOption.ALL_COMMITS,
             ListChangesOption.ALL_REVISIONS,
             ListChangesOption.CHANGE_ACTIONS,
-            ListChangesOption.CURRENT_ACTIONS,
             ListChangesOption.DETAILED_LABELS,
             ListChangesOption.DOWNLOAD_COMMANDS,
             ListChangesOption.MESSAGES,
diff --git a/javatests/com/google/gerrit/httpd/raw/IndexHtmlUtilTest.java b/javatests/com/google/gerrit/httpd/raw/IndexHtmlUtilTest.java
index d9438f0..cad4796 100644
--- a/javatests/com/google/gerrit/httpd/raw/IndexHtmlUtilTest.java
+++ b/javatests/com/google/gerrit/httpd/raw/IndexHtmlUtilTest.java
@@ -15,6 +15,9 @@
 package com.google.gerrit.httpd.raw;
 
 import static com.google.common.truth.Truth.assertThat;
+import static com.google.gerrit.httpd.raw.IndexHtmlUtil.changeUrlPattern;
+import static com.google.gerrit.httpd.raw.IndexHtmlUtil.computeChangeRequestsPath;
+import static com.google.gerrit.httpd.raw.IndexHtmlUtil.diffUrlPattern;
 import static com.google.gerrit.httpd.raw.IndexHtmlUtil.staticTemplateData;
 
 import com.google.template.soy.data.SanitizedContent;
@@ -29,7 +32,12 @@
   public void noPathAndNoCDN() throws Exception {
     assertThat(
             staticTemplateData(
-                "http://example.com/", null, null, new HashMap<>(), IndexHtmlUtilTest::ordain))
+                "http://example.com/",
+                null,
+                null,
+                new HashMap<>(),
+                IndexHtmlUtilTest::ordain,
+                null))
         .containsExactly("canonicalPath", "", "staticResourcePath", ordain(""));
   }
 
@@ -41,7 +49,8 @@
                 null,
                 null,
                 new HashMap<>(),
-                IndexHtmlUtilTest::ordain))
+                IndexHtmlUtilTest::ordain,
+                null))
         .containsExactly("canonicalPath", "/gerrit", "staticResourcePath", ordain("/gerrit"));
   }
 
@@ -53,7 +62,8 @@
                 "http://my-cdn.com/foo/bar/",
                 null,
                 new HashMap<>(),
-                IndexHtmlUtilTest::ordain))
+                IndexHtmlUtilTest::ordain,
+                null))
         .containsExactly(
             "canonicalPath", "", "staticResourcePath", ordain("http://my-cdn.com/foo/bar/"));
   }
@@ -66,7 +76,8 @@
                 "http://my-cdn.com/foo/bar/",
                 null,
                 new HashMap<>(),
-                IndexHtmlUtilTest::ordain))
+                IndexHtmlUtilTest::ordain,
+                null))
         .containsExactly(
             "canonicalPath", "/gerrit", "staticResourcePath", ordain("http://my-cdn.com/foo/bar/"));
   }
@@ -77,11 +88,51 @@
     urlParms.put("gf", new String[0]);
     assertThat(
             staticTemplateData(
-                "http://example.com/", null, null, urlParms, IndexHtmlUtilTest::ordain))
+                "http://example.com/", null, null, urlParms, IndexHtmlUtilTest::ordain, null))
         .containsExactly(
             "canonicalPath", "", "staticResourcePath", ordain(""), "useGoogleFonts", "true");
   }
 
+  @Test
+  public void usePreloadRest() throws Exception {
+    Map<String, String[]> urlParms = new HashMap<>();
+    urlParms.put("pl", new String[0]);
+    assertThat(
+            staticTemplateData(
+                "http://example.com/",
+                null,
+                null,
+                urlParms,
+                IndexHtmlUtilTest::ordain,
+                "/c/project/+/123"))
+        .containsExactly(
+            "canonicalPath", "",
+            "staticResourcePath", ordain(""),
+            "defaultChangeDetailHex", "916314",
+            "defaultDiffDetailHex", "800014",
+            "preloadChangePage", "true",
+            "changeRequestsPath", "changes/project~123");
+  }
+
+  @Test
+  public void computeChangePath() throws Exception {
+    assertThat(computeChangeRequestsPath("/c/project/+/123", changeUrlPattern))
+        .isEqualTo("changes/project~123");
+
+    assertThat(computeChangeRequestsPath("/c/project/+/124/2", changeUrlPattern))
+        .isEqualTo("changes/project~124");
+
+    assertThat(computeChangeRequestsPath("/c/project/src/+/23", changeUrlPattern))
+        .isEqualTo("changes/project%2Fsrc~23");
+
+    assertThat(computeChangeRequestsPath("/q/project/src/+/23", changeUrlPattern)).isEqualTo(null);
+
+    assertThat(computeChangeRequestsPath("/c/Scripts/+/232/1//COMMIT_MSG", changeUrlPattern))
+        .isEqualTo(null);
+    assertThat(computeChangeRequestsPath("/c/Scripts/+/232/1//COMMIT_MSG", diffUrlPattern))
+        .isEqualTo("changes/Scripts~232");
+  }
+
   private static SanitizedContent ordain(String s) {
     return UnsafeSanitizedContentOrdainer.ordainAsSafe(
         s, SanitizedContent.ContentKind.TRUSTED_RESOURCE_URI);
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 8a1ed87..495f8ab 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
@@ -148,6 +148,7 @@
 
     NEXT_LINE: 'NEXT_LINE',
     PREV_LINE: 'PREV_LINE',
+    VISIBLE_LINE: 'VISIBLE_LINE',
     NEXT_CHUNK: 'NEXT_CHUNK',
     PREV_CHUNK: 'PREV_CHUNK',
     EXPAND_ALL_DIFF_CONTEXT: 'EXPAND_ALL_DIFF_CONTEXT',
@@ -237,6 +238,8 @@
 
   _describe(Shortcut.NEXT_LINE, ShortcutSection.DIFFS, 'Go to next line');
   _describe(Shortcut.PREV_LINE, ShortcutSection.DIFFS, 'Go to previous line');
+  _describe(Shortcut.VISIBLE_LINE, ShortcutSection.DIFFS,
+      'Move cursor to currently visible code');
   _describe(Shortcut.NEXT_CHUNK, ShortcutSection.DIFFS,
       'Go to next diff chunk');
   _describe(Shortcut.PREV_CHUNK, ShortcutSection.DIFFS,
diff --git a/polygerrit-ui/app/elements/change/gr-reply-dialog/gr-reply-dialog.html b/polygerrit-ui/app/elements/change/gr-reply-dialog/gr-reply-dialog.html
index 05a6209f..7674099 100644
--- a/polygerrit-ui/app/elements/change/gr-reply-dialog/gr-reply-dialog.html
+++ b/polygerrit-ui/app/elements/change/gr-reply-dialog/gr-reply-dialog.html
@@ -299,11 +299,10 @@
                 class="action save"
                 has-tooltip
                 title="[[_saveTooltip]]"
-                on-click="_saveClickHandler">Send</gr-button>
+                on-click="_saveClickHandler">Save</gr-button>
           </template>
           <gr-button
               id="sendButton"
-              link
               primary
               disabled="[[_sendDisabled]]"
               class="action send"
diff --git a/polygerrit-ui/app/elements/change/gr-reply-dialog/gr-reply-dialog.js b/polygerrit-ui/app/elements/change/gr-reply-dialog/gr-reply-dialog.js
index 90f8540..fac631b 100644
--- a/polygerrit-ui/app/elements/change/gr-reply-dialog/gr-reply-dialog.js
+++ b/polygerrit-ui/app/elements/change/gr-reply-dialog/gr-reply-dialog.js
@@ -43,7 +43,7 @@
   };
 
   const ButtonTooltips = {
-    SAVE: 'Send but do not send notification or change review state',
+    SAVE: 'Save but do not send notification or change review state',
     START_REVIEW: 'Mark as ready for review and send reply',
     SEND: 'Send reply',
   };
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 05bff9f..7a20dbb 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
@@ -248,14 +248,12 @@
     if (showPartialLinks) {
       td.appendChild(this._createContextButton(
           GrDiffBuilder.ContextButtonType.ABOVE, section, line, numLines));
-      td.appendChild(document.createTextNode(' - '));
     }
 
     td.appendChild(this._createContextButton(
         GrDiffBuilder.ContextButtonType.ALL, section, line, numLines));
 
     if (showPartialLinks) {
-      td.appendChild(document.createTextNode(' - '));
       td.appendChild(this._createContextButton(
           GrDiffBuilder.ContextButtonType.BELOW, section, line, numLines));
     }
@@ -273,22 +271,26 @@
 
     let text;
     let groups = []; // The groups that replace this one if tapped.
-
     if (type === GrDiffBuilder.ContextButtonType.ALL) {
+      const icon = this._createElement('iron-icon', 'showContext');
+      icon.setAttribute('icon', 'gr-icons:unfold-more');
+      Polymer.dom(button).appendChild(icon);
+
       text = 'Show ' + numLines + ' common line';
       if (numLines > 1) { text += 's'; }
       groups.push(...line.contextGroups);
     } else if (type === GrDiffBuilder.ContextButtonType.ABOVE) {
-      text = '+' + context + '↑';
+      text = '+' + context + ' above';
       groups = GrDiffGroup.hideInContextControl(line.contextGroups,
           context, numLines);
     } else if (type === GrDiffBuilder.ContextButtonType.BELOW) {
-      text = '+' + context + '↓';
+      text = '+' + context + ' below';
       groups = GrDiffGroup.hideInContextControl(line.contextGroups,
           0, numLines - context);
     }
-
-    Polymer.dom(button).textContent = text;
+    const textSpan = this._createElement('span', 'showContext');
+    Polymer.dom(textSpan).textContent = text;
+    Polymer.dom(button).appendChild(textSpan);
 
     button.addEventListener('tap', e => {
       e.detail = {
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 589d7c2..320909c 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
@@ -125,9 +125,9 @@
       buttons = td.querySelectorAll('gr-button.showContext');
 
       assert.equal(buttons.length, 3);
-      assert.equal(Polymer.dom(buttons[0]).textContent, '+10↑');
+      assert.equal(Polymer.dom(buttons[0]).textContent, '+10 above');
       assert.equal(Polymer.dom(buttons[1]).textContent, 'Show 11 common lines');
-      assert.equal(Polymer.dom(buttons[2]).textContent, '+10↓');
+      assert.equal(Polymer.dom(buttons[2]).textContent, '+10 below');
     });
 
     test('newlines 1', () => {
diff --git a/polygerrit-ui/app/elements/diff/gr-diff-cursor/gr-diff-cursor.js b/polygerrit-ui/app/elements/diff/gr-diff-cursor/gr-diff-cursor.js
index e8d629c..87152d8 100644
--- a/polygerrit-ui/app/elements/diff/gr-diff-cursor/gr-diff-cursor.js
+++ b/polygerrit-ui/app/elements/diff/gr-diff-cursor/gr-diff-cursor.js
@@ -180,6 +180,15 @@
       }
     }
 
+    moveToVisibleArea() {
+      if (this._getViewMode() === DiffViewMode.SIDE_BY_SIDE) {
+        this.$.cursorManager.moveToVisibleArea(
+            this._rowHasSide.bind(this));
+      } else {
+        this.$.cursorManager.moveToVisibleArea();
+      }
+    }
+
     moveToNextChunk(opt_clipToTop) {
       this.$.cursorManager.next(this._isFirstRowOfChunk.bind(this),
           target => target.parentNode.scrollHeight, opt_clipToTop);
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 2fbf59b..cb89c0c 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
@@ -229,6 +229,7 @@
         [this.Shortcut.RIGHT_PANE]: '_handleRightPane',
         [this.Shortcut.NEXT_LINE]: '_handleNextLineOrFileWithComments',
         [this.Shortcut.PREV_LINE]: '_handlePrevLineOrFileWithComments',
+        [this.Shortcut.VISIBLE_LINE]: '_handleVisibleLine',
         [this.Shortcut.NEXT_FILE_WITH_COMMENTS]:
             '_handleNextLineOrFileWithComments',
         [this.Shortcut.PREV_FILE_WITH_COMMENTS]:
@@ -388,6 +389,13 @@
       this.$.cursor.moveUp();
     }
 
+    _handleVisibleLine(e) {
+      if (this.shouldSuppressKeyboardShortcut(e)) { return; }
+
+      e.preventDefault();
+      this.$.cursor.moveToVisibleArea();
+    }
+
     _onOpenFixPreview(e) {
       this.$.applyFixDialog.open(e);
     }
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 172a151..4301ac2 100644
--- a/polygerrit-ui/app/elements/diff/gr-diff/gr-diff.html
+++ b/polygerrit-ui/app/elements/diff/gr-diff/gr-diff.html
@@ -192,11 +192,18 @@
       .contextControl gr-button {
         display: inline-block;
         text-decoration: none;
+        vertical-align: top;
+        line-height: var(--line-height-mono, 18px);
         --gr-button: {
           color: var(--diff-context-control-color);
-          padding: var(--spacing-xs);
+          padding: var(--spacing-xxs) var(--spacing-l);
         }
       }
+      .contextControl gr-button iron-icon {
+        /* should match line-height of gr-button */
+        width: var(--line-height-mono, 18px);
+        height: var(--line-height-mono, 18px);
+      }
       .contextControl td:not(.lineNum) {
         text-align: center;
       }
diff --git a/polygerrit-ui/app/elements/gr-app-element.js b/polygerrit-ui/app/elements/gr-app-element.js
index d39ba58..c1e317a 100644
--- a/polygerrit-ui/app/elements/gr-app-element.js
+++ b/polygerrit-ui/app/elements/gr-app-element.js
@@ -240,6 +240,10 @@
           this.Shortcut.NEXT_LINE, 'j', 'down');
       this.bindShortcut(
           this.Shortcut.PREV_LINE, 'k', 'up');
+      if (this._isCursorManagerSupportMoveToVisibleLine()) {
+        this.bindShortcut(
+            this.Shortcut.VISIBLE_LINE, '.');
+      }
       this.bindShortcut(
           this.Shortcut.NEXT_CHUNK, 'n');
       this.bindShortcut(
@@ -303,6 +307,14 @@
           this.Shortcut.SEARCH, '/');
     }
 
+    _isCursorManagerSupportMoveToVisibleLine() {
+      // This method is a copy-paste from the
+      // method _isIntersectionObserverSupported of gr-cursor-manager.js
+      // It is better share this method with gr-cursor-manager,
+      // but doing it require a lot if changes instead of 1-line copied code
+      return 'IntersectionObserver' in window;
+    }
+
     _accountChanged(account) {
       if (!account) { return; }
 
diff --git a/polygerrit-ui/app/elements/shared/gr-cursor-manager/gr-cursor-manager.js b/polygerrit-ui/app/elements/shared/gr-cursor-manager/gr-cursor-manager.js
index 37c7147..4f98e88 100644
--- a/polygerrit-ui/app/elements/shared/gr-cursor-manager/gr-cursor-manager.js
+++ b/polygerrit-ui/app/elements/shared/gr-cursor-manager/gr-cursor-manager.js
@@ -131,6 +131,75 @@
     }
 
     /**
+     * Move the cursor to the row which is the closest to the viewport center
+     * in vertical direction.
+     * The method uses IntersectionObservers API. If browser
+     * doesn't support this API the method does nothing
+     *
+     * @param {!Function=} opt_condition Optional condition. If a condition
+     *    is passed only stops which meet conditions are taken into account.
+     */
+    moveToVisibleArea(opt_condition) {
+      if (!this.stops || !this._isIntersectionObserverSupported()) {
+        return;
+      }
+      const filteredStops = opt_condition ? this.stops.filter(opt_condition)
+        : this.stops;
+      const dims = this._getWindowDims();
+      const windowCenter =
+          Math.round((dims.innerHeight + this.scrollTopMargin) / 2);
+
+      let closestToTheCenter = null;
+      let minDistanceToCenter = null;
+      let unobservedCount = filteredStops.length;
+
+      const observer = new IntersectionObserver(entries => {
+        // This callback is called for the first time immediately.
+        // Typically it gets all observed stops at once, but
+        // sometimes can get them in several chunks.
+        entries.forEach(entry => {
+          observer.unobserve(entry.target);
+
+          // In Edge it is recommended to use intersectionRatio instead of
+          // isIntersecting.
+          const isInsideViewport =
+              entry.isIntersecting || entry.intersectionRatio > 0;
+          if (!isInsideViewport) {
+            return;
+          }
+          const center = entry.boundingClientRect.top + Math.round(
+              entry.boundingClientRect.height / 2);
+          const distanceToWindowCenter = Math.abs(center - windowCenter);
+          if (minDistanceToCenter === null ||
+              distanceToWindowCenter < minDistanceToCenter) {
+            closestToTheCenter = entry.target;
+            minDistanceToCenter = distanceToWindowCenter;
+          }
+        });
+        unobservedCount -= entries.length;
+        if (unobservedCount == 0 && closestToTheCenter) {
+          // set cursor when all stops were observed.
+          // In most cases the target is visible, so scroll is not
+          // needed. But in rare cases the target can become invisible
+          // at this point (due to some scrolling in window).
+          // To avoid jumps set noScroll options.
+          this.setCursor(closestToTheCenter, true);
+        }
+      });
+      filteredStops.forEach(stop => {
+        observer.observe(stop);
+      });
+    }
+
+    _isIntersectionObserverSupported() {
+      // The copy of this method exists in gr-app-element.js under the
+      // name _isCursorManagerSupportMoveToVisibleLine
+      // If you update this method, you must update gr-app-element.js
+      // as well.
+      return 'IntersectionObserver' in window;
+    }
+
+    /**
      * Set the cursor to an arbitrary element.
      *
      * @param {!HTMLElement} element
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 75ab77a..d36ce3b 100644
--- a/polygerrit-ui/app/elements/shared/gr-icons/gr-icons.html
+++ b/polygerrit-ui/app/elements/shared/gr-icons/gr-icons.html
@@ -24,6 +24,8 @@
       <g id="expand-less"><path d="M12 8l-6 6 1.41 1.41L12 10.83l4.59 4.58L18 14z"/></g>
       <!-- This SVG is a copy from iron-icons https://github.com/PolymerElements/iron-icons/blob/master/iron-icons.js -->
       <g id="expand-more"><path d="M16.59 8.59L12 13.17 7.41 8.59 6 10l6 6 6-6z"/></g>
+      <!-- This SVG is a copy from material.io https://material.io/icons/#unfold_more -->
+      <g id="unfold-more"><path d="M0 0h24v24H0z" fill="none"/><path d="M12 5.83L15.17 9l1.41-1.41L12 3 7.41 7.59 8.83 9 12 5.83zm0 12.34L8.83 15l-1.41 1.41L12 21l4.59-4.59L15.17 15 12 18.17z"/></g>
       <!-- This SVG is a copy from iron-icons https://github.com/PolymerElements/iron-icons/blob/master/iron-icons.js -->
       <g id="search"><path d="M15.5 14h-.79l-.28-.27C15.41 12.59 16 11.11 16 9.5 16 5.91 13.09 3 9.5 3S3 5.91 3 9.5 5.91 16 9.5 16c1.61 0 3.09-.59 4.23-1.57l.27.28v.79l5 4.99L20.49 19l-4.99-5zm-6 0C7.01 14 5 11.99 5 9.5S7.01 5 9.5 5 14 7.01 14 9.5 11.99 14 9.5 14z"/></g>
       <!-- This SVG is a copy from iron-icons https://github.com/PolymerElements/iron-icons/blob/master/iron-icons.js -->
diff --git a/polygerrit-ui/app/elements/shared/gr-linked-text/gr-linked-text_test.html b/polygerrit-ui/app/elements/shared/gr-linked-text/gr-linked-text_test.html
index 68b6756..b0186f0 100644
--- a/polygerrit-ui/app/elements/shared/gr-linked-text/gr-linked-text_test.html
+++ b/polygerrit-ui/app/elements/shared/gr-linked-text/gr-linked-text_test.html
@@ -278,16 +278,58 @@
       let links = element.$.output.querySelectorAll('a');
       assert.equal(links.length, 1);
       assert.equal(links[0].getAttribute('href'), 'mailto:test@google.com');
+      assert.equal(links[0].innerHTML, 'mailto:test@google.com');
 
       element.content = 'xx http://google.com yy';
       links = element.$.output.querySelectorAll('a');
       assert.equal(links.length, 1);
       assert.equal(links[0].getAttribute('href'), 'http://google.com');
+      assert.equal(links[0].innerHTML, 'http://google.com');
 
       element.content = 'xx https://google.com yy';
       links = element.$.output.querySelectorAll('a');
       assert.equal(links.length, 1);
       assert.equal(links[0].getAttribute('href'), 'https://google.com');
+      assert.equal(links[0].innerHTML, 'https://google.com');
+
+      element.content = 'xx ssh://google.com yy';
+      links = element.$.output.querySelectorAll('a');
+      assert.equal(links.length, 0);
+
+      element.content = 'xx ftp://google.com yy';
+      links = element.$.output.querySelectorAll('a');
+      assert.equal(links.length, 0);
+    });
+
+    test('links without leading whitespace are linkified', () => {
+      element.content = 'xx abcmailto:test@google.com yy';
+      assert.equal(element.$.output.innerHTML.substr(0, 6), 'xx abc');
+      let links = element.$.output.querySelectorAll('a');
+      assert.equal(links.length, 1);
+      assert.equal(links[0].getAttribute('href'), 'mailto:test@google.com');
+      assert.equal(links[0].innerHTML, 'mailto:test@google.com');
+
+      element.content = 'xx defhttp://google.com yy';
+      assert.equal(element.$.output.innerHTML.substr(0, 6), 'xx def');
+      links = element.$.output.querySelectorAll('a');
+      assert.equal(links.length, 1);
+      assert.equal(links[0].getAttribute('href'), 'http://google.com');
+      assert.equal(links[0].innerHTML, 'http://google.com');
+
+      element.content = 'xx qwehttps://google.com yy';
+      assert.equal(element.$.output.innerHTML.substr(0, 6), 'xx qwe');
+      links = element.$.output.querySelectorAll('a');
+      assert.equal(links.length, 1);
+      assert.equal(links[0].getAttribute('href'), 'https://google.com');
+      assert.equal(links[0].innerHTML, 'https://google.com');
+
+      // Non-latin character
+      element.content = 'xx абвhttps://google.com yy';
+      assert.equal(element.$.output.innerHTML.substr(0, 6), 'xx абв');
+      links = element.$.output.querySelectorAll('a');
+      assert.equal(links.length, 1);
+      assert.equal(links[0].getAttribute('href'), 'https://google.com');
+      assert.equal(links[0].innerHTML, 'https://google.com');
 
       element.content = 'xx ssh://google.com yy';
       links = element.$.output.querySelectorAll('a');
diff --git a/polygerrit-ui/app/elements/shared/gr-linked-text/link-text-parser.js b/polygerrit-ui/app/elements/shared/gr-linked-text/link-text-parser.js
index c4f427e..aac6d76 100644
--- a/polygerrit-ui/app/elements/shared/gr-linked-text/link-text-parser.js
+++ b/polygerrit-ui/app/elements/shared/gr-linked-text/link-text-parser.js
@@ -22,7 +22,7 @@
    *
    * @type {RegExp}
    */
-  const URL_PROTOCOL_PATTERN = /^(https?:\/\/|mailto:)/;
+  const URL_PROTOCOL_PATTERN = /^(.*)(https?:\/\/|mailto:)/;
 
   /**
    * Construct a parser for linkifying text. Will linkify plain URLs that appear
@@ -257,13 +257,29 @@
     // the source text does not include a protocol, the protocol will be added
     // by ba-linkify. Create the link if the href is provided and its protocol
     // matches the expected pattern.
-    if (href && URL_PROTOCOL_PATTERN.test(href)) {
-      this.addText(text, href);
-    } else {
-      // For the sections of text that lie between the links found by
-      // ba-linkify, we search for the project-config-specified link patterns.
-      this.parseLinks(text, this.linkConfig);
+    if (href) {
+      const result = URL_PROTOCOL_PATTERN.exec(href);
+      if (result) {
+        const prefixText = result[1];
+        if (prefixText.length > 0) {
+          // Fix for simple cases from
+          // https://bugs.chromium.org/p/gerrit/issues/detail?id=11697
+          // When leading whitespace is missed before link,
+          // linkify add this text before link as a schema name to href.
+          // We suppose, that prefixText just a single word
+          // before link and add this word as is, without processing
+          // any patterns in it.
+          this.parseLinks(prefixText, []);
+          text = text.substring(prefixText.length);
+          href = href.substring(prefixText.length);
+        }
+        this.addText(text, href);
+        return;
+      }
     }
+    // For the sections of text that lie between the links found by
+    // ba-linkify, we search for the project-config-specified link patterns.
+    this.parseLinks(text, this.linkConfig);
   };
 
   /**
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 4aefe46..ce26bf6 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
@@ -990,6 +990,20 @@
      * @param {function()=} opt_cancelCondition
      */
     getChangeDetail(changeNum, opt_errFn, opt_cancelCondition) {
+      return this.getConfig(false).then(config => {
+        const optionsHex = this._getChangeOptionsHex(config);
+        return this._getChangeDetail(
+            changeNum, optionsHex, opt_errFn, opt_cancelCondition)
+            .then(GrReviewerUpdatesParser.parse);
+      });
+    }
+
+    _getChangeOptionsHex(config) {
+      if (window.DEFAULT_DETAIL_HEXES && window.DEFAULT_DETAIL_HEXES.changePage
+          && !(config.receive && config.receive.enable_signed_push)) {
+        return window.DEFAULT_DETAIL_HEXES.changePage;
+      }
+
       // This list MUST be kept in sync with
       // ChangeIT#changeDetailsDoesNotRequireIndex
       const options = [
@@ -1003,15 +1017,10 @@
         this.ListChangesOption.WEB_LINKS,
         this.ListChangesOption.SKIP_DIFFSTAT,
       ];
-      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);
-      });
+      if (config.receive && config.receive.enable_signed_push) {
+        options.push(this.ListChangesOption.PUSH_CERTIFICATES);
+      }
+      return this.listChangesOptionsToHex(...options);
     }
 
     /**
@@ -1020,11 +1029,16 @@
      * @param {function()=} opt_cancelCondition
      */
     getDiffChangeDetail(changeNum, opt_errFn, opt_cancelCondition) {
-      const optionsHex = this.listChangesOptionsToHex(
-          this.ListChangesOption.ALL_COMMITS,
-          this.ListChangesOption.ALL_REVISIONS,
-          this.ListChangesOption.SKIP_DIFFSTAT
-      );
+      let optionsHex = '';
+      if (window.DEFAULT_DETAIL_HEXES && window.DEFAULT_DETAIL_HEXES.diffPage) {
+        optionsHex = window.DEFAULT_DETAIL_HEXES.diffPage;
+      } else {
+        optionsHex = this.listChangesOptionsToHex(
+            this.ListChangesOption.ALL_COMMITS,
+            this.ListChangesOption.ALL_REVISIONS,
+            this.ListChangesOption.SKIP_DIFFSTAT
+        );
+      }
       return this._getChangeDetail(changeNum, optionsHex, opt_errFn,
           opt_cancelCondition);
     }
@@ -1040,7 +1054,7 @@
         const urlWithParams = this._restApiHelper
             .urlWithParams(url, optionsHex);
         const params = {O: optionsHex};
-        let req = {
+        const req = {
           url,
           errFn: opt_errFn,
           cancelCondition: opt_cancelCondition,
@@ -1048,7 +1062,6 @@
           fetchOptions: this._etags.getOptions(urlWithParams),
           anonymizedUrl: '/changes/*~*/detail?O=' + optionsHex,
         };
-        req = this._restApiHelper.addAcceptJsonHeader(req);
         return this._restApiHelper.fetchRawJSON(req).then(response => {
           if (response && response.status === 304) {
             return Promise.resolve(this._restApiHelper.parsePrefixedJSON(
@@ -2039,12 +2052,15 @@
        * @param {string|number=} opt_patchNum
        * @return {!Promise<!Object>} Diff comments response.
        */
+      // We don't want to add accept header, since preloading of comments is
+      // working only without accept header.
+      const noAcceptHeader = true;
       const fetchComments = opt_patchNum => this._getChangeURLAndFetch({
         changeNum,
         endpoint,
         patchNum: opt_patchNum,
         reportEndpointAsIs: true,
-      });
+      }, noAcceptHeader);
 
       if (!opt_basePatchNum && !opt_patchNum && !opt_path) {
         return fetchComments();
@@ -2609,7 +2625,7 @@
      * @param {Gerrit.ChangeFetchRequest} req
      * @return {!Promise<!Object>}
      */
-    _getChangeURLAndFetch(req) {
+    _getChangeURLAndFetch(req, noAcceptHeader) {
       const anonymizedEndpoint = req.reportEndpointAsIs ?
         req.endpoint : req.anonymizedEndpoint;
       const anonymizedBaseUrl = req.patchNum ?
@@ -2622,7 +2638,7 @@
             fetchOptions: req.fetchOptions,
             anonymizedUrl: anonymizedEndpoint ?
               (anonymizedBaseUrl + anonymizedEndpoint) : undefined,
-          }));
+          }, noAcceptHeader));
     }
 
     /**
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 ca4b246..ed7d29f 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
@@ -1036,16 +1036,6 @@
         });
       });
 
-      test('_getChangeDetail accepts only json', () => {
-        const authFetchStub = sandbox.stub(element._auth, 'fetch')
-            .returns(Promise.resolve());
-        const errFn = sinon.stub();
-        element._getChangeDetail(123, '516714', errFn);
-        assert.isTrue(authFetchStub.called);
-        assert.equal(authFetchStub.lastCall.args[1].headers.get('Accept'),
-            'application/json');
-      });
-
       test('_getChangeDetail populates _projectLookup', () => {
         sandbox.stub(element, 'getChangeActionURL')
             .returns(Promise.resolve(''));
diff --git a/polygerrit-ui/app/elements/shared/gr-rest-api-interface/gr-rest-apis/gr-rest-api-helper.js b/polygerrit-ui/app/elements/shared/gr-rest-api-interface/gr-rest-apis/gr-rest-api-helper.js
index 91fef29..2c326df 100644
--- a/polygerrit-ui/app/elements/shared/gr-rest-api-interface/gr-rest-apis/gr-rest-api-helper.js
+++ b/polygerrit-ui/app/elements/shared/gr-rest-api-interface/gr-rest-apis/gr-rest-api-helper.js
@@ -204,9 +204,12 @@
      * Same as {@link fetchRawJSON}, plus error handling.
      *
      * @param {Gerrit.FetchJSONRequest} req
+     * @param {boolean} noAcceptHeader - don't add default accept json header
      */
-    fetchJSON(req) {
-      req = this.addAcceptJsonHeader(req);
+    fetchJSON(req, noAcceptHeader) {
+      if (!noAcceptHeader) {
+        req = this.addAcceptJsonHeader(req);
+      }
       return this.fetchRawJSON(req).then(response => {
         if (!response) {
           return;
diff --git a/resources/com/google/gerrit/httpd/raw/PolyGerritIndexHtml.soy b/resources/com/google/gerrit/httpd/raw/PolyGerritIndexHtml.soy
index 5046a2a..b182309 100644
--- a/resources/com/google/gerrit/httpd/raw/PolyGerritIndexHtml.soy
+++ b/resources/com/google/gerrit/httpd/raw/PolyGerritIndexHtml.soy
@@ -28,6 +28,11 @@
   {@param? polyfillSD: ?}
   {@param? polyfillSC: ?}
   {@param? useGoogleFonts: ?}
+  {@param? changeRequestsPath: ?}
+  {@param? defaultChangeDetailHex: ?}
+  {@param? defaultDiffDetailHex: ?}
+  {@param? preloadChangePage: ?}
+  {@param? preloadDiffPage: ?}
   <!DOCTYPE html>{\n}
   <html lang="en">{\n}
   <meta charset="utf-8">{\n}
@@ -43,6 +48,14 @@
     // Disable extra font load from paper-styles
     window.polymerSkipLoadingFontRoboto = true;
     window.CLOSURE_NO_DEPS = true;
+    window.DEFAULT_DETAIL_HEXES = {lb}
+      {if $defaultChangeDetailHex}
+        changePage: '{$defaultChangeDetailHex}',
+      {/if}
+      {if $defaultDiffDetailHex}
+        diffPage: '{$defaultDiffDetailHex}',
+      {/if}
+    {rb};
     {if $canonicalPath != ''}window.CANONICAL_PATH = '{$canonicalPath}';{/if}
     {if $versionInfo}window.VERSION_INFO = '{$versionInfo}';{/if}
     {if $staticResourcePath != ''}window.STATIC_RESOURCE_PATH = '{$staticResourcePath}';{/if}
@@ -68,6 +81,16 @@
   {else}
     <link rel="icon" type="image/x-icon" href="{$canonicalPath}/favicon.ico">{\n}
   {/if}
+  {if $changeRequestsPath}
+    {if $preloadChangePage and $defaultChangeDetailHex}
+      <link rel="preload" href="{$canonicalPath}/{$changeRequestsPath}/detail?O={$defaultChangeDetailHex}" as="fetch" type="application/json" crossorigin="anonymous"/>{\n}
+    {/if}
+    {if $preloadDiffPage and $defaultDiffDetailHex}
+      <link rel="preload" href="{$canonicalPath}/{$changeRequestsPath}/detail?O={$defaultDiffDetailHex}" as="fetch" type="application/json" crossorigin="anonymous"/>{\n}
+    {/if}
+    <link rel="preload" href="{$canonicalPath}/{$changeRequestsPath}/comments" as="fetch" type="application/json" crossorigin="anonymous"/>{\n}
+    <link rel="preload" href="{$canonicalPath}/{$changeRequestsPath}/robotcomments" as="fetch" type="application/json" crossorigin="anonymous"/>{\n}
+  {/if}
 
   // RobotoMono fonts are used in styles/fonts.css
   {if $useGoogleFonts}