Merge "Add the ability to add a new section in gr-repo-access"
diff --git a/Documentation/config-gerrit.txt b/Documentation/config-gerrit.txt
index be4316e..38c3e0f 100644
--- a/Documentation/config-gerrit.txt
+++ b/Documentation/config-gerrit.txt
@@ -147,6 +147,17 @@
 +
 By default 1.
 
+[[audit]]
+=== Section audit
+
+[[audit.maskSensitiveData]]audit.maskSensitiveData::
++
+If true, command parameters marked as sensitive are masked in audit logs.
++
+This option only affects audit. Other means of logging will always be masked.
++
+By default `false`.
+
 [[auth]]
 === Section auth
 
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/ErrorDialog.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/ErrorDialog.java
index d793082..c116d76 100644
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/ErrorDialog.java
+++ b/gerrit-gwtui/src/main/java/com/google/gerrit/client/ErrorDialog.java
@@ -88,7 +88,7 @@
   /** Create a dialog box to show a single message string. */
   public ErrorDialog(String message) {
     this();
-    body.add(new Label(message));
+    body.add(createErrorMsgLabel(message));
   }
 
   /** Create a dialog box to show a single message string. */
@@ -145,12 +145,16 @@
     }
 
     if (msg != null) {
-      final Label m = new Label(msg);
-      m.getElement().getStyle().setProperty("whiteSpace", "pre");
-      body.add(m);
+      body.add(createErrorMsgLabel(msg));
     }
   }
 
+  private Label createErrorMsgLabel(String message) {
+    Label m = new Label(message);
+    m.getElement().getStyle().setProperty("white-space", "pre");
+    return m;
+  }
+
   public ErrorDialog setText(String t) {
     text.setText(t);
     return this;
diff --git a/gerrit-plugin-gwtui/BUILD b/gerrit-plugin-gwtui/BUILD
index 3f066c7..0880993 100644
--- a/gerrit-plugin-gwtui/BUILD
+++ b/gerrit-plugin-gwtui/BUILD
@@ -24,6 +24,7 @@
         "//java/org/eclipse/jgit:libclient-src.jar",
         "//java/org/eclipse/jgit:libEdit-src.jar",
         "//java/com/google/gerrit/common:libclient-src.jar",
+        "//java/com/google/gerrit/extensions:libapi-src.jar",
         "//java/com/google/gwtexpui/clippy:libclippy-src.jar",
         "//java/com/google/gwtexpui/globalkey:libglobalkey-src.jar",
         "//java/com/google/gwtexpui/progress:libprogress-src.jar",
diff --git a/java/com/google/gerrit/server/events/EventFactory.java b/java/com/google/gerrit/server/events/EventFactory.java
index 8749838..6da2d43 100644
--- a/java/com/google/gerrit/server/events/EventFactory.java
+++ b/java/com/google/gerrit/server/events/EventFactory.java
@@ -246,8 +246,7 @@
         la.label = lbl.label;
         la.status = lbl.status.name();
         if (lbl.appliedBy != null) {
-          AccountState accountState = accountCache.get(lbl.appliedBy);
-          la.by = asAccountAttribute(accountState);
+          la.by = asAccountAttribute(lbl.appliedBy);
         }
         sa.labels.add(la);
       }
@@ -573,7 +572,7 @@
     if (id == null) {
       return null;
     }
