James Moger
2012-09-07 13a3f5bc3e2d25fc76850f86070dc34efe60d77a
Draft project pages, project metadata, and RSS feeds

This is an in-progress feature to offer an interface for grouped
repositories. This may help installations with large numbers of
repositories stay organized. It also will be part of a future,
more advanced security model.
6 files added
21 files modified
1645 ■■■■■ changed files
.gitignore 1 ●●●● patch | view | raw | blame | history
distrib/gitblit.properties 5 ●●●●● patch | view | raw | blame | history
docs/01_setup.mkd 1 ●●●● patch | view | raw | blame | history
docs/05_roadmap.mkd 2 ●●●●● patch | view | raw | blame | history
resources/clippy.png patch | view | raw | blame | history
resources/gitblit.css 4 ●●●● patch | view | raw | blame | history
src/com/gitblit/GitBlit.java 135 ●●●●● patch | view | raw | blame | history
src/com/gitblit/SyndicationFilter.java 143 ●●●●● patch | view | raw | blame | history
src/com/gitblit/SyndicationServlet.java 164 ●●●● patch | view | raw | blame | history
src/com/gitblit/models/ProjectModel.java 95 ●●●●● patch | view | raw | blame | history
src/com/gitblit/utils/SyndicationUtils.java 3 ●●●● patch | view | raw | blame | history
src/com/gitblit/wicket/GitBlitWebApp.java 4 ●●●● patch | view | raw | blame | history
src/com/gitblit/wicket/GitBlitWebApp.properties 6 ●●●● patch | view | raw | blame | history
src/com/gitblit/wicket/WicketUtils.java 8 ●●●●● patch | view | raw | blame | history
src/com/gitblit/wicket/pages/BasePage.java 100 ●●●●● patch | view | raw | blame | history
src/com/gitblit/wicket/pages/ProjectPage.html 132 ●●●●● patch | view | raw | blame | history
src/com/gitblit/wicket/pages/ProjectPage.java 502 ●●●●● patch | view | raw | blame | history
src/com/gitblit/wicket/pages/ProjectsPage.html 37 ●●●●● patch | view | raw | blame | history
src/com/gitblit/wicket/pages/ProjectsPage.java 224 ●●●●● patch | view | raw | blame | history
src/com/gitblit/wicket/pages/RepositoryPage.html 2 ●●● patch | view | raw | blame | history
src/com/gitblit/wicket/pages/RepositoryPage.java 7 ●●●●● patch | view | raw | blame | history
src/com/gitblit/wicket/pages/RootPage.java 31 ●●●●● patch | view | raw | blame | history
src/com/gitblit/wicket/panels/RepositoriesPanel.html 5 ●●●●● patch | view | raw | blame | history
src/com/gitblit/wicket/panels/RepositoriesPanel.java 20 ●●●● patch | view | raw | blame | history
src/com/gitblit/wicket/panels/RepositoryUrlPanel.html 9 ●●●●● patch | view | raw | blame | history
src/com/gitblit/wicket/panels/RepositoryUrlPanel.java 3 ●●●● patch | view | raw | blame | history
tests/com/gitblit/tests/SyndicationUtilsTest.java 2 ●●● patch | view | raw | blame | history
.gitignore
@@ -26,3 +26,4 @@
/users.conf
*.directory
/.gradle
/projects.conf
distrib/gitblit.properties
@@ -290,6 +290,11 @@
# SINCE 0.5.0
web.allowCookieAuthentication = true
# Config file for storing project metadata
#
# SINCE 1.2.0
web.projectsFile = projects.conf
# Either the full path to a user config file (users.conf)
# OR the full path to a simple user properties file (users.properties)
# OR a fully qualified class name that implements the IUserService interface.
docs/01_setup.mkd
@@ -9,6 +9,7 @@
    - <context-parameter> *git.repositoryFolder* (set the full path to your repositories folder)
    - <context-parameter> *groovy.scriptsFolder* (set the full path to your Groovy hook scripts folder)
    - <context-parameter> *groovy.grapeFolder* (set the full path to your Groovy Grape artifact cache)
    - <context-parameter> *web.projectsFile* (set the full path to your projects metadata file)
    - <context-parameter> *realm.userService* (set the full path to `users.conf`)
    - <context-parameter> *git.packedGitLimit* (set larger than the size of your largest repository)
    - <context-parameter> *git.streamFileThreshold* (set larger than the size of your largest committed file)
