ChangeScreen2: Show download commands defined by plugins

The download commands are returned as part of the FetchInfo of a
revision, but only if the download commands are requested by setting
the option DOWNLOAD_COMMANDS.

This allows to completely customize the download commands on a Gerrit
installation.

This change only effects the download commands that are shown on
ChangeScreen2.

For non-anonymous download commands the username is now again included
into the displayed download commands. Also the HTTP scheme and the
Anonymous HTTP scheme can be used at the same time.

With this change the patch download (base64 & zipped) is still
hard-coded in Gerrit core and does not come from a plugin.

Bug: issue 2116
Change-Id: I8fb21fdeb1a98548ce9027655e1b5e467ee2d27e
Signed-off-by: Edwin Kempin <edwin.kempin@sap.com>
diff --git a/Documentation/dev-plugins.txt b/Documentation/dev-plugins.txt
index 2e74b7c..8ab906b 100644
--- a/Documentation/dev-plugins.txt
+++ b/Documentation/dev-plugins.txt
@@ -982,6 +982,20 @@
 }
 ----
 
+[[download-commands]]
+Download Commands
+-----------------
+
+Gerrit offers commands for downloading changes using different
+download schemes (e.g. for downloading via different network
+protocols). Plugins can contribute download schemes and download
+commands by implementing
+`com.google.gerrit.extensions.config.DownloadScheme` and
+`com.google.gerrit.extensions.config.DownloadCommand`.
+
+The download schemes and download commands which are used most often
+are provided by the Gerrit core plugin `download-commands`.
+
 [[documentation]]
 Documentation
 -------------
diff --git a/Documentation/rest-api-changes.txt b/Documentation/rest-api-changes.txt
index 8c1fa16..9404d00 100644
--- a/Documentation/rest-api-changes.txt
+++ b/Documentation/rest-api-changes.txt
@@ -171,6 +171,13 @@
 * `ALL_REVISIONS`: describe all revisions, not just current.
 --
 
+[[download_commands]]
+--
+* `DOWNLOAD_COMMANDS`: include the `commands` field in the
+  link:#fetch-info[FetchInfo] for revisions. Only valid when the
+  `CURRENT_REVISION` or `ALL_REVISIONS` option is selected.
+--
+
 [[draft_comments]]
 --
 * `DRAFT_COMMENTS`: include the `has_draft_comments` field for
@@ -233,7 +240,7 @@
 
 .Request
 ----
-  GET /changes/?q=97&o=CURRENT_REVISION&o=CURRENT_COMMIT&o=CURRENT_FILES HTTP/1.0
+  GET /changes/?q=97&o=CURRENT_REVISION&o=CURRENT_COMMIT&o=CURRENT_FILES&o=DOWNLOAD_COMMANDS HTTP/1.0
 ----
 
 .Response