-    return asAccountAttribute(accountCache.get(id));
+    return accountCache.maybeGet(id).map(a -> asAccountAttribute(a)).orElse(null);
   }
 
   /**
@@ -583,10 +582,6 @@
    * @return object suitable for serialization to JSON
    */
   public AccountAttribute asAccountAttribute(AccountState accountState) {
-    if (accountState == null) {
-      return null;
-    }
-
     AccountAttribute who = new AccountAttribute();
     who.name = accountState.getAccount().getFullName();
     who.email = accountState.getAccount().getPreferredEmail();
diff --git a/java/com/google/gerrit/server/permissions/ChangeControl.java b/java/com/google/gerrit/server/permissions/ChangeControl.java
index 0aa80e2..47c8543 100644
--- a/java/com/google/gerrit/server/permissions/ChangeControl.java
+++ b/java/com/google/gerrit/server/permissions/ChangeControl.java
@@ -283,6 +283,7 @@
   private class ForChangeImpl extends ForChange {
     private ChangeData cd;
     private Map<String, PermissionRange> labels;
+    private String resourcePath;
 
     ForChangeImpl(@Nullable ChangeData cd, @Nullable Provider<ReviewDb> db) {
       this.cd = cd;
@@ -320,9 +321,13 @@
 
     @Override
     public String resourcePath() {
-      return String.format(
-          "/projects/%s/+changes/%s",
-          getProjectControl().getProjectState().getName(), changeData().getId().get());
+      if (resourcePath == null) {
+        resourcePath =
+            String.format(
+                "/projects/%s/+changes/%s",
+                getProjectControl().getProjectState().getName(), changeData().getId().get());
+      }
+      return resourcePath;
     }
 
     @Override
diff --git a/java/com/google/gerrit/server/permissions/ProjectControl.java b/java/com/google/gerrit/server/permissions/ProjectControl.java
index a3bb424..ddadeaa 100644
--- a/java/com/google/gerrit/server/permissions/ProjectControl.java
+++ b/java/com/google/gerrit/server/permissions/ProjectControl.java
@@ -312,6 +312,8 @@
   }
 
   private class ForProjectImpl extends ForProject {
+    private String resourcePath;
+
     @Override
     public CurrentUser user() {
       return getUser();
@@ -324,7 +326,10 @@
 
     @Override
     public String resourcePath() {
-      return "/projects/" + getProjectState().getName();
+      if (resourcePath == null) {
+        resourcePath = "/projects/" + getProjectState().getName();
+      }
+      return resourcePath;
     }
 
     @Override
diff --git a/java/com/google/gerrit/server/permissions/RefControl.java b/java/com/google/gerrit/server/permissions/RefControl.java
index 94f1acf..9f95171 100644
--- a/java/com/google/gerrit/server/permissions/RefControl.java
+++ b/java/com/google/gerrit/server/permissions/RefControl.java
@@ -431,6 +431,8 @@
   }
 
   private class ForRefImpl extends ForRef {
+    private String resourcePath;
+
     @Override
     public CurrentUser user() {
       return getUser();
@@ -443,8 +445,12 @@
 
     @Override
     public String resourcePath() {
-      return String.format(
-          "/projects/%s/+refs/%s", getProjectControl().getProjectState().getName(), refName);
+      if (resourcePath == null) {
+        resourcePath =
+            String.format(
+                "/projects/%s/+refs/%s", getProjectControl().getProjectState().getName(), refName);
+      }
+      return resourcePath;
     }
 
     @Override
diff --git a/java/com/google/gerrit/sshd/BaseCommand.java b/java/com/google/gerrit/sshd/BaseCommand.java
index cf54a47..1833f9c 100644
--- a/java/com/google/gerrit/sshd/BaseCommand.java
+++ b/java/com/google/gerrit/sshd/BaseCommand.java
@@ -48,6 +48,10 @@
 import java.io.PrintWriter;
 import java.io.StringWriter;
 import java.nio.charset.Charset;
+import java.util.ArrayList;
+import java.util.HashSet;
+import java.util.List;
+import java.util.Set;
 import java.util.concurrent.Future;
 import java.util.concurrent.ScheduledThreadPoolExecutor;
 import java.util.concurrent.atomic.AtomicReference;
@@ -70,6 +74,8 @@
   static final int STATUS_NOT_FOUND = PRIVATE_STATUS | 2;
   public static final int STATUS_NOT_ADMIN = PRIVATE_STATUS | 3;
 
+  private static final String MASK = "***";
+
   @Option(name = "--", usage = "end of options", handler = EndOfOptionsHandler.class)
   private boolean endOfOptions;
 
@@ -93,6 +99,8 @@
 
   @Inject private SshScope.Context context;
 
+  @Inject private SshCommandSensitiveFieldsCache cache;
+
   /** Commands declared by a plugin can be scoped by the plugin name. */
   @Inject(optional = true)
   @PluginName
@@ -111,6 +119,10 @@
   /** Unparsed command line options. */
   private String[] argv;
 
+  private List<String> maskedArgv = new ArrayList<>();
+
+  private Set<String> sensitiveParameters = new HashSet<>();
+
   public BaseCommand() {
     task = Atomics.newReference();
   }
@@ -156,6 +168,22 @@
     this.argv = argv;
   }
 
+  public List<String> getMaskedArguments() {
+    return maskedArgv;
+  }
+
+  public String getFormattedMaskedArguments(String delimiter) {
+    return String.join(delimiter, maskedArgv);
+  }
+
+  public void setMaskedArguments(List<String> argv) {
+    this.maskedArgv = argv;
+  }
+
+  public boolean isSensitiveParameter(String param) {
+    return sensitiveParameters.contains(param);
+  }
+
   @Override
   public void destroy() {
     Future<?> future = task.getAndSet(null);
@@ -326,7 +354,7 @@
         m.append(")");
       }
       m.append(" during ");
-      m.append(context.getCommandLine());
+      m.append(getFormattedMaskedArguments(" "));
       log.error(m.toString(), e);
     }
 
@@ -372,7 +400,7 @@
 
   protected String getTaskDescription() {
     StringBuilder m = new StringBuilder();
-    m.append(context.getCommandLine());
+    m.append(getFormattedMaskedArguments(" "));
     return m.toString();
   }
 
@@ -388,12 +416,49 @@
     return m.toString();
   }
 
+  private void maskSensitiveParameters() {
+    if (argv == null) {
+      return;
+    }
+    sensitiveParameters = cache.get(this.getClass());
+    maskedArgv = new ArrayList<>();
+    maskedArgv.add(commandName);
+    boolean maskNext = false;
+    for (int i = 0; i < argv.length; i++) {
+      if (maskNext) {
+        maskedArgv.add(MASK);
+        maskNext = false;
+        continue;
+      }
+      String arg = argv[i];
+      String key = extractKey(arg);
+      if (isSensitiveParameter(key)) {
+        maskNext = arg.equals(key);
+        // When arg != key then parameter contains '=' sign and we mask them right away.
+        // Otherwise we mask the next parameter as indicated by maskNext.
+        if (!maskNext) {
+          arg = key + "=" + MASK;
+        }
+      }
+      maskedArgv.add(arg);
+    }
+  }
+
+  private String extractKey(String arg) {
+    int eqPos = arg.indexOf('=');
+    if (eqPos > 0) {
+      return arg.substring(0, eqPos);
+    }
+    return arg;
+  }
+
   private final class TaskThunk implements CancelableRunnable, ProjectRunnable {
     private final CommandRunnable thunk;
     private final String taskName;
     private Project.NameKey projectName;
 
     private TaskThunk(CommandRunnable thunk) {
+      maskSensitiveParameters();
       this.thunk = thunk;
       this.taskName = getTaskName();
     }
diff --git a/java/com/google/gerrit/sshd/CommandFactoryProvider.java b/java/com/google/gerrit/sshd/CommandFactoryProvider.java
index 0287ceb..d061535 100644
--- a/java/com/google/gerrit/sshd/CommandFactoryProvider.java
+++ b/java/com/google/gerrit/sshd/CommandFactoryProvider.java
@@ -29,6 +29,7 @@
 import java.io.InputStream;
 import java.io.OutputStream;
 import java.util.ArrayList;
+import java.util.Arrays;
 import java.util.List;
 import java.util.concurrent.ExecutorService;
 import java.util.concurrent.Executors;
@@ -159,7 +160,7 @@
                   } catch (Exception e) {
                     logger.warn(
                         "Cannot start command \""
-                            + ctx.getCommandLine()
+                            + cmd.getFormattedMaskedArguments(" ")
                             + "\" for user "
                             + ctx.getSession().getUsername(),
                         e);
@@ -179,6 +180,10 @@
         try {
           cmd = dispatcher.get();
           cmd.setArguments(argv);
+          cmd.setMaskedArguments(
+              argv.length > 0
+                  ? Arrays.asList(argv[0])
+                  : Arrays.asList(ctx.getCommandLine().split(" ")[0]));
           cmd.setInputStream(in);
           cmd.setOutputStream(out);
           cmd.setErrorStream(err);
diff --git a/java/com/google/gerrit/sshd/DispatchCommand.java b/java/com/google/gerrit/sshd/DispatchCommand.java
index 3f2e258..62f80c9 100644
--- a/java/com/google/gerrit/sshd/DispatchCommand.java
+++ b/java/com/google/gerrit/sshd/DispatchCommand.java
@@ -107,6 +107,10 @@
       atomicCmd.set(cmd);
       cmd.start(env);
 
+      if (cmd instanceof BaseCommand) {
+        setMaskedArguments(((BaseCommand) cmd).getMaskedArguments());
+      }
+
     } catch (UnloggedFailure e) {
       String msg = e.getMessage();
       if (!msg.endsWith("\n")) {
diff --git a/java/com/google/gerrit/sshd/SensitiveData.java b/java/com/google/gerrit/sshd/SensitiveData.java
new file mode 100644
index 0000000..1dd7896
--- /dev/null
+++ b/java/com/google/gerrit/sshd/SensitiveData.java
@@ -0,0 +1,28 @@
+// 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.
+
+package com.google.gerrit.sshd;
+
+import static java.lang.annotation.ElementType.FIELD;
+import static java.lang.annotation.RetentionPolicy.RUNTIME;
+
+import java.lang.annotation.Retention;
+import java.lang.annotation.Target;
+
+/**
+ * Annotation tagged on a field of an ssh command to indicate the value must be hidden from logs.
+ */
+@Target({FIELD})
+@Retention(RUNTIME)
+public @interface SensitiveData {}
diff --git a/java/com/google/gerrit/sshd/SshCommandSensitiveFieldsCache.java b/java/com/google/gerrit/sshd/SshCommandSensitiveFieldsCache.java
new file mode 100644
index 0000000..8c79299
--- /dev/null
+++ b/java/com/google/gerrit/sshd/SshCommandSensitiveFieldsCache.java
@@ -0,0 +1,24 @@
+// 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.sshd;
+
+import java.util.Set;
+
+/** Keeps data about ssh commands' parameters that have extra secure annotation. */
+public interface SshCommandSensitiveFieldsCache {
+  Set<String> get(Class<?> command);
+
+  void evictAll();
+}
diff --git a/java/com/google/gerrit/sshd/SshCommandSensitiveFieldsCacheImpl.java b/java/com/google/gerrit/sshd/SshCommandSensitiveFieldsCacheImpl.java
new file mode 100644
index 0000000..b593388
--- /dev/null
+++ b/java/com/google/gerrit/sshd/SshCommandSensitiveFieldsCacheImpl.java
@@ -0,0 +1,76 @@
+// 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.sshd;
+
+import com.google.common.cache.CacheLoader;
+import com.google.common.cache.LoadingCache;
+import com.google.gerrit.server.cache.CacheModule;
+import com.google.inject.Inject;
+import com.google.inject.Module;
+import com.google.inject.TypeLiteral;
+import com.google.inject.name.Named;
+import java.lang.reflect.Field;
+import java.util.HashSet;
+import java.util.Set;
+import org.kohsuke.args4j.Option;
+
+public class SshCommandSensitiveFieldsCacheImpl implements SshCommandSensitiveFieldsCache {
+  private static final String CACHE_NAME = "sshd_sensitive_command_params";
+  private final LoadingCache<Class<?>, Set<String>> sshdCommandsCache;
+
+  static Module module() {
+    return new CacheModule() {
+      @Override
+      protected void configure() {
+        cache(CACHE_NAME, new TypeLiteral<Class<?>>() {}, new TypeLiteral<Set<String>>() {})
+            .loader(Loader.class);
+        bind(SshCommandSensitiveFieldsCache.class).to(SshCommandSensitiveFieldsCacheImpl.class);
+      }
+    };
+  }
+
+  @Inject
+  SshCommandSensitiveFieldsCacheImpl(@Named(CACHE_NAME) LoadingCache<Class<?>, Set<String>> cache) {
+    sshdCommandsCache = cache;
+  }
+
+  @Override
+  public Set<String> get(Class<?> cmd) {
+    return sshdCommandsCache.getUnchecked(cmd);
+  }
+
+  @Override
+  public void evictAll() {
+    sshdCommandsCache.invalidateAll();
+  }
+
+  static class Loader extends CacheLoader<Class<?>, Set<String>> {
+
+    @Override
+    public Set<String> load(Class<?> cmd) throws Exception {
+      Set<String> datas = new HashSet<>();
+      for (Field field : cmd.getDeclaredFields()) {
+        if (field.isAnnotationPresent(SensitiveData.class)) {
+          Option option = field.getAnnotation(Option.class);
+          datas.add(option.name());
+          for (String opt : option.aliases()) {
+            datas.add(opt);
+          }
+        }
+      }
+      return datas;
+    }
+  }
+}
diff --git a/java/com/google/gerrit/sshd/SshLog.java b/java/com/google/gerrit/sshd/SshLog.java
index 6465a30..035989a 100644
--- a/java/com/google/gerrit/sshd/SshLog.java
+++ b/java/com/google/gerrit/sshd/SshLog.java
@@ -48,9 +48,12 @@
   private static final String P_STATUS = "status";
   private static final String P_AGENT = "agent";
 
+  private static final String MASK = "***";
+
   private final Provider<SshSession> session;
   private final Provider<Context> context;
   private final AsyncAppender async;
+  private final boolean auditMask;
   private final AuditService auditService;
 
   @Inject
@@ -64,6 +67,7 @@
     this.context = context;
     this.auditService = auditService;
 
+    auditMask = config.getBoolean("audit", "maskSensitiveData", false);
     if (!config.getBoolean("sshd", "requestLog", true)) {
       async = null;
       return;
@@ -121,8 +125,7 @@
     final Context ctx = context.get();
     ctx.finished = TimeUtil.nowMs();
 
-    String cmd = extractWhat(dcmd);
-
+    String cmd = extractWhat(dcmd, true);
     final LoggingEvent event = log(cmd);
     event.setProperty(P_WAIT, (ctx.started - ctx.created) + "ms");
     event.setProperty(P_EXEC, (ctx.finished - ctx.started) + "ms");
@@ -154,7 +157,11 @@
     if (async != null) {
       async.append(event);
     }
-    audit(context.get(), status, dcmd);
+
+    if (!auditMask) {
+      cmd = extractWhat(dcmd, false);
+    }
+    audit(ctx, status, cmd, extractParameters(dcmd));
   }
 
   private ListMultimap<String, ?> extractParameters(DispatchCommand dcmd) {
@@ -177,7 +184,10 @@
       // --param=value
       int eqPos = arg.indexOf('=');
       if (arg.startsWith("--") && eqPos > 0) {
-        parms.put(arg.substring(0, eqPos), arg.substring(eqPos + 1));
+        String param = arg.substring(0, eqPos);
+        String value =
+            auditMask && dcmd.isSensitiveParameter(param) ? MASK : arg.substring(eqPos + 1);
+        parms.put(param, value);
         continue;
       }
       // -p value or --param value
@@ -192,7 +202,7 @@
       if (paramName == null) {
         parms.put("$" + argPos++, arg);
       } else {
-        parms.put(paramName, arg);
+        parms.put(paramName, auditMask && dcmd.isSensitiveParameter(paramName) ? MASK : arg);
         paramName = null;
       }
     }
@@ -256,10 +266,6 @@
     audit(ctx, result, cmd, null);
   }
 
-  void audit(Context ctx, Object result, DispatchCommand cmd) {
-    audit(ctx, result, extractWhat(cmd), extractParameters(cmd));
-  }
-
   private void audit(Context ctx, Object result, String cmd, ListMultimap<String, ?> params) {
     String sessionId;
     CurrentUser currentUser;
@@ -277,11 +283,16 @@
     auditService.dispatch(new SshAuditEvent(sessionId, currentUser, cmd, created, params, result));
   }
 
-  private String extractWhat(DispatchCommand dcmd) {
+  private String extractWhat(DispatchCommand dcmd, boolean hideSensitive) {
     if (dcmd == null) {
       return "Command was already destroyed";
     }
-    StringBuilder commandName = new StringBuilder(dcmd.getCommandName());
+    return hideSensitive ? dcmd.getFormattedMaskedArguments(".") : extractWhat(dcmd);
+  }
+
+  private String extractWhat(DispatchCommand dcmd) {
+    String name = dcmd.getCommandName();
+    StringBuilder commandName = new StringBuilder(name == null ? "" : name);
     String[] args = dcmd.getArguments();
     for (int i = 1; i < args.length; i++) {
       commandName.append(".").append(args[i]);
diff --git a/java/com/google/gerrit/sshd/SshModule.java b/java/com/google/gerrit/sshd/SshModule.java
index 748277e..c672b00 100644
--- a/java/com/google/gerrit/sshd/SshModule.java
+++ b/java/com/google/gerrit/sshd/SshModule.java
@@ -65,6 +65,7 @@
     configureRequestScope();
     install(new AsyncReceiveCommits.Module());
     configureAliases();
+    install(SshCommandSensitiveFieldsCacheImpl.module());
 
     bind(SshLog.class);
     bind(SshInfo.class).to(SshDaemon.class).in(SINGLETON);
diff --git a/java/com/google/gerrit/sshd/SshPluginStarterCallback.java b/java/com/google/gerrit/sshd/SshPluginStarterCallback.java
index 9ae1814..047690c 100644
--- a/java/com/google/gerrit/sshd/SshPluginStarterCallback.java
+++ b/java/com/google/gerrit/sshd/SshPluginStarterCallback.java
@@ -33,13 +33,16 @@
 
   private final DispatchCommandProvider root;
   private final DynamicMap<DynamicOptions.DynamicBean> dynamicBeans;
+  private final SshCommandSensitiveFieldsCache cache;
 
   @Inject
   SshPluginStarterCallback(
       @CommandName(Commands.ROOT) DispatchCommandProvider root,
-      DynamicMap<DynamicOptions.DynamicBean> dynamicBeans) {
+      DynamicMap<DynamicOptions.DynamicBean> dynamicBeans,
+      SshCommandSensitiveFieldsCache cache) {
     this.root = root;
     this.dynamicBeans = dynamicBeans;
+    this.cache = cache;
   }
 
   @Override
@@ -56,6 +59,7 @@
     if (cmd != null) {
       newPlugin.add(root.replace(Commands.named(newPlugin.getName()), cmd));
     }
+    cache.evictAll();
   }
 
   private Provider<Command> load(Plugin plugin) {
diff --git a/plugins/codemirror-editor b/plugins/codemirror-editor
index eedb7a4..6442943 160000
--- a/plugins/codemirror-editor
+++ b/plugins/codemirror-editor
@@ -1 +1 @@
-Subproject commit eedb7a4309bf7b59bded8e022078934f282fbdc0
+Subproject commit 6442943d6a6de21b7d6d25b3fad2753a3c30d2d8
diff --git a/polygerrit-ui/app/behaviors/base-url-behavior/base-url-behavior.html b/polygerrit-ui/app/behaviors/base-url-behavior/base-url-behavior.html
index f1ce45c..e16d296 100644
--- a/polygerrit-ui/app/behaviors/base-url-behavior/base-url-behavior.html
+++ b/polygerrit-ui/app/behaviors/base-url-behavior/base-url-behavior.html
@@ -21,8 +21,8 @@
   window.Gerrit = window.Gerrit || {};
 
   const PROJECT_DASHBOARD_PATTERN = /\/p\/(.+)\/\+\/dashboard\/(.*)/;
-  const REPO_URL = '/admin/repos/';
-  const PROJECT_URL = '/admin/projects/';
+  const REPO_URL_PATTERN = /^\/admin\/repos/;
+  const PROJECT_URL = '/admin/projects';
   /** @polymerBehavior Gerrit.BaseUrlBehavior */
   Gerrit.BaseUrlBehavior = {
     /** @return {string} */
@@ -38,7 +38,7 @@
         clientPath = `/projects/${match[1]},dashboards/${match[2]}`;
       }
       // Replace any '/admin/project' links to '/admin/repo'
-      clientPath = clientPath.replace(REPO_URL, PROJECT_URL);
+      clientPath = clientPath.replace(REPO_URL_PATTERN, PROJECT_URL);
       return base + '/?polygerrit=0#' + clientPath;
     },
   };
diff --git a/polygerrit-ui/app/behaviors/gr-patch-set-behavior/gr-patch-set-behavior.html b/polygerrit-ui/app/behaviors/gr-patch-set-behavior/gr-patch-set-behavior.html
index 3ec7e7b..19eb437 100644
--- a/polygerrit-ui/app/behaviors/gr-patch-set-behavior/gr-patch-set-behavior.html
+++ b/polygerrit-ui/app/behaviors/gr-patch-set-behavior/gr-patch-set-behavior.html
@@ -209,7 +209,8 @@
     },
 
     /** @return {Boolean} */
-    hasEditPatchsetLoaded(patchRange) {
+    hasEditPatchsetLoaded(patchRangeRecord) {
+      const patchRange = patchRangeRecord.base;
       if (!patchRange) { return false; }
       return patchRange.patchNum === Gerrit.PatchSetBehavior.EDIT_NAME ||
           patchRange.basePatchNum === Gerrit.PatchSetBehavior.EDIT_NAME;
diff --git a/polygerrit-ui/app/elements/change-list/gr-change-list-view/gr-change-list-view.html b/polygerrit-ui/app/elements/change-list/gr-change-list-view/gr-change-list-view.html
index d79dc69..276febb 100644
--- a/polygerrit-ui/app/elements/change-list/gr-change-list-view/gr-change-list-view.html
+++ b/polygerrit-ui/app/elements/change-list/gr-change-list-view/gr-change-list-view.html
@@ -61,6 +61,8 @@
     <div hidden$="[[_loading]]" hidden>
       <gr-user-header
           user-id="[[_userId]]"
+          show-dashboard-link
+          logged-in="[[loggedIn]]"
           class$="[[_computeUserHeaderClass(_userId)]]"></gr-user-header>
       <gr-change-list
           changes="{{_changes}}"
diff --git a/polygerrit-ui/app/elements/change-list/gr-user-header/gr-user-header.html b/polygerrit-ui/app/elements/change-list/gr-user-header/gr-user-header.html
index 9a7ca33..d3d0736 100644
--- a/polygerrit-ui/app/elements/change-list/gr-user-header/gr-user-header.html
+++ b/polygerrit-ui/app/elements/change-list/gr-user-header/gr-user-header.html
@@ -15,6 +15,7 @@
 -->
 
 <link rel="import" href="../../../bower_components/polymer/polymer.html">
+<link rel="import" href="../../core/gr-navigation/gr-navigation.html">
 <link rel="import" href="../../shared/gr-avatar/gr-avatar.html">
 <link rel="import" href="../../shared/gr-date-formatter/gr-date-formatter.html">
 <link rel="import" href="../../shared/gr-rest-api-interface/gr-rest-api-interface.html">
@@ -26,35 +27,36 @@
       :host {
         display: block;
         height: 9em;
-        position: relative;
         width: 100%;
       }
       gr-avatar {
+        display: inline-block;
         height: 7em;
         left: 1em;
-        position: absolute;
+        margin: 1em;
         top: 1em;
         width: 7em;
       }
       .info {
-        left: 9em;
-        position: absolute;
-        top: 1em;
+        display: inline-block;
+        padding: 1em;
+        vertical-align: top;
       }
       .info > div > span {
         display: inline-block;
         font-weight: bold;
         text-align: right;
-        width: 6em;
+        width: 4em;
       }
       .name {
-        margin-bottom: .25em;
+        display: inline-block;
       }
       .name hr {
         width: 100%;
       }
       .status.hide,
-      .name.hide {
+      .name.hide,
+      .dashboardLink.hide {
         display: none;
       }
     </style>
@@ -63,10 +65,10 @@
         image-size="100"
         aria-label="Account avatar"></gr-avatar>
     <div class="info">
-      <h1 class$="name">
+      <h1 class="name">
         [[_computeDetail(_accountDetails, 'name')]]
-        <hr/>
       </h1>
+      <hr/>
       <div class$="status [[_computeStatusClass(_accountDetails)]]">
         <span>Status:</span> [[_status]]
       </div>
@@ -82,6 +84,11 @@
         </gr-date-formatter>
       </div>
     </div>
+    <div class="info">
+      <div class$="[[_computeDashboardLinkClass(showDashboardLink, loggedIn)]]">
+        <a href$="[[_computeDashboardUrl(_accountDetails)]]">View dashboard</a>
+      </div>
+    </div>
     <gr-rest-api-interface id="restAPI"></gr-rest-api-interface>
   </template>
   <script src="gr-user-header.js"></script>
diff --git a/polygerrit-ui/app/elements/change-list/gr-user-header/gr-user-header.js b/polygerrit-ui/app/elements/change-list/gr-user-header/gr-user-header.js
index dd3512a..d09e865 100644
--- a/polygerrit-ui/app/elements/change-list/gr-user-header/gr-user-header.js
+++ b/polygerrit-ui/app/elements/change-list/gr-user-header/gr-user-header.js
@@ -23,6 +23,16 @@
         observer: '_accountChanged',
       },
 
+      showDashboardLink: {
+        type: Boolean,
+        value: false,
+      },
+
+      loggedIn: {
+        type: Boolean,
+        value: false,
+      },
+
       /**
        * @type {?{name: ?, email: ?, registered_on: ?}}
        */
@@ -64,5 +74,15 @@
     _computeStatusClass(accountDetails) {
       return this._computeDetail(accountDetails, 'status') ? '' : 'hide';
     },
+
+    _computeDashboardUrl(accountDetails) {
+      if (!accountDetails || !accountDetails.email) { return null; }
+      return Gerrit.Nav.getUrlForUserDashboard(accountDetails.email);
+    },
+
+    _computeDashboardLinkClass(showDashboardLink, loggedIn) {
+      return showDashboardLink && loggedIn ?
+          'dashboardLink' : 'dashboardLink hide';
+    },
   });
 })();
diff --git a/polygerrit-ui/app/elements/change-list/gr-user-header/gr-user-header_test.html b/polygerrit-ui/app/elements/change-list/gr-user-header/gr-user-header_test.html
index ab3b249..4ae8db4 100644
--- a/polygerrit-ui/app/elements/change-list/gr-user-header/gr-user-header_test.html
+++ b/polygerrit-ui/app/elements/change-list/gr-user-header/gr-user-header_test.html
@@ -68,5 +68,12 @@
         });
       });
     });