docs/05_roadmap.mkd
@@ -30,7 +30,5 @@
* Gitblit: diff should highlight inserted/removed fragment compared to original line
* Gitblit: implement branch permission controls as Groovy pre-receive script.  
*Maintain permissions text file similar to a gitolite configuration file or svn authz file.*
* Gitblit: aggregate RSS feeds by tag or subfolder
* Gitblit: Consider creating more Git model objects and exposing them via the JSON RPC interface to allow inspection/retrieval of Git commits, Git trees, etc from Gitblit.
* Gitblit: Blame coloring by author (issue 2)
* Gitblit: View binary files in blob page (issue 6)
resources/clippy.png
resources/gitblit.css
@@ -734,6 +734,10 @@
     border-bottom: 1px solid #aaa; 
}
table.repositories tr.group td a {
    color: black;
}
table.palette { border:0; width: 0 !important; }
table.palette td.header { 
    font-weight: bold; 
src/com/gitblit/GitBlit.java
@@ -37,6 +37,7 @@
import java.util.Map.Entry;
import java.util.Set;
import java.util.TimeZone;
import java.util.TreeMap;
import java.util.TreeSet;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.Executors;
@@ -79,6 +80,7 @@
import com.gitblit.models.FederationProposal;
import com.gitblit.models.FederationSet;
import com.gitblit.models.Metric;
import com.gitblit.models.ProjectModel;
import com.gitblit.models.RepositoryModel;
import com.gitblit.models.SearchResult;
import com.gitblit.models.ServerSettings;
@@ -132,6 +134,8 @@
    
    private final Map<String, RepositoryModel> repositoryListCache = new ConcurrentHashMap<String, RepositoryModel>();
    
    private final Map<String, ProjectModel> projectCache = new ConcurrentHashMap<String, ProjectModel>();
    private final AtomicReference<String> repositoryListSettingsChecksum = new AtomicReference<String>("");
    private RepositoryResolver<Void> repositoryResolver;
@@ -153,6 +157,8 @@
    private LuceneExecutor luceneExecutor;
    
    private TimeZone timezone;
    private FileBasedConfig projectConfigs;
    public GitBlit() {
        if (gitblit == null) {
@@ -1018,6 +1024,130 @@
        
        // return a copy of the cached model
        return DeepCopier.copy(model);
    }
    /**
     * Returns the map of project config.  This map is cached and reloaded if
     * the underlying projects.conf file changes.
     *
     * @return project config map
     */
    private Map<String, ProjectModel> getProjectConfigs() {
        if (projectConfigs.isOutdated()) {
            try {
                projectConfigs.load();
            } catch (Exception e) {
            }
            // project configs
            String rootName = GitBlit.getString(Keys.web.repositoryRootGroupName, "main");
            ProjectModel rootProject = new ProjectModel(rootName, true);
            Map<String, ProjectModel> configs = new HashMap<String, ProjectModel>();
            // cache the root project under its alias and an empty path
            configs.put("", rootProject);
            configs.put(rootProject.name.toLowerCase(), rootProject);
            for (String name : projectConfigs.getSubsections("project")) {
                ProjectModel project;
                if (name.equalsIgnoreCase(rootName)) {
                    project = rootProject;
                } else {
                    project = new ProjectModel(name);
                }
                project.title = projectConfigs.getString("project", name, "title");
                project.description = projectConfigs.getString("project", name, "description");
                // TODO add more interesting metadata
                // project manager?
                // commit message regex?
                // RW+
                // RW
                // R
                configs.put(name.toLowerCase(), project);
            }
            projectCache.clear();
            projectCache.putAll(configs);
        }
        return projectCache;
    }
    /**
     * Returns a list of project models for the user.
     *
     * @param user
     * @return list of projects that are accessible to the user
     */
    public List<ProjectModel> getProjectModels(UserModel user) {
        Map<String, ProjectModel> configs = getProjectConfigs();
        // per-user project lists, this accounts for security and visibility
        Map<String, ProjectModel> map = new TreeMap<String, ProjectModel>();
        // root project
        map.put("", configs.get(""));
        for (RepositoryModel model : getRepositoryModels(user)) {
            String rootPath = StringUtils.getRootPath(model.name).toLowerCase();
            if (!map.containsKey(rootPath)) {
                ProjectModel project;
                if (configs.containsKey(rootPath)) {
                    // clone the project model because it's repository list will
                    // be tailored for the requesting user
                    project = DeepCopier.copy(configs.get(rootPath));
                } else {
                    project = new ProjectModel(rootPath);
                }
                map.put(rootPath, project);
            }
            map.get(rootPath).addRepository(model);
        }
        // sort projects, root project first
        List<ProjectModel> projects = new ArrayList<ProjectModel>(map.values());
        Collections.sort(projects);
        projects.remove(map.get(""));
        projects.add(0, map.get(""));
        return projects;
    }
    /**
     * Returns the project model for the specified user.
     *
     * @param name
     * @param user
     * @return a project model, or null if it does not exist
     */
    public ProjectModel getProjectModel(String name, UserModel user) {
        for (ProjectModel project : getProjectModels(user)) {
            if (project.name.equalsIgnoreCase(name)) {
                return project;
            }
        }
        return null;
    }
    /**
     * Returns a project model for the Gitblit/system user.
     *
     * @param name a project name
     * @return a project model or null if the project does not exist
     */
    public ProjectModel getProjectModel(String name) {
        Map<String, ProjectModel> configs = getProjectConfigs();
        ProjectModel project = configs.get(name.toLowerCase());
        if (project == null) {
            return null;
        }
        // clone the object
        project = DeepCopier.copy(project);
        String folder = name.toLowerCase() + "/";
        for (String repository : getRepositoryList()) {
            if (repository.toLowerCase().startsWith(folder)) {
                project.addRepository(repository);
            }
        }
        return project;
    }
    
    /**
@@ -2180,6 +2310,11 @@
            loginService = new GitblitUserService();
        }
        setUserService(loginService);
        // load and cache the project metadata
        projectConfigs = new FileBasedConfig(getFileOrFolder(Keys.web.projectsFile, "projects.conf"), FS.detect());
        getProjectConfigs();
        mailExecutor = new MailExecutor(settings);
        if (mailExecutor.isReady()) {
            logger.info("Mail executor is scheduled to process the message queue every 2 minutes.");
src/com/gitblit/SyndicationFilter.java
@@ -15,19 +15,30 @@
 */
package com.gitblit;
import java.io.IOException;
import java.text.MessageFormat;
import javax.servlet.FilterChain;
import javax.servlet.ServletException;
import javax.servlet.ServletRequest;
import javax.servlet.ServletResponse;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import com.gitblit.Constants.AccessRestrictionType;
import com.gitblit.models.ProjectModel;
import com.gitblit.models.RepositoryModel;
import com.gitblit.models.UserModel;
/**
 * The SyndicationFilter is an AccessRestrictionFilter which ensures that feed
 * requests for view-restricted repositories have proper authentication
 * The SyndicationFilter is an AuthenticationFilter which ensures that feed
 * requests for projects or view-restricted repositories have proper authentication
 * credentials and are authorized for the requested feed.
 * 
 * @author James Moger
 * 
 */
public class SyndicationFilter extends AccessRestrictionFilter {
public class SyndicationFilter extends AuthenticationFilter {
    /**
     * Extract the repository name from the url.
@@ -35,8 +46,7 @@
     * @param url
     * @return repository name
     */
    @Override
    protected String extractRepositoryName(String url) {
    protected String extractRequestedName(String url) {
        if (url.indexOf('?') > -1) {
            return url.substring(0, url.indexOf('?'));
        }
@@ -44,52 +54,91 @@
    }
    /**
     * Analyze the url and returns the action of the request.
     * doFilter does the actual work of preprocessing the request to ensure that
     * the user may proceed.
     * 
     * @param url
     * @return action of the request
     * @see javax.servlet.Filter#doFilter(javax.servlet.ServletRequest,
     *      javax.servlet.ServletResponse, javax.servlet.FilterChain)
     */
    @Override
    protected String getUrlRequestAction(String url) {
        return "VIEW";
    }
    public void doFilter(final ServletRequest request, final ServletResponse response,
            final FilterChain chain) throws IOException, ServletException {
    /**
     * Determine if the action may be executed on the repository.
     *
     * @param repository
     * @param action
     * @return true if the action may be performed
     */
    @Override
    protected boolean isActionAllowed(RepositoryModel repository, String action) {
        return true;
    }
    /**
     * Determine if the repository requires authentication.
     *
     * @param repository
     * @param action
     * @return true if authentication required
     */
    @Override
    protected boolean requiresAuthentication(RepositoryModel repository, String action) {
        return repository.accessRestriction.atLeast(AccessRestrictionType.VIEW);
    }
        HttpServletRequest httpRequest = (HttpServletRequest) request;
        HttpServletResponse httpResponse = (HttpServletResponse) response;
    /**
     * Determine if the user can access the repository and perform the specified
     * action.
     *
     * @param repository
     * @param user
     * @param action
     * @return true if user may execute the action on the repository
     */
    @Override
    protected boolean canAccess(RepositoryModel repository, UserModel user, String action) {
        return user.canAccessRepository(repository);
    }
        String fullUrl = getFullUrl(httpRequest);
        String name = extractRequestedName(fullUrl);
        ProjectModel project = GitBlit.self().getProjectModel(name);
        RepositoryModel model = null;
        if (project == null) {
            // try loading a repository model
            model = GitBlit.self().getRepositoryModel(name);
            if (model == null) {
                // repository not found. send 404.
                logger.info(MessageFormat.format("ARF: {0} ({1})", fullUrl,
                        HttpServletResponse.SC_NOT_FOUND));
                httpResponse.sendError(HttpServletResponse.SC_NOT_FOUND);
                return;
            }
        }
        // Wrap the HttpServletRequest with the AccessRestrictionRequest which
        // overrides the servlet container user principal methods.
        // JGit requires either:
        //
        // 1. servlet container authenticated user
        // 2. http.receivepack = true in each repository's config
        //
        // Gitblit must conditionally authenticate users per-repository so just
        // enabling http.receivepack is insufficient.
        AuthenticatedRequest authenticatedRequest = new AuthenticatedRequest(httpRequest);
        UserModel user = getUser(httpRequest);
        if (user != null) {
            authenticatedRequest.setUser(user);
        }
        // BASIC authentication challenge and response processing
        if (model != null) {
            if (model.accessRestriction.atLeast(AccessRestrictionType.VIEW)) {
                if (user == null) {
                    // challenge client to provide credentials. send 401.
                    if (GitBlit.isDebugMode()) {
                        logger.info(MessageFormat.format("ARF: CHALLENGE {0}", fullUrl));
                    }
                    httpResponse.setHeader("WWW-Authenticate", CHALLENGE);
                    httpResponse.sendError(HttpServletResponse.SC_UNAUTHORIZED);
                    return;
                } else {
                    // check user access for request
                    if (user.canAdmin || user.canAccessRepository(model)) {
                        // authenticated request permitted.
                        // pass processing to the restricted servlet.
                        newSession(authenticatedRequest, httpResponse);
                        logger.info(MessageFormat.format("ARF: {0} ({1}) authenticated", fullUrl,
                                HttpServletResponse.SC_CONTINUE));
                        chain.doFilter(authenticatedRequest, httpResponse);
                        return;
                    }
                    // valid user, but not for requested access. send 403.
                    if (GitBlit.isDebugMode()) {
                        logger.info(MessageFormat.format("ARF: {0} forbidden to access {1}",
                                user.username, fullUrl));
                    }
                    httpResponse.sendError(HttpServletResponse.SC_FORBIDDEN);
                    return;
                }
            }
        }
        if (GitBlit.isDebugMode()) {
            logger.info(MessageFormat.format("ARF: {0} ({1}) unauthenticated", fullUrl,
                    HttpServletResponse.SC_CONTINUE));
        }
        // unauthenticated request permitted.
        // pass processing to the restricted servlet.
        chain.doFilter(authenticatedRequest, httpResponse);
    }
}
src/com/gitblit/SyndicationServlet.java
@@ -17,6 +17,8 @@
import java.text.MessageFormat;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Collections;
import java.util.List;
import java.util.Map;
@@ -28,9 +30,12 @@
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import com.gitblit.AuthenticationFilter.AuthenticatedRequest;
import com.gitblit.models.FeedEntryModel;
import com.gitblit.models.ProjectModel;
import com.gitblit.models.RefModel;
import com.gitblit.models.RepositoryModel;
import com.gitblit.models.UserModel;
import com.gitblit.utils.HttpUtils;
import com.gitblit.utils.JGitUtils;
import com.gitblit.utils.StringUtils;
@@ -157,19 +162,36 @@
        }
        response.setContentType("application/rss+xml; charset=UTF-8");
        Repository repository = GitBlit.self().getRepository(repositoryName);
        RepositoryModel model = GitBlit.self().getRepositoryModel(repositoryName);
        List<RevCommit> commits;
        if (StringUtils.isEmpty(searchString)) {
            // standard log/history lookup
            commits = JGitUtils.getRevLog(repository, objectId, offset, length);
        } else {
            // repository search
            commits = JGitUtils.searchRevlogs(repository, objectId, searchString, searchType,
                    offset, length);
        boolean isProjectFeed = false;
        String feedName = null;
        String feedTitle = null;
        String feedDescription = null;
        List<String> repositories = null;
        if (repositoryName.indexOf('/') == -1 && !repositoryName.toLowerCase().endsWith(".git")) {
            // try to find a project
            UserModel user = null;
            if (request instanceof AuthenticatedRequest) {
                user = ((AuthenticatedRequest) request).getUser();
            }
            ProjectModel project = GitBlit.self().getProjectModel(repositoryName, user);
            if (project != null) {
                isProjectFeed = true;
                repositories = new ArrayList<String>(project.repositories);
                // project feed
                feedName = project.name;
                feedTitle = project.title;
                feedDescription = project.description;
            }
        }
        Map<ObjectId, List<RefModel>> allRefs = JGitUtils.getAllRefs(repository);
        List<FeedEntryModel> entries = new ArrayList<FeedEntryModel>();
        if (repositories == null) {
            // could not find project, assume this is a repository
            repositories = Arrays.asList(repositoryName);
        }
        boolean mountParameters = GitBlit.getBoolean(Keys.web.mountParameters, true);
        String urlPattern;
@@ -182,51 +204,99 @@
        }
        String gitblitUrl = HttpUtils.getGitblitURL(request);
        char fsc = GitBlit.getChar(Keys.web.forwardSlashCharacter, '/');
        // convert RevCommit to SyndicatedEntryModel
        for (RevCommit commit : commits) {
            FeedEntryModel entry = new FeedEntryModel();
            entry.title = commit.getShortMessage();
            entry.author = commit.getAuthorIdent().getName();
            entry.link = MessageFormat.format(urlPattern, gitblitUrl,
                    StringUtils.encodeURL(model.name.replace('/', fsc)), commit.getName());
            entry.published = commit.getCommitterIdent().getWhen();
            entry.contentType = "text/html";
            String message = GitBlit.self().processCommitMessage(model.name,
                    commit.getFullMessage());
            entry.content = message;
            entry.repository = model.name;
            entry.branch = objectId;
            entry.tags = new ArrayList<String>();
        List<FeedEntryModel> entries = new ArrayList<FeedEntryModel>();
        for (String name : repositories) {
            Repository repository = GitBlit.self().getRepository(name);
            RepositoryModel model = GitBlit.self().getRepositoryModel(name);
            
            // add commit id and parent commit ids
            entry.tags.add("commit:" + commit.getName());
            for (RevCommit parent : commit.getParents()) {
                entry.tags.add("parent:" + parent.getName());
            if (!isProjectFeed) {
                // single-repository feed
                feedName = model.name;
                feedTitle = model.name;
                feedDescription = model.description;
            }
            
            // add refs to tabs list
            List<RefModel> refs = allRefs.get(commit.getId());
            if (refs != null && refs.size() > 0) {
                for (RefModel ref : refs) {
                    entry.tags.add("ref:" + ref.getName());
            List<RevCommit> commits;
            if (StringUtils.isEmpty(searchString)) {
                // standard log/history lookup
                commits = JGitUtils.getRevLog(repository, objectId, offset, length);
            } else {
                // repository search
                commits = JGitUtils.searchRevlogs(repository, objectId, searchString, searchType,
                        offset, length);
            }
            Map<ObjectId, List<RefModel>> allRefs = JGitUtils.getAllRefs(repository);
            // convert RevCommit to SyndicatedEntryModel
            for (RevCommit commit : commits) {
                FeedEntryModel entry = new FeedEntryModel();
                entry.title = commit.getShortMessage();
                entry.author = commit.getAuthorIdent().getName();
                entry.link = MessageFormat.format(urlPattern, gitblitUrl,
                        StringUtils.encodeURL(model.name.replace('/', fsc)), commit.getName());
                entry.published = commit.getCommitterIdent().getWhen();
                entry.contentType = "text/html";
                String message = GitBlit.self().processCommitMessage(model.name,
                        commit.getFullMessage());
                entry.content = message;
                entry.repository = model.name;
                entry.branch = objectId;
                entry.tags = new ArrayList<String>();
                // add commit id and parent commit ids
                entry.tags.add("commit:" + commit.getName());
                for (RevCommit parent : commit.getParents()) {
                    entry.tags.add("parent:" + parent.getName());
                }
            }
            entries.add(entry);
                // add refs to tabs list
                List<RefModel> refs = allRefs.get(commit.getId());
                if (refs != null && refs.size() > 0) {
                    for (RefModel ref : refs) {
                        entry.tags.add("ref:" + ref.getName());
                    }
                }
                entries.add(entry);
            }
        }
        // sort & truncate the feed
        Collections.sort(entries);
        if (entries.size() > length) {
            // clip the list
            entries = entries.subList(0, length);
        }
        String feedLink;
        if (mountParameters) {
            // mounted url
            feedLink = MessageFormat.format("{0}/summary/{1}", gitblitUrl,
                    StringUtils.encodeURL(model.name));
        if (isProjectFeed) {
            // project feed
            if (mountParameters) {
                // mounted url
                feedLink = MessageFormat.format("{0}/project/{1}", gitblitUrl,
                        StringUtils.encodeURL(feedName));
            } else {
                // parameterized url
                feedLink = MessageFormat.format("{0}/project/?p={1}", gitblitUrl,
                        StringUtils.encodeURL(feedName));
            }
        } else {
            // parameterized url
            feedLink = MessageFormat.format("{0}/summary/?r={1}", gitblitUrl,
                    StringUtils.encodeURL(model.name));
            // repository feed
            if (mountParameters) {
                // mounted url
                feedLink = MessageFormat.format("{0}/summary/{1}", gitblitUrl,
                        StringUtils.encodeURL(feedName));
            } else {
                // parameterized url
                feedLink = MessageFormat.format("{0}/summary/?r={1}", gitblitUrl,
                        StringUtils.encodeURL(feedName));
            }
        }
        try {
            SyndicationUtils.toRSS(gitblitUrl, feedLink, getTitle(model.name, objectId),
                    model.description, model.name, entries, response.getOutputStream());
            SyndicationUtils.toRSS(gitblitUrl, feedLink, getTitle(feedTitle, objectId),
                    feedDescription, entries, response.getOutputStream());
        } catch (Exception e) {
            logger.error("An error occurred during feed generation", e);
        }
src/com/gitblit/models/ProjectModel.java
New file
@@ -0,0 +1,95 @@
/*
 * Copyright 2012 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 java.util.Collection;
import java.util.Date;
import java.util.HashSet;
import java.util.Set;
import com.gitblit.utils.StringUtils;
/**
 * ProjectModel is a serializable model class.
 *
 * @author James Moger
 *
 */
public class ProjectModel implements Serializable, Comparable<ProjectModel> {
    private static final long serialVersionUID = 1L;
    // field names are reflectively mapped in EditProject page
    public final String name;
    public String title;
    public String description;
    public final Set<String> repositories = new HashSet<String>();
    public Date lastChange;
    public final boolean isRoot;
    public ProjectModel(String name) {
        this(name, false);
    }
    public ProjectModel(String name, boolean isRoot) {
        this.name = name;
        this.isRoot = isRoot;
        this.lastChange = new Date(0);
        this.title = "";
        this.description = "";
    }
    public boolean hasRepository(String name) {
        return repositories.contains(name.toLowerCase());
    }
    public void addRepository(String name) {
        repositories.add(name.toLowerCase());
    }
    public void addRepository(RepositoryModel model) {
        repositories.add(model.name.toLowerCase());
        if (lastChange.before(model.lastChange)) {
            lastChange = model.lastChange;
        }
    }
    public void addRepositories(Collection<String> names) {
        for (String name:names) {
            repositories.add(name.toLowerCase());
        }
    }
    public void removeRepository(String name) {
        repositories.remove(name.toLowerCase());
    }
    public String getDisplayName() {
        return StringUtils.isEmpty(title) ? name : title;
    }
    @Override
    public String toString() {
        return name;
    }
    @Override
    public int compareTo(ProjectModel o) {
        return name.compareTo(o.name);
    }
}
src/com/gitblit/utils/SyndicationUtils.java
@@ -56,14 +56,13 @@
     * @param feedLink
     * @param title
     * @param description
     * @param repository
     * @param entryModels
     * @param os
     * @throws IOException
     * @throws FeedException
     */
    public static void toRSS(String hostUrl, String feedLink, String title, String description,
            String repository, List<FeedEntryModel> entryModels, OutputStream os)
            List<FeedEntryModel> entryModels, OutputStream os)
            throws IOException, FeedException {
        SyndFeed feed = new SyndFeedImpl();
src/com/gitblit/wicket/GitBlitWebApp.java
@@ -42,6 +42,8 @@
import com.gitblit.wicket.pages.MarkdownPage;
import com.gitblit.wicket.pages.MetricsPage;
import com.gitblit.wicket.pages.PatchPage;
import com.gitblit.wicket.pages.ProjectPage;
import com.gitblit.wicket.pages.ProjectsPage;
import com.gitblit.wicket.pages.RawPage;
import com.gitblit.wicket.pages.RepositoriesPage;
import com.gitblit.wicket.pages.ReviewProposalPage;
@@ -112,6 +114,8 @@
        mount("/activity", ActivityPage.class, "r", "h");
        mount("/gravatar", GravatarProfilePage.class, "h");
        mount("/lucene", LuceneSearchPage.class);
        mount("/project", ProjectPage.class, "p");
        mount("/projects", ProjectsPage.class);
    }
    private void mount(String location, Class<? extends WebPage> clazz, String... parameters) {
src/com/gitblit/wicket/GitBlitWebApp.properties
@@ -314,4 +314,8 @@
gb.allowAuthenticatedDescription = grant restricted access to all authenticated users
gb.allowNamedDescription = grant restricted access to named users or teams
gb.markdownFailure = Failed to parse Markdown content!
gb.clearCache = clear cache
gb.clearCache = clear cache
gb.projects = projects
gb.project = project
gb.allProjects = all projects
gb.copyToClipboard = copy to clipboard
src/com/gitblit/wicket/WicketUtils.java
@@ -276,6 +276,10 @@
        return new PageParameters("team=" + teamname);
    }
    public static PageParameters newProjectParameter(String projectName) {
        return new PageParameters("p=" + projectName);
    }
    public static PageParameters newRepositoryParameter(String repositoryName) {
        return new PageParameters("r=" + repositoryName);
    }
@@ -353,6 +357,10 @@
                + ",st=" + type.name() + ",pg=" + pageNumber);
    }
    public static String getProjectName(PageParameters params) {
        return params.getString("p", "");
    }
    public static String getRepositoryName(PageParameters params) {
        return params.getString("r", "");
    }
src/com/gitblit/wicket/pages/BasePage.java
@@ -15,10 +15,19 @@
 */
package com.gitblit.wicket.pages;
import java.util.ArrayList;
import java.util.Calendar;
import java.util.Collections;
import java.util.Comparator;
import java.util.Date;
import java.util.HashSet;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.ResourceBundle;
import java.util.Set;
import java.util.TimeZone;
import java.util.regex.Pattern;
import javax.servlet.http.Cookie;
import javax.servlet.http.HttpServletRequest;
@@ -39,7 +48,6 @@
import org.apache.wicket.protocol.http.WebRequest;
import org.apache.wicket.protocol.http.WebResponse;
import org.apache.wicket.protocol.http.servlet.ServletWebRequest;
import org.apache.wicket.request.RequestParameters;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
@@ -48,10 +56,14 @@
import com.gitblit.Constants.FederationStrategy;
import com.gitblit.GitBlit;
import com.gitblit.Keys;
import com.gitblit.models.ProjectModel;
import com.gitblit.models.RepositoryModel;
import com.gitblit.models.TeamModel;
import com.gitblit.models.UserModel;
import com.gitblit.utils.StringUtils;
import com.gitblit.utils.TimeUtils;
import com.gitblit.wicket.GitBlitWebSession;
import com.gitblit.wicket.PageRegistration.DropDownMenuItem;
import com.gitblit.wicket.WicketUtils;
import com.gitblit.wicket.panels.LinkPanel;
@@ -237,16 +249,98 @@
        }
        return sb.toString();
    }
    protected List<ProjectModel> getProjectModels() {
        final UserModel user = GitBlitWebSession.get().getUser();
        List<ProjectModel> projects = GitBlit.self().getProjectModels(user);
        return projects;
    }
    protected List<ProjectModel> getProjects(PageParameters params) {
        if (params == null) {
            return getProjectModels();
        }
        boolean hasParameter = false;
        String regex = WicketUtils.getRegEx(params);
        String team = WicketUtils.getTeam(params);
        int daysBack = params.getInt("db", 0);
        List<ProjectModel> availableModels = getProjectModels();
        Set<ProjectModel> models = new HashSet<ProjectModel>();
        if (!StringUtils.isEmpty(regex)) {
            // filter the projects by the regex
            hasParameter = true;
            Pattern pattern = Pattern.compile(regex);
            for (ProjectModel model : availableModels) {
                if (pattern.matcher(model.name).find()) {
                    models.add(model);
                }
            }
        }
        if (!StringUtils.isEmpty(team)) {
            // filter the projects by the specified teams
            hasParameter = true;
            List<String> teams = StringUtils.getStringsFromValue(team, ",");
            // need TeamModels first
            List<TeamModel> teamModels = new ArrayList<TeamModel>();
            for (String name : teams) {
                TeamModel teamModel = GitBlit.self().getTeamModel(name);
                if (teamModel != null) {
                    teamModels.add(teamModel);
                }
            }
            // brute-force our way through finding the matching models
            for (ProjectModel projectModel : availableModels) {
                for (String repositoryName : projectModel.repositories) {
                    for (TeamModel teamModel : teamModels) {
                        if (teamModel.hasRepository(repositoryName)) {
                            models.add(projectModel);
                        }
                    }
                }
            }
        }
        if (!hasParameter) {
            models.addAll(availableModels);
        }
        // time-filter the list
        if (daysBack > 0) {
            Calendar cal = Calendar.getInstance();
            cal.set(Calendar.HOUR_OF_DAY, 0);
            cal.set(Calendar.MINUTE, 0);
            cal.set(Calendar.SECOND, 0);
            cal.set(Calendar.MILLISECOND, 0);
            cal.add(Calendar.DATE, -1 * daysBack);
            Date threshold = cal.getTime();
            Set<ProjectModel> timeFiltered = new HashSet<ProjectModel>();
            for (ProjectModel model : models) {
                if (model.lastChange.after(threshold)) {
                    timeFiltered.add(model);
                }
            }
            models = timeFiltered;
        }
        List<ProjectModel> list = new ArrayList<ProjectModel>(models);
        Collections.sort(list);
        return list;
    }
    public void warn(String message, Throwable t) {
        logger.warn(message, t);
    }
    public void error(String message, boolean redirect) {
        logger.error(message  + " for " + GitBlitWebSession.get().getUsername());
        if (redirect) {
            GitBlitWebSession.get().cacheErrorMessage(message);
            RequestParameters params = getRequest().getRequestParameters();
            String relativeUrl = urlFor(RepositoriesPage.class, null).toString();
            String absoluteUrl = RequestUtils.toAbsolutePath(relativeUrl);
            throw new RedirectToUrlException(absoluteUrl);
src/com/gitblit/wicket/pages/ProjectPage.html
New file
@@ -0,0 +1,132 @@
<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Strict//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-strict.dtd">
<html xmlns="http://www.w3.org/1999/xhtml"
      xmlns:wicket="http://wicket.apache.org/dtds.data/wicket-xhtml1.3-strict.dtd"
      xml:lang="en"
      lang="en">
<body>
<wicket:extend>
    <wicket:fragment wicket:id="repositoryAdminLinks">
        <span class="link">
            <a wicket:id="tree"><wicket:message key="gb.tree"></wicket:message></a>
            | <a wicket:id="log"><wicket:message key="gb.log"></wicket:message></a>
            | <a wicket:id="editRepository"><wicket:message key="gb.edit">[edit]</wicket:message></a>
            | <a wicket:id="deleteRepository"><wicket:message key="gb.delete">[delete]</wicket:message></a>
        </span>
    </wicket:fragment>
    <wicket:fragment wicket:id="repositoryOwnerLinks">
        <span class="link">
            <a wicket:id="tree"><wicket:message key="gb.tree"></wicket:message></a>
            | <a wicket:id="log"><wicket:message key="gb.log"></wicket:message></a>
            | <a wicket:id="editRepository"><wicket:message key="gb.edit">[edit]</wicket:message></a>
        </span>
    </wicket:fragment>
    <wicket:fragment wicket:id="repositoryUserLinks">
        <span class="link">
            <a wicket:id="tree"><wicket:message key="gb.tree"></wicket:message></a>
            | <a wicket:id="log"><wicket:message key="gb.log"></wicket:message></a>
        </span>
    </wicket:fragment>
    <div class="row">
        <div class="span12">
            <h2><span wicket:id="projectTitle"></span> <small><span wicket:id="projectDescription"></span></small>
                <a class="hidden-phone hidden-tablet brand" style="text-decoration: none;" wicket:id="syndication" wicket:message="title:gb.feed">
                    <img style="border:0px;vertical-align:middle;" src="feed_16x16.png"></img>
                </a>
            </h2>
            <div class="markdown" wicket:id="projectMessage">[project message]</div>
        </div>
    </div>
    <div class="tabbable">
        <!-- tab titles -->
        <ul class="nav nav-tabs">
            <li class="active"><a href="#repositories" data-toggle="tab"><wicket:message key="gb.repositories"></wicket:message></a></li>
            <li ><a href="#activity" data-toggle="tab"><wicket:message key="gb.activity"></wicket:message></a></li>
        </ul>
        <!-- tab content -->
        <div class="tab-content">
            <!-- repositories tab -->
            <div class="tab-pane active" id="repositories">
                <!-- markdown -->
                <div class="row">
                    <div class="span12">
                        <div class="markdown" wicket:id="repositoriesMessage">[repositories message]</div>
                    </div>
                </div>
                <!-- repositories -->
                <div class="row">
                    <div class="span6" style="border-top:1px solid #eee;" wicket:id="repository">
                        <div style="padding-top:15px;padding-bottom:15px;margin-right:15px;">
                            <div class="pull-right" style="text-align:right;padding-right:15px;">
                                <span wicket:id="repositoryLinks"></span>
                                <div>
                                    <img class="inlineIcon" wicket:id="frozenIcon" />
                                    <img class="inlineIcon" wicket:id="federatedIcon" />
                                    <a style="text-decoration: none;" wicket:id="tickets" wicket:message="title:gb.tickets">
                                        <img style="border:0px;vertical-align:middle;" src="bug_16x16.png"></img>
                                    </a>
                                    <a style="text-decoration: none;" wicket:id="docs" wicket:message="title:gb.docs">
                                        <img style="border:0px;vertical-align:middle;" src="book_16x16.png"></img>
                                    </a>
                                    <a style="text-decoration: none;" wicket:id="syndication" wicket:message="title:gb.feed">
                                        <img style="border:0px;vertical-align:middle;" src="feed_16x16.png"></img>
                                    </a>
                                </div>
                                <span style="color: #999;font-style:italic;font-size:0.8em;" wicket:id="repositoryOwner">[owner]</span>
                            </div>
                            <h3><span class="repositorySwatch" wicket:id="repositorySwatch"></span>
                                <span style="padding-left:3px;" wicket:id="repositoryName">[repository name]</span>
                                <img class="inlineIcon" wicket:id="accessRestrictionIcon" />
                            </h3>
                            <div style="padding-left:20px;">
                                <div style="padding-bottom:10px" wicket:id="repositoryDescription">[repository description]</div>
                                <div style="color: #999;">
                                    <wicket:message key="gb.lastChange">[last change]</wicket:message> <span wicket:id="repositoryLastChange">[last change]</span>,
                                    <span style="font-size:0.8em;" wicket:id="repositorySize">[repository size]</span>
                                </div>
                                <div class="hidden-phone hidden-tablet" wicket:id="repositoryCloneUrl">[repository clone url]</div>
                            </div>
                        </div>
                    </div>
                </div>
            </div>
            <!-- activity tab -->
            <div class="tab-pane" id="activity">
                <div class="pageTitle">
                    <h2><wicket:message key="gb.recentActivity"></wicket:message><small> <span class="hidden-phone">/ <span wicket:id="subheader">[days back]</span></span></small></h2>
                </div>
                <div class="hidden-phone" style="height: 155px;text-align: center;">
                    <table>
                    <tr>
                        <td><span class="hidden-tablet" id="chartDaily"></span></td>
                        <td><span id="chartRepositories"></span></td>
                        <td><span id="chartAuthors"></span></td>
                    </tr>
                    </table>
                </div>
                <div wicket:id="activityPanel">[activity panel]</div>
            </div>
        </div>
    </div>
</wicket:extend>
</body>
</html>
src/com/gitblit/wicket/pages/ProjectPage.java
New file
@@ -0,0 +1,502 @@
/*
 * Copyright 2012 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.wicket.pages;
import java.io.File;
import java.io.FileInputStream;
import java.io.InputStreamReader;
import java.text.MessageFormat;
import java.text.SimpleDateFormat;
import java.util.ArrayList;
import java.util.Collections;
import java.util.Comparator;
import java.util.HashMap;
import java.util.HashSet;
import java.util.List;
import java.util.Map;
import java.util.Set;
import org.apache.wicket.Component;
import org.apache.wicket.PageParameters;
import org.apache.wicket.RedirectException;
import org.apache.wicket.behavior.HeaderContributor;
import org.apache.wicket.markup.html.basic.Label;
import org.apache.wicket.markup.html.link.BookmarkablePageLink;
import org.apache.wicket.markup.html.link.ExternalLink;
import org.apache.wicket.markup.html.link.Link;
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.eclipse.jgit.lib.Constants;
import com.gitblit.GitBlit;
import com.gitblit.Keys;
import com.gitblit.SyndicationServlet;
import com.gitblit.models.Activity;
import com.gitblit.models.Metric;
import com.gitblit.models.ProjectModel;
import com.gitblit.models.RepositoryModel;
import com.gitblit.models.UserModel;
import com.gitblit.utils.ActivityUtils;
import com.gitblit.utils.ArrayUtils;
import com.gitblit.utils.MarkdownUtils;
import com.gitblit.utils.StringUtils;
import com.gitblit.wicket.GitBlitWebApp;
import com.gitblit.wicket.GitBlitWebSession;
import com.gitblit.wicket.PageRegistration;
import com.gitblit.wicket.PageRegistration.DropDownMenuItem;
import com.gitblit.wicket.PageRegistration.DropDownMenuRegistration;
import com.gitblit.wicket.WicketUtils;
import com.gitblit.wicket.charting.GoogleChart;
import com.gitblit.wicket.charting.GoogleCharts;
import com.gitblit.wicket.charting.GoogleLineChart;
import com.gitblit.wicket.charting.GooglePieChart;
import com.gitblit.wicket.panels.ActivityPanel;
import com.gitblit.wicket.panels.BasePanel.JavascriptEventConfirmation;
import com.gitblit.wicket.panels.LinkPanel;
import com.gitblit.wicket.panels.RepositoryUrlPanel;
public class ProjectPage extends RootPage {
    List<ProjectModel> projectModels = new ArrayList<ProjectModel>();
    public ProjectPage() {
        super();
        throw new RedirectException(GitBlitWebApp.get().getHomePage());
    }
    public ProjectPage(PageParameters params) {
        super(params);
        setup(params);
    }
    @Override
    protected boolean reusePageParameters() {
        return true;
    }
    private void setup(PageParameters params) {
        setupPage("", "");
        // check to see if we should display a login message
        boolean authenticateView = GitBlit.getBoolean(Keys.web.authenticateViewPages, true);
        if (authenticateView && !GitBlitWebSession.get().isLoggedIn()) {
            authenticationError("Please login");
            return;
        }
        String projectName = WicketUtils.getProjectName(params);
        if (StringUtils.isEmpty(projectName)) {
            throw new RedirectException(GitBlitWebApp.get().getHomePage());
        }
        ProjectModel project = getProjectModel(projectName);
        if (project == null) {
            throw new RedirectException(GitBlitWebApp.get().getHomePage());
        }
        add(new Label("projectTitle", project.getDisplayName()));
        add(new Label("projectDescription", project.description));
        String feedLink = SyndicationServlet.asLink(getRequest().getRelativePathPrefixToContextRoot(), projectName, null, 0);
        add(new ExternalLink("syndication", feedLink));
        add(WicketUtils.syndicationDiscoveryLink(SyndicationServlet.getTitle(project.getDisplayName(),
                null), feedLink));
        String groupName = projectName;
        if (project.isRoot) {
            groupName = "";
        } else {
            groupName += "/";
        }
        // project markdown message
        File pmkd = new File(GitBlit.getRepositoriesFolder(),  groupName + "project.mkd");
        String pmessage = readMarkdown(projectName, pmkd);
        Component projectMessage = new Label("projectMessage", pmessage)
                .setEscapeModelStrings(false).setVisible(pmessage.length() > 0);
        add(projectMessage);
        // markdown message above repositories list
        File rmkd = new File(GitBlit.getRepositoriesFolder(),  groupName + "repositories.mkd");
        String rmessage = readMarkdown(projectName, rmkd);
        Component repositoriesMessage = new Label("repositoriesMessage", rmessage)
                .setEscapeModelStrings(false).setVisible(rmessage.length() > 0);
        add(repositoriesMessage);
        List<RepositoryModel> repositories = getRepositories(params);
        Collections.sort(repositories, new Comparator<RepositoryModel>() {
            @Override
            public int compare(RepositoryModel o1, RepositoryModel o2) {
                // reverse-chronological sort
                return o2.lastChange.compareTo(o1.lastChange);
            }
        });
        final boolean showSwatch = GitBlit.getBoolean(Keys.web.repositoryListSwatches, true);
        final boolean gitServlet = GitBlit.getBoolean(Keys.git.enableGitServlet, true);
        final boolean showSize = GitBlit.getBoolean(Keys.web.showRepositorySizes, true);
        final ListDataProvider<RepositoryModel> dp = new ListDataProvider<RepositoryModel>(repositories);
        DataView<RepositoryModel> dataView = new DataView<RepositoryModel>("repository", dp) {
            private static final long serialVersionUID = 1L;
            public void populateItem(final Item<RepositoryModel> item) {
                final RepositoryModel entry = item.getModelObject();
                // repository swatch
                Component swatch;
                if (entry.isBare){
                    swatch = new Label("repositorySwatch", "&nbsp;").setEscapeModelStrings(false);
                } else {
                    swatch = new Label("repositorySwatch", "!");
                    WicketUtils.setHtmlTooltip(swatch, getString("gb.workingCopyWarning"));
                }
                WicketUtils.setCssBackground(swatch, entry.toString());
                item.add(swatch);
                swatch.setVisible(showSwatch);
                PageParameters pp = WicketUtils.newRepositoryParameter(entry.name);
                item.add(new LinkPanel("repositoryName", "list", entry.name, SummaryPage.class, pp));
                item.add(new Label("repositoryDescription", entry.description).setVisible(!StringUtils.isEmpty(entry.description)));
                item.add(new BookmarkablePageLink<Void>("tickets", TicketsPage.class, pp).setVisible(entry.useTickets));
                item.add(new BookmarkablePageLink<Void>("docs", DocsPage.class, pp).setVisible(entry.useDocs));
                if (entry.isFrozen) {
                    item.add(WicketUtils.newImage("frozenIcon", "cold_16x16.png",
                            getString("gb.isFrozen")));
                } else {
                    item.add(WicketUtils.newClearPixel("frozenIcon").setVisible(false));
                }
                if (entry.isFederated) {
                    item.add(WicketUtils.newImage("federatedIcon", "federated_16x16.png",
                            getString("gb.isFederated")));
                } else {
                    item.add(WicketUtils.newClearPixel("federatedIcon").setVisible(false));
                }
                switch (entry.accessRestriction) {
                case NONE:
                    item.add(WicketUtils.newBlankImage("accessRestrictionIcon").setVisible(false));
                    break;
                case PUSH:
                    item.add(WicketUtils.newImage("accessRestrictionIcon", "lock_go_16x16.png",
                            getAccessRestrictions().get(entry.accessRestriction)));
                    break;
                case CLONE:
                    item.add(WicketUtils.newImage("accessRestrictionIcon", "lock_pull_16x16.png",
                            getAccessRestrictions().get(entry.accessRestriction)));
                    break;
                case VIEW:
                    item.add(WicketUtils.newImage("accessRestrictionIcon", "shield_16x16.png",
                            getAccessRestrictions().get(entry.accessRestriction)));
                    break;
                default:
                    item.add(WicketUtils.newBlankImage("accessRestrictionIcon"));
                }
                item.add(new Label("repositoryOwner", StringUtils.isEmpty(entry.owner) ? "" : (entry.owner + " (" + getString("gb.owner") + ")")));
                UserModel user = GitBlitWebSession.get().getUser();
                Fragment repositoryLinks;
                boolean showOwner = user != null && user.username.equalsIgnoreCase(entry.owner);
                if (showAdmin || showOwner) {
                    repositoryLinks = new Fragment("repositoryLinks",
                            showAdmin ? "repositoryAdminLinks" : "repositoryOwnerLinks", this);
                    repositoryLinks.add(new BookmarkablePageLink<Void>("editRepository",
                            EditRepositoryPage.class, WicketUtils
                                    .newRepositoryParameter(entry.name)));
                    if (showAdmin) {
                        Link<Void> deleteLink = new Link<Void>("deleteRepository") {
                            private static final long serialVersionUID = 1L;
                            @Override
                            public void onClick() {
                                if (GitBlit.self().deleteRepositoryModel(entry)) {
                                    info(MessageFormat.format(getString("gb.repositoryDeleted"), entry));
                                    // TODO dp.remove(entry);
                                } else {
                                    error(MessageFormat.format(getString("gb.repositoryDeleteFailed"), entry));
                                }
                            }
                        };
                        deleteLink.add(new JavascriptEventConfirmation("onclick", MessageFormat.format(
                                getString("gb.deleteRepository"), entry)));
                        repositoryLinks.add(deleteLink);
                    }
                } else {
                    repositoryLinks = new Fragment("repositoryLinks", "repositoryUserLinks", this);
                }
                repositoryLinks.add(new BookmarkablePageLink<Void>("tree", TreePage.class,
                        WicketUtils.newRepositoryParameter(entry.name)).setEnabled(entry.hasCommits));
                repositoryLinks.add(new BookmarkablePageLink<Void>("log", LogPage.class,
                        WicketUtils.newRepositoryParameter(entry.name)).setEnabled(entry.hasCommits));
                item.add(repositoryLinks);
                String lastChange;
                if (entry.lastChange.getTime() == 0) {
                    lastChange = "--";
                } else {
                    lastChange = getTimeUtils().timeAgo(entry.lastChange);
                }
                Label lastChangeLabel = new Label("repositoryLastChange", lastChange);
                item.add(lastChangeLabel);
                WicketUtils.setCssClass(lastChangeLabel, getTimeUtils().timeAgoCss(entry.lastChange));
                if (entry.hasCommits) {
                    // Existing repository
                    item.add(new Label("repositorySize", entry.size).setVisible(showSize));
                } else {
                    // New repository
                    item.add(new Label("repositorySize", getString("gb.empty"))
                            .setEscapeModelStrings(false));
                }
                item.add(new ExternalLink("syndication", SyndicationServlet.asLink("",
                        entry.name, null, 0)));
                List<String> repositoryUrls = new ArrayList<String>();
                if (gitServlet) {
                    // add the Gitblit repository url
                    repositoryUrls.add(getRepositoryUrl(entry));
                }
                repositoryUrls.addAll(GitBlit.self().getOtherCloneUrls(entry.name));
                String primaryUrl = ArrayUtils.isEmpty(repositoryUrls) ? "" : repositoryUrls.remove(0);
                item.add(new RepositoryUrlPanel("repositoryCloneUrl", primaryUrl));
            }
        };
        add(dataView);
        // project activity
        // parameters
        int daysBack = WicketUtils.getDaysBack(params);
        if (daysBack < 1) {
            daysBack = 14;
        }
        String objectId = WicketUtils.getObject(params);
        List<Activity> recentActivity = ActivityUtils.getRecentActivity(repositories,
                daysBack, objectId, getTimeZone());
        if (recentActivity.size() == 0) {
            // no activity, skip graphs and activity panel
            add(new Label("subheader", MessageFormat.format(getString("gb.recentActivityNone"),
                    daysBack)));
            add(new Label("activityPanel"));
        } else {
            // calculate total commits and total authors
            int totalCommits = 0;
            Set<String> uniqueAuthors = new HashSet<String>();
            for (Activity activity : recentActivity) {
                totalCommits += activity.getCommitCount();
                uniqueAuthors.addAll(activity.getAuthorMetrics().keySet());
            }
            int totalAuthors = uniqueAuthors.size();
            // add the subheader with stat numbers
            add(new Label("subheader", MessageFormat.format(getString("gb.recentActivityStats"),
                    daysBack, totalCommits, totalAuthors)));
            // create the activity charts
            GoogleCharts charts = createCharts(recentActivity);
            add(new HeaderContributor(charts));
            // add activity panel
            add(new ActivityPanel("activityPanel", recentActivity));
        }
    }
    /**
     * Creates the daily activity line chart, the active repositories pie chart,
     * and the active authors pie chart
     *
     * @param recentActivity
     * @return
     */
    private GoogleCharts createCharts(List<Activity> recentActivity) {
        // activity metrics
        Map<String, Metric> repositoryMetrics = new HashMap<String, Metric>();
        Map<String, Metric> authorMetrics = new HashMap<String, Metric>();
        // aggregate repository and author metrics
        for (Activity activity : recentActivity) {
            // aggregate author metrics
            for (Map.Entry<String, Metric> entry : activity.getAuthorMetrics().entrySet()) {
                String author = entry.getKey();
                if (!authorMetrics.containsKey(author)) {
                    authorMetrics.put(author, new Metric(author));
                }
                authorMetrics.get(author).count += entry.getValue().count;
            }
            // aggregate repository metrics
            for (Map.Entry<String, Metric> entry : activity.getRepositoryMetrics().entrySet()) {
                String repository = StringUtils.stripDotGit(entry.getKey());
                if (!repositoryMetrics.containsKey(repository)) {
                    repositoryMetrics.put(repository, new Metric(repository));
                }
                repositoryMetrics.get(repository).count += entry.getValue().count;
            }
        }
        // build google charts
        int w = 310;
        int h = 150;
        GoogleCharts charts = new GoogleCharts();
        // sort in reverse-chronological order and then reverse that
        Collections.sort(recentActivity);
        Collections.reverse(recentActivity);
        // daily line chart
        GoogleChart chart = new GoogleLineChart("chartDaily", getString("gb.dailyActivity"), "day",
                getString("gb.commits"));
        SimpleDateFormat df = new SimpleDateFormat("MMM dd");
        df.setTimeZone(getTimeZone());
        for (Activity metric : recentActivity) {
            chart.addValue(df.format(metric.startDate), metric.getCommitCount());
        }
        chart.setWidth(w);
        chart.setHeight(h);
        charts.addChart(chart);
        // active repositories pie chart
        chart = new GooglePieChart("chartRepositories", getString("gb.activeRepositories"),
                getString("gb.repository"), getString("gb.commits"));
        for (Metric metric : repositoryMetrics.values()) {
            chart.addValue(metric.name, metric.count);
        }
        chart.setWidth(w);
        chart.setHeight(h);
        charts.addChart(chart);
        // active authors pie chart
        chart = new GooglePieChart("chartAuthors", getString("gb.activeAuthors"),
                getString("gb.author"), getString("gb.commits"));
        for (Metric metric : authorMetrics.values()) {
            chart.addValue(metric.name, metric.count);
        }
        chart.setWidth(w);
        chart.setHeight(h);
        charts.addChart(chart);
        return charts;
    }
    @Override
    protected void addDropDownMenus(List<PageRegistration> pages) {
        PageParameters params = getPageParameters();
        DropDownMenuRegistration projects = new DropDownMenuRegistration("gb.projects",
                ProjectPage.class);
        projects.menuItems.addAll(getProjectsMenu());
        pages.add(0, projects);
        DropDownMenuRegistration menu = new DropDownMenuRegistration("gb.filters",
                ProjectPage.class);
        // preserve time filter option on repository choices
        menu.menuItems.addAll(getRepositoryFilterItems(params));
        // preserve repository filter option on time choices
        menu.menuItems.addAll(getTimeFilterItems(params));
        if (menu.menuItems.size() > 0) {
            // Reset Filter
            menu.menuItems.add(new DropDownMenuItem(getString("gb.reset"), null, null));
        }
        pages.add(menu);
    }
    @Override
    protected List<ProjectModel> getProjectModels() {
        if (projectModels.isEmpty()) {
            final UserModel user = GitBlitWebSession.get().getUser();
            List<ProjectModel> projects = GitBlit.self().getProjectModels(user);
            projectModels.addAll(projects);
        }
        return projectModels;
    }
    private ProjectModel getProjectModel(String name) {
        for (ProjectModel project : getProjectModels()) {
            if (name.equalsIgnoreCase(project.name)) {
                return project;
            }
        }
        return null;
    }
    protected List<DropDownMenuItem> getProjectsMenu() {
        List<DropDownMenuItem> menu = new ArrayList<DropDownMenuItem>();
        List<ProjectModel> projects = getProjectModels();
        int maxProjects = 15;
        boolean showAllProjects = projects.size() > maxProjects;
        if (showAllProjects) {
            // sort by last changed
            Collections.sort(projects, new Comparator<ProjectModel>() {
                @Override
                public int compare(ProjectModel o1, ProjectModel o2) {
                    return o2.lastChange.compareTo(o1.lastChange);
                }
            });
            // take most recent subset
            projects = projects.subList(0, maxProjects);
            // sort those by name
            Collections.sort(projects);
        }
        for (ProjectModel project : projects) {
            menu.add(new DropDownMenuItem(project.getDisplayName(), "p", project.name));
        }
        if (showAllProjects) {
            menu.add(new DropDownMenuItem());
            menu.add(new DropDownMenuItem("all projects", null, null));
        }
        return menu;
    }
    private String readMarkdown(String projectName, File projectMessage) {
        String message = "";
        if (projectMessage.exists()) {
            // Read user-supplied message
            try {
                FileInputStream fis = new FileInputStream(projectMessage);
                InputStreamReader reader = new InputStreamReader(fis,
                        Constants.CHARACTER_ENCODING);
                message = MarkdownUtils.transformMarkdown(reader);
                reader.close();
            } catch (Throwable t) {
                message = getString("gb.failedToRead") + " " + projectMessage;
                warn(message, t);
            }
        }
        return message;
    }
}
src/com/gitblit/wicket/pages/ProjectsPage.html
New file
@@ -0,0 +1,37 @@
<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Strict//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-strict.dtd">
<html xmlns="http://www.w3.org/1999/xhtml"
      xmlns:wicket="http://wicket.apache.org/dtds.data/wicket-xhtml1.3-strict.dtd"
      xml:lang="en"
      lang="en">
<body>
<wicket:extend>
    <div class="markdown" style="padding-bottom:5px;" wicket:id="projectsMessage">[projects message]</div>
    <table class="repositories">
        <thead>
            <tr>
                <th class="left">
                    <i class="icon-folder-close" ></i>
                    <wicket:message key="gb.project">Project</wicket:message>
                </th>
                <th class="hidden-phone" ><span><wicket:message key="gb.description">Description</wicket:message></span></th>
                <th class="hidden-phone"><wicket:message key="gb.repositories">Repositories</wicket:message></th>
                <th><wicket:message key="gb.lastChange">Last Change</wicket:message></th>
                <th class="right"></th>
            </tr>
        </thead>
        <tbody>
               <tr wicket:id="project">
                <td class="left" style="padding-left:3px;" ><span style="padding-left:3px;" wicket:id="projectTitle">[project title]</span></td>
                <td class="hidden-phone"><span class="list" wicket:id="projectDescription">[project description]</span></td>
                <td class="hidden-phone" style="padding-right:15px;"><span style="font-size:0.8em;" wicket:id="repositoryCount">[repository count]</span></td>
                <td><span wicket:id="projectLastChange">[last change]</span></td>
                <td class="rightAlign"></td>
               </tr>
        </tbody>
    </table>
</wicket:extend>
</body>
</html>
src/com/gitblit/wicket/pages/ProjectsPage.java
New file
@@ -0,0 +1,224 @@
/*
 * Copyright 2012 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.wicket.pages;
import java.io.File;
import java.io.FileInputStream;
import java.io.InputStream;
import java.io.InputStreamReader;
import java.text.MessageFormat;
import java.util.ArrayList;
import java.util.List;
import org.apache.wicket.Component;
import org.apache.wicket.PageParameters;
import org.apache.wicket.markup.html.basic.Label;
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.resource.ContextRelativeResource;
import org.apache.wicket.util.resource.ResourceStreamNotFoundException;
import org.eclipse.jgit.lib.Constants;
import com.gitblit.GitBlit;
import com.gitblit.Keys;
import com.gitblit.models.ProjectModel;
import com.gitblit.utils.MarkdownUtils;
import com.gitblit.utils.StringUtils;
import com.gitblit.wicket.GitBlitWebSession;
import com.gitblit.wicket.PageRegistration;
import com.gitblit.wicket.PageRegistration.DropDownMenuItem;
import com.gitblit.wicket.PageRegistration.DropDownMenuRegistration;
import com.gitblit.wicket.WicketUtils;
import com.gitblit.wicket.panels.LinkPanel;
public class ProjectsPage extends RootPage {
    List<ProjectModel> projectModels = new ArrayList<ProjectModel>();
    public ProjectsPage() {
        super();
        setup(null);
    }
    public ProjectsPage(PageParameters params) {
        super(params);
        setup(params);
    }
    @Override
    protected boolean reusePageParameters() {
        return true;
    }
    private void setup(PageParameters params) {
        setupPage("", "");
        // check to see if we should display a login message
        boolean authenticateView = GitBlit.getBoolean(Keys.web.authenticateViewPages, true);
        if (authenticateView && !GitBlitWebSession.get().isLoggedIn()) {
            String messageSource = GitBlit.getString(Keys.web.loginMessage, "gitblit");
            String message = readMarkdown(messageSource, "login.mkd");
            Component repositoriesMessage = new Label("projectsMessage", message);
            add(repositoriesMessage.setEscapeModelStrings(false));
            add(new Label("projectsPanel"));
            return;
        }
        // Load the markdown welcome message
        String messageSource = GitBlit.getString(Keys.web.repositoriesMessage, "gitblit");
        String message = readMarkdown(messageSource, "welcome.mkd");
        Component projectsMessage = new Label("projectsMessage", message).setEscapeModelStrings(
                false).setVisible(message.length() > 0);
        add(projectsMessage);
        List<ProjectModel> projects = getProjects(params);
        ListDataProvider<ProjectModel> dp = new ListDataProvider<ProjectModel>(projects);
        DataView<ProjectModel> dataView = new DataView<ProjectModel>("project", dp) {
            private static final long serialVersionUID = 1L;
            int counter;
            @Override
            protected void onBeforeRender() {
                super.onBeforeRender();
                counter = 0;
            }
            public void populateItem(final Item<ProjectModel> item) {
                final ProjectModel entry = item.getModelObject();
                PageParameters pp = WicketUtils.newProjectParameter(entry.name);
                item.add(new LinkPanel("projectTitle", "list", entry.getDisplayName(),
                        ProjectPage.class, pp));
                item.add(new LinkPanel("projectDescription", "list", entry.description,
                        ProjectPage.class, pp));
                item.add(new Label("repositoryCount", entry.repositories.size()
                        + " "
                        + (entry.repositories.size() == 1 ? getString("gb.repository")
                                : getString("gb.repositories"))));
                String lastChange;
                if (entry.lastChange.getTime() == 0) {
                    lastChange = "--";
                } else {
                    lastChange = getTimeUtils().timeAgo(entry.lastChange);
                }
                Label lastChangeLabel = new Label("projectLastChange", lastChange);
                item.add(lastChangeLabel);
                WicketUtils.setCssClass(lastChangeLabel, getTimeUtils()
                        .timeAgoCss(entry.lastChange));
                WicketUtils.setAlternatingBackground(item, counter);
                counter++;
            }
        };
        add(dataView);
        // push the panel down if we are hiding the admin controls and the
        // welcome message
        if (!showAdmin && !projectsMessage.isVisible()) {
            WicketUtils.setCssStyle(dataView, "padding-top:5px;");
        }
    }
    @Override
    protected void addDropDownMenus(List<PageRegistration> pages) {
        PageParameters params = getPageParameters();
        pages.add(0, new PageRegistration("gb.projects", ProjectsPage.class, params));
        DropDownMenuRegistration menu = new DropDownMenuRegistration("gb.filters",
                ProjectsPage.class);
        // preserve time filter option on repository choices
        menu.menuItems.addAll(getRepositoryFilterItems(params));
        // preserve repository filter option on time choices
        menu.menuItems.addAll(getTimeFilterItems(params));
        if (menu.menuItems.size() > 0) {
            // Reset Filter
            menu.menuItems.add(new DropDownMenuItem(getString("gb.reset"), null, null));
        }
        pages.add(menu);
    }
    private String readMarkdown(String messageSource, String resource) {
        String message = "";
        if (messageSource.equalsIgnoreCase("gitblit")) {
            // Read default message
            message = readDefaultMarkdown(resource);
        } else {
            // Read user-supplied message
            if (!StringUtils.isEmpty(messageSource)) {
                File file = new File(messageSource);
                if (file.exists()) {
                    try {
                        FileInputStream fis = new FileInputStream(file);
                        InputStreamReader reader = new InputStreamReader(fis,
                                Constants.CHARACTER_ENCODING);
                        message = MarkdownUtils.transformMarkdown(reader);
                        reader.close();
                    } catch (Throwable t) {
                        message = getString("gb.failedToRead") + " " + file;
                        warn(message, t);
                    }
                } else {
                    message = messageSource + " " + getString("gb.isNotValidFile");
                }
            }
        }
        return message;
    }
    private String readDefaultMarkdown(String file) {
        String content = readDefaultMarkdown(file, getLanguageCode());
        if (StringUtils.isEmpty(content)) {
            content = readDefaultMarkdown(file, null);
        }
        return content;
    }
    private String readDefaultMarkdown(String file, String lc) {
        if (!StringUtils.isEmpty(lc)) {
            // convert to file_lc.mkd
            file = file.substring(0, file.lastIndexOf('.')) + "_" + lc
                    + file.substring(file.lastIndexOf('.'));
        }
        String message;
        try {
            ContextRelativeResource res = WicketUtils.getResource(file);
            InputStream is = res.getResourceStream().getInputStream();
            InputStreamReader reader = new InputStreamReader(is, Constants.CHARACTER_ENCODING);
            message = MarkdownUtils.transformMarkdown(reader);
            reader.close();
        } catch (ResourceStreamNotFoundException t) {
            if (lc == null) {
                // could not find default language resource
                message = MessageFormat.format(getString("gb.failedToReadMessage"), file);
                error(message, t, false);
            } else {
                // ignore so we can try default language resource
                message = null;
            }
        } catch (Throwable t) {
            message = MessageFormat.format(getString("gb.failedToReadMessage"), file);
            error(message, t, false);
        }
        return message;
    }
}
src/com/gitblit/wicket/pages/RepositoryPage.html
@@ -21,7 +21,7 @@
                
                    <div class="nav-collapse" wicket:id="navPanel"></div>
                
                    <a class="hidden-phone hidden-tablet brand" style="text-decoration: none;" wicket:id="syndication">
                    <a class="hidden-phone hidden-tablet brand" style="text-decoration: none;" wicket:id="syndication" wicket:message="title:gb.feed">
                        <img style="border:0px;vertical-align:middle;" src="feed_16x16.png"></img>
                    </a>
                
src/com/gitblit/wicket/pages/RepositoryPage.java
@@ -64,6 +64,7 @@
public abstract class RepositoryPage extends BasePage {
    protected final String projectName;
    protected final String repositoryName;
    protected final String objectId;
    
@@ -78,6 +79,11 @@
    public RepositoryPage(PageParameters params) {
        super(params);
        repositoryName = WicketUtils.getRepositoryName(params);
        if (repositoryName.indexOf('/') > -1) {
            projectName = repositoryName.substring(0, repositoryName.indexOf('/'));
        } else {
            projectName = GitBlit.getString(Keys.web.repositoryRootGroupName, "main");
        }
        objectId = WicketUtils.getObject(params);
        
        if (StringUtils.isEmpty(repositoryName)) {
@@ -117,6 +123,7 @@
        // standard links
        pages.put("repositories", new PageRegistration("gb.repositories", RepositoriesPage.class));
        pages.put("project", new PageRegistration("gb.project", ProjectPage.class, WicketUtils.newProjectParameter(projectName)));
        pages.put("summary", new PageRegistration("gb.summary", SummaryPage.class, params));
        pages.put("log", new PageRegistration("gb.log", LogPage.class, params));
        pages.put("branches", new PageRegistration("gb.branches", BranchesPage.class, params));
src/com/gitblit/wicket/pages/RootPage.java
@@ -178,6 +178,9 @@
            PageParameters pp = getPageParameters();
            if (pp != null) {
                PageParameters params = new PageParameters(pp);
                // remove named project parameter
                params.remove("p");
                // remove named repository parameter
                params.remove("r");
@@ -230,6 +233,7 @@
            final UserModel user = GitBlitWebSession.get().getUser();
            List<RepositoryModel> repositories = GitBlit.self().getRepositoryModels(user);
            repositoryModels.addAll(repositories);
            Collections.sort(repositoryModels);
        }
        return repositoryModels;
    }
@@ -322,6 +326,7 @@
        }
        boolean hasParameter = false;
        String projectName = WicketUtils.getProjectName(params);
        String repositoryName = WicketUtils.getRepositoryName(params);
        String set = WicketUtils.getSet(params);
        String regex = WicketUtils.getRegEx(params);
@@ -338,6 +343,27 @@
                if (model.name.equalsIgnoreCase(repositoryName)) {
                    models.add(model);
                    break;
                }
            }
        }
        if (!StringUtils.isEmpty(projectName)) {
            // try named project
            hasParameter = true;
            if (projectName.equalsIgnoreCase(GitBlit.getString(Keys.web.repositoryRootGroupName, "main"))) {
                // root project/group
                for (RepositoryModel model : availableModels) {
                    if (model.name.indexOf('/') == -1) {
                        models.add(model);
                    }
                }
            } else {
                // named project/group
                String group = projectName.toLowerCase() + "/";
                for (RepositoryModel model : availableModels) {
                    if (model.name.toLowerCase().startsWith(group)) {
                        models.add(model);
                    }
                }
            }
        }
@@ -411,6 +437,9 @@
            }
            models = timeFiltered;
        }
        return new ArrayList<RepositoryModel>(models);
        List<RepositoryModel> list = new ArrayList<RepositoryModel>(models);
        Collections.sort(list);
        return list;
    }
}
src/com/gitblit/wicket/panels/RepositoriesPanel.html
@@ -71,7 +71,8 @@
    </wicket:fragment>
    
    <wicket:fragment wicket:id="groupRepositoryRow">
        <td colspan="7"><span wicket:id="groupName">[group name]</span></td>
        <td colspan="1"><span wicket:id="groupName">[group name]</span></td>
        <td colspan="6"><span class="hidden-phone" style="font-weight:normal;color:#666;" wicket:id="groupDescription">[description]</span></td>
    </wicket:fragment>
        
    <wicket:fragment wicket:id="repositoryRow">
@@ -84,7 +85,7 @@
        <td class="rightAlign">
            <span class="hidden-phone">
                <span wicket:id="repositoryLinks"></span>
                <a style="text-decoration: none;" wicket:id="syndication">
                <a style="text-decoration: none;" wicket:id="syndication" wicket:message="title:gb.feed">
                    <img style="border:0px;vertical-align:middle;" src="feed_16x16.png"></img>
                </a>
            </span>
src/com/gitblit/wicket/panels/RepositoriesPanel.java
@@ -46,6 +46,7 @@
import com.gitblit.GitBlit;
import com.gitblit.Keys;
import com.gitblit.SyndicationServlet;
import com.gitblit.models.ProjectModel;
import com.gitblit.models.RepositoryModel;
import com.gitblit.models.UserModel;
import com.gitblit.utils.StringUtils;
@@ -54,6 +55,7 @@
import com.gitblit.wicket.pages.BasePage;
import com.gitblit.wicket.pages.EditRepositoryPage;
import com.gitblit.wicket.pages.EmptyRepositoryPage;
import com.gitblit.wicket.pages.ProjectPage;
import com.gitblit.wicket.pages.RepositoriesPage;
import com.gitblit.wicket.pages.SummaryPage;
@@ -112,10 +114,20 @@
                roots.add(0, rootPath);
                groups.put(rootPath, rootRepositories);
            }
            Map<String, ProjectModel> projects = new HashMap<String, ProjectModel>();
            for (ProjectModel project : GitBlit.self().getProjectModels(user)) {
                projects.put(project.name, project);
            }
            List<RepositoryModel> groupedModels = new ArrayList<RepositoryModel>();
            for (String root : roots) {
                List<RepositoryModel> subModels = groups.get(root);
                groupedModels.add(new GroupRepositoryModel(root, subModels.size()));
                GroupRepositoryModel group = new GroupRepositoryModel(root, subModels.size());
                if (projects.containsKey(root)) {
                    group.title = projects.get(root).title;
                    group.description = projects.get(root).description;
                }
                groupedModels.add(group);
                Collections.sort(subModels);
                groupedModels.addAll(subModels);
            }
@@ -144,7 +156,8 @@
                    currGroupName = entry.name;
                    Fragment row = new Fragment("rowContent", "groupRepositoryRow", this);
                    item.add(row);
                    row.add(new Label("groupName", entry.toString()));
                    row.add(new LinkPanel("groupName", null, entry.toString(), ProjectPage.class, WicketUtils.newProjectParameter(entry.name)));
                    row.add(new Label("groupDescription", entry.description == null ? "":entry.description));
                    WicketUtils.setCssClass(item, "group");
                    // reset counter so that first row is light background
                    counter = 0;
@@ -326,6 +339,7 @@
        private static final long serialVersionUID = 1L;
        int count;
        String title;
        GroupRepositoryModel(String name, int count) {
            super(name, "", "", new Date(0));
@@ -334,7 +348,7 @@
        @Override
        public String toString() {
            return name + " (" + count + ")";
            return StringUtils.isEmpty(title) ? name  : title + " (" + count + ")";
        }
    }
src/com/gitblit/wicket/panels/RepositoryUrlPanel.html
@@ -9,20 +9,21 @@
    
    <!-- Plain JavaScript manual copy & paste -->
    <wicket:fragment wicket:id="jsPanel">
        <span class="btn" style="padding:0px 3px 0px 3px;vertical-align:middle;">
            <img wicket:id="copyIcon" style="padding-top:1px;"></img>
        <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 style="padding:0px 2px;vertical-align:middle;"
           <object wicket:message="title:gb.copyToClipboard" style="vertical-align:middle;"
               wicket:id="clippy"
               width="110"
               width="14"
               height="14"
               bgcolor="#ffffff" 
               quality="high"
               wmode="transparent"
               scale="noscale"
               allowScriptAccess="always"></object>
    </wicket:fragment>
</wicket:panel>
src/com/gitblit/wicket/panels/RepositoryUrlPanel.java
@@ -42,8 +42,7 @@
        } else {
            // javascript: manual copy & paste with modal browser prompt dialog
            Fragment fragment = new Fragment("copyFunction", "jsPanel", this);
            ContextImage img = WicketUtils.newImage("copyIcon", "clipboard_13x13.png");
            WicketUtils.setHtmlTooltip(img, "Manual Copy to Clipboard");
            ContextImage img = WicketUtils.newImage("copyIcon", "clippy.png");
            img.add(new JavascriptTextPrompt("onclick", "Copy to Clipboard (Ctrl+C, Enter)", url));
            fragment.add(img);
            add(fragment);
tests/com/gitblit/tests/SyndicationUtilsTest.java
@@ -54,7 +54,7 @@
            entries.add(entry);
        }
        ByteArrayOutputStream os = new ByteArrayOutputStream();
        SyndicationUtils.toRSS("http://localhost", "", "Title", "Description", "Repository",
        SyndicationUtils.toRSS("http://localhost", "", "Title", "Description",
                entries, os);
        String feed = os.toString();
        os.close();