diff --git a/scm-core/src/main/java/sonia/scm/repository/BrowserResult.java b/scm-core/src/main/java/sonia/scm/repository/BrowserResult.java index 212ce45f81..17c447eb87 100644 --- a/scm-core/src/main/java/sonia/scm/repository/BrowserResult.java +++ b/scm-core/src/main/java/sonia/scm/repository/BrowserResult.java @@ -1,19 +1,19 @@ /** * Copyright (c) 2010, Sebastian Sdorra * All rights reserved. - * + *

* Redistribution and use in source and binary forms, with or without * modification, are permitted provided that the following conditions are met: - * + *

* 1. Redistributions of source code must retain the above copyright notice, - * this list of conditions and the following disclaimer. + * this list of conditions and the following disclaimer. * 2. Redistributions in binary form must reproduce the above copyright notice, - * this list of conditions and the following disclaimer in the documentation - * and/or other materials provided with the distribution. + * this list of conditions and the following disclaimer in the documentation + * and/or other materials provided with the distribution. * 3. Neither the name of SCM-Manager; nor the names of its - * contributors may be used to endorse or promote products derived from this - * software without specific prior written permission. - * + * contributors may be used to endorse or promote products derived from this + * software without specific prior written permission. + *

* THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" * AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE * IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE @@ -24,13 +24,11 @@ * ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT * (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS * SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. - * + *

* http://bitbucket.org/sdorra/scm-manager - * */ - package sonia.scm.repository; //~--- non-JDK imports -------------------------------------------------------- @@ -40,12 +38,8 @@ import com.google.common.base.Objects; import javax.xml.bind.annotation.XmlAccessType; import javax.xml.bind.annotation.XmlAccessorType; -import javax.xml.bind.annotation.XmlElement; -import javax.xml.bind.annotation.XmlElementWrapper; import javax.xml.bind.annotation.XmlRootElement; import java.io.Serializable; -import java.util.Iterator; -import java.util.List; //~--- JDK imports ------------------------------------------------------------ @@ -56,224 +50,56 @@ import java.util.List; */ @XmlAccessorType(XmlAccessType.FIELD) @XmlRootElement(name = "browser-result") -public class BrowserResult implements Iterable, Serializable -{ +public class BrowserResult implements Serializable { - /** Field description */ - private static final long serialVersionUID = 2818662048045182761L; + private String revision; + private FileObject file; - //~--- constructors --------------------------------------------------------- - - /** - * Constructs ... - * - */ - public BrowserResult() {} - - /** - * Constructs ... - * - * - * @param revision - * @param tag - * @param branch - * @param files - */ - public BrowserResult(String revision, String tag, String branch, - List files) - { - this.revision = revision; - this.tag = tag; - this.branch = branch; - this.files = files; + public BrowserResult() { } - //~--- methods -------------------------------------------------------------- + public BrowserResult(String revision, FileObject file) { + this.revision = revision; + this.file = file; + } + + public String getRevision() { + return revision; + } + + public FileObject getFile() { + return file; + } - /** - * {@inheritDoc} - * - * - * @param obj - * - * @return - */ @Override - public boolean equals(Object obj) - { - if (obj == null) - { + public boolean equals(Object obj) { + if (obj == null) { return false; } - if (getClass() != obj.getClass()) - { + if (getClass() != obj.getClass()) { return false; } final BrowserResult other = (BrowserResult) obj; return Objects.equal(revision, other.revision) - && Objects.equal(tag, other.tag) - && Objects.equal(branch, other.branch) - && Objects.equal(files, other.files); + && Objects.equal(file, other.file); } - /** - * {@inheritDoc} - * - * - * @return - */ @Override - public int hashCode() - { - return Objects.hashCode(revision, tag, branch, files); + public int hashCode() { + return Objects.hashCode(revision, file); } - /** - * Method description - * - * - * @return - */ + @Override - public Iterator iterator() - { - Iterator it = null; - - if (files != null) - { - it = files.iterator(); - } - - return it; - } - - /** - * {@inheritDoc} - * - * - * @return - */ - @Override - public String toString() - { - //J- + public String toString() { return MoreObjects.toStringHelper(this) - .add("revision", revision) - .add("tag", tag) - .add("branch", branch) - .add("files", files) - .toString(); - //J+ + .add("revision", revision) + .add("files", file) + .toString(); } - //~--- get methods ---------------------------------------------------------- - /** - * Method description - * - * - * @return - */ - public String getBranch() - { - return branch; - } - - /** - * Method description - * - * - * @return - */ - public List getFiles() - { - return files; - } - - /** - * Method description - * - * - * @return - */ - public String getRevision() - { - return revision; - } - - /** - * Method description - * - * - * @return - */ - public String getTag() - { - return tag; - } - - //~--- set methods ---------------------------------------------------------- - - /** - * Method description - * - * - * @param branch - */ - public void setBranch(String branch) - { - this.branch = branch; - } - - /** - * Method description - * - * - * @param files - */ - public void setFiles(List files) - { - this.files = files; - } - - /** - * Method description - * - * - * @param revision - */ - public void setRevision(String revision) - { - this.revision = revision; - } - - /** - * Method description - * - * - * @param tag - */ - public void setTag(String tag) - { - this.tag = tag; - } - - //~--- fields --------------------------------------------------------------- - - /** Field description */ - private String branch; - - /** Field description */ - @XmlElement(name = "file") - @XmlElementWrapper(name = "files") - private List files; - - /** Field description */ - private String revision; - - /** Field description */ - private String tag; } diff --git a/scm-core/src/main/java/sonia/scm/repository/FileObject.java b/scm-core/src/main/java/sonia/scm/repository/FileObject.java index 5279921257..7dedebb13a 100644 --- a/scm-core/src/main/java/sonia/scm/repository/FileObject.java +++ b/scm-core/src/main/java/sonia/scm/repository/FileObject.java @@ -33,10 +33,9 @@ package sonia.scm.repository; -//~--- non-JDK imports -------------------------------------------------------- - import com.google.common.base.MoreObjects; import com.google.common.base.Objects; +import com.google.common.base.Strings; import sonia.scm.LastModifiedAware; import javax.xml.bind.annotation.XmlAccessType; @@ -44,8 +43,11 @@ import javax.xml.bind.annotation.XmlAccessorType; import javax.xml.bind.annotation.XmlElement; import javax.xml.bind.annotation.XmlRootElement; import java.io.Serializable; +import java.util.ArrayList; +import java.util.Collection; +import java.util.List; -//~--- JDK imports ------------------------------------------------------------ +import static java.util.Collections.unmodifiableCollection; /** * The FileObject represents a file or a directory in a repository. @@ -181,6 +183,22 @@ public class FileObject implements LastModifiedAware, Serializable return path; } + /** + * Returns the parent path of the file. + * + * @return parent path + */ + public String getParentPath() { + if (Strings.isNullOrEmpty(path)) { + return null; + } + int index = path.lastIndexOf('/'); + if (index > 0) { + return path.substring(0, index); + } + return ""; + } + /** * Return sub repository informations or null if the file is not * sub repository. @@ -284,6 +302,22 @@ public class FileObject implements LastModifiedAware, Serializable this.subRepository = subRepository; } + public Collection getChildren() { + return unmodifiableCollection(children); + } + + public void setChildren(List children) { + this.children = new ArrayList<>(children); + } + + public void addChild(FileObject child) { + this.children.add(child); + } + + public boolean hasChildren() { + return !children.isEmpty(); + } + //~--- fields --------------------------------------------------------------- /** file description */ @@ -307,4 +341,6 @@ public class FileObject implements LastModifiedAware, Serializable /** sub repository informations */ @XmlElement(name = "subrepository") private SubRepository subRepository; + + private Collection children = new ArrayList<>(); } diff --git a/scm-core/src/main/java/sonia/scm/repository/PreProcessorUtil.java b/scm-core/src/main/java/sonia/scm/repository/PreProcessorUtil.java index 2a1d9c0340..e64979dde6 100644 --- a/scm-core/src/main/java/sonia/scm/repository/PreProcessorUtil.java +++ b/scm-core/src/main/java/sonia/scm/repository/PreProcessorUtil.java @@ -161,11 +161,21 @@ public class PreProcessorUtil { if (logger.isTraceEnabled()) { - logger.trace("prepare browser result of repository {} for return", - repository.getName()); + logger.trace("prepare browser result of repository {} for return", repository.getName()); } - handlePreProcessForIterable(repository, result,fileObjectPreProcessorFactorySet, fileObjectPreProcessorSet); + PreProcessorHandler handler = new PreProcessorHandler<>(fileObjectPreProcessorFactorySet, fileObjectPreProcessorSet, repository); + handlePreProcessorForFileObject(handler, result.getFile()); + } + + private void handlePreProcessorForFileObject(PreProcessorHandler handler, FileObject fileObject) { + if (fileObject.isDirectory()) { + for (FileObject child : fileObject.getChildren()) { + handlePreProcessorForFileObject(handler, child); + } + } + handler.callPreProcessorFactories(fileObject); + handler.callPreProcessors(fileObject); } /** diff --git a/scm-core/src/main/java/sonia/scm/repository/api/BrowseCommandBuilder.java b/scm-core/src/main/java/sonia/scm/repository/api/BrowseCommandBuilder.java index fe39aa0a05..d2db6856a7 100644 --- a/scm-core/src/main/java/sonia/scm/repository/api/BrowseCommandBuilder.java +++ b/scm-core/src/main/java/sonia/scm/repository/api/BrowseCommandBuilder.java @@ -38,11 +38,11 @@ package sonia.scm.repository.api; import com.google.common.base.Objects; import org.slf4j.Logger; import org.slf4j.LoggerFactory; +import sonia.scm.NotFoundException; import sonia.scm.cache.Cache; import sonia.scm.cache.CacheManager; import sonia.scm.repository.BrowserResult; import sonia.scm.repository.FileObject; -import sonia.scm.repository.FileObjectNameComparator; import sonia.scm.repository.PreProcessorUtil; import sonia.scm.repository.Repository; import sonia.scm.repository.RepositoryCacheKey; @@ -52,8 +52,6 @@ import sonia.scm.repository.spi.BrowseCommandRequest; import java.io.IOException; import java.io.Serializable; -import java.util.Collections; -import java.util.List; //~--- JDK imports ------------------------------------------------------------ @@ -138,7 +136,7 @@ public final class BrowseCommandBuilder * * @throws IOException */ - public BrowserResult getBrowserResult() throws IOException, RevisionNotFoundException { + public BrowserResult getBrowserResult() throws IOException, NotFoundException { BrowserResult result = null; if (disableCache) @@ -180,14 +178,6 @@ public final class BrowseCommandBuilder if (!disablePreProcessors && (result != null)) { preProcessorUtil.prepareForReturn(repository, result); - - List fileObjects = result.getFiles(); - - if (fileObjects != null) - { - Collections.sort(fileObjects, FileObjectNameComparator.instance); - result.setFiles(fileObjects); - } } return result; diff --git a/scm-core/src/main/java/sonia/scm/repository/spi/BrowseCommand.java b/scm-core/src/main/java/sonia/scm/repository/spi/BrowseCommand.java index 2c9fff589c..be679f9df1 100644 --- a/scm-core/src/main/java/sonia/scm/repository/spi/BrowseCommand.java +++ b/scm-core/src/main/java/sonia/scm/repository/spi/BrowseCommand.java @@ -35,8 +35,8 @@ package sonia.scm.repository.spi; //~--- non-JDK imports -------------------------------------------------------- +import sonia.scm.NotFoundException; import sonia.scm.repository.BrowserResult; -import sonia.scm.repository.RevisionNotFoundException; import java.io.IOException; @@ -60,4 +60,5 @@ public interface BrowseCommand * * @throws IOException */ - BrowserResult getBrowserResult(BrowseCommandRequest request) throws IOException, RevisionNotFoundException;} + BrowserResult getBrowserResult(BrowseCommandRequest request) throws IOException, NotFoundException; +} diff --git a/scm-core/src/test/java/sonia/scm/repository/FileObjectTest.java b/scm-core/src/test/java/sonia/scm/repository/FileObjectTest.java new file mode 100644 index 0000000000..bbd9d0d483 --- /dev/null +++ b/scm-core/src/test/java/sonia/scm/repository/FileObjectTest.java @@ -0,0 +1,32 @@ +package sonia.scm.repository; + +import org.junit.Test; + +import static org.junit.Assert.*; + +public class FileObjectTest { + + @Test + public void getParentPath() { + FileObject file = create("a/b/c"); + assertEquals("a/b", file.getParentPath()); + } + + @Test + public void getParentPathWithoutParent() { + FileObject file = create("a"); + assertEquals("", file.getParentPath()); + } + + @Test + public void getParentPathOfRoot() { + FileObject file = create(""); + assertNull(file.getParentPath()); + } + + private FileObject create(String path) { + FileObject file = new FileObject(); + file.setPath(path); + return file; + } +} diff --git a/scm-it/src/test/java/sonia/scm/it/I18nServletITCase.java b/scm-it/src/test/java/sonia/scm/it/I18nServletITCase.java new file mode 100644 index 0000000000..6597880665 --- /dev/null +++ b/scm-it/src/test/java/sonia/scm/it/I18nServletITCase.java @@ -0,0 +1,19 @@ +package sonia.scm.it; + +import org.junit.Test; +import sonia.scm.it.utils.ScmRequests; + +import static org.assertj.core.api.Assertions.assertThat; + +public class I18nServletITCase { + + @Test + public void shouldGetCollectedPluginTranslations() { + ScmRequests.start() + .requestPluginTranslations("de") + .assertStatusCode(200) + .assertSingleProperty(value -> assertThat(value).isNotNull(), "scm-git-plugin") + .assertSingleProperty(value -> assertThat(value).isNotNull(), "scm-hg-plugin") + .assertSingleProperty(value -> assertThat(value).isNotNull(), "scm-svn-plugin"); + } +} diff --git a/scm-it/src/test/java/sonia/scm/it/RepositoryAccessITCase.java b/scm-it/src/test/java/sonia/scm/it/RepositoryAccessITCase.java index 334274b7b0..66ebc57c90 100644 --- a/scm-it/src/test/java/sonia/scm/it/RepositoryAccessITCase.java +++ b/scm-it/src/test/java/sonia/scm/it/RepositoryAccessITCase.java @@ -197,7 +197,7 @@ public class RepositoryAccessITCase { .then() .statusCode(HttpStatus.SC_OK) .extract() - .path("_embedded.files.find{it.name=='a.txt'}._links.self.href"); + .path("_embedded.children.find{it.name=='a.txt'}._links.self.href"); given() .when() @@ -212,7 +212,7 @@ public class RepositoryAccessITCase { .then() .statusCode(HttpStatus.SC_OK) .extract() - .path("_embedded.files.find{it.name=='subfolder'}._links.self.href"); + .path("_embedded.children.find{it.name=='subfolder'}._links.self.href"); String selfOfSubfolderUrl = given() .when() .get(subfolderSourceUrl) @@ -227,7 +227,7 @@ public class RepositoryAccessITCase { .then() .statusCode(HttpStatus.SC_OK) .extract() - .path("_embedded.files[0]._links.self.href"); + .path("_embedded.children[0]._links.self.href"); given() .when() .get(subfolderContentUrl) diff --git a/scm-it/src/test/java/sonia/scm/it/utils/ScmRequests.java b/scm-it/src/test/java/sonia/scm/it/utils/ScmRequests.java index 69c79c37bf..14caa57beb 100644 --- a/scm-it/src/test/java/sonia/scm/it/utils/ScmRequests.java +++ b/scm-it/src/test/java/sonia/scm/it/utils/ScmRequests.java @@ -2,11 +2,13 @@ package sonia.scm.it.utils; import io.restassured.RestAssured; import io.restassured.response.Response; +import org.junit.Assert; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import sonia.scm.user.User; import sonia.scm.web.VndMediaType; +import java.net.ConnectException; import java.util.List; import java.util.Map; import java.util.function.Consumer; @@ -52,7 +54,12 @@ public class ScmRequests { setUsername(username); setPassword(password); return new ChangePasswordResponse<>(applyPUTRequest(RestUtil.REST_BASE_URL.resolve("users/"+userPathParam+"/password").toString(), VndMediaType.PASSWORD_OVERWRITE, TestData.createPasswordChangeJson(password,newPassword)), null); + } + @SuppressWarnings("unchecked") + public ModelResponse requestPluginTranslations(String language) { + Response response = applyGETRequest(RestUtil.BASE_URL.resolve("locales/" + language + "/plugins.json").toString()); + return new ModelResponse(response, null); } /** @@ -75,10 +82,12 @@ public class ScmRequests { * @return the response of the GET request using the given link */ private Response applyGETRequestFromLinkWithParams(Response response, String linkPropertyName, String params) { - return applyGETRequestWithQueryParams(response + String url = response .then() .extract() - .path(linkPropertyName), params); + .path(linkPropertyName); + Assert.assertNotNull("no url found for link " + linkPropertyName, url); + return applyGETRequestWithQueryParams(url, params); } /** @@ -90,6 +99,11 @@ public class ScmRequests { */ private Response applyGETRequestWithQueryParams(String url, String params) { LOG.info("GET {}", url); + if (username == null || password == null){ + return RestAssured.given() + .when() + .get(url + params); + } return RestAssured.given() .auth().preemptive().basic(username, password) .when() @@ -249,11 +263,11 @@ public class ScmRequests { } public ChangesetsResponse requestFileHistory(String fileName) { - return new ChangesetsResponse<>(applyGETRequestFromLink(response, "_embedded.files.find{it.name=='" + fileName + "'}._links.history.href"), this); + return new ChangesetsResponse<>(applyGETRequestFromLink(response, "_embedded.children.find{it.name=='" + fileName + "'}._links.history.href"), this); } public SourcesResponse requestSelf(String fileName) { - return new SourcesResponse<>(applyGETRequestFromLink(response, "_embedded.files.find{it.name=='" + fileName + "'}._links.self.href"), this); + return new SourcesResponse<>(applyGETRequestFromLink(response, "_embedded.children.find{it.name=='" + fileName + "'}._links.self.href"), this); } } diff --git a/scm-plugins/scm-git-plugin/src/main/java/sonia/scm/repository/spi/GitBrowseCommand.java b/scm-plugins/scm-git-plugin/src/main/java/sonia/scm/repository/spi/GitBrowseCommand.java index f194796bdc..ab1b0ae420 100644 --- a/scm-plugins/scm-git-plugin/src/main/java/sonia/scm/repository/spi/GitBrowseCommand.java +++ b/scm-plugins/scm-git-plugin/src/main/java/sonia/scm/repository/spi/GitBrowseCommand.java @@ -35,9 +35,9 @@ package sonia.scm.repository.spi; //~--- non-JDK imports -------------------------------------------------------- +import com.google.common.base.Strings; import com.google.common.collect.Lists; import com.google.common.collect.Maps; -import org.eclipse.jgit.errors.MissingObjectException; import org.eclipse.jgit.lib.Constants; import org.eclipse.jgit.lib.ObjectId; import org.eclipse.jgit.lib.ObjectLoader; @@ -50,6 +50,7 @@ import org.eclipse.jgit.treewalk.filter.PathFilter; import org.eclipse.jgit.treewalk.filter.TreeFilter; import org.slf4j.Logger; import org.slf4j.LoggerFactory; +import sonia.scm.NotFoundException; import sonia.scm.repository.BrowserResult; import sonia.scm.repository.FileObject; import sonia.scm.repository.GitSubModuleParser; @@ -103,10 +104,11 @@ public class GitBrowseCommand extends AbstractGitCommand @Override @SuppressWarnings("unchecked") public BrowserResult getBrowserResult(BrowseCommandRequest request) - throws IOException, RevisionNotFoundException { + throws IOException, NotFoundException { logger.debug("try to create browse result for {}", request); BrowserResult result; + org.eclipse.jgit.lib.Repository repo = open(); ObjectId revId; @@ -121,7 +123,7 @@ public class GitBrowseCommand extends AbstractGitCommand if (revId != null) { - result = getResult(repo, request, revId); + result = new BrowserResult(revId.getName(), getEntry(repo, request, revId)); } else { @@ -134,8 +136,7 @@ public class GitBrowseCommand extends AbstractGitCommand logger.warn("coul not find head of repository, empty?"); } - result = new BrowserResult(Constants.HEAD, null, null, - Collections.EMPTY_LIST); + result = new BrowserResult(Constants.HEAD, createEmtpyRoot()); } return result; @@ -143,6 +144,14 @@ public class GitBrowseCommand extends AbstractGitCommand //~--- methods -------------------------------------------------------------- + private FileObject createEmtpyRoot() { + FileObject fileObject = new FileObject(); + fileObject.setName(""); + fileObject.setPath(""); + fileObject.setDirectory(true); + return fileObject; + } + /** * Method description * @@ -158,68 +167,52 @@ public class GitBrowseCommand extends AbstractGitCommand private FileObject createFileObject(org.eclipse.jgit.lib.Repository repo, BrowseCommandRequest request, ObjectId revId, TreeWalk treeWalk) throws IOException, RevisionNotFoundException { - FileObject file; - try + FileObject file = new FileObject(); + + String path = treeWalk.getPathString(); + + file.setName(treeWalk.getNameString()); + file.setPath(path); + + SubRepository sub = null; + + if (!request.isDisableSubRepositoryDetection()) { - file = new FileObject(); + sub = getSubRepository(repo, revId, path); + } - String path = treeWalk.getPathString(); + if (sub != null) + { + logger.trace("{} seems to be a sub repository", path); + file.setDirectory(true); + file.setSubRepository(sub); + } + else + { + ObjectLoader loader = repo.open(treeWalk.getObjectId(0)); - file.setName(treeWalk.getNameString()); - file.setPath(path); + file.setDirectory(loader.getType() == Constants.OBJ_TREE); + file.setLength(loader.getSize()); - SubRepository sub = null; - - if (!request.isDisableSubRepositoryDetection()) + // don't show message and date for directories to improve performance + if (!file.isDirectory() &&!request.isDisableLastCommit()) { - sub = getSubRepository(repo, revId, path); - } + logger.trace("fetch last commit for {} at {}", path, revId.getName()); + RevCommit commit = getLatestCommit(repo, revId, path); - if (sub != null) - { - logger.trace("{} seems to be a sub repository", path); - file.setDirectory(true); - file.setSubRepository(sub); - } - else - { - ObjectLoader loader = repo.open(treeWalk.getObjectId(0)); - - file.setDirectory(loader.getType() == Constants.OBJ_TREE); - file.setLength(loader.getSize()); - - // don't show message and date for directories to improve performance - if (!file.isDirectory() &&!request.isDisableLastCommit()) + if (commit != null) { - logger.trace("fetch last commit for {} at {}", path, revId.getName()); - - RevCommit commit = getLatestCommit(repo, revId, path); - - if (commit != null) - { - file.setLastModified(GitUtil.getCommitTime(commit)); - file.setDescription(commit.getShortMessage()); - } - else if (logger.isWarnEnabled()) - { - logger.warn("could not find latest commit for {} on {}", path, - revId); - } + file.setLastModified(GitUtil.getCommitTime(commit)); + file.setDescription(commit.getShortMessage()); + } + else if (logger.isWarnEnabled()) + { + logger.warn("could not find latest commit for {} on {}", path, + revId); } } } - catch (MissingObjectException ex) - { - file = null; - logger.error("could not fetch object for id {}", revId); - - if (logger.isTraceEnabled()) - { - logger.trace("could not fetch object", ex); - } - } - return file; } @@ -265,22 +258,19 @@ public class GitBrowseCommand extends AbstractGitCommand return result; } - private BrowserResult getResult(org.eclipse.jgit.lib.Repository repo, - BrowseCommandRequest request, ObjectId revId) - throws IOException, RevisionNotFoundException { - BrowserResult result = null; + private FileObject getEntry(org.eclipse.jgit.lib.Repository repo, BrowseCommandRequest request, ObjectId revId) throws IOException, NotFoundException { RevWalk revWalk = null; TreeWalk treeWalk = null; - try - { - if (logger.isDebugEnabled()) - { - logger.debug("load repository browser for revision {}", revId.name()); - } + FileObject result; + + try { + logger.debug("load repository browser for revision {}", revId.name()); treeWalk = new TreeWalk(repo); - treeWalk.setRecursive(request.isRecursive()); + if (!isRootRequest(request)) { + treeWalk.setFilter(PathFilter.create(request.getPath())); + } revWalk = new RevWalk(repo); RevTree tree = revWalk.parseTree(revId); @@ -291,65 +281,20 @@ public class GitBrowseCommand extends AbstractGitCommand } else { - logger.error("could not find tree for {}", revId.name()); + throw new IllegalStateException("could not find tree for " + revId.name()); } - result = new BrowserResult(); - - List files = Lists.newArrayList(); - - String path = request.getPath(); - - if (Util.isEmpty(path)) - { - while (treeWalk.next()) - { - FileObject fo = createFileObject(repo, request, revId, treeWalk); - - if (fo != null) - { - files.add(fo); - } - } - } - else - { - String[] parts = path.split("/"); - int current = 0; - int limit = parts.length; - - while (treeWalk.next()) - { - String name = treeWalk.getNameString(); - - if (current >= limit) - { - String p = treeWalk.getPathString(); - - if (p.split("/").length > limit) - { - FileObject fo = createFileObject(repo, request, revId, treeWalk); - - if (fo != null) - { - files.add(fo); - } - } - } - else if (name.equalsIgnoreCase(parts[current])) - { - current++; - - if (!request.isRecursive()) - { - treeWalk.enterSubtree(); - } - } + if (isRootRequest(request)) { + result = createEmtpyRoot(); + findChildren(result, repo, request, revId, treeWalk); + } else { + result = findFirstMatch(repo, request, revId, treeWalk); + if ( result.isDirectory() ) { + treeWalk.enterSubtree(); + findChildren(result, repo, request, revId, treeWalk); } } - result.setFiles(files); - result.setRevision(revId.getName()); } finally { @@ -360,6 +305,60 @@ public class GitBrowseCommand extends AbstractGitCommand return result; } + private boolean isRootRequest(BrowseCommandRequest request) { + return Strings.isNullOrEmpty(request.getPath()) || "/".equals(request.getPath()); + } + + private FileObject findChildren(FileObject parent, org.eclipse.jgit.lib.Repository repo, BrowseCommandRequest request, ObjectId revId, TreeWalk treeWalk) throws IOException, NotFoundException { + List files = Lists.newArrayList(); + while (treeWalk.next()) + { + + FileObject fileObject = createFileObject(repo, request, revId, treeWalk); + if (!fileObject.getPath().startsWith(parent.getPath())) { + parent.setChildren(files); + return fileObject; + } + + files.add(fileObject); + + if (request.isRecursive() && fileObject.isDirectory()) { + treeWalk.enterSubtree(); + FileObject rc = findChildren(fileObject, repo, request, revId, treeWalk); + if (rc != null) { + files.add(rc); + } + } + } + + parent.setChildren(files); + + return null; + } + + private FileObject findFirstMatch(org.eclipse.jgit.lib.Repository repo, + BrowseCommandRequest request, ObjectId revId, TreeWalk treeWalk) throws IOException, NotFoundException { + String[] pathElements = request.getPath().split("/"); + int currentDepth = 0; + int limit = pathElements.length; + + while (treeWalk.next()) { + String name = treeWalk.getNameString(); + + if (name.equalsIgnoreCase(pathElements[currentDepth])) { + currentDepth++; + + if (currentDepth >= limit) { + return createFileObject(repo, request, revId, treeWalk); + } else { + treeWalk.enterSubtree(); + } + } + } + + throw new NotFoundException("file", request.getPath()); + } + @SuppressWarnings("unchecked") private Map getSubRepositories(org.eclipse.jgit.lib.Repository repo, diff --git a/scm-plugins/scm-git-plugin/src/main/js/ProtocolInformation.js b/scm-plugins/scm-git-plugin/src/main/js/ProtocolInformation.js index d8eb4ae0e0..c6aed483e7 100644 --- a/scm-plugins/scm-git-plugin/src/main/js/ProtocolInformation.js +++ b/scm-plugins/scm-git-plugin/src/main/js/ProtocolInformation.js @@ -2,15 +2,17 @@ import React from "react"; import { repositories } from "@scm-manager/ui-components"; import type { Repository } from "@scm-manager/ui-types"; +import { translate } from "react-i18next"; type Props = { - repository: Repository + repository: Repository, + t: string => string } class ProtocolInformation extends React.Component { render() { - const { repository } = this.props; + const { repository, t } = this.props; const href = repositories.getProtocolLinkByType(repository, "http"); if (!href) { return null; @@ -18,11 +20,11 @@ class ProtocolInformation extends React.Component { return (

