From 13a3f5bc3e2d25fc76850f86070dc34efe60d77a Mon Sep 17 00:00:00 2001
From: James Moger <james.moger@gitblit.com>
Date: Fri, 07 Sep 2012 22:06:15 -0400
Subject: [PATCH] Draft project pages, project metadata, and RSS feeds

---
 .gitignore                                            |    1 
 src/com/gitblit/wicket/pages/ProjectsPage.java        |  224 ++++++++
 src/com/gitblit/wicket/panels/RepositoriesPanel.java  |   20 
 src/com/gitblit/wicket/pages/RepositoryPage.java      |    7 
 tests/com/gitblit/tests/SyndicationUtilsTest.java     |    2 
 src/com/gitblit/wicket/pages/ProjectPage.html         |  132 ++++
 src/com/gitblit/GitBlit.java                          |  135 ++++
 src/com/gitblit/wicket/GitBlitWebApp.properties       |    6 
 src/com/gitblit/wicket/WicketUtils.java               |    8 
 src/com/gitblit/wicket/pages/RootPage.java            |   31 +
 src/com/gitblit/wicket/panels/RepositoryUrlPanel.html |    9 
 src/com/gitblit/wicket/pages/RepositoryPage.html      |    2 
 src/com/gitblit/wicket/panels/RepositoriesPanel.html  |    5 
 src/com/gitblit/wicket/panels/RepositoryUrlPanel.java |    3 
 docs/01_setup.mkd                                     |    1 
 src/com/gitblit/SyndicationFilter.java                |  143 +++-
 src/com/gitblit/wicket/pages/ProjectPage.java         |  502 +++++++++++++++++
 distrib/gitblit.properties                            |    5 
 src/com/gitblit/SyndicationServlet.java               |  164 ++++-
 resources/clippy.png                                  |    0 
 src/com/gitblit/utils/SyndicationUtils.java           |    3 
 src/com/gitblit/wicket/pages/BasePage.java            |  100 +++
 src/com/gitblit/models/ProjectModel.java              |   95 +++
 src/com/gitblit/wicket/pages/ProjectsPage.html        |   37 +
 src/com/gitblit/wicket/GitBlitWebApp.java             |    4 
 docs/05_roadmap.mkd                                   |    2 
 resources/gitblit.css                                 |    4 
 27 files changed, 1,529 insertions(+), 116 deletions(-)

diff --git a/.gitignore b/.gitignore
index 6158fcd..e93e18e 100644
--- a/.gitignore
+++ b/.gitignore
@@ -26,3 +26,4 @@
 /users.conf
 *.directory
 /.gradle
+/projects.conf
diff --git a/distrib/gitblit.properties b/distrib/gitblit.properties
index 80cbb7e..c7f0ae3 100644
--- a/distrib/gitblit.properties
+++ b/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.
diff --git a/docs/01_setup.mkd b/docs/01_setup.mkd
index 42f870f..eaaf3be 100644
--- a/docs/01_setup.mkd
+++ b/docs/01_setup.mkd
@@ -9,6 +9,7 @@
     - &lt;context-parameter&gt; *git.repositoryFolder* (set the full path to your repositories folder)
     - &lt;context-parameter&gt; *groovy.scriptsFolder* (set the full path to your Groovy hook scripts folder)
     - &lt;context-parameter&gt; *groovy.grapeFolder* (set the full path to your Groovy Grape artifact cache)
+    - &lt;context-parameter&gt; *web.projectsFile* (set the full path to your projects metadata file)
     - &lt;context-parameter&gt; *realm.userService* (set the full path to `users.conf`)
     - &lt;context-parameter&gt; *git.packedGitLimit* (set larger than the size of your largest repository)
     - &lt;context-parameter&gt; *git.streamFileThreshold* (set larger than the size of your largest committed file)
diff --git a/docs/05_roadmap.mkd b/docs/05_roadmap.mkd
index 6b4def4..3238f73 100644
--- a/docs/05_roadmap.mkd
+++ b/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)
diff --git a/resources/clippy.png b/resources/clippy.png
new file mode 100644
index 0000000..7a462e1
--- /dev/null
+++ b/resources/clippy.png
Binary files differ
diff --git a/resources/gitblit.css b/resources/gitblit.css
index b51637d..7a73a24 100644
--- a/resources/gitblit.css
+++ b/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; 
diff --git a/src/com/gitblit/GitBlit.java b/src/com/gitblit/GitBlit.java
index e6effc2..c758654 100644
--- a/src/com/gitblit/GitBlit.java
+++ b/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.");
diff --git a/src/com/gitblit/SyndicationFilter.java b/src/com/gitblit/SyndicationFilter.java
index 0826566..0dff1c8 100644
--- a/src/com/gitblit/SyndicationFilter.java
+++ b/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);
+	}
 }
diff --git a/src/com/gitblit/SyndicationServlet.java b/src/com/gitblit/SyndicationServlet.java
index 81cfb76..4c542b6 100644
--- a/src/com/gitblit/SyndicationServlet.java
+++ b/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);
 		}
