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/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..d42a64b98e 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; @@ -75,10 +77,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); } /** @@ -249,11 +253,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/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/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/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-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 98a6ead283..948b98ffe5 100644 --- a/scm-ui-components/packages/ui-types/src/index.js +++ b/scm-ui-components/packages/ui-types/src/index.js @@ -18,3 +18,5 @@ export type { Tag } from "./Tags"; export type { Config } from "./Config"; 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 c9dcc84815..44ba35ddae 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", diff --git a/scm-ui/src/createReduxStore.js b/scm-ui/src/createReduxStore.js index 710ade20e5..8d3e016c80 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"; @@ -34,7 +35,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/changesets.js b/scm-ui/src/repos/components/changesets/changesets.js index 9d4821e5ec..f61a89c74b 100644 --- a/scm-ui/src/repos/components/changesets/changesets.js +++ b/scm-ui/src/repos/components/changesets/changesets.js @@ -4,17 +4,18 @@ export type Description = { message: string }; -export function parseDescription(description: string): Description { - const lineBreak = description.indexOf("\n"); +export function parseDescription(description?: string): Description { + const desc = description ? description : ""; + const lineBreak = desc.indexOf("\n"); let title; let message = ""; if (lineBreak > 0) { - title = description.substring(0, lineBreak); - message = description.substring(lineBreak + 1); + title = desc.substring(0, lineBreak); + message = desc.substring(lineBreak + 1); } else { - title = description; + title = desc; } return { diff --git a/scm-ui/src/repos/components/changesets/changesets.test.js b/scm-ui/src/repos/components/changesets/changesets.test.js index cd3788e9fb..ea92bcead3 100644 --- a/scm-ui/src/repos/components/changesets/changesets.test.js +++ b/scm-ui/src/repos/components/changesets/changesets.test.js @@ -13,4 +13,10 @@ describe("parseDescription tests", () => { 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/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 1732170c79..9475612765 100644 --- a/scm-ui/src/repos/containers/RepositoryRoot.js +++ b/scm-ui/src/repos/containers/RepositoryRoot.js @@ -29,9 +29,11 @@ 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"; type Props = { namespace: string, @@ -133,6 +135,19 @@ class RepositoryRoot extends React.Component { path={`${url}/changeset/:id`} render={() => } /> + ( + + )} + /> + ( + + )} + /> ( @@ -159,11 +174,20 @@ class RepositoryRoot 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/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/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; } }