diff --git a/Jenkinsfile b/Jenkinsfile index 57cc3b901f..c0ca5f33d0 100644 --- a/Jenkinsfile +++ b/Jenkinsfile @@ -125,7 +125,7 @@ boolean isMainBranch() { boolean waitForQualityGateWebhookToBeCalled() { boolean isQualityGateSucceeded = true - timeout(time: 2, unit: 'MINUTES') { // Needed when there is no webhook for example + timeout(time: 5, unit: 'MINUTES') { // Needed when there is no webhook for example def qGate = waitForQualityGate() echo "SonarQube Quality Gate status: ${qGate.status}" if (qGate.status != 'OK') { diff --git a/scm-core/src/main/java/sonia/scm/GenericDAO.java b/scm-core/src/main/java/sonia/scm/GenericDAO.java index b63a96f733..003c73806c 100644 --- a/scm-core/src/main/java/sonia/scm/GenericDAO.java +++ b/scm-core/src/main/java/sonia/scm/GenericDAO.java @@ -114,4 +114,5 @@ public interface GenericDAO * @return all items */ public Collection getAll(); + } diff --git a/scm-core/src/main/java/sonia/scm/Manager.java b/scm-core/src/main/java/sonia/scm/Manager.java index 2925b5b6b4..20b3a16a8e 100644 --- a/scm-core/src/main/java/sonia/scm/Manager.java +++ b/scm-core/src/main/java/sonia/scm/Manager.java @@ -47,6 +47,9 @@ public interface Manager extends HandlerBase, LastModifiedAware { + int DEFAULT_LIMIT = 5; + + /** * Reloads a object from store and overwrites all changes. * @@ -96,14 +99,14 @@ public interface Manager * @param limit parameter * * @since 1.4 - * @return objects from the store which are starts at the given + * @return objects from the store which are starts at the given * start parameter */ Collection getAll(int start, int limit); /** * Returns objects from the store which are starts at the given start - * parameter sorted by the given {@link java.util.Comparator}. + * parameter sorted by the given {@link java.util.Comparator}. * The objects returned are limited by the limit parameter. * * @@ -112,7 +115,7 @@ public interface Manager * @param limit parameter * * @since 1.4 - * @return objects from the store which are starts at the given + * @return objects from the store which are starts at the given * start parameter */ Collection getAll(Comparator comparator, int start, int limit); diff --git a/scm-core/src/main/java/sonia/scm/ManagerDecorator.java b/scm-core/src/main/java/sonia/scm/ManagerDecorator.java index 7b3f03ee8c..ef20a374cb 100644 --- a/scm-core/src/main/java/sonia/scm/ManagerDecorator.java +++ b/scm-core/src/main/java/sonia/scm/ManagerDecorator.java @@ -71,7 +71,7 @@ public class ManagerDecorator implements Manager { } @Override - public void delete(T object) throws NotFoundException { + public void delete(T object){ decorated.delete(object); } @@ -82,12 +82,12 @@ public class ManagerDecorator implements Manager { } @Override - public void modify(T object) throws NotFoundException { + public void modify(T object){ decorated.modify(object); } @Override - public void refresh(T object) throws NotFoundException { + public void refresh(T object){ decorated.refresh(object); } diff --git a/scm-core/src/main/java/sonia/scm/NotFoundException.java b/scm-core/src/main/java/sonia/scm/NotFoundException.java index 8a7ae642bd..37546be0b8 100644 --- a/scm-core/src/main/java/sonia/scm/NotFoundException.java +++ b/scm-core/src/main/java/sonia/scm/NotFoundException.java @@ -1,6 +1,6 @@ package sonia.scm; -public class NotFoundException extends Exception { +public class NotFoundException extends RuntimeException { public NotFoundException(String type, String id) { super(type + " with id '" + id + "' not found"); } diff --git a/scm-core/src/main/java/sonia/scm/ReducedModelObject.java b/scm-core/src/main/java/sonia/scm/ReducedModelObject.java new file mode 100644 index 0000000000..b8db8c3ee0 --- /dev/null +++ b/scm-core/src/main/java/sonia/scm/ReducedModelObject.java @@ -0,0 +1,15 @@ +package sonia.scm; + + +/** + * This is a reduced form of a model object. + * It can be used as search result to avoid returning the whole object properties. + * + * @author Mohamed Karray + */ +public interface ReducedModelObject { + + String getId(); + + String getDisplayName(); +} diff --git a/scm-core/src/main/java/sonia/scm/group/Group.java b/scm-core/src/main/java/sonia/scm/group/Group.java index 5e7f596c58..c0b3c2ee8b 100644 --- a/scm-core/src/main/java/sonia/scm/group/Group.java +++ b/scm-core/src/main/java/sonia/scm/group/Group.java @@ -42,6 +42,7 @@ import com.google.common.base.Objects; import com.google.common.collect.Lists; import sonia.scm.BasicPropertiesAware; import sonia.scm.ModelObject; +import sonia.scm.ReducedModelObject; import sonia.scm.util.Util; import sonia.scm.util.ValidationUtil; @@ -55,16 +56,16 @@ import java.util.List; /** * Organizes users into a group for easier permissions management. - * + * * TODO for 2.0: Use a set instead of a list for members * * @author Sebastian Sdorra */ -@StaticPermissions(value = "group", globalPermissions = {"create", "list"}) +@StaticPermissions(value = "group", globalPermissions = {"create", "list", "autocomplete"}) @XmlRootElement(name = "groups") @XmlAccessorType(XmlAccessType.FIELD) public class Group extends BasicPropertiesAware - implements ModelObject, PermissionObject + implements ModelObject, PermissionObject, ReducedModelObject { /** Field description */ @@ -309,6 +310,11 @@ public class Group extends BasicPropertiesAware return name; } + @Override + public String getDisplayName() { + return description; + } + /** * Returns a timestamp of the last modified date of this group. * diff --git a/scm-core/src/main/java/sonia/scm/group/GroupManager.java b/scm-core/src/main/java/sonia/scm/group/GroupManager.java index 288196894d..08057ae3db 100644 --- a/scm-core/src/main/java/sonia/scm/group/GroupManager.java +++ b/scm-core/src/main/java/sonia/scm/group/GroupManager.java @@ -61,4 +61,14 @@ public interface GroupManager * @return all groups assigned to the given member */ public Collection getGroupsForMember(String member); + + + /** + * Returns a {@link java.util.Collection} of filtered objects + * + * @param filter the searched string + * @return filtered object from the store + */ + Collection autocomplete(String filter); + } diff --git a/scm-core/src/main/java/sonia/scm/group/GroupManagerDecorator.java b/scm-core/src/main/java/sonia/scm/group/GroupManagerDecorator.java index e2367d863c..ef6de4164c 100644 --- a/scm-core/src/main/java/sonia/scm/group/GroupManagerDecorator.java +++ b/scm-core/src/main/java/sonia/scm/group/GroupManagerDecorator.java @@ -109,6 +109,11 @@ public class GroupManagerDecorator return decorated.getGroupsForMember(member); } + @Override + public Collection autocomplete(String filter) { + return decorated.autocomplete(filter); + } + //~--- fields --------------------------------------------------------------- /** Field description */ 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/ChangesetPagingResult.java b/scm-core/src/main/java/sonia/scm/repository/ChangesetPagingResult.java index 59a705e36a..ca1018b7aa 100644 --- a/scm-core/src/main/java/sonia/scm/repository/ChangesetPagingResult.java +++ b/scm-core/src/main/java/sonia/scm/repository/ChangesetPagingResult.java @@ -82,6 +82,22 @@ public class ChangesetPagingResult implements Iterable, Serializable { this.total = total; this.changesets = changesets; + this.branchName = null; + } + + /** + * Constructs a new changeset paging result for a specific branch. + * + * + * @param total total number of changesets + * @param changesets current list of fetched changesets + * @param branchName branch name this result was created for + */ + public ChangesetPagingResult(int total, List changesets, String branchName) + { + this.total = total; + this.changesets = changesets; + this.branchName = branchName; } //~--- methods -------------------------------------------------------------- @@ -158,6 +174,7 @@ public class ChangesetPagingResult implements Iterable, Serializable return MoreObjects.toStringHelper(this) .add("changesets", changesets) .add("total", total) + .add("branch", branchName) .toString(); //J+ } @@ -186,37 +203,35 @@ public class ChangesetPagingResult implements Iterable, Serializable return total; } - //~--- set methods ---------------------------------------------------------- - - /** - * Sets the current list of changesets. - * - * - * @param changesets current list of changesets - */ - public void setChangesets(List changesets) + void setChangesets(List changesets) { this.changesets = changesets; } - /** - * Sets the total number of changesets - * - * - * @param total total number of changesets - */ - public void setTotal(int total) + void setTotal(int total) { this.total = total; } + void setBranchName(String branchName) { + this.branchName = branchName; + } + + /** + * Returns the branch name this result was created for. This can either be an explicit branch ("give me all + * changesets for branch xyz") or an implicit one ("give me the changesets for the default"). + */ + public String getBranchName() { + return branchName; + } + //~--- fields --------------------------------------------------------------- - /** current list of changesets */ @XmlElement(name = "changeset") @XmlElementWrapper(name = "changesets") private List changesets; - /** total number of changesets */ private int total; + + private String branchName; } 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/Repository.java b/scm-core/src/main/java/sonia/scm/repository/Repository.java index cad36f2d88..19c5b31349 100644 --- a/scm-core/src/main/java/sonia/scm/repository/Repository.java +++ b/scm-core/src/main/java/sonia/scm/repository/Repository.java @@ -64,7 +64,7 @@ import java.util.List; ) @XmlAccessorType(XmlAccessType.FIELD) @XmlRootElement(name = "repositories") -public class Repository extends BasicPropertiesAware implements ModelObject, PermissionObject { +public class Repository extends BasicPropertiesAware implements ModelObject, PermissionObject{ private static final long serialVersionUID = 3486560714961909711L; 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/main/java/sonia/scm/search/SearchRequest.java b/scm-core/src/main/java/sonia/scm/search/SearchRequest.java index e3998e2c6a..63f34346c2 100644 --- a/scm-core/src/main/java/sonia/scm/search/SearchRequest.java +++ b/scm-core/src/main/java/sonia/scm/search/SearchRequest.java @@ -70,6 +70,12 @@ public class SearchRequest this.ignoreCase = ignoreCase; } + public SearchRequest(String query, boolean ignoreCase, int maxResults) { + this.query = query; + this.ignoreCase = ignoreCase; + this.maxResults = maxResults; + } + //~--- get methods ---------------------------------------------------------- /** diff --git a/scm-core/src/main/java/sonia/scm/user/ChangePasswordNotAllowedException.java b/scm-core/src/main/java/sonia/scm/user/ChangePasswordNotAllowedException.java index 19c609ba30..fda5e69323 100644 --- a/scm-core/src/main/java/sonia/scm/user/ChangePasswordNotAllowedException.java +++ b/scm-core/src/main/java/sonia/scm/user/ChangePasswordNotAllowedException.java @@ -2,10 +2,10 @@ package sonia.scm.user; public class ChangePasswordNotAllowedException extends RuntimeException { - public static final String WRONG_USER_TYPE = "User of type {0} are not allowed to change password"; + public static final String WRONG_USER_TYPE = "User of type %s are not allowed to change password"; - public ChangePasswordNotAllowedException(String message) { - super(message); + public ChangePasswordNotAllowedException(String type) { + super(String.format(WRONG_USER_TYPE, type)); } } diff --git a/scm-core/src/main/java/sonia/scm/user/InvalidPasswordException.java b/scm-core/src/main/java/sonia/scm/user/InvalidPasswordException.java index e06191a8f2..870430a1bb 100644 --- a/scm-core/src/main/java/sonia/scm/user/InvalidPasswordException.java +++ b/scm-core/src/main/java/sonia/scm/user/InvalidPasswordException.java @@ -2,9 +2,7 @@ package sonia.scm.user; public class InvalidPasswordException extends RuntimeException { - public static final String INVALID_MATCHING = "The given Password does not match with the stored one."; - - public InvalidPasswordException(String message) { - super(message); + public InvalidPasswordException() { + super("The given Password does not match with the stored one."); } } diff --git a/scm-core/src/main/java/sonia/scm/user/User.java b/scm-core/src/main/java/sonia/scm/user/User.java index 0d909bec8d..cae383a402 100644 --- a/scm-core/src/main/java/sonia/scm/user/User.java +++ b/scm-core/src/main/java/sonia/scm/user/User.java @@ -41,6 +41,7 @@ import com.google.common.base.MoreObjects; import com.google.common.base.Objects; import sonia.scm.BasicPropertiesAware; import sonia.scm.ModelObject; +import sonia.scm.ReducedModelObject; import sonia.scm.util.Util; import sonia.scm.util.ValidationUtil; @@ -55,10 +56,13 @@ import java.security.Principal; * * @author Sebastian Sdorra */ -@StaticPermissions(value = "user", globalPermissions = {"create", "list"}) +@StaticPermissions( + value = "user", + globalPermissions = {"create", "list", "autocomplete"}, + permissions = {"read", "modify", "delete", "changePassword"}) @XmlRootElement(name = "users") @XmlAccessorType(XmlAccessType.FIELD) -public class User extends BasicPropertiesAware implements Principal, ModelObject, PermissionObject +public class User extends BasicPropertiesAware implements Principal, ModelObject, PermissionObject, ReducedModelObject { /** Field description */ @@ -273,10 +277,6 @@ public class User extends BasicPropertiesAware implements Principal, ModelObject //J+ } - public User changePassword(String password){ - setPassword(password); - return this; - } //~--- get methods ---------------------------------------------------------- /** diff --git a/scm-core/src/main/java/sonia/scm/user/UserManager.java b/scm-core/src/main/java/sonia/scm/user/UserManager.java index 1f5aee1f19..f301c1f2b1 100644 --- a/scm-core/src/main/java/sonia/scm/user/UserManager.java +++ b/scm-core/src/main/java/sonia/scm/user/UserManager.java @@ -38,10 +38,7 @@ package sonia.scm.user; import sonia.scm.Manager; import sonia.scm.search.Searchable; -import java.text.MessageFormat; -import java.util.function.Consumer; - -import static sonia.scm.user.ChangePasswordNotAllowedException.WRONG_USER_TYPE; +import java.util.Collection; /** * The central class for managing {@link User} objects. @@ -74,21 +71,29 @@ public interface UserManager */ public String getDefaultType(); - - /** - * Only account of the default type "xml" can change their password - */ - default Consumer getUserTypeChecker() { - return user -> { - if (!isTypeDefault(user)) { - throw new ChangePasswordNotAllowedException(MessageFormat.format(WRONG_USER_TYPE, user.getType())); - } - }; - } - default boolean isTypeDefault(User user) { return getDefaultType().equals(user.getType()); } + /** + * Returns a {@link java.util.Collection} of filtered objects + * + * @param filter the searched string + * @return filtered object from the store + */ + Collection autocomplete(String filter); + /** + * Changes the password of the logged in user. + * @param oldPassword The current encrypted password of the user. + * @param newPassword The new encrypted password of the user. + */ + void changePasswordForLoggedInUser(String oldPassword, String newPassword); + + /** + * Overwrites the password for the given user id. This needs user write privileges. + * @param userId The id of the user to change the password for. + * @param newPassword The new encrypted password. + */ + void overwritePassword(String userId, String newPassword); } diff --git a/scm-core/src/main/java/sonia/scm/user/UserManagerDecorator.java b/scm-core/src/main/java/sonia/scm/user/UserManagerDecorator.java index 225681f9e6..0384fe1b52 100644 --- a/scm-core/src/main/java/sonia/scm/user/UserManagerDecorator.java +++ b/scm-core/src/main/java/sonia/scm/user/UserManagerDecorator.java @@ -121,7 +121,21 @@ public class UserManagerDecorator extends ManagerDecorator return decorated.getDefaultType(); } - //~--- fields --------------------------------------------------------------- + @Override + public Collection autocomplete(String filter) { + return decorated.autocomplete(filter); + } + + @Override + public void changePasswordForLoggedInUser(String oldPassword, String newPassword) { + decorated.changePasswordForLoggedInUser(oldPassword, newPassword); + } + + @Override + public void overwritePassword(String userId, String newPassword) { + decorated.overwritePassword(userId, newPassword); + } +//~--- fields --------------------------------------------------------------- /** Field description */ private final UserManager decorated; diff --git a/scm-core/src/main/java/sonia/scm/web/VndMediaType.java b/scm-core/src/main/java/sonia/scm/web/VndMediaType.java index f0711cd1e4..7b6d5cb039 100644 --- a/scm-core/src/main/java/sonia/scm/web/VndMediaType.java +++ b/scm-core/src/main/java/sonia/scm/web/VndMediaType.java @@ -18,6 +18,7 @@ public class VndMediaType { public static final String INDEX = PREFIX + "index" + SUFFIX; public static final String USER = PREFIX + "user" + SUFFIX; public static final String GROUP = PREFIX + "group" + SUFFIX; + public static final String AUTOCOMPLETE = PREFIX + "autocomplete" + SUFFIX; public static final String REPOSITORY = PREFIX + "repository" + SUFFIX; public static final String PERMISSION = PREFIX + "permission" + SUFFIX; public static final String CHANGESET = PREFIX + "changeset" + SUFFIX; @@ -38,6 +39,8 @@ public class VndMediaType { public static final String UI_PLUGIN_COLLECTION = PREFIX + "uiPluginCollection" + SUFFIX; @SuppressWarnings("squid:S2068") public static final String PASSWORD_CHANGE = PREFIX + "passwordChange" + SUFFIX; + @SuppressWarnings("squid:S2068") + public static final String PASSWORD_OVERWRITE = PREFIX + "passwordOverwrite" + SUFFIX; public static final String ME = PREFIX + "me" + SUFFIX; public static final String SOURCE = PREFIX + "source" + SUFFIX; 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-core/src/test/java/sonia/scm/security/SyncingRealmHelperTest.java b/scm-core/src/test/java/sonia/scm/security/SyncingRealmHelperTest.java index 27ca923902..93b0752766 100644 --- a/scm-core/src/test/java/sonia/scm/security/SyncingRealmHelperTest.java +++ b/scm-core/src/test/java/sonia/scm/security/SyncingRealmHelperTest.java @@ -154,7 +154,7 @@ public class SyncingRealmHelperTest { * Tests {@link SyncingRealmHelper#store(Group)} with an existing group. */ @Test - public void testStoreGroupModify() throws NotFoundException { + public void testStoreGroupModify(){ Group group = new Group("unit-test", "heartOfGold"); when(groupManager.get("heartOfGold")).thenReturn(group); @@ -191,7 +191,7 @@ public class SyncingRealmHelperTest { * Tests {@link SyncingRealmHelper#store(User)} with an existing user. */ @Test - public void testStoreUserModify() throws NotFoundException { + public void testStoreUserModify(){ when(userManager.contains("tricia")).thenReturn(Boolean.TRUE); User user = new User("tricia"); diff --git a/scm-dao-xml/src/main/java/sonia/scm/xml/AbstractXmlDAO.java b/scm-dao-xml/src/main/java/sonia/scm/xml/AbstractXmlDAO.java index 6b74fce7ca..b8d1e6f42e 100644 --- a/scm-dao-xml/src/main/java/sonia/scm/xml/AbstractXmlDAO.java +++ b/scm-dao-xml/src/main/java/sonia/scm/xml/AbstractXmlDAO.java @@ -35,17 +35,20 @@ package sonia.scm.xml; //~--- non-JDK imports -------------------------------------------------------- import com.google.common.collect.ImmutableList; +import org.apache.commons.lang.StringUtils; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import sonia.scm.GenericDAO; import sonia.scm.ModelObject; import sonia.scm.group.xml.XmlGroupDAO; - -//~--- JDK imports ------------------------------------------------------------ +import sonia.scm.store.ConfigurationStore; +import sonia.scm.util.AssertUtil; import java.util.Collection; -import sonia.scm.store.ConfigurationStore; +import java.util.stream.Collectors; + +//~--- JDK imports ------------------------------------------------------------ /** * diff --git a/scm-it/src/test/java/sonia/scm/it/AutoCompleteITCase.java b/scm-it/src/test/java/sonia/scm/it/AutoCompleteITCase.java new file mode 100644 index 0000000000..f343f322a3 --- /dev/null +++ b/scm-it/src/test/java/sonia/scm/it/AutoCompleteITCase.java @@ -0,0 +1,73 @@ +package sonia.scm.it; + +import org.junit.Before; +import org.junit.Test; +import sonia.scm.it.utils.ScmRequests; +import sonia.scm.it.utils.TestData; + +import java.util.List; +import java.util.Map; +import java.util.function.Consumer; +import java.util.stream.IntStream; + +import static org.assertj.core.api.Assertions.assertThat; + +public class AutoCompleteITCase { + + + public static final String CREATED_USER_PREFIX = "user_"; + public static final String CREATED_GROUP_PREFIX = "group_"; + + @Before + public void init() { + TestData.cleanup(); + } + + @Test + public void adminShouldAutoComplete() { + shouldAutocomplete(TestData.USER_SCM_ADMIN, TestData.USER_SCM_ADMIN); + } + + @Test + public void userShouldAutoComplete() { + String username = "nonAdmin"; + String password = "pass"; + TestData.createUser(username, password, false, "xml", "email@e.de"); + shouldAutocomplete(username, password); + } + + public void shouldAutocomplete(String username, String password) { + createUsers(); + createGroups(); + ScmRequests.start() + .requestIndexResource(username, password) + .assertStatusCode(200) + .requestAutoCompleteGroups("group*") + .assertStatusCode(200) + .assertAutoCompleteResults(assertAutoCompleteResult(CREATED_GROUP_PREFIX)) + .returnToPrevious() + .requestAutoCompleteUsers("user*") + .assertStatusCode(200) + .assertAutoCompleteResults(assertAutoCompleteResult(CREATED_USER_PREFIX)); + } + + @SuppressWarnings("unchecked") + private Consumer> assertAutoCompleteResult(String id) { + return autoCompleteDtos -> { + IntStream.range(0, 5).forEach(i -> { + assertThat(autoCompleteDtos).as("return maximum 5 entries").hasSize(5); + assertThat(autoCompleteDtos.get(i)).containsEntry("id", id + (i + 1)); + assertThat(autoCompleteDtos.get(i)).containsEntry("displayName", id + (i + 1)); + }); + }; + } + + private void createUsers() { + IntStream.range(0, 6).forEach(i -> TestData.createUser(CREATED_USER_PREFIX + (i + 1), "pass", false, "xml", CREATED_USER_PREFIX + (i + 1) + "@scm-manager.org")); + } + + private void createGroups() { + IntStream.range(0, 6).forEach(i -> TestData.createGroup(CREATED_GROUP_PREFIX + (i + 1), CREATED_GROUP_PREFIX + (i + 1))); + } + +} diff --git a/scm-it/src/test/java/sonia/scm/it/I18nServletITCase.java b/scm-it/src/test/java/sonia/scm/it/I18nServletITCase.java new file mode 100644 index 0000000000..6597880665 --- /dev/null +++ b/scm-it/src/test/java/sonia/scm/it/I18nServletITCase.java @@ -0,0 +1,19 @@ +package sonia.scm.it; + +import org.junit.Test; +import sonia.scm.it.utils.ScmRequests; + +import static org.assertj.core.api.Assertions.assertThat; + +public class I18nServletITCase { + + @Test + public void shouldGetCollectedPluginTranslations() { + ScmRequests.start() + .requestPluginTranslations("de") + .assertStatusCode(200) + .assertSingleProperty(value -> assertThat(value).isNotNull(), "scm-git-plugin") + .assertSingleProperty(value -> assertThat(value).isNotNull(), "scm-hg-plugin") + .assertSingleProperty(value -> assertThat(value).isNotNull(), "scm-svn-plugin"); + } +} diff --git a/scm-it/src/test/java/sonia/scm/it/MeITCase.java b/scm-it/src/test/java/sonia/scm/it/MeITCase.java index 64f06765ea..ce6593ef11 100644 --- a/scm-it/src/test/java/sonia/scm/it/MeITCase.java +++ b/scm-it/src/test/java/sonia/scm/it/MeITCase.java @@ -20,12 +20,9 @@ public class MeITCase { String newPassword = TestData.USER_SCM_ADMIN + "1"; // admin change the own password ScmRequests.start() - .given() - .url(TestData.getMeUrl()) - .usernameAndPassword(TestData.USER_SCM_ADMIN, TestData.USER_SCM_ADMIN) - .getMeResource() + .requestIndexResource(TestData.USER_SCM_ADMIN, TestData.USER_SCM_ADMIN) + .requestMe() .assertStatusCode(200) - .usingMeResponse() .assertAdmin(aBoolean -> assertThat(aBoolean).isEqualTo(Boolean.TRUE)) .assertPassword(Assert::assertNull) .assertType(s -> assertThat(s).isEqualTo("xml")) @@ -33,30 +30,48 @@ public class MeITCase { .assertStatusCode(204); // assert password is changed -> login with the new Password than undo changes ScmRequests.start() - .given() - .url(TestData.getUserUrl(TestData.USER_SCM_ADMIN)) - .usernameAndPassword(TestData.USER_SCM_ADMIN, newPassword) - .getMeResource() + .requestIndexResource(TestData.USER_SCM_ADMIN, newPassword) + .requestMe() .assertStatusCode(200) - .usingMeResponse() .assertAdmin(aBoolean -> assertThat(aBoolean).isEqualTo(Boolean.TRUE))// still admin .requestChangePassword(newPassword, TestData.USER_SCM_ADMIN) .assertStatusCode(204); } + @Test + public void nonAdminUserShouldChangeOwnPassword() { + String newPassword = "pass1"; + String username = "user1"; + String password = "pass"; + TestData.createUser(username, password,false,"xml", "em@l.de"); + // user change the own password + ScmRequests.start() + .requestIndexResource(username, password) + .requestMe() + .assertStatusCode(200) + .assertAdmin(aBoolean -> assertThat(aBoolean).isEqualTo(Boolean.FALSE)) + .assertPassword(Assert::assertNull) + .assertType(s -> assertThat(s).isEqualTo("xml")) + .requestChangePassword(password, newPassword) + .assertStatusCode(204); + // assert password is changed -> login with the new Password than undo changes + ScmRequests.start() + .requestIndexResource(username, newPassword) + .requestMe() + .assertStatusCode(200); + + } + @Test public void shouldHidePasswordLinkIfUserTypeIsNotXML() { String newUser = "user"; String password = "pass"; String type = "not XML Type"; - TestData.createUser(newUser, password, true, type); + TestData.createUser(newUser, password, true, type, "user@scm-manager.org"); ScmRequests.start() - .given() - .url(TestData.getMeUrl()) - .usernameAndPassword(newUser, password) - .getMeResource() + .requestIndexResource(newUser, password) + .requestMe() .assertStatusCode(200) - .usingMeResponse() .assertAdmin(aBoolean -> assertThat(aBoolean).isEqualTo(Boolean.TRUE)) .assertPassword(Assert::assertNull) .assertType(s -> assertThat(s).isEqualTo(type)) diff --git a/scm-it/src/test/java/sonia/scm/it/PermissionsITCase.java b/scm-it/src/test/java/sonia/scm/it/PermissionsITCase.java index f288d4891c..8785f1d8ce 100644 --- a/scm-it/src/test/java/sonia/scm/it/PermissionsITCase.java +++ b/scm-it/src/test/java/sonia/scm/it/PermissionsITCase.java @@ -87,13 +87,13 @@ public class PermissionsITCase { @Before public void prepareEnvironment() { TestData.createDefault(); - TestData.createUser(USER_READ, USER_PASS); + TestData.createNotAdminUser(USER_READ, USER_PASS); TestData.createUserPermission(USER_READ, PermissionType.READ, repositoryType); - TestData.createUser(USER_WRITE, USER_PASS); + TestData.createNotAdminUser(USER_WRITE, USER_PASS); TestData.createUserPermission(USER_WRITE, PermissionType.WRITE, repositoryType); - TestData.createUser(USER_OWNER, USER_PASS); + TestData.createNotAdminUser(USER_OWNER, USER_PASS); TestData.createUserPermission(USER_OWNER, PermissionType.OWNER, repositoryType); - TestData.createUser(USER_OTHER, USER_PASS); + TestData.createNotAdminUser(USER_OTHER, USER_PASS); createdPermissions = 3; } 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 3f8832a3f5..66ebc57c90 100644 --- a/scm-it/src/test/java/sonia/scm/it/RepositoryAccessITCase.java +++ b/scm-it/src/test/java/sonia/scm/it/RepositoryAccessITCase.java @@ -44,7 +44,7 @@ public class RepositoryAccessITCase { private final String repositoryType; private File folder; - private ScmRequests.AppliedRepositoryRequest repositoryGetRequest; + private ScmRequests.RepositoryResponse repositoryResponse; public RepositoryAccessITCase(String repositoryType) { this.repositoryType = repositoryType; @@ -59,17 +59,13 @@ public class RepositoryAccessITCase { public void init() { TestData.createDefault(); folder = tempFolder.getRoot(); - repositoryGetRequest = ScmRequests.start() - .given() - .url(TestData.getDefaultRepositoryUrl(repositoryType)) - .usernameAndPassword(ADMIN_USERNAME, ADMIN_PASSWORD) - .getRepositoryResource() + String namespace = ADMIN_USERNAME; + String repo = TestData.getDefaultRepoName(repositoryType); + repositoryResponse = + ScmRequests.start() + .requestIndexResource(ADMIN_USERNAME, ADMIN_PASSWORD) + .requestRepository(namespace, repo) .assertStatusCode(HttpStatus.SC_OK); - ScmRequests.AppliedMeRequest meGetRequest = ScmRequests.start() - .given() - .url(TestData.getMeUrl()) - .usernameAndPassword(ADMIN_USERNAME, ADMIN_PASSWORD) - .getMeResource(); } @Test @@ -201,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() @@ -216,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) @@ -231,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) @@ -306,17 +302,12 @@ public class RepositoryAccessITCase { public void shouldFindFileHistory() throws IOException { RepositoryClient repositoryClient = RepositoryUtil.createRepositoryClient(repositoryType, folder); Changeset changeset = RepositoryUtil.createAndCommitFile(repositoryClient, ADMIN_USERNAME, "folder/subfolder/a.txt", "a"); - repositoryGetRequest - .usingRepositoryResponse() + repositoryResponse .requestSources() - .usingSourcesResponse() .requestSelf("folder") - .usingSourcesResponse() .requestSelf("subfolder") - .usingSourcesResponse() .requestFileHistory("a.txt") .assertStatusCode(HttpStatus.SC_OK) - .usingChangesetsResponse() .assertChangesets(changesets -> { assertThat(changesets).hasSize(1); assertThat(changesets.get(0)).containsEntry("id", changeset.getId()); @@ -332,14 +323,11 @@ public class RepositoryAccessITCase { String fileName = "a.txt"; Changeset changeset = RepositoryUtil.createAndCommitFile(repositoryClient, ADMIN_USERNAME, fileName, "a"); String revision = changeset.getId(); - repositoryGetRequest - .usingRepositoryResponse() + repositoryResponse .requestChangesets() .assertStatusCode(HttpStatus.SC_OK) - .usingChangesetsResponse() .requestModifications(revision) .assertStatusCode(HttpStatus.SC_OK) - .usingModificationsResponse() .assertRevision(actualRevision -> assertThat(actualRevision).isEqualTo(revision)) .assertAdded(addedFiles -> assertThat(addedFiles) .hasSize(1) @@ -359,14 +347,11 @@ public class RepositoryAccessITCase { Changeset changeset = RepositoryUtil.removeAndCommitFile(repositoryClient, ADMIN_USERNAME, fileName); String revision = changeset.getId(); - repositoryGetRequest - .usingRepositoryResponse() + repositoryResponse .requestChangesets() .assertStatusCode(HttpStatus.SC_OK) - .usingChangesetsResponse() .requestModifications(revision) .assertStatusCode(HttpStatus.SC_OK) - .usingModificationsResponse() .assertRevision(actualRevision -> assertThat(actualRevision).isEqualTo(revision)) .assertRemoved(removedFiles -> assertThat(removedFiles) .hasSize(1) @@ -386,14 +371,11 @@ public class RepositoryAccessITCase { Changeset changeset = RepositoryUtil.createAndCommitFile(repositoryClient, ADMIN_USERNAME, fileName, "new Content"); String revision = changeset.getId(); - repositoryGetRequest - .usingRepositoryResponse() + repositoryResponse .requestChangesets() .assertStatusCode(HttpStatus.SC_OK) - .usingChangesetsResponse() .requestModifications(revision) .assertStatusCode(HttpStatus.SC_OK) - .usingModificationsResponse() .assertRevision(actualRevision -> assertThat(actualRevision).isEqualTo(revision)) .assertModified(modifiedFiles -> assertThat(modifiedFiles) .hasSize(1) @@ -423,14 +405,11 @@ public class RepositoryAccessITCase { Changeset changeset = RepositoryUtil.commitMultipleFileModifications(repositoryClient, ADMIN_USERNAME, addedFiles, modifiedFiles, removedFiles); String revision = changeset.getId(); - repositoryGetRequest - .usingRepositoryResponse() + repositoryResponse .requestChangesets() .assertStatusCode(HttpStatus.SC_OK) - .usingChangesetsResponse() .requestModifications(revision) .assertStatusCode(HttpStatus.SC_OK) - .usingModificationsResponse() .assertRevision(actualRevision -> assertThat(actualRevision).isEqualTo(revision)) .assertAdded(a -> assertThat(a) .hasSize(1) @@ -463,14 +442,11 @@ public class RepositoryAccessITCase { Changeset changeset = RepositoryUtil.commitMultipleFileModifications(repositoryClient, ADMIN_USERNAME, addedFiles, modifiedFiles, removedFiles); String revision = changeset.getId(); - repositoryGetRequest - .usingRepositoryResponse() + repositoryResponse .requestChangesets() .assertStatusCode(HttpStatus.SC_OK) - .usingChangesetsResponse() .requestModifications(revision) .assertStatusCode(HttpStatus.SC_OK) - .usingModificationsResponse() .assertRevision(actualRevision -> assertThat(actualRevision).isEqualTo(revision)) .assertAdded(a -> assertThat(a) .hasSize(3) diff --git a/scm-it/src/test/java/sonia/scm/it/UserITCase.java b/scm-it/src/test/java/sonia/scm/it/UserITCase.java index 33fbe0cc5d..4c7e3b2cac 100644 --- a/scm-it/src/test/java/sonia/scm/it/UserITCase.java +++ b/scm-it/src/test/java/sonia/scm/it/UserITCase.java @@ -19,75 +19,83 @@ public class UserITCase { public void adminShouldChangeOwnPassword() { String newUser = "user"; String password = "pass"; - TestData.createUser(newUser, password, true, "xml"); + TestData.createUser(newUser, password, true, "xml", "user@scm-manager.org"); String newPassword = "new_password"; // admin change the own password ScmRequests.start() - .given() - .url(TestData.getUserUrl(newUser)) - .usernameAndPassword(newUser, password) - .getUserResource() + .requestIndexResource(newUser, password) + .assertStatusCode(200) + .requestUser(newUser) .assertStatusCode(200) - .usingUserResponse() .assertAdmin(aBoolean -> assertThat(aBoolean).isEqualTo(Boolean.TRUE)) .assertPassword(Assert::assertNull) - .requestChangePassword(newPassword) // the oldPassword is not needed in the user resource + .requestChangePassword(newPassword) .assertStatusCode(204); // assert password is changed -> login with the new Password ScmRequests.start() - .given() - .url(TestData.getUserUrl(newUser)) - .usernameAndPassword(newUser, newPassword) - .getUserResource() + .requestIndexResource(newUser, newPassword) .assertStatusCode(200) - .usingUserResponse() + .requestUser(newUser) .assertAdmin(isAdmin -> assertThat(isAdmin).isEqualTo(Boolean.TRUE)) .assertPassword(Assert::assertNull); - } @Test public void adminShouldChangePasswordOfOtherUser() { String newUser = "user"; String password = "pass"; - TestData.createUser(newUser, password, true, "xml"); + TestData.createUser(newUser, password, true, "xml", "user@scm-manager.org"); String newPassword = "new_password"; // admin change the password of the user ScmRequests.start() - .given() - .url(TestData.getUserUrl(newUser))// the admin get the user object - .usernameAndPassword(TestData.USER_SCM_ADMIN, TestData.USER_SCM_ADMIN) - .getUserResource() + .requestIndexResource(TestData.USER_SCM_ADMIN, TestData.USER_SCM_ADMIN) + .assertStatusCode(200) + .requestUser(newUser) .assertStatusCode(200) - .usingUserResponse() .assertAdmin(aBoolean -> assertThat(aBoolean).isEqualTo(Boolean.TRUE)) // the user anonymous is not an admin .assertPassword(Assert::assertNull) .requestChangePassword(newPassword) // the oldPassword is not needed in the user resource .assertStatusCode(204); // assert password is changed ScmRequests.start() - .given() - .url(TestData.getUserUrl(newUser)) - .usernameAndPassword(newUser, newPassword) - .getUserResource() + .requestIndexResource(newUser, newPassword) + .assertStatusCode(200) + .requestUser(newUser) .assertStatusCode(200); } + @Test + public void nonAdminUserShouldNotChangePasswordOfOtherUser() { + String user = "user"; + String password = "pass"; + TestData.createUser(user, password, false, "xml", "em@l.de"); + String user2 = "user2"; + TestData.createUser(user2, password, false, "xml", "em@l.de"); + ScmRequests.start() + .requestIndexResource(user, password) + .assertUsersLinkDoesNotExists(); + // use the users/ endpoint bypassed the index resource + ScmRequests.start() + .requestUser(user, password, user2) + .assertStatusCode(403); + // use the users/password endpoint bypassed the index and users resources + ScmRequests.start() + .requestUserChangePassword(user, password, user2, "newPassword") + .assertStatusCode(403); + } @Test public void shouldHidePasswordLinkIfUserTypeIsNotXML() { String newUser = "user"; String password = "pass"; String type = "not XML Type"; - TestData.createUser(newUser, password, true, type); + TestData.createUser(newUser, password, true, type, "user@scm-manager.org"); ScmRequests.start() - .given() - .url(TestData.getMeUrl()) - .usernameAndPassword(newUser, password) - .getUserResource() + .requestIndexResource(newUser, password) + .assertStatusCode(200) + .requestUser(newUser) .assertStatusCode(200) - .usingUserResponse() .assertAdmin(aBoolean -> assertThat(aBoolean).isEqualTo(Boolean.TRUE)) .assertPassword(Assert::assertNull) .assertType(s -> assertThat(s).isEqualTo(type)) 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 41fd9a1290..14caa57beb 100644 --- a/scm-it/src/test/java/sonia/scm/it/utils/ScmRequests.java +++ b/scm-it/src/test/java/sonia/scm/it/utils/ScmRequests.java @@ -2,9 +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.URI; +import java.net.ConnectException; import java.util.List; import java.util.Map; import java.util.function.Consumer; @@ -25,7 +29,8 @@ import static sonia.scm.it.utils.TestData.createPasswordChangeJson; */ public class ScmRequests { - private String url; + private static final Logger LOG = LoggerFactory.getLogger(ScmRequests.class); + private String username; private String password; @@ -33,10 +38,29 @@ public class ScmRequests { return new ScmRequests(); } - public Given given() { - return new Given(); + public IndexResponse requestIndexResource(String username, String password) { + setUsername(username); + setPassword(password); + return new IndexResponse(applyGETRequest(RestUtil.REST_BASE_URL.toString())); } + public , T extends ModelResponse> UserResponse requestUser(String username, String password, String pathParam) { + setUsername(username); + setPassword(password); + return new UserResponse<>(applyGETRequest(RestUtil.REST_BASE_URL.resolve("users/"+pathParam).toString()), null); + } + + public ChangePasswordResponse requestUserChangePassword(String username, String password, String userPathParam, String newPassword) { + setUsername(username); + setPassword(password); + return new ChangePasswordResponse<>(applyPUTRequest(RestUtil.REST_BASE_URL.resolve("users/"+userPathParam+"/password").toString(), VndMediaType.PASSWORD_OVERWRITE, TestData.createPasswordChangeJson(password,newPassword)), null); + } + + @SuppressWarnings("unchecked") + public ModelResponse requestPluginTranslations(String language) { + Response response = applyGETRequest(RestUtil.BASE_URL.resolve("locales/" + language + "/plugins.json").toString()); + return new ModelResponse(response, null); + } /** * Apply a GET Request to the extracted url from the given link @@ -46,24 +70,54 @@ public class ScmRequests { * @return the response of the GET request using the given link */ private Response applyGETRequestFromLink(Response response, String linkPropertyName) { - return applyGETRequest(response - .then() - .extract() - .path(linkPropertyName)); + return applyGETRequestFromLinkWithParams(response, linkPropertyName, ""); } + /** + * Apply a GET Request to the extracted url from the given link + * + * @param linkPropertyName the property name of link + * @param response the response containing the link + * @param params query params eg. ?q=xyz&count=12 or path params eg. namespace/name + * @return the response of the GET request using the given link + */ + private Response applyGETRequestFromLinkWithParams(Response response, String linkPropertyName, String params) { + String url = response + .then() + .extract() + .path(linkPropertyName); + Assert.assertNotNull("no url found for link " + linkPropertyName, url); + return applyGETRequestWithQueryParams(url, params); + } + + /** + * Apply a GET Request to the given url and return the response. + * + * @param url the url of the GET request + * @param params query params eg. ?q=xyz&count=12 or path params eg. namespace/name + * @return the response of the GET request using the given url + */ + private Response applyGETRequestWithQueryParams(String url, String params) { + LOG.info("GET {}", url); + if (username == null || password == null){ + return RestAssured.given() + .when() + .get(url + params); + } + return RestAssured.given() + .auth().preemptive().basic(username, password) + .when() + .get(url + params); + } /** * Apply a GET Request to the given url and return the response. * * @param url the url of the GET request * @return the response of the GET request using the given url - */ + **/ private Response applyGETRequest(String url) { - return RestAssured.given() - .auth().preemptive().basic(username, password) - .when() - .get(url); + return applyGETRequestWithQueryParams(url, ""); } @@ -92,6 +146,7 @@ public class ScmRequests { * @return the response of the PUT request using the given url */ private Response applyPUTRequest(String url, String mediaType, String body) { + LOG.info("PUT {}", url); return RestAssured.given() .auth().preemptive().basic(username, password) .when() @@ -101,11 +156,6 @@ public class ScmRequests { .put(url); } - - private void setUrl(String url) { - this.url = url; - } - private void setUsername(String username) { this.username = username; } @@ -114,272 +164,163 @@ public class ScmRequests { this.password = password; } - private String getUrl() { - return url; - } + public class IndexResponse extends ModelResponse { + public static final String LINK_AUTOCOMPLETE_USERS = "_links.autocomplete.find{it.name=='users'}.href"; + public static final String LINK_AUTOCOMPLETE_GROUPS = "_links.autocomplete.find{it.name=='groups'}.href"; + public static final String LINK_REPOSITORIES = "_links.repositories.href"; + private static final String LINK_ME = "_links.me.href"; + private static final String LINK_USERS = "_links.users.href"; - private String getUsername() { - return username; - } - - private String getPassword() { - return password; - } - - public class Given { - - public GivenUrl url(String url) { - setUrl(url); - return new GivenUrl(); + public IndexResponse(Response response) { + super(response, null); } - public GivenUrl url(URI url) { - setUrl(url.toString()); - return new GivenUrl(); + public AutoCompleteResponse requestAutoCompleteUsers(String q) { + return new AutoCompleteResponse<>(applyGETRequestFromLinkWithParams(response, LINK_AUTOCOMPLETE_USERS, "?q=" + q), this); + } + + public AutoCompleteResponse requestAutoCompleteGroups(String q) { + return new AutoCompleteResponse<>(applyGETRequestFromLinkWithParams(response, LINK_AUTOCOMPLETE_GROUPS, "?q=" + q), this); + } + + public RepositoryResponse requestRepository(String namespace, String name) { + return new RepositoryResponse<>(applyGETRequestFromLinkWithParams(response, LINK_REPOSITORIES, namespace + "/" + name), this); + } + + public MeResponse requestMe() { + return new MeResponse<>(applyGETRequestFromLink(response, LINK_ME), this); + } + + public UserResponse requestUser(String username) { + return new UserResponse<>(applyGETRequestFromLinkWithParams(response, LINK_USERS, username), this); + } + + public IndexResponse assertUsersLinkDoesNotExists() { + return super.assertPropertyPathDoesNotExists(LINK_USERS); + } + + + } + + public class RepositoryResponse extends ModelResponse, PREV> { + + + public static final String LINKS_SOURCES = "_links.sources.href"; + public static final String LINKS_CHANGESETS = "_links.changesets.href"; + + public RepositoryResponse(Response response, PREV previousResponse) { + super(response, previousResponse); + } + + public SourcesResponse requestSources() { + return new SourcesResponse<>(applyGETRequestFromLink(response, LINKS_SOURCES), this); + } + + public ChangesetsResponse requestChangesets() { + return new ChangesetsResponse<>(applyGETRequestFromLink(response, LINKS_CHANGESETS), this); } } - public class GivenWithUrlAndAuth { - public AppliedMeRequest getMeResource() { - return new AppliedMeRequest(applyGETRequest(url)); - } + public class ChangesetsResponse extends ModelResponse, PREV> { - public AppliedUserRequest getUserResource() { - return new AppliedUserRequest(applyGETRequest(url)); - } - - public AppliedRepositoryRequest getRepositoryResource() { - return new AppliedRepositoryRequest( - applyGETRequest(url) - ); - } - } - - public class AppliedRequest { - private Response response; - - public AppliedRequest(Response response) { - this.response = response; - } - - /** - * apply custom assertions to the actual response - * - * @param consumer consume the response in order to assert the content. the header, the payload etc.. - * @return the self object - */ - public SELF assertResponse(Consumer consumer) { - consumer.accept(response); - return (SELF) this; - } - - /** - * special assertion of the status code - * - * @param expectedStatusCode the expected status code - * @return the self object - */ - public SELF assertStatusCode(int expectedStatusCode) { - this.response.then().assertThat().statusCode(expectedStatusCode); - return (SELF) this; - } - - } - - public class AppliedRepositoryRequest extends AppliedRequest { - - public AppliedRepositoryRequest(Response response) { - super(response); - } - - public RepositoryResponse usingRepositoryResponse() { - return new RepositoryResponse(super.response); - } - } - - public class RepositoryResponse { - - private Response repositoryResponse; - - public RepositoryResponse(Response repositoryResponse) { - this.repositoryResponse = repositoryResponse; - } - - public AppliedSourcesRequest requestSources() { - return new AppliedSourcesRequest(applyGETRequestFromLink(repositoryResponse, "_links.sources.href")); - } - - public AppliedChangesetsRequest requestChangesets() { - return new AppliedChangesetsRequest(applyGETRequestFromLink(repositoryResponse, "_links.changesets.href")); - } - } - - public class AppliedChangesetsRequest extends AppliedRequest { - - public AppliedChangesetsRequest(Response response) { - super(response); - } - - public ChangesetsResponse usingChangesetsResponse() { - return new ChangesetsResponse(super.response); - } - } - - public class ChangesetsResponse { - private Response changesetsResponse; - - public ChangesetsResponse(Response changesetsResponse) { - this.changesetsResponse = changesetsResponse; + public ChangesetsResponse(Response response, PREV previousResponse) { + super(response, previousResponse); } public ChangesetsResponse assertChangesets(Consumer> changesetsConsumer) { - List changesets = changesetsResponse.then().extract().path("_embedded.changesets"); + List changesets = response.then().extract().path("_embedded.changesets"); changesetsConsumer.accept(changesets); return this; } - public AppliedDiffRequest requestDiff(String revision) { - return new AppliedDiffRequest(applyGETRequestFromLink(changesetsResponse, "_embedded.changesets.find{it.id=='" + revision + "'}._links.diff.href")); + public DiffResponse requestDiff(String revision) { + return new DiffResponse<>(applyGETRequestFromLink(response, "_embedded.changesets.find{it.id=='" + revision + "'}._links.diff.href"), this); } - public AppliedModificationsRequest requestModifications(String revision) { - return new AppliedModificationsRequest(applyGETRequestFromLink(changesetsResponse, "_embedded.changesets.find{it.id=='" + revision + "'}._links.modifications.href")); + public ModificationsResponse requestModifications(String revision) { + return new ModificationsResponse<>(applyGETRequestFromLink(response, "_embedded.changesets.find{it.id=='" + revision + "'}._links.modifications.href"), this); } } - public class AppliedSourcesRequest extends AppliedRequest { - public AppliedSourcesRequest(Response sourcesResponse) { - super(sourcesResponse); - } + public class SourcesResponse extends ModelResponse, PREV> { - public SourcesResponse usingSourcesResponse() { - return new SourcesResponse(super.response); - } - } - - public class SourcesResponse { - - private Response sourcesResponse; - - public SourcesResponse(Response sourcesResponse) { - this.sourcesResponse = sourcesResponse; + public SourcesResponse(Response response, PREV previousResponse) { + super(response, previousResponse); } public SourcesResponse assertRevision(Consumer assertRevision) { - String revision = sourcesResponse.then().extract().path("revision"); + String revision = response.then().extract().path("revision"); assertRevision.accept(revision); return this; } public SourcesResponse assertFiles(Consumer assertFiles) { - List files = sourcesResponse.then().extract().path("files"); + List files = response.then().extract().path("files"); assertFiles.accept(files); return this; } - public AppliedChangesetsRequest requestFileHistory(String fileName) { - return new AppliedChangesetsRequest(applyGETRequestFromLink(sourcesResponse, "_embedded.files.find{it.name=='" + fileName + "'}._links.history.href")); + public ChangesetsResponse requestFileHistory(String fileName) { + return new ChangesetsResponse<>(applyGETRequestFromLink(response, "_embedded.children.find{it.name=='" + fileName + "'}._links.history.href"), this); } - public AppliedSourcesRequest requestSelf(String fileName) { - return new AppliedSourcesRequest(applyGETRequestFromLink(sourcesResponse, "_embedded.files.find{it.name=='" + fileName + "'}._links.self.href")); + public SourcesResponse requestSelf(String fileName) { + return new SourcesResponse<>(applyGETRequestFromLink(response, "_embedded.children.find{it.name=='" + fileName + "'}._links.self.href"), this); } } - public class AppliedDiffRequest extends AppliedRequest { + public class ModificationsResponse extends ModelResponse, PREV> { - public AppliedDiffRequest(Response response) { - super(response); - } - } - - public class GivenUrl { - - public GivenWithUrlAndAuth usernameAndPassword(String username, String password) { - setUsername(username); - setPassword(password); - return new GivenWithUrlAndAuth(); - } - } - - public class AppliedModificationsRequest extends AppliedRequest { - public AppliedModificationsRequest(Response response) { - super(response); + public ModificationsResponse(Response response, PREV previousResponse) { + super(response, previousResponse); } - public ModificationsResponse usingModificationsResponse() { - return new ModificationsResponse(super.response); - } - - } - - public class ModificationsResponse { - private Response resource; - - public ModificationsResponse(Response resource) { - this.resource = resource; - } - - public ModificationsResponse assertRevision(Consumer assertRevision) { - String revision = resource.then().extract().path("revision"); + public ModificationsResponse assertRevision(Consumer assertRevision) { + String revision = response.then().extract().path("revision"); assertRevision.accept(revision); return this; } - public ModificationsResponse assertAdded(Consumer> assertAdded) { - List added = resource.then().extract().path("added"); + public ModificationsResponse assertAdded(Consumer> assertAdded) { + List added = response.then().extract().path("added"); assertAdded.accept(added); return this; } - public ModificationsResponse assertRemoved(Consumer> assertRemoved) { - List removed = resource.then().extract().path("removed"); + public ModificationsResponse assertRemoved(Consumer> assertRemoved) { + List removed = response.then().extract().path("removed"); assertRemoved.accept(removed); return this; } - public ModificationsResponse assertModified(Consumer> assertModified) { - List modified = resource.then().extract().path("modified"); + public ModificationsResponse assertModified(Consumer> assertModified) { + List modified = response.then().extract().path("modified"); assertModified.accept(modified); return this; } } - public class AppliedMeRequest extends AppliedRequest { + public class MeResponse extends UserResponse, PREV> { - public AppliedMeRequest(Response response) { - super(response); + + public MeResponse(Response response, PREV previousResponse) { + super(response, previousResponse); } - public MeResponse usingMeResponse() { - return new MeResponse(super.response); + public ChangePasswordResponse requestChangePassword(String oldPassword, String newPassword) { + return new ChangePasswordResponse<>(applyPUTRequestFromLink(super.response, LINKS_PASSWORD_HREF, VndMediaType.PASSWORD_CHANGE, createPasswordChangeJson(oldPassword, newPassword)), this); } - } - public class MeResponse extends UserResponse { - - - public MeResponse(Response response) { - super(response); - } - - public AppliedChangePasswordRequest requestChangePassword(String oldPassword, String newPassword) { - return new AppliedChangePasswordRequest(applyPUTRequestFromLink(super.response, "_links.password.href", VndMediaType.PASSWORD_CHANGE, createPasswordChangeJson(oldPassword, newPassword))); - } - - - } - - public class UserResponse extends ModelResponse { + public class UserResponse, PREV extends ModelResponse> extends ModelResponse { public static final String LINKS_PASSWORD_HREF = "_links.password.href"; - public UserResponse(Response response) { - super(response); + public UserResponse(Response response, PREV previousResponse) { + super(response, previousResponse); } public SELF assertPassword(Consumer assertPassword) { @@ -402,22 +343,27 @@ public class ScmRequests { return assertPropertyPathExists(LINKS_PASSWORD_HREF); } - public AppliedChangePasswordRequest requestChangePassword(String newPassword) { - return new AppliedChangePasswordRequest(applyPUTRequestFromLink(super.response, LINKS_PASSWORD_HREF, VndMediaType.PASSWORD_CHANGE, createPasswordChangeJson(null, newPassword))); + public ChangePasswordResponse requestChangePassword(String newPassword) { + return new ChangePasswordResponse<>(applyPUTRequestFromLink(super.response, LINKS_PASSWORD_HREF, VndMediaType.PASSWORD_OVERWRITE, createPasswordChangeJson(null, newPassword)), this); } - } /** * encapsulate standard assertions over model properties */ - public class ModelResponse { + public class ModelResponse, PREV extends ModelResponse> { + protected PREV previousResponse; protected Response response; - public ModelResponse(Response response) { + public ModelResponse(Response response, PREV previousResponse) { this.response = response; + this.previousResponse = previousResponse; + } + + public PREV returnToPrevious() { + return previousResponse; } public SELF assertSingleProperty(Consumer assertSingleProperty, String propertyJsonPath) { @@ -441,25 +387,45 @@ public class ScmRequests { assertProperties.accept(properties); return (SELF) this; } + + /** + * special assertion of the status code + * + * @param expectedStatusCode the expected status code + * @return the self object + */ + public SELF assertStatusCode(int expectedStatusCode) { + this.response.then().assertThat().statusCode(expectedStatusCode); + return (SELF) this; + } } - public class AppliedChangePasswordRequest extends AppliedRequest { + public class AutoCompleteResponse extends ModelResponse, PREV> { - public AppliedChangePasswordRequest(Response response) { - super(response); + public AutoCompleteResponse(Response response, PREV previousResponse) { + super(response, previousResponse); + } + + public AutoCompleteResponse assertAutoCompleteResults(Consumer> checker) { + List result = response.then().extract().path(""); + checker.accept(result); + return this; } } - public class AppliedUserRequest extends AppliedRequest { - public AppliedUserRequest(Response response) { - super(response); + public class DiffResponse extends ModelResponse, PREV> { + + public DiffResponse(Response response, PREV previousResponse) { + super(response, previousResponse); } + } - public UserResponse usingUserResponse() { - return new UserResponse(super.response); + public class ChangePasswordResponse extends ModelResponse, PREV> { + + public ChangePasswordResponse(Response response, PREV previousResponse) { + super(response, previousResponse); } - } } diff --git a/scm-it/src/test/java/sonia/scm/it/utils/TestData.java b/scm-it/src/test/java/sonia/scm/it/utils/TestData.java index 03da80ea3b..a164fc6649 100644 --- a/scm-it/src/test/java/sonia/scm/it/utils/TestData.java +++ b/scm-it/src/test/java/sonia/scm/it/utils/TestData.java @@ -46,11 +46,11 @@ public class TestData { return DEFAULT_REPOSITORIES.get(repositoryType); } - public static void createUser(String username, String password) { - createUser(username, password, false, "xml"); + public static void createNotAdminUser(String username, String password) { + createUser(username, password, false, "xml", "user1@scm-manager.org"); } - public static void createUser(String username, String password, boolean isAdmin, String type) { + public static void createUser(String username, String password, boolean isAdmin, String type, final String email) { LOG.info("create user with username: {}", username); String admin = isAdmin ? "true" : "false"; given(VndMediaType.USER) @@ -61,7 +61,7 @@ public class TestData { .append(" \"admin\": ").append(admin).append(",\n") .append(" \"creationDate\": \"2018-08-21T12:26:46.084Z\",\n") .append(" \"displayName\": \"").append(username).append("\",\n") - .append(" \"mail\": \"user1@scm-manager.org\",\n") + .append(" \"mail\": \"" + email + "\",\n") .append(" \"name\": \"").append(username).append("\",\n") .append(" \"password\": \"").append(password).append("\",\n") .append(" \"type\": \"").append(type).append("\"\n") @@ -71,6 +71,16 @@ public class TestData { .statusCode(HttpStatus.SC_CREATED) ; } + public static void createGroup(String groupName, String desc) { + LOG.info("create group with group name: {} and description {}", groupName, desc); + given(VndMediaType.GROUP) + .when() + .content(getGroupJson(groupName,desc)) + .post(getGroupsUrl()) + .then() + .statusCode(HttpStatus.SC_CREATED) + ; + } public static void createUserPermission(String name, PermissionType permissionType, String repositoryType) { String defaultPermissionUrl = TestData.getDefaultPermissionUrl(USER_SCM_ADMIN, USER_SCM_ADMIN, repositoryType); @@ -193,28 +203,31 @@ public class TestData { return JSON_BUILDER .add("contact", "zaphod.beeblebrox@hitchhiker.com") .add("description", "Heart of Gold") - .add("name", "HeartOfGold-" + repositoryType) + .add("name", getDefaultRepoName(repositoryType)) .add("archived", false) .add("type", repositoryType) .build().toString(); } - public static URI getMeUrl() { - return RestUtil.createResourceUrl("me/"); + public static String getDefaultRepoName(String repositoryType) { + return "HeartOfGold-" + repositoryType; + } + public static String getGroupJson(String groupname , String desc) { + return JSON_BUILDER + .add("name", groupname) + .add("description", desc) + .build().toString(); + } + + public static URI getGroupsUrl() { + return RestUtil.createResourceUrl("groups/"); } public static URI getUsersUrl() { return RestUtil.createResourceUrl("users/"); - } - public static URI getUserUrl(String username) { - return getUsersUrl().resolve(username); - - } - - public static String createPasswordChangeJson(String oldPassword, String newPassword) { return JSON_BUILDER .add("oldPassword", oldPassword) @@ -225,4 +238,5 @@ public class TestData { public static void main(String[] args) { cleanup(); } + } diff --git a/scm-plugins/scm-git-plugin/package.json b/scm-plugins/scm-git-plugin/package.json index 93ec098597..8f0ab90d34 100644 --- a/scm-plugins/scm-git-plugin/package.json +++ b/scm-plugins/scm-git-plugin/package.json @@ -9,6 +9,6 @@ "@scm-manager/ui-extensions": "^0.0.7" }, "devDependencies": { - "@scm-manager/ui-bundler": "^0.0.15" + "@scm-manager/ui-bundler": "^0.0.17" } } diff --git a/scm-plugins/scm-git-plugin/src/main/java/sonia/scm/repository/GitChangesetConverter.java b/scm-plugins/scm-git-plugin/src/main/java/sonia/scm/repository/GitChangesetConverter.java index 6936c51269..2275fbcfd0 100644 --- a/scm-plugins/scm-git-plugin/src/main/java/sonia/scm/repository/GitChangesetConverter.java +++ b/scm-plugins/scm-git-plugin/src/main/java/sonia/scm/repository/GitChangesetConverter.java @@ -39,7 +39,6 @@ import com.google.common.collect.Lists; import com.google.common.collect.Multimap; import org.eclipse.jgit.lib.ObjectId; import org.eclipse.jgit.lib.PersonIdent; -import org.eclipse.jgit.lib.Ref; import org.eclipse.jgit.revwalk.RevCommit; import org.eclipse.jgit.revwalk.RevWalk; import org.eclipse.jgit.treewalk.TreeWalk; @@ -51,8 +50,8 @@ import java.io.Closeable; import java.io.IOException; import java.util.ArrayList; import java.util.Collection; +import java.util.Collections; import java.util.List; -import java.util.Set; //~--- JDK imports ------------------------------------------------------------ @@ -130,27 +129,9 @@ public class GitChangesetConverter implements Closeable * * @throws IOException */ - public Changeset createChangeset(RevCommit commit) throws IOException + public Changeset createChangeset(RevCommit commit) { - List branches = Lists.newArrayList(); - Set refs = repository.getAllRefsByPeeledObjectId().get(commit.getId()); - - if (Util.isNotEmpty(refs)) - { - - for (Ref ref : refs) - { - String branch = GitUtil.getBranch(ref); - - if (branch != null) - { - branches.add(branch); - } - } - - } - - return createChangeset(commit, branches); + return createChangeset(commit, Collections.emptyList()); } /** @@ -165,7 +146,6 @@ public class GitChangesetConverter implements Closeable * @throws IOException */ public Changeset createChangeset(RevCommit commit, String branch) - throws IOException { return createChangeset(commit, Lists.newArrayList(branch)); } @@ -183,7 +163,6 @@ public class GitChangesetConverter implements Closeable * @throws IOException */ public Changeset createChangeset(RevCommit commit, List branches) - throws IOException { String id = commit.getId().name(); List parentList = null; diff --git a/scm-plugins/scm-git-plugin/src/main/java/sonia/scm/repository/GitUtil.java b/scm-plugins/scm-git-plugin/src/main/java/sonia/scm/repository/GitUtil.java index 7e145f2dd9..13340a20e7 100644 --- a/scm-plugins/scm-git-plugin/src/main/java/sonia/scm/repository/GitUtil.java +++ b/scm-plugins/scm-git-plugin/src/main/java/sonia/scm/repository/GitUtil.java @@ -63,8 +63,11 @@ import javax.servlet.http.HttpServletRequest; import java.io.File; import java.io.IOException; import java.util.Map; +import java.util.Optional; import java.util.concurrent.TimeUnit; +import static java.util.Optional.of; + //~--- JDK imports ------------------------------------------------------------ /** @@ -345,12 +348,11 @@ public final class GitUtil * * @throws IOException */ - public static ObjectId getBranchId(org.eclipse.jgit.lib.Repository repo, + public static Ref getBranchId(org.eclipse.jgit.lib.Repository repo, String branchName) throws IOException { - ObjectId branchId = null; - + Ref ref = null; if (!branchName.startsWith(REF_HEAD)) { branchName = PREFIX_HEADS.concat(branchName); @@ -360,24 +362,19 @@ public final class GitUtil try { - Ref ref = repo.findRef(branchName); + ref = repo.findRef(branchName); - if (ref != null) - { - branchId = ref.getObjectId(); - } - else if (logger.isWarnEnabled()) + if (ref == null) { logger.warn("could not find branch for {}", branchName); } - } catch (IOException ex) { logger.warn("error occured during resolve of branch id", ex); } - return branchId; + return ref; } /** @@ -499,68 +496,48 @@ public final class GitUtil return ref; } - /** - * Method description - * - * - * @param repo - * - * @return - * - * @throws IOException - */ - public static ObjectId getRepositoryHead(org.eclipse.jgit.lib.Repository repo) - throws IOException - { - ObjectId id = null; - String head = null; - Map refs = repo.getAllRefs(); + public static ObjectId getRepositoryHead(org.eclipse.jgit.lib.Repository repo) { + return getRepositoryHeadRef(repo).map(Ref::getObjectId).orElse(null); + } - for (Map.Entry e : refs.entrySet()) - { - String key = e.getKey(); + public static Optional getRepositoryHeadRef(org.eclipse.jgit.lib.Repository repo) { + Optional foundRef = findMostAppropriateHead(repo.getAllRefs()); - if (REF_HEAD.equals(key)) - { - head = REF_HEAD; - id = e.getValue().getObjectId(); - - break; - } - else if (key.startsWith(REF_HEAD_PREFIX)) - { - id = e.getValue().getObjectId(); - head = key.substring(REF_HEAD_PREFIX.length()); - - if (REF_MASTER.equals(head)) - { - break; - } + if (foundRef.isPresent()) { + if (logger.isDebugEnabled()) { + logger.debug("use {}:{} as repository head for directory {}", + foundRef.map(GitUtil::getBranch).orElse(null), + foundRef.map(Ref::getObjectId).map(ObjectId::name).orElse(null), + repo.getDirectory()); } + } else { + logger.warn("could not find repository head in directory {}", repo.getDirectory()); } - if (id == null) - { - id = repo.resolve(Constants.HEAD); + return foundRef; + } + + private static Optional findMostAppropriateHead(Map refs) { + Ref refHead = refs.get(REF_HEAD); + if (refHead != null && refHead.isSymbolic() && isBranch(refHead.getTarget().getName())) { + return of(refHead.getTarget()); } - if (logger.isDebugEnabled()) - { - if ((head != null) && (id != null)) - { - logger.debug("use {}:{} as repository head", head, id.name()); - } - else if (id != null) - { - logger.debug("use {} as repository head", id.name()); - } - else - { - logger.warn("could not find repository head"); - } + Ref master = refs.get(REF_HEAD_PREFIX + REF_MASTER); + if (master != null) { + return of(master); } - return id; + Ref develop = refs.get(REF_HEAD_PREFIX + "develop"); + if (develop != null) { + return of(develop); + } + + return refs.entrySet() + .stream() + .filter(e -> e.getKey().startsWith(REF_HEAD_PREFIX)) + .map(Map.Entry::getValue) + .findFirst(); } /** @@ -648,7 +625,7 @@ public final class GitUtil return tagName; } - + /** * Method description * diff --git a/scm-plugins/scm-git-plugin/src/main/java/sonia/scm/repository/spi/AbstractGitCommand.java b/scm-plugins/scm-git-plugin/src/main/java/sonia/scm/repository/spi/AbstractGitCommand.java index d098c30b4e..2970bbd627 100644 --- a/scm-plugins/scm-git-plugin/src/main/java/sonia/scm/repository/spi/AbstractGitCommand.java +++ b/scm-plugins/scm-git-plugin/src/main/java/sonia/scm/repository/spi/AbstractGitCommand.java @@ -34,19 +34,20 @@ package sonia.scm.repository.spi; //~--- non-JDK imports -------------------------------------------------------- -import com.google.common.annotations.VisibleForTesting; import com.google.common.base.Strings; -import org.eclipse.jgit.lib.Repository; - -//~--- JDK imports ------------------------------------------------------------ - -import java.io.IOException; import org.eclipse.jgit.lib.ObjectId; +import org.eclipse.jgit.lib.Ref; +import org.eclipse.jgit.lib.Repository; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import sonia.scm.repository.GitConstants; import sonia.scm.repository.GitUtil; +import java.io.IOException; +import java.util.Optional; + +//~--- JDK imports ------------------------------------------------------------ + /** * * @author Sebastian Sdorra @@ -97,27 +98,29 @@ public class AbstractGitCommand } return commit; } - - protected ObjectId getBranchOrDefault(Repository gitRepository, String requestedBranch) throws IOException { - ObjectId head; - if ( Strings.isNullOrEmpty(requestedBranch) ) { - head = getDefaultBranch(gitRepository); - } else { - head = GitUtil.getBranchId(gitRepository, requestedBranch); - } - return head; - } - + protected ObjectId getDefaultBranch(Repository gitRepository) throws IOException { - ObjectId head; - String defaultBranchName = repository.getProperty(GitConstants.PROPERTY_DEFAULT_BRANCH); - if (!Strings.isNullOrEmpty(defaultBranchName)) { - head = GitUtil.getBranchId(gitRepository, defaultBranchName); + Ref ref = getBranchOrDefault(gitRepository, null); + if (ref == null) { + return null; } else { - logger.trace("no default branch configured, use repository head as default"); - head = GitUtil.getRepositoryHead(gitRepository); + return ref.getObjectId(); + } + } + + protected Ref getBranchOrDefault(Repository gitRepository, String requestedBranch) throws IOException { + if ( Strings.isNullOrEmpty(requestedBranch) ) { + String defaultBranchName = repository.getProperty(GitConstants.PROPERTY_DEFAULT_BRANCH); + if (!Strings.isNullOrEmpty(defaultBranchName)) { + return GitUtil.getBranchId(gitRepository, defaultBranchName); + } else { + logger.trace("no default branch configured, use repository head as default"); + Optional repositoryHeadRef = GitUtil.getRepositoryHeadRef(gitRepository); + return repositoryHeadRef.orElse(null); + } + } else { + return GitUtil.getBranchId(gitRepository, requestedBranch); } - return head; } //~--- fields --------------------------------------------------------------- diff --git a/scm-plugins/scm-git-plugin/src/main/java/sonia/scm/repository/spi/GitBrowseCommand.java b/scm-plugins/scm-git-plugin/src/main/java/sonia/scm/repository/spi/GitBrowseCommand.java index f194796bdc..ab1b0ae420 100644 --- a/scm-plugins/scm-git-plugin/src/main/java/sonia/scm/repository/spi/GitBrowseCommand.java +++ b/scm-plugins/scm-git-plugin/src/main/java/sonia/scm/repository/spi/GitBrowseCommand.java @@ -35,9 +35,9 @@ package sonia.scm.repository.spi; //~--- non-JDK imports -------------------------------------------------------- +import com.google.common.base.Strings; import com.google.common.collect.Lists; import com.google.common.collect.Maps; -import org.eclipse.jgit.errors.MissingObjectException; import org.eclipse.jgit.lib.Constants; import org.eclipse.jgit.lib.ObjectId; import org.eclipse.jgit.lib.ObjectLoader; @@ -50,6 +50,7 @@ import org.eclipse.jgit.treewalk.filter.PathFilter; import org.eclipse.jgit.treewalk.filter.TreeFilter; import org.slf4j.Logger; import org.slf4j.LoggerFactory; +import sonia.scm.NotFoundException; import sonia.scm.repository.BrowserResult; import sonia.scm.repository.FileObject; import sonia.scm.repository.GitSubModuleParser; @@ -103,10 +104,11 @@ public class GitBrowseCommand extends AbstractGitCommand @Override @SuppressWarnings("unchecked") public BrowserResult getBrowserResult(BrowseCommandRequest request) - throws IOException, RevisionNotFoundException { + throws IOException, NotFoundException { logger.debug("try to create browse result for {}", request); BrowserResult result; + org.eclipse.jgit.lib.Repository repo = open(); ObjectId revId; @@ -121,7 +123,7 @@ public class GitBrowseCommand extends AbstractGitCommand if (revId != null) { - result = getResult(repo, request, revId); + result = new BrowserResult(revId.getName(), getEntry(repo, request, revId)); } else { @@ -134,8 +136,7 @@ public class GitBrowseCommand extends AbstractGitCommand logger.warn("coul not find head of repository, empty?"); } - result = new BrowserResult(Constants.HEAD, null, null, - Collections.EMPTY_LIST); + result = new BrowserResult(Constants.HEAD, createEmtpyRoot()); } return result; @@ -143,6 +144,14 @@ public class GitBrowseCommand extends AbstractGitCommand //~--- methods -------------------------------------------------------------- + private FileObject createEmtpyRoot() { + FileObject fileObject = new FileObject(); + fileObject.setName(""); + fileObject.setPath(""); + fileObject.setDirectory(true); + return fileObject; + } + /** * Method description * @@ -158,68 +167,52 @@ public class GitBrowseCommand extends AbstractGitCommand private FileObject createFileObject(org.eclipse.jgit.lib.Repository repo, BrowseCommandRequest request, ObjectId revId, TreeWalk treeWalk) throws IOException, RevisionNotFoundException { - FileObject file; - try + FileObject file = new FileObject(); + + String path = treeWalk.getPathString(); + + file.setName(treeWalk.getNameString()); + file.setPath(path); + + SubRepository sub = null; + + if (!request.isDisableSubRepositoryDetection()) { - file = new FileObject(); + sub = getSubRepository(repo, revId, path); + } - String path = treeWalk.getPathString(); + if (sub != null) + { + logger.trace("{} seems to be a sub repository", path); + file.setDirectory(true); + file.setSubRepository(sub); + } + else + { + ObjectLoader loader = repo.open(treeWalk.getObjectId(0)); - file.setName(treeWalk.getNameString()); - file.setPath(path); + file.setDirectory(loader.getType() == Constants.OBJ_TREE); + file.setLength(loader.getSize()); - SubRepository sub = null; - - if (!request.isDisableSubRepositoryDetection()) + // don't show message and date for directories to improve performance + if (!file.isDirectory() &&!request.isDisableLastCommit()) { - sub = getSubRepository(repo, revId, path); - } + logger.trace("fetch last commit for {} at {}", path, revId.getName()); + RevCommit commit = getLatestCommit(repo, revId, path); - if (sub != null) - { - logger.trace("{} seems to be a sub repository", path); - file.setDirectory(true); - file.setSubRepository(sub); - } - else - { - ObjectLoader loader = repo.open(treeWalk.getObjectId(0)); - - file.setDirectory(loader.getType() == Constants.OBJ_TREE); - file.setLength(loader.getSize()); - - // don't show message and date for directories to improve performance - if (!file.isDirectory() &&!request.isDisableLastCommit()) + if (commit != null) { - logger.trace("fetch last commit for {} at {}", path, revId.getName()); - - RevCommit commit = getLatestCommit(repo, revId, path); - - if (commit != null) - { - file.setLastModified(GitUtil.getCommitTime(commit)); - file.setDescription(commit.getShortMessage()); - } - else if (logger.isWarnEnabled()) - { - logger.warn("could not find latest commit for {} on {}", path, - revId); - } + file.setLastModified(GitUtil.getCommitTime(commit)); + file.setDescription(commit.getShortMessage()); + } + else if (logger.isWarnEnabled()) + { + logger.warn("could not find latest commit for {} on {}", path, + revId); } } } - catch (MissingObjectException ex) - { - file = null; - logger.error("could not fetch object for id {}", revId); - - if (logger.isTraceEnabled()) - { - logger.trace("could not fetch object", ex); - } - } - return file; } @@ -265,22 +258,19 @@ public class GitBrowseCommand extends AbstractGitCommand return result; } - private BrowserResult getResult(org.eclipse.jgit.lib.Repository repo, - BrowseCommandRequest request, ObjectId revId) - throws IOException, RevisionNotFoundException { - BrowserResult result = null; + private FileObject getEntry(org.eclipse.jgit.lib.Repository repo, BrowseCommandRequest request, ObjectId revId) throws IOException, NotFoundException { RevWalk revWalk = null; TreeWalk treeWalk = null; - try - { - if (logger.isDebugEnabled()) - { - logger.debug("load repository browser for revision {}", revId.name()); - } + FileObject result; + + try { + logger.debug("load repository browser for revision {}", revId.name()); treeWalk = new TreeWalk(repo); - treeWalk.setRecursive(request.isRecursive()); + if (!isRootRequest(request)) { + treeWalk.setFilter(PathFilter.create(request.getPath())); + } revWalk = new RevWalk(repo); RevTree tree = revWalk.parseTree(revId); @@ -291,65 +281,20 @@ public class GitBrowseCommand extends AbstractGitCommand } else { - logger.error("could not find tree for {}", revId.name()); + throw new IllegalStateException("could not find tree for " + revId.name()); } - result = new BrowserResult(); - - List files = Lists.newArrayList(); - - String path = request.getPath(); - - if (Util.isEmpty(path)) - { - while (treeWalk.next()) - { - FileObject fo = createFileObject(repo, request, revId, treeWalk); - - if (fo != null) - { - files.add(fo); - } - } - } - else - { - String[] parts = path.split("/"); - int current = 0; - int limit = parts.length; - - while (treeWalk.next()) - { - String name = treeWalk.getNameString(); - - if (current >= limit) - { - String p = treeWalk.getPathString(); - - if (p.split("/").length > limit) - { - FileObject fo = createFileObject(repo, request, revId, treeWalk); - - if (fo != null) - { - files.add(fo); - } - } - } - else if (name.equalsIgnoreCase(parts[current])) - { - current++; - - if (!request.isRecursive()) - { - treeWalk.enterSubtree(); - } - } + if (isRootRequest(request)) { + result = createEmtpyRoot(); + findChildren(result, repo, request, revId, treeWalk); + } else { + result = findFirstMatch(repo, request, revId, treeWalk); + if ( result.isDirectory() ) { + treeWalk.enterSubtree(); + findChildren(result, repo, request, revId, treeWalk); } } - result.setFiles(files); - result.setRevision(revId.getName()); } finally { @@ -360,6 +305,60 @@ public class GitBrowseCommand extends AbstractGitCommand return result; } + private boolean isRootRequest(BrowseCommandRequest request) { + return Strings.isNullOrEmpty(request.getPath()) || "/".equals(request.getPath()); + } + + private FileObject findChildren(FileObject parent, org.eclipse.jgit.lib.Repository repo, BrowseCommandRequest request, ObjectId revId, TreeWalk treeWalk) throws IOException, NotFoundException { + List files = Lists.newArrayList(); + while (treeWalk.next()) + { + + FileObject fileObject = createFileObject(repo, request, revId, treeWalk); + if (!fileObject.getPath().startsWith(parent.getPath())) { + parent.setChildren(files); + return fileObject; + } + + files.add(fileObject); + + if (request.isRecursive() && fileObject.isDirectory()) { + treeWalk.enterSubtree(); + FileObject rc = findChildren(fileObject, repo, request, revId, treeWalk); + if (rc != null) { + files.add(rc); + } + } + } + + parent.setChildren(files); + + return null; + } + + private FileObject findFirstMatch(org.eclipse.jgit.lib.Repository repo, + BrowseCommandRequest request, ObjectId revId, TreeWalk treeWalk) throws IOException, NotFoundException { + String[] pathElements = request.getPath().split("/"); + int currentDepth = 0; + int limit = pathElements.length; + + while (treeWalk.next()) { + String name = treeWalk.getNameString(); + + if (name.equalsIgnoreCase(pathElements[currentDepth])) { + currentDepth++; + + if (currentDepth >= limit) { + return createFileObject(repo, request, revId, treeWalk); + } else { + treeWalk.enterSubtree(); + } + } + } + + throw new NotFoundException("file", request.getPath()); + } + @SuppressWarnings("unchecked") private Map getSubRepositories(org.eclipse.jgit.lib.Repository repo, diff --git a/scm-plugins/scm-git-plugin/src/main/java/sonia/scm/repository/spi/GitLogCommand.java b/scm-plugins/scm-git-plugin/src/main/java/sonia/scm/repository/spi/GitLogCommand.java index beb79cb921..4e9261f517 100644 --- a/scm-plugins/scm-git-plugin/src/main/java/sonia/scm/repository/spi/GitLogCommand.java +++ b/scm-plugins/scm-git-plugin/src/main/java/sonia/scm/repository/spi/GitLogCommand.java @@ -39,6 +39,7 @@ import com.google.common.base.Strings; import com.google.common.collect.Lists; import org.eclipse.jgit.errors.MissingObjectException; import org.eclipse.jgit.lib.ObjectId; +import org.eclipse.jgit.lib.Ref; import org.eclipse.jgit.lib.Repository; import org.eclipse.jgit.revwalk.RevCommit; import org.eclipse.jgit.revwalk.RevWalk; @@ -170,8 +171,8 @@ public class GitLogCommand extends AbstractGitCommand implements LogCommand GitChangesetConverter converter = null; RevWalk revWalk = null; - try (org.eclipse.jgit.lib.Repository gr = open()) { - if (!gr.getAllRefs().isEmpty()) { + try (org.eclipse.jgit.lib.Repository repository = open()) { + if (!repository.getAllRefs().isEmpty()) { int counter = 0; int start = request.getPagingStart(); @@ -188,18 +189,18 @@ public class GitLogCommand extends AbstractGitCommand implements LogCommand ObjectId startId = null; if (!Strings.isNullOrEmpty(request.getStartChangeset())) { - startId = gr.resolve(request.getStartChangeset()); + startId = repository.resolve(request.getStartChangeset()); } ObjectId endId = null; if (!Strings.isNullOrEmpty(request.getEndChangeset())) { - endId = gr.resolve(request.getEndChangeset()); + endId = repository.resolve(request.getEndChangeset()); } - revWalk = new RevWalk(gr); + revWalk = new RevWalk(repository); - converter = new GitChangesetConverter(gr, revWalk); + converter = new GitChangesetConverter(repository, revWalk); if (!Strings.isNullOrEmpty(request.getPath())) { revWalk.setTreeFilter( @@ -207,13 +208,13 @@ public class GitLogCommand extends AbstractGitCommand implements LogCommand PathFilter.create(request.getPath()), TreeFilter.ANY_DIFF)); } - ObjectId head = getBranchOrDefault(gr, request.getBranch()); + Ref branch = getBranchOrDefault(repository,request.getBranch()); - if (head != null) { + if (branch != null) { if (startId != null) { revWalk.markStart(revWalk.lookupCommit(startId)); } else { - revWalk.markStart(revWalk.lookupCommit(head)); + revWalk.markStart(revWalk.lookupCommit(branch.getObjectId())); } Iterator iterator = revWalk.iterator(); @@ -234,10 +235,14 @@ public class GitLogCommand extends AbstractGitCommand implements LogCommand } } - changesets = new ChangesetPagingResult(counter, changesetList); + if (branch != null) { + changesets = new ChangesetPagingResult(counter, changesetList, GitUtil.getBranch(branch.getName())); + } else { + changesets = new ChangesetPagingResult(counter, changesetList); + } } else if (logger.isWarnEnabled()) { logger.warn("the repository {} seems to be empty", - repository.getName()); + this.repository.getName()); changesets = new ChangesetPagingResult(0, Collections.EMPTY_LIST); } diff --git a/scm-plugins/scm-git-plugin/src/main/js/ProtocolInformation.js b/scm-plugins/scm-git-plugin/src/main/js/ProtocolInformation.js index d8eb4ae0e0..c6aed483e7 100644 --- a/scm-plugins/scm-git-plugin/src/main/js/ProtocolInformation.js +++ b/scm-plugins/scm-git-plugin/src/main/js/ProtocolInformation.js @@ -2,15 +2,17 @@ import React from "react"; import { repositories } from "@scm-manager/ui-components"; import type { Repository } from "@scm-manager/ui-types"; +import { translate } from "react-i18next"; type Props = { - repository: Repository + repository: Repository, + t: string => string } class ProtocolInformation extends React.Component { render() { - const { repository } = this.props; + const { repository, t } = this.props; const href = repositories.getProtocolLinkByType(repository, "http"); if (!href) { return null; @@ -18,11 +20,11 @@ class ProtocolInformation extends React.Component { return (

-

Clone the repository

+

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

           git clone {href}
         
-

Create a new repository

+

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

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

Push an existing repository

+

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

           
             git remote add origin {href}
@@ -54,4 +56,4 @@ class ProtocolInformation extends React.Component {
 
 }
 
-export default ProtocolInformation;
+export default translate("plugins")(ProtocolInformation);
diff --git a/scm-plugins/scm-git-plugin/src/main/resources/locales/de/plugins.json b/scm-plugins/scm-git-plugin/src/main/resources/locales/de/plugins.json
new file mode 100644
index 0000000000..1dc0e254c2
--- /dev/null
+++ b/scm-plugins/scm-git-plugin/src/main/resources/locales/de/plugins.json
@@ -0,0 +1,9 @@
+{
+  "scm-git-plugin": {
+    "information": {
+      "clone" : "Repository Klonen",
+      "create" : "Neue Repository erstellen",
+      "replace" : "Eine existierende Repository aktualisieren"
+    }
+  }
+}
diff --git a/scm-plugins/scm-git-plugin/src/main/resources/locales/en/plugins.json b/scm-plugins/scm-git-plugin/src/main/resources/locales/en/plugins.json
new file mode 100644
index 0000000000..65594bae19
--- /dev/null
+++ b/scm-plugins/scm-git-plugin/src/main/resources/locales/en/plugins.json
@@ -0,0 +1,9 @@
+{
+  "scm-git-plugin": {
+    "information": {
+      "clone" : "Clone the repository",
+      "create" : "Create a new repository",
+      "replace" : "Push an existing repository"
+    }
+  }
+}
diff --git a/scm-plugins/scm-git-plugin/src/test/java/sonia/scm/repository/spi/GitBrowseCommandTest.java b/scm-plugins/scm-git-plugin/src/test/java/sonia/scm/repository/spi/GitBrowseCommandTest.java
index d71c85a152..92b7ff69a9 100644
--- a/scm-plugins/scm-git-plugin/src/test/java/sonia/scm/repository/spi/GitBrowseCommandTest.java
+++ b/scm-plugins/scm-git-plugin/src/test/java/sonia/scm/repository/spi/GitBrowseCommandTest.java
@@ -6,13 +6,13 @@
  * modification, are permitted provided that the following conditions are met:
  *
  * 1. Redistributions of source code must retain the above copyright notice,
- *    this list of conditions and the following disclaimer.
+ * this list of conditions and the following disclaimer.
  * 2. Redistributions in binary form must reproduce the above copyright notice,
- *    this list of conditions and the following disclaimer in the documentation
- *    and/or other materials provided with the distribution.
+ * this list of conditions and the following disclaimer in the documentation
+ * and/or other materials provided with the distribution.
  * 3. Neither the name of SCM-Manager; nor the names of its
- *    contributors may be used to endorse or promote products derived from this
- *    software without specific prior written permission.
+ * contributors may be used to endorse or promote products derived from this
+ * software without specific prior written permission.
  *
  * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
  * AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
@@ -26,152 +26,114 @@
  * SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
  *
  * http://bitbucket.org/sdorra/scm-manager
- *
  */
 
 
-
 package sonia.scm.repository.spi;
 
-//~--- non-JDK imports --------------------------------------------------------
-
 import org.junit.Test;
+import sonia.scm.NotFoundException;
 import sonia.scm.repository.BrowserResult;
 import sonia.scm.repository.FileObject;
 import sonia.scm.repository.GitConstants;
-import sonia.scm.repository.RevisionNotFoundException;
 
 import java.io.IOException;
-import java.util.List;
+import java.util.Collection;
 
+import static org.assertj.core.api.Assertions.assertThat;
 import static org.junit.Assert.assertEquals;
 import static org.junit.Assert.assertFalse;
 import static org.junit.Assert.assertNotNull;
 import static org.junit.Assert.assertTrue;
 
-//~--- JDK imports ------------------------------------------------------------
-
 /**
  * Unit tests for {@link GitBrowseCommand}.
- * 
+ *
  * @author Sebastian Sdorra
  */
-public class GitBrowseCommandTest extends AbstractGitCommandTestBase
-{
-  
-  /**
-   * Test browse command with default branch.
-   */
+public class GitBrowseCommandTest extends AbstractGitCommandTestBase {
+
   @Test
-  public void testDefaultBranch() throws IOException, RevisionNotFoundException {
-    // without default branch, the repository head should be used
-    BrowserResult result = createCommand().getBrowserResult(new BrowseCommandRequest());
-    assertNotNull(result);
-
-    List foList = result.getFiles(); 
-    assertNotNull(foList);
-    assertFalse(foList.isEmpty());
-    assertEquals(4, foList.size());
-    
-    assertEquals("a.txt", foList.get(0).getName());
-    assertEquals("b.txt", foList.get(1).getName());
-    assertEquals("c", foList.get(2).getName());
-    assertEquals("f.txt", foList.get(3).getName());
-    
-    // set default branch and fetch again
-    repository.setProperty(GitConstants.PROPERTY_DEFAULT_BRANCH, "test-branch");
-    result = createCommand().getBrowserResult(new BrowseCommandRequest());
-    assertNotNull(result);
-
-    foList = result.getFiles(); 
-    assertNotNull(foList);
-    assertFalse(foList.isEmpty());
-    assertEquals(2, foList.size());
-    
-    assertEquals("a.txt", foList.get(0).getName());
-    assertEquals("c", foList.get(1).getName());
+  public void testGetFile() throws IOException, NotFoundException {
+    BrowseCommandRequest request = new BrowseCommandRequest();
+    request.setPath("a.txt");
+    BrowserResult result = createCommand().getBrowserResult(request);
+    FileObject fileObject = result.getFile();
+    assertEquals("a.txt", fileObject.getName());
   }
 
   @Test
-  public void testBrowse() throws IOException, RevisionNotFoundException {
-    BrowserResult result =
-      createCommand().getBrowserResult(new BrowseCommandRequest());
-
-    assertNotNull(result);
-
-    List foList = result.getFiles();
+  public void testDefaultDefaultBranch() throws IOException, NotFoundException {
+    // without default branch, the repository head should be used
+    FileObject root = createCommand().getBrowserResult(new BrowseCommandRequest()).getFile();
+    assertNotNull(root);
 
+    Collection foList = root.getChildren();
     assertNotNull(foList);
     assertFalse(foList.isEmpty());
-    assertEquals(4, foList.size());
 
-    FileObject a = null;
-    FileObject c = null;
+    assertThat(foList)
+      .extracting("name")
+      .containsExactly("a.txt", "b.txt", "c", "f.txt");
+  }
 
-    for (FileObject f : foList)
-    {
-      if ("a.txt".equals(f.getName()))
-      {
-        a = f;
-      }
-      else if ("c".equals(f.getName()))
-      {
-        c = f;
-      }
-    }
+  @Test
+  public void testExplicitDefaultBranch() throws IOException, NotFoundException {
+    repository.setProperty(GitConstants.PROPERTY_DEFAULT_BRANCH, "test-branch");
+
+    FileObject root = createCommand().getBrowserResult(new BrowseCommandRequest()).getFile();
+    assertNotNull(root);
+
+    Collection foList = root.getChildren();
+    assertThat(foList)
+      .extracting("name")
+      .containsExactly("a.txt", "c");
+  }
+
+  @Test
+  public void testBrowse() throws IOException, NotFoundException {
+    FileObject root = createCommand().getBrowserResult(new BrowseCommandRequest()).getFile();
+    assertNotNull(root);
+
+    Collection foList = root.getChildren();
+
+    FileObject a = findFile(foList, "a.txt");
+    FileObject c = findFile(foList, "c");
 
-    assertNotNull(a);
     assertFalse(a.isDirectory());
     assertEquals("a.txt", a.getName());
     assertEquals("a.txt", a.getPath());
     assertEquals("added new line for blame", a.getDescription());
     assertTrue(a.getLength() > 0);
     checkDate(a.getLastModified());
-    assertNotNull(c);
+
     assertTrue(c.isDirectory());
     assertEquals("c", c.getName());
     assertEquals("c", c.getPath());
   }
 
   @Test
-  public void testBrowseSubDirectory() throws IOException, RevisionNotFoundException {
+  public void testBrowseSubDirectory() throws IOException, NotFoundException {
     BrowseCommandRequest request = new BrowseCommandRequest();
 
     request.setPath("c");
 
-    BrowserResult result = createCommand().getBrowserResult(request);
+    FileObject root = createCommand().getBrowserResult(request).getFile();
 
-    assertNotNull(result);
+    Collection foList = root.getChildren();
 
-    List foList = result.getFiles();
+    assertThat(foList).hasSize(2);
 
-    assertNotNull(foList);
-    assertFalse(foList.isEmpty());
-    assertEquals(2, foList.size());
+    FileObject d = findFile(foList, "d.txt");
+    FileObject e = findFile(foList, "e.txt");
 
-    FileObject d = null;
-    FileObject e = null;
-
-    for (FileObject f : foList)
-    {
-      if ("d.txt".equals(f.getName()))
-      {
-        d = f;
-      }
-      else if ("e.txt".equals(f.getName()))
-      {
-        e = f;
-      }
-    }
-
-    assertNotNull(d);
     assertFalse(d.isDirectory());
     assertEquals("d.txt", d.getName());
     assertEquals("c/d.txt", d.getPath());
     assertEquals("added file d and e in folder c", d.getDescription());
     assertTrue(d.getLength() > 0);
     checkDate(d.getLastModified());
-    assertNotNull(e);
+
     assertFalse(e.isDirectory());
     assertEquals("e.txt", e.getName());
     assertEquals("c/e.txt", e.getPath());
@@ -181,30 +143,35 @@ public class GitBrowseCommandTest extends AbstractGitCommandTestBase
   }
 
   @Test
-  public void testRecusive() throws IOException, RevisionNotFoundException {
+  public void testRecursive() throws IOException, NotFoundException {
     BrowseCommandRequest request = new BrowseCommandRequest();
 
     request.setRecursive(true);
 
-    BrowserResult result = createCommand().getBrowserResult(request);
+    FileObject root = createCommand().getBrowserResult(request).getFile();
 
-    assertNotNull(result);
+    Collection foList = root.getChildren();
 
-    List foList = result.getFiles();
+    assertThat(foList)
+      .extracting("name")
+      .containsExactly("a.txt", "b.txt", "c", "f.txt");
 
-    assertNotNull(foList);
-    assertFalse(foList.isEmpty());
-    assertEquals(5, foList.size());
+    FileObject c = findFile(foList, "c");
+
+    Collection cChildren = c.getChildren();
+    assertThat(cChildren)
+      .extracting("name")
+      .containsExactly("d.txt", "e.txt");
   }
 
-  /**
-   * Method description
-   *
-   *
-   * @return
-   */
-  private GitBrowseCommand createCommand()
-  {
+  private FileObject findFile(Collection foList, String name) {
+    return foList.stream()
+      .filter(f -> name.equals(f.getName()))
+      .findFirst()
+      .orElseThrow(() -> new AssertionError("file " + name + " not found"));
+  }
+
+  private GitBrowseCommand createCommand() {
     return new GitBrowseCommand(createContext(), repository);
   }
 }
diff --git a/scm-plugins/scm-git-plugin/src/test/java/sonia/scm/repository/spi/GitLogCommandTest.java b/scm-plugins/scm-git-plugin/src/test/java/sonia/scm/repository/spi/GitLogCommandTest.java
index d6e6ac98d8..78db8ae686 100644
--- a/scm-plugins/scm-git-plugin/src/test/java/sonia/scm/repository/spi/GitLogCommandTest.java
+++ b/scm-plugins/scm-git-plugin/src/test/java/sonia/scm/repository/spi/GitLogCommandTest.java
@@ -1,3 +1,4 @@
+
 /**
  * Copyright (c) 2010, Sebastian Sdorra
  * All rights reserved.
@@ -33,14 +34,17 @@
 
 package sonia.scm.repository.spi;
 
-//~--- non-JDK imports --------------------------------------------------------
-
+import com.google.common.io.Files;
 import org.junit.Test;
 import sonia.scm.repository.Changeset;
 import sonia.scm.repository.ChangesetPagingResult;
 import sonia.scm.repository.GitConstants;
 import sonia.scm.repository.Modifications;
 
+import java.io.File;
+import java.io.IOException;
+
+import static java.nio.charset.Charset.defaultCharset;
 import static org.hamcrest.Matchers.contains;
 import static org.junit.Assert.assertEquals;
 import static org.junit.Assert.assertFalse;
@@ -48,8 +52,6 @@ import static org.junit.Assert.assertNotNull;
 import static org.junit.Assert.assertThat;
 import static org.junit.Assert.assertTrue;
 
-//~--- JDK imports ------------------------------------------------------------
-
 /**
  * Unit tests for {@link GitLogCommand}.
  *
@@ -72,6 +74,8 @@ public class GitLogCommandTest extends AbstractGitCommandTestBase
     assertEquals("86a6645eceefe8b9a247db5eb16e3d89a7e6e6d1", result.getChangesets().get(1).getId());
     assertEquals("592d797cd36432e591416e8b2b98154f4f163411", result.getChangesets().get(2).getId());
     assertEquals("435df2f061add3589cb326cc64be9b9c3897ceca", result.getChangesets().get(3).getId());
+    assertEquals("master", result.getBranchName());
+    assertTrue(result.getChangesets().stream().allMatch(r -> r.getBranches().isEmpty()));
 
     // set default branch and fetch again
     repository.setProperty(GitConstants.PROPERTY_DEFAULT_BRANCH, "test-branch");
@@ -79,10 +83,12 @@ public class GitLogCommandTest extends AbstractGitCommandTestBase
     result = createCommand().getChangesets(new LogCommandRequest());
 
     assertNotNull(result);
+    assertEquals("test-branch", result.getBranchName());
     assertEquals(3, result.getTotal());
     assertEquals("3f76a12f08a6ba0dc988c68b7f0b2cd190efc3c4", result.getChangesets().get(0).getId());
     assertEquals("592d797cd36432e591416e8b2b98154f4f163411", result.getChangesets().get(1).getId());
     assertEquals("435df2f061add3589cb326cc64be9b9c3897ceca", result.getChangesets().get(2).getId());
+    assertTrue(result.getChangesets().stream().allMatch(r -> r.getBranches().isEmpty()));
   }
 
   @Test
@@ -210,6 +216,32 @@ public class GitLogCommandTest extends AbstractGitCommandTestBase
     assertEquals("435df2f061add3589cb326cc64be9b9c3897ceca", c2.getId());
   }
 
+  @Test
+  public void shouldFindDefaultBranchFromHEAD() throws Exception {
+    setRepositoryHeadReference("ref: refs/heads/test-branch");
+
+    ChangesetPagingResult changesets = createCommand().getChangesets(new LogCommandRequest());
+
+    assertEquals("test-branch", changesets.getBranchName());
+  }
+
+  @Test
+  public void shouldFindMasterBranchWhenHEADisNoRef() throws Exception {
+    setRepositoryHeadReference("592d797cd36432e591416e8b2b98154f4f163411");
+
+    ChangesetPagingResult changesets = createCommand().getChangesets(new LogCommandRequest());
+
+    assertEquals("master", changesets.getBranchName());
+  }
+
+  private void setRepositoryHeadReference(String s) throws IOException {
+    Files.write(s, repositoryHeadReferenceFile(), defaultCharset());
+  }
+
+  private File repositoryHeadReferenceFile() {
+    return new File(repositoryDirectory, "HEAD");
+  }
+
   private GitLogCommand createCommand()
   {
     return new GitLogCommand(createContext(), repository);
diff --git a/scm-plugins/scm-git-plugin/yarn.lock b/scm-plugins/scm-git-plugin/yarn.lock
index 702e28711f..c1abfd6640 100644
--- a/scm-plugins/scm-git-plugin/yarn.lock
+++ b/scm-plugins/scm-git-plugin/yarn.lock
@@ -707,9 +707,9 @@
   version "0.0.2"
   resolved "https://registry.yarnpkg.com/@scm-manager/eslint-config/-/eslint-config-0.0.2.tgz#94cc8c3fb4f51f870b235893dc134fc6c423ae85"
 
-"@scm-manager/ui-bundler@^0.0.15":
-  version "0.0.15"
-  resolved "https://registry.yarnpkg.com/@scm-manager/ui-bundler/-/ui-bundler-0.0.15.tgz#8ed4a557d5ae38d6b99493b29608fd6a4c9cd917"
+"@scm-manager/ui-bundler@^0.0.17":
+  version "0.0.17"
+  resolved "https://registry.yarnpkg.com/@scm-manager/ui-bundler/-/ui-bundler-0.0.17.tgz#949b90ca57e4268be28fcf4975bd9622f60278bb"
   dependencies:
     "@babel/core" "^7.0.0"
     "@babel/plugin-proposal-class-properties" "^7.0.0"
@@ -726,7 +726,6 @@
     browserify-css "^0.14.0"
     colors "^1.3.1"
     commander "^2.17.1"
-    connect-history-api-fallback "^1.5.0"
     eslint "^5.4.0"
     eslint-config-react-app "^2.1.0"
     eslint-plugin-flowtype "^2.50.0"
diff --git a/scm-plugins/scm-hg-plugin/package.json b/scm-plugins/scm-hg-plugin/package.json
index c5907d38bc..a2ebd0e6ad 100644
--- a/scm-plugins/scm-hg-plugin/package.json
+++ b/scm-plugins/scm-hg-plugin/package.json
@@ -9,6 +9,6 @@
     "@scm-manager/ui-extensions": "^0.0.7"
   },
   "devDependencies": {
-    "@scm-manager/ui-bundler": "^0.0.15"
+    "@scm-manager/ui-bundler": "^0.0.17"
   }
 }
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/HgLogCommand.java b/scm-plugins/scm-hg-plugin/src/main/java/sonia/scm/repository/spi/HgLogCommand.java
index 68d6913962..e9de7f7471 100644
--- a/scm-plugins/scm-hg-plugin/src/main/java/sonia/scm/repository/spi/HgLogCommand.java
+++ b/scm-plugins/scm-hg-plugin/src/main/java/sonia/scm/repository/spi/HgLogCommand.java
@@ -132,7 +132,11 @@ public class HgLogCommand extends AbstractCommand implements LogCommand
         List changesets = on(repository).rev(start + ":"
                                        + end).execute();
 
-        result = new ChangesetPagingResult(total, changesets);
+        if (request.getBranch() == null) {
+          result = new ChangesetPagingResult(total, changesets);
+        } else {
+          result = new ChangesetPagingResult(total, changesets, request.getBranch());
+        }
       }
       else
       {
diff --git a/scm-plugins/scm-hg-plugin/src/main/java/sonia/scm/repository/spi/javahg/AbstractChangesetCommand.java b/scm-plugins/scm-hg-plugin/src/main/java/sonia/scm/repository/spi/javahg/AbstractChangesetCommand.java
index 6466eb6d11..89164a8d80 100644
--- a/scm-plugins/scm-hg-plugin/src/main/java/sonia/scm/repository/spi/javahg/AbstractChangesetCommand.java
+++ b/scm-plugins/scm-hg-plugin/src/main/java/sonia/scm/repository/spi/javahg/AbstractChangesetCommand.java
@@ -216,10 +216,7 @@ public abstract class AbstractChangesetCommand extends AbstractCommand
 
     String branch = in.textUpTo('\n');
 
-    if (!BRANCH_DEFAULT.equals(branch))
-    {
-      changeset.getBranches().add(branch);
-    }
+    changeset.getBranches().add(branch);
 
     String p1 = readId(in, changeset, PROPERTY_PARENT1_REVISION);
 
diff --git a/scm-plugins/scm-hg-plugin/src/main/java/sonia/scm/repository/spi/javahg/HgFileviewCommand.java b/scm-plugins/scm-hg-plugin/src/main/java/sonia/scm/repository/spi/javahg/HgFileviewCommand.java
index 74695217d2..f351ffa572 100644
--- a/scm-plugins/scm-hg-plugin/src/main/java/sonia/scm/repository/spi/javahg/HgFileviewCommand.java
+++ b/scm-plugins/scm-hg-plugin/src/main/java/sonia/scm/repository/spi/javahg/HgFileviewCommand.java
@@ -50,35 +50,31 @@ import sonia.scm.repository.SubRepository;
 
 import java.io.IOException;
 
+import java.util.Deque;
+import java.util.LinkedList;
 import java.util.List;
 
 /**
+ * Mercurial command to list files of a repository.
  *
  * @author Sebastian Sdorra
  */
 public class HgFileviewCommand extends AbstractCommand
 {
 
-  /**
-   * Constructs ...
-   *
-   *
-   * @param repository
-   */
-  public HgFileviewCommand(Repository repository)
+  private boolean disableLastCommit = false;
+
+  private HgFileviewCommand(Repository repository)
   {
     super(repository);
   }
 
-  //~--- methods --------------------------------------------------------------
-
   /**
-   * Method description
+   * Create command for the given repository.
    *
+   * @param repository repository
    *
-   * @param repository
-   *
-   * @return
+   * @return fileview command
    */
   public static HgFileviewCommand on(Repository repository)
   {
@@ -86,13 +82,11 @@ public class HgFileviewCommand extends AbstractCommand
   }
 
   /**
-   * Method description
+   * Disable last commit fetching for file objects.
    *
-   *
-   * @return
+   * @return {@code this}
    */
-  public HgFileviewCommand disableLastCommit()
-  {
+  public HgFileviewCommand disableLastCommit() {
     disableLastCommit = true;
     cmdAppend("-d");
 
@@ -100,132 +94,128 @@ public class HgFileviewCommand extends AbstractCommand
   }
 
   /**
-   * Method description
+   * Disables sub repository detection
    *
-   *
-   * @return
+   * @return {@code this}
    */
-  public HgFileviewCommand disableSubRepositoryDetection()
-  {
+  public HgFileviewCommand disableSubRepositoryDetection() {
     cmdAppend("-s");
 
     return this;
   }
 
   /**
-   * Method description
+   * Start file object fetching at the given path.
    *
    *
-   * @return
+   * @param path path to start fetching
    *
-   * @throws IOException
+   * @return {@code this}
    */
-  public List execute() throws IOException
-  {
-    cmdAppend("-t");
-
-    List files = Lists.newArrayList();
-
-    HgInputStream stream = launchStream();
-
-    while (stream.peek() != -1)
-    {
-      FileObject file = null;
-      char type = (char) stream.read();
-
-      if (type == 'd')
-      {
-        file = readDirectory(stream);
-      }
-      else if (type == 'f')
-      {
-        file = readFile(stream);
-      }
-      else if (type == 's')
-      {
-        file = readSubRepository(stream);
-      }
-
-      if (file != null)
-      {
-        files.add(file);
-      }
-    }
-
-    return files;
-  }
-
-  /**
-   * Method description
-   *
-   *
-   * @param path
-   *
-   * @return
-   */
-  public HgFileviewCommand path(String path)
-  {
+  public HgFileviewCommand path(String path) {
     cmdAppend("-p", path);
 
     return this;
   }
 
   /**
-   * Method description
+   * Fetch file objects recursive.
    *
    *
-   * @return
+   * @return {@code this}
    */
-  public HgFileviewCommand recursive()
-  {
+  public HgFileviewCommand recursive() {
     cmdAppend("-c");
 
     return this;
   }
 
   /**
-   * Method description
+   * Use given revision for file view.
    *
+   * @param revision revision id, hash, tag or branch
    *
-   * @param revision
-   *
-   * @return
+   * @return {@code this}
    */
-  public HgFileviewCommand rev(String revision)
-  {
+  public HgFileviewCommand rev(String revision) {
     cmdAppend("-r", revision);
 
     return this;
   }
 
-  //~--- get methods ----------------------------------------------------------
-
   /**
-   * Method description
+   * Executes the mercurial command and parses the output.
    *
-   *
-   * @return
-   */
-  @Override
-  public String getCommandName()
-  {
-    return HgFileviewExtension.NAME;
-  }
-
-  //~--- methods --------------------------------------------------------------
-
-  /**
-   * Method description
-   *
-   *
-   * @param stream
-   *
-   * @return
+   * @return file object
    *
    * @throws IOException
    */
-  private FileObject readDirectory(HgInputStream stream) throws IOException
+  public FileObject execute() throws IOException
   {
+    cmdAppend("-t");
+
+    Deque stack = new LinkedList<>();
+
+    HgInputStream stream = launchStream();
+
+    FileObject last = null;
+    while (stream.peek() != -1) {
+      FileObject file = read(stream);
+
+      while (!stack.isEmpty()) {
+        FileObject current = stack.peek();
+        if (isParent(current, file)) {
+          current.addChild(file);
+          break;
+        } else {
+          stack.pop();
+        }
+      }
+
+      if (file.isDirectory()) {
+        stack.push(file);
+      }
+      last = file;
+    }
+
+    if (stack.isEmpty()) {
+      // if the stack is empty, the requested path is probably a file
+      return last;
+    } else {
+      // if the stack is not empty, the requested path is a directory
+      return stack.getLast();
+    }
+  }
+
+  private FileObject read(HgInputStream stream) throws IOException {
+    char type = (char) stream.read();
+
+    FileObject file;
+    switch (type) {
+      case 'd':
+        file = readDirectory(stream);
+        break;
+      case 'f':
+        file = readFile(stream);
+        break;
+      case 's':
+        file = readSubRepository(stream);
+        break;
+      default:
+        throw new IOException("unknown file object type: " + type);
+    }
+    return file;
+  }
+
+  private boolean isParent(FileObject parent, FileObject child) {
+    String parentPath = parent.getPath();
+    if (parentPath.equals("")) {
+      return true;
+    }
+    return child.getParentPath().equals(parentPath);
+  }
+
+  private FileObject readDirectory(HgInputStream stream) throws IOException {
     FileObject directory = new FileObject();
     String path = removeTrailingSlash(stream.textUpTo('\0'));
 
@@ -236,18 +226,7 @@ public class HgFileviewCommand extends AbstractCommand
     return directory;
   }
 
-  /**
-   * Method description
-   *
-   *
-   * @param stream
-   *
-   * @return
-   *
-   * @throws IOException
-   */
-  private FileObject readFile(HgInputStream stream) throws IOException
-  {
+  private FileObject readFile(HgInputStream stream) throws IOException {
     FileObject file = new FileObject();
     String path = removeTrailingSlash(stream.textUpTo('\n'));
 
@@ -259,8 +238,7 @@ public class HgFileviewCommand extends AbstractCommand
     DateTime timestamp = stream.dateTimeUpTo(' ');
     String description = stream.textUpTo('\0');
 
-    if (!disableLastCommit)
-    {
+    if (!disableLastCommit) {
       file.setLastModified(timestamp.getDate().getTime());
       file.setDescription(description);
     }
@@ -268,18 +246,7 @@ public class HgFileviewCommand extends AbstractCommand
     return file;
   }
 
-  /**
-   * Method description
-   *
-   *
-   * @param stream
-   *
-   * @return
-   *
-   * @throws IOException
-   */
-  private FileObject readSubRepository(HgInputStream stream) throws IOException
-  {
+  private FileObject readSubRepository(HgInputStream stream) throws IOException {
     FileObject directory = new FileObject();
     String path = removeTrailingSlash(stream.textUpTo('\n'));
 
@@ -292,8 +259,7 @@ public class HgFileviewCommand extends AbstractCommand
 
     SubRepository subRepository = new SubRepository(url);
 
-    if (!Strings.isNullOrEmpty(revision))
-    {
+    if (!Strings.isNullOrEmpty(revision)) {
       subRepository.setRevision(revision);
     }
 
@@ -302,48 +268,33 @@ public class HgFileviewCommand extends AbstractCommand
     return directory;
   }
 
-  /**
-   * Method description
-   *
-   *
-   * @param path
-   *
-   * @return
-   */
-  private String removeTrailingSlash(String path)
-  {
-    if (path.endsWith("/"))
-    {
+  private String removeTrailingSlash(String path) {
+    if (path.endsWith("/")) {
       path = path.substring(0, path.length() - 1);
     }
 
     return path;
   }
 
-  //~--- get methods ----------------------------------------------------------
-
-  /**
-   * Method description
-   *
-   *
-   * @param path
-   *
-   * @return
-   */
-  private String getNameFromPath(String path)
-  {
+  private String getNameFromPath(String path) {
     int index = path.lastIndexOf('/');
 
-    if (index > 0)
-    {
+    if (index > 0) {
       path = path.substring(index + 1);
     }
 
     return path;
   }
 
-  //~--- fields ---------------------------------------------------------------
+  /**
+   * Returns the name of the mercurial command.
+   *
+   * @return command name
+   */
+  @Override
+  public String getCommandName()
+  {
+    return HgFileviewExtension.NAME;
+  }
 
-  /** Field description */
-  private boolean disableLastCommit = false;
 }
diff --git a/scm-plugins/scm-hg-plugin/src/main/js/ProtocolInformation.js b/scm-plugins/scm-hg-plugin/src/main/js/ProtocolInformation.js
index 03fc41450a..a8ae91cdfb 100644
--- a/scm-plugins/scm-hg-plugin/src/main/js/ProtocolInformation.js
+++ b/scm-plugins/scm-hg-plugin/src/main/js/ProtocolInformation.js
@@ -2,26 +2,28 @@
 import React from "react";
 import { repositories } from "@scm-manager/ui-components";
 import type { Repository } from "@scm-manager/ui-types";
+import { translate } from "react-i18next";
 
 type Props = {
-  repository: Repository
+  repository: Repository,
+  t: string => string
 }
 
 class ProtocolInformation extends React.Component {
 
   render() {
-    const { repository } = this.props;
+    const { repository, t } = this.props;
     const href = repositories.getProtocolLinkByType(repository, "http");
     if (!href) {
       return null;
     }
     return (
       
-

Clone the repository

+

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

           hg clone {href}
         
-

Create a new repository

+

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

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

Push an existing repository

+

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

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

Checkout the repository

+

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

           svn checkout {href}
         
@@ -27,4 +29,4 @@ class ProtocolInformation extends React.Component { } -export default ProtocolInformation; +export default translate("plugins")(ProtocolInformation); diff --git a/scm-plugins/scm-svn-plugin/src/main/resources/locales/de/plugins.json b/scm-plugins/scm-svn-plugin/src/main/resources/locales/de/plugins.json new file mode 100644 index 0000000000..7c58498ef1 --- /dev/null +++ b/scm-plugins/scm-svn-plugin/src/main/resources/locales/de/plugins.json @@ -0,0 +1,7 @@ +{ + "scm-svn-plugin": { + "information": { + "checkout" : "Repository auschecken" + } + } +} diff --git a/scm-plugins/scm-svn-plugin/src/main/resources/locales/en/plugins.json b/scm-plugins/scm-svn-plugin/src/main/resources/locales/en/plugins.json new file mode 100644 index 0000000000..07b34baf10 --- /dev/null +++ b/scm-plugins/scm-svn-plugin/src/main/resources/locales/en/plugins.json @@ -0,0 +1,7 @@ +{ + "scm-svn-plugin": { + "information": { + "checkout" : "Checkout repository" + } + } +} diff --git a/scm-plugins/scm-svn-plugin/src/test/java/sonia/scm/repository/spi/SvnBrowseCommandTest.java b/scm-plugins/scm-svn-plugin/src/test/java/sonia/scm/repository/spi/SvnBrowseCommandTest.java index c4c658ea7a..bcf5d2ec55 100644 --- a/scm-plugins/scm-svn-plugin/src/test/java/sonia/scm/repository/spi/SvnBrowseCommandTest.java +++ b/scm-plugins/scm-svn-plugin/src/test/java/sonia/scm/repository/spi/SvnBrowseCommandTest.java @@ -33,15 +33,13 @@ package sonia.scm.repository.spi; -//~--- non-JDK imports -------------------------------------------------------- - import org.junit.Test; import sonia.scm.repository.BrowserResult; import sonia.scm.repository.FileObject; import sonia.scm.repository.RevisionNotFoundException; import java.io.IOException; -import java.util.List; +import java.util.Collection; import static org.junit.Assert.assertEquals; import static org.junit.Assert.assertFalse; @@ -49,8 +47,6 @@ import static org.junit.Assert.assertNotNull; import static org.junit.Assert.assertNull; import static org.junit.Assert.assertTrue; -//~--- JDK imports ------------------------------------------------------------ - /** * * @author Sebastian Sdorra @@ -58,9 +54,19 @@ import static org.junit.Assert.assertTrue; public class SvnBrowseCommandTest extends AbstractSvnCommandTestBase { + @Test + public void testBrowseWithFilePath() throws RevisionNotFoundException { + BrowseCommandRequest request = new BrowseCommandRequest(); + request.setPath("a.txt"); + FileObject file = createCommand().getBrowserResult(request).getFile(); + assertEquals("a.txt", file.getName()); + assertFalse(file.isDirectory()); + assertTrue(file.getChildren().isEmpty()); + } + @Test public void testBrowse() throws RevisionNotFoundException { - List foList = getRootFromTip(new BrowseCommandRequest()); + Collection foList = getRootFromTip(new BrowseCommandRequest()); FileObject a = getFileObject(foList, "a.txt"); FileObject c = getFileObject(foList, "c"); @@ -92,7 +98,7 @@ public class SvnBrowseCommandTest extends AbstractSvnCommandTestBase assertNotNull(result); - List foList = result.getFiles(); + Collection foList = result.getFile().getChildren(); assertNotNull(foList); assertFalse(foList.isEmpty()); @@ -135,7 +141,7 @@ public class SvnBrowseCommandTest extends AbstractSvnCommandTestBase request.setDisableLastCommit(true); - List foList = getRootFromTip(request); + Collection foList = getRootFromTip(request); FileObject a = getFileObject(foList, "a.txt"); @@ -151,15 +157,16 @@ public class SvnBrowseCommandTest extends AbstractSvnCommandTestBase assertNotNull(result); - List foList = result.getFiles(); + Collection foList = result.getFile().getChildren(); assertNotNull(foList); assertFalse(foList.isEmpty()); - assertEquals(4, foList.size()); - - for ( FileObject fo : foList ){ - System.out.println(fo); - } + assertEquals(2, foList.size()); + + FileObject c = getFileObject(foList, "c"); + assertEquals("c", c.getName()); + assertTrue(c.isDirectory()); + assertEquals(2, c.getChildren().size()); } /** @@ -184,31 +191,20 @@ public class SvnBrowseCommandTest extends AbstractSvnCommandTestBase * * @return */ - private FileObject getFileObject(List foList, String name) + private FileObject getFileObject(Collection foList, String name) { - FileObject a = null; - - for (FileObject f : foList) - { - if (name.equals(f.getName())) - { - a = f; - - break; - } - } - - assertNotNull(a); - - return a; + return foList.stream() + .filter(f -> name.equals(f.getName())) + .findFirst() + .orElseThrow(() -> new AssertionError("file " + name + " not found")); } - private List getRootFromTip(BrowseCommandRequest request) throws RevisionNotFoundException { + private Collection getRootFromTip(BrowseCommandRequest request) throws RevisionNotFoundException { BrowserResult result = createCommand().getBrowserResult(request); assertNotNull(result); - List foList = result.getFiles(); + Collection foList = result.getFile().getChildren(); assertNotNull(foList); assertFalse(foList.isEmpty()); diff --git a/scm-plugins/scm-svn-plugin/yarn.lock b/scm-plugins/scm-svn-plugin/yarn.lock index 8822bd5e57..c3e8cc476f 100644 --- a/scm-plugins/scm-svn-plugin/yarn.lock +++ b/scm-plugins/scm-svn-plugin/yarn.lock @@ -641,9 +641,9 @@ version "0.0.2" resolved "https://registry.yarnpkg.com/@scm-manager/eslint-config/-/eslint-config-0.0.2.tgz#94cc8c3fb4f51f870b235893dc134fc6c423ae85" -"@scm-manager/ui-bundler@^0.0.15": - version "0.0.15" - resolved "https://registry.yarnpkg.com/@scm-manager/ui-bundler/-/ui-bundler-0.0.15.tgz#8ed4a557d5ae38d6b99493b29608fd6a4c9cd917" +"@scm-manager/ui-bundler@^0.0.17": + version "0.0.17" + resolved "https://registry.yarnpkg.com/@scm-manager/ui-bundler/-/ui-bundler-0.0.17.tgz#949b90ca57e4268be28fcf4975bd9622f60278bb" dependencies: "@babel/core" "^7.0.0" "@babel/plugin-proposal-class-properties" "^7.0.0" @@ -660,7 +660,6 @@ browserify-css "^0.14.0" colors "^1.3.1" commander "^2.17.1" - connect-history-api-fallback "^1.5.0" eslint "^5.4.0" eslint-config-react-app "^2.1.0" eslint-plugin-flowtype "^2.50.0" diff --git a/scm-test/src/main/java/sonia/scm/user/UserManagerTestBase.java b/scm-test/src/main/java/sonia/scm/user/UserManagerTestBase.java index 9f1c50c797..dddce344ce 100644 --- a/scm-test/src/main/java/sonia/scm/user/UserManagerTestBase.java +++ b/scm-test/src/main/java/sonia/scm/user/UserManagerTestBase.java @@ -196,7 +196,7 @@ public abstract class UserManagerTestBase extends ManagerTestBase { } @Test(expected = NotFoundException.class) - public void testModifyNotExisting() throws NotFoundException, ConcurrentModificationException { + public void testModifyNotExisting() { manager.modify(UserTestData.createZaphod()); } @@ -249,7 +249,7 @@ public abstract class UserManagerTestBase extends ManagerTestBase { } @Test(expected = NotFoundException.class) - public void testRefreshNotFound() throws NotFoundException { + public void testRefreshNotFound(){ manager.refresh(UserTestData.createDent()); } diff --git a/scm-ui-components/package.json b/scm-ui-components/package.json index 73c20625bd..527acbd9dc 100644 --- a/scm-ui-components/package.json +++ b/scm-ui-components/package.json @@ -5,7 +5,7 @@ "scripts": { "bootstrap": "lerna bootstrap", "link": "lerna exec -- yarn link", - "unlink": "lerna exec --no-bail -- yarn unlink" + "unlink": "lerna exec --no-bail -- yarn unlink || true" }, "devDependencies": { "lerna": "^3.2.1" diff --git a/scm-ui-components/packages/ui-components/package.json b/scm-ui-components/packages/ui-components/package.json index e515002728..096a8636b3 100644 --- a/scm-ui-components/packages/ui-components/package.json +++ b/scm-ui-components/packages/ui-components/package.json @@ -12,20 +12,21 @@ "eslint-fix": "eslint src --fix" }, "devDependencies": { - "@scm-manager/ui-bundler": "^0.0.15", + "@scm-manager/ui-bundler": "^0.0.17", "create-index": "^2.3.0", "enzyme": "^3.5.0", "enzyme-adapter-react-16": "^1.3.1", "flow-bin": "^0.79.1", "flow-typed": "^2.5.1", "jest": "^23.5.0", - "raf": "^3.4.0" + "raf": "^3.4.0", + "react-router-enzyme-context": "^1.2.0" }, "dependencies": { "classnames": "^2.2.6", "moment": "^2.22.2", - "react": "^16.4.2", - "react-dom": "^16.4.2", + "react": "^16.5.2", + "react-dom": "^16.5.2", "react-i18next": "^7.11.0", "react-jss": "^8.6.1", "react-router-dom": "^4.3.1", diff --git a/scm-ui-components/packages/ui-components/src/Image.js b/scm-ui-components/packages/ui-components/src/Image.js index d46a32217f..5cb7fd6aa9 100644 --- a/scm-ui-components/packages/ui-components/src/Image.js +++ b/scm-ui-components/packages/ui-components/src/Image.js @@ -9,9 +9,18 @@ type Props = { }; class Image extends React.Component { + + createImageSrc = () => { + const { src } = this.props; + if (src.startsWith("http")) { + return src; + } + return withContextPath(src); + }; + render() { - const { src, alt, className } = this.props; - return {alt}; + const { alt, className } = this.props; + return {alt}; } } diff --git a/scm-ui-components/packages/ui-components/src/LinkPaginator.js b/scm-ui-components/packages/ui-components/src/LinkPaginator.js new file mode 100644 index 0000000000..aaf13d7b15 --- /dev/null +++ b/scm-ui-components/packages/ui-components/src/LinkPaginator.js @@ -0,0 +1,133 @@ +//@flow +import React from "react"; +import {translate} from "react-i18next"; +import type {PagedCollection} from "@scm-manager/ui-types"; +import {Button} from "./buttons"; + +type Props = { + collection: PagedCollection, + page: number, + + // context props + t: string => string +}; + +class LinkPaginator extends React.Component { + + renderFirstButton() { + return ( +