+
+    test('_computeDashboardLinkClass', () => {
+      assert.include(element._computeDashboardLinkClass(false, false), 'hide');
+      assert.include(element._computeDashboardLinkClass(true, false), 'hide');
+      assert.include(element._computeDashboardLinkClass(false, true), 'hide');
+      assert.notInclude(element._computeDashboardLinkClass(true, true), 'hide');
+    });
   });
 </script>
diff --git a/polygerrit-ui/app/elements/change/gr-change-actions/gr-change-actions.js b/polygerrit-ui/app/elements/change/gr-change-actions/gr-change-actions.js
index 7ca94e8..9dc1c0f 100644
--- a/polygerrit-ui/app/elements/change/gr-change-actions/gr-change-actions.js
+++ b/polygerrit-ui/app/elements/change/gr-change-actions/gr-change-actions.js
@@ -561,21 +561,23 @@
         }
       }
 
-      // Only show edit button if there is no edit patchset loaded and the
-      // file list is not in edit mode.
-      if (editPatchsetLoaded || editMode) {
-        if (changeActions.edit) {
-          delete this.actions.edit;
-          this.notifyPath('actions.edit');
-        }
-        if (!changeActions.doneEdit) {
-          this.set('actions.doneEdit', DONE_EDIT);
-        }
-      } else {
-        if (!changeActions.edit) { this.set('actions.edit', EDIT); }
-        if (changeActions.doneEdit) {
-          delete this.actions.doneEdit;
-          this.notifyPath('actions.doneEdit');
+      if (this.changeIsOpen(this.change.status)) {
+        // Only show edit button if there is no edit patchset loaded and the
+        // file list is not in edit mode.
+        if (editPatchsetLoaded || editMode) {
+          if (changeActions.edit) {
+            delete this.actions.edit;
+            this.notifyPath('actions.edit');
+          }
+          if (!changeActions.doneEdit) {
+            this.set('actions.doneEdit', DONE_EDIT);
+          }
+        } else {
+          if (!changeActions.edit) { this.set('actions.edit', EDIT); }
+          if (changeActions.doneEdit) {
+            delete this.actions.doneEdit;
+            this.notifyPath('actions.doneEdit');
+          }
         }
       }
     },
diff --git a/polygerrit-ui/app/elements/change/gr-change-actions/gr-change-actions_test.html b/polygerrit-ui/app/elements/change/gr-change-actions/gr-change-actions_test.html
index cc065e9..e76e494 100644
--- a/polygerrit-ui/app/elements/change/gr-change-actions/gr-change-actions_test.html
+++ b/polygerrit-ui/app/elements/change/gr-change-actions/gr-change-actions_test.html
@@ -423,6 +423,7 @@
         assert.isNotOk(element.$$('gr-button[data-action-key="publishEdit"]'));
         assert.isNotOk(element.$$('gr-button[data-action-key="rebaseEdit"]'));
         assert.isOk(element.$$('gr-button[data-action-key="deleteEdit"]'));
+        assert.isNotOk(element.$$('gr-button[data-action-key="edit"]'));
       });
 
       test('edit patchset is loaded, needs rebase', () => {
@@ -482,6 +483,7 @@
       test('edit action', done => {
         element.addEventListener('edit-tap', () => { done(); });
         element.set('editMode', true);
+        element.change = {status: 'NEW'};
         flushAsynchronousOperations();
 
         assert.isNotOk(element.$$('gr-button[data-action-key="edit"]'));
diff --git a/polygerrit-ui/app/elements/change/gr-change-view/gr-change-view.html b/polygerrit-ui/app/elements/change/gr-change-view/gr-change-view.html
index bf7f94e..5310a2b 100644
--- a/polygerrit-ui/app/elements/change/gr-change-view/gr-change-view.html
+++ b/polygerrit-ui/app/elements/change/gr-change-view/gr-change-view.html
@@ -364,7 +364,7 @@
               reply-disabled="[[_replyDisabled]]"
               reply-button-label="[[_replyButtonLabel]]"
               commit-message="[[_latestCommitMessage]]"
-              edit-patchset-loaded="[[hasEditPatchsetLoaded(_patchRange)]]"
+              edit-patchset-loaded="[[hasEditPatchsetLoaded(_patchRange.*)]]"
               edit-mode="[[_editMode]]"
               edit-based-on-current-patch-set="[[hasEditBasedOnCurrentPatchSet(_allPatchSets)]]"
               on-reload-change="_handleReloadChange"
diff --git a/polygerrit-ui/app/elements/change/gr-file-list-header/gr-file-list-header.html b/polygerrit-ui/app/elements/change/gr-file-list-header/gr-file-list-header.html
index ddd072e..8cd0329 100644
--- a/polygerrit-ui/app/elements/change/gr-file-list-header/gr-file-list-header.html
+++ b/polygerrit-ui/app/elements/change/gr-file-list-header/gr-file-list-header.html
@@ -110,13 +110,18 @@
         align-items: center;
         display: flex;
       }
+      .rightControls gr-button,
+      gr-patch-range-select {
+        margin: 0 -4px;
+      }
       .fileViewActions gr-button {
+        margin: 0;
         --gr-button: {
           padding: 2px 4px;
         }
       }
-      .fileViewActions > *:not(:last-child) {
-        margin-right: 5px;
+      .fileViewActions gr-button:first-of-type {
+        margin-left: 4px;
       }
       .editMode .hideOnEdit {
         display: none;
diff --git a/polygerrit-ui/app/elements/change/gr-file-list/gr-file-list.html b/polygerrit-ui/app/elements/change/gr-file-list/gr-file-list.html
index 083eb67..c088ffb 100644
--- a/polygerrit-ui/app/elements/change/gr-file-list/gr-file-list.html
+++ b/polygerrit-ui/app/elements/change/gr-file-list/gr-file-list.html
@@ -59,12 +59,19 @@
       :host(.editMode) .showOnEdit {
         display: initial;
       }
+      .invisible {
+        visibility: hidden;
+      }
       .controlRow {
         align-items: center;
         display: flex;
         height: 2.25em;
         justify-content: center;
       }
+      .controlRow.invisible,
+      .show-hide.invisible {
+        display: none;
+      }
       .reviewed,
       .status {
         align-items: center;
@@ -119,12 +126,6 @@
       .stats {
         min-width: 7em;
       }
-      .invisible {
-        display: none;
-      }
-      .hideContent {
-        visibility: hidden;
-      }
       .row:not(.header) .stats,
       .total-stats {
         font-family: var(--monospace-font-family);
@@ -256,7 +257,7 @@
         <div class="stickyArea">
           <div class$="file-row row [[_computePathClass(file.__path, _expandedFilePaths.*)]]"
               data-path$="[[file.__path]]" tabindex="-1">
-              <div class$="[[_computeClass('status', file.__path, 'true')]]"
+              <div class$="[[_computeClass('status', file.__path)]]"
                   tabindex="0"
                   aria-label$="[[_computeFileStatusLabel(file.status)]]">
               [[_computeFileStatus(file.status)]]
diff --git a/polygerrit-ui/app/elements/change/gr-file-list/gr-file-list.js b/polygerrit-ui/app/elements/change/gr-file-list/gr-file-list.js
index 9873fea..c9989cc 100644
--- a/polygerrit-ui/app/elements/change/gr-file-list/gr-file-list.js
+++ b/polygerrit-ui/app/elements/change/gr-file-list/gr-file-list.js
@@ -696,12 +696,11 @@
     /**
      * @param {string} baseClass
      * @param {string} path
-     * @param {boolean=} opt_keepSpace
      */
-    _computeClass(baseClass, path, opt_keepSpace) {
+    _computeClass(baseClass, path) {
       const classes = [baseClass];
       if (path === this.COMMIT_MESSAGE_PATH || path === this.MERGE_LIST_PATH) {
-        classes.push(!opt_keepSpace ? 'invisible' : 'hideContent');
+        classes.push('invisible');
       }
       return classes.join(' ');
     },
diff --git a/polygerrit-ui/app/elements/change/gr-file-list/gr-file-list_test.html b/polygerrit-ui/app/elements/change/gr-file-list/gr-file-list_test.html
index a8588ee..90ec46f 100644
--- a/polygerrit-ui/app/elements/change/gr-file-list/gr-file-list_test.html
+++ b/polygerrit-ui/app/elements/change/gr-file-list/gr-file-list_test.html
@@ -610,8 +610,6 @@
       assert.equal(element._computeClass('clazz', '/foo/bar/baz'), 'clazz');
       assert.equal(element._computeClass('clazz', '/COMMIT_MSG'),
           'clazz invisible');
-      assert.equal(element._computeClass('clazz', '/COMMIT_MSG', true),
-          'clazz hideContent');
     });
 
     test('file review status', () => {
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 ed69a73..fcb4ecc 100644
--- a/polygerrit-ui/app/elements/core/gr-navigation/gr-navigation.html
+++ b/polygerrit-ui/app/elements/core/gr-navigation/gr-navigation.html
@@ -375,8 +375,19 @@
        */
       getUrlForOwner(owner) {
         return this._getUrlFor({
+          view: Gerrit.Nav.View.SEARCH,
+          owner,
+        });
+      },
+
+      /**
+       * @param {string} user The name of the user.
+       * @return {string}
+       */
+      getUrlForUserDashboard(user) {
+        return this._getUrlFor({
           view: Gerrit.Nav.View.DASHBOARD,
-          user: owner,
+          user,
         });
       },
 
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 00735cf..c13d69f 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
@@ -82,6 +82,16 @@
         </span>
       </section>
       <section>
+        <span class="title">Line numbers</span>
+        <span class="value">
+          <input
+              id="showLineNumbers"
+              type="checkbox"
+              checked$="[[editPrefs.hide_line_numbers]]"
+              on-change="_handleLineNumbersChanged">
+        </span>
+      </section>
+      <section>
         <span class="title">Match brackets</span>
         <span class="value">
           <input
diff --git a/polygerrit-ui/app/elements/settings/gr-edit-preferences/gr-edit-preferences.js b/polygerrit-ui/app/elements/settings/gr-edit-preferences/gr-edit-preferences.js
index 30752aa..0a791fd 100644
--- a/polygerrit-ui/app/elements/settings/gr-edit-preferences/gr-edit-preferences.js
+++ b/polygerrit-ui/app/elements/settings/gr-edit-preferences/gr-edit-preferences.js
@@ -49,6 +49,11 @@
       this._handleEditPrefsChanged();
     },
 
+    _handleLineNumbersChanged() {
+      this.set('editPrefs.hide_line_numbers', !this.$.showLineNumbers.checked);
+      this._handleEditPrefsChanged();
+    },
+
     _handleMatchBracketsChanged() {
       this.set('editPrefs.match_brackets', this.$.showMatchBrackets.checked);
       this._handleEditPrefsChanged();
diff --git a/polygerrit-ui/app/elements/settings/gr-edit-preferences/gr-edit-preferences_test.html b/polygerrit-ui/app/elements/settings/gr-edit-preferences/gr-edit-preferences_test.html
index e91290f6..118ee4f 100644
--- a/polygerrit-ui/app/elements/settings/gr-edit-preferences/gr-edit-preferences_test.html
+++ b/polygerrit-ui/app/elements/settings/gr-edit-preferences/gr-edit-preferences_test.html
@@ -93,6 +93,8 @@
           .firstElementChild.checked, editPreferences.syntax_highlighting);
       assert.equal(valueOf('Show tabs', 'editPreferences')
           .firstElementChild.checked, editPreferences.show_tabs);
+      assert.equal(valueOf('Line numbers', 'editPreferences')
+          .firstElementChild.checked, editPreferences.hide_line_numbers);
       assert.equal(valueOf('Match brackets', 'editPreferences')
           .firstElementChild.checked, editPreferences.match_brackets);
       assert.equal(valueOf('Line wrapping', 'editPreferences')
diff --git a/polygerrit-ui/app/elements/shared/gr-tooltip/gr-tooltip.html b/polygerrit-ui/app/elements/shared/gr-tooltip/gr-tooltip.html
index 1744d28..fa6c1e2 100644
--- a/polygerrit-ui/app/elements/shared/gr-tooltip/gr-tooltip.html
+++ b/polygerrit-ui/app/elements/shared/gr-tooltip/gr-tooltip.html
@@ -27,7 +27,7 @@
         background-color: var(--tooltip-background-color, #333);
         box-shadow: 0 1px 3px rgba(0, 0, 0, .3);
         color: #fff;
-        font-size: .75rem;
+        font-size: var(--font-size-small);
         position: absolute;
         z-index: 1000;
         max-width: var(--tooltip-max-width);
diff --git a/polygerrit-ui/app/styles/shared-styles.html b/polygerrit-ui/app/styles/shared-styles.html
index 0c80cdc..7f7b441 100644
--- a/polygerrit-ui/app/styles/shared-styles.html
+++ b/polygerrit-ui/app/styles/shared-styles.html
@@ -75,7 +75,7 @@
         font-family: var(--font-family-bold);
       }
       h3 {
-        font-size: var(--font-size-small);
+        font-size: 1.17em;
         font-family: var(--font-family-bold);
       }
       iron-icon {
@@ -88,13 +88,13 @@
         display: none !important;
       }
       .separator {
-        background-color: rgba(0, 0, 0, .3);
+        border-left: 1px solid rgba(0, 0, 0, .3);
         height: 20px;
         margin: 0 8px;
-        width: 1px;
+
       }
       .separator.transparent {
-        background-color: transparent;
+        border-color: transparent;
       }
       paper-toggle-button {
         --paper-toggle-button-checked-bar-color: var(--color-link);