@@ -267,11 +274,33 @@
           "fetch": {
             "git": {
               "url": "git://localhost/gerrit",
-              "ref": "refs/changes/97/97/1"
+              "ref": "refs/changes/97/97/1",
+              "commands": {
+                "Checkout": "git fetch git://localhost/gerrit refs/changes/97/97/1 \u0026\u0026 git checkout FETCH_HEAD",
+                "Cherry-Pick": "git fetch git://localhost/gerrit refs/changes/97/97/1 \u0026\u0026 git cherry-pick FETCH_HEAD",
+                "Format-Patch": "git fetch git://localhost/gerrit refs/changes/97/97/1 \u0026\u0026 git format-patch -1 --stdout FETCH_HEAD",
+                "Pull": "git pull git://localhost/gerrit refs/changes/97/97/1"
+              }
             },
             "http": {
-              "url": "http://127.0.0.1:8080/gerrit",
-              "ref": "refs/changes/97/97/1"
+              "url": "http://myuser@127.0.0.1:8080/gerrit",
+              "ref": "refs/changes/97/97/1",
+              "commands": {
+                "Checkout": "git fetch http://myuser@127.0.0.1:8080/gerrit refs/changes/97/97/1 \u0026\u0026 git checkout FETCH_HEAD",
+                "Cherry-Pick": "git fetch http://myuser@127.0.0.1:8080/gerrit refs/changes/97/97/1 \u0026\u0026 git cherry-pick FETCH_HEAD",
+                "Format-Patch": "git fetch http://myuser@127.0.0.1:8080/gerrit refs/changes/97/97/1 \u0026\u0026 git format-patch -1 --stdout FETCH_HEAD",
+                "Pull": "git pull http://myuser@127.0.0.1:8080/gerrit refs/changes/97/97/1"
+              }
+            },
+            "ssh": {
+              "url": "ssh://myuser@*:29418/gerrit",
+              "ref": "refs/changes/97/97/1",
+              "commands": {
+                "Checkout": "git fetch ssh://myuser@*:29418/gerrit refs/changes/97/97/1 \u0026\u0026 git checkout FETCH_HEAD",
+                "Cherry-Pick": "git fetch ssh://myuser@*:29418/gerrit refs/changes/97/97/1 \u0026\u0026 git cherry-pick FETCH_HEAD",
+                "Format-Patch": "git fetch ssh://myuser@*:29418/gerrit refs/changes/97/97/1 \u0026\u0026 git format-patch -1 --stdout FETCH_HEAD",
+                "Pull": "git pull ssh://myuser@*:29418/gerrit refs/changes/97/97/1"
+              }
             }
           },
           "commit": {
@@ -2949,11 +2978,15 @@
 The `FetchInfo` entity contains information about how to fetch a patch
 set via a certain protocol.
 
-[options="header",width="50%",cols="1,6"]
+[options="header",width="50%",cols="1,^1,5"]
 |==========================
-|Field Name    |Description
-|`url`         |The URL of the project.
-|`ref`         |The ref of the patch set.
+|Field Name    ||Description
+|`url`         ||The URL of the project.
+|`ref`         ||The ref of the patch set.
+|`commands`    |optional|
+The download commands for this patch set as a map that maps the command
+names to the commands. +
+Only set if link:#download_commands[download commands] are requested.
 |==========================
 
 [[file-info]]
diff --git a/gerrit-common/src/main/java/com/google/gerrit/common/changes/ListChangesOption.java b/gerrit-common/src/main/java/com/google/gerrit/common/changes/ListChangesOption.java
index ff80b43..276c332 100644
--- a/gerrit-common/src/main/java/com/google/gerrit/common/changes/ListChangesOption.java
+++ b/gerrit-common/src/main/java/com/google/gerrit/common/changes/ListChangesOption.java
@@ -46,7 +46,10 @@
   REVIEWED(11),
 
   /** Include draft comments for the caller. */
-  DRAFT_COMMENTS(12);
+  DRAFT_COMMENTS(12),
+
+  /** Include download commands for the caller. */
+  DOWNLOAD_COMMANDS(13);
 
   private final int value;
 
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/GerritCss.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/GerritCss.java
index 0cb3fa2..9842049 100644
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/GerritCss.java
+++ b/gerrit-gwtui/src/main/java/com/google/gerrit/client/GerritCss.java
@@ -92,6 +92,12 @@
   String diffTextHunkHeader();
   String diffTextINSERT();
   String diffTextNoLF();
+  String downloadBox();
+  String downloadBoxTable();
+  String downloadBoxTableCommandColumn();
+  String downloadBoxSpacer();
+  String downloadBoxScheme();
+  String downloadBoxCopyLabel();
   String downloadLink();
   String downloadLinkCopyLabel();
   String downloadLinkHeader();
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/change/ChangeScreen2.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/change/ChangeScreen2.java
index 9ba04dd..5f6b4e9 100644
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/change/ChangeScreen2.java
+++ b/gerrit-gwtui/src/main/java/com/google/gerrit/client/change/ChangeScreen2.java
@@ -280,11 +280,8 @@
   }
 
   private void initDownloadAction(ChangeInfo info, String revision) {
-    downloadAction = new DownloadAction(
-        info.legacy_id(),
-        info.project(),
-        info.revision(revision),
-        style, headerLine, download);
+    downloadAction =
+        new DownloadAction(info, revision, style, headerLine, download);
   }
 
   private void initProjectLink(ChangeInfo info) {
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/change/DownloadAction.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/change/DownloadAction.java
index 91ff99c..891cc10 100644
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/change/DownloadAction.java
+++ b/gerrit-gwtui/src/main/java/com/google/gerrit/client/change/DownloadAction.java
@@ -14,10 +14,7 @@
 
 package com.google.gerrit.client.change;
 
-import com.google.gerrit.client.changes.ChangeInfo.FetchInfo;
-import com.google.gerrit.client.changes.ChangeInfo.RevisionInfo;
-import com.google.gerrit.client.rpc.NativeMap;
-import com.google.gerrit.reviewdb.client.Change;
+import com.google.gerrit.client.changes.ChangeInfo;
 import com.google.gerrit.reviewdb.client.PatchSet;
 import com.google.gwt.user.client.ui.UIObject;
 import com.google.gwt.user.client.ui.Widget;
@@ -26,20 +23,15 @@
   private final DownloadBox downloadBox;
 
   DownloadAction(
-      Change.Id changeId,
-      String project,
-      RevisionInfo revision,
+      ChangeInfo info,
+      String revision,
       ChangeScreen2.Style style,
       UIObject relativeTo,
       Widget downloadButton) {
     super(style, relativeTo, downloadButton);
-    this.downloadBox = new DownloadBox(
-        revision.has_fetch()
-            ? revision.fetch()
-            : NativeMap.<FetchInfo> create(),
-        revision.name(),
-        project,
-        new PatchSet.Id(changeId, revision._number()));
+    this.downloadBox = new DownloadBox(info, revision,
+        new PatchSet.Id(info.legacy_id(),
+            info.revision(revision)._number()));
   }
 
   Widget getWidget() {
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/change/DownloadBox.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/change/DownloadBox.java
index ae29801..7c57620 100644
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/change/DownloadBox.java
+++ b/gerrit-gwtui/src/main/java/com/google/gerrit/client/change/DownloadBox.java
@@ -14,102 +14,107 @@
 
 package com.google.gerrit.client.change;
 
-import static com.google.gerrit.reviewdb.client.AccountGeneralPreferences.DownloadScheme.REPO_DOWNLOAD;
-
 import com.google.gerrit.client.Gerrit;
 import com.google.gerrit.client.account.AccountApi;
+import com.google.gerrit.client.changes.ChangeApi;
+import com.google.gerrit.client.changes.ChangeInfo;
 import com.google.gerrit.client.changes.ChangeInfo.FetchInfo;
+import com.google.gerrit.client.changes.ChangeList;
 import com.google.gerrit.client.rpc.NativeMap;
+import com.google.gerrit.client.rpc.Natives;
 import com.google.gerrit.client.rpc.RestApi;
+import com.google.gerrit.common.changes.ListChangesOption;
 import com.google.gerrit.reviewdb.client.AccountGeneralPreferences;
 import com.google.gerrit.reviewdb.client.AccountGeneralPreferences.DownloadScheme;
 import com.google.gerrit.reviewdb.client.PatchSet;
-import com.google.gwt.core.client.GWT;
 import com.google.gwt.core.client.JavaScriptObject;
-import com.google.gwt.dom.client.AnchorElement;
-import com.google.gwt.dom.client.Element;
 import com.google.gwt.event.dom.client.ChangeEvent;
-import com.google.gwt.uibinder.client.UiBinder;
-import com.google.gwt.uibinder.client.UiField;
-import com.google.gwt.uibinder.client.UiHandler;
+import com.google.gwt.event.dom.client.ChangeHandler;
 import com.google.gwt.user.client.rpc.AsyncCallback;
-import com.google.gwt.user.client.ui.Composite;
-import com.google.gwt.user.client.ui.HTMLPanel;
+import com.google.gwt.user.client.ui.Anchor;
+import com.google.gwt.user.client.ui.FlexTable;
+import com.google.gwt.user.client.ui.HorizontalPanel;
+import com.google.gwt.user.client.ui.InlineLabel;
 import com.google.gwt.user.client.ui.ListBox;
-import com.google.gwt.user.client.ui.UIObject;
+import com.google.gwt.user.client.ui.VerticalPanel;
+import com.google.gwt.user.client.ui.Widget;
 import com.google.gwtexpui.clippy.client.CopyableLabel;
 
-class DownloadBox extends Composite {
-  interface Binder extends UiBinder<HTMLPanel, DownloadBox> {}
-  private static final Binder uiBinder = GWT.create(Binder.class);
+import java.util.EnumSet;
 
-  private final NativeMap<FetchInfo> fetch;
+class DownloadBox extends VerticalPanel {
+  private final ChangeInfo change;
   private final String revision;
-  private final String project;
   private final PatchSet.Id psId;
+  private final FlexTable commandTable;
+  private final ListBox scheme;
+  private NativeMap<FetchInfo> fetch;
 
-  @UiField ListBox scheme;
-  @UiField CopyableLabel checkout;
-  @UiField CopyableLabel cherryPick;
-  @UiField CopyableLabel pull;
-  @UiField AnchorElement patchBase64;
-  @UiField AnchorElement patchZip;
-  @UiField Element repoSection;
-  @UiField CopyableLabel repoDownload;
-
-  DownloadBox(NativeMap<FetchInfo> fetch, String revision,
-      String project, PatchSet.Id psId) {
-    this.fetch = fetch;
+  DownloadBox(ChangeInfo change, String revision, PatchSet.Id psId) {
+    this.change = change;
     this.revision = revision;
-    this.project = project;
     this.psId = psId;
-    initWidget(uiBinder.createAndBindUi(this));
+    this.commandTable = new FlexTable();
+    this.scheme = new ListBox();
+    this.scheme.addChangeHandler(new ChangeHandler() {
+      @Override
+      public void onChange(ChangeEvent event) {
+        renderCommands();
+        if (Gerrit.isSignedIn()) {
+          saveScheme();
+        }
+      }
+    });
+
+    setStyleName(Gerrit.RESOURCES.css().downloadBox());
+    commandTable.setStyleName(Gerrit.RESOURCES.css().downloadBoxTable());
+    scheme.setStyleName(Gerrit.RESOURCES.css().downloadBoxScheme());
+    add(commandTable);
   }
 
   @Override
   protected void onLoad() {
-    if (scheme.getItemCount() == 0) {
-      renderScheme(fetch);
-    }
-  }
+    if (fetch == null) {
+      RestApi call = ChangeApi.detail(change.legacy_id().get());
+      ChangeList.addOptions(call, EnumSet.of(
+          revision.equals(change.current_revision())
+             ? ListChangesOption.CURRENT_REVISION
+             : ListChangesOption.ALL_REVISIONS,
+          ListChangesOption.DOWNLOAD_COMMANDS));
+      call.get(new AsyncCallback<ChangeInfo>() {
+        @Override
+        public void onSuccess(ChangeInfo result) {
+          fetch = result.revision(revision).fetch();
+          renderScheme();
+        }
 
-  @UiHandler("scheme")
-  void onScheme(ChangeEvent event) {
-    renderCommands();
-
-    if (Gerrit.isSignedIn()) {
-      saveScheme();
+        @Override
+        public void onFailure(Throwable caught) {
+        }
+      });
     }
   }
 
   private void renderCommands() {
-    FetchInfo info = fetch.get(scheme.getValue(scheme.getSelectedIndex()));
-    checkout(info);
-    cherryPick(info);
-    pull(info);
-    patch(info);
-    repo(info);
+    commandTable.removeAllRows();
+
+    if (scheme.getItemCount() > 0) {
+      FetchInfo fetchInfo =
+          fetch.get(scheme.getValue(scheme.getSelectedIndex()));
+      for (String commandName : Natives.keys(fetchInfo.commands())) {
+        CopyableLabel copyLabel =
+            new CopyableLabel(fetchInfo.command(commandName));
+        copyLabel.setStyleName(Gerrit.RESOURCES.css().downloadBoxCopyLabel());
+        insertCommand(commandName, copyLabel);
+      }
+    }
+    insertPatch();
+    insertCommand(null, scheme);
   }
 
-  private void checkout(FetchInfo info) {
-    checkout.setText(
-        "git fetch " + info.url() + " " + info.ref()
-        + " && git checkout FETCH_HEAD");
-  }
-
-  private void cherryPick(FetchInfo info) {
-    cherryPick.setText(
-        "git fetch " + info.url() + " " + info.ref()
-        + " && git cherry-pick FETCH_HEAD");
-  }
-
-  private void pull(FetchInfo info) {
-    pull.setText("git pull " + info.url() + " " + info.ref());
-  }
-
-  private void patch(FetchInfo info) {
+  private void insertPatch() {
     String id = revision.substring(0, 7);
-    patchBase64.setInnerText(id + ".diff.base64");
+    Anchor patchBase64 = new Anchor(id + ".diff.base64");
     patchBase64.setHref(new RestApi("/changes/")
       .id(psId.getParentKey().get())
       .view("revisions")
@@ -118,7 +123,7 @@
       .addParameterTrue("download")
       .url());
 
-    patchZip.setInnerText(id + ".diff.zip");
+    Anchor patchZip = new Anchor(id + ".diff.zip");
     patchZip.setHref(new RestApi("/changes/")
       .id(psId.getParentKey().get())
       .view("revisions")
@@ -126,45 +131,52 @@
       .view("patch")
       .addParameterTrue("zip")
       .url());
+
+    HorizontalPanel p = new HorizontalPanel();
+    p.add(patchBase64);
+    InlineLabel spacer = new InlineLabel("|");
+    spacer.setStyleName(Gerrit.RESOURCES.css().downloadBoxSpacer());
+    p.add(spacer);
+    p.add(patchZip);
+    insertCommand("Patch-File", p);
   }
 
-  private void repo(FetchInfo info) {
-    if (Gerrit.getConfig().getDownloadSchemes().contains(REPO_DOWNLOAD)) {
-      UIObject.setVisible(repoSection, true);
-      repoDownload.setText("repo download "
-          + project
-          + " " + psId.getParentKey().get() + "/" + psId.get());
+  private void insertCommand(String commandName, Widget w) {
+    int row = commandTable.getRowCount();
+    commandTable.insertRow(row);
+    commandTable.getCellFormatter().addStyleName(row, 0,
+        Gerrit.RESOURCES.css().downloadBoxTableCommandColumn());
+    if (commandName != null) {
+      commandTable.setText(row, 0, commandName);
+    }
+    if (w != null) {
+      commandTable.setWidget(row, 1, w);
     }
   }
 
-  private void renderScheme(NativeMap<FetchInfo> fetch) {
+  private void renderScheme() {
     for (String id : fetch.keySet()) {
-      FetchInfo info = fetch.get(id);
-      String u = info.url();
-      int css = u.indexOf("://");
-      if (css > 0) {
-        int s = u.indexOf('/', css + 3);
-        if (s > 0) {
-          u = u.substring(0, s + 1);
-        }
-      }
-      scheme.addItem(u, id);
+      scheme.addItem(id);
     }
-    if (scheme.getItemCount() == 1) {
-      scheme.setSelectedIndex(0);
+    if (scheme.getItemCount() == 0) {
       scheme.setVisible(false);
     } else {
-      int select = 0;
-      String find = getUserPreference();
-      if (find != null) {
-        for (int i = 0; i < scheme.getItemCount(); i++) {
-          if (find.equals(scheme.getValue(i))) {
-            select = i;
-            break;
+      if (scheme.getItemCount() == 1) {
+        scheme.setSelectedIndex(0);
+        scheme.setVisible(false);
+      } else {
+        int select = 0;
+        String find = getUserPreference();
+        if (find != null) {
+          for (int i = 0; i < scheme.getItemCount(); i++) {
+            if (find.equals(scheme.getValue(i))) {
+              select = i;
+              break;
+            }
           }
         }
+        scheme.setSelectedIndex(select);
       }
-      scheme.setSelectedIndex(select);
     }
     renderCommands();
   }
@@ -177,11 +189,14 @@
         switch (pref) {
           case ANON_GIT:
             return "git";
-          case HTTP:
           case ANON_HTTP:
+            return "anonymous http";
+          case HTTP:
             return "http";
           case SSH:
             return "ssh";
+          case REPO_DOWNLOAD:
+            return "repo";
           default:
             return null;
         }
@@ -216,10 +231,14 @@
     String id = scheme.getValue(scheme.getSelectedIndex());
     if ("git".equals(id)) {
       return DownloadScheme.ANON_GIT;
+    } else if ("anonymous http".equals(id)) {
+      return DownloadScheme.ANON_HTTP;
     } else if ("http".equals(id)) {
       return DownloadScheme.HTTP;
     } else if ("ssh".equals(id)) {
       return DownloadScheme.SSH;
+    } else if ("repo".equals(id)) {
+      return DownloadScheme.REPO_DOWNLOAD;
     }
     return null;
   }
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/change/DownloadBox.ui.xml b/gerrit-gwtui/src/main/java/com/google/gerrit/client/change/DownloadBox.ui.xml
deleted file mode 100644
index 4490982..0000000
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/change/DownloadBox.ui.xml
+++ /dev/null
@@ -1,98 +0,0 @@
-<?xml version="1.0" encoding="UTF-8"?>
-<!--
-Copyright (C) 2013 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.
--->
-<ui:UiBinder
-    xmlns:ui='urn:ui:com.google.gwt.uibinder'
-    xmlns:g='urn:import:com.google.gwt.user.client.ui'
-    xmlns:c='urn:import:com.google.gwtexpui.clippy.client'>
-  <ui:with field='res' type='com.google.gerrit.client.change.Resources'/>
-  <ui:style>
-    @external .gwt-TextBox;
-
-    .downloadBox {
-      min-width: 580px;
-      margin: 5px;
-    }
-
-    .table {
-      border-spacing: 0;
-      width: 100%;
-    }
-    .table th {
-      text-align: left;
-      font-weight: normal;
-      white-space: nowrap;
-      max-height: 18px;
-      width: 80px;
-      padding-right: 5px;
-    }
-
-    .scheme {
-      float: right;
-    }
-
-    .clippy {
-      font-size: smaller;
-      font-family: monospace;
-    }
-    .clippy span {
-      width: 500px;
-      white-space: nowrap;
-      display: inline-block;
-      overflow: hidden;
-      text-overflow: ellipsis;
-    }
-    .clippy .gwt-TextBox {
-      padding: 0;
-      margin: 0;
-      border: 0;
-      max-height: 18px;
-      width: 500px;
-    }
-    .clippy div {
-      float: right;
-    }
-  </ui:style>
-  <g:HTMLPanel styleName='{style.downloadBox}'>
-    <table class='{style.table}'>
-      <tr>
-        <th><ui:msg>Checkout</ui:msg></th>
-        <td><c:CopyableLabel ui:field='checkout' styleName='{style.clippy}'/></td>
-      </tr>
-      <tr>
-        <th><ui:msg>Cherry Pick</ui:msg></th>
-        <td><c:CopyableLabel ui:field='cherryPick' styleName='{style.clippy}'/></td>
-      </tr>
-      <tr>
-        <th><ui:msg>Pull</ui:msg></th>
-        <td><c:CopyableLabel ui:field='pull' styleName='{style.clippy}'/></td>
-      </tr>
-      <tr>
-        <th><ui:msg>Patch File</ui:msg></th>
-        <td><a ui:field='patchZip'/> | <a ui:field='patchBase64'/></td>
-      </tr>
-      <tr ui:field='repoSection' style='display: NONE' aria-hidden='true'>
-        <th><ui:msg>repo</ui:msg></th>
-        <td><c:CopyableLabel ui:field='repoDownload' styleName='{style.clippy}'/></td>
-      </tr>
-      <tr>
-        <td colspan='2'>
-          <g:ListBox ui:field='scheme' styleName='{style.scheme}'/>
-        </td>
-      </tr>
-    </table>
-  </g:HTMLPanel>
-</ui:UiBinder>
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/changes/ChangeInfo.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/changes/ChangeInfo.java
index d46b7ca..f82abee 100644
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/changes/ChangeInfo.java
+++ b/gerrit-gwtui/src/main/java/com/google/gerrit/client/changes/ChangeInfo.java
@@ -232,6 +232,8 @@
   public static class FetchInfo extends JavaScriptObject {
     public final native String url() /*-{ return this.url }-*/;
     public final native String ref() /*-{ return this.ref }-*/;
+    public final native NativeMap<NativeString> commands() /*-{ return this.commands }-*/;
+    public final native String command(String n) /*-{ return this.commands[n]; }-*/;
 
     protected FetchInfo () {
     }
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/gerrit.css b/gerrit-gwtui/src/main/java/com/google/gerrit/client/gerrit.css
index 651ab51..5e604fb 100644
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/gerrit.css
+++ b/gerrit-gwtui/src/main/java/com/google/gerrit/client/gerrit.css
@@ -1090,6 +1090,51 @@
   padding-left: 4em;
   border-left: none;
 }
+
+.downloadBox {
+  min-width: 580px;
+  margin: 5px;
+}
+.downloadBoxTable {
+  border-spacing: 0;
+  width: 100%;
+}
+.downloadBoxTableCommandColumn {
+  text-align: left;
+  font-weight: normal;
+  white-space: nowrap;
+  max-height: 18px;
+  width: 80px;
+  padding-right: 5px;
+}
+.downloadBoxSpacer {
+  margin-left: 5px;
+  margin-right: 5px;
+}
+.downloadBoxScheme {
+  float: right;
+}
+.downloadBoxCopyLabel {
+  font-size: smaller;
+  font-family: monospace;
+}
+.downloadBoxCopyLabel span {
+  width: 500px;
+  white-space: nowrap;
+  display: inline-block;
+  overflow: hidden;
+  text-overflow: ellipsis;
+}
+.downloadBoxCopyLabel .gwt-TextBox {
+  padding: 0;
+  margin: 0;
+  border: 0;
+  max-height: 18px;
+  width: 500px;
+}
+.downloadBoxCopyLabel div {
+  float: right;
+}
 td.downloadLinkListCell {
   padding: 0px;
 }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/change/ChangeJson.java b/gerrit-server/src/main/java/com/google/gerrit/server/change/ChangeJson.java
index c7f6d16..d6ecc5e 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/change/ChangeJson.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/change/ChangeJson.java
@@ -23,6 +23,7 @@
 import static com.google.gerrit.common.changes.ListChangesOption.CURRENT_REVISION;
 import static com.google.gerrit.common.changes.ListChangesOption.DETAILED_ACCOUNTS;
 import static com.google.gerrit.common.changes.ListChangesOption.DETAILED_LABELS;
+import static com.google.gerrit.common.changes.ListChangesOption.DOWNLOAD_COMMANDS;
 import static com.google.gerrit.common.changes.ListChangesOption.DRAFT_COMMENTS;
 import static com.google.gerrit.common.changes.ListChangesOption.LABELS;
 import static com.google.gerrit.common.changes.ListChangesOption.MESSAGES;
@@ -30,7 +31,6 @@
 
 import com.google.common.base.Joiner;
 import com.google.common.base.Objects;
-import com.google.common.base.Strings;
 import com.google.common.collect.HashBasedTable;
 import com.google.common.collect.HashMultimap;
 import com.google.common.collect.ImmutableList;
@@ -50,6 +50,8 @@
 import com.google.gerrit.common.data.Permission;
 import com.google.gerrit.common.data.PermissionRange;
 import com.google.gerrit.common.data.SubmitRecord;
+import com.google.gerrit.extensions.config.DownloadCommand;
+import com.google.gerrit.extensions.config.DownloadScheme;
 import com.google.gerrit.extensions.registration.DynamicMap;
 import com.google.gerrit.extensions.restapi.RestView;
 import com.google.gerrit.extensions.restapi.Url;
@@ -69,8 +71,6 @@
 import com.google.gerrit.server.IdentifiedUser;
 import com.google.gerrit.server.account.AccountInfo;
 import com.google.gerrit.server.actions.ActionInfo;
-import com.google.gerrit.server.config.CanonicalWebUrl;
-import com.google.gerrit.server.config.GerritServerConfig;
 import com.google.gerrit.server.extensions.webui.UiActions;
 import com.google.gerrit.server.git.LabelNormalizer;
 import com.google.gerrit.server.patch.PatchListNotAvailableException;
@@ -79,14 +79,11 @@
 import com.google.gerrit.server.project.ChangeControl;
 import com.google.gerrit.server.project.NoSuchChangeException;
 import com.google.gerrit.server.query.change.ChangeData;
-import com.google.gerrit.server.ssh.SshAdvertisedAddresses;
 import com.google.gwtorm.server.OrmException;
 import com.google.gwtorm.server.ResultSet;
 import com.google.inject.Inject;
 import com.google.inject.Provider;
-import com.google.inject.Singleton;
 
-import org.eclipse.jgit.lib.Config;
 import org.slf4j.Logger;
 import org.slf4j.LoggerFactory;
 
@@ -120,30 +117,6 @@
         }
       };
 
-  @Singleton
-  static class Urls {
-    final String git;
-    final String http;
-    final String ssh;
-
-    @Inject
-    Urls(@GerritServerConfig Config cfg,
-        @SshAdvertisedAddresses List<String> sshAddresses) {
-      this.git = ensureSlash(cfg.getString("gerrit", null, "canonicalGitUrl"));
-      this.http = ensureSlash(cfg.getString("gerrit", null, "gitHttpUrl"));
-      this.ssh = !sshAddresses.isEmpty()
-          ? ensureSlash("ssh://" + sshAddresses.get(0))
-          : null;
-    }
-
-    private static String ensureSlash(String in) {
-      if (in != null && !in.endsWith("/")) {
-        return in + "/";
-      }
-      return in;
-    }
-  }
-
   private final Provider<ReviewDb> db;
   private final LabelNormalizer labelNormalizer;
   private final Provider<CurrentUser> userProvider;
@@ -153,8 +126,8 @@
   private final PatchSetInfoFactory patchSetInfoFactory;
   private final FileInfoJson fileInfoJson;
   private final AccountInfo.Loader.Factory accountLoaderFactory;
-  private final Provider<String> urlProvider;
-  private final Urls urls;
+  private final DynamicMap<DownloadScheme> downloadSchemes;
+  private final DynamicMap<DownloadCommand> downloadCommands;
   private final DynamicMap<RestView<ChangeResource>> changes;
   private final Revisions revisions;
 
@@ -175,8 +148,8 @@
       PatchSetInfoFactory psi,
       FileInfoJson fileInfoJson,
       AccountInfo.Loader.Factory ailf,
-      @CanonicalWebUrl Provider<String> curl,
-      Urls urls,
+      DynamicMap<DownloadScheme> downloadSchemes,
+      DynamicMap<DownloadCommand> downloadCommands,
       DynamicMap<RestView<ChangeResource>> changes,
       Revisions revisions) {
     this.db = db;
@@ -188,8 +161,8 @@
     this.patchSetInfoFactory = psi;
     this.fileInfoJson = fileInfoJson;
     this.accountLoaderFactory = ailf;
-    this.urlProvider = curl;
-    this.urls = urls;
+    this.downloadSchemes = downloadSchemes;
+    this.downloadCommands = downloadCommands;
     this.changes = changes;
     this.revisions = revisions;
 
@@ -872,28 +845,37 @@
   private Map<String, FetchInfo> makeFetchMap(ChangeData cd, PatchSet in)
       throws OrmException {
     Map<String, FetchInfo> r = Maps.newLinkedHashMap();
-    String refName = in.getRefName();
-    ChangeControl ctl = control(cd);
-    if (ctl != null && ctl.forUser(anonymous).isPatchVisible(in, db.get())) {
-      if (urls.git != null) {
-        r.put("git", new FetchInfo(urls.git
-            + cd.change(db).getProject().get(), refName));
+
+    for (DynamicMap.Entry<DownloadScheme> e : downloadSchemes) {
+      String schemeName = e.getExportName();
+      DownloadScheme scheme = e.getProvider().get();
+      if (!scheme.isEnabled()
+          || (scheme.isAuthRequired() && !userProvider.get().isIdentifiedUser())) {
+        continue;
       }
-    }
-    if (urls.http != null) {
-      r.put("http", new FetchInfo(urls.http
-          + cd.change(db).getProject().get(), refName));
-    } else {
-      String http = urlProvider.get();
-      if (!Strings.isNullOrEmpty(http)) {
-        r.put("http", new FetchInfo(http
-            + cd.change(db).getProject().get(), refName));
+
+      ChangeControl ctl = control(cd);
+      if (!scheme.isAuthRequired()
+          && !ctl.forUser(anonymous).isPatchVisible(in, db.get())) {
+        continue;
       }
-    }
-    if (urls.ssh != null) {
-      r.put("ssh", new FetchInfo(
-          urls.ssh + cd.change(db).getProject().get(),
-          refName));
+
+      String projectName = ctl.getProject().getNameKey().get();
+      String url = scheme.getUrl(projectName);
+      String refName = in.getRefName();
+      FetchInfo fetchInfo = new FetchInfo(url, refName);
+      r.put(schemeName, fetchInfo);
+
+      if (has(DOWNLOAD_COMMANDS)) {
+        for (DynamicMap.Entry<DownloadCommand> e2 : downloadCommands) {
+          String commandName = e2.getExportName();
+          DownloadCommand command = e2.getProvider().get();
+          String c = command.getCommand(scheme, projectName, refName);
+          if (c != null) {
+            fetchInfo.addCommand(commandName, c);
+          }
+        }
+      }
     }
 
     return r;
@@ -960,11 +942,19 @@
   static class FetchInfo {
     String url;
     String ref;
+    Map<String, String> commands;
 
     FetchInfo(String url, String ref) {
       this.url = url;
       this.ref = ref;
     }
+
+    void addCommand(String name, String command) {
+      if (commands == null) {
+        commands = Maps.newTreeMap();
+      }
+      commands.put(name, command);
+    }
   }
 
   static class GitPerson {