James Moger
2013-05-07 ffbf03175ba1154ba5984d7c473cf1ac4130c043
Uber-cool repository panel overhaul
3 files added
11 files modified
559 ■■■■ changed files
build.xml 6 ●●●●● patch | view | raw | blame | history
src/main/distrib/data/clientapps.json 42 ●●●●● patch | view | raw | blame | history
src/main/java/.gitignore 1 ●●●● patch | view | raw | blame | history
src/main/java/com/gitblit/GitBlit.java 49 ●●●●● patch | view | raw | blame | history
src/main/java/com/gitblit/GitFilter.java 2 ●●● patch | view | raw | blame | history
src/main/java/com/gitblit/PagesFilter.java 2 ●●● patch | view | raw | blame | history
src/main/java/com/gitblit/models/GitClientApplication.java 58 ●●●●● patch | view | raw | blame | history
src/main/java/com/gitblit/wicket/GitBlitWebApp.properties 3 ●●●● patch | view | raw | blame | history
src/main/java/com/gitblit/wicket/pages/SummaryPage.html 5 ●●●● patch | view | raw | blame | history
src/main/java/com/gitblit/wicket/pages/SummaryPage.java 13 ●●●●● patch | view | raw | blame | history
src/main/java/com/gitblit/wicket/panels/BasePanel.java 14 ●●●●● patch | view | raw | blame | history
src/main/java/com/gitblit/wicket/panels/RepositoryUrlPanel.html 56 ●●●● patch | view | raw | blame | history
src/main/java/com/gitblit/wicket/panels/RepositoryUrlPanel.java 293 ●●●●● patch | view | raw | blame | history
src/main/resources/gitblit.css 15 ●●●● patch | view | raw | blame | history
build.xml
@@ -101,6 +101,11 @@
             this file is only used for parsing setting descriptions. -->
        <copy tofile="${project.src.dir}/reference.properties" overwrite="true"
            file="${project.distrib.dir}/data/gitblit.properties" />
        <!-- copy clientapps.json to the source directory.
             this file is only used if a local file is not provided. -->
        <copy tofile="${project.src.dir}/clientapps.json" overwrite="true"
            file="${project.distrib.dir}/data/clientapps.json" />
        
        <!-- 
            upgrade existing workspace to data directory
@@ -959,6 +964,7 @@
                    <include name="users.conf" />
                    <include name="projects.conf" />
                    <include name="gitblit.properties" />
                    <include name="clientapps.json" />
                </fileset>
            </copy>
            <mkdir dir="@{toDir}/groovy" />