diff --git a/src/com/gitblit/models/ProjectModel.java b/src/com/gitblit/models/ProjectModel.java
new file mode 100644
index 0000000..bc35903
--- /dev/null
+++ b/src/com/gitblit/models/ProjectModel.java
@@ -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);
+	}
+}
diff --git a/src/com/gitblit/utils/SyndicationUtils.java b/src/com/gitblit/utils/SyndicationUtils.java
index 061d12a..d01d469 100644
--- a/src/com/gitblit/utils/SyndicationUtils.java
+++ b/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();
diff --git a/src/com/gitblit/wicket/GitBlitWebApp.java b/src/com/gitblit/wicket/GitBlitWebApp.java
index 5d092e5..507de15 100644
--- a/src/com/gitblit/wicket/GitBlitWebApp.java
+++ b/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) {
diff --git a/src/com/gitblit/wicket/GitBlitWebApp.properties b/src/com/gitblit/wicket/GitBlitWebApp.properties
index 0630a12..c427dd3 100644
--- a/src/com/gitblit/wicket/GitBlitWebApp.properties
+++ b/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
\ No newline at end of file
+gb.clearCache = clear cache
+gb.projects = projects
+gb.project = project
+gb.allProjects = all projects
+gb.copyToClipboard = copy to clipboard
\ No newline at end of file
diff --git a/src/com/gitblit/wicket/WicketUtils.java b/src/com/gitblit/wicket/WicketUtils.java
index 90f7ee6..e4eb29f 100644
--- a/src/com/gitblit/wicket/WicketUtils.java
+++ b/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", "");
 	}
diff --git a/src/com/gitblit/wicket/pages/BasePage.java b/src/com/gitblit/wicket/pages/BasePage.java
index 234c2a9..f9f90b0 100644
--- a/src/com/gitblit/wicket/pages/BasePage.java
+++ b/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);
diff --git a/src/com/gitblit/wicket/pages/ProjectPage.html b/src/com/gitblit/wicket/pages/ProjectPage.html
new file mode 100644
index 0000000..db10329
--- /dev/null
+++ b/src/com/gitblit/wicket/pages/ProjectPage.html
@@ -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>
\ No newline at end of file
diff --git a/src/com/gitblit/wicket/pages/ProjectPage.java b/src/com/gitblit/wicket/pages/ProjectPage.java
new file mode 100644
index 0000000..be3cf38
--- /dev/null
+++ b/src/com/gitblit/wicket/pages/ProjectPage.java
@@ -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;
+	}
+}
diff --git a/src/com/gitblit/wicket/pages/ProjectsPage.html b/src/com/gitblit/wicket/pages/ProjectsPage.html
new file mode 100644
index 0000000..528ed48
--- /dev/null
+++ b/src/com/gitblit/wicket/pages/ProjectsPage.html
@@ -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>
\ No newline at end of file
diff --git a/src/com/gitblit/wicket/pages/ProjectsPage.java b/src/com/gitblit/wicket/pages/ProjectsPage.java
new file mode 100644
index 0000000..f3c4416
--- /dev/null
+++ b/src/com/gitblit/wicket/pages/ProjectsPage.java
@@ -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;
+	}
+}
diff --git a/src/com/gitblit/wicket/pages/RepositoryPage.html b/src/com/gitblit/wicket/pages/RepositoryPage.html
index 3195a93..de64fce 100644
--- a/src/com/gitblit/wicket/pages/RepositoryPage.html
+++ b/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>
 				
diff --git a/src/com/gitblit/wicket/pages/RepositoryPage.java b/src/com/gitblit/wicket/pages/RepositoryPage.java
index 19a5de2..7e21911 100644
--- a/src/com/gitblit/wicket/pages/RepositoryPage.java
+++ b/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));
diff --git a/src/com/gitblit/wicket/pages/RootPage.java b/src/com/gitblit/wicket/pages/RootPage.java
index 40f7aec..4858368 100644
--- a/src/com/gitblit/wicket/pages/RootPage.java
+++ b/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;
 	}
 }
diff --git a/src/com/gitblit/wicket/panels/RepositoriesPanel.html b/src/com/gitblit/wicket/panels/RepositoriesPanel.html
index 99bedc6..5da43e0 100644
--- a/src/com/gitblit/wicket/panels/RepositoriesPanel.html
+++ b/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>
diff --git a/src/com/gitblit/wicket/panels/RepositoriesPanel.java b/src/com/gitblit/wicket/panels/RepositoriesPanel.java
index 8c8e1e5..a113e00 100644
--- a/src/com/gitblit/wicket/panels/RepositoriesPanel.java
+++ b/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 + ")";
 		}
 	}
 
diff --git a/src/com/gitblit/wicket/panels/RepositoryUrlPanel.html b/src/com/gitblit/wicket/panels/RepositoryUrlPanel.html
index 32f79de..d7c76f1 100644
--- a/src/com/gitblit/wicket/panels/RepositoryUrlPanel.html
+++ b/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>
diff --git a/src/com/gitblit/wicket/panels/RepositoryUrlPanel.java b/src/com/gitblit/wicket/panels/RepositoryUrlPanel.java
index a98e40a..58df028 100644
--- a/src/com/gitblit/wicket/panels/RepositoryUrlPanel.java
+++ b/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);
diff --git a/tests/com/gitblit/tests/SyndicationUtilsTest.java b/tests/com/gitblit/tests/SyndicationUtilsTest.java
index 4542adb..75fbd7c 100644
--- a/tests/com/gitblit/tests/SyndicationUtilsTest.java
+++ b/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();

--
Gitblit v1.9.1