-

Clone the repository

+

{t("scm-git-plugin.information.clone")}

           git clone {href}
         
-

Create a new repository

+

{t("scm-git-plugin.information.create")}

           
             git init {repository.name}
@@ -39,7 +41,7 @@ class ProtocolInformation extends React.Component {
             
-

Push an existing repository

+

{t("scm-git-plugin.information.replace")}

           
             git remote add origin {href}
@@ -54,4 +56,4 @@ class ProtocolInformation extends React.Component {
 
 }
 
-export default ProtocolInformation;
+export default translate("plugins")(ProtocolInformation);
diff --git a/scm-plugins/scm-git-plugin/src/main/resources/locales/de/plugins.json b/scm-plugins/scm-git-plugin/src/main/resources/locales/de/plugins.json
new file mode 100644
index 0000000000..1dc0e254c2
--- /dev/null
+++ b/scm-plugins/scm-git-plugin/src/main/resources/locales/de/plugins.json
@@ -0,0 +1,9 @@
+{
+  "scm-git-plugin": {
+    "information": {
+      "clone" : "Repository Klonen",
+      "create" : "Neue Repository erstellen",
+      "replace" : "Eine existierende Repository aktualisieren"
+    }
+  }
+}
diff --git a/scm-plugins/scm-git-plugin/src/main/resources/locales/en/plugins.json b/scm-plugins/scm-git-plugin/src/main/resources/locales/en/plugins.json
new file mode 100644
index 0000000000..65594bae19
--- /dev/null
+++ b/scm-plugins/scm-git-plugin/src/main/resources/locales/en/plugins.json
@@ -0,0 +1,9 @@
+{
+  "scm-git-plugin": {
+    "information": {
+      "clone" : "Clone the repository",
+      "create" : "Create a new repository",
+      "replace" : "Push an existing repository"
+    }
+  }
+}
diff --git a/scm-plugins/scm-git-plugin/src/test/java/sonia/scm/repository/spi/GitBrowseCommandTest.java b/scm-plugins/scm-git-plugin/src/test/java/sonia/scm/repository/spi/GitBrowseCommandTest.java
index d71c85a152..92b7ff69a9 100644
--- a/scm-plugins/scm-git-plugin/src/test/java/sonia/scm/repository/spi/GitBrowseCommandTest.java
+++ b/scm-plugins/scm-git-plugin/src/test/java/sonia/scm/repository/spi/GitBrowseCommandTest.java
@@ -6,13 +6,13 @@
  * modification, are permitted provided that the following conditions are met:
  *
  * 1. Redistributions of source code must retain the above copyright notice,
- *    this list of conditions and the following disclaimer.
+ * this list of conditions and the following disclaimer.
  * 2. Redistributions in binary form must reproduce the above copyright notice,
- *    this list of conditions and the following disclaimer in the documentation
- *    and/or other materials provided with the distribution.
+ * this list of conditions and the following disclaimer in the documentation
+ * and/or other materials provided with the distribution.
  * 3. Neither the name of SCM-Manager; nor the names of its
- *    contributors may be used to endorse or promote products derived from this
- *    software without specific prior written permission.
+ * contributors may be used to endorse or promote products derived from this
+ * software without specific prior written permission.
  *
  * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
  * AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
@@ -26,152 +26,114 @@
  * SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
  *
  * http://bitbucket.org/sdorra/scm-manager
- *
  */
 
 
-
 package sonia.scm.repository.spi;
 
-//~--- non-JDK imports --------------------------------------------------------
-
 import org.junit.Test;
+import sonia.scm.NotFoundException;
 import sonia.scm.repository.BrowserResult;
 import sonia.scm.repository.FileObject;
 import sonia.scm.repository.GitConstants;
-import sonia.scm.repository.RevisionNotFoundException;
 
 import java.io.IOException;
-import java.util.List;
+import java.util.Collection;
 
+import static org.assertj.core.api.Assertions.assertThat;
 import static org.junit.Assert.assertEquals;
 import static org.junit.Assert.assertFalse;
 import static org.junit.Assert.assertNotNull;
 import static org.junit.Assert.assertTrue;
 
-//~--- JDK imports ------------------------------------------------------------
-
 /**
  * Unit tests for {@link GitBrowseCommand}.
- * 
+ *
  * @author Sebastian Sdorra
  */
-public class GitBrowseCommandTest extends AbstractGitCommandTestBase
-{
-  
-  /**
-   * Test browse command with default branch.
-   */
+public class GitBrowseCommandTest extends AbstractGitCommandTestBase {
+
   @Test
-  public void testDefaultBranch() throws IOException, RevisionNotFoundException {
-    // without default branch, the repository head should be used
-    BrowserResult result = createCommand().getBrowserResult(new BrowseCommandRequest());
-    assertNotNull(result);
-
-    List foList = result.getFiles(); 
-    assertNotNull(foList);
-    assertFalse(foList.isEmpty());
-    assertEquals(4, foList.size());
-    
-    assertEquals("a.txt", foList.get(0).getName());
-    assertEquals("b.txt", foList.get(1).getName());
-    assertEquals("c", foList.get(2).getName());
-    assertEquals("f.txt", foList.get(3).getName());
-    
-    // set default branch and fetch again
-    repository.setProperty(GitConstants.PROPERTY_DEFAULT_BRANCH, "test-branch");
-    result = createCommand().getBrowserResult(new BrowseCommandRequest());
-    assertNotNull(result);
-
-    foList = result.getFiles(); 
-    assertNotNull(foList);
-    assertFalse(foList.isEmpty());
-    assertEquals(2, foList.size());
-    
-    assertEquals("a.txt", foList.get(0).getName());
-    assertEquals("c", foList.get(1).getName());
+  public void testGetFile() throws IOException, NotFoundException {
+    BrowseCommandRequest request = new BrowseCommandRequest();
+    request.setPath("a.txt");
+    BrowserResult result = createCommand().getBrowserResult(request);
+    FileObject fileObject = result.getFile();
+    assertEquals("a.txt", fileObject.getName());
   }
 
   @Test
-  public void testBrowse() throws IOException, RevisionNotFoundException {
-    BrowserResult result =
-      createCommand().getBrowserResult(new BrowseCommandRequest());
-
-    assertNotNull(result);
-
-    List foList = result.getFiles();
+  public void testDefaultDefaultBranch() throws IOException, NotFoundException {
+    // without default branch, the repository head should be used
+    FileObject root = createCommand().getBrowserResult(new BrowseCommandRequest()).getFile();
+    assertNotNull(root);
 
+    Collection foList = root.getChildren();
     assertNotNull(foList);
     assertFalse(foList.isEmpty());
-    assertEquals(4, foList.size());
 
-    FileObject a = null;
-    FileObject c = null;
+    assertThat(foList)
+      .extracting("name")
+      .containsExactly("a.txt", "b.txt", "c", "f.txt");
+  }
 
-    for (FileObject f : foList)
-    {
-      if ("a.txt".equals(f.getName()))
-      {
-        a = f;
-      }
-      else if ("c".equals(f.getName()))
-      {
-        c = f;
-      }
-    }
+  @Test
+  public void testExplicitDefaultBranch() throws IOException, NotFoundException {
+    repository.setProperty(GitConstants.PROPERTY_DEFAULT_BRANCH, "test-branch");
+
+    FileObject root = createCommand().getBrowserResult(new BrowseCommandRequest()).getFile();
+    assertNotNull(root);
+
+    Collection foList = root.getChildren();
+    assertThat(foList)
+      .extracting("name")
+      .containsExactly("a.txt", "c");
+  }
+
+  @Test
+  public void testBrowse() throws IOException, NotFoundException {
+    FileObject root = createCommand().getBrowserResult(new BrowseCommandRequest()).getFile();
+    assertNotNull(root);
+
+    Collection foList = root.getChildren();
+
+    FileObject a = findFile(foList, "a.txt");
+    FileObject c = findFile(foList, "c");
 
-    assertNotNull(a);
     assertFalse(a.isDirectory());
     assertEquals("a.txt", a.getName());
     assertEquals("a.txt", a.getPath());
     assertEquals("added new line for blame", a.getDescription());
     assertTrue(a.getLength() > 0);
     checkDate(a.getLastModified());
-    assertNotNull(c);
+
     assertTrue(c.isDirectory());
     assertEquals("c", c.getName());
     assertEquals("c", c.getPath());
   }
 
   @Test
-  public void testBrowseSubDirectory() throws IOException, RevisionNotFoundException {
+  public void testBrowseSubDirectory() throws IOException, NotFoundException {
     BrowseCommandRequest request = new BrowseCommandRequest();
 
     request.setPath("c");
 
-    BrowserResult result = createCommand().getBrowserResult(request);
+    FileObject root = createCommand().getBrowserResult(request).getFile();
 
-    assertNotNull(result);
+    Collection foList = root.getChildren();
 
-    List foList = result.getFiles();
+    assertThat(foList).hasSize(2);
 
-    assertNotNull(foList);
-    assertFalse(foList.isEmpty());
-    assertEquals(2, foList.size());
+    FileObject d = findFile(foList, "d.txt");
+    FileObject e = findFile(foList, "e.txt");
 
-    FileObject d = null;
-    FileObject e = null;
-
-    for (FileObject f : foList)
-    {
-      if ("d.txt".equals(f.getName()))
-      {
-        d = f;
-      }
-      else if ("e.txt".equals(f.getName()))
-      {
-        e = f;
-      }
-    }
-
-    assertNotNull(d);
     assertFalse(d.isDirectory());
     assertEquals("d.txt", d.getName());
     assertEquals("c/d.txt", d.getPath());
     assertEquals("added file d and e in folder c", d.getDescription());
     assertTrue(d.getLength() > 0);
     checkDate(d.getLastModified());
-    assertNotNull(e);
+
     assertFalse(e.isDirectory());
     assertEquals("e.txt", e.getName());
     assertEquals("c/e.txt", e.getPath());
@@ -181,30 +143,35 @@ public class GitBrowseCommandTest extends AbstractGitCommandTestBase
   }
 
   @Test
-  public void testRecusive() throws IOException, RevisionNotFoundException {
+  public void testRecursive() throws IOException, NotFoundException {
     BrowseCommandRequest request = new BrowseCommandRequest();
 
     request.setRecursive(true);
 
-    BrowserResult result = createCommand().getBrowserResult(request);
+    FileObject root = createCommand().getBrowserResult(request).getFile();
 
-    assertNotNull(result);
+    Collection foList = root.getChildren();
 
-    List foList = result.getFiles();
+    assertThat(foList)
+      .extracting("name")
+      .containsExactly("a.txt", "b.txt", "c", "f.txt");
 
-    assertNotNull(foList);
-    assertFalse(foList.isEmpty());
-    assertEquals(5, foList.size());
+    FileObject c = findFile(foList, "c");
+
+    Collection cChildren = c.getChildren();
+    assertThat(cChildren)
+      .extracting("name")
+      .containsExactly("d.txt", "e.txt");
   }
 
-  /**
-   * Method description
-   *
-   *
-   * @return
-   */
-  private GitBrowseCommand createCommand()
-  {
+  private FileObject findFile(Collection foList, String name) {
+    return foList.stream()
+      .filter(f -> name.equals(f.getName()))
+      .findFirst()
+      .orElseThrow(() -> new AssertionError("file " + name + " not found"));
+  }
+
+  private GitBrowseCommand createCommand() {
     return new GitBrowseCommand(createContext(), repository);
   }
 }
diff --git a/scm-plugins/scm-hg-plugin/src/main/java/sonia/scm/repository/spi/HgBrowseCommand.java b/scm-plugins/scm-hg-plugin/src/main/java/sonia/scm/repository/spi/HgBrowseCommand.java
index 4e4721ba14..19a8724b69 100644
--- a/scm-plugins/scm-hg-plugin/src/main/java/sonia/scm/repository/spi/HgBrowseCommand.java
+++ b/scm-plugins/scm-hg-plugin/src/main/java/sonia/scm/repository/spi/HgBrowseCommand.java
@@ -35,8 +35,10 @@ package sonia.scm.repository.spi;
 
 //~--- non-JDK imports --------------------------------------------------------
 
+import com.google.common.base.MoreObjects;
 import com.google.common.base.Strings;
 import sonia.scm.repository.BrowserResult;
+import sonia.scm.repository.FileObject;
 import sonia.scm.repository.Repository;
 import sonia.scm.repository.spi.javahg.HgFileviewCommand;
 
@@ -45,6 +47,7 @@ import java.io.IOException;
 //~--- JDK imports ------------------------------------------------------------
 
 /**
+ * Utilizes the mercurial fileview extension in order to support mercurial repository browsing.
  *
  * @author Sebastian Sdorra
  */
@@ -94,16 +97,7 @@ public class HgBrowseCommand extends AbstractCommand implements BrowseCommand
       cmd.disableSubRepositoryDetection();
     }
 
-    BrowserResult result = new BrowserResult();
-
-    result.setFiles(cmd.execute());
-
-    if (!Strings.isNullOrEmpty(request.getRevision())) {
-      result.setRevision(request.getRevision());
-    } else {
-      result.setRevision("tip");
-    }
-
-    return result;
+    FileObject file = cmd.execute();
+    return new BrowserResult(MoreObjects.firstNonNull(request.getRevision(), "tip"), file);
   }
 }
diff --git a/scm-plugins/scm-hg-plugin/src/main/java/sonia/scm/repository/spi/javahg/HgFileviewCommand.java b/scm-plugins/scm-hg-plugin/src/main/java/sonia/scm/repository/spi/javahg/HgFileviewCommand.java
index 74695217d2..f351ffa572 100644
--- a/scm-plugins/scm-hg-plugin/src/main/java/sonia/scm/repository/spi/javahg/HgFileviewCommand.java
+++ b/scm-plugins/scm-hg-plugin/src/main/java/sonia/scm/repository/spi/javahg/HgFileviewCommand.java
@@ -50,35 +50,31 @@ import sonia.scm.repository.SubRepository;
 
 import java.io.IOException;
 
+import java.util.Deque;
+import java.util.LinkedList;
 import java.util.List;
 
 /**
+ * Mercurial command to list files of a repository.
  *
  * @author Sebastian Sdorra
  */
 public class HgFileviewCommand extends AbstractCommand
 {
 
-  /**
-   * Constructs ...
-   *
-   *
-   * @param repository
-   */
-  public HgFileviewCommand(Repository repository)
+  private boolean disableLastCommit = false;
+
+  private HgFileviewCommand(Repository repository)
   {
     super(repository);
   }
 
-  //~--- methods --------------------------------------------------------------
-
   /**
-   * Method description
+   * Create command for the given repository.
    *
+   * @param repository repository
    *
-   * @param repository
-   *
-   * @return
+   * @return fileview command
    */
   public static HgFileviewCommand on(Repository repository)
   {
@@ -86,13 +82,11 @@ public class HgFileviewCommand extends AbstractCommand
   }
 
   /**
-   * Method description
+   * Disable last commit fetching for file objects.
    *
-   *
-   * @return
+   * @return {@code this}
    */
-  public HgFileviewCommand disableLastCommit()
-  {
+  public HgFileviewCommand disableLastCommit() {
     disableLastCommit = true;
     cmdAppend("-d");
 
@@ -100,132 +94,128 @@ public class HgFileviewCommand extends AbstractCommand
   }
 
   /**
-   * Method description
+   * Disables sub repository detection
    *
-   *
-   * @return
+   * @return {@code this}
    */
-  public HgFileviewCommand disableSubRepositoryDetection()
-  {
+  public HgFileviewCommand disableSubRepositoryDetection() {
     cmdAppend("-s");
 
     return this;
   }
 
   /**
-   * Method description
+   * Start file object fetching at the given path.
    *
    *
-   * @return
+   * @param path path to start fetching
    *
-   * @throws IOException
+   * @return {@code this}
    */
-  public List execute() throws IOException
-  {
-    cmdAppend("-t");
-
-    List files = Lists.newArrayList();
-
-    HgInputStream stream = launchStream();
-
-    while (stream.peek() != -1)
-    {
-      FileObject file = null;
-      char type = (char) stream.read();
-
-      if (type == 'd')
-      {
-        file = readDirectory(stream);
-      }
-      else if (type == 'f')
-      {
-        file = readFile(stream);
-      }
-      else if (type == 's')
-      {
-        file = readSubRepository(stream);
-      }
-
-      if (file != null)
-      {
-        files.add(file);
-      }
-    }
-
-    return files;
-  }
-
-  /**
-   * Method description
-   *
-   *
-   * @param path
-   *
-   * @return
-   */
-  public HgFileviewCommand path(String path)
-  {
+  public HgFileviewCommand path(String path) {
     cmdAppend("-p", path);
 
     return this;
   }
 
   /**
-   * Method description
+   * Fetch file objects recursive.
    *
    *
-   * @return
+   * @return {@code this}
    */
-  public HgFileviewCommand recursive()
-  {
+  public HgFileviewCommand recursive() {
     cmdAppend("-c");
 
     return this;
   }
 
   /**
-   * Method description
+   * Use given revision for file view.
    *
+   * @param revision revision id, hash, tag or branch
    *
-   * @param revision
-   *
-   * @return
+   * @return {@code this}
    */
-  public HgFileviewCommand rev(String revision)
-  {
+  public HgFileviewCommand rev(String revision) {
     cmdAppend("-r", revision);
 
     return this;
   }
 
-  //~--- get methods ----------------------------------------------------------
-
   /**
-   * Method description
+   * Executes the mercurial command and parses the output.
    *
-   *
-   * @return
-   */
-  @Override
-  public String getCommandName()
-  {
-    return HgFileviewExtension.NAME;
-  }
-
-  //~--- methods --------------------------------------------------------------
-
-  /**
-   * Method description
-   *
-   *
-   * @param stream
-   *
-   * @return
+   * @return file object
    *
    * @throws IOException
    */
-  private FileObject readDirectory(HgInputStream stream) throws IOException
+  public FileObject execute() throws IOException
   {
+    cmdAppend("-t");
+
+    Deque stack = new LinkedList<>();
+
+    HgInputStream stream = launchStream();
+
+    FileObject last = null;
+    while (stream.peek() != -1) {
+      FileObject file = read(stream);
+
+      while (!stack.isEmpty()) {
+        FileObject current = stack.peek();
+        if (isParent(current, file)) {
+          current.addChild(file);
+          break;
+        } else {
+          stack.pop();
+        }
+      }
+
+      if (file.isDirectory()) {
+        stack.push(file);
+      }
+      last = file;
+    }
+
+    if (stack.isEmpty()) {
+      // if the stack is empty, the requested path is probably a file
+      return last;
+    } else {
+      // if the stack is not empty, the requested path is a directory
+      return stack.getLast();
+    }
+  }
+
+  private FileObject read(HgInputStream stream) throws IOException {
+    char type = (char) stream.read();
+
+    FileObject file;
+    switch (type) {
+      case 'd':
+        file = readDirectory(stream);
+        break;
+      case 'f':
+        file = readFile(stream);
+        break;
+      case 's':
+        file = readSubRepository(stream);
+        break;
+      default:
+        throw new IOException("unknown file object type: " + type);
+    }
+    return file;
+  }
+
+  private boolean isParent(FileObject parent, FileObject child) {
+    String parentPath = parent.getPath();
+    if (parentPath.equals("")) {
+      return true;
+    }
+    return child.getParentPath().equals(parentPath);
+  }
+
+  private FileObject readDirectory(HgInputStream stream) throws IOException {
     FileObject directory = new FileObject();
     String path = removeTrailingSlash(stream.textUpTo('\0'));
 
@@ -236,18 +226,7 @@ public class HgFileviewCommand extends AbstractCommand
     return directory;
   }
 
-  /**
-   * Method description
-   *
-   *
-   * @param stream
-   *
-   * @return
-   *
-   * @throws IOException
-   */
-  private FileObject readFile(HgInputStream stream) throws IOException
-  {
+  private FileObject readFile(HgInputStream stream) throws IOException {
     FileObject file = new FileObject();
     String path = removeTrailingSlash(stream.textUpTo('\n'));
 
@@ -259,8 +238,7 @@ public class HgFileviewCommand extends AbstractCommand
     DateTime timestamp = stream.dateTimeUpTo(' ');
     String description = stream.textUpTo('\0');
 
-    if (!disableLastCommit)
-    {
+    if (!disableLastCommit) {
       file.setLastModified(timestamp.getDate().getTime());
       file.setDescription(description);
     }
@@ -268,18 +246,7 @@ public class HgFileviewCommand extends AbstractCommand
     return file;
   }
 
-  /**
-   * Method description
-   *
-   *
-   * @param stream
-   *
-   * @return
-   *
-   * @throws IOException
-   */
-  private FileObject readSubRepository(HgInputStream stream) throws IOException
-  {
+  private FileObject readSubRepository(HgInputStream stream) throws IOException {
     FileObject directory = new FileObject();
     String path = removeTrailingSlash(stream.textUpTo('\n'));
 
@@ -292,8 +259,7 @@ public class HgFileviewCommand extends AbstractCommand
 
     SubRepository subRepository = new SubRepository(url);
 
-    if (!Strings.isNullOrEmpty(revision))
-    {
+    if (!Strings.isNullOrEmpty(revision)) {
       subRepository.setRevision(revision);
     }
 
@@ -302,48 +268,33 @@ public class HgFileviewCommand extends AbstractCommand
     return directory;
   }
 
-  /**
-   * Method description
-   *
-   *
-   * @param path
-   *
-   * @return
-   */
-  private String removeTrailingSlash(String path)
-  {
-    if (path.endsWith("/"))
-    {
+  private String removeTrailingSlash(String path) {
+    if (path.endsWith("/")) {
       path = path.substring(0, path.length() - 1);
     }
 
     return path;
   }
 
-  //~--- get methods ----------------------------------------------------------
-
-  /**
-   * Method description
-   *
-   *
-   * @param path
-   *
-   * @return
-   */
-  private String getNameFromPath(String path)
-  {
+  private String getNameFromPath(String path) {
     int index = path.lastIndexOf('/');
 
-    if (index > 0)
-    {
+    if (index > 0) {
       path = path.substring(index + 1);
     }
 
     return path;
   }
 
-  //~--- fields ---------------------------------------------------------------
+  /**
+   * Returns the name of the mercurial command.
+   *
+   * @return command name
+   */
+  @Override
+  public String getCommandName()
+  {
+    return HgFileviewExtension.NAME;
+  }
 
-  /** Field description */
-  private boolean disableLastCommit = false;
 }
diff --git a/scm-plugins/scm-hg-plugin/src/main/js/ProtocolInformation.js b/scm-plugins/scm-hg-plugin/src/main/js/ProtocolInformation.js
index 03fc41450a..a8ae91cdfb 100644
--- a/scm-plugins/scm-hg-plugin/src/main/js/ProtocolInformation.js
+++ b/scm-plugins/scm-hg-plugin/src/main/js/ProtocolInformation.js
@@ -2,26 +2,28 @@
 import React from "react";
 import { repositories } from "@scm-manager/ui-components";
 import type { Repository } from "@scm-manager/ui-types";
+import { translate } from "react-i18next";
 
 type Props = {
-  repository: Repository
+  repository: Repository,
+  t: string => string
 }
 
 class ProtocolInformation extends React.Component {
 
   render() {
-    const { repository } = this.props;
+    const { repository, t } = this.props;
     const href = repositories.getProtocolLinkByType(repository, "http");
     if (!href) {
       return null;
     }
     return (
       
-

Clone the repository

+

{t("scm-hg-plugin.information.clone")}

           hg clone {href}
         
-

Create a new repository

+

{t("scm-hg-plugin.information.create")}

           
             hg init {repository.name}
@@ -41,7 +43,7 @@ class ProtocolInformation extends React.Component {
             
-

Push an existing repository

+

{t("scm-hg-plugin.information.replace")}

           
             # add the repository url as default to your .hg/hgrc e.g:
@@ -59,4 +61,4 @@ class ProtocolInformation extends React.Component {
 
 }
 
-export default ProtocolInformation;
+export default translate("plugins")(ProtocolInformation);
diff --git a/scm-plugins/scm-hg-plugin/src/main/resources/locales/de/plugins.json b/scm-plugins/scm-hg-plugin/src/main/resources/locales/de/plugins.json
new file mode 100644
index 0000000000..0824a4ad38
--- /dev/null
+++ b/scm-plugins/scm-hg-plugin/src/main/resources/locales/de/plugins.json
@@ -0,0 +1,9 @@
+{
+  "scm-hg-plugin": {
+    "information": {
+      "clone" : "Repository Klonen",
+      "create" : "Neue Repository erstellen",
+      "replace" : "Eine existierende Repository aktualisieren"
+    }
+  }
+}
diff --git a/scm-plugins/scm-hg-plugin/src/main/resources/locales/en/plugins.json b/scm-plugins/scm-hg-plugin/src/main/resources/locales/en/plugins.json
new file mode 100644
index 0000000000..4ec1d4e4d2
--- /dev/null
+++ b/scm-plugins/scm-hg-plugin/src/main/resources/locales/en/plugins.json
@@ -0,0 +1,9 @@
+{
+  "scm-hg-plugin": {
+    "information": {
+      "clone" : "Clone the repository",
+      "create" : "Create a new repository",
+      "replace" : "Push an existing repository"
+    }
+  }
+}
diff --git a/scm-plugins/scm-hg-plugin/src/main/resources/sonia/scm/hg/ext/fileview.py b/scm-plugins/scm-hg-plugin/src/main/resources/sonia/scm/hg/ext/fileview.py
index 518f229011..6aa9bac2f8 100644
--- a/scm-plugins/scm-hg-plugin/src/main/resources/sonia/scm/hg/ext/fileview.py
+++ b/scm-plugins/scm-hg-plugin/src/main/resources/sonia/scm/hg/ext/fileview.py
@@ -32,61 +32,129 @@
 
 Prints date, size and last message of files.
 """
+
+from collections import defaultdict
 from mercurial import cmdutil,util
 
 cmdtable = {}
 command = cmdutil.command(cmdtable)
 
+FILE_MARKER = ''
+
+class File_Collector:
+
+  def __init__(self, recursive = False):
+    self.recursive = recursive
+    self.structure = defaultdict(dict, ((FILE_MARKER, []),))
+
+  def collect(self, paths, path = "", dir_only = False):
+    for p in paths:
+      if p.startswith(path):
+        self.attach(self.extract_name_without_parent(path, p), self.structure, dir_only)
+
+  def attach(self, branch, trunk, dir_only = False):
+    parts = branch.split('/', 1)
+    if len(parts) == 1:  # branch is a file
+      if dir_only:
+        trunk[parts[0]] = defaultdict(dict, ((FILE_MARKER, []),))
+      else:
+        trunk[FILE_MARKER].append(parts[0])
+    else:
+      node, others = parts
+      if node not in trunk:
+        trunk[node] = defaultdict(dict, ((FILE_MARKER, []),))
+      if self.recursive:
+        self.attach(others, trunk[node], dir_only)
+
+  def extract_name_without_parent(self, parent, name_with_parent):
+    if len(parent) > 0:
+      name_without_parent = name_with_parent[len(parent):]
+      if name_without_parent.startswith("/"):
+          name_without_parent = name_without_parent[1:]
+      return name_without_parent
+    return name_with_parent
+
+class File_Object:
+  def __init__(self, directory, path):
+    self.directory = directory
+    self.path = path
+    self.children = []
+    self.sub_repository = None
+
+  def get_name(self):
+    parts = self.path.split("/")
+    return parts[len(parts) - 1]
+
+  def get_parent(self):
+    idx = self.path.rfind("/")
+    if idx > 0:
+      return self.path[0:idx]
+    return ""
+
+  def add_child(self, child):
+    self.children.append(child)
+
+  def __getitem__(self, key):
+    return self.children[key]
+
+  def __len__(self):
+    return len(self.children)
+
+  def __repr__(self):
+    result = self.path
+    if self.directory:
+      result += "/"
+    return result
+
+class File_Walker:
+  
+  def __init__(self, sub_repositories, visitor):
+    self.visitor = visitor
+    self.sub_repositories = sub_repositories
+
+  def create_file(self, path):
+    return File_Object(False, path)
+
+  def create_directory(self, path):
+    directory = File_Object(True, path)
+    if path in self.sub_repositories:
+      directory.sub_repository = self.sub_repositories[path]
+    return directory
+
+  def visit_file(self, path):
+    file = self.create_file(path)
+    self.visit(file)
+
+  def visit_directory(self, path):
+    file = self.create_directory(path)
+    self.visit(file)
+
+  def visit(self, file):
+    self.visitor.visit(file)
+
+  def create_path(self, parent, path):
+    if len(parent) > 0:
+      return parent + "/" + path
+    return path
+
+  def walk(self, structure, parent = ""):
+    for key, value in structure.iteritems():
+      if key == FILE_MARKER:
+        if value:
+          for v in value:
+            self.visit_file(self.create_path(parent, v))
+      else:
+        self.visit_directory(self.create_path(parent, key))
+        if isinstance(value, dict):
+          self.walk(value, self.create_path(parent, key))
+        else:
+          self.visit_directory(self.create_path(parent, value))
+
 class SubRepository:
   url = None
   revision = None
 
-def removeTrailingSlash(path):
-  if path.endswith('/'):
-    path = path[0:-1]
-  return path
-  
-def appendTrailingSlash(path):
-  if not path.endswith('/'):
-    path += '/'
-  return path
-
-def collectFiles(revCtx, path, files, directories, recursive):
-  length = 0
-  paths = []
-  mf = revCtx.manifest()
-  if path is "":
-    length = 1
-    for f in mf:
-      paths.append(f)
-  else:
-    length = len(path.split('/')) + 1
-    directory = path
-    if not directory.endswith('/'):
-       directory += '/'
-       
-    for f in mf:
-      if f.startswith(directory):
-        paths.append(f)
-  
-  if not recursive:
-    for p in paths:
-      parts = p.split('/')
-      depth = len(parts)
-      if depth is length:
-        file = revCtx[p]
-        files.append(file)
-      elif depth > length:
-        dirpath = ''
-        for i in range(0, length):
-          dirpath += parts[i] + '/'
-        if not dirpath in directories:
-          directories.append(dirpath)
-  else:
-    for p in paths:
-      files.append(revCtx[p])
-        
-def createSubRepositoryMap(revCtx):
+def collect_sub_repositories(revCtx):
   subrepos = {}
   try:
     hgsub = revCtx.filectx('.hgsub').data().split('\n')
@@ -98,7 +166,7 @@ def createSubRepositoryMap(revCtx):
         subrepos[parts[0].strip()] = subrepo
   except Exception:
     pass
-    
+        
   try:
     hgsubstate = revCtx.filectx('.hgsubstate').data().split('\n')
     for line in hgsubstate:
@@ -109,32 +177,77 @@ def createSubRepositoryMap(revCtx):
         subrepo.revision = subrev
   except Exception:
     pass
-    
+
   return subrepos
-  
-def printSubRepository(ui, path, subrepository, transport):
-  format = '%s %s %s\n'
-  if transport:
-    format = 's%s\n%s %s\0'
-  ui.write( format % (appendTrailingSlash(path), subrepository.revision, subrepository.url))
-  
-def printDirectory(ui, path, transport):
-  format = '%s\n'
-  if transport:
-    format = 'd%s\0'
-  ui.write( format % path)
-  
-def printFile(ui, repo, file, disableLastCommit, transport):
-  date = '0 0'
-  description = 'n/a'
-  if not disableLastCommit:
-    linkrev = repo[file.linkrev()]
-    date = '%d %d' % util.parsedate(linkrev.date())
-    description = linkrev.description()
-  format = '%s %i %s %s\n'
-  if transport:
-    format = 'f%s\n%i %s %s\0'
-  ui.write( format % (file.path(), file.size(), date, description) )
+
+class File_Printer:
+
+  def __init__(self, ui, repo, revCtx, disableLastCommit, transport):
+    self.ui = ui
+    self.repo = repo
+    self.revCtx = revCtx
+    self.disableLastCommit = disableLastCommit
+    self.transport = transport
+
+  def print_directory(self, path):
+    format = '%s/\n'
+    if self.transport:
+        format = 'd%s/\0'
+    self.ui.write( format % path)
+
+  def print_file(self, path):
+    file = self.revCtx[path]
+    date = '0 0'
+    description = 'n/a'
+    if not self.disableLastCommit:
+      linkrev = self.repo[file.linkrev()]
+      date = '%d %d' % util.parsedate(linkrev.date())
+      description = linkrev.description()
+    format = '%s %i %s %s\n'
+    if self.transport:
+      format = 'f%s\n%i %s %s\0'
+    self.ui.write( format % (file.path(), file.size(), date, description) )
+
+  def print_sub_repository(self, path, subrepo):
+    format = '%s/ %s %s\n'
+    if self.transport:
+      format = 's%s/\n%s %s\0'
+    self.ui.write( format % (path, subrepo.revision, subrepo.url))
+
+  def visit(self, file):
+    if file.sub_repository:
+      self.print_sub_repository(file.path, file.sub_repository)
+    elif file.directory:
+      self.print_directory(file.path)
+    else:
+      self.print_file(file.path)
+
+class File_Viewer:
+  def __init__(self, revCtx, visitor):
+    self.revCtx = revCtx
+    self.visitor = visitor
+    self.sub_repositories = {}
+    self.recursive = False
+
+  def remove_ending_slash(self, path):
+    if path.endswith("/"):
+        return path[:-1]
+    return path
+
+  def view(self, path = ""):
+    manifest = self.revCtx.manifest()
+    if len(path) > 0 and path in manifest:
+      self.visitor.visit(File_Object(False, path))
+    else:
+      p = self.remove_ending_slash(path)
+
+      collector = File_Collector(self.recursive)
+      walker = File_Walker(self.sub_repositories, self.visitor)
+
+      self.visitor.visit(File_Object(True, p))
+      collector.collect(manifest, p)
+      collector.collect(self.sub_repositories.keys(), p, True)
+      walker.walk(collector.structure, p)
 
 @command('fileview', [
     ('r', 'revision', 'tip', 'revision to print'),
@@ -145,23 +258,12 @@ def printFile(ui, repo, file, disableLastCommit, transport):
     ('t', 'transport', False, 'format the output for command server'),
   ])
 def fileview(ui, repo, **opts):
-  files = []
-  directories = []
-  revision = opts['revision']
-  if revision == None:
-    revision = 'tip'
-  revCtx = repo[revision]
-  path = opts['path']
-  if path.endswith('/'):
-    path = path[0:-1]
-  transport = opts['transport']
-  collectFiles(revCtx, path, files, directories, opts['recursive'])
-  if not opts['disableSubRepositoryDetection']:
-    subRepositories = createSubRepositoryMap(revCtx)
-    for k, v in subRepositories.iteritems():
-      if k.startswith(path):
-        printSubRepository(ui, k, v, transport)
-  for d in directories:
-    printDirectory(ui, d, transport)
-  for f in files:
-    printFile(ui, repo, f, opts['disableLastCommit'], transport)
+  revCtx = repo[opts["revision"]]
+  subrepos = {}
+  if not opts["disableSubRepositoryDetection"]:
+    subrepos = collect_sub_repositories(revCtx)
+  printer = File_Printer(ui, repo, revCtx, opts["disableLastCommit"], opts["transport"])
+  viewer = File_Viewer(revCtx, printer)
+  viewer.recursive = opts["recursive"]
+  viewer.sub_repositories = subrepos
+  viewer.view(opts["path"])
diff --git a/scm-plugins/scm-hg-plugin/src/main/resources/sonia/scm/hg/ext/fileview_test.py b/scm-plugins/scm-hg-plugin/src/main/resources/sonia/scm/hg/ext/fileview_test.py
new file mode 100644
index 0000000000..2ce3989d58
--- /dev/null
+++ b/scm-plugins/scm-hg-plugin/src/main/resources/sonia/scm/hg/ext/fileview_test.py
@@ -0,0 +1,131 @@
+from fileview import File_Viewer, SubRepository
+import unittest
+
+class DummyRevContext():
+
+  def __init__(self, mf):
+    self.mf = mf
+
+  def manifest(self):
+    return self.mf
+
+class File_Object_Collector():
+
+  def __init__(self):
+    self.stack = []
+  
+  def __getitem__(self, key):
+    if len(self.stack) == 0 and key == 0:
+      return self.last
+    return self.stack[key]
+
+  def visit(self, file):
+    while len(self.stack) > 0:
+      current = self.stack[-1]
+      if file.get_parent() == current.path:
+        current.add_child(file)
+        break
+      else:
+        self.stack.pop()
+    if file.directory:
+      self.stack.append(file)
+    self.last = file
+    
+
+class Test_File_Viewer(unittest.TestCase):
+
+  def test_single_file(self):
+    root = self.collect(["a.txt", "b.txt"], "a.txt")
+    self.assertFile(root, "a.txt")
+
+  def test_simple(self):
+    root = self.collect(["a.txt", "b.txt"])
+    self.assertFile(root[0], "a.txt")
+    self.assertFile(root[1], "b.txt")
+
+  def test_recursive(self):
+    root = self.collect(["a", "b", "c/d.txt", "c/e.txt", "f.txt", "c/g/h.txt"], "", True)
+    self.assertChildren(root, ["a", "b", "f.txt", "c"])
+    c = root[3]
+    self.assertDirectory(c, "c")
+    self.assertChildren(c, ["c/d.txt", "c/e.txt", "c/g"])
+    g = c[2]
+    self.assertDirectory(g, "c/g")
+    self.assertChildren(g, ["c/g/h.txt"])
+
+  def test_recursive_with_path(self):
+    root = self.collect(["a", "b", "c/d.txt", "c/e.txt", "f.txt", "c/g/h.txt"], "c", True)
+    self.assertDirectory(root, "c")
+    self.assertChildren(root, ["c/d.txt", "c/e.txt", "c/g"])
+    g = root[2]
+    self.assertDirectory(g, "c/g")
+    self.assertChildren(g, ["c/g/h.txt"])
+
+  def test_recursive_with_deep_path(self):
+    root = self.collect(["a", "b", "c/d.txt", "c/e.txt", "f.txt", "c/g/h.txt"], "c/g", True)
+    self.assertDirectory(root, "c/g")
+    self.assertChildren(root, ["c/g/h.txt"])
+
+  def test_non_recursive(self):
+    root = self.collect(["a.txt", "b.txt", "c/d.txt", "c/e.txt", "c/f/g.txt"])
+    self.assertDirectory(root, "")
+    self.assertChildren(root, ["a.txt", "b.txt", "c"])
+    c = root[2]
+    self.assertEmptyDirectory(c, "c")
+
+  def test_non_recursive_with_path(self):
+    root = self.collect(["a.txt", "b.txt", "c/d.txt", "c/e.txt", "c/f/g.txt"], "c")
+    self.assertDirectory(root, "c")
+    self.assertChildren(root, ["c/d.txt", "c/e.txt", "c/f"])
+    f = root[2]
+    self.assertEmptyDirectory(f, "c/f")
+
+  def test_non_recursive_with_path_with_ending_slash(self):
+    root = self.collect(["c/d.txt"], "c/")
+    self.assertDirectory(root, "c")
+    self.assertFile(root[0], "c/d.txt")
+
+  def test_with_sub_directory(self):
+    revCtx = DummyRevContext(["a.txt", "b/c.txt"])
+    collector = File_Object_Collector()
+    viewer = File_Viewer(revCtx, collector)
+    sub_repositories = {}
+    sub_repositories["d"] = SubRepository()
+    sub_repositories["d"].url = "d"
+    sub_repositories["d"].revision = "42"
+    viewer.sub_repositories = sub_repositories
+    viewer.view()
+
+    d = collector[0][2]
+    self.assertDirectory(d, "d")
+
+
+  def collect(self, paths, path = "", recursive = False):
+    revCtx = DummyRevContext(paths)
+    collector = File_Object_Collector()
+
+    viewer = File_Viewer(revCtx, collector)
+    viewer.recursive = recursive
+    viewer.view(path)
+
+    return collector[0]
+
+  def assertChildren(self, parent, expectedPaths):
+    self.assertEqual(len(parent), len(expectedPaths))
+    for idx,item in enumerate(parent.children):
+      self.assertEqual(item.path, expectedPaths[idx])
+    
+  def assertFile(self, file, expectedPath):
+    self.assertEquals(file.path, expectedPath)
+    self.assertFalse(file.directory)
+
+  def assertDirectory(self, file, expectedPath):
+    self.assertEquals(file.path, expectedPath)
+    self.assertTrue(file.directory)
+
+  def assertEmptyDirectory(self, file, expectedPath):
+    self.assertDirectory(file, expectedPath)
+    self.assertTrue(len(file.children) == 0)
+
+if __name__ == '__main__':
+  unittest.main()
diff --git a/scm-plugins/scm-hg-plugin/src/test/java/sonia/scm/repository/spi/HgBrowseCommandTest.java b/scm-plugins/scm-hg-plugin/src/test/java/sonia/scm/repository/spi/HgBrowseCommandTest.java
index c53aa8c607..32b536e69d 100644
--- a/scm-plugins/scm-hg-plugin/src/test/java/sonia/scm/repository/spi/HgBrowseCommandTest.java
+++ b/scm-plugins/scm-hg-plugin/src/test/java/sonia/scm/repository/spi/HgBrowseCommandTest.java
@@ -33,14 +33,12 @@
 
 package sonia.scm.repository.spi;
 
-//~--- non-JDK imports --------------------------------------------------------
-
 import org.junit.Test;
 import sonia.scm.repository.BrowserResult;
 import sonia.scm.repository.FileObject;
 
 import java.io.IOException;
-import java.util.List;
+import java.util.Collection;
 
 import static org.junit.Assert.assertEquals;
 import static org.junit.Assert.assertFalse;
@@ -48,18 +46,25 @@ import static org.junit.Assert.assertNotNull;
 import static org.junit.Assert.assertNull;
 import static org.junit.Assert.assertTrue;
 
-//~--- JDK imports ------------------------------------------------------------
-
 /**
  *
  * @author Sebastian Sdorra
  */
-public class HgBrowseCommandTest extends AbstractHgCommandTestBase
-{
+public class HgBrowseCommandTest extends AbstractHgCommandTestBase {
+
+  @Test
+  public void testBrowseWithFilePath() throws IOException {
+    BrowseCommandRequest request = new BrowseCommandRequest();
+    request.setPath("a.txt");
+    FileObject file = new HgBrowseCommand(cmdContext, repository).getBrowserResult(request).getFile();
+    assertEquals("a.txt", file.getName());
+    assertFalse(file.isDirectory());
+    assertTrue(file.getChildren().isEmpty());
+  }
 
   @Test
   public void testBrowse() throws IOException {
-    List foList = getRootFromTip(new BrowseCommandRequest());
+    Collection foList = getRootFromTip(new BrowseCommandRequest());
     FileObject a = getFileObject(foList, "a.txt");
     FileObject c = getFileObject(foList, "c");
 
@@ -85,7 +90,9 @@ public class HgBrowseCommandTest extends AbstractHgCommandTestBase
 
     assertNotNull(result);
 
-    List foList = result.getFiles();
+    FileObject c = result.getFile();
+    assertEquals("c", c.getName());
+    Collection foList = c.getChildren();
 
     assertNotNull(foList);
     assertFalse(foList.isEmpty());
@@ -128,7 +135,7 @@ public class HgBrowseCommandTest extends AbstractHgCommandTestBase
 
     request.setDisableLastCommit(true);
 
-    List foList = getRootFromTip(request);
+    Collection foList = getRootFromTip(request);
 
     FileObject a = getFileObject(foList, "a.txt");
 
@@ -147,11 +154,16 @@ public class HgBrowseCommandTest extends AbstractHgCommandTestBase
 
     assertNotNull(result);
 
-    List foList = result.getFiles();
+    FileObject root = result.getFile();
+    Collection foList = root.getChildren();
 
     assertNotNull(foList);
     assertFalse(foList.isEmpty());
-    assertEquals(5, foList.size());
+    assertEquals(4, foList.size());
+
+    FileObject c = getFileObject(foList, "c");
+    assertTrue(c.isDirectory());
+    assertEquals(2, c.getChildren().size());
   }
 
   //~--- get methods ----------------------------------------------------------
@@ -165,32 +177,22 @@ public class HgBrowseCommandTest extends AbstractHgCommandTestBase
    *
    * @return
    */
-  private FileObject getFileObject(List foList, String name)
+  private FileObject getFileObject(Collection foList, String name)
   {
-    FileObject a = null;
-
-    for (FileObject f : foList)
-    {
-      if (name.equals(f.getName()))
-      {
-        a = f;
-
-        break;
-      }
-    }
-
-    assertNotNull(a);
-
-    return a;
+    return foList.stream()
+      .filter(f -> name.equals(f.getName()))
+      .findFirst()
+      .orElseThrow(() -> new AssertionError("file " + name + " not found"));
   }
 
-  private List getRootFromTip(BrowseCommandRequest request) throws IOException {
+  private Collection getRootFromTip(BrowseCommandRequest request) throws IOException {
     BrowserResult result = new HgBrowseCommand(cmdContext,
                              repository).getBrowserResult(request);
 
     assertNotNull(result);
 
-    List foList = result.getFiles();
+    FileObject root = result.getFile();
+    Collection foList = root.getChildren();
 
     assertNotNull(foList);
     assertFalse(foList.isEmpty());
diff --git a/scm-plugins/scm-svn-plugin/src/main/java/sonia/scm/repository/spi/SvnBrowseCommand.java b/scm-plugins/scm-svn-plugin/src/main/java/sonia/scm/repository/spi/SvnBrowseCommand.java
index a75adf6b78..43cc1c3c70 100644
--- a/scm-plugins/scm-svn-plugin/src/main/java/sonia/scm/repository/spi/SvnBrowseCommand.java
+++ b/scm-plugins/scm-svn-plugin/src/main/java/sonia/scm/repository/spi/SvnBrowseCommand.java
@@ -35,6 +35,7 @@ package sonia.scm.repository.spi;
 
 //~--- non-JDK imports --------------------------------------------------------
 
+import com.google.common.base.Strings;
 import com.google.common.collect.Lists;
 import org.slf4j.Logger;
 import org.slf4j.LoggerFactory;
@@ -79,11 +80,11 @@ public class SvnBrowseCommand extends AbstractSvnCommand
   @Override
   @SuppressWarnings("unchecked")
   public BrowserResult getBrowserResult(BrowseCommandRequest request) throws RevisionNotFoundException {
-    String path = request.getPath();
+    String path = Strings.nullToEmpty(request.getPath());
     long revisionNumber = SvnUtil.getRevisionNumber(request.getRevision());
 
     if (logger.isDebugEnabled()) {
-      logger.debug("browser repository {} in path {} at revision {}", repository.getName(), path, revisionNumber);
+      logger.debug("browser repository {} in path \"{}\" at revision {}", repository.getName(), path, revisionNumber);
     }
 
     BrowserResult result = null;
@@ -91,34 +92,21 @@ public class SvnBrowseCommand extends AbstractSvnCommand
     try
     {
       SVNRepository svnRepository = open();
-      Collection entries =
-        svnRepository.getDir(Util.nonNull(path), revisionNumber, null,
-          (Collection) null);
-      List children = Lists.newArrayList();
-      String basePath = createBasePath(path);
-
-      if (request.isRecursive())
-      {
-        browseRecursive(svnRepository, revisionNumber, request, children,
-          entries, basePath);
-      }
-      else
-      {
-        for (SVNDirEntry entry : entries)
-        {
-          children.add(createFileObject(request, svnRepository, revisionNumber,
-            entry, basePath));
-
-        }
-      }
 
       if (revisionNumber == -1) {
         revisionNumber = svnRepository.getLatestRevision();
       }
 
-      result = new BrowserResult();
-      result.setRevision(String.valueOf(revisionNumber));
-      result.setFiles(children);
+      SVNDirEntry rootEntry = svnRepository.info(path, revisionNumber);
+      FileObject root = createFileObject(request, svnRepository, revisionNumber, rootEntry, path);
+      root.setPath(path);
+
+      if (root.isDirectory()) {
+        traverse(svnRepository, revisionNumber, request, root, createBasePath(path));
+      }
+
+
+      result = new BrowserResult(String.valueOf(revisionNumber), root);
     }
     catch (SVNException ex)
     {
@@ -130,52 +118,24 @@ public class SvnBrowseCommand extends AbstractSvnCommand
 
   //~--- methods --------------------------------------------------------------
 
-  /**
-   * Method description
-   *
-   *
-   * @param svnRepository
-   * @param revisionNumber
-   * @param request
-   * @param children
-   * @param entries
-   * @param basePath
-   *
-   * @throws SVNException
-   */
   @SuppressWarnings("unchecked")
-  private void browseRecursive(SVNRepository svnRepository,
-    long revisionNumber, BrowseCommandRequest request,
-    List children, Collection entries, String basePath)
+  private void traverse(SVNRepository svnRepository, long revisionNumber, BrowseCommandRequest request,
+    FileObject parent, String basePath)
     throws SVNException
   {
+    Collection entries = svnRepository.getDir(parent.getPath(), revisionNumber, null, (Collection) null);
     for (SVNDirEntry entry : entries)
     {
-      FileObject fo = createFileObject(request, svnRepository, revisionNumber,
-                        entry, basePath);
+      FileObject child = createFileObject(request, svnRepository, revisionNumber, entry, basePath);
 
-      children.add(fo);
+      parent.addChild(child);
 
-      if (fo.isDirectory())
-      {
-        Collection subEntries =
-          svnRepository.getDir(Util.nonNull(fo.getPath()), revisionNumber,
-            null, (Collection) null);
-
-        browseRecursive(svnRepository, revisionNumber, request, children,
-          subEntries, createBasePath(fo.getPath()));
+      if (child.isDirectory() && request.isRecursive()) {
+        traverse(svnRepository, revisionNumber, request, child, createBasePath(child.getPath()));
       }
     }
   }
 
-  /**
-   * Method description
-   *
-   *
-   * @param path
-   *
-   * @return
-   */
   private String createBasePath(String path)
   {
     String basePath = Util.EMPTY_STRING;
@@ -193,20 +153,6 @@ public class SvnBrowseCommand extends AbstractSvnCommand
     return basePath;
   }
 
-  /**
-   * Method description
-   *
-   *
-   *
-   *
-   * @param request
-   * @param repository
-   * @param revision
-   * @param entry
-   * @param path
-   *
-   * @return
-   */
   private FileObject createFileObject(BrowseCommandRequest request,
     SVNRepository repository, long revision, SVNDirEntry entry, String path)
   {
@@ -237,15 +183,6 @@ public class SvnBrowseCommand extends AbstractSvnCommand
     return fileObject;
   }
 
-  /**
-   * Method description
-   *
-   *
-   * @param repository
-   * @param revision
-   * @param entry
-   * @param fileObject
-   */
   private void fetchExternalsProperty(SVNRepository repository, long revision,
     SVNDirEntry entry, FileObject fileObject)
   {
diff --git a/scm-plugins/scm-svn-plugin/src/main/js/ProtocolInformation.js b/scm-plugins/scm-svn-plugin/src/main/js/ProtocolInformation.js
index 0ba195887f..68fdc68f74 100644
--- a/scm-plugins/scm-svn-plugin/src/main/js/ProtocolInformation.js
+++ b/scm-plugins/scm-svn-plugin/src/main/js/ProtocolInformation.js
@@ -2,22 +2,24 @@
 import React from "react";
 import { repositories } from "@scm-manager/ui-components";
 import type { Repository } from "@scm-manager/ui-types";
+import { translate } from "react-i18next";
 
 type Props = {
-  repository: Repository
+  repository: Repository,
+  t: string => string
 }
 
 class ProtocolInformation extends React.Component {
 
   render() {
-    const { repository } = this.props;
+    const { repository, t } = this.props;
     const href = repositories.getProtocolLinkByType(repository, "http");
     if (!href) {
       return null;
     }
     return (
       
-

Checkout the repository

+

{t("scm-svn-plugin.information.checkout")}

           svn checkout {href}
         
@@ -27,4 +29,4 @@ class ProtocolInformation extends React.Component { } -export default ProtocolInformation; +export default translate("plugins")(ProtocolInformation); diff --git a/scm-plugins/scm-svn-plugin/src/main/resources/locales/de/plugins.json b/scm-plugins/scm-svn-plugin/src/main/resources/locales/de/plugins.json new file mode 100644 index 0000000000..7c58498ef1 --- /dev/null +++ b/scm-plugins/scm-svn-plugin/src/main/resources/locales/de/plugins.json @@ -0,0 +1,7 @@ +{ + "scm-svn-plugin": { + "information": { + "checkout" : "Repository auschecken" + } + } +} diff --git a/scm-plugins/scm-svn-plugin/src/main/resources/locales/en/plugins.json b/scm-plugins/scm-svn-plugin/src/main/resources/locales/en/plugins.json new file mode 100644 index 0000000000..07b34baf10 --- /dev/null +++ b/scm-plugins/scm-svn-plugin/src/main/resources/locales/en/plugins.json @@ -0,0 +1,7 @@ +{ + "scm-svn-plugin": { + "information": { + "checkout" : "Checkout repository" + } + } +} diff --git a/scm-plugins/scm-svn-plugin/src/test/java/sonia/scm/repository/spi/SvnBrowseCommandTest.java b/scm-plugins/scm-svn-plugin/src/test/java/sonia/scm/repository/spi/SvnBrowseCommandTest.java index c4c658ea7a..bcf5d2ec55 100644 --- a/scm-plugins/scm-svn-plugin/src/test/java/sonia/scm/repository/spi/SvnBrowseCommandTest.java +++ b/scm-plugins/scm-svn-plugin/src/test/java/sonia/scm/repository/spi/SvnBrowseCommandTest.java @@ -33,15 +33,13 @@ package sonia.scm.repository.spi; -//~--- non-JDK imports -------------------------------------------------------- - import org.junit.Test; import sonia.scm.repository.BrowserResult; import sonia.scm.repository.FileObject; import sonia.scm.repository.RevisionNotFoundException; import java.io.IOException; -import java.util.List; +import java.util.Collection; import static org.junit.Assert.assertEquals; import static org.junit.Assert.assertFalse; @@ -49,8 +47,6 @@ import static org.junit.Assert.assertNotNull; import static org.junit.Assert.assertNull; import static org.junit.Assert.assertTrue; -//~--- JDK imports ------------------------------------------------------------ - /** * * @author Sebastian Sdorra @@ -58,9 +54,19 @@ import static org.junit.Assert.assertTrue; public class SvnBrowseCommandTest extends AbstractSvnCommandTestBase { + @Test + public void testBrowseWithFilePath() throws RevisionNotFoundException { + BrowseCommandRequest request = new BrowseCommandRequest(); + request.setPath("a.txt"); + FileObject file = createCommand().getBrowserResult(request).getFile(); + assertEquals("a.txt", file.getName()); + assertFalse(file.isDirectory()); + assertTrue(file.getChildren().isEmpty()); + } + @Test public void testBrowse() throws RevisionNotFoundException { - List foList = getRootFromTip(new BrowseCommandRequest()); + Collection foList = getRootFromTip(new BrowseCommandRequest()); FileObject a = getFileObject(foList, "a.txt"); FileObject c = getFileObject(foList, "c"); @@ -92,7 +98,7 @@ public class SvnBrowseCommandTest extends AbstractSvnCommandTestBase assertNotNull(result); - List foList = result.getFiles(); + Collection foList = result.getFile().getChildren(); assertNotNull(foList); assertFalse(foList.isEmpty()); @@ -135,7 +141,7 @@ public class SvnBrowseCommandTest extends AbstractSvnCommandTestBase request.setDisableLastCommit(true); - List foList = getRootFromTip(request); + Collection foList = getRootFromTip(request); FileObject a = getFileObject(foList, "a.txt"); @@ -151,15 +157,16 @@ public class SvnBrowseCommandTest extends AbstractSvnCommandTestBase assertNotNull(result); - List foList = result.getFiles(); + Collection foList = result.getFile().getChildren(); assertNotNull(foList); assertFalse(foList.isEmpty()); - assertEquals(4, foList.size()); - - for ( FileObject fo : foList ){ - System.out.println(fo); - } + assertEquals(2, foList.size()); + + FileObject c = getFileObject(foList, "c"); + assertEquals("c", c.getName()); + assertTrue(c.isDirectory()); + assertEquals(2, c.getChildren().size()); } /** @@ -184,31 +191,20 @@ public class SvnBrowseCommandTest extends AbstractSvnCommandTestBase * * @return */ - private FileObject getFileObject(List foList, String name) + private FileObject getFileObject(Collection foList, String name) { - FileObject a = null; - - for (FileObject f : foList) - { - if (name.equals(f.getName())) - { - a = f; - - break; - } - } - - assertNotNull(a); - - return a; + return foList.stream() + .filter(f -> name.equals(f.getName())) + .findFirst() + .orElseThrow(() -> new AssertionError("file " + name + " not found")); } - private List getRootFromTip(BrowseCommandRequest request) throws RevisionNotFoundException { + private Collection getRootFromTip(BrowseCommandRequest request) throws RevisionNotFoundException { BrowserResult result = createCommand().getBrowserResult(request); assertNotNull(result); - List foList = result.getFiles(); + Collection foList = result.getFile().getChildren(); assertNotNull(foList); assertFalse(foList.isEmpty()); diff --git a/scm-ui-components/packages/ui-components/src/Image.js b/scm-ui-components/packages/ui-components/src/Image.js index d46a32217f..5cb7fd6aa9 100644 --- a/scm-ui-components/packages/ui-components/src/Image.js +++ b/scm-ui-components/packages/ui-components/src/Image.js @@ -9,9 +9,18 @@ type Props = { }; class Image extends React.Component { + + createImageSrc = () => { + const { src } = this.props; + if (src.startsWith("http")) { + return src; + } + return withContextPath(src); + }; + render() { - const { src, alt, className } = this.props; - return {alt}; + const { alt, className } = this.props; + return {alt}; } } diff --git a/scm-ui-components/packages/ui-components/src/urls.js b/scm-ui-components/packages/ui-components/src/urls.js index 543519f952..dd8888d7a3 100644 --- a/scm-ui-components/packages/ui-components/src/urls.js +++ b/scm-ui-components/packages/ui-components/src/urls.js @@ -5,6 +5,21 @@ export function withContextPath(path: string) { return contextPath + path; } +export function withEndingSlash(url: string) { + if (url.endsWith("/")) { + return url; + } + return url + "/"; +} + +export function concat(base: string, ...parts: string[]) { + let url = base; + for ( let p of parts) { + url = withEndingSlash(url) + p; + } + return url; +} + export function getPageFromMatch(match: any) { let page = parseInt(match.params.page, 10); if (isNaN(page) || !page) { diff --git a/scm-ui-components/packages/ui-components/src/urls.test.js b/scm-ui-components/packages/ui-components/src/urls.test.js index 61803f213f..e1d88bfe55 100644 --- a/scm-ui-components/packages/ui-components/src/urls.test.js +++ b/scm-ui-components/packages/ui-components/src/urls.test.js @@ -1,5 +1,27 @@ // @flow -import { getPageFromMatch } from "./urls"; +import { concat, getPageFromMatch, withEndingSlash } from "./urls"; + +describe("tests for withEndingSlash", () => { + + it("should append missing slash", () => { + expect(withEndingSlash("abc")).toBe("abc/"); + }); + + it("should not append a second slash", () => { + expect(withEndingSlash("abc/")).toBe("abc/"); + }); + +}); + +describe("concat tests", () => { + + it("should concat the parts to a single url", () => { + expect(concat("a")).toBe("a"); + expect(concat("a", "b")).toBe("a/b"); + expect(concat("a", "b", "c")).toBe("a/b/c"); + }); + +}); describe("tests for getPageFromMatch", () => { function createMatch(page: string) { diff --git a/scm-ui-components/packages/ui-types/src/RepositoryPermissions.js b/scm-ui-components/packages/ui-types/src/RepositoryPermissions.js index ceb5fe135e..d86e499378 100644 --- a/scm-ui-components/packages/ui-types/src/RepositoryPermissions.js +++ b/scm-ui-components/packages/ui-types/src/RepositoryPermissions.js @@ -1,14 +1,11 @@ //@flow import type { Links } from "./hal"; -export type Permission = { - name: string, - type: string, - groupPermission: boolean, - _links?: Links +export type Permission = PermissionCreateEntry & { + _links: Links }; -export type PermissionEntry = { +export type PermissionCreateEntry = { name: string, type: string, groupPermission: boolean diff --git a/scm-ui-components/packages/ui-types/src/Sources.js b/scm-ui-components/packages/ui-types/src/Sources.js new file mode 100644 index 0000000000..c8b3fafe0c --- /dev/null +++ b/scm-ui-components/packages/ui-types/src/Sources.js @@ -0,0 +1,25 @@ +// @flow + +import type { Collection, Links } from "./hal"; + +// TODO ?? check ?? links +export type SubRepository = { + repositoryUrl: string, + browserUrl: string, + revision: string +}; + +export type File = { + name: string, + path: string, + directory: boolean, + description?: string, + revision: string, + length: number, + lastModified?: string, + subRepository?: SubRepository, // TODO + _links: Links, + _embedded: { + children: File[] + } +}; diff --git a/scm-ui-components/packages/ui-types/src/index.js b/scm-ui-components/packages/ui-types/src/index.js index c96eacef98..883272b4d4 100644 --- a/scm-ui-components/packages/ui-types/src/index.js +++ b/scm-ui-components/packages/ui-types/src/index.js @@ -17,5 +17,8 @@ export type { Tag } from "./Tags"; export type { Config } from "./Config"; -export type {IndexResources} from "./IndexResources"; -export type { Permission, PermissionEntry, PermissionCollection } from "./RepositoryPermissions"; +export type { IndexResources } from "./IndexResources"; + +export type { Permission, PermissionCreateEntry, PermissionCollection } from "./RepositoryPermissions"; + +export type { SubRepository, File } from "./Sources"; diff --git a/scm-ui/public/locales/en/repos.json b/scm-ui/public/locales/en/repos.json index a01513a437..60ee220318 100644 --- a/scm-ui/public/locales/en/repos.json +++ b/scm-ui/public/locales/en/repos.json @@ -24,7 +24,8 @@ "navigation-label": "Navigation", "history": "Commits", "information": "Information", - "permissions": "Permissions" + "permissions": "Permissions", + "sources": "Sources" }, "create": { "title": "Create Repository", @@ -45,6 +46,14 @@ "cancel": "No" } }, + "sources": { + "file-tree": { + "name": "Name", + "length": "Length", + "lastModified": "Last modified", + "description": "Description" + } + }, "changesets": { "error-title": "Error", "error-subtitle": "Could not fetch changesets", @@ -53,7 +62,7 @@ "description": "Description", "contact": "Contact", "date": "Date", - "summary": "Changeset {{id}} committed {{time}}" + "summary": "Changeset {{id}} was committed {{time}}" }, "author": { "name": "Author", diff --git a/scm-ui/src/createReduxStore.js b/scm-ui/src/createReduxStore.js index f7c8b2521f..bc519f3741 100644 --- a/scm-ui/src/createReduxStore.js +++ b/scm-ui/src/createReduxStore.js @@ -8,6 +8,7 @@ import users from "./users/modules/users"; import repos from "./repos/modules/repos"; import repositoryTypes from "./repos/modules/repositoryTypes"; import changesets from "./repos/modules/changesets"; +import sources from "./repos/sources/modules/sources"; import groups from "./groups/modules/groups"; import auth from "./modules/auth"; import pending from "./modules/pending"; @@ -36,7 +37,8 @@ function createReduxStore(history: BrowserHistory) { permissions, groups, auth, - config + config, + sources }); return createStore( diff --git a/scm-ui/src/repos/components/RepositoryNavLink.js b/scm-ui/src/repos/components/RepositoryNavLink.js new file mode 100644 index 0000000000..b4cf7774af --- /dev/null +++ b/scm-ui/src/repos/components/RepositoryNavLink.js @@ -0,0 +1,30 @@ +//@flow +import React from "react"; +import type { Repository } from "@scm-manager/ui-types"; +import { NavLink } from "@scm-manager/ui-components"; + +type Props = { + repository: Repository, + to: string, + label: string, + linkName: string, + activeWhenMatch?: (route: any) => boolean, + activeOnlyWhenExact: boolean +}; + +/** + * Component renders only if the repository contains the link with the given name. + */ +class RepositoryNavLink extends React.Component { + render() { + const { repository, linkName } = this.props; + + if (!repository._links[linkName]) { + return null; + } + + return ; + } +} + +export default RepositoryNavLink; diff --git a/scm-ui/src/repos/components/RepositoryNavLink.test.js b/scm-ui/src/repos/components/RepositoryNavLink.test.js new file mode 100644 index 0000000000..0d93cb7c4d --- /dev/null +++ b/scm-ui/src/repos/components/RepositoryNavLink.test.js @@ -0,0 +1,49 @@ +// @flow +import React from "react"; +import { shallow, mount } from "enzyme"; +import "../../tests/enzyme"; +import "../../tests/i18n"; +import ReactRouterEnzymeContext from "react-router-enzyme-context"; +import RepositoryNavLink from "./RepositoryNavLink"; + +describe("RepositoryNavLink", () => { + const options = new ReactRouterEnzymeContext(); + + it("should render nothing, if the sources link is missing", () => { + const repository = { + _links: {} + }; + + const navLink = shallow( + , + options.get() + ); + expect(navLink.text()).toBe(""); + }); + + it("should render the navLink", () => { + const repository = { + _links: { + sources: { + href: "/sources" + } + } + }; + + const navLink = mount( + , + options.get() + ); + expect(navLink.text()).toBe("Sources"); + }); +}); diff --git a/scm-ui/src/repos/components/changesets/AvatarImage.js b/scm-ui/src/repos/components/changesets/AvatarImage.js new file mode 100644 index 0000000000..77792b1690 --- /dev/null +++ b/scm-ui/src/repos/components/changesets/AvatarImage.js @@ -0,0 +1,32 @@ +//@flow +import React from "react"; +import { binder } from "@scm-manager/ui-extensions"; +import type { Changeset } from "@scm-manager/ui-types"; +import { Image } from "@scm-manager/ui-components"; + +type Props = { + changeset: Changeset +}; + +class AvatarImage extends React.Component { + render() { + const { changeset } = this.props; + + const avatarFactory = binder.getExtension("changeset.avatar-factory"); + if (avatarFactory) { + const avatar = avatarFactory(changeset); + + return ( + {changeset.author.name} + ); + } + + return null; + } +} + +export default AvatarImage; diff --git a/scm-ui/src/repos/components/changesets/AvatarWrapper.js b/scm-ui/src/repos/components/changesets/AvatarWrapper.js new file mode 100644 index 0000000000..0d3d55e62a --- /dev/null +++ b/scm-ui/src/repos/components/changesets/AvatarWrapper.js @@ -0,0 +1,18 @@ +//@flow +import * as React from "react"; +import { binder } from "@scm-manager/ui-extensions"; + +type Props = { + children: React.Node +}; + +class AvatarWrapper extends React.Component { + render() { + if (binder.hasExtension("changeset.avatar-factory")) { + return <>{this.props.children}; + } + return null; + } +} + +export default AvatarWrapper; diff --git a/scm-ui/src/repos/components/changesets/ChangesetAvatar.js b/scm-ui/src/repos/components/changesets/ChangesetAvatar.js deleted file mode 100644 index 90f116daed..0000000000 --- a/scm-ui/src/repos/components/changesets/ChangesetAvatar.js +++ /dev/null @@ -1,30 +0,0 @@ -//@flow -import React from "react"; -import { ExtensionPoint } from "@scm-manager/ui-extensions"; -import type { Changeset } from "@scm-manager/ui-types"; - -type Props = { - changeset: Changeset -}; - -class ChangesetAvatar extends React.Component { - render() { - const { changeset } = this.props; - return ( - - {/* extension should render something like this: */} - {/*
*/} - {/*
*/} - {/* Logo */} - {/*
*/} - {/*
*/} -
- ); - } -} - -export default ChangesetAvatar; diff --git a/scm-ui/src/repos/components/changesets/ChangesetDetails.js b/scm-ui/src/repos/components/changesets/ChangesetDetails.js new file mode 100644 index 0000000000..a8edf0365c --- /dev/null +++ b/scm-ui/src/repos/components/changesets/ChangesetDetails.js @@ -0,0 +1,100 @@ +//@flow +import React from "react"; +import type { + Changeset, + Repository +} from "../../../../../scm-ui-components/packages/ui-types/src/index"; +import { Interpolate, translate } from "react-i18next"; +import injectSheet from "react-jss"; +import ChangesetTag from "./ChangesetTag"; +import ChangesetAuthor from "./ChangesetAuthor"; +import { parseDescription } from "./changesets"; +import { DateFromNow } from "../../../../../scm-ui-components/packages/ui-components/src/index"; +import AvatarWrapper from "./AvatarWrapper"; +import AvatarImage from "./AvatarImage"; +import classNames from "classnames"; +import ChangesetId from "./ChangesetId"; +import type { Tag } from "@scm-manager/ui-types"; + +const styles = { + spacing: { + marginRight: "1em" + } +}; + +type Props = { + changeset: Changeset, + repository: Repository, + t: string => string, + classes: any +}; + +class ChangesetDetails extends React.Component { + render() { + const { changeset, repository, classes } = this.props; + + const description = parseDescription(changeset.description); + + const id = ( + + ); + const date = ; + + return ( +
+

{description.title}

+
+ +

+ +

+
+
+

+ +

+

+ +

+
+
{this.renderTags()}
+
+

+ {description.message.split("\n").map((item, key) => { + return ( + + {item} +
+
+ ); + })} +

+
+ ); + } + + getTags = () => { + const { changeset } = this.props; + return changeset._embedded.tags || []; + }; + + renderTags = () => { + const tags = this.getTags(); + if (tags.length > 0) { + return ( +
+ {tags.map((tag: Tag) => { + return ; + })} +
+ ); + } + return null; + }; +} + +export default injectSheet(styles)(translate("repos")(ChangesetDetails)); diff --git a/scm-ui/src/repos/components/changesets/ChangesetId.js b/scm-ui/src/repos/components/changesets/ChangesetId.js index 7669cd606e..ba38e6179c 100644 --- a/scm-ui/src/repos/components/changesets/ChangesetId.js +++ b/scm-ui/src/repos/components/changesets/ChangesetId.js @@ -6,20 +6,42 @@ import type { Repository, Changeset } from "@scm-manager/ui-types"; type Props = { repository: Repository, - changeset: Changeset + changeset: Changeset, + link: boolean }; export default class ChangesetId extends React.Component { - render() { - const { repository, changeset } = this.props; + static defaultProps = { + link: true + }; + + shortId = (changeset: Changeset) => { + return changeset.id.substr(0, 7); + }; + + renderLink = () => { + const { changeset, repository } = this.props; return ( - {changeset.id.substr(0, 7)} + {this.shortId(changeset)} ); + }; + + renderText = () => { + const { changeset } = this.props; + return this.shortId(changeset); + }; + + render() { + const { link } = this.props; + if (link) { + return this.renderLink(); + } + return this.renderText(); } } diff --git a/scm-ui/src/repos/components/changesets/ChangesetRow.js b/scm-ui/src/repos/components/changesets/ChangesetRow.js index a1a497ad67..ffe2a7eda4 100644 --- a/scm-ui/src/repos/components/changesets/ChangesetRow.js +++ b/scm-ui/src/repos/components/changesets/ChangesetRow.js @@ -3,13 +3,15 @@ import React from "react"; import type { Changeset, Repository, Tag } from "@scm-manager/ui-types"; import classNames from "classnames"; import { translate, Interpolate } from "react-i18next"; -import ChangesetAvatar from "./ChangesetAvatar"; import ChangesetId from "./ChangesetId"; import injectSheet from "react-jss"; import { DateFromNow } from "@scm-manager/ui-components"; import ChangesetAuthor from "./ChangesetAuthor"; import ChangesetTag from "./ChangesetTag"; import { compose } from "redux"; +import { parseDescription } from "./changesets"; +import AvatarWrapper from "./AvatarWrapper"; +import AvatarImage from "./AvatarImage"; const styles = { pointer: { @@ -46,14 +48,23 @@ class ChangesetRow extends React.Component { const changesetLink = this.createLink(changeset); const dateFromNow = ; const authorLine = ; + const description = parseDescription(changeset.description); return (
- + +
+
+

+ +

+
+
+

- {changeset.description} + {description.title}
0) { + title = desc.substring(0, lineBreak); + message = desc.substring(lineBreak + 1); + } else { + title = desc; + } + + return { + title, + message + }; +} diff --git a/scm-ui/src/repos/components/changesets/changesets.test.js b/scm-ui/src/repos/components/changesets/changesets.test.js new file mode 100644 index 0000000000..ea92bcead3 --- /dev/null +++ b/scm-ui/src/repos/components/changesets/changesets.test.js @@ -0,0 +1,22 @@ +// @flow + +import { parseDescription } from "./changesets"; + +describe("parseDescription tests", () => { + it("should return a description with title and message", () => { + const desc = parseDescription("Hello\nTrillian"); + expect(desc.title).toBe("Hello"); + expect(desc.message).toBe("Trillian"); + }); + + it("should return a description with title and without message", () => { + const desc = parseDescription("Hello Trillian"); + expect(desc.title).toBe("Hello Trillian"); + }); + + it("should return an empty description for undefined", () => { + const desc = parseDescription(); + expect(desc.title).toBe(""); + expect(desc.message).toBe(""); + }); +}); diff --git a/scm-ui/src/repos/containers/BranchSelector.js b/scm-ui/src/repos/containers/BranchSelector.js index 2d8c817542..2183e13b69 100644 --- a/scm-ui/src/repos/containers/BranchSelector.js +++ b/scm-ui/src/repos/containers/BranchSelector.js @@ -17,6 +17,7 @@ const styles = { type Props = { branches: Branch[], // TODO: Use generics? selected: (branch?: Branch) => void, + selectedBranch: string, // context props classes: Object, @@ -31,6 +32,12 @@ class BranchSelector extends React.Component { this.state = {}; } + componentDidMount() { + this.props.branches + .filter(branch => branch.name === this.props.selectedBranch) + .forEach(branch => this.setState({ selectedBranch: branch })); + } + render() { const { branches, classes, t } = this.props; @@ -60,6 +67,8 @@ class BranchSelector extends React.Component {

); + } else { + return null; } } diff --git a/scm-ui/src/repos/containers/ChangesetView.js b/scm-ui/src/repos/containers/ChangesetView.js new file mode 100644 index 0000000000..b164ca03ec --- /dev/null +++ b/scm-ui/src/repos/containers/ChangesetView.js @@ -0,0 +1,75 @@ +//@flow +import React from "react"; +import { connect } from "react-redux"; +import { withRouter } from "react-router-dom"; +import type { Changeset, Repository } from "@scm-manager/ui-types"; +import { + fetchChangesetIfNeeded, + getChangeset, + getFetchChangesetFailure, + isFetchChangesetPending +} from "../modules/changesets"; +import ChangesetDetails from "../components/changesets/ChangesetDetails"; +import { translate } from "react-i18next"; +import { Loading, ErrorPage } from "@scm-manager/ui-components"; + +type Props = { + id: string, + changeset: Changeset, + repository: Repository, + loading: boolean, + error: Error, + fetchChangesetIfNeeded: (repository: Repository, id: string) => void, + match: any, + t: string => string +}; + +class ChangesetView extends React.Component { + componentDidMount() { + const { fetchChangesetIfNeeded, repository } = this.props; + const id = this.props.match.params.id; + fetchChangesetIfNeeded(repository, id); + } + + render() { + const { changeset, loading, error, t, repository } = this.props; + + if (error) { + return ( + + ); + } + + if (!changeset || loading) return ; + + return ; + } +} + +const mapStateToProps = (state, ownProps: Props) => { + const repository = ownProps.repository; + const id = ownProps.match.params.id; + const changeset = getChangeset(state, repository, id); + const loading = isFetchChangesetPending(state, repository, id); + const error = getFetchChangesetFailure(state, repository, id); + return { changeset, error, loading }; +}; + +const mapDispatchToProps = dispatch => { + return { + fetchChangesetIfNeeded: (repository: Repository, id: string) => { + dispatch(fetchChangesetIfNeeded(repository, id)); + } + }; +}; + +export default withRouter( + connect( + mapStateToProps, + mapDispatchToProps + )(translate("changesets")(ChangesetView)) +); diff --git a/scm-ui/src/repos/containers/BranchRoot.js b/scm-ui/src/repos/containers/ChangesetsRoot.js similarity index 97% rename from scm-ui/src/repos/containers/BranchRoot.js rename to scm-ui/src/repos/containers/ChangesetsRoot.js index c60dc7281a..1f3f0c1e3b 100644 --- a/scm-ui/src/repos/containers/BranchRoot.js +++ b/scm-ui/src/repos/containers/ChangesetsRoot.js @@ -92,11 +92,12 @@ class BranchRoot extends React.Component { } renderBranchSelector = () => { - const { repository, branches } = this.props; + const { repository, branches, selected } = this.props; if (repository._links.branches) { return ( { this.branchSelected(b); }} diff --git a/scm-ui/src/repos/containers/RepositoryRoot.js b/scm-ui/src/repos/containers/RepositoryRoot.js index ba3ce72e4b..9e6ada9a4e 100644 --- a/scm-ui/src/repos/containers/RepositoryRoot.js +++ b/scm-ui/src/repos/containers/RepositoryRoot.js @@ -7,9 +7,11 @@ import { getRepository, isFetchRepoPending } from "../modules/repos"; + import { connect } from "react-redux"; import { Route, Switch } from "react-router-dom"; import type { Repository } from "@scm-manager/ui-types"; + import { ErrorPage, Loading, @@ -26,8 +28,12 @@ import Permissions from "../permissions/containers/Permissions"; import type { History } from "history"; import EditNavLink from "../components/EditNavLink"; -import BranchRoot from "./BranchRoot"; + +import BranchRoot from "./ChangesetsRoot"; +import ChangesetView from "./ChangesetView"; import PermissionsNavLink from "../components/PermissionsNavLink"; +import Sources from "../sources/containers/Sources"; +import RepositoryNavLink from "../components/RepositoryNavLink"; import { getRepositoriesLink } from "../../modules/indexResource"; type Props = { @@ -74,6 +80,11 @@ class RepositoryRoot extends React.Component { this.props.deleteRepo(repository, this.deleted); }; + matchChangeset = (route: any) => { + const url = this.matchedUrl(); + return route.location.pathname.match(`${url}/changeset/`); + }; + matches = (route: any) => { const url = this.matchedUrl(); const regex = new RegExp(`${url}(/branches)?/?[^/]*/changesets?.*`); @@ -121,6 +132,24 @@ class RepositoryRoot extends React.Component { /> )} /> + } + /> + ( + + )} + /> + ( + + )} + /> ( @@ -147,11 +176,20 @@ class RepositoryRoot extends React.Component {
- + { + if (shouldFetchChangeset(getState(), repository, id)) { + return dispatch(fetchChangeset(repository, id)); + } + }; +} + +export function fetchChangeset(repository: Repository, id: string) { + return function(dispatch: any) { + dispatch(fetchChangesetPending(repository, id)); + return apiClient + .get(createChangesetUrl(repository, id)) + .then(response => response.json()) + .then(data => dispatch(fetchChangesetSuccess(data, repository, id))) + .catch(err => { + dispatch(fetchChangesetFailure(repository, id, err)); + }); + }; +} + +function createChangesetUrl(repository: Repository, id: string) { + return urls.concat(repository._links.changesets.href, id); +} + +export function fetchChangesetPending( + repository: Repository, + id: string +): Action { + return { + type: FETCH_CHANGESET_PENDING, + itemId: createChangesetItemId(repository, id) + }; +} + +export function fetchChangesetSuccess( + changeset: any, + repository: Repository, + id: string +): Action { + return { + type: FETCH_CHANGESET_SUCCESS, + payload: { changeset, repository, id }, + itemId: createChangesetItemId(repository, id) + }; +} + +function fetchChangesetFailure( + repository: Repository, + id: string, + error: Error +): Action { + return { + type: FETCH_CHANGESET_FAILURE, + payload: { + repository, + id, + error + }, + itemId: createChangesetItemId(repository, id) + }; +} export function fetchChangesets( repository: Repository, @@ -80,7 +148,11 @@ export function fetchChangesetsSuccess( ): Action { return { type: FETCH_CHANGESETS_SUCCESS, - payload: changesets, + payload: { + repository, + branch, + changesets + }, itemId: createItemId(repository, branch) }; } @@ -101,6 +173,11 @@ function fetchChangesetsFailure( }; } +function createChangesetItemId(repository: Repository, id: string) { + const { namespace, name } = repository; + return namespace + "/" + name + "/" + id; +} + function createItemId(repository: Repository, branch?: Branch): string { const { namespace, name } = repository; let itemId = namespace + "/" + name; @@ -118,10 +195,32 @@ export default function reducer( if (!action.payload) { return state; } + const payload = action.payload; switch (action.type) { + case FETCH_CHANGESET_SUCCESS: + const _key = createItemId(payload.repository); + + let _oldByIds = {}; + if (state[_key] && state[_key].byId) { + _oldByIds = state[_key].byId; + } + + const changeset = payload.changeset; + + return { + ...state, + [_key]: { + ...state[_key], + byId: { + ..._oldByIds, + [changeset.id]: changeset + } + } + }; + case FETCH_CHANGESETS_SUCCESS: - const changesets = payload._embedded.changesets; + const changesets = payload.changesets._embedded.changesets; const changesetIds = changesets.map(c => c.id); const key = action.itemId; @@ -129,26 +228,32 @@ export default function reducer( return state; } - let oldByIds = {}; - if (state[key] && state[key].byId) { - oldByIds = state[key].byId; + const repoId = createItemId(payload.repository); + + let oldState = {}; + if (state[repoId]) { + oldState = state[repoId]; } + const branchName = payload.branch ? payload.branch.name : ""; const byIds = extractChangesetsByIds(changesets); return { ...state, - [key]: { + [repoId]: { byId: { - ...oldByIds, + ...oldState.byId, ...byIds }, - list: { - entries: changesetIds, - entry: { - page: payload.page, - pageTotal: payload.pageTotal, - _links: payload._links + byBranch: { + ...oldState.byBranch, + [branchName]: { + entries: changesetIds, + entry: { + page: payload.changesets.page, + pageTotal: payload.changesets.pageTotal, + _links: payload.changesets._links + } } } } @@ -174,17 +279,76 @@ export function getChangesets( repository: Repository, branch?: Branch ) { - const key = createItemId(repository, branch); + const repoKey = createItemId(repository); - const changesets = state.changesets[key]; + const stateRoot = state.changesets[repoKey]; + if (!stateRoot || !stateRoot.byBranch) { + return null; + } + + const branchName = branch ? branch.name : ""; + + const changesets = stateRoot.byBranch[branchName]; if (!changesets) { return null; } - return changesets.list.entries.map((id: string) => { - return changesets.byId[id]; + + return changesets.entries.map((id: string) => { + return stateRoot.byId[id]; }); } +export function getChangeset( + state: Object, + repository: Repository, + id: string +) { + const key = createItemId(repository); + const changesets = + state.changesets && state.changesets[key] + ? state.changesets[key].byId + : null; + if (changesets != null && changesets[id]) { + return changesets[id]; + } + return null; +} + +export function shouldFetchChangeset( + state: Object, + repository: Repository, + id: string +) { + if (getChangeset(state, repository, id)) { + return false; + } + return true; +} + +export function isFetchChangesetPending( + state: Object, + repository: Repository, + id: string +) { + return isPending( + state, + FETCH_CHANGESET, + createChangesetItemId(repository, id) + ); +} + +export function getFetchChangesetFailure( + state: Object, + repository: Repository, + id: string +) { + return getFailure( + state, + FETCH_CHANGESET, + createChangesetItemId(repository, id) + ); +} + export function isFetchChangesetsPending( state: Object, repository: Repository, @@ -202,9 +366,15 @@ export function getFetchChangesetsFailure( } const selectList = (state: Object, repository: Repository, branch?: Branch) => { - const itemId = createItemId(repository, branch); - if (state.changesets[itemId] && state.changesets[itemId].list) { - return state.changesets[itemId].list; + const repoId = createItemId(repository); + + const branchName = branch ? branch.name : ""; + if (state.changesets[repoId]) { + const repoState = state.changesets[repoId]; + + if (repoState.byBranch && repoState.byBranch[branchName]) { + return repoState.byBranch[branchName]; + } } return {}; }; diff --git a/scm-ui/src/repos/modules/changesets.test.js b/scm-ui/src/repos/modules/changesets.test.js index 3b0410b635..489312688d 100644 --- a/scm-ui/src/repos/modules/changesets.test.js +++ b/scm-ui/src/repos/modules/changesets.test.js @@ -8,11 +8,23 @@ import reducer, { FETCH_CHANGESETS_FAILURE, FETCH_CHANGESETS_PENDING, FETCH_CHANGESETS_SUCCESS, + FETCH_CHANGESET, + FETCH_CHANGESET_FAILURE, + FETCH_CHANGESET_PENDING, + FETCH_CHANGESET_SUCCESS, fetchChangesets, fetchChangesetsSuccess, getChangesets, getFetchChangesetsFailure, - isFetchChangesetsPending + isFetchChangesetsPending, + fetchChangeset, + getChangeset, + fetchChangesetIfNeeded, + shouldFetchChangeset, + isFetchChangesetPending, + getFetchChangesetFailure, + fetchChangesetSuccess, + selectListAsCollection } from "./changesets"; const branch = { @@ -21,7 +33,7 @@ const branch = { _links: { history: { href: - "http://scm/api/rest/v2/repositories/foo/bar/branches/specific/changesets" + "http://scm.hitchhicker.com/api/v2/repositories/foo/bar/branches/specific/changesets" } } }; @@ -32,14 +44,14 @@ const repository = { type: "GIT", _links: { self: { - href: "http://scm/api/rest/v2/repositories/foo/bar" + href: "http://scm.hitchhicker.com/api/v2/repositories/foo/bar" }, changesets: { - href: "http://scm/api/rest/v2/repositories/foo/bar/changesets" + href: "http://scm.hitchhicker.com/api/v2/repositories/foo/bar/changesets" }, branches: { href: - "http://scm/api/rest/v2/repositories/foo/bar/branches/specific/branches" + "http://scm.hitchhicker.com/api/v2/repositories/foo/bar/branches/specific/branches" } } }; @@ -49,9 +61,10 @@ const changesets = {}; describe("changesets", () => { describe("fetching of changesets", () => { const DEFAULT_BRANCH_URL = - "http://scm/api/rest/v2/repositories/foo/bar/changesets"; + "http://scm.hitchhicker.com/api/v2/repositories/foo/bar/changesets"; const SPECIFIC_BRANCH_URL = - "http://scm/api/rest/v2/repositories/foo/bar/branches/specific/changesets"; + "http://scm.hitchhicker.com/api/v2/repositories/foo/bar/branches/specific/changesets"; + const mockStore = configureMockStore([thunk]); afterEach(() => { @@ -59,6 +72,102 @@ describe("changesets", () => { fetchMock.restore(); }); + const changesetId = "aba876c0625d90a6aff1494f3d161aaa7008b958"; + + it("should fetch changeset", () => { + fetchMock.getOnce(DEFAULT_BRANCH_URL + "/" + changesetId, "{}"); + + const expectedActions = [ + { + type: FETCH_CHANGESET_PENDING, + itemId: "foo/bar/" + changesetId + }, + { + type: FETCH_CHANGESET_SUCCESS, + payload: { + changeset: {}, + id: changesetId, + repository: repository + }, + itemId: "foo/bar/" + changesetId + } + ]; + + const store = mockStore({}); + return store + .dispatch(fetchChangeset(repository, changesetId)) + .then(() => { + expect(store.getActions()).toEqual(expectedActions); + }); + }); + + it("should fail fetching changeset on error", () => { + fetchMock.getOnce(DEFAULT_BRANCH_URL + "/" + changesetId, 500); + + const expectedActions = [ + { + type: FETCH_CHANGESET_PENDING, + itemId: "foo/bar/" + changesetId + } + ]; + + const store = mockStore({}); + return store + .dispatch(fetchChangeset(repository, changesetId)) + .then(() => { + expect(store.getActions()[0]).toEqual(expectedActions[0]); + expect(store.getActions()[1].type).toEqual(FETCH_CHANGESET_FAILURE); + expect(store.getActions()[1].payload).toBeDefined(); + }); + }); + + it("should fetch changeset if needed", () => { + fetchMock.getOnce(DEFAULT_BRANCH_URL + "/id3", "{}"); + + const expectedActions = [ + { + type: FETCH_CHANGESET_PENDING, + itemId: "foo/bar/id3" + }, + { + type: FETCH_CHANGESET_SUCCESS, + payload: { + changeset: {}, + id: "id3", + repository: repository + }, + itemId: "foo/bar/id3" + } + ]; + + const store = mockStore({}); + return store + .dispatch(fetchChangesetIfNeeded(repository, "id3")) + .then(() => { + expect(store.getActions()).toEqual(expectedActions); + }); + }); + + it("should not fetch changeset if not needed", () => { + fetchMock.getOnce(DEFAULT_BRANCH_URL + "/id1", 500); + + const state = { + changesets: { + "foo/bar": { + byId: { + id1: { id: "id1" }, + id2: { id: "id2" } + } + } + } + }; + + const store = mockStore(state); + return expect( + store.dispatch(fetchChangesetIfNeeded(repository, "id1")) + ).toEqual(undefined); + }); + it("should fetch changesets for default branch", () => { fetchMock.getOnce(DEFAULT_BRANCH_URL, "{}"); @@ -69,7 +178,11 @@ describe("changesets", () => { }, { type: FETCH_CHANGESETS_SUCCESS, - payload: changesets, + payload: { + repository, + undefined, + changesets + }, itemId: "foo/bar" } ]; @@ -91,7 +204,11 @@ describe("changesets", () => { }, { type: FETCH_CHANGESETS_SUCCESS, - payload: changesets, + payload: { + repository, + branch, + changesets + }, itemId } ]; @@ -150,7 +267,11 @@ describe("changesets", () => { }, { type: FETCH_CHANGESETS_SUCCESS, - payload: changesets, + payload: { + repository, + undefined, + changesets + }, itemId: "foo/bar" } ]; @@ -173,7 +294,11 @@ describe("changesets", () => { }, { type: FETCH_CHANGESETS_SUCCESS, - payload: changesets, + payload: { + repository, + branch, + changesets + }, itemId: "foo/bar/specific" } ]; @@ -215,7 +340,7 @@ describe("changesets", () => { ); expect(newState["foo/bar"].byId["changeset2"].description).toEqual("foo"); expect(newState["foo/bar"].byId["changeset3"].description).toEqual("bar"); - expect(newState["foo/bar"].list).toEqual({ + expect(newState["foo/bar"].byBranch[""]).toEqual({ entry: { page: 1, pageTotal: 10, @@ -225,6 +350,20 @@ describe("changesets", () => { }); }); + it("should store the changeset list to branch", () => { + const newState = reducer( + {}, + fetchChangesetsSuccess(repository, branch, responseBody) + ); + + expect(newState["foo/bar"].byId["changeset1"]).toBeDefined(); + expect(newState["foo/bar"].byBranch["specific"].entries).toEqual([ + "changeset1", + "changeset2", + "changeset3" + ]); + }); + it("should not remove existing changesets", () => { const state = { "foo/bar": { @@ -232,8 +371,10 @@ describe("changesets", () => { id2: { id: "id2" }, id1: { id: "id1" } }, - list: { - entries: ["id1", "id2"] + byBranch: { + "": { + entries: ["id1", "id2"] + } } } }; @@ -245,7 +386,7 @@ describe("changesets", () => { const fooBar = newState["foo/bar"]; - expect(fooBar.list.entries).toEqual([ + expect(fooBar.byBranch[""].entries).toEqual([ "changeset1", "changeset2", "changeset3" @@ -253,11 +394,154 @@ describe("changesets", () => { expect(fooBar.byId["id2"]).toEqual({ id: "id2" }); expect(fooBar.byId["id1"]).toEqual({ id: "id1" }); }); + + const responseBodySingleChangeset = { + id: "id3", + author: { + mail: "z@phod.com", + name: "zaphod" + }, + date: "2018-09-13T08:46:22Z", + description: "added testChangeset", + _links: {}, + _embedded: { + tags: [], + branches: [] + } + }; + + it("should add changeset to state", () => { + const newState = reducer( + { + "foo/bar": { + byId: { + "id2": { + id: "id2", + author: { mail: "mail@author.com", name: "author" } + } + }, + list: { + entry: { + page: 1, + pageTotal: 10, + _links: {} + }, + entries: ["id2"] + } + } + }, + fetchChangesetSuccess(responseBodySingleChangeset, repository, "id3") + ); + + expect(newState).toBeDefined(); + expect(newState["foo/bar"].byId["id3"].description).toEqual( + "added testChangeset" + ); + expect(newState["foo/bar"].byId["id3"].author.mail).toEqual("z@phod.com"); + expect(newState["foo/bar"].byId["id2"]).toBeDefined(); + expect(newState["foo/bar"].byId["id3"]).toBeDefined(); + expect(newState["foo/bar"].list).toEqual({ + entry: { + page: 1, + pageTotal: 10, + _links: {} + }, + entries: ["id2"] + }); + }); }); describe("changeset selectors", () => { const error = new Error("Something went wrong"); + it("should return changeset", () => { + const state = { + changesets: { + "foo/bar": { + byId: { + id1: { id: "id1" }, + id2: { id: "id2" } + } + } + } + }; + const result = getChangeset(state, repository, "id1"); + expect(result).toEqual({ id: "id1" }); + }); + + it("should return null if changeset does not exist", () => { + const state = { + changesets: { + "foo/bar": { + byId: { + id1: { id: "id1" }, + id2: { id: "id2" } + } + } + } + }; + const result = getChangeset(state, repository, "id3"); + expect(result).toEqual(null); + }); + + it("should return true if changeset does not exist", () => { + const state = { + changesets: { + "foo/bar": { + byId: { + id1: { id: "id1" }, + id2: { id: "id2" } + } + } + } + }; + const result = shouldFetchChangeset(state, repository, "id3"); + expect(result).toEqual(true); + }); + + it("should return false if changeset exists", () => { + const state = { + changesets: { + "foo/bar": { + byId: { + id1: { id: "id1" }, + id2: { id: "id2" } + } + } + } + }; + const result = shouldFetchChangeset(state, repository, "id2"); + expect(result).toEqual(false); + }); + + it("should return true, when fetching changeset is pending", () => { + const state = { + pending: { + [FETCH_CHANGESET + "/foo/bar/id1"]: true + } + }; + + expect(isFetchChangesetPending(state, repository, "id1")).toBeTruthy(); + }); + + it("should return false, when fetching changeset is not pending", () => { + expect(isFetchChangesetPending({}, repository, "id1")).toEqual(false); + }); + + it("should return error if fetching changeset failed", () => { + const state = { + failure: { + [FETCH_CHANGESET + "/foo/bar/id1"]: error + } + }; + + expect(getFetchChangesetFailure(state, repository, "id1")).toEqual(error); + }); + + it("should return false if fetching changeset did not fail", () => { + expect(getFetchChangesetFailure({}, repository, "id1")).toBeUndefined(); + }); + it("should get all changesets for a given repository", () => { const state = { changesets: { @@ -266,8 +550,10 @@ describe("changesets", () => { id2: { id: "id2" }, id1: { id: "id1" } }, - list: { - entries: ["id1", "id2"] + byBranch: { + "": { + entries: ["id1", "id2"] + } } } } @@ -303,5 +589,32 @@ describe("changesets", () => { it("should return false if fetching changesets did not fail", () => { expect(getFetchChangesetsFailure({}, repository)).toBeUndefined(); }); + + it("should return list as collection for the default branch", () => { + const state = { + changesets: { + "foo/bar": { + byId: { + id2: { id: "id2" }, + id1: { id: "id1" } + }, + byBranch: { + "": { + entry: { + page: 1, + pageTotal: 10, + _links: {} + }, + entries: ["id1", "id2"] + } + } + } + } + }; + + const collection = selectListAsCollection(state, repository); + expect(collection.page).toBe(1); + expect(collection.pageTotal).toBe(10); + }); }); }); diff --git a/scm-ui/src/repos/permissions/components/CreatePermissionForm.js b/scm-ui/src/repos/permissions/components/CreatePermissionForm.js index 2a7d15658f..0bc42fac9f 100644 --- a/scm-ui/src/repos/permissions/components/CreatePermissionForm.js +++ b/scm-ui/src/repos/permissions/components/CreatePermissionForm.js @@ -5,13 +5,13 @@ import { Checkbox, InputField, SubmitButton } from "@scm-manager/ui-components"; import TypeSelector from "./TypeSelector"; import type { PermissionCollection, - PermissionEntry + PermissionCreateEntry } from "@scm-manager/ui-types"; import * as validator from "./permissionValidation"; type Props = { t: string => string, - createPermission: (permission: PermissionEntry) => void, + createPermission: (permission: PermissionCreateEntry) => void, loading: boolean, currentPermissions: PermissionCollection }; @@ -68,7 +68,7 @@ class CreatePermissionForm extends React.Component {
diff --git a/scm-ui/src/repos/permissions/components/permissionValidation.js b/scm-ui/src/repos/permissions/components/permissionValidation.js index b74ae40988..d63ab72d54 100644 --- a/scm-ui/src/repos/permissions/components/permissionValidation.js +++ b/scm-ui/src/repos/permissions/components/permissionValidation.js @@ -1,21 +1,30 @@ // @flow import { validation } from "@scm-manager/ui-components"; -import type { - PermissionCollection, -} from "@scm-manager/ui-types"; +import type { PermissionCollection } from "@scm-manager/ui-types"; const isNameValid = validation.isNameValid; export { isNameValid }; -export const isPermissionValid = (name: string, groupPermission: boolean, permissions: PermissionCollection) => { - return isNameValid(name) && !currentPermissionIncludeName(name, groupPermission, permissions); +export const isPermissionValid = ( + name: string, + groupPermission: boolean, + permissions: PermissionCollection +) => { + return ( + isNameValid(name) && + !currentPermissionIncludeName(name, groupPermission, permissions) + ); }; -const currentPermissionIncludeName = (name: string, groupPermission: boolean, permissions: PermissionCollection) => { +const currentPermissionIncludeName = ( + name: string, + groupPermission: boolean, + permissions: PermissionCollection +) => { for (let i = 0; i < permissions.length; i++) { if ( permissions[i].name === name && - permissions[i].groupPermission == groupPermission + permissions[i].groupPermission === groupPermission ) return true; } diff --git a/scm-ui/src/repos/permissions/components/permissionValidation.test.js b/scm-ui/src/repos/permissions/components/permissionValidation.test.js index 036375a348..b2e7e8be68 100644 --- a/scm-ui/src/repos/permissions/components/permissionValidation.test.js +++ b/scm-ui/src/repos/permissions/components/permissionValidation.test.js @@ -17,7 +17,8 @@ describe("permission validation", () => { { name: "PermissionName", groupPermission: true, - type: "READ" + type: "READ", + _links: {} } ]; const name = "PermissionName"; @@ -33,7 +34,8 @@ describe("permission validation", () => { { name: "PermissionName", groupPermission: false, - type: "READ" + type: "READ", + _links: {} } ]; const name = "PermissionName"; diff --git a/scm-ui/src/repos/permissions/containers/Permissions.js b/scm-ui/src/repos/permissions/containers/Permissions.js index 94b12e62ad..ee9ac281a5 100644 --- a/scm-ui/src/repos/permissions/containers/Permissions.js +++ b/scm-ui/src/repos/permissions/containers/Permissions.js @@ -21,7 +21,7 @@ import { Loading, ErrorPage } from "@scm-manager/ui-components"; import type { Permission, PermissionCollection, - PermissionEntry + PermissionCreateEntry } from "@scm-manager/ui-types"; import SinglePermission from "./SinglePermission"; import CreatePermissionForm from "../components/CreatePermissionForm"; @@ -42,7 +42,7 @@ type Props = { fetchPermissions: (link: string, namespace: string, repoName: string) => void, createPermission: ( link: string, - permission: PermissionEntry, + permission: PermissionCreateEntry, namespace: string, repoName: string, callback?: () => void @@ -184,7 +184,7 @@ const mapDispatchToProps = dispatch => { }, createPermission: ( link: string, - permission: PermissionEntry, + permission: PermissionCreateEntry, namespace: string, repoName: string, callback?: () => void diff --git a/scm-ui/src/repos/permissions/modules/permissions.js b/scm-ui/src/repos/permissions/modules/permissions.js index ff0e242e4c..465d7a4ee0 100644 --- a/scm-ui/src/repos/permissions/modules/permissions.js +++ b/scm-ui/src/repos/permissions/modules/permissions.js @@ -6,7 +6,7 @@ import type { Action } from "@scm-manager/ui-components"; import type { PermissionCollection, Permission, - PermissionEntry + PermissionCreateEntry } from "@scm-manager/ui-types"; import { isPending } from "../../../modules/pending"; import { getFailure } from "../../../modules/failure"; @@ -222,7 +222,7 @@ export function modifyPermissionReset(namespace: string, repoName: string) { // create permission export function createPermission( link: string, - permission: PermissionEntry, + permission: PermissionCreateEntry, namespace: string, repoName: string, callback?: () => void @@ -259,7 +259,7 @@ export function createPermission( } export function createPermissionPending( - permission: PermissionEntry, + permission: PermissionCreateEntry, namespace: string, repoName: string ): Action { @@ -271,7 +271,7 @@ export function createPermissionPending( } export function createPermissionSuccess( - permission: PermissionEntry, + permission: PermissionCreateEntry, namespace: string, repoName: string ): Action { diff --git a/scm-ui/src/repos/permissions/modules/permissions.test.js b/scm-ui/src/repos/permissions/modules/permissions.test.js index 98f545d53e..3043a7db29 100644 --- a/scm-ui/src/repos/permissions/modules/permissions.test.js +++ b/scm-ui/src/repos/permissions/modules/permissions.test.js @@ -656,7 +656,7 @@ describe("permissions selectors", () => { it("should return true, when createPermission is true", () => { const state = { permissions: { - ["hitchhiker/puzzle42"]: { + "hitchhiker/puzzle42": { createPermission: true } } @@ -667,7 +667,7 @@ describe("permissions selectors", () => { it("should return false, when createPermission is false", () => { const state = { permissions: { - ["hitchhiker/puzzle42"]: { + "hitchhiker/puzzle42": { createPermission: false } } diff --git a/scm-ui/src/repos/sources/components/FileIcon.js b/scm-ui/src/repos/sources/components/FileIcon.js new file mode 100644 index 0000000000..97aa5bc6f6 --- /dev/null +++ b/scm-ui/src/repos/sources/components/FileIcon.js @@ -0,0 +1,22 @@ +// @flow +import React from "react"; +import type { File } from "@scm-manager/ui-types"; + +type Props = { + file: File +}; + +class FileIcon extends React.Component { + render() { + const { file } = this.props; + let icon = "file"; + if (file.subRepository) { + icon = "folder-plus"; + } else if (file.directory) { + icon = "folder"; + } + return ; + } +} + +export default FileIcon; diff --git a/scm-ui/src/repos/sources/components/FileSize.js b/scm-ui/src/repos/sources/components/FileSize.js new file mode 100644 index 0000000000..c7d966d901 --- /dev/null +++ b/scm-ui/src/repos/sources/components/FileSize.js @@ -0,0 +1,27 @@ +// @flow +import React from "react"; + +type Props = { + bytes: number +}; + +class FileSize extends React.Component { + static format(bytes: number) { + if (!bytes) { + return "0 B"; + } + + const units = ["B", "K", "M", "G", "T", "P", "E", "Z", "Y"]; + const i = Math.floor(Math.log(bytes) / Math.log(1024)); + + const size = i === 0 ? bytes : (bytes / 1024 ** i).toFixed(2); + return `${size} ${units[i]}`; + } + + render() { + const formattedBytes = FileSize.format(this.props.bytes); + return {formattedBytes}; + } +} + +export default FileSize; diff --git a/scm-ui/src/repos/sources/components/FileSize.test.js b/scm-ui/src/repos/sources/components/FileSize.test.js new file mode 100644 index 0000000000..8ecb53e1bb --- /dev/null +++ b/scm-ui/src/repos/sources/components/FileSize.test.js @@ -0,0 +1,10 @@ +import FileSize from "./FileSize"; + +it("should format bytes", () => { + expect(FileSize.format(0)).toBe("0 B"); + expect(FileSize.format(160)).toBe("160 B"); + expect(FileSize.format(6304)).toBe("6.16 K"); + expect(FileSize.format(28792588)).toBe("27.46 M"); + expect(FileSize.format(1369510189)).toBe("1.28 G"); + expect(FileSize.format(42949672960)).toBe("40.00 G"); +}); diff --git a/scm-ui/src/repos/sources/components/FileTree.js b/scm-ui/src/repos/sources/components/FileTree.js new file mode 100644 index 0000000000..02aa22f942 --- /dev/null +++ b/scm-ui/src/repos/sources/components/FileTree.js @@ -0,0 +1,184 @@ +//@flow +import React from "react"; +import { translate } from "react-i18next"; +import { connect } from "react-redux"; +import injectSheet from "react-jss"; +import FileTreeLeaf from "./FileTreeLeaf"; +import type { Repository, File } from "@scm-manager/ui-types"; +import { ErrorNotification, Loading } from "@scm-manager/ui-components"; +import { + fetchSources, + getFetchSourcesFailure, + isFetchSourcesPending, + getSources +} from "../modules/sources"; +import { withRouter } from "react-router-dom"; +import { compose } from "redux"; + +const styles = { + iconColumn: { + width: "16px" + } +}; + +type Props = { + loading: boolean, + error: Error, + tree: File, + repository: Repository, + revision: string, + path: string, + baseUrl: string, + fetchSources: (Repository, string, string) => void, + // context props + classes: any, + t: string => string, + match: any +}; + +export function findParent(path: string) { + if (path.endsWith("/")) { + path = path.substring(0, path.length - 1); + } + + const index = path.lastIndexOf("/"); + if (index > 0) { + return path.substring(0, index); + } + return ""; +} + +class FileTree extends React.Component { + componentDidMount() { + const { fetchSources, repository, revision, path } = this.props; + + fetchSources(repository, revision, path); + } + + componentDidUpdate(prevProps) { + const { fetchSources, repository, revision, path } = this.props; + if (prevProps.revision !== revision || prevProps.path !== path) { + fetchSources(repository, revision, path); + } + } + + render() { + const { + error, + loading, + tree, + revision, + path, + baseUrl, + classes, + t + } = this.props; + + const compareFiles = function(f1: File, f2: File): number { + if (f1.directory) { + if (f2.directory) { + return f1.name.localeCompare(f2.name); + } else { + return -1; + } + } else { + if (f2.directory) { + return 1; + } else { + return f1.name.localeCompare(f2.name); + } + } + }; + + if (error) { + return ; + } + + if (loading) { + return ; + } + if (!tree) { + return null; + } + + const files = []; + + if (path) { + files.push({ + name: "..", + path: findParent(path), + directory: true + }); + } + + if (tree._embedded) { + files.push(...tree._embedded.children.sort(compareFiles)); + } + + let baseUrlWithRevision = baseUrl; + if (revision) { + baseUrlWithRevision += "/" + encodeURIComponent(revision); + } else { + baseUrlWithRevision += "/" + encodeURIComponent(tree.revision); + } + + return ( + + + + + + + + + + + {files.map(file => ( + + ))} + +
+ {t("sources.file-tree.name")} + {t("sources.file-tree.length")} + + {t("sources.file-tree.lastModified")} + {t("sources.file-tree.description")}
+ ); + } +} + +const mapStateToProps = (state: any, ownProps: Props) => { + const { repository, revision, path } = ownProps; + + const loading = isFetchSourcesPending(state, repository, revision, path); + const error = getFetchSourcesFailure(state, repository, revision, path); + const tree = getSources(state, repository, revision, path); + + return { + revision, + path, + loading, + error, + tree + }; +}; + +const mapDispatchToProps = dispatch => { + return { + fetchSources: (repository: Repository, revision: string, path: string) => { + dispatch(fetchSources(repository, revision, path)); + } + }; +}; + +export default compose( + withRouter, + connect( + mapStateToProps, + mapDispatchToProps + ) +)(injectSheet(styles)(translate("repos")(FileTree))); diff --git a/scm-ui/src/repos/sources/components/FileTree.test.js b/scm-ui/src/repos/sources/components/FileTree.test.js new file mode 100644 index 0000000000..77977df5bc --- /dev/null +++ b/scm-ui/src/repos/sources/components/FileTree.test.js @@ -0,0 +1,12 @@ +// @flow + +import { findParent } from "./FileTree"; + +describe("find parent tests", () => { + it("should return the parent path", () => { + expect(findParent("src/main/js/")).toBe("src/main"); + expect(findParent("src/main/js")).toBe("src/main"); + expect(findParent("src/main")).toBe("src"); + expect(findParent("src")).toBe(""); + }); +}); diff --git a/scm-ui/src/repos/sources/components/FileTreeLeaf.js b/scm-ui/src/repos/sources/components/FileTreeLeaf.js new file mode 100644 index 0000000000..033d3b9b8a --- /dev/null +++ b/scm-ui/src/repos/sources/components/FileTreeLeaf.js @@ -0,0 +1,81 @@ +//@flow +import * as React from "react"; +import injectSheet from "react-jss"; +import { DateFromNow } from "@scm-manager/ui-components"; +import FileSize from "./FileSize"; +import FileIcon from "./FileIcon"; +import { Link } from "react-router-dom"; +import type { File } from "@scm-manager/ui-types"; + +const styles = { + iconColumn: { + width: "16px" + } +}; + +type Props = { + file: File, + baseUrl: string, + + // context props + classes: any +}; + +export function createLink(base: string, file: File) { + let link = base; + if (file.path) { + let path = file.path; + if (path.startsWith("/")) { + path = path.substring(1); + } + link += "/" + path; + } + if (!link.endsWith("/")) { + link += "/"; + } + return link; +} + +class FileTreeLeaf extends React.Component { + createLink = (file: File) => { + return createLink(this.props.baseUrl, file); + }; + + createFileIcon = (file: File) => { + if (file.directory) { + return ( + + + + ); + } + return ; + }; + + createFileName = (file: File) => { + if (file.directory) { + return {file.name}; + } + return file.name; + }; + + render() { + const { file, classes } = this.props; + + const fileSize = file.directory ? "" : ; + + return ( + + {this.createFileIcon(file)} + {this.createFileName(file)} + {fileSize} + + + + {file.description} + + ); + } +} + +export default injectSheet(styles)(FileTreeLeaf); diff --git a/scm-ui/src/repos/sources/components/FileTreeLeaf.test.js b/scm-ui/src/repos/sources/components/FileTreeLeaf.test.js new file mode 100644 index 0000000000..d5004521c8 --- /dev/null +++ b/scm-ui/src/repos/sources/components/FileTreeLeaf.test.js @@ -0,0 +1,24 @@ +// @flow + +import { createLink } from "./FileTreeLeaf"; +import type { File } from "@scm-manager/ui-types"; + +describe("create link tests", () => { + function dir(path: string): File { + return { + name: "dir", + path: path, + directory: true + }; + } + + it("should create link", () => { + expect(createLink("src", dir("main"))).toBe("src/main/"); + expect(createLink("src", dir("/main"))).toBe("src/main/"); + expect(createLink("src", dir("/main/"))).toBe("src/main/"); + }); + + it("should return base url if the directory path is empty", () => { + expect(createLink("src", dir(""))).toBe("src/"); + }); +}); diff --git a/scm-ui/src/repos/sources/containers/Sources.js b/scm-ui/src/repos/sources/containers/Sources.js new file mode 100644 index 0000000000..cf072e958e --- /dev/null +++ b/scm-ui/src/repos/sources/containers/Sources.js @@ -0,0 +1,131 @@ +// @flow +import React from "react"; +import { connect } from "react-redux"; +import { withRouter } from "react-router-dom"; +import type { Repository, Branch } from "@scm-manager/ui-types"; +import FileTree from "../components/FileTree"; +import { ErrorNotification, Loading } from "@scm-manager/ui-components"; +import BranchSelector from "../../containers/BranchSelector"; +import { + fetchBranches, + getBranches, + getFetchBranchesFailure, + isFetchBranchesPending +} from "../../modules/branches"; +import { compose } from "redux"; + +type Props = { + repository: Repository, + loading: boolean, + error: Error, + baseUrl: string, + branches: Branch[], + revision: string, + path: string, + + // dispatch props + fetchBranches: Repository => void, + + // Context props + history: any, + match: any +}; + +class Sources extends React.Component { + componentDidMount() { + const { fetchBranches, repository } = this.props; + + fetchBranches(repository); + } + + branchSelected = (branch?: Branch) => { + const { baseUrl, history, path } = this.props; + + let url; + if (branch) { + if (path) { + url = `${baseUrl}/${encodeURIComponent(branch.name)}/${path}`; + } else { + url = `${baseUrl}/${encodeURIComponent(branch.name)}/`; + } + } else { + url = `${baseUrl}/`; + } + history.push(url); + }; + + render() { + const { repository, baseUrl, loading, error, revision, path } = this.props; + + if (error) { + return ; + } + + if (loading) { + return ; + } + + return ( + <> + {this.renderBranchSelector()} + + + ); + } + + renderBranchSelector = () => { + const { repository, branches, revision } = this.props; + if (repository._links.branches) { + return ( + { + this.branchSelected(b); + }} + /> + ); + } + return null; + }; +} + +const mapStateToProps = (state, ownProps) => { + const { repository, match } = ownProps; + const { revision, path } = match.params; + const decodedRevision = revision ? decodeURIComponent(revision) : undefined; + + const loading = isFetchBranchesPending(state, repository); + const error = getFetchBranchesFailure(state, repository); + const branches = getBranches(state, repository); + + return { + repository, + revision: decodedRevision, + path, + loading, + error, + branches + }; +}; + +const mapDispatchToProps = dispatch => { + return { + fetchBranches: (repository: Repository) => { + dispatch(fetchBranches(repository)); + } + }; +}; + +export default compose( + withRouter, + connect( + mapStateToProps, + mapDispatchToProps + ) +)(Sources); diff --git a/scm-ui/src/repos/sources/modules/sources.js b/scm-ui/src/repos/sources/modules/sources.js new file mode 100644 index 0000000000..719770d75c --- /dev/null +++ b/scm-ui/src/repos/sources/modules/sources.js @@ -0,0 +1,141 @@ +// @flow + +import * as types from "../../../modules/types"; +import type { Repository, File, Action } from "@scm-manager/ui-types"; +import { apiClient } from "@scm-manager/ui-components"; +import { isPending } from "../../../modules/pending"; +import { getFailure } from "../../../modules/failure"; + +export const FETCH_SOURCES = "scm/repos/FETCH_SOURCES"; +export const FETCH_SOURCES_PENDING = `${FETCH_SOURCES}_${types.PENDING_SUFFIX}`; +export const FETCH_SOURCES_SUCCESS = `${FETCH_SOURCES}_${types.SUCCESS_SUFFIX}`; +export const FETCH_SOURCES_FAILURE = `${FETCH_SOURCES}_${types.FAILURE_SUFFIX}`; + +export function fetchSources( + repository: Repository, + revision: string, + path: string +) { + return function(dispatch: any) { + dispatch(fetchSourcesPending(repository, revision, path)); + return apiClient + .get(createUrl(repository, revision, path)) + .then(response => response.json()) + .then(sources => { + dispatch(fetchSourcesSuccess(repository, revision, path, sources)); + }) + .catch(err => { + const error = new Error(`failed to fetch sources: ${err.message}`); + dispatch(fetchSourcesFailure(repository, revision, path, error)); + }); + }; +} + +function createUrl(repository: Repository, revision: string, path: string) { + const base = repository._links.sources.href; + if (!revision && !path) { + return base; + } + + // TODO handle trailing slash + const pathDefined = path ? path : ""; + return `${base}${encodeURIComponent(revision)}/${pathDefined}`; +} + +export function fetchSourcesPending( + repository: Repository, + revision: string, + path: string +): Action { + return { + type: FETCH_SOURCES_PENDING, + itemId: createItemId(repository, revision, path) + }; +} + +export function fetchSourcesSuccess( + repository: Repository, + revision: string, + path: string, + sources: File +) { + return { + type: FETCH_SOURCES_SUCCESS, + payload: sources, + itemId: createItemId(repository, revision, path) + }; +} + +export function fetchSourcesFailure( + repository: Repository, + revision: string, + path: string, + error: Error +): Action { + return { + type: FETCH_SOURCES_FAILURE, + payload: error, + itemId: createItemId(repository, revision, path) + }; +} + +function createItemId(repository: Repository, revision: string, path: string) { + const revPart = revision ? revision : "_"; + const pathPart = path ? path : ""; + return `${repository.namespace}/${repository.name}/${revPart}/${pathPart}`; +} + +// reducer + +export default function reducer( + state: any = {}, + action: Action = { type: "UNKNOWN" } +): any { + if (action.type === FETCH_SOURCES_SUCCESS) { + return { + [action.itemId]: action.payload, + ...state + }; + } + return state; +} + +// selectors + +export function getSources( + state: any, + repository: Repository, + revision: string, + path: string +): ?File { + if (state.sources) { + return state.sources[createItemId(repository, revision, path)]; + } + return null; +} + +export function isFetchSourcesPending( + state: any, + repository: Repository, + revision: string, + path: string +): boolean { + return isPending( + state, + FETCH_SOURCES, + createItemId(repository, revision, path) + ); +} + +export function getFetchSourcesFailure( + state: any, + repository: Repository, + revision: string, + path: string +): ?Error { + return getFailure( + state, + FETCH_SOURCES, + createItemId(repository, revision, path) + ); +} diff --git a/scm-ui/src/repos/sources/modules/sources.test.js b/scm-ui/src/repos/sources/modules/sources.test.js new file mode 100644 index 0000000000..068fa39e8f --- /dev/null +++ b/scm-ui/src/repos/sources/modules/sources.test.js @@ -0,0 +1,220 @@ +// @flow + +import type { Repository } from "@scm-manager/ui-types"; +import configureMockStore from "redux-mock-store"; +import thunk from "redux-thunk"; +import fetchMock from "fetch-mock"; +import { + FETCH_SOURCES, + FETCH_SOURCES_FAILURE, + FETCH_SOURCES_PENDING, + FETCH_SOURCES_SUCCESS, + fetchSources, + getFetchSourcesFailure, + isFetchSourcesPending, + default as reducer, + getSources, + fetchSourcesSuccess +} from "./sources"; + +const sourcesUrl = + "http://localhost:8081/scm/rest/api/v2/repositories/scm/core/sources/"; + +const repository: Repository = { + name: "core", + namespace: "scm", + type: "git", + _links: { + sources: { + href: sourcesUrl + } + } +}; + +const collection = { + revision: "76aae4bb4ceacf0e88938eb5b6832738b7d537b4", + _links: { + self: { + href: + "http://localhost:8081/scm/rest/api/v2/repositories/scm/core/sources/76aae4bb4ceacf0e88938eb5b6832738b7d537b4/" + } + }, + _embedded: { + files: [ + { + name: "src", + path: "src", + directory: true, + description: null, + length: 176, + lastModified: null, + subRepository: null, + _links: { + self: { + href: + "http://localhost:8081/scm/rest/api/v2/repositories/scm/core/sources/76aae4bb4ceacf0e88938eb5b6832738b7d537b4/src" + } + } + }, + { + name: "package.json", + path: "package.json", + directory: false, + description: "bump version", + length: 780, + lastModified: "2017-07-31T11:17:19Z", + subRepository: null, + _links: { + self: { + href: + "http://localhost:8081/scm/rest/api/v2/repositories/scm/core/content/76aae4bb4ceacf0e88938eb5b6832738b7d537b4/package.json" + }, + history: { + href: + "http://localhost:8081/scm/rest/api/v2/repositories/scm/core/sources/history/76aae4bb4ceacf0e88938eb5b6832738b7d537b4/package.json" + } + } + } + ] + } +}; + +describe("sources fetch", () => { + const mockStore = configureMockStore([thunk]); + + afterEach(() => { + fetchMock.reset(); + fetchMock.restore(); + }); + + it("should fetch the sources of the repository", () => { + fetchMock.getOnce(sourcesUrl, collection); + + const expectedActions = [ + { type: FETCH_SOURCES_PENDING, itemId: "scm/core/_/" }, + { + type: FETCH_SOURCES_SUCCESS, + itemId: "scm/core/_/", + payload: collection + } + ]; + + const store = mockStore({}); + return store.dispatch(fetchSources(repository)).then(() => { + expect(store.getActions()).toEqual(expectedActions); + }); + }); + + it("should fetch the sources of the repository with the given revision and path", () => { + fetchMock.getOnce(sourcesUrl + "abc/src", collection); + + const expectedActions = [ + { type: FETCH_SOURCES_PENDING, itemId: "scm/core/abc/src" }, + { + type: FETCH_SOURCES_SUCCESS, + itemId: "scm/core/abc/src", + payload: collection + } + ]; + + const store = mockStore({}); + return store.dispatch(fetchSources(repository, "abc", "src")).then(() => { + expect(store.getActions()).toEqual(expectedActions); + }); + }); + + it("should dispatch FETCH_SOURCES_FAILURE on server error", () => { + fetchMock.getOnce(sourcesUrl, { + status: 500 + }); + + const store = mockStore({}); + return store.dispatch(fetchSources(repository)).then(() => { + const actions = store.getActions(); + expect(actions[0].type).toBe(FETCH_SOURCES_PENDING); + expect(actions[1].type).toBe(FETCH_SOURCES_FAILURE); + expect(actions[1].itemId).toBe("scm/core/_/"); + expect(actions[1].payload).toBeDefined(); + }); + }); +}); + +describe("reducer tests", () => { + it("should return unmodified state on unknown action", () => { + const state = {}; + expect(reducer(state)).toBe(state); + }); + + it("should store the collection, without revision and path", () => { + const expectedState = { + "scm/core/_/": collection + }; + expect( + reducer({}, fetchSourcesSuccess(repository, null, null, collection)) + ).toEqual(expectedState); + }); + + it("should store the collection, with revision and path", () => { + const expectedState = { + "scm/core/abc/src/main": collection + }; + expect( + reducer( + {}, + fetchSourcesSuccess(repository, "abc", "src/main", collection) + ) + ).toEqual(expectedState); + }); +}); + +describe("selector tests", () => { + it("should return null", () => { + expect(getSources({}, repository)).toBeFalsy(); + }); + + it("should return the source collection without revision and path", () => { + const state = { + sources: { + "scm/core/_/": collection + } + }; + expect(getSources(state, repository)).toBe(collection); + }); + + it("should return the source collection without revision and path", () => { + const state = { + sources: { + "scm/core/abc/src/main": collection + } + }; + expect(getSources(state, repository, "abc", "src/main")).toBe(collection); + }); + + it("should return true, when fetch sources is pending", () => { + const state = { + pending: { + [FETCH_SOURCES + "/scm/core/_/"]: true + } + }; + expect(isFetchSourcesPending(state, repository)).toEqual(true); + }); + + it("should return false, when fetch sources is not pending", () => { + expect(isFetchSourcesPending({}, repository)).toEqual(false); + }); + + const error = new Error("incredible error from hell"); + + it("should return error when fetch sources did fail", () => { + const state = { + failure: { + [FETCH_SOURCES + "/scm/core/_/"]: error + } + }; + expect(getFetchSourcesFailure(state, repository)).toEqual(error); + }); + + it("should return undefined when fetch sources did not fail", () => { + expect(getFetchSourcesFailure({}, repository)).toBe(undefined); + }); +}); diff --git a/scm-webapp/src/main/java/sonia/scm/WebResourceServlet.java b/scm-webapp/src/main/java/sonia/scm/WebResourceServlet.java index 6b4a29961d..222800c9e3 100644 --- a/scm-webapp/src/main/java/sonia/scm/WebResourceServlet.java +++ b/scm-webapp/src/main/java/sonia/scm/WebResourceServlet.java @@ -23,6 +23,7 @@ import java.net.URL; * @since 2.0.0 */ @Singleton +@Priority(WebResourceServlet.PRIORITY) @WebElement(value = WebResourceServlet.PATTERN, regex = true) public class WebResourceServlet extends HttpServlet { @@ -35,6 +36,9 @@ public class WebResourceServlet extends HttpServlet { @VisibleForTesting static final String PATTERN = "/(?!api/|git/|hg/|svn/|hook/|repo/).*"; + // Be sure that this servlet is the last one in the servlet chain. + static final int PRIORITY = Integer.MAX_VALUE; + private static final Logger LOG = LoggerFactory.getLogger(WebResourceServlet.class); private final WebResourceSender sender = WebResourceSender.create() diff --git a/scm-webapp/src/main/java/sonia/scm/api/v2/resources/BrowserResultDto.java b/scm-webapp/src/main/java/sonia/scm/api/v2/resources/BrowserResultDto.java deleted file mode 100644 index 2b49f18fa1..0000000000 --- a/scm-webapp/src/main/java/sonia/scm/api/v2/resources/BrowserResultDto.java +++ /dev/null @@ -1,26 +0,0 @@ -package sonia.scm.api.v2.resources; - -import de.otto.edison.hal.HalRepresentation; -import de.otto.edison.hal.Links; -import lombok.Getter; -import lombok.NoArgsConstructor; -import lombok.Setter; - -import java.util.List; - -@Getter -@Setter -@NoArgsConstructor -public class BrowserResultDto extends HalRepresentation { - private String revision; - - @Override - @SuppressWarnings("squid:S1185") // We want to have this method available in this package - protected HalRepresentation add(Links links) { - return super.add(links); - } - - public void setFiles(List files) { - this.withEmbedded("files", files); - } -} diff --git a/scm-webapp/src/main/java/sonia/scm/api/v2/resources/BrowserResultToBrowserResultDtoMapper.java b/scm-webapp/src/main/java/sonia/scm/api/v2/resources/BrowserResultToBrowserResultDtoMapper.java deleted file mode 100644 index c877cb0647..0000000000 --- a/scm-webapp/src/main/java/sonia/scm/api/v2/resources/BrowserResultToBrowserResultDtoMapper.java +++ /dev/null @@ -1,49 +0,0 @@ -package sonia.scm.api.v2.resources; - -import de.otto.edison.hal.Links; -import sonia.scm.repository.BrowserResult; -import sonia.scm.repository.FileObject; -import sonia.scm.repository.NamespaceAndName; - -import javax.inject.Inject; -import java.util.ArrayList; -import java.util.List; - -public class BrowserResultToBrowserResultDtoMapper { - - @Inject - private FileObjectToFileObjectDtoMapper fileObjectToFileObjectDtoMapper; - - @Inject - private ResourceLinks resourceLinks; - - public BrowserResultDto map(BrowserResult browserResult, NamespaceAndName namespaceAndName, String path) { - BrowserResultDto browserResultDto = new BrowserResultDto(); - - browserResultDto.setRevision(browserResult.getRevision()); - - List fileObjectDtoList = new ArrayList<>(); - for (FileObject fileObject : browserResult.getFiles()) { - fileObjectDtoList.add(mapFileObject(fileObject, namespaceAndName, browserResult.getRevision())); - } - - browserResultDto.setFiles(fileObjectDtoList); - this.addLinks(browserResult, browserResultDto, namespaceAndName, path); - return browserResultDto; - } - - private FileObjectDto mapFileObject(FileObject fileObject, NamespaceAndName namespaceAndName, String revision) { - return fileObjectToFileObjectDtoMapper.map(fileObject, namespaceAndName, revision); - } - - private void addLinks(BrowserResult browserResult, BrowserResultDto dto, NamespaceAndName namespaceAndName, String path) { - if (path.equals("/")) { - path = ""; - } - if (browserResult.getRevision() == null) { - throw new IllegalStateException("missing revision in browser result for repository " + namespaceAndName + " and path " + path); - } else { - dto.add(Links.linkingTo().self(resourceLinks.source().sourceWithPath(namespaceAndName.getNamespace(), namespaceAndName.getName(), browserResult.getRevision(), path)).build()); - } - } -} diff --git a/scm-webapp/src/main/java/sonia/scm/api/v2/resources/BrowserResultToFileObjectDtoMapper.java b/scm-webapp/src/main/java/sonia/scm/api/v2/resources/BrowserResultToFileObjectDtoMapper.java new file mode 100644 index 0000000000..9720fb5b1a --- /dev/null +++ b/scm-webapp/src/main/java/sonia/scm/api/v2/resources/BrowserResultToFileObjectDtoMapper.java @@ -0,0 +1,22 @@ +package sonia.scm.api.v2.resources; + +import sonia.scm.repository.BrowserResult; +import sonia.scm.repository.NamespaceAndName; + +import javax.inject.Inject; + +public class BrowserResultToFileObjectDtoMapper { + + private final FileObjectToFileObjectDtoMapper fileObjectToFileObjectDtoMapper; + + @Inject + public BrowserResultToFileObjectDtoMapper(FileObjectToFileObjectDtoMapper fileObjectToFileObjectDtoMapper) { + this.fileObjectToFileObjectDtoMapper = fileObjectToFileObjectDtoMapper; + } + + public FileObjectDto map(BrowserResult browserResult, NamespaceAndName namespaceAndName) { + FileObjectDto fileObjectDto = fileObjectToFileObjectDtoMapper.map(browserResult.getFile(), namespaceAndName, browserResult.getRevision()); + fileObjectDto.setRevision( browserResult.getRevision() ); + return fileObjectDto; + } +} diff --git a/scm-webapp/src/main/java/sonia/scm/api/v2/resources/FileObjectDto.java b/scm-webapp/src/main/java/sonia/scm/api/v2/resources/FileObjectDto.java index ab4986554a..c183d731c6 100644 --- a/scm-webapp/src/main/java/sonia/scm/api/v2/resources/FileObjectDto.java +++ b/scm-webapp/src/main/java/sonia/scm/api/v2/resources/FileObjectDto.java @@ -1,5 +1,6 @@ package sonia.scm.api.v2.resources; +import com.fasterxml.jackson.annotation.JsonInclude; import de.otto.edison.hal.HalRepresentation; import de.otto.edison.hal.Links; import lombok.Getter; @@ -7,6 +8,7 @@ import lombok.NoArgsConstructor; import lombok.Setter; import java.time.Instant; +import java.util.List; @Getter @Setter @@ -15,14 +17,26 @@ public class FileObjectDto extends HalRepresentation { private String name; private String path; private boolean directory; + @JsonInclude(JsonInclude.Include.NON_EMPTY) private String description; - private int length; + private long length; + @JsonInclude(JsonInclude.Include.NON_EMPTY) private Instant lastModified; + @JsonInclude(JsonInclude.Include.NON_EMPTY) private SubRepositoryDto subRepository; + @JsonInclude(JsonInclude.Include.NON_EMPTY) + private String revision; @Override @SuppressWarnings("squid:S1185") // We want to have this method available in this package protected HalRepresentation add(Links links) { return super.add(links); } + + public void setChildren(List children) { + if (!children.isEmpty()) { + // prevent empty embedded attribute in json + this.withEmbedded("children", children); + } + } } diff --git a/scm-webapp/src/main/java/sonia/scm/api/v2/resources/FileObjectToFileObjectDtoMapper.java b/scm-webapp/src/main/java/sonia/scm/api/v2/resources/FileObjectToFileObjectDtoMapper.java index 365c0ad4cb..7ba8d21c75 100644 --- a/scm-webapp/src/main/java/sonia/scm/api/v2/resources/FileObjectToFileObjectDtoMapper.java +++ b/scm-webapp/src/main/java/sonia/scm/api/v2/resources/FileObjectToFileObjectDtoMapper.java @@ -12,6 +12,9 @@ import sonia.scm.repository.SubRepository; import javax.inject.Inject; +import java.util.List; +import java.util.stream.Collectors; + import static de.otto.edison.hal.Link.link; @Mapper diff --git a/scm-webapp/src/main/java/sonia/scm/api/v2/resources/ResourceLinks.java b/scm-webapp/src/main/java/sonia/scm/api/v2/resources/ResourceLinks.java index 2b37eda9b7..1b397480a4 100644 --- a/scm-webapp/src/main/java/sonia/scm/api/v2/resources/ResourceLinks.java +++ b/scm-webapp/src/main/java/sonia/scm/api/v2/resources/ResourceLinks.java @@ -4,6 +4,7 @@ import sonia.scm.repository.NamespaceAndName; import javax.inject.Inject; import java.net.URI; +import java.net.URISyntaxException; class ResourceLinks { @@ -16,7 +17,11 @@ class ResourceLinks { // we have to add the file path using URI, so that path separators (aka '/') will not be encoded as '%2F' private static String addPath(String sourceWithPath, String path) { - return URI.create(sourceWithPath).resolve(path).toASCIIString(); + try { + return new URI(sourceWithPath).resolve(new URI(null, null, path, null)).toASCIIString(); + } catch (URISyntaxException e) { + throw new RuntimeException(e); + } } GroupLinks group() { diff --git a/scm-webapp/src/main/java/sonia/scm/api/v2/resources/SourceRootResource.java b/scm-webapp/src/main/java/sonia/scm/api/v2/resources/SourceRootResource.java index e3cf17d3a4..8ca9c07cf6 100644 --- a/scm-webapp/src/main/java/sonia/scm/api/v2/resources/SourceRootResource.java +++ b/scm-webapp/src/main/java/sonia/scm/api/v2/resources/SourceRootResource.java @@ -3,8 +3,6 @@ package sonia.scm.api.v2.resources; import sonia.scm.NotFoundException; import sonia.scm.repository.BrowserResult; import sonia.scm.repository.NamespaceAndName; -import sonia.scm.repository.RepositoryNotFoundException; -import sonia.scm.repository.RevisionNotFoundException; import sonia.scm.repository.api.BrowseCommandBuilder; import sonia.scm.repository.api.RepositoryService; import sonia.scm.repository.api.RepositoryServiceFactory; @@ -21,26 +19,26 @@ import java.io.IOException; public class SourceRootResource { private final RepositoryServiceFactory serviceFactory; - private final BrowserResultToBrowserResultDtoMapper browserResultToBrowserResultDtoMapper; + private final BrowserResultToFileObjectDtoMapper browserResultToFileObjectDtoMapper; @Inject - public SourceRootResource(RepositoryServiceFactory serviceFactory, BrowserResultToBrowserResultDtoMapper browserResultToBrowserResultDtoMapper) { + public SourceRootResource(RepositoryServiceFactory serviceFactory, BrowserResultToFileObjectDtoMapper browserResultToFileObjectDtoMapper) { this.serviceFactory = serviceFactory; - this.browserResultToBrowserResultDtoMapper = browserResultToBrowserResultDtoMapper; + this.browserResultToFileObjectDtoMapper = browserResultToFileObjectDtoMapper; } @GET @Produces(VndMediaType.SOURCE) @Path("") - public Response getAllWithoutRevision(@PathParam("namespace") String namespace, @PathParam("name") String name) throws RevisionNotFoundException, RepositoryNotFoundException, IOException { + public Response getAllWithoutRevision(@PathParam("namespace") String namespace, @PathParam("name") String name) throws NotFoundException, IOException { return getSource(namespace, name, "/", null); } @GET @Produces(VndMediaType.SOURCE) @Path("{revision}") - public Response getAll(@PathParam("namespace") String namespace, @PathParam("name") String name, @PathParam("revision") String revision) throws RevisionNotFoundException, RepositoryNotFoundException, IOException { + public Response getAll(@PathParam("namespace") String namespace, @PathParam("name") String name, @PathParam("revision") String revision) throws NotFoundException, IOException { return getSource(namespace, name, "/", revision); } @@ -51,7 +49,7 @@ public class SourceRootResource { return getSource(namespace, name, path, revision); } - private Response getSource(String namespace, String repoName, String path, String revision) throws IOException, RevisionNotFoundException, RepositoryNotFoundException { + private Response getSource(String namespace, String repoName, String path, String revision) throws IOException, NotFoundException { NamespaceAndName namespaceAndName = new NamespaceAndName(namespace, repoName); try (RepositoryService repositoryService = serviceFactory.create(namespaceAndName)) { BrowseCommandBuilder browseCommand = repositoryService.getBrowseCommand(); @@ -59,10 +57,11 @@ public class SourceRootResource { if (revision != null && !revision.isEmpty()) { browseCommand.setRevision(revision); } + browseCommand.setDisableCache(true); BrowserResult browserResult = browseCommand.getBrowserResult(); if (browserResult != null) { - return Response.ok(browserResultToBrowserResultDtoMapper.map(browserResult, namespaceAndName, path)).build(); + return Response.ok(browserResultToFileObjectDtoMapper.map(browserResult, namespaceAndName)).build(); } else { return Response.status(Response.Status.NOT_FOUND).build(); } diff --git a/scm-webapp/src/main/java/sonia/scm/web/i18n/I18nServlet.java b/scm-webapp/src/main/java/sonia/scm/web/i18n/I18nServlet.java new file mode 100644 index 0000000000..9773b91cf7 --- /dev/null +++ b/scm-webapp/src/main/java/sonia/scm/web/i18n/I18nServlet.java @@ -0,0 +1,188 @@ +package sonia.scm.web.i18n; + + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.node.ObjectNode; +import com.github.legman.Subscribe; +import com.google.common.annotations.VisibleForTesting; +import com.google.inject.Singleton; +import lombok.extern.slf4j.Slf4j; +import sonia.scm.NotFoundException; +import sonia.scm.SCMContext; +import sonia.scm.Stage; +import sonia.scm.boot.RestartEvent; +import sonia.scm.cache.Cache; +import sonia.scm.cache.CacheManager; +import sonia.scm.filter.WebElement; +import sonia.scm.plugin.PluginLoader; + +import javax.inject.Inject; +import javax.servlet.http.HttpServlet; +import javax.servlet.http.HttpServletRequest; +import javax.servlet.http.HttpServletResponse; +import java.io.IOException; +import java.io.PrintWriter; +import java.net.URL; +import java.util.Enumeration; +import java.util.Iterator; +import java.util.Optional; +import java.util.function.BiConsumer; +import java.util.function.Function; + + +/** + * Collect the plugin translations. + */ +@Singleton +@WebElement(value = I18nServlet.PATTERN, regex = true) +@Slf4j +public class I18nServlet extends HttpServlet { + + public static final String PLUGINS_JSON = "plugins.json"; + public static final String PATTERN = "/locales/[a-z\\-A-Z]*/" + PLUGINS_JSON; + public static final String CACHE_NAME = "sonia.cache.plugins.translations"; + + private final ClassLoader classLoader; + private final Cache cache; + private static ObjectMapper objectMapper = new ObjectMapper(); + + + @Inject + public I18nServlet(PluginLoader pluginLoader, CacheManager cacheManager) { + this.classLoader = pluginLoader.getUberClassLoader(); + this.cache = cacheManager.getCache(CACHE_NAME); + } + + @Subscribe(async = false) + public void handleRestartEvent(RestartEvent event) { + log.debug("Clear cache on restart event with reason {}", event.getReason()); + cache.clear(); + } + + private JsonNode getCollectedJson(String path, + Function> jsonFileProvider, + BiConsumer createdJsonFileConsumer) { + return Optional.ofNullable(jsonFileProvider.apply(path) + .orElseGet(() -> { + Optional createdFile = collectJsonFile(path); + createdFile.ifPresent(map -> createdJsonFileConsumer.accept(path, map)); + return createdFile.orElse(null); + } + )).orElseThrow(NotFoundException::new); + } + + @VisibleForTesting + @Override + protected void doGet(HttpServletRequest req, HttpServletResponse response) { + try (PrintWriter out = response.getWriter()) { + response.setContentType("application/json"); + String path = req.getServletPath(); + Function> jsonFileProvider = usedPath -> Optional.empty(); + BiConsumer createdJsonFileConsumer = (usedPath, jsonNode) -> log.debug("A json File is created from the path {}", usedPath); + if (isProductionStage()) { + log.debug("In Production Stage get the plugin translations from the cache"); + jsonFileProvider = usedPath -> Optional.ofNullable( + cache.get(usedPath)); + createdJsonFileConsumer = createdJsonFileConsumer + .andThen((usedPath, jsonNode) -> log.debug("Put the created json File in the cache with the key {}", usedPath)) + .andThen(cache::put); + } + objectMapper.writeValue(out, getCollectedJson(path, jsonFileProvider, createdJsonFileConsumer)); + } catch (IOException e) { + log.error("Error on getting the translation of the plugins", e); + response.setStatus(HttpServletResponse.SC_INTERNAL_SERVER_ERROR); + } catch (NotFoundException e) { + log.error("Plugin translations are not found", e); + response.setStatus(HttpServletResponse.SC_NOT_FOUND); + } + } + + @VisibleForTesting + protected boolean isProductionStage() { + return SCMContext.getContext().getStage() == Stage.PRODUCTION; + } + + /** + * Return a collected Json File as JsonNode from the given path from all plugins in the class path + * + * @param path the searched resource path + * @return a collected Json File as JsonNode from the given path from all plugins in the class path + */ + @VisibleForTesting + protected Optional collectJsonFile(String path) { + log.debug("Collect plugin translations from path {} for every plugin", path); + JsonNode mergedJsonNode = null; + try { + Enumeration resources = classLoader.getResources(path.replaceFirst("/", "")); + while (resources.hasMoreElements()) { + URL url = resources.nextElement(); + JsonNode jsonNode = objectMapper.readTree(url); + if (mergedJsonNode != null) { + merge(mergedJsonNode, jsonNode); + } else { + mergedJsonNode = jsonNode; + } + } + } catch (IOException e) { + log.error("Error on loading sources from {}", path, e); + return Optional.empty(); + } + return Optional.ofNullable(mergedJsonNode); + } + + + /** + * Merge the updateNode into the mainNode and return it. + * + * This is not a deep merge. + * + * @param mainNode the main node + * @param updateNode the update node + * @return the merged mainNode + */ + @VisibleForTesting + protected JsonNode merge(JsonNode mainNode, JsonNode updateNode) { + Iterator fieldNames = updateNode.fieldNames(); + + while (fieldNames.hasNext()) { + + String fieldName = fieldNames.next(); + JsonNode jsonNode = mainNode.get(fieldName); + + if (jsonNode != null) { + mergeNode(updateNode, fieldName, jsonNode); + } else { + mergeField(mainNode, updateNode, fieldName); + } + } + return mainNode; + } + + private void mergeField(JsonNode mainNode, JsonNode updateNode, String fieldName) { + if (mainNode instanceof ObjectNode) { + JsonNode value = updateNode.get(fieldName); + if (value.isNull()) { + return; + } + if (value.isIntegralNumber() && value.toString().equals("0")) { + return; + } + if (value.isFloatingPointNumber() && value.toString().equals("0.0")) { + return; + } + ((ObjectNode) mainNode).set(fieldName, value); + } + } + + private void mergeNode(JsonNode updateNode, String fieldName, JsonNode jsonNode) { + if (jsonNode.isObject()) { + merge(jsonNode, updateNode.get(fieldName)); + } else if (jsonNode.isArray()) { + for (int i = 0; i < jsonNode.size(); i++) { + merge(jsonNode.get(i), updateNode.get(fieldName).get(i)); + } + } + } + +} diff --git a/scm-webapp/src/test/java/sonia/scm/api/v2/resources/BrowserResultToBrowserResultDtoMapperTest.java b/scm-webapp/src/test/java/sonia/scm/api/v2/resources/BrowserResultToFileObjectDtoMapperTest.java similarity index 64% rename from scm-webapp/src/test/java/sonia/scm/api/v2/resources/BrowserResultToBrowserResultDtoMapperTest.java rename to scm-webapp/src/test/java/sonia/scm/api/v2/resources/BrowserResultToFileObjectDtoMapperTest.java index 528418a187..b0f0d00708 100644 --- a/scm-webapp/src/test/java/sonia/scm/api/v2/resources/BrowserResultToBrowserResultDtoMapperTest.java +++ b/scm-webapp/src/test/java/sonia/scm/api/v2/resources/BrowserResultToFileObjectDtoMapperTest.java @@ -8,34 +8,26 @@ import org.junit.After; import org.junit.Before; import org.junit.Test; import org.mockito.InjectMocks; -import org.mockito.Mock; import sonia.scm.repository.BrowserResult; import sonia.scm.repository.FileObject; import sonia.scm.repository.NamespaceAndName; import java.net.URI; -import java.util.ArrayList; -import java.util.List; import static org.assertj.core.api.Assertions.assertThat; -import static org.mockito.ArgumentMatchers.any; -import static org.mockito.ArgumentMatchers.anyString; import static org.mockito.Mockito.mock; -import static org.mockito.Mockito.verify; -import static org.mockito.Mockito.when; import static org.mockito.MockitoAnnotations.initMocks; -public class BrowserResultToBrowserResultDtoMapperTest { +public class BrowserResultToFileObjectDtoMapperTest { private final URI baseUri = URI.create("http://example.com/base/"); @SuppressWarnings("unused") // Is injected private final ResourceLinks resourceLinks = ResourceLinksMock.createMock(baseUri); - @Mock - private FileObjectToFileObjectDtoMapper fileObjectToFileObjectDtoMapper; - @InjectMocks - private BrowserResultToBrowserResultDtoMapper mapper; + private FileObjectToFileObjectDtoMapperImpl fileObjectToFileObjectDtoMapper; + + private BrowserResultToFileObjectDtoMapper mapper; private final Subject subject = mock(Subject.class); private final ThreadState subjectThreadState = new SubjectThreadState(subject); @@ -47,6 +39,7 @@ public class BrowserResultToBrowserResultDtoMapperTest { @Before public void init() { initMocks(this); + mapper = new BrowserResultToFileObjectDtoMapper(fileObjectToFileObjectDtoMapper); subjectThreadState.bind(); ThreadContext.bind(subject); @@ -63,9 +56,6 @@ public class BrowserResultToBrowserResultDtoMapperTest { fileObject2.setPath("/path/object/2"); fileObject2.setDescription("description of file object 2"); fileObject2.setDirectory(true); - - when(fileObjectToFileObjectDtoMapper.map(any(FileObject.class), any(NamespaceAndName.class), anyString())) - .thenReturn(new FileObjectDto()); } @After @@ -77,7 +67,7 @@ public class BrowserResultToBrowserResultDtoMapperTest { public void shouldMapAttributesCorrectly() { BrowserResult browserResult = createBrowserResult(); - BrowserResultDto dto = mapper.map(browserResult, new NamespaceAndName("foo", "bar"), "path"); + FileObjectDto dto = mapper.map(browserResult, new NamespaceAndName("foo", "bar")); assertEqualAttributes(browserResult, dto); } @@ -87,10 +77,9 @@ public class BrowserResultToBrowserResultDtoMapperTest { BrowserResult browserResult = createBrowserResult(); NamespaceAndName namespaceAndName = new NamespaceAndName("foo", "bar"); - BrowserResultDto dto = mapper.map(browserResult, namespaceAndName, "path"); + FileObjectDto dto = mapper.map(browserResult, namespaceAndName); - verify(fileObjectToFileObjectDtoMapper).map(fileObject1, namespaceAndName, "Revision"); - verify(fileObjectToFileObjectDtoMapper).map(fileObject2, namespaceAndName, "Revision"); + assertThat(dto.getEmbedded().getItemsBy("children")).hasSize(2); } @Test @@ -98,28 +87,27 @@ public class BrowserResultToBrowserResultDtoMapperTest { BrowserResult browserResult = createBrowserResult(); NamespaceAndName namespaceAndName = new NamespaceAndName("foo", "bar"); - BrowserResultDto dto = mapper.map(browserResult, namespaceAndName, "path"); + FileObjectDto dto = mapper.map(browserResult, namespaceAndName); assertThat(dto.getLinks().getLinkBy("self").get().getHref()).contains("path"); } private BrowserResult createBrowserResult() { - BrowserResult browserResult = new BrowserResult(); - browserResult.setRevision("Revision"); - browserResult.setFiles(createFileObjects()); - - return browserResult; + return new BrowserResult("Revision", createFileObject()); } - private List createFileObjects() { - List fileObjects = new ArrayList<>(); + private FileObject createFileObject() { + FileObject file = new FileObject(); + file.setName(""); + file.setPath("/path"); + file.setDirectory(true); - fileObjects.add(fileObject1); - fileObjects.add(fileObject2); - return fileObjects; + file.addChild(fileObject1); + file.addChild(fileObject2); + return file; } - private void assertEqualAttributes(BrowserResult browserResult, BrowserResultDto dto) { + private void assertEqualAttributes(BrowserResult browserResult, FileObjectDto dto) { assertThat(dto.getRevision()).isEqualTo(browserResult.getRevision()); } diff --git a/scm-webapp/src/test/java/sonia/scm/api/v2/resources/ResourceLinksTest.java b/scm-webapp/src/test/java/sonia/scm/api/v2/resources/ResourceLinksTest.java index 0544bf6a0d..74ff49dbb7 100644 --- a/scm-webapp/src/test/java/sonia/scm/api/v2/resources/ResourceLinksTest.java +++ b/scm-webapp/src/test/java/sonia/scm/api/v2/resources/ResourceLinksTest.java @@ -173,6 +173,30 @@ public class ResourceLinksTest { assertEquals(BASE_URL + ConfigResource.CONFIG_PATH_V2, url); } + @Test + public void shouldHandleSpacesInPaths() { + String url = resourceLinks.source().content("space", "name", "rev", "name with spaces"); + assertEquals(BASE_URL + RepositoryRootResource.REPOSITORIES_PATH_V2 + "space/name/content/rev/name%20with%20spaces", url); + } + + @Test + public void shouldHandleBackslashInPaths() { + String url = resourceLinks.source().content("space", "name", "rev", "name_with_\\"); + assertEquals(BASE_URL + RepositoryRootResource.REPOSITORIES_PATH_V2 + "space/name/content/rev/name_with_%5C", url); + } + + @Test + public void shouldHandleNewLineInPaths() { + String url = resourceLinks.source().content("space", "name", "rev", "name_with\nnew_line"); + assertEquals(BASE_URL + RepositoryRootResource.REPOSITORIES_PATH_V2 + "space/name/content/rev/name_with%0Anew_line", url); + } + + @Test + public void shouldKeepSlashesInInPaths() { + String url = resourceLinks.source().content("space", "name", "rev", "some/dir/somewhere"); + assertEquals(BASE_URL + RepositoryRootResource.REPOSITORIES_PATH_V2 + "space/name/content/rev/some/dir/somewhere", url); + } + @Before public void initUriInfo() { initMocks(this); diff --git a/scm-webapp/src/test/java/sonia/scm/api/v2/resources/SourceRootResourceTest.java b/scm-webapp/src/test/java/sonia/scm/api/v2/resources/SourceRootResourceTest.java index c84a74bc92..96b8ac45f7 100644 --- a/scm-webapp/src/test/java/sonia/scm/api/v2/resources/SourceRootResourceTest.java +++ b/scm-webapp/src/test/java/sonia/scm/api/v2/resources/SourceRootResourceTest.java @@ -10,11 +10,11 @@ import org.junit.runner.RunWith; import org.mockito.InjectMocks; import org.mockito.Mock; import org.mockito.junit.MockitoJUnitRunner; +import sonia.scm.NotFoundException; import sonia.scm.repository.BrowserResult; import sonia.scm.repository.FileObject; import sonia.scm.repository.NamespaceAndName; import sonia.scm.repository.RepositoryNotFoundException; -import sonia.scm.repository.RevisionNotFoundException; import sonia.scm.repository.api.BrowseCommandBuilder; import sonia.scm.repository.api.RepositoryService; import sonia.scm.repository.api.RepositoryServiceFactory; @@ -22,12 +22,8 @@ import sonia.scm.repository.api.RepositoryServiceFactory; import java.io.IOException; import java.net.URI; import java.net.URISyntaxException; -import java.util.Arrays; -import java.util.List; import static org.assertj.core.api.Assertions.assertThat; -import static org.mockito.ArgumentMatchers.any; -import static org.mockito.ArgumentMatchers.anyString; import static org.mockito.Mockito.when; import static sonia.scm.api.v2.resources.DispatcherMock.createDispatcher; @@ -46,30 +42,25 @@ public class SourceRootResourceTest extends RepositoryTestBase { @Mock private BrowseCommandBuilder browseCommandBuilder; - @Mock - private FileObjectToFileObjectDtoMapper fileObjectToFileObjectDtoMapper; - @InjectMocks - private BrowserResultToBrowserResultDtoMapper browserResultToBrowserResultDtoMapper; + private FileObjectToFileObjectDtoMapperImpl fileObjectToFileObjectDtoMapper; + + private BrowserResultToFileObjectDtoMapper browserResultToFileObjectDtoMapper; @Before public void prepareEnvironment() throws Exception { + browserResultToFileObjectDtoMapper = new BrowserResultToFileObjectDtoMapper(fileObjectToFileObjectDtoMapper); when(serviceFactory.create(new NamespaceAndName("space", "repo"))).thenReturn(service); when(service.getBrowseCommand()).thenReturn(browseCommandBuilder); - FileObjectDto dto = new FileObjectDto(); - dto.setName("name"); - dto.setLength(1024); - - when(fileObjectToFileObjectDtoMapper.map(any(FileObject.class), any(NamespaceAndName.class), anyString())).thenReturn(dto); - SourceRootResource sourceRootResource = new SourceRootResource(serviceFactory, browserResultToBrowserResultDtoMapper); + SourceRootResource sourceRootResource = new SourceRootResource(serviceFactory, browserResultToFileObjectDtoMapper); super.sourceRootResource = Providers.of(sourceRootResource); dispatcher = createDispatcher(getRepositoryRootResource()); } @Test - public void shouldReturnSources() throws URISyntaxException, IOException, RevisionNotFoundException { + public void shouldReturnSources() throws URISyntaxException, IOException, NotFoundException { BrowserResult result = createBrowserResult(); when(browseCommandBuilder.getBrowserResult()).thenReturn(result); MockHttpRequest request = MockHttpRequest.get("/" + RepositoryRootResource.REPOSITORIES_PATH_V2 + "space/repo/sources"); @@ -77,8 +68,9 @@ public class SourceRootResourceTest extends RepositoryTestBase { dispatcher.invoke(request, response); assertThat(response.getStatus()).isEqualTo(200); + System.out.println(response.getContentAsString()); assertThat(response.getContentAsString()).contains("\"revision\":\"revision\""); - assertThat(response.getContentAsString()).contains("\"files\":"); + assertThat(response.getContentAsString()).contains("\"children\":"); } @Test @@ -92,13 +84,11 @@ public class SourceRootResourceTest extends RepositoryTestBase { } @Test - public void shouldGetResultForSingleFile() throws URISyntaxException, IOException, RevisionNotFoundException { - BrowserResult browserResult = new BrowserResult(); - browserResult.setRevision("revision"); + public void shouldGetResultForSingleFile() throws URISyntaxException, IOException, NotFoundException { FileObject fileObject = new FileObject(); fileObject.setName("File Object!"); - - browserResult.setFiles(Arrays.asList(fileObject)); + fileObject.setPath("/"); + BrowserResult browserResult = new BrowserResult("revision", fileObject); when(browseCommandBuilder.getBrowserResult()).thenReturn(browserResult); MockHttpRequest request = MockHttpRequest.get("/" + RepositoryRootResource.REPOSITORIES_PATH_V2 + "space/repo/sources/revision/fileabc"); @@ -121,10 +111,15 @@ public class SourceRootResourceTest extends RepositoryTestBase { } private BrowserResult createBrowserResult() { - return new BrowserResult("revision", "tag", "branch", createFileObjects()); + return new BrowserResult("revision", createFileObject()); } - private List createFileObjects() { + private FileObject createFileObject() { + FileObject parent = new FileObject(); + parent.setName("bar"); + parent.setPath("/foo/bar"); + parent.setDirectory(true); + FileObject fileObject1 = new FileObject(); fileObject1.setName("FO 1"); fileObject1.setDirectory(false); @@ -132,6 +127,7 @@ public class SourceRootResourceTest extends RepositoryTestBase { fileObject1.setPath("/foo/bar/fo1"); fileObject1.setLength(1024L); fileObject1.setLastModified(0L); + parent.addChild(fileObject1); FileObject fileObject2 = new FileObject(); fileObject2.setName("FO 2"); @@ -140,7 +136,8 @@ public class SourceRootResourceTest extends RepositoryTestBase { fileObject2.setPath("/foo/bar/fo2"); fileObject2.setLength(4096L); fileObject2.setLastModified(1234L); + parent.addChild(fileObject2); - return Arrays.asList(fileObject1, fileObject2); + return parent; } } diff --git a/scm-webapp/src/test/java/sonia/scm/web/i18n/I18nServletTest.java b/scm-webapp/src/test/java/sonia/scm/web/i18n/I18nServletTest.java new file mode 100644 index 0000000000..a912f738e2 --- /dev/null +++ b/scm-webapp/src/test/java/sonia/scm/web/i18n/I18nServletTest.java @@ -0,0 +1,255 @@ +package sonia.scm.web.i18n; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.github.sdorra.shiro.ShiroRule; +import com.github.sdorra.shiro.SubjectAware; +import com.google.common.base.Charsets; +import com.google.common.io.Files; +import org.apache.commons.lang3.StringUtils; +import org.assertj.core.util.Lists; +import org.junit.Before; +import org.junit.Rule; +import org.junit.Test; +import org.junit.rules.TemporaryFolder; +import org.junit.runner.RunWith; +import org.mockito.Mock; +import org.mockito.MockSettings; +import org.mockito.internal.creation.MockSettingsImpl; +import org.mockito.invocation.InvocationOnMock; +import org.mockito.junit.MockitoJUnitRunner; +import sonia.scm.boot.RestartEvent; +import sonia.scm.cache.Cache; +import sonia.scm.cache.CacheManager; +import sonia.scm.event.ScmEventBus; +import sonia.scm.plugin.PluginLoader; + +import javax.servlet.http.HttpServletRequest; +import javax.servlet.http.HttpServletResponse; +import java.io.File; +import java.io.FileOutputStream; +import java.io.IOException; +import java.io.PrintWriter; +import java.net.URL; +import java.nio.charset.Charset; +import java.util.Collections; +import java.util.Enumeration; +import java.util.Optional; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.*; + +@RunWith(MockitoJUnitRunner.Silent.class) +@SubjectAware(configuration = "classpath:sonia/scm/shiro-001.ini") +public class I18nServletTest { + + @Rule + public ShiroRule shiro = new ShiroRule(); + + private static final String GIT_PLUGIN_JSON = json( + "{", + "'scm-git-plugin': {", + "'information': {", + "'clone' : 'Clone',", + "'create' : 'Create',", + "'replace' : 'Push'", + "}", + "}", + "}" + ); + private static final String HG_PLUGIN_JSON = json( + "{", + "'scm-hg-plugin': {", + "'information': {", + "'clone' : 'Clone',", + "'create' : 'Create',", + "'replace' : 'Push'", + "}", + "}", + "}" + ); + private static String SVN_PLUGIN_JSON = json( + "{", + "'scm-svn-plugin': {", + "'information': {", + "'checkout' : 'Checkout'", + "}", + "}", + "}" + ); + + private static String json(String... parts) { + return String.join("\n", parts ).replaceAll("'", "\""); + } + + @Rule + public TemporaryFolder temporaryFolder = new TemporaryFolder(); + + @Mock + PluginLoader pluginLoader; + + @Mock + CacheManager cacheManager; + + @Mock + ClassLoader classLoader; + + I18nServlet servlet; + + @Mock + private Cache cache; + private Enumeration resources; + + @Before + @SuppressWarnings("unchecked") + public void init() throws IOException { + resources = Collections.enumeration(Lists.newArrayList( + createFileFromString(SVN_PLUGIN_JSON).toURL(), + createFileFromString(GIT_PLUGIN_JSON).toURL(), + createFileFromString(HG_PLUGIN_JSON).toURL() + )); + when(pluginLoader.getUberClassLoader()).thenReturn(classLoader); + when(cacheManager.getCache(I18nServlet.CACHE_NAME)).thenReturn(cache); + MockSettings settings = new MockSettingsImpl<>(); + settings.useConstructor(pluginLoader, cacheManager); + settings.defaultAnswer(InvocationOnMock::callRealMethod); + servlet = mock(I18nServlet.class, settings); + } + + @Test + public void shouldCleanCacheOnRestartEvent() { + ScmEventBus.getInstance().register(servlet); + + ScmEventBus.getInstance().post(new RestartEvent(I18nServlet.class, "Restart to reload the plugin resources")); + + verify(cache).clear(); + } + + @Test + @SuppressWarnings("unchecked") + public void shouldFailWith404OnMissingResources() throws IOException { + String path = "/locales/de/plugins.json"; + HttpServletRequest request = mock(HttpServletRequest.class); + HttpServletResponse response = mock(HttpServletResponse.class); + PrintWriter writer = mock(PrintWriter.class); + when(response.getWriter()).thenReturn(writer); + when(request.getServletPath()).thenReturn(path); + when(classLoader.getResources("locales/de/plugins.json")).thenThrow(IOException.class); + + servlet.doGet(request, response); + + verify(response).setStatus(404); + } + + @Test + @SuppressWarnings("unchecked") + public void shouldFailWith500OnIOException() throws IOException { + HttpServletRequest request = mock(HttpServletRequest.class); + HttpServletResponse response = mock(HttpServletResponse.class); + doThrow(IOException.class).when(response).getWriter(); + + servlet.doGet(request, response); + + verify(response).setStatus(500); + } + + @Test + @SuppressWarnings("unchecked") + public void inDevelopmentStageShouldNotUseCache() throws IOException { + String path = "/locales/de/plugins.json"; + when(servlet.isProductionStage()).thenReturn(false); + HttpServletRequest request = mock(HttpServletRequest.class); + HttpServletResponse response = mock(HttpServletResponse.class); + File file = temporaryFolder.newFile(); + PrintWriter writer = new PrintWriter(new FileOutputStream(file)); + when(response.getWriter()).thenReturn(writer); + when(request.getServletPath()).thenReturn(path); + when(classLoader.getResources("locales/de/plugins.json")).thenReturn(resources); + + servlet.doGet(request, response); + + String json = Files.readLines(file, Charset.defaultCharset()).get(0); + assertJson(json); + verify(cache, never()).get(any()); + } + + @Test + @SuppressWarnings("unchecked") + public void inProductionStageShouldUseCache() throws IOException { + String path = "/locales/de/plugins.json"; + when(servlet.isProductionStage()).thenReturn(true); + HttpServletRequest request = mock(HttpServletRequest.class); + HttpServletResponse response = mock(HttpServletResponse.class); + File file = temporaryFolder.newFile(); + PrintWriter writer = new PrintWriter(new FileOutputStream(file)); + when(response.getWriter()).thenReturn(writer); + when(request.getServletPath()).thenReturn(path); + when(classLoader.getResources("locales/de/plugins.json")).thenReturn(resources); + + servlet.doGet(request, response); + + String json = Files.readLines(file, Charset.defaultCharset()).get(0); + assertJson(json); + verify(cache).get(path); + verify(cache).put(eq(path), any()); + } + + @Test + @SuppressWarnings("unchecked") + public void inProductionStageShouldGetFromCache() throws IOException { + String path = "/locales/de/plugins.json"; + when(servlet.isProductionStage()).thenReturn(true); + HttpServletRequest request = mock(HttpServletRequest.class); + HttpServletResponse response = mock(HttpServletResponse.class); + File file = temporaryFolder.newFile(); + PrintWriter writer = new PrintWriter(new FileOutputStream(file)); + when(response.getWriter()).thenReturn(writer); + when(request.getServletPath()).thenReturn(path); + when(classLoader.getResources("locales/de/plugins.json")).thenReturn(resources); + ObjectMapper objectMapper = new ObjectMapper(); + JsonNode node = objectMapper.readTree(GIT_PLUGIN_JSON); + node = servlet.merge(node, objectMapper.readTree(HG_PLUGIN_JSON)); + node = servlet.merge(node, objectMapper.readTree(SVN_PLUGIN_JSON)); + when(cache.get(path)).thenReturn(node); + + servlet.doGet(request, response); + + String json = Files.readLines(file, Charset.defaultCharset()).get(0); + verify(servlet, never()).collectJsonFile(path); + verify(cache, never()).put(eq(path), any()); + verify(cache).get(path); + assertJson(json); + } + + @Test + @SuppressWarnings("unchecked") + public void shouldCollectJsonFile() throws IOException { + String path = "locales/de/plugins.json"; + when(classLoader.getResources(path)).thenReturn(resources); + + Optional jsonNodeOptional = servlet.collectJsonFile("/" + path); + + assertJson(jsonNodeOptional.orElse(null)); + } + + public void assertJson(JsonNode actual) throws IOException { + assertJson(actual.toString()); + } + + public void assertJson(String actual) throws IOException { + assertThat(actual) + .isNotEmpty() + .contains(StringUtils.deleteWhitespace(GIT_PLUGIN_JSON.substring(1, GIT_PLUGIN_JSON.length() - 1))) + .contains(StringUtils.deleteWhitespace(HG_PLUGIN_JSON.substring(1, HG_PLUGIN_JSON.length() - 1))) + .contains(StringUtils.deleteWhitespace(SVN_PLUGIN_JSON.substring(1, SVN_PLUGIN_JSON.length() - 1))); + } + + public File createFileFromString(String json) throws IOException { + File file = temporaryFolder.newFile(); + Files.write(json.getBytes(Charsets.UTF_8), file); + return file; + } + +}