src/main/distrib/data/clientapps.json
New file
@@ -0,0 +1,42 @@
[
    {
        "name": "SmartGit/Hg",
        "cloneUrl": "smartgit://cloneRepo/{0}",
        "productUrl": "http://www.syntevo.com/smartgithg",
        "attribution": "Syntevo SmartGit/Hg\u2122",
        "platforms": [ "windows", "macintosh", "linux" ],
        "isActive": false
    },
    {
        "name": "SourceTree",
        "cloneUrl": "sourcetree://cloneRepo/{0}",
        "productUrl": "http://sourcetreeapp.com",
        "attribution": "Atlassian SourceTree\u2122",
        "platforms": [ "windows", "macintosh" ],
        "isActive": true
    },
    {
        "name": "Tower",
        "cloneUrl": "gittower://openRepo/{0}",
        "productUrl": "http://www.git-tower.com",
        "attribution": "fournova Tower\u2122",
        "platforms": [ "macintosh" ],
        "isActive": true
    },
    {
        "name": "GitHub for Macintosh",
        "cloneUrl": "github-mac://openRepo/{0}",
        "productUrl": "http://mac.github.com",
        "attribution": "GitHub\u2122 for Macintosh",
        "platforms": [ "macintosh" ],
        "isActive": false
    },
    {
        "name": "GitHub for Windows",
        "cloneUrl": "github-windows://openRepo/{0}",
        "productUrl": "http://windows.github.com",
        "attribution": "GitHub\u2122 for Windows",
        "platforms": [ "windows" ],
        "isActive": false
    }
]
src/main/java/.gitignore
New file
@@ -0,0 +1 @@
/clientapps.json
src/main/java/com/gitblit/GitBlit.java
@@ -22,6 +22,7 @@
import java.io.InputStream;
import java.io.InputStreamReader;
import java.lang.reflect.Field;
import java.lang.reflect.Type;
import java.net.URI;
import java.net.URISyntaxException;
import java.nio.charset.Charset;
@@ -90,6 +91,7 @@
import com.gitblit.fanout.FanoutService;
import com.gitblit.fanout.FanoutSocketService;
import com.gitblit.git.GitDaemon;
import com.gitblit.models.GitClientApplication;
import com.gitblit.models.FederationModel;
import com.gitblit.models.FederationProposal;
import com.gitblit.models.FederationSet;
@@ -120,6 +122,11 @@
import com.gitblit.utils.X509Utils.X509Metadata;
import com.gitblit.wicket.GitBlitWebSession;
import com.gitblit.wicket.WicketUtils;
import com.google.gson.Gson;
import com.google.gson.GsonBuilder;
import com.google.gson.JsonIOException;
import com.google.gson.JsonSyntaxException;
import com.google.gson.reflect.TypeToken;
/**
 * GitBlit is the servlet context listener singleton that acts as the core for
@@ -147,6 +154,9 @@
    private final List<FederationModel> federationRegistrations = Collections
            .synchronizedList(new ArrayList<FederationModel>());
    private final List<GitClientApplication> clientApplications = Collections
            .synchronizedList(new ArrayList<GitClientApplication>());
    private final Map<String, FederationModel> federationPullResults = new ConcurrentHashMap<String, FederationModel>();
@@ -466,6 +476,45 @@
        }
        return cloneUrls;
    }
    /**
     * Returns the list of custom client applications to be used for the
     * repository url panel;
     *
     * @return a list of client applications
     */
    public List<GitClientApplication> getClientApplications() {
        if (clientApplications.isEmpty()) {
            try {
                InputStream is = getClass().getResourceAsStream("/clientapps.json");
                Collection<GitClientApplication> clients = readClientApplications(is);
                is.close();
                if (clients != null) {
                    clientApplications.clear();
                    clientApplications.addAll(clients);
                }
            } catch (IOException e) {
                logger.error("Failed to deserialize clientapps.json resource!", e);
            }
        }
        return clientApplications;
    }
    private Collection<GitClientApplication> readClientApplications(InputStream is) {
        try {
            Type type = new TypeToken<Collection<GitClientApplication>>() {
            }.getType();
            InputStreamReader reader = new InputStreamReader(is);
            Gson gson = new GsonBuilder().create();
            Collection<GitClientApplication> links = gson.fromJson(reader, type);
            return links;
        } catch (JsonIOException e) {
            logger.error("Error deserializing client applications!", e);
        } catch (JsonSyntaxException e) {
            logger.error("Error deserializing client applications!", e);
        }
        return null;
    }
    /**
     * Set the user service. The user service authenticates all users and is
src/main/java/com/gitblit/GitFilter.java
@@ -43,7 +43,7 @@
    /**
     * Extract the repository name from the url.
     * 
     * @param url
     * @param cloneUrl
     * @return repository name
     */
    public static String getRepositoryName(String value) {
src/main/java/com/gitblit/PagesFilter.java
@@ -68,7 +68,7 @@
    /**
     * Analyze the url and returns the action of the request.
     * 
     * @param url
     * @param cloneUrl
     * @return action of the request
     */
    @Override
src/main/java/com/gitblit/models/GitClientApplication.java
New file
@@ -0,0 +1,58 @@
/*
 * Copyright 2013 gitblit.com.
 *
 * 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.gitblit.models;
import java.io.Serializable;
import com.gitblit.utils.ArrayUtils;
import com.gitblit.utils.StringUtils;
/**
 * Model class to represent a git client application.
 *
 * @author James Moger
 *
 */
public class GitClientApplication implements Serializable {
    private static final long serialVersionUID = 1L;
    public String name;
    public String cloneUrl;
    public String command;
    public String productUrl;
    public String attribution;
    public boolean isApplication = true;
    public boolean isActive = true;
    public String[] platforms;
    public boolean allowsPlatform(String p) {
        if (ArrayUtils.isEmpty(platforms)) {
            // all platforms
            return true;
        }
        if (StringUtils.isEmpty(p)) {
            return false;
        }
        String plc = p.toLowerCase();
        for (String platform : platforms) {
            if (plc.contains(platform)) {
                return true;
            }
        }
        return false;
    }
}
src/main/java/com/gitblit/wicket/GitBlitWebApp.properties
@@ -452,4 +452,5 @@
gb.externalPermissions = {0} access permissions for {1} are externally maintained
gb.viewAccess = You do not have Gitblit read or write access
gb.yourProtocolPermissionIs = Your {0} access permission for {1} is {2}
gb.cloneWithApp  = clone with {0}
gb.cloneUrl = clone {0}
gb.visitSite = visit {0} website
src/main/java/com/gitblit/wicket/pages/SummaryPage.html
@@ -13,7 +13,7 @@
        </div>    
    
        <!-- Repository info -->
        <div class="hidden-phone" style="padding-bottom: 10px;">
        <div class="hidden-phone">
            <table class="plain">
                <tr><th><wicket:message key="gb.description">[description]</wicket:message></th><td><span wicket:id="repositoryDescription">[repository description]</span></td></tr>
                <tr><th><wicket:message key="gb.owners">[owner]</wicket:message></th><td><span wicket:id="repositoryOwners"><span wicket:id="owner"></span><span wicket:id="comma"></span></span></td></tr>
@@ -22,9 +22,6 @@
                <tr><th style="vertical-align:top;padding-top:4px;"><wicket:message key="gb.repositoryUrl">[URL]</wicket:message>&nbsp;<img style="vertical-align: top;padding-left:3px;" wicket:id="accessRestrictionIcon" /></th>
                    <td style="padding-top:4px;">
                        <div wicket:id="repositoryUrlPanel">[repository url panel]</div>
                        <div wicket:id="otherUrls" >
                            <div wicket:id="otherUrl" style="padding-top:10px"></div>
                        </div>
                    </td>
                </tr>
            </table>
src/main/java/com/gitblit/wicket/pages/SummaryPage.java
@@ -55,7 +55,6 @@
import com.gitblit.wicket.GitBlitWebSession;
import com.gitblit.wicket.WicketUtils;
import com.gitblit.wicket.panels.BranchesPanel;
import com.gitblit.wicket.panels.DetailedRepositoryUrlPanel;
import com.gitblit.wicket.panels.LinkPanel;
import com.gitblit.wicket.panels.LogPanel;
import com.gitblit.wicket.panels.RepositoryUrlPanel;
@@ -152,18 +151,6 @@
        
        add(new RepositoryUrlPanel("repositoryUrlPanel", false, user, model, getLocalizer(), this));
                
        List<String> otherUrls = GitBlit.self().getOtherCloneUrls(repositoryName, UserModel.ANONYMOUS.equals(user) ? "" : user.username);
        ListDataProvider<String> urls = new ListDataProvider<String>(otherUrls);
        DataView<String> otherUrlsView = new DataView<String>("otherUrls", urls) {
            private static final long serialVersionUID = 1L;
            public void populateItem(final Item<String> item) {
                final String url = item.getModelObject();
                item.add(new DetailedRepositoryUrlPanel("otherUrl", getLocalizer(), this, model.name, url));
            }
        };
        add(otherUrlsView);
        add(new LogPanel("commitsPanel", repositoryName, getRepositoryModel().HEAD, r, numberCommits, 0, getRepositoryModel().showRemoteBranches));
        add(new TagsPanel("tagsPanel", repositoryName, r, numberRefs).hideIfEmpty());
        add(new BranchesPanel("branchesPanel", getRepositoryModel(), r, numberRefs, false).hideIfEmpty());
src/main/java/com/gitblit/wicket/panels/BasePanel.java
@@ -22,7 +22,6 @@
import org.apache.wicket.Component;
import org.apache.wicket.markup.html.panel.Panel;
import org.apache.wicket.model.Model;
import org.apache.wicket.protocol.http.request.WebClientInfo;
import com.gitblit.Constants;
import com.gitblit.GitBlit;
@@ -59,19 +58,6 @@
        return timeUtils;
    }
    
    protected boolean isWindows() {
        return isPlatform("windows");
    }
    protected boolean isMac() {
        return isPlatform("macintosh");
    }
    protected boolean isPlatform(String platform) {
        String ua = ((WebClientInfo) GitBlitWebSession.get().getClientInfo()).getUserAgent();
        return ua.toLowerCase().contains(platform);
    }
    protected void setPersonSearchTooltip(Component component, String value, Constants.SearchType searchType) {
        if (searchType.equals(Constants.SearchType.AUTHOR)) {
            WicketUtils.setHtmlTooltip(component, getString("gb.searchForAuthor") + " " + value);
src/main/java/com/gitblit/wicket/panels/RepositoryUrlPanel.html
@@ -6,13 +6,53 @@
<wicket:panel>
    <div wicket:id="repositoryPrimaryUrl">[repository primary url]</div>
    <div style="padding-top: 2px;">
        <span class="link" wicket:id="appCloneLink">
            <span wicket:id="icon"></span>
            <span wicket:id="link"></span>
            <span wicket:id="separator" style="padding: 0px 5px 0px 5px;"></span>
        </span>
    </div>
    <div wicket:id="repositoryGitDaemonUrl">[repository git daemon url]</div>
    <div class="btn-toolbar" style="margin-bottom: 0px;">
        <div class="btn-group" wicket:id="urlMenus">
               <a class="btn btn-mini btn-action" data-toggle="dropdown" href="#">
                   <i class="icon-download icon-black"></i>
                <span wicket:id="productName"></span>
                <span class="caret"></span>
               </a>
               <ul class="dropdown-menu">
                   <li><div style="padding-left: 15px; font-style: italic;" wicket:id="productAttribution"></div></li>
                   <li class="divider"></li>
                   <li wicket:id="repoLinks">
                       <span wicket:id="repoLink"></span>
                   </li>
                   <li style="border-top: 1px solid #eee; margin-top:5px;padding-top:5px;"><span wicket:id="productLink"></span></li>
               </ul>
           </div>
    </div>
    <wicket:fragment wicket:id="commandFragment">
        <span wicket:id="content"></span><span class="hidden-phone hidden-tablet" wicket:id="copyFunction"></span>
    </wicket:fragment>
    <wicket:fragment wicket:id="linkFragment">
        <span wicket:id="content"></span>
    </wicket:fragment>
    <!-- Plain JavaScript manual copy & paste -->
    <wicket:fragment wicket:id="jsPanel">
        <span style="vertical-align:baseline;">
            <img wicket:id="copyIcon" wicket:message="title:gb.copyToClipboard"></img>
        </span>
    </wicket:fragment>
    <!-- flash-based button-press copy & paste -->
    <wicket:fragment wicket:id="clippyPanel">
           <object wicket:message="title:gb.copyToClipboard" style="vertical-align:middle;"
               wicket:id="clippy"
               width="14"
               height="14"
               bgcolor="#ffffff"
               quality="high"
               wmode="transparent"
               scale="noscale"
               allowScriptAccess="always"></object>
    </wicket:fragment>
</wicket:panel>
</html>
src/main/java/com/gitblit/wicket/panels/RepositoryUrlPanel.java
@@ -24,10 +24,13 @@
import org.apache.wicket.Localizer;
import org.apache.wicket.RequestCycle;
import org.apache.wicket.markup.html.basic.Label;
import org.apache.wicket.markup.html.image.ContextImage;
import org.apache.wicket.markup.html.panel.Fragment;
import org.apache.wicket.markup.repeater.Item;
import org.apache.wicket.markup.repeater.data.DataView;
import org.apache.wicket.markup.repeater.data.ListDataProvider;
import org.apache.wicket.protocol.http.WebRequest;
import org.apache.wicket.protocol.http.request.WebClientInfo;
import com.gitblit.Constants;
import com.gitblit.Constants.AccessPermission;
@@ -35,6 +38,7 @@
import com.gitblit.GitBlit;
import com.gitblit.Keys;
import com.gitblit.SparkleShareInviteServlet;
import com.gitblit.models.GitClientApplication;
import com.gitblit.models.RepositoryModel;
import com.gitblit.models.UserModel;
import com.gitblit.utils.StringUtils;
@@ -52,99 +56,174 @@
    private static final long serialVersionUID = 1L;
    
    private final String primaryUrl;
    private final RepoUrl primaryUrl;
    public RepositoryUrlPanel(String wicketId, boolean onlyPrimary, UserModel user, 
            RepositoryModel repository, Localizer localizer, Component owner) {
            final RepositoryModel repository, Localizer localizer, Component owner) {
        super(wicketId);
        if (user == null) {
            user = UserModel.ANONYMOUS;
        }
        List<String> repositoryUrls = new ArrayList<String>();
        List<RepoUrl> repositoryUrls = new ArrayList<RepoUrl>();
        AccessPermission accessPermission = null;
        // http/https url
        if (GitBlit.getBoolean(Keys.git.enableGitServlet, true)) {
            accessPermission = user.getRepositoryPermission(repository).permission;
            repositoryUrls.add(getRepositoryUrl(repository));
        }
        repositoryUrls.addAll(GitBlit.self().getOtherCloneUrls(repository.name, UserModel.ANONYMOUS.equals(user) ? "" : user.username));
        primaryUrl = repositoryUrls.size() == 0 ? "" : repositoryUrls.remove(0);
        add(new DetailedRepositoryUrlPanel("repositoryPrimaryUrl", localizer, owner, repository.name, primaryUrl, accessPermission));
        if (!onlyPrimary) {
            Component gitDaemonUrlPanel = createGitDaemonUrlPanel("repositoryGitDaemonUrl", user, repository);
            if (!StringUtils.isEmpty(primaryUrl) && gitDaemonUrlPanel instanceof DetailedRepositoryUrlPanel) {
                WicketUtils.setCssStyle(gitDaemonUrlPanel, "padding-top: 10px");
            AccessPermission permission = user.getRepositoryPermission(repository).permission;
            if (permission.exceeds(AccessPermission.NONE)) {
                repositoryUrls.add(new RepoUrl(getRepositoryUrl(repository), permission));
            }
            add(gitDaemonUrlPanel);
        } else {
            add(new Label("repositoryGitDaemonUrl").setVisible(false));
        }
        
        String cloneWith = localizer.getString("gb.cloneWithApp", owner);
        final List<AppCloneLink> cloneLinks = new ArrayList<AppCloneLink>();
        if (user.canClone(repository) && GitBlit.getBoolean(Keys.web.allowAppCloneLinks, true)) {
            // universal app clone urls
//            cloneLinks.add(new AppCloneLink(MessageFormat.format(cloneWith, "SmartGit\u2122"),
//                    MessageFormat.format("smartgit://cloneRepo/{0}", primaryUrl),
//                    "Syntevo SmartGit\u2122"));
        // git daemon url
        String gitDaemonUrl = getGitDaemonUrl(user, repository);
        if (!StringUtils.isEmpty(gitDaemonUrl)) {
            AccessPermission permission = getGitDaemonAccessPermission(user, repository);
            if (permission.exceeds(AccessPermission.NONE)) {
                repositoryUrls.add(new RepoUrl(gitDaemonUrl, permission));
            }
        }
        // add all other urls
        for (String url : GitBlit.self().getOtherCloneUrls(repository.name, UserModel.ANONYMOUS.equals(user) ? "" : user.username)) {
            repositoryUrls.add(new RepoUrl(url, null));
        }
        // grab primary url from the top of the list
        primaryUrl = repositoryUrls.size() == 0 ? null : repositoryUrls.get(0);
            if (isWindows()) {
                // Windows client app clone urls
                cloneLinks.add(new AppCloneLink(MessageFormat.format(cloneWith, "SourceTree\u2122"),
                        MessageFormat.format("sourcetree://cloneRepo/{0}", primaryUrl),
                        "Atlassian SourceTree\u2122"));
//                cloneLinks.add(new AppCloneLink(
//                        MessageFormat.format(cloneWith, "GitHub\u2122 for Windows"),
//                        MessageFormat.format("github-windows://openRepo/{0}", primaryUrl),
//                        "GitHub\u2122 for Windows"));
            } else if (isMac()) {
                // Mac client app clone urls
                cloneLinks.add(new AppCloneLink(MessageFormat.format(cloneWith, "SourceTree\u2122"),
                        MessageFormat.format("sourcetree://cloneRepo/{0}", primaryUrl),
                        "Atlassian SourceTree\u2122"));
                cloneLinks.add(new AppCloneLink(MessageFormat.format(cloneWith, "Tower\u2122"),
                        MessageFormat.format("gittower://openRepo/{0}", primaryUrl),
                        "fournova Tower\u2122"));
//                cloneLinks.add(new AppCloneLink(
//                        MessageFormat.format(cloneWith, "GitHub\u2122 for Mac"),
//                        MessageFormat.format("github-mac://openRepo/{0}", primaryUrl),
//                        "GitHub\u2122 for Mac"));
        add(new DetailedRepositoryUrlPanel("repositoryPrimaryUrl", localizer, owner,
                repository.name, primaryUrl == null ? "" : primaryUrl.url,
                primaryUrl == null ? null : primaryUrl.permission));
        if (onlyPrimary) {
            // only displaying the primary url
            add(new Label("urlMenus").setVisible(false));
            return;
        }
        final String clonePattern = localizer.getString("gb.cloneUrl", owner);
        final String visitSitePattern = localizer.getString("gb.visitSite", owner);
        GitClientApplication URLS = new GitClientApplication();
        URLS.name = "URLs";
        URLS.command = "{0}";
        URLS.attribution = "Repository URLs";
        URLS.isApplication = false;
        URLS.isActive = true;
        GitClientApplication GIT = new GitClientApplication();
        GIT.name = "Git";
        GIT.command = "git clone {0}";
        GIT.productUrl = "http://git-scm.org";
        GIT.attribution = "Git Syntax";
        GIT.isApplication = false;
        GIT.isActive = true;
        final List<GitClientApplication> clientApps = new ArrayList<GitClientApplication>();
        clientApps.add(URLS);
        clientApps.add(GIT);
        final String userAgent = ((WebClientInfo) GitBlitWebSession.get().getClientInfo()).getUserAgent();
        boolean allowAppLinks = GitBlit.getBoolean(Keys.web.allowAppCloneLinks, true);
        if (user.canClone(repository)) {
            for (GitClientApplication app : GitBlit.self().getClientApplications()) {
                if (app.isActive && app.allowsPlatform(userAgent) && (!app.isApplication || (app.isApplication && allowAppLinks))) {
                    clientApps.add(app);
                }
            }
            // sparkleshare invite url
            String sparkleshareUrl = getSparkleShareInviteUrl(user, repository);
            if (!StringUtils.isEmpty(sparkleshareUrl)) {
                cloneLinks.add(new AppCloneLink(MessageFormat.format(cloneWith, "SparkleShare\u2122"),
                        sparkleshareUrl, "SparkleShare\u2122", "icon-star"));
            if (!StringUtils.isEmpty(sparkleshareUrl) && allowAppLinks) {
                GitClientApplication link = new GitClientApplication();
                link.name = "SparkleShare";
                link.cloneUrl = sparkleshareUrl;
                link.attribution = "SparkleShare\u2122";
                link.platforms = new String [] { "windows", "macintosh", "linux" };
                link.productUrl = "http://sparkleshare.org";
                link.isApplication = true;
                link.isActive = true;
                clientApps.add(link);
            }
        }
        final ListDataProvider<RepoUrl> repoUrls = new ListDataProvider<RepoUrl>(repositoryUrls);
        // app clone links
        ListDataProvider<AppCloneLink> appLinks = new ListDataProvider<AppCloneLink>(cloneLinks);
        DataView<AppCloneLink> appCloneLinks = new DataView<AppCloneLink>("appCloneLink", appLinks) {
        ListDataProvider<GitClientApplication> appLinks = new ListDataProvider<GitClientApplication>(clientApps);
        DataView<GitClientApplication> urlMenus = new DataView<GitClientApplication>("urlMenus", appLinks) {
            private static final long serialVersionUID = 1L;
            int count;
            
            public void populateItem(final Item<AppCloneLink> item) {
                final AppCloneLink appLink = item.getModelObject();
                item.add(new Label("icon", MessageFormat.format("<i class=\"{0}\"></i>", appLink.icon)).setEscapeModelStrings(false));
                LinkPanel linkPanel = new LinkPanel("link", null, appLink.name, appLink.url);
                if (!StringUtils.isEmpty(appLink.tooltip)) {
                    WicketUtils.setHtmlTooltip(linkPanel, appLink.tooltip);
            public void populateItem(final Item<GitClientApplication> item) {
                final GitClientApplication cloneLink = item.getModelObject();
                item.add(new Label("productName", cloneLink.name));
                // a nested repeater for all repo links
                DataView<RepoUrl> repoLinks = new DataView<RepoUrl>("repoLinks", repoUrls) {
                    private static final long serialVersionUID = 1L;
                    public void populateItem(final Item<RepoUrl> repoLinkItem) {
                        RepoUrl repoUrl = repoLinkItem.getModelObject();
                        if (!StringUtils.isEmpty(cloneLink.cloneUrl)) {
                            // custom registered url
                            Fragment fragment = new Fragment("repoLink", "linkFragment", this);
                            String name;
                            if (repoUrl.permission != null) {
                                name = MessageFormat.format("{0} ({1})", repoUrl.url, repoUrl.permission);
                            } else {
                                name = repoUrl.url;
                            }
                            String url = MessageFormat.format(cloneLink.cloneUrl, repoUrl);
                            fragment.add(new LinkPanel("content", null, MessageFormat.format(clonePattern, name), url));
                            repoLinkItem.add(fragment);
                            String tooltip = getProtocolPermissionDescription(repository, repoUrl);
                            WicketUtils.setHtmlTooltip(fragment, tooltip);
                        } else if (!StringUtils.isEmpty(cloneLink.command)) {
                            // command-line
                            Fragment fragment = new Fragment("repoLink", "commandFragment", this);
                            WicketUtils.setCssClass(fragment, "repositoryUrlMenuItem");
                            String command = MessageFormat.format(cloneLink.command, repoUrl);
                            fragment.add(new Label("content", command));
                            repoLinkItem.add(fragment);
                            String tooltip = getProtocolPermissionDescription(repository, repoUrl);
                            WicketUtils.setHtmlTooltip(fragment, tooltip);
                            // copy function for command
                            if (GitBlit.getBoolean(Keys.web.allowFlashCopyToClipboard, true)) {
                                // clippy: flash-based copy & paste
                                Fragment copyFragment = new Fragment("copyFunction", "clippyPanel", this);
                                String baseUrl = WicketUtils.getGitblitURL(getRequest());
                                ShockWaveComponent clippy = new ShockWaveComponent("clippy", baseUrl + "/clippy.swf");
                                clippy.setValue("flashVars", "text=" + StringUtils.encodeURL(command));
                                copyFragment.add(clippy);
                                fragment.add(copyFragment);
                            } else {
                                // javascript: manual copy & paste with modal browser prompt dialog
                                Fragment copyFragment = new Fragment("copyFunction", "jsPanel", this);
                                ContextImage img = WicketUtils.newImage("copyIcon", "clippy.png");
                                img.add(new JavascriptTextPrompt("onclick", "Copy to Clipboard (Ctrl+C, Enter)", command));
                                copyFragment.add(img);
                                fragment.add(copyFragment);
                            }
                        }
                    }};
                item.add(repoLinks);
                item.add(new Label("productAttribution", cloneLink.attribution));
                if (!StringUtils.isEmpty(cloneLink.productUrl)) {
                    LinkPanel productlinkPanel = new LinkPanel("productLink", null,
                            MessageFormat.format(visitSitePattern, cloneLink.name), cloneLink.productUrl, true);
                    item.add(productlinkPanel);
                } else {
                    item.add(new Label("productLink").setVisible(false));
                }
                item.add(linkPanel);
                item.add(new Label("separator", "|").setVisible(count < (cloneLinks.size() - 1)));
                count++;
            }
        };
        add(appCloneLinks);
        add(urlMenus);
    }
    
    public String getPrimaryUrl() {
        return primaryUrl;
        return primaryUrl == null ? "" : primaryUrl.url;
    }
    
    protected String getRepositoryUrl(RepositoryModel repository) {
@@ -162,7 +241,7 @@
        return sb.toString();
    }
    
    protected Component createGitDaemonUrlPanel(String wicketId, UserModel user, RepositoryModel repository) {
    protected String getGitDaemonUrl(UserModel user, RepositoryModel repository) {
        int gitDaemonPort = GitBlit.getInteger(Keys.git.daemonPort, 0);
        if (gitDaemonPort > 0 && user.canClone(repository)) {
            String servername = ((WebRequest) getRequest()).getHttpServletRequest().getServerName();
@@ -174,7 +253,14 @@
                // non-standard port
                gitDaemonUrl = MessageFormat.format("git://{0}:{1,number,0}/{2}", servername, gitDaemonPort, repository.name);
            }
            return gitDaemonUrl;
        }
        return null;
    }
    protected AccessPermission getGitDaemonAccessPermission(UserModel user, RepositoryModel repository) {
        int gitDaemonPort = GitBlit.getInteger(Keys.git.daemonPort, 0);
        if (gitDaemonPort > 0 && user.canClone(repository)) {
            AccessPermission gitDaemonPermission = user.getRepositoryPermission(repository).permission;;
            if (gitDaemonPermission.atLeast(AccessPermission.CLONE)) {
                if (repository.accessRestriction.atLeast(AccessRestrictionType.CLONE)) {
@@ -187,18 +273,9 @@
                    // normal user permission
                }
            }
            if (AccessPermission.NONE.equals(gitDaemonPermission)) {
                // repository prohibits all anonymous access
                return new Label(wicketId).setVisible(false);
            } else {
                // repository allows some form of anonymous access
                return new DetailedRepositoryUrlPanel(wicketId, getLocalizer(), this, repository.name, gitDaemonUrl, gitDaemonPermission);
            }
        } else {
            // git daemon is not running
            return new Label(wicketId).setVisible(false);
            return gitDaemonPermission;
        }
        return AccessPermission.NONE;
    }
    protected String getSparkleShareInviteUrl(UserModel user, RepositoryModel repository) {
@@ -223,24 +300,62 @@
        return null;
    }
    
    static class AppCloneLink implements Serializable {
    protected String getProtocolPermissionDescription(RepositoryModel repository, RepoUrl repoUrl) {
        String protocol = repoUrl.url.substring(0, repoUrl.url.indexOf("://"));
        String note;
        if (repoUrl.permission == null) {
            note = MessageFormat.format(getString("gb.externalPermissions"), protocol, repository.name);
        } else {
            note = null;
            String key;
            switch (repoUrl.permission) {
                case OWNER:
                case REWIND:
                    key = "gb.rewindPermission";
                    break;
                case DELETE:
                    key = "gb.deletePermission";
                    break;
                case CREATE:
                    key = "gb.createPermission";
                    break;
                case PUSH:
                    key = "gb.pushPermission";
                    break;
                case CLONE:
                    key = "gb.clonePermission";
                    break;
                default:
                    key = null;
                    note = getString("gb.viewAccess");
                    break;
            }
            if (note == null) {
                String pattern = getString(key);
                String description = MessageFormat.format(pattern, repoUrl.permission.toString());
                String permissionPattern = getString("gb.yourProtocolPermissionIs");
                note = MessageFormat.format(permissionPattern, protocol.toUpperCase(), repository, description);
            }
        }
        return note;
    }
    private class RepoUrl implements Serializable {
        
        private static final long serialVersionUID = 1L;
        
        final String name;
        final String url;
        final String tooltip;
        final String icon;
        final AccessPermission permission;
        
        public AppCloneLink(String name, String url, String tooltip) {
            this(name, url, tooltip, "icon-download");
        RepoUrl(String url, AccessPermission permission) {
            this.url = url;
            this.permission = permission;
        }
        
        public AppCloneLink(String name, String url, String tooltip, String icon) {
            this.name = name;
            this.url = url;
            this.tooltip = tooltip;
            this.icon = icon;
        @Override
        public String toString() {
            return url;
        }
    }
}
src/main/resources/gitblit.css
@@ -181,9 +181,9 @@
span.repositoryUrlContainer {
    color: black;
    background-color: #eee;
    background-color: whiteSmoke;
    padding: 4px;
    border: 1px solid #ccc;
    border: 1px solid #ddd;
    border-radius: 3px 
}
@@ -199,8 +199,15 @@
    padding: 4px;
    color: blue;
    background-color: #fff;
    border-left: 1px solid #ccc;
    border-right: 1px solid #ccc;
    border-left: 1px solid #ddd;
    border-right: 1px solid #ddd;
}
span.repositoryUrlMenuItem {
    line-height: 24px;
    padding: 3px 15px;
    font-size: 0.85em;
    font-family: menlo,consolas,monospace;
}
div.odd {