diff --git a/CHANGELOG.md b/CHANGELOG.md index 0ef29f3a73..a613576ac5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Extension point entries with supplied extensionName are sorted ascending - Possibility to configure git core config entries for jgit like core.trustfolderstat and core.supportsatomicfilecreation - Babel-plugin-styled-components for persistent generated classnames +- By default, only 100 files will be listed in source view in one request ### Changed - New footer design 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 8f1cf298de..e262177e19 100644 --- a/scm-core/src/main/java/sonia/scm/repository/FileObject.java +++ b/scm-core/src/main/java/sonia/scm/repository/FileObject.java @@ -87,7 +87,7 @@ public class FileObject implements LastModifiedAware, Serializable final FileObject other = (FileObject) obj; //J- - return Objects.equal(name, other.name) + return Objects.equal(name, other.name) && Objects.equal(path, other.path) && Objects.equal(directory, other.directory) && Objects.equal(description, other.description) @@ -282,6 +282,10 @@ public class FileObject implements LastModifiedAware, Serializable return computationAborted; } + public boolean isTruncated() { + return truncated; + } + //~--- set methods ---------------------------------------------------------- /** @@ -403,6 +407,10 @@ public class FileObject implements LastModifiedAware, Serializable this.children.add(child); } + public void setTruncated(boolean truncated) { + this.truncated = truncated; + } + //~--- fields --------------------------------------------------------------- /** file description */ @@ -435,4 +443,6 @@ public class FileObject implements LastModifiedAware, Serializable /** Children of this file (aka directory). */ private Collection children = new ArrayList<>(); + + private boolean truncated; } 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 563557f0c1..20c79e60dd 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 @@ -300,6 +300,35 @@ public final class BrowseCommandBuilder return this; } + /** + * Limit the number of result files to limit entries. By default this is set to + * {@value BrowseCommandRequest#DEFAULT_REQUEST_LIMIT}. Be aware that this parameter can have + * severe performance implications. Reading a repository with thousands of files in one folder + * can generate a huge load for a longer time. + * + * @param limit The maximal number of files this request shall return (directories are not counted). + * + * @since 2.0.0 + */ + public BrowseCommandBuilder setLimit(int limit) { + request.setLimit(limit); + return this; + } + + /** + * Proceed the list from the given number on (zero based). + * + * @param offset The number of the file, the result should start with (zero based). + * All preceding files will be omitted. Directories are not + * counted. Therefore directories are only listed in results without + * offset. + * @since 2.0.0 + */ + public BrowseCommandBuilder setOffset(int offset) { + request.setOffset(offset); + return this; + } + private void updateCache(BrowserResult updatedResult) { if (!disableCache) { CacheKey key = new CacheKey(repository, request); 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 ee37d6243e..899bda077b 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 @@ -38,6 +38,9 @@ package sonia.scm.repository.spi; import sonia.scm.repository.BrowserResult; import java.io.IOException; +import java.util.List; +import java.util.Locale; +import java.util.function.Function; //~--- JDK imports ------------------------------------------------------------ @@ -60,4 +63,16 @@ public interface BrowseCommand * @throws IOException */ BrowserResult getBrowserResult(BrowseCommandRequest request) throws IOException; + + default void sort(List entries, Function isDirectory, Function nameOf) { + entries.sort((e1, e2) -> { + if (isDirectory.apply(e1).equals(isDirectory.apply(e2))) { + return nameOf.apply(e1).compareTo(nameOf.apply(e2)); + } else if (isDirectory.apply(e1)) { + return -1; + } else { + return 1; + } + }); + } } diff --git a/scm-core/src/main/java/sonia/scm/repository/spi/BrowseCommandRequest.java b/scm-core/src/main/java/sonia/scm/repository/spi/BrowseCommandRequest.java index 9c23fe93f2..5e7524c958 100644 --- a/scm-core/src/main/java/sonia/scm/repository/spi/BrowseCommandRequest.java +++ b/scm-core/src/main/java/sonia/scm/repository/spi/BrowseCommandRequest.java @@ -49,8 +49,10 @@ import java.util.function.Consumer; public final class BrowseCommandRequest extends FileBaseCommandRequest { - /** Field description */ + public static final int DEFAULT_REQUEST_LIMIT = 100; + private static final long serialVersionUID = 7956624623516803183L; + private int offset; public BrowseCommandRequest() { this(null); @@ -110,10 +112,12 @@ public final class BrowseCommandRequest extends FileBaseCommandRequest final BrowseCommandRequest other = (BrowseCommandRequest) obj; - return super.equals(obj) && Objects.equal(recursive, other.recursive) + return super.equals(obj) + && Objects.equal(recursive, other.recursive) && Objects.equal(disableLastCommit, other.disableLastCommit) - && Objects.equal(disableSubRepositoryDetection, - other.disableSubRepositoryDetection); + && Objects.equal(disableSubRepositoryDetection, other.disableSubRepositoryDetection) + && Objects.equal(offset, other.offset) + && Objects.equal(limit, other.limit); } /** @@ -126,7 +130,7 @@ public final class BrowseCommandRequest extends FileBaseCommandRequest public int hashCode() { return Objects.hashCode(super.hashCode(), recursive, disableLastCommit, - disableSubRepositoryDetection); + disableSubRepositoryDetection, offset, limit); } /** @@ -145,6 +149,8 @@ public final class BrowseCommandRequest extends FileBaseCommandRequest .add("recursive", recursive) .add("disableLastCommit", disableLastCommit) .add("disableSubRepositoryDetection", disableSubRepositoryDetection) + .add("limit", limit) + .add("offset", offset) .toString(); //J+ } @@ -191,6 +197,28 @@ public final class BrowseCommandRequest extends FileBaseCommandRequest this.recursive = recursive; } + /** + * Limit the number of result files to limit entries. + * + * @param limit The maximal number of files this request shall return. + * + * @since 2.0.0 + */ + public void setLimit(int limit) { + this.limit = limit; + } + + /** + * Proceed the list from the given number on (zero based). + * + * @param offset The number of the entry, the result should start with (zero based). + * All preceding entries will be omitted. + * @since 2.0.0 + */ + public void setOffset(int offset) { + this.offset = offset; + } + //~--- get methods ---------------------------------------------------------- /** @@ -232,6 +260,24 @@ public final class BrowseCommandRequest extends FileBaseCommandRequest return recursive; } + /** + * Returns the limit for the number of result files. + * + * @since 2.0.0 + */ + public int getLimit() { + return limit; + } + + /** + * The number of the entry, the result start with. All preceding entries will be omitted. + * + * @since 2.0.0 + */ + public int getOffset() { + return offset; + } + public void updateCache(BrowserResult update) { if (updater != null) { updater.accept(update); @@ -249,6 +295,10 @@ public final class BrowseCommandRequest extends FileBaseCommandRequest /** browse file objects recursive */ private boolean recursive = false; + + /** Limit the number of result files to limit entries. */ + private int limit = DEFAULT_REQUEST_LIMIT; + // WARNING / TODO: This field creates a reverse channel from the implementation to the API. This will break // whenever the API runs in a different process than the SPI (for example to run explicit hosts for git repositories). private final transient Consumer updater; diff --git a/scm-core/src/test/java/sonia/scm/repository/spi/BrowseCommandTest.java b/scm-core/src/test/java/sonia/scm/repository/spi/BrowseCommandTest.java new file mode 100644 index 0000000000..1f1c9ffe20 --- /dev/null +++ b/scm-core/src/test/java/sonia/scm/repository/spi/BrowseCommandTest.java @@ -0,0 +1,70 @@ +package sonia.scm.repository.spi; + +import org.junit.jupiter.api.Test; +import sonia.scm.repository.BrowserResult; + +import java.io.IOException; +import java.util.List; + +import static java.util.Arrays.asList; +import static org.assertj.core.api.Assertions.assertThat; +import static sonia.scm.repository.spi.BrowseCommandTest.Entry.directory; +import static sonia.scm.repository.spi.BrowseCommandTest.Entry.file; + +class BrowseCommandTest implements BrowseCommand { + + @Test + void shouldSort() { + List entries = asList( + file("b.txt"), + file("a.txt"), + file("Dockerfile"), + file(".gitignore"), + directory("src"), + file("README") + ); + + sort(entries, Entry::isDirectory, Entry::getName); + + assertThat(entries).extracting("name") + .containsExactly( + "src", + ".gitignore", + "Dockerfile", + "README", + "a.txt", + "b.txt" + ); + } + + @Override + public BrowserResult getBrowserResult(BrowseCommandRequest request) throws IOException { + return null; + } + + static class Entry { + private final String name; + private final boolean directory; + + static Entry file(String name) { + return new Entry(name, false); + } + + static Entry directory(String name) { + return new Entry(name, true); + } + + public Entry(String name, boolean directory) { + this.name = name; + this.directory = directory; + } + + public String getName() { + return name; + } + + public boolean isDirectory() { + return directory; + } + } +} 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 a93c1b5d81..28f64e52cb 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 @@ -744,14 +744,13 @@ public final class GitUtil public static Optional getLfsPointer(org.eclipse.jgit.lib.Repository repo, String path, RevCommit commit, TreeWalk treeWalk) throws IOException { Attributes attributes = LfsFactory.getAttributesForPath(repo, path, commit); - - return getLfsPointer(repo, treeWalk, attributes); + ObjectId blobId = treeWalk.getObjectId(0); + return getLfsPointer(repo, blobId, attributes); } - public static Optional getLfsPointer(org.eclipse.jgit.lib.Repository repo, TreeWalk treeWalk, Attributes attributes) throws IOException { + public static Optional getLfsPointer(org.eclipse.jgit.lib.Repository repo, ObjectId blobId, Attributes attributes) throws IOException { Attribute filter = attributes.get("filter"); if (filter != null && "lfs".equals(filter.getValue())) { - ObjectId blobId = treeWalk.getObjectId(0); try (InputStream is = repo.open(blobId, Constants.OBJ_BLOB).openStream()) { return of(LfsPointer.parseLfsPointer(is)); } 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 e8ef5a7a33..b6d6e7ed6a 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 @@ -69,12 +69,15 @@ import sonia.scm.web.lfs.LfsBlobStoreFactory; import java.io.ByteArrayOutputStream; import java.io.IOException; +import java.util.ArrayList; import java.util.Collections; +import java.util.Iterator; import java.util.List; import java.util.Map; import java.util.Optional; import java.util.function.Consumer; +import static java.util.Collections.emptyList; import static java.util.Optional.empty; import static java.util.Optional.of; import static sonia.scm.ContextEntry.ContextBuilder.entity; @@ -110,6 +113,8 @@ public class GitBrowseCommand extends AbstractGitCommand private BrowserResult browserResult; + private int resultCount = 0; + public GitBrowseCommand(GitContext context, Repository repository, LfsBlobStoreFactory lfsBlobStoreFactory, SyncAsyncExecutor executor) { super(context, repository); this.lfsBlobStoreFactory = lfsBlobStoreFactory; @@ -151,18 +156,19 @@ public class GitBrowseCommand extends AbstractGitCommand fileObject.setName(""); fileObject.setPath(""); fileObject.setDirectory(true); + fileObject.setTruncated(false); return fileObject; } private FileObject createFileObject(org.eclipse.jgit.lib.Repository repo, - BrowseCommandRequest request, ObjectId revId, TreeWalk treeWalk) + BrowseCommandRequest request, ObjectId revId, TreeEntry treeEntry) throws IOException { FileObject file = new FileObject(); - String path = treeWalk.getPathString(); + String path = treeEntry.getPathString(); - file.setName(treeWalk.getNameString()); + file.setName(treeEntry.getNameString()); file.setPath(path); SubRepository sub = null; @@ -180,7 +186,7 @@ public class GitBrowseCommand extends AbstractGitCommand } else { - ObjectLoader loader = repo.open(treeWalk.getObjectId(0)); + ObjectLoader loader = repo.open(treeEntry.getObjectId()); file.setDirectory(loader.getType() == Constants.OBJ_TREE); @@ -192,7 +198,7 @@ public class GitBrowseCommand extends AbstractGitCommand try (RevWalk walk = new RevWalk(repo)) { commit = walk.parseCommit(revId); } - Optional lfsPointer = getLfsPointer(repo, path, commit, treeWalk); + Optional lfsPointer = getLfsPointer(repo, path, commit, treeEntry); if (lfsPointer.isPresent()) { setFileLengthFromLfsBlob(lfsPointer.get(), file); @@ -249,31 +255,58 @@ public class GitBrowseCommand extends AbstractGitCommand 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 { - List files = Lists.newArrayList(); - while (treeWalk.next()) - { + private void findChildren(FileObject parent, org.eclipse.jgit.lib.Repository repo, BrowseCommandRequest request, ObjectId revId, TreeWalk treeWalk) throws IOException { + TreeEntry entry = new TreeEntry(); + createTree(parent.getPath(), entry, repo, request, treeWalk); + convertToFileObject(parent, repo, request, revId, entry.getChildren()); + } - FileObject fileObject = createFileObject(repo, request, revId, treeWalk); - if (!fileObject.getPath().startsWith(parent.getPath())) { - parent.setChildren(files); - return fileObject; + private void convertToFileObject(FileObject parent, org.eclipse.jgit.lib.Repository repo, BrowseCommandRequest request, ObjectId revId, List entries) throws IOException { + List files = Lists.newArrayList(); + Iterator entryIterator = entries.iterator(); + boolean hasNext; + while ((hasNext = entryIterator.hasNext()) && resultCount < request.getLimit() + request.getOffset()) + { + TreeEntry entry = entryIterator.next(); + FileObject fileObject = createFileObject(repo, request, revId, entry); + + if (!fileObject.isDirectory()) { + ++resultCount; } - files.add(fileObject); - if (request.isRecursive() && fileObject.isDirectory()) { - treeWalk.enterSubtree(); - FileObject rc = findChildren(fileObject, repo, request, revId, treeWalk); - if (rc != null) { - files.add(rc); - } + convertToFileObject(fileObject, repo, request, revId, entry.getChildren()); + } + + if (resultCount > request.getOffset() || (request.getOffset() == 0 && fileObject.isDirectory())) { + files.add(fileObject); } } parent.setChildren(files); - return null; + parent.setTruncated(hasNext); + } + + private Optional createTree(String path, TreeEntry parent, org.eclipse.jgit.lib.Repository repo, BrowseCommandRequest request, TreeWalk treeWalk) throws IOException { + List entries = new ArrayList<>(); + while (treeWalk.next()) { + TreeEntry treeEntry = new TreeEntry(repo, treeWalk); + if (!treeEntry.getPathString().startsWith(path)) { + parent.setChildren(entries); + return of(treeEntry); + } + + entries.add(treeEntry); + + if (request.isRecursive() && treeEntry.isDirectory()) { + treeWalk.enterSubtree(); + Optional surplus = createTree(treeEntry.getNameString(), treeEntry, repo, request, treeWalk); + surplus.ifPresent(entries::add); + } + } + parent.setChildren(entries); + return empty(); } private FileObject findFirstMatch(org.eclipse.jgit.lib.Repository repo, @@ -289,7 +322,7 @@ public class GitBrowseCommand extends AbstractGitCommand currentDepth++; if (currentDepth >= limit) { - return createFileObject(repo, request, revId, treeWalk); + return createFileObject(repo, request, revId, new TreeEntry(repo, treeWalk)); } else { treeWalk.enterSubtree(); } @@ -329,11 +362,11 @@ public class GitBrowseCommand extends AbstractGitCommand return null; } - private Optional getLfsPointer(org.eclipse.jgit.lib.Repository repo, String path, RevCommit commit, TreeWalk treeWalk) { + private Optional getLfsPointer(org.eclipse.jgit.lib.Repository repo, String path, RevCommit commit, TreeEntry treeWalk) { try { Attributes attributes = LfsFactory.getAttributesForPath(repo, path, commit); - return GitUtil.getLfsPointer(repo, treeWalk, attributes); + return GitUtil.getLfsPointer(repo, treeWalk.getObjectId(), attributes); } catch (IOException e) { throw new InternalRepositoryException(repository, "could not read lfs pointer", e); } @@ -439,4 +472,54 @@ public class GitBrowseCommand extends AbstractGitCommand return changed; } } + + private class TreeEntry { + + private final String pathString; + private final String nameString; + private final ObjectId objectId; + private final boolean directory; + private List children = emptyList(); + + TreeEntry() { + pathString = ""; + nameString = ""; + objectId = null; + directory = true; + } + + TreeEntry(org.eclipse.jgit.lib.Repository repo, TreeWalk treeWalk) throws IOException { + this.pathString = treeWalk.getPathString(); + this.nameString = treeWalk.getNameString(); + this.objectId = treeWalk.getObjectId(0); + ObjectLoader loader = repo.open(objectId); + + this.directory = loader.getType() == Constants.OBJ_TREE; + } + + String getPathString() { + return pathString; + } + + String getNameString() { + return nameString; + } + + ObjectId getObjectId() { + return objectId; + } + + boolean isDirectory() { + return directory; + } + + List getChildren() { + return children; + } + + void setChildren(List children) { + sort(children, TreeEntry::isDirectory, TreeEntry::getNameString); + this.children = children; + } + } } 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 faa8f0c2fa..0bef6d951b 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 @@ -72,6 +72,7 @@ public class GitBrowseCommandTest extends AbstractGitCommandTestBase { BrowserResult result = createCommand().getBrowserResult(request); FileObject fileObject = result.getFile(); assertEquals("a.txt", fileObject.getName()); + assertFalse(fileObject.isTruncated()); } @Test @@ -86,7 +87,7 @@ public class GitBrowseCommandTest extends AbstractGitCommandTestBase { assertThat(foList) .extracting("name") - .containsExactly("a.txt", "b.txt", "c", "f.txt"); + .containsExactly("c", "a.txt", "b.txt", "f.txt"); } @Test @@ -99,7 +100,7 @@ public class GitBrowseCommandTest extends AbstractGitCommandTestBase { Collection foList = root.getChildren(); assertThat(foList) .extracting("name") - .containsExactly("a.txt", "c"); + .containsExactly("c", "a.txt"); } @Test @@ -174,7 +175,9 @@ public class GitBrowseCommandTest extends AbstractGitCommandTestBase { Collection foList = root.getChildren(); - assertThat(foList).hasSize(2); + assertThat(foList) + .extracting("name") + .containsExactly("d.txt", "e.txt"); FileObject d = findFile(foList, "d.txt"); FileObject e = findFile(foList, "e.txt"); @@ -206,7 +209,7 @@ public class GitBrowseCommandTest extends AbstractGitCommandTestBase { assertThat(foList) .extracting("name") - .containsExactly("a.txt", "b.txt", "c", "f.txt"); + .containsExactly("c", "a.txt", "b.txt", "f.txt"); FileObject c = findFile(foList, "c"); @@ -236,6 +239,120 @@ public class GitBrowseCommandTest extends AbstractGitCommandTestBase { .containsExactly(of(42L)); } + @Test + public void testBrowseLimit() throws IOException { + BrowseCommandRequest request = new BrowseCommandRequest(); + request.setLimit(1); + FileObject root = createCommand() + .getBrowserResult(request).getFile(); + assertNotNull(root); + + Collection foList = root.getChildren(); + + assertThat(foList).extracting("name").containsExactly("c", "a.txt"); + assertThat(foList).hasSize(2); + assertTrue("result should be marked as trunctated", root.isTruncated()); + } + + @Test + public void testBrowseLimitWithoutTruncation() throws IOException { + BrowseCommandRequest request = new BrowseCommandRequest(); + request.setLimit(3); + FileObject root = createCommand() + .getBrowserResult(request).getFile(); + assertNotNull(root); + + Collection foList = root.getChildren(); + + assertThat(foList).extracting("name").containsExactly("c", "a.txt", "b.txt", "f.txt"); + assertThat(foList).hasSize(4); + assertFalse("result should not be marked as trunctated", root.isTruncated()); + } + + @Test + public void testBrowseOffset() throws IOException { + BrowseCommandRequest request = new BrowseCommandRequest(); + request.setLimit(1); + request.setOffset(2); + FileObject root = createCommand() + .getBrowserResult(request).getFile(); + assertNotNull(root); + + Collection foList = root.getChildren(); + + assertThat(foList).extracting("name").containsExactly("f.txt"); + assertFalse("result should not be marked as trunctated", root.isTruncated()); + } + + @Test + public void testRecursiveLimit() throws IOException { + BrowseCommandRequest request = new BrowseCommandRequest(); + + request.setLimit(3); + request.setRecursive(true); + + FileObject root = createCommand().getBrowserResult(request).getFile(); + + Collection foList = root.getChildren(); + + assertThat(foList) + .extracting("name") + .containsExactly("c", "a.txt"); + + FileObject c = findFile(foList, "c"); + + Collection cChildren = c.getChildren(); + assertThat(cChildren) + .extracting("name") + .containsExactly("d.txt", "e.txt"); + } + + @Test + public void testRecursiveLimitInSubDir() throws IOException { + BrowseCommandRequest request = new BrowseCommandRequest(); + + request.setLimit(1); + request.setRecursive(true); + + FileObject root = createCommand().getBrowserResult(request).getFile(); + + Collection foList = root.getChildren(); + + assertThat(foList) + .extracting("name") + .containsExactly("c"); + + FileObject c = findFile(foList, "c"); + + Collection cChildren = c.getChildren(); + assertThat(cChildren) + .extracting("name") + .containsExactly("d.txt"); + } + + @Test + public void testRecursiveOffset() throws IOException { + BrowseCommandRequest request = new BrowseCommandRequest(); + + request.setOffset(1); + request.setRecursive(true); + + FileObject root = createCommand().getBrowserResult(request).getFile(); + + Collection foList = root.getChildren(); + + assertThat(foList) + .extracting("name") + .containsExactly("c", "a.txt", "b.txt", "f.txt"); + + FileObject c = findFile(foList, "c"); + + Collection cChildren = c.getChildren(); + assertThat(cChildren) + .extracting("name") + .containsExactly("e.txt"); + } + private FileObject findFile(Collection foList, String name) { return foList.stream() .filter(f -> name.equals(f.getName())) 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 b3e8e89a5f..63a2b0f4cd 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 @@ -101,6 +101,9 @@ public class HgBrowseCommand extends AbstractCommand implements BrowseCommand cmd.disableSubRepositoryDetection(); } + cmd.setLimit(request.getLimit()); + cmd.setOffset(request.getOffset()); + FileObject file = cmd.execute(); return new BrowserResult(c == null? "tip": c.getNode(), revision, file); } diff --git a/scm-plugins/scm-hg-plugin/src/main/java/sonia/scm/repository/spi/javahg/HgFileviewCommand.java b/scm-plugins/scm-hg-plugin/src/main/java/sonia/scm/repository/spi/javahg/HgFileviewCommand.java index 4d5d5e8646..99c0623d97 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 @@ -35,23 +35,16 @@ package sonia.scm.repository.spi.javahg; //~--- non-JDK imports -------------------------------------------------------- -import com.aragost.javahg.DateTime; import com.aragost.javahg.Repository; import com.aragost.javahg.internals.AbstractCommand; import com.aragost.javahg.internals.HgInputStream; -import com.google.common.base.Strings; - import sonia.scm.repository.FileObject; -import sonia.scm.repository.SubRepository; //~--- JDK imports ------------------------------------------------------------ import java.io.IOException; -import java.util.Deque; -import java.util.LinkedList; - /** * Mercurial command to list files of a repository. * @@ -141,6 +134,35 @@ public class HgFileviewCommand extends AbstractCommand return this; } + /** + * Limit the number of result files to limit entries. + * + * @param limit The maximal number of files this request shall return. + * + * @return {@code this} + * @since 2.0.0 + */ + public HgFileviewCommand setLimit(int limit) { + cmdAppend("-l", limit); + + return this; + } + + /** + * Proceed the list from the given number on (zero based). + * + * @param offset The number of the entry, the result should start with (zero based). + * All preceding entries will be omitted. + * + * @return {@code this} + * @since 2.0.0 + */ + public HgFileviewCommand setOffset(int offset) { + cmdAppend("-o", offset); + + return this; + } + /** * Executes the mercurial command and parses the output. * @@ -152,136 +174,9 @@ public class HgFileviewCommand extends AbstractCommand { 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')); - - directory.setName(getNameFromPath(path)); - directory.setDirectory(true); - directory.setPath(path); - - return directory; - } - - private FileObject readFile(HgInputStream stream) throws IOException { - FileObject file = new FileObject(); - String path = removeTrailingSlash(stream.textUpTo('\n')); - - file.setName(getNameFromPath(path)); - file.setPath(path); - file.setDirectory(false); - file.setLength((long) stream.decimalIntUpTo(' ')); - - DateTime timestamp = stream.dateTimeUpTo(' '); - String description = stream.textUpTo('\0'); - - if (!disableLastCommit) { - file.setCommitDate(timestamp.getDate().getTime()); - file.setDescription(description); - } - - return file; - } - - private FileObject readSubRepository(HgInputStream stream) throws IOException { - FileObject directory = new FileObject(); - String path = removeTrailingSlash(stream.textUpTo('\n')); - - directory.setName(getNameFromPath(path)); - directory.setDirectory(true); - directory.setPath(path); - - String revision = stream.textUpTo(' '); - String url = stream.textUpTo('\0'); - - SubRepository subRepository = new SubRepository(url); - - if (!Strings.isNullOrEmpty(revision)) { - subRepository.setRevision(revision); - } - - directory.setSubRepository(subRepository); - - return directory; - } - - private String removeTrailingSlash(String path) { - if (path.endsWith("/")) { - path = path.substring(0, path.length() - 1); - } - - return path; - } - - private String getNameFromPath(String path) { - int index = path.lastIndexOf('/'); - - if (index > 0) { - path = path.substring(index + 1); - } - - return path; + return new HgFileviewCommandResultReader(stream, disableLastCommit).parseResult(); } /** @@ -294,5 +189,4 @@ public class HgFileviewCommand extends AbstractCommand { return HgFileviewExtension.NAME; } - } diff --git a/scm-plugins/scm-hg-plugin/src/main/java/sonia/scm/repository/spi/javahg/HgFileviewCommandResultReader.java b/scm-plugins/scm-hg-plugin/src/main/java/sonia/scm/repository/spi/javahg/HgFileviewCommandResultReader.java new file mode 100644 index 0000000000..c3c2efe4b5 --- /dev/null +++ b/scm-plugins/scm-hg-plugin/src/main/java/sonia/scm/repository/spi/javahg/HgFileviewCommandResultReader.java @@ -0,0 +1,190 @@ +package sonia.scm.repository.spi.javahg; + +import com.aragost.javahg.DateTime; +import com.aragost.javahg.internals.HgInputStream; +import com.google.common.base.Strings; +import sonia.scm.repository.FileObject; +import sonia.scm.repository.SubRepository; + +import java.io.IOException; +import java.util.ArrayList; +import java.util.Collection; +import java.util.Deque; +import java.util.LinkedList; + +class HgFileviewCommandResultReader { + + private static final char TRUNCATED_MARK = 't'; + + private final HgInputStream stream; + private final boolean disableLastCommit; + + HgFileviewCommandResultReader(HgInputStream stream, boolean disableLastCommit) { + this.stream = stream; + this.disableLastCommit = disableLastCommit; + } + + FileObject parseResult() throws IOException { + Deque stack = new LinkedList<>(); + + FileObject last = null; + while (stream.peek() != -1 && stream.peek() != TRUNCATED_MARK) { + FileObject file = read(stream); + + while (!stack.isEmpty()) { + FileObject current = stack.peek(); + if (isParent(current, file)) { + current.addChild(file); + break; + } else if (isAncestor(current, file)) { + Collection missingParents = createMissingParents(current, file); + for (FileObject subDir : missingParents) { + current.addChild(subDir); + stack.push(subDir); + current = stack.peek(); + } + 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 + if (stream.read() == TRUNCATED_MARK) { + stack.getLast().setTruncated(true); + } + 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(); + return child.getParentPath().equals(parentPath); + } + + private boolean isAncestor(FileObject ancestor, FileObject child) { + String ancestorPath = ancestor.getPath(); + return ancestorPath.equals("") || child.getParentPath().startsWith(ancestorPath + '/'); + } + + private Collection createMissingParents(FileObject current, FileObject file) { + String missingPath = file.getPath().substring(current.getPath().length(), file.getPath().lastIndexOf('/')); + + FileObject directory = new FileObject(); + directory.setName(getNameFromPath(missingPath)); + directory.setDirectory(true); + directory.setPath(missingPath); + + Collection parents = new ArrayList<>(); + + if (!isParent(current, directory)) { + parents.addAll(createMissingParents(current, directory)); + } + + parents.add(directory); + + return parents; + } + + private FileObject readDirectory(HgInputStream stream) throws IOException { + FileObject directory = new FileObject(); + String path = removeTrailingSlash(stream.textUpTo('\0')); + + directory.setName(getNameFromPath(path)); + directory.setDirectory(true); + directory.setPath(path); + + return directory; + } + + private FileObject readFile(HgInputStream stream) throws IOException { + FileObject file = new FileObject(); + String path = removeTrailingSlash(stream.textUpTo('\n')); + + file.setName(getNameFromPath(path)); + file.setPath(path); + file.setDirectory(false); + file.setLength((long) stream.decimalIntUpTo(' ')); + + DateTime timestamp = stream.dateTimeUpTo(' '); + String description = stream.textUpTo('\0'); + + if (!disableLastCommit) { + file.setCommitDate(timestamp.getDate().getTime()); + file.setDescription(description); + } + + return file; + } + + private FileObject readSubRepository(HgInputStream stream) throws IOException { + FileObject directory = new FileObject(); + String path = removeTrailingSlash(stream.textUpTo('\n')); + + directory.setName(getNameFromPath(path)); + directory.setDirectory(true); + directory.setPath(path); + + String revision = stream.textUpTo(' '); + String url = stream.textUpTo('\0'); + + SubRepository subRepository = new SubRepository(url); + + if (!Strings.isNullOrEmpty(revision)) { + subRepository.setRevision(revision); + } + + directory.setSubRepository(subRepository); + + return directory; + } + + private String removeTrailingSlash(String path) { + if (path.endsWith("/")) { + path = path.substring(0, path.length() - 1); + } + + return path; + } + + private String getNameFromPath(String path) { + int index = path.lastIndexOf('/'); + + if (index > 0) { + path = path.substring(index + 1); + } + + return path; + } +} 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 1871200389..94947296bc 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 @@ -153,7 +153,8 @@ class File_Walker: return path def walk(self, structure, parent = ""): - for key, value in structure.iteritems(): + sortedItems = sorted(structure.iteritems(), key = lambda item: self.sortKey(item)) + for key, value in sortedItems: if key == FILE_MARKER: if value: for v in value: @@ -165,6 +166,12 @@ class File_Walker: else: self.visit_directory(self.create_path(parent, value)) + def sortKey(self, item): + if (item[0] == FILE_MARKER): + return "2" + else: + return "1" + item[0] + class SubRepository: url = None revision = None @@ -195,39 +202,55 @@ def collect_sub_repositories(revCtx): return subrepos +class Writer: + def __init__(self, ui): + self.ui = ui + + def write(self, value): + self.ui.write(value) + class File_Printer: - def __init__(self, ui, repo, revCtx, disableLastCommit, transport): - self.ui = ui + def __init__(self, writer, repo, revCtx, disableLastCommit, transport, limit, offset): + self.writer = writer self.repo = repo self.revCtx = revCtx self.disableLastCommit = disableLastCommit self.transport = transport + self.result_count = 0 + self.initial_path_printed = False + self.limit = limit + self.offset = offset def print_directory(self, path): - format = '%s/\n' - if self.transport: - format = 'd%s/\0' - self.ui.write( format % path) + if not self.initial_path_printed or self.offset == 0 or self.shouldPrintResult(): + self.initial_path_printed = True + format = '%s/\n' + if self.transport: + format = 'd%s/\0' + self.writer.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' % _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) ) + self.result_count += 1 + if self.shouldPrintResult(): + file = self.revCtx[path] + date = '0 0' + description = 'n/a' + if not self.disableLastCommit: + linkrev = self.repo[file.linkrev()] + date = '%d %d' % _parsedate(linkrev.date()) + description = linkrev.description() + format = '%s %i %s %s\n' + if self.transport: + format = 'f%s\n%i %s %s\0' + self.writer.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)) + if self.shouldPrintResult(): + format = '%s/ %s %s\n' + if self.transport: + format = 's%s/\n%s %s\0' + self.writer.write( format % (path, subrepo.revision, subrepo.url)) def visit(self, file): if file.sub_repository: @@ -237,6 +260,19 @@ class File_Printer: else: self.print_file(file.path) + def shouldPrintResult(self): + return self.offset < self.result_count <= self.limit + self.offset + + def isTruncated(self): + return self.result_count > self.limit + self.offset + + def finish(self): + if self.isTruncated(): + if self.transport: + self.writer.write( "t") + else: + self.writer.write("truncated") + class File_Viewer: def __init__(self, revCtx, visitor): self.revCtx = revCtx @@ -271,14 +307,18 @@ class File_Viewer: ('d', 'disableLastCommit', False, 'disables last commit description and date'), ('s', 'disableSubRepositoryDetection', False, 'disables detection of sub repositories'), ('t', 'transport', False, 'format the output for command server'), + ('l', 'limit', 100, 'limit the number of results'), + ('o', 'offset', 0, 'proceed from the given result number (zero based)'), ]) def fileview(ui, repo, **opts): revCtx = scmutil.revsingle(repo, opts["revision"]) subrepos = {} if not opts["disableSubRepositoryDetection"]: subrepos = collect_sub_repositories(revCtx) - printer = File_Printer(ui, repo, revCtx, opts["disableLastCommit"], opts["transport"]) + writer = Writer(ui) + printer = File_Printer(writer, repo, revCtx, opts["disableLastCommit"], opts["transport"], opts["limit"], opts["offset"]) viewer = File_Viewer(revCtx, printer) viewer.recursive = opts["recursive"] viewer.sub_repositories = subrepos viewer.view(opts["path"]) + printer.finish() 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 index 2ce3989d58..bce3fa0ec0 100644 --- 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 @@ -1,11 +1,24 @@ -from fileview import File_Viewer, SubRepository +from fileview import File_Viewer, File_Printer, SubRepository import unittest +class DummyManifestEntry: + def __init__(self, name): + self.name = name + + def path(self): + return self.name + + def size(self): + return len(self.name) + class DummyRevContext(): def __init__(self, mf): self.mf = mf + def __getitem__(self, path): + return DummyManifestEntry(path) + def manifest(self): return self.mf @@ -13,7 +26,7 @@ class File_Object_Collector(): def __init__(self): self.stack = [] - + def __getitem__(self, key): if len(self.stack) == 0 and key == 0: return self.last @@ -30,7 +43,19 @@ class File_Object_Collector(): if file.directory: self.stack.append(file) self.last = file - + +class CollectingWriter: + def __init__(self): + self.stack = [] + + def __len__(self): + return len(self.stack) + + def __getitem__(self, key): + return self.stack[key] + + def write(self, value): + self.stack.append(value) class Test_File_Viewer(unittest.TestCase): @@ -45,19 +70,57 @@ class Test_File_Viewer(unittest.TestCase): 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.assertChildren(root, ["c", "a", "b", "f.txt"]) + c = root[0] self.assertDirectory(c, "c") - self.assertChildren(c, ["c/d.txt", "c/e.txt", "c/g"]) - g = c[2] + self.assertChildren(c, ["c/g", "c/d.txt", "c/e.txt"]) + g = c[0] self.assertDirectory(g, "c/g") self.assertChildren(g, ["c/g/h.txt"]) + def test_printer(self): + paths = ["a", "b", "c/d.txt", "c/e.txt", "f.txt", "c/g/h.txt"] + writer = self.view_with_limit_and_offset(paths, 1000, 0) + self.assertPaths(writer, ["/", "c/", "c/g/", "c/g/h.txt", "c/d.txt", "c/e.txt", "a", "b", "f.txt"]) + + def test_printer_with_limit(self): + paths = ["a", "b", "c/d.txt", "c/e.txt", "f.txt", "c/g/h.txt"] + writer = self.view_with_limit_and_offset(paths, 1, 0) + self.assertPaths(writer, ["/", "c/", "c/g/", "c/g/h.txt"]) + + def test_printer_with_offset(self): + paths = ["c/g/h.txt", "c/g/i.txt", "c/d.txt", "c/e.txt", "a", "b", "f.txt"] + writer = self.view_with_limit_and_offset(paths, 100, 1) + self.assertPaths(writer, ["/", "c/g/i.txt", "c/d.txt", "c/e.txt", "a", "b", "f.txt"]) + + def view_with_limit_and_offset(self, paths, limit, offset): + revCtx = DummyRevContext(paths) + collector = File_Object_Collector() + + writer = CollectingWriter() + printer = File_Printer(writer, None, revCtx, True, False, limit, offset) + + viewer = File_Viewer(revCtx, printer) + viewer.recursive = True + viewer.view("") + return writer + + def assertPath(self, actual, expected): + path = actual[:len(expected)] + self.assertEqual(path, expected) + nextChar = actual[len(expected)] + self.assertTrue(nextChar == " " or nextChar == "\n", expected + " does not match " + actual) + + def assertPaths(self, actual, expected): + self.assertEqual(len(actual), len(expected)) + for idx,item in enumerate(actual): + self.assertPath(item, expected[idx]) + 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.assertChildren(root, ["c/g", "c/d.txt", "c/e.txt"]) + g = root[0] self.assertDirectory(g, "c/g") self.assertChildren(g, ["c/g/h.txt"]) @@ -69,15 +132,15 @@ class Test_File_Viewer(unittest.TestCase): 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.assertChildren(root, ["c", "a.txt", "b.txt"]) + c = root[0] 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.assertChildren(root, ["c/f", "c/d.txt", "c/e.txt"]) + f = root[0] self.assertEmptyDirectory(f, "c/f") def test_non_recursive_with_path_with_ending_slash(self): @@ -96,7 +159,7 @@ class Test_File_Viewer(unittest.TestCase): viewer.sub_repositories = sub_repositories viewer.view() - d = collector[0][2] + d = collector[0][1] self.assertDirectory(d, "d") @@ -114,7 +177,7 @@ class Test_File_Viewer(unittest.TestCase): 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) 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 92a05a05a0..aa786bb693 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 @@ -45,7 +45,6 @@ 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.assertNull; import static org.junit.Assert.assertTrue; /** @@ -67,6 +66,11 @@ public class HgBrowseCommandTest extends AbstractHgCommandTestBase { @Test public void testBrowse() throws IOException { Collection foList = getRootFromTip(new BrowseCommandRequest()); + + assertThat(foList) + .extracting("name") + .containsExactly("c", "a.txt", "b.txt", "f.txt"); + FileObject a = getFileObject(foList, "a.txt"); FileObject c = getFileObject(foList, "c"); @@ -109,6 +113,10 @@ public class HgBrowseCommandTest extends AbstractHgCommandTestBase { assertEquals("c", c.getName()); Collection foList = c.getChildren(); + assertThat(foList) + .extracting("name") + .containsExactly("d.txt", "e.txt"); + assertNotNull(foList); assertFalse(foList.isEmpty()); assertEquals(2, foList.size()); @@ -181,6 +189,105 @@ public class HgBrowseCommandTest extends AbstractHgCommandTestBase { assertEquals(2, c.getChildren().size()); } + @Test + public void testLimit() throws IOException { + BrowseCommandRequest request = new BrowseCommandRequest(); + request.setLimit(1); + + BrowserResult result = new HgBrowseCommand(cmdContext, repository).getBrowserResult(request); + FileObject root = result.getFile(); + + Collection foList = root.getChildren(); + + assertThat(foList).extracting("name").containsExactly("c", "a.txt"); + assertThat(root.isTruncated()).isTrue(); + } + + @Test + public void testOffset() throws IOException { + BrowseCommandRequest request = new BrowseCommandRequest(); + request.setLimit(2); + request.setOffset(1); + + BrowserResult result = new HgBrowseCommand(cmdContext, repository).getBrowserResult(request); + FileObject root = result.getFile(); + + Collection foList = root.getChildren(); + + assertThat(foList).extracting("name").containsExactly("b.txt", "f.txt"); + assertThat(root.isTruncated()).isFalse(); + } + + + @Test + public void testRecursiveLimit() throws IOException { + BrowseCommandRequest request = new BrowseCommandRequest(); + + request.setLimit(3); + request.setRecursive(true); + + FileObject root = new HgBrowseCommand(cmdContext, repository).getBrowserResult(request).getFile(); + + Collection foList = root.getChildren(); + + assertThat(foList) + .extracting("name") + .containsExactly("c", "a.txt"); + + FileObject c = getFileObject(foList, "c"); + + Collection cChildren = c.getChildren(); + assertThat(cChildren) + .extracting("name") + .containsExactly("d.txt", "e.txt"); + } + + @Test + public void testRecursiveLimitInSubDir() throws IOException { + BrowseCommandRequest request = new BrowseCommandRequest(); + + request.setLimit(1); + request.setRecursive(true); + + FileObject root = new HgBrowseCommand(cmdContext, repository).getBrowserResult(request).getFile(); + + Collection foList = root.getChildren(); + + assertThat(foList) + .extracting("name") + .containsExactly("c"); + + FileObject c = getFileObject(foList, "c"); + + Collection cChildren = c.getChildren(); + assertThat(cChildren) + .extracting("name") + .containsExactly("d.txt"); + } + + @Test + public void testRecursiveOffset() throws IOException { + BrowseCommandRequest request = new BrowseCommandRequest(); + + request.setOffset(1); + request.setRecursive(true); + + FileObject root = new HgBrowseCommand(cmdContext, repository).getBrowserResult(request).getFile(); + + Collection foList = root.getChildren(); + + assertThat(foList) + .extracting("name") + .containsExactly("c", "a.txt", "b.txt", "f.txt"); + + FileObject c = getFileObject(foList, "c"); + + Collection cChildren = c.getChildren(); + assertThat(cChildren) + .extracting("name") + .containsExactly("e.txt"); + } + //~--- get methods ---------------------------------------------------------- /** @@ -210,8 +317,7 @@ public class HgBrowseCommandTest extends AbstractHgCommandTestBase { Collection foList = root.getChildren(); assertNotNull(foList); - assertFalse(foList.isEmpty()); - assertEquals(4, foList.size()); + assertThat(foList).extracting("name").containsExactly("c", "a.txt", "b.txt", "f.txt"); return foList; } diff --git a/scm-plugins/scm-hg-plugin/src/test/java/sonia/scm/repository/spi/javahg/HgFileviewCommandResultReaderTest.java b/scm-plugins/scm-hg-plugin/src/test/java/sonia/scm/repository/spi/javahg/HgFileviewCommandResultReaderTest.java new file mode 100644 index 0000000000..806896b1f8 --- /dev/null +++ b/scm-plugins/scm-hg-plugin/src/test/java/sonia/scm/repository/spi/javahg/HgFileviewCommandResultReaderTest.java @@ -0,0 +1,285 @@ +package sonia.scm.repository.spi.javahg; + +import com.aragost.javahg.internals.HgInputStream; +import org.assertj.core.api.Assertions; +import org.junit.jupiter.api.Test; +import sonia.scm.repository.FileObject; + +import java.io.ByteArrayInputStream; +import java.io.IOException; +import java.time.Instant; +import java.time.temporal.ChronoUnit; +import java.util.Iterator; +import java.util.OptionalLong; + +import static java.nio.charset.StandardCharsets.UTF_8; +import static java.util.Optional.empty; +import static java.util.Optional.of; +import static org.assertj.core.api.Assertions.assertThat; + +class HgFileviewCommandResultReaderTest { + + @Test + void shouldParseSimpleAttributes() throws IOException { + Instant time1 = Instant.now().truncatedTo(ChronoUnit.SECONDS); + Instant time2 = time1.minus(1, ChronoUnit.DAYS); + + HgFileviewCommandResultReader reader = new MockInput() + .dir("") + .dir("dir") + .file("a.txt", 10, time1.toEpochMilli(), "file a") + .file("b.txt", 100, time2.toEpochMilli(), "file b\nwith some\nmore text") + .build(); + + FileObject fileObject = reader.parseResult(); + + assertThat(fileObject.isDirectory()).isTrue(); + assertThat(fileObject.getChildren()) + .extracting("name") + .containsExactly("dir", "a.txt", "b.txt"); + assertThat(fileObject.getChildren()) + .extracting("directory") + .containsExactly(true, false, false); + assertThat(fileObject.getChildren()) + .extracting("length") + .containsExactly(OptionalLong.empty(), OptionalLong.of(10L), OptionalLong.of(100L)); + assertThat(fileObject.getChildren()) + .extracting("description") + .containsExactly(empty(), of("file a"), of("file b\nwith some\nmore text")); + assertThat(fileObject.getChildren()) + .extracting("commitDate") + .containsExactly(OptionalLong.empty(), OptionalLong.of(time1.toEpochMilli()), OptionalLong.of(time2.toEpochMilli())); + assertThat(fileObject.isTruncated()).isFalse(); + } + + @Test + void shouldParseTruncatedFlag() throws IOException { + HgFileviewCommandResultReader reader = new MockInput() + .dir("") + .dir("dir") + .file("a.txt") + .truncated(); + + FileObject fileObject = reader.parseResult(); + + assertThat(fileObject.isTruncated()).isTrue(); + } + + @Test + void shouldParseSubDirectory() throws IOException { + HgFileviewCommandResultReader reader = new MockInput() + .dir("dir") + .file("dir/a.txt") + .build(); + + FileObject fileObject = reader.parseResult(); + + assertThat(fileObject.isDirectory()).isTrue(); + assertThat(fileObject.getName()).isEqualTo("dir"); + assertThat(fileObject.getChildren()) + .extracting("name") + .containsExactly("a.txt"); + } + + @Test + void shouldParseRecursiveResult() throws IOException { + HgFileviewCommandResultReader reader = new MockInput() + .dir("") + .dir("dir") + .dir("dir/more") + .file("dir/more/c.txt") + .file("dir/a.txt") + .file("dir/b.txt") + .file("d.txt") + .build(); + + FileObject fileObject = reader.parseResult(); + + assertThat(fileObject.getChildren()) + .extracting("name") + .containsExactly("dir", "d.txt"); + assertThat(fileObject.getChildren()) + .extracting("directory") + .containsExactly(true, false); + + FileObject subDir = fileObject.getChildren().iterator().next(); + assertThat(subDir.getChildren()) + .extracting("name") + .containsExactly("more", "a.txt", "b.txt"); + assertThat(subDir.getChildren()) + .extracting("directory") + .containsExactly(true, false, false); + + FileObject subSubDir = subDir.getChildren().iterator().next(); + assertThat(subSubDir.getChildren()) + .extracting("name") + .containsExactly("c.txt"); + assertThat(subSubDir.getChildren()) + .extracting("directory") + .containsExactly(false); + } + + @Test + void shouldCreateDirectoriesImplicitly() throws IOException { + HgFileviewCommandResultReader reader = new MockInput() + .dir("") + .file("dir/a.txt") + .file("dir/b.txt") + .file("d.txt") + .build(); + + FileObject fileObject = reader.parseResult(); + + assertThat(fileObject.getChildren()) + .extracting("name") + .containsExactly("dir", "d.txt"); + assertThat(fileObject.getChildren()) + .extracting("directory") + .containsExactly(true, false); + + FileObject subDir = fileObject.getChildren().iterator().next(); + assertThat(subDir.getChildren()) + .extracting("name") + .containsExactly("a.txt", "b.txt"); + assertThat(subDir.getChildren()) + .extracting("directory") + .containsExactly(false, false); + } + + @Test + void shouldCreateSubSubDirectoriesImplicitly() throws IOException { + HgFileviewCommandResultReader reader = new MockInput() + .dir("") + .file("dir/more/a.txt") + .file("dir/b.txt") + .file("d.txt") + .build(); + + FileObject fileObject = reader.parseResult(); + + assertThat(fileObject.getChildren()) + .extracting("name") + .containsExactly("dir", "d.txt"); + assertThat(fileObject.getChildren()) + .extracting("directory") + .containsExactly(true, false); + + FileObject subDir = fileObject.getChildren().iterator().next(); + assertThat(subDir.getChildren()) + .extracting("name") + .containsExactly("more", "b.txt"); + assertThat(subDir.getChildren()) + .extracting("directory") + .containsExactly(true, false); + + FileObject subSubDir = subDir.getChildren().iterator().next(); + assertThat(subSubDir.getChildren()) + .extracting("name") + .containsExactly("a.txt"); + assertThat(subSubDir.getChildren()) + .extracting("directory") + .containsExactly(false); + } + + @Test + void shouldCreateSimilarSubDirectoriesCorrectly() throws IOException { + HgFileviewCommandResultReader reader = new MockInput() + .dir("") + .file("dir/a.txt") + .file("directory/b.txt") + .build(); + + FileObject fileObject = reader.parseResult(); + + assertThat(fileObject.getChildren()) + .extracting("name") + .containsExactly("dir", "directory"); + assertThat(fileObject.getChildren()) + .extracting("directory") + .containsExactly(true, true); + + Iterator fileIterator = fileObject.getChildren().iterator(); + FileObject firstSubDir = fileIterator.next(); + assertThat(firstSubDir.getChildren()) + .extracting("name") + .containsExactly("a.txt"); + assertThat(firstSubDir.getChildren()) + .extracting("directory") + .containsExactly(false); + + FileObject secondSubDir = fileIterator.next(); + assertThat(secondSubDir.getChildren()) + .extracting("name") + .containsExactly("b.txt"); + assertThat(secondSubDir.getChildren()) + .extracting("directory") + .containsExactly(false); + } + + @Test + void shouldIgnoreTimeAndCommentWhenDisabled() throws IOException { + HgFileviewCommandResultReader reader = new MockInput() + .dir("") + .dir("c") + .file("a.txt") + .build(); + + FileObject fileObject = reader.parseResult(); + + assertThat(fileObject.getChildren()) + .extracting("description") + .containsOnly(empty()); + assertThat(fileObject.getChildren()) + .extracting("commitDate") + .containsOnly(OptionalLong.empty()); + } + + private HgInputStream createInputStream(String input) { + return new HgInputStream(new ByteArrayInputStream(input.getBytes(UTF_8)), UTF_8.newDecoder()); + } + + private class MockInput { + private final StringBuilder stringBuilder = new StringBuilder(); + private boolean disableLastCommit = false; + + MockInput dir(String name) { + stringBuilder + .append('d') + .append(name) + .append('/') + .append('\0'); + return this; + } + + MockInput file(String name) { + disableLastCommit = true; + return file(name, 1024, 0, "n/a"); + } + + MockInput file(String name, int length, long time, String comment) { + stringBuilder + .append('f') + .append(name) + .append('\n') + .append(length) + .append(' ') + .append(time/1000) + .append(' ') + .append(0) + .append(' ') + .append(comment) + .append('\0'); + return this; + } + + HgFileviewCommandResultReader truncated() { + stringBuilder.append("t"); + return build(); + } + + HgFileviewCommandResultReader build() { + HgInputStream inputStream = createInputStream(stringBuilder.toString()); + return new HgFileviewCommandResultReader(inputStream, disableLastCommit); + } + } +} 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 e4a32c8ca6..c362e628a4 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 @@ -51,8 +51,12 @@ import sonia.scm.repository.SubRepository; import sonia.scm.repository.SvnUtil; import sonia.scm.util.Util; +import java.util.ArrayList; import java.util.Collection; +import java.util.Iterator; +import java.util.List; +import static java.util.Comparator.comparing; import static org.tmatesoft.svn.core.SVNErrorCode.FS_NO_SUCH_REVISION; import static sonia.scm.ContextEntry.ContextBuilder.entity; import static sonia.scm.NotFoundException.notFound; @@ -73,6 +77,8 @@ public class SvnBrowseCommand extends AbstractSvnCommand private static final Logger logger = LoggerFactory.getLogger(SvnBrowseCommand.class); + private int resultCount = 0; + SvnBrowseCommand(SvnContext context, Repository repository) { super(context, repository); @@ -127,16 +133,26 @@ public class SvnBrowseCommand extends AbstractSvnCommand FileObject parent, String basePath) throws SVNException { - Collection entries = svnRepository.getDir(parent.getPath(), revisionNumber, null, (Collection) null); - for (SVNDirEntry entry : entries) - { + List entries = new ArrayList<>(svnRepository.getDir(parent.getPath(), revisionNumber, null, (Collection) null)); + sort(entries, entry -> entry.getKind() == SVNNodeKind.DIR, SVNDirEntry::getName); + for (Iterator iterator = entries.iterator(); resultCount < request.getLimit() + request.getOffset() && iterator.hasNext(); ) { + SVNDirEntry entry = iterator.next(); FileObject child = createFileObject(request, svnRepository, revisionNumber, entry, basePath); - parent.addChild(child); + if (!child.isDirectory()) { + ++resultCount; + } if (child.isDirectory() && request.isRecursive()) { traverse(svnRepository, revisionNumber, request, child, createBasePath(child.getPath())); } + + if (resultCount > request.getOffset() || (request.getOffset() == 0 && child.isDirectory())) { + parent.addChild(child); + } + } + if (resultCount >= request.getLimit() + request.getOffset()) { + parent.setTruncated(true); } } 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 980d486b5c..fb12e6ff3d 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 @@ -39,11 +39,12 @@ import sonia.scm.repository.FileObject; import java.io.IOException; import java.util.Collection; +import java.util.Iterator; +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.assertNull; import static org.junit.Assert.assertTrue; /** @@ -65,10 +66,17 @@ public class SvnBrowseCommandTest extends AbstractSvnCommandTestBase @Test public void testBrowse() { - Collection foList = getRootFromTip(new BrowseCommandRequest()); + BrowserResult result = createCommand().getBrowserResult(new BrowseCommandRequest()); - FileObject a = getFileObject(foList, "a.txt"); - FileObject c = getFileObject(foList, "c"); + assertNotNull(result); + + Collection foList = result.getFile().getChildren(); + + assertThat(foList).extracting("name").containsExactly("c", "a.txt"); + + Iterator iterator = foList.iterator(); + FileObject c = iterator.next(); + FileObject a = iterator.next(); assertFalse(a.isDirectory()); assertEquals("a.txt", a.getName()); @@ -99,24 +107,11 @@ public class SvnBrowseCommandTest extends AbstractSvnCommandTestBase Collection foList = result.getFile().getChildren(); - assertNotNull(foList); - assertFalse(foList.isEmpty()); - assertEquals(2, foList.size()); + assertThat(foList).extracting("name").containsExactly("d.txt", "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; - } - } + Iterator iterator = foList.iterator(); + FileObject d = iterator.next(); + FileObject e = iterator.next(); assertNotNull(d); assertFalse(d.isDirectory()); @@ -140,14 +135,24 @@ public class SvnBrowseCommandTest extends AbstractSvnCommandTestBase request.setDisableLastCommit(true); - Collection foList = getRootFromTip(request); + BrowserResult result = createCommand().getBrowserResult(request); + + assertNotNull(result); + + Collection foList1 = result.getFile().getChildren(); + + assertNotNull(foList1); + assertFalse(foList1.isEmpty()); + assertEquals(2, foList1.size()); + + Collection foList = foList1; FileObject a = getFileObject(foList, "a.txt"); assertFalse(a.getDescription().isPresent()); assertFalse(a.getCommitDate().isPresent()); } - + @Test public void testRecursive() { BrowseCommandRequest request = new BrowseCommandRequest(); @@ -168,6 +173,102 @@ public class SvnBrowseCommandTest extends AbstractSvnCommandTestBase assertEquals(2, c.getChildren().size()); } + @Test + public void testLimit() { + BrowseCommandRequest request = new BrowseCommandRequest(); + request.setLimit(1); + BrowserResult result = createCommand().getBrowserResult(request); + + assertNotNull(result); + + Collection foList = result.getFile().getChildren(); + + assertThat(foList).extracting("name").containsExactly("c", "a.txt"); + assertThat(result.getFile().isTruncated()).isTrue(); + } + + @Test + public void testOffset() { + BrowseCommandRequest request = new BrowseCommandRequest(); + request.setOffset(1); + BrowserResult result = createCommand().getBrowserResult(request); + + assertNotNull(result); + + Collection foList = result.getFile().getChildren(); + + assertThat(foList).isEmpty(); + } + + @Test + public void testRecursiveLimit() throws IOException { + BrowseCommandRequest request = new BrowseCommandRequest(); + + request.setLimit(4); + request.setRecursive(true); + + FileObject root = createCommand().getBrowserResult(request).getFile(); + + Collection foList = root.getChildren(); + + assertThat(foList) + .extracting("name") + .containsExactly("c", "a.txt"); + + FileObject c = getFileObject(foList, "c"); + + Collection cChildren = c.getChildren(); + assertThat(cChildren) + .extracting("name") + .containsExactly("d.txt", "e.txt"); + } + + @Test + public void testRecursiveLimitInSubDir() throws IOException { + BrowseCommandRequest request = new BrowseCommandRequest(); + + request.setLimit(1); + request.setRecursive(true); + + FileObject root = createCommand().getBrowserResult(request).getFile(); + + Collection foList = root.getChildren(); + + assertThat(foList) + .extracting("name") + .containsExactly("c"); + + FileObject c = getFileObject(foList, "c"); + + Collection cChildren = c.getChildren(); + assertThat(cChildren) + .extracting("name") + .containsExactly("d.txt"); + } + + @Test + public void testRecursiveOffset() throws IOException { + BrowseCommandRequest request = new BrowseCommandRequest(); + + request.setOffset(1); + request.setRecursive(true); + + FileObject root = createCommand().getBrowserResult(request).getFile(); + + Collection foList = root.getChildren(); + + assertThat(foList) + .extracting("name") + .containsExactly("c", "a.txt"); + + FileObject c = getFileObject(foList, "c"); + + Collection cChildren = c.getChildren(); + assertThat(cChildren) + .extracting("name") + .containsExactly("e.txt"); + } + /** * Method description * @@ -198,17 +299,4 @@ public class SvnBrowseCommandTest extends AbstractSvnCommandTestBase .orElseThrow(() -> new AssertionError("file " + name + " not found")); } - private Collection getRootFromTip(BrowseCommandRequest request) { - BrowserResult result = createCommand().getBrowserResult(request); - - assertNotNull(result); - - Collection foList = result.getFile().getChildren(); - - assertNotNull(foList); - assertFalse(foList.isEmpty()); - assertEquals(2, foList.size()); - - return foList; - } } diff --git a/scm-ui/ui-types/src/Sources.ts b/scm-ui/ui-types/src/Sources.ts index dce6947622..b1cca3c59f 100644 --- a/scm-ui/ui-types/src/Sources.ts +++ b/scm-ui/ui-types/src/Sources.ts @@ -16,8 +16,9 @@ export type File = { length?: number; commitDate?: string; subRepository?: SubRepository; // TODO - partialResult: boolean; - computationAborted: boolean; + partialResult?: boolean; + computationAborted?: boolean; + truncated?: boolean; _links: Links; _embedded: { children: File[] | null | undefined; diff --git a/scm-ui/ui-webapp/public/locales/de/repos.json b/scm-ui/ui-webapp/public/locales/de/repos.json index bb353f6b67..a0fce5cbed 100644 --- a/scm-ui/ui-webapp/public/locales/de/repos.json +++ b/scm-ui/ui-webapp/public/locales/de/repos.json @@ -128,7 +128,9 @@ "noSources": "Keine Sources in diesem Branch gefunden.", "extension": { "notBound": "Keine Erweiterung angebunden." - } + }, + "loadMore": "Laden", + "moreFilesAvailable": "Es werden nur die ersten {{count}} Dateien angezeigt. Es sind weitere Dateien vorhanden." }, "permission": { "title": "Berechtigungen bearbeiten", diff --git a/scm-ui/ui-webapp/public/locales/en/repos.json b/scm-ui/ui-webapp/public/locales/en/repos.json index 004cf594ea..2abf63eb47 100644 --- a/scm-ui/ui-webapp/public/locales/en/repos.json +++ b/scm-ui/ui-webapp/public/locales/en/repos.json @@ -128,7 +128,9 @@ "noSources": "No sources found for this branch.", "extension": { "notBound": "No extension bound." - } + }, + "loadMore": "Load", + "moreFilesAvailable": "These are just the first {{count}} files. There are more files available." }, "permission": { "title": "Edit Permissions", diff --git a/scm-ui/ui-webapp/src/repos/sources/components/FileTree.tsx b/scm-ui/ui-webapp/src/repos/sources/components/FileTree.tsx index 1a5a8b5b58..2d9d8b1e61 100644 --- a/scm-ui/ui-webapp/src/repos/sources/components/FileTree.tsx +++ b/scm-ui/ui-webapp/src/repos/sources/components/FileTree.tsx @@ -7,26 +7,40 @@ import styled from "styled-components"; import { binder } from "@scm-manager/ui-extensions"; import { File, Repository } from "@scm-manager/ui-types"; import { ErrorNotification, Loading, Notification } from "@scm-manager/ui-components"; -import { fetchSources, getFetchSourcesFailure, getSources, isFetchSourcesPending } from "../modules/sources"; +import { + fetchSources, + getFetchSourcesFailure, + getHunkCount, + getSources, + isFetchSourcesPending +} from "../modules/sources"; import FileTreeLeaf from "./FileTreeLeaf"; +import { Button } from "@scm-manager/ui-components"; -type Props = WithTranslation & { +type Hunk = { + tree: File; loading: boolean; error: Error; - tree: File; +}; + +type Props = WithTranslation & { repository: Repository; revision: string; path: string; baseUrl: string; + location: any; + hunks: Hunk[]; - updateSources: () => void; + // dispatch props + fetchSources: (repository: Repository, revision: string, path: string, hunk: number) => void; + updateSources: (hunk: number) => () => void; // context props match: any; }; type State = { - stoppableUpdateHandler?: number; + stoppableUpdateHandler: number[]; }; const FixedWidthTh = styled.th` @@ -48,44 +62,73 @@ export function findParent(path: string) { class FileTree extends React.Component { constructor(props: Props) { super(props); - this.state = {}; + this.state = { stoppableUpdateHandler: [] }; } componentDidUpdate(prevProps: Readonly, prevState: Readonly): void { if (prevState.stoppableUpdateHandler === this.state.stoppableUpdateHandler) { - const { tree, updateSources } = this.props; - if (tree?._embedded?.children && tree._embedded.children.find(c => c.partialResult)) { - const stoppableUpdateHandler = setTimeout(updateSources, 3000); - this.setState({ stoppableUpdateHandler: stoppableUpdateHandler }); - } + const { hunks, updateSources } = this.props; + hunks?.forEach((hunk, index) => { + if (hunk.tree?._embedded?.children && hunk.tree._embedded.children.find(c => c.partialResult)) { + const stoppableUpdateHandler = setTimeout(updateSources(index), 3000); + this.setState(prevState => { + return { + stoppableUpdateHandler: [...prevState.stoppableUpdateHandler, stoppableUpdateHandler] + }; + }); + } + }); } } componentWillUnmount(): void { - if (this.state.stoppableUpdateHandler) { - clearTimeout(this.state.stoppableUpdateHandler); - } + this.state.stoppableUpdateHandler.forEach(handler => clearTimeout(handler)); } + loadMore = () => { + this.props.fetchSources(this.props.repository, this.props.revision, this.props.path, this.props.hunks.length); + }; + + renderTruncatedInfo = () => { + const { hunks, t } = this.props; + const lastHunk = hunks[hunks.length - 1]; + const fileCount = hunks + .filter(hunk => hunk?.tree?._embedded?.children) + .map(hunk => hunk.tree._embedded.children.filter(c => !c.directory).length) + .reduce((a, b) => a + b, 0); + if (lastHunk.tree?.truncated) { + return ( + +
+
{t("sources.moreFilesAvailable", { count: fileCount })}
+
+
+ ); + } + }; + render() { - const { error, loading, tree } = this.props; + const { hunks } = this.props; - if (error) { - return ; - } - - if (loading) { - return ; - } - if (!tree) { + if (!hunks || hunks.length === 0) { return null; } - return
{this.renderSourcesTable()}
; + if (hunks[0]?.error) { + return ; + } + + return ( +
+ {this.renderSourcesTable()} + {this.renderTruncatedInfo()} +
+ ); } renderSourcesTable() { - const { tree, revision, path, baseUrl, t } = this.props; + const { hunks, revision, path, baseUrl, t } = this.props; const files = []; @@ -97,53 +140,51 @@ class FileTree extends React.Component { }); } - const compareFiles = function(f1: File, f2: File): number { - if (f1.directory) { - if (f2.directory) { - return f1.name.localeCompare(f2.name); - } else { - return -1; - } - } else { - if (f2.directory) { - return 1; - } else { - return f1.name.localeCompare(f2.name); - } - } - }; - - if (tree._embedded && tree._embedded.children) { - const children = [...tree._embedded.children].sort(compareFiles); - files.push(...children); + if (hunks.every(hunk => hunk.loading)) { + return ; } - if (files && files.length > 0) { + hunks + .filter(hunk => !hunk.loading) + .forEach(hunk => { + if (hunk.tree?._embedded && hunk.tree._embedded.children) { + const children = [...hunk.tree._embedded.children]; + files.push(...children); + } + }); + + const loading = hunks.filter(hunk => hunk.loading).length > 0; + + if (loading || (files && files.length > 0)) { let baseUrlWithRevision = baseUrl; if (revision) { baseUrlWithRevision += "/" + encodeURIComponent(revision); } else { - baseUrlWithRevision += "/" + encodeURIComponent(tree.revision); + baseUrlWithRevision += "/" + encodeURIComponent(hunks[0].tree.revision); } return ( - - - - - - - - - {binder.hasExtension("repos.sources.tree.row.right") && - - - {files.map(file => ( - - ))} - -
{t("sources.file-tree.name")}{t("sources.file-tree.length")}{t("sources.file-tree.commitDate")}{t("sources.file-tree.description")}} -
+ <> + + + + + + + + + {binder.hasExtension("repos.sources.tree.row.right") && + + + {files.map((file: any) => ( + + ))} + +
{t("sources.file-tree.name")}{t("sources.file-tree.length")}{t("sources.file-tree.commitDate")}{t("sources.file-tree.description")}} +
+ {hunks[hunks.length - 1].loading && } + {hunks[hunks.length - 1].error && } + ); } return {t("sources.noSources")}; @@ -153,24 +194,36 @@ class FileTree extends React.Component { const mapDispatchToProps = (dispatch: any, ownProps: Props) => { const { repository, revision, path } = ownProps; - const updateSources = () => dispatch(fetchSources(repository, revision, path, false)); - - return { updateSources }; + return { + updateSources: (hunk: number) => () => dispatch(fetchSources(repository, revision, path, false, hunk)), + fetchSources: (repository: Repository, revision: string, path: string, hunk: number) => { + dispatch(fetchSources(repository, revision, path, true, hunk)); + } + }; }; const mapStateToProps = (state: any, ownProps: Props) => { const { repository, revision, path } = ownProps; - const loading = isFetchSourcesPending(state, repository, revision, path); - const error = getFetchSourcesFailure(state, repository, revision, path); - const tree = getSources(state, repository, revision, path); + const error = getFetchSourcesFailure(state, repository, revision, path, 0); + const hunkCount = getHunkCount(state, repository, revision, path); + const hunks = []; + for (let i = 0; i < hunkCount; ++i) { + const tree = getSources(state, repository, revision, path, i); + const loading = isFetchSourcesPending(state, repository, revision, path, i); + const error = getFetchSourcesFailure(state, repository, revision, path, i); + hunks.push({ + tree, + loading, + error + }); + } return { revision, path, - loading, error, - tree + hunks }; }; diff --git a/scm-ui/ui-webapp/src/repos/sources/modules/sources.test.ts b/scm-ui/ui-webapp/src/repos/sources/modules/sources.test.ts index fa6ff5c1f6..1c819c91f1 100644 --- a/scm-ui/ui-webapp/src/repos/sources/modules/sources.test.ts +++ b/scm-ui/ui-webapp/src/repos/sources/modules/sources.test.ts @@ -37,6 +37,9 @@ const collection = { length: 176, revision: "76aae4bb4ceacf0e88938eb5b6832738b7d537b4", subRepository: undefined, + truncated: true, + partialResult: false, + computationAborted: false, _links: { self: { href: @@ -115,17 +118,28 @@ describe("sources fetch", () => { }); it("should fetch the sources of the repository", () => { - fetchMock.getOnce(sourcesUrl, collection); + fetchMock.getOnce(sourcesUrl + "?offset=0", collection); const expectedActions = [ { type: FETCH_SOURCES_PENDING, - itemId: "scm/core/_/" + itemId: "scm/core/_//", + payload: { + hunk: 0, + updatePending: false, + pending: true, + sources: {} + } }, { type: FETCH_SOURCES_SUCCESS, - itemId: "scm/core/_/", - payload: { updatePending: false, sources: collection } + itemId: "scm/core/_//", + payload: { + hunk: 0, + updatePending: false, + pending: false, + sources: collection + } } ]; @@ -136,17 +150,28 @@ describe("sources fetch", () => { }); it("should fetch the sources of the repository with the given revision and path", () => { - fetchMock.getOnce(sourcesUrl + "abc/src", collection); + fetchMock.getOnce(sourcesUrl + "abc/src?offset=0", collection); const expectedActions = [ { type: FETCH_SOURCES_PENDING, - itemId: "scm/core/abc/src" + itemId: "scm/core/abc/src/", + payload: { + hunk: 0, + updatePending: false, + pending: true, + sources: {} + } }, { type: FETCH_SOURCES_SUCCESS, - itemId: "scm/core/abc/src", - payload: { updatePending: false, sources: collection } + itemId: "scm/core/abc/src/", + payload: { + hunk: 0, + updatePending: false, + pending: false, + sources: collection + } } ]; @@ -157,7 +182,7 @@ describe("sources fetch", () => { }); it("should dispatch FETCH_SOURCES_FAILURE on server error", () => { - fetchMock.getOnce(sourcesUrl, { + fetchMock.getOnce(sourcesUrl + "?offset=0", { status: 500 }); @@ -166,7 +191,7 @@ describe("sources fetch", () => { const actions = store.getActions(); expect(actions[0].type).toBe(FETCH_SOURCES_PENDING); expect(actions[1].type).toBe(FETCH_SOURCES_FAILURE); - expect(actions[1].itemId).toBe("scm/core/_/"); + expect(actions[1].itemId).toBe("scm/core/_//"); expect(actions[1].payload).toBeDefined(); }); }); @@ -180,16 +205,18 @@ describe("reducer tests", () => { it("should store the collection, without revision and path", () => { const expectedState = { - "scm/core/_/": { updatePending: false, sources: collection } + "scm/core/_//0": { pending: false, updatePending: false, sources: collection }, + "scm/core/_//hunkCount": 1 }; - expect(reducer({}, fetchSourcesSuccess(repository, "", "", collection))).toEqual(expectedState); + expect(reducer({}, fetchSourcesSuccess(repository, "", "", 0, collection))).toEqual(expectedState); }); it("should store the collection, with revision and path", () => { const expectedState = { - "scm/core/abc/src/main": { updatePending: false, sources: collection } + "scm/core/abc/src/main/0": { pending: false, updatePending: false, sources: collection }, + "scm/core/abc/src/main/hunkCount": 1 }; - expect(reducer({}, fetchSourcesSuccess(repository, "abc", "src/main", collection))).toEqual(expectedState); + expect(reducer({}, fetchSourcesSuccess(repository, "abc", "src/main", 0, collection))).toEqual(expectedState); }); }); @@ -197,7 +224,7 @@ describe("selector tests", () => { it("should return false if it is no directory", () => { const state = { sources: { - "scm/core/abc/src/main/package.json": { + "scm/core/abc/src/main/package.json/0": { sources: { noDirectory } } } @@ -208,7 +235,7 @@ describe("selector tests", () => { it("should return true if it is directory", () => { const state = { sources: { - "scm/core/abc/src": noDirectory + "scm/core/abc/src/0": noDirectory } }; expect(isDirectory(state, repository, "abc", "src")).toBe(true); @@ -221,7 +248,7 @@ describe("selector tests", () => { it("should return the source collection without revision and path", () => { const state = { sources: { - "scm/core/_/": { + "scm/core/_//0": { sources: collection } } @@ -232,7 +259,7 @@ describe("selector tests", () => { it("should return the source collection with revision and path", () => { const state = { sources: { - "scm/core/abc/src/main": { + "scm/core/abc/src/main/0": { sources: collection } } @@ -242,29 +269,44 @@ describe("selector tests", () => { it("should return true, when fetch sources is pending", () => { const state = { - pending: { - [FETCH_SOURCES + "/scm/core/_/"]: true + sources: { + "scm/core/_//0": { + pending: true, + sources: {} + } } }; - expect(isFetchSourcesPending(state, repository, "", "")).toEqual(true); + expect(isFetchSourcesPending(state, repository, "", "", 0)).toEqual(true); }); it("should return false, when fetch sources is not pending", () => { - expect(isFetchSourcesPending({}, repository, "", "")).toEqual(false); + const state = { + sources: { + "scm/core/_//0": { + pending: false, + sources: {} + } + } + }; + expect(isFetchSourcesPending(state, repository, "", "", 0)).toEqual(false); }); const error = new Error("incredible error from hell"); it("should return error when fetch sources did fail", () => { const state = { - failure: { - [FETCH_SOURCES + "/scm/core/_/"]: error + sources: { + "scm/core/_//0": { + pending: false, + sources: {}, + error: error + } } }; - expect(getFetchSourcesFailure(state, repository, "", "")).toEqual(error); + expect(getFetchSourcesFailure(state, repository, "", "", 0)).toEqual(error); }); it("should return undefined when fetch sources did not fail", () => { - expect(getFetchSourcesFailure({}, repository, "", "")).toBe(undefined); + expect(getFetchSourcesFailure({}, repository, "", "", 0)).toBe(null); }); }); diff --git a/scm-ui/ui-webapp/src/repos/sources/modules/sources.ts b/scm-ui/ui-webapp/src/repos/sources/modules/sources.ts index 2c7413f7ea..4c78c3525f 100644 --- a/scm-ui/ui-webapp/src/repos/sources/modules/sources.ts +++ b/scm-ui/ui-webapp/src/repos/sources/modules/sources.ts @@ -1,57 +1,73 @@ import * as types from "../../../modules/types"; import { Action, File, Link, Repository } from "@scm-manager/ui-types"; import { apiClient } from "@scm-manager/ui-components"; -import { isPending } from "../../../modules/pending"; import { getFailure } from "../../../modules/failure"; export const FETCH_SOURCES = "scm/repos/FETCH_SOURCES"; export const FETCH_SOURCES_PENDING = `${FETCH_SOURCES}_${types.PENDING_SUFFIX}`; export const FETCH_UPDATES_PENDING = `${FETCH_SOURCES}_UPDATE_PENDING`; export const FETCH_SOURCES_SUCCESS = `${FETCH_SOURCES}_${types.SUCCESS_SUFFIX}`; +export const FETCH_UPDATES_SUCCESS = `${FETCH_SOURCES}_UPDATE_SUCCESS`; export const FETCH_SOURCES_FAILURE = `${FETCH_SOURCES}_${types.FAILURE_SUFFIX}`; -export function fetchSources(repository: Repository, revision: string, path: string, initialLoad = true) { +export function fetchSources(repository: Repository, revision: string, path: string, initialLoad = true, hunk = 0) { return function(dispatch: any, getState: () => any) { const state = getState(); if ( - isFetchSourcesPending(state, repository, revision, path) || - isUpdateSourcePending(state, repository, revision, path) + isFetchSourcesPending(state, repository, revision, path, hunk) || + isUpdateSourcePending(state, repository, revision, path, hunk) ) { return; } if (initialLoad) { - dispatch(fetchSourcesPending(repository, revision, path)); + dispatch(fetchSourcesPending(repository, revision, path, hunk)); } else { - dispatch(updateSourcesPending(repository, revision, path, getSources(state, repository, revision, path))); + dispatch( + updateSourcesPending(repository, revision, path, hunk, getSources(state, repository, revision, path, hunk)) + ); } + + let offset = 0; + for (let i = 0; i < hunk; ++i) { + const sources = getSources(state, repository, revision, path, i); + if (sources?._embedded.children) { + offset += sources._embedded.children.filter(c => !c.directory).length; + } + } + return apiClient - .get(createUrl(repository, revision, path)) + .get(createUrl(repository, revision, path, offset)) .then(response => response.json()) .then((sources: File) => { - dispatch(fetchSourcesSuccess(repository, revision, path, sources)); + if (initialLoad) { + dispatch(fetchSourcesSuccess(repository, revision, path, hunk, sources)); + } else { + dispatch(fetchUpdatesSuccess(repository, revision, path, hunk, sources)); + } }) .catch(err => { - dispatch(fetchSourcesFailure(repository, revision, path, err)); + dispatch(fetchSourcesFailure(repository, revision, path, hunk, err)); }); }; } -function createUrl(repository: Repository, revision: string, path: string) { +function createUrl(repository: Repository, revision: string, path: string, offset: number) { const base = (repository._links.sources as Link).href; if (!revision && !path) { - return base; + return `${base}?offset=${offset}`; } // TODO handle trailing slash const pathDefined = path ? path : ""; - return `${base}${encodeURIComponent(revision)}/${pathDefined}`; + return `${base}${encodeURIComponent(revision)}/${pathDefined}?offset=${offset}`; } -export function fetchSourcesPending(repository: Repository, revision: string, path: string): Action { +export function fetchSourcesPending(repository: Repository, revision: string, path: string, hunk: number): Action { return { type: FETCH_SOURCES_PENDING, - itemId: createItemId(repository, revision, path) + itemId: createItemId(repository, revision, path, ""), + payload: { hunk, pending: true, updatePending: false, sources: {} } }; } @@ -59,35 +75,62 @@ export function updateSourcesPending( repository: Repository, revision: string, path: string, - currentSources: any + hunk: number, + currentSources: File ): Action { return { type: FETCH_UPDATES_PENDING, - payload: { updatePending: true, sources: currentSources }, - itemId: createItemId(repository, revision, path) + payload: { hunk, pending: false, updatePending: true, sources: currentSources }, + itemId: createItemId(repository, revision, path, "") }; } -export function fetchSourcesSuccess(repository: Repository, revision: string, path: string, sources: File) { +export function fetchSourcesSuccess( + repository: Repository, + revision: string, + path: string, + hunk: number, + sources: File +) { return { type: FETCH_SOURCES_SUCCESS, - payload: { updatePending: false, sources }, - itemId: createItemId(repository, revision, path) + payload: { hunk, pending: false, updatePending: false, sources }, + itemId: createItemId(repository, revision, path, "") }; } -export function fetchSourcesFailure(repository: Repository, revision: string, path: string, error: Error): Action { +export function fetchUpdatesSuccess( + repository: Repository, + revision: string, + path: string, + hunk: number, + sources: File +) { + return { + type: FETCH_UPDATES_SUCCESS, + payload: { hunk, pending: false, updatePending: false, sources }, + itemId: createItemId(repository, revision, path, "") + }; +} + +export function fetchSourcesFailure( + repository: Repository, + revision: string, + path: string, + hunk: number, + error: Error +): Action { return { type: FETCH_SOURCES_FAILURE, - payload: error, - itemId: createItemId(repository, revision, path) + payload: { hunk, pending: false, updatePending: false, error }, + itemId: createItemId(repository, revision, path, "") }; } -function createItemId(repository: Repository, revision: string | undefined, path: string) { +function createItemId(repository: Repository, revision: string | undefined, path: string, hunk: number | string) { const revPart = revision ? revision : "_"; const pathPart = path ? path : ""; - return `${repository.namespace}/${repository.name}/${decodeURIComponent(revPart)}/${pathPart}`; + return `${repository.namespace}/${repository.name}/${decodeURIComponent(revPart)}/${pathPart}/${hunk}`; } // reducer @@ -98,19 +141,54 @@ export default function reducer( type: "UNKNOWN" } ): any { - if (action.itemId && (action.type === FETCH_SOURCES_SUCCESS || action.type === FETCH_UPDATES_PENDING)) { + if (action.itemId && (action.type === FETCH_SOURCES_SUCCESS || action.type === FETCH_SOURCES_FAILURE)) { return { ...state, - [action.itemId]: action.payload + [action.itemId + "hunkCount"]: action.payload.hunk + 1, + [action.itemId + action.payload.hunk]: { + sources: action.payload.sources, + error: action.payload.error, + updatePending: false, + pending: false + } }; + } else if (action.itemId && (action.type === FETCH_UPDATES_SUCCESS)) { + return { + ...state, + [action.itemId + action.payload.hunk]: { + sources: action.payload.sources, + error: action.payload.error, + updatePending: false, + pending: false + } + }; + } else if (action.itemId && action.type === FETCH_UPDATES_PENDING) { + return { + ...state, + [action.itemId + action.payload.hunk]: { + sources: action.payload.sources, + updatePending: true, + pending: false + } + }; + } else if (action.itemId && action.type === FETCH_SOURCES_PENDING) { + return { + ...state, + [action.itemId + "hunkCount"]: action.payload.hunk + 1, + [action.itemId + action.payload.hunk]: { + updatePending: false, + pending: true + } + }; + } else { + return state; } - return state; } // selectors export function isDirectory(state: any, repository: Repository, revision: string, path: string): boolean { - const currentFile = getSources(state, repository, revision, path); + const currentFile = getSources(state, repository, revision, path, 0); if (currentFile && !currentFile.directory) { return false; } else { @@ -118,31 +196,62 @@ export function isDirectory(state: any, repository: Repository, revision: string } } +export function getHunkCount(state: any, repository: Repository, revision: string | undefined, path: string): number { + if (state.sources) { + const count = state.sources[createItemId(repository, revision, path, "hunkCount")]; + return count ? count : 0; + } + return 0; +} + export function getSources( state: any, repository: Repository, revision: string | undefined, - path: string + path: string, + hunk = 0 ): File | null | undefined { if (state.sources) { - return state.sources[createItemId(repository, revision, path)]?.sources; + return state.sources[createItemId(repository, revision, path, hunk)]?.sources; } return null; } -export function isFetchSourcesPending(state: any, repository: Repository, revision: string, path: string): boolean { - return state && isPending(state, FETCH_SOURCES, createItemId(repository, revision, path)); +export function isFetchSourcesPending( + state: any, + repository: Repository, + revision: string, + path: string, + hunk = 0 +): boolean { + if (state.sources) { + return state.sources[createItemId(repository, revision, path, hunk)]?.pending; + } + return false; } -function isUpdateSourcePending(state: any, repository: Repository, revision: string, path: string): boolean { - return state?.sources && state.sources[createItemId(repository, revision, path)]?.updatePending; +export function isUpdateSourcePending( + state: any, + repository: Repository, + revision: string, + path: string, + hunk: number +): boolean { + if (state.sources) { + return state.sources[createItemId(repository, revision, path, hunk)]?.updatePending; + } + return false; } export function getFetchSourcesFailure( state: any, repository: Repository, revision: string, - path: string + path: string, + hunk = 0 ): Error | null | undefined { - return getFailure(state, FETCH_SOURCES, createItemId(repository, revision, path)); + if (state.sources) { + return state.sources && state.sources[createItemId(repository, revision, path, hunk)]?.error; + } + return null; } diff --git a/scm-webapp/src/main/java/sonia/scm/api/v2/resources/BaseFileObjectDtoMapper.java b/scm-webapp/src/main/java/sonia/scm/api/v2/resources/BaseFileObjectDtoMapper.java index bb74bc5045..be9621f0b7 100644 --- a/scm-webapp/src/main/java/sonia/scm/api/v2/resources/BaseFileObjectDtoMapper.java +++ b/scm-webapp/src/main/java/sonia/scm/api/v2/resources/BaseFileObjectDtoMapper.java @@ -10,6 +10,7 @@ import sonia.scm.repository.BrowserResult; import sonia.scm.repository.FileObject; import sonia.scm.repository.NamespaceAndName; import sonia.scm.repository.SubRepository; +import sonia.scm.repository.spi.BrowseCommandRequest; import javax.inject.Inject; @@ -30,7 +31,7 @@ abstract class BaseFileObjectDtoMapper extends HalAppenderMapper implements Inst abstract SubRepositoryDto mapSubrepository(SubRepository subRepository); @ObjectFactory - FileObjectDto createDto(@Context NamespaceAndName namespaceAndName, @Context BrowserResult browserResult, FileObject fileObject) { + FileObjectDto createDto(@Context NamespaceAndName namespaceAndName, @Context BrowserResult browserResult, @Context Integer offset, FileObject fileObject) { String path = removeFirstSlash(fileObject.getPath()); Links.Builder links = Links.linkingTo(); if (fileObject.isDirectory()) { @@ -39,6 +40,9 @@ abstract class BaseFileObjectDtoMapper extends HalAppenderMapper implements Inst links.self(resourceLinks.source().content(namespaceAndName.getNamespace(), namespaceAndName.getName(), browserResult.getRevision(), path)); links.single(link("history", resourceLinks.fileHistory().self(namespaceAndName.getNamespace(), namespaceAndName.getName(), browserResult.getRevision(), path))); } + if (fileObject.isTruncated()) { + links.single(link("proceed", resourceLinks.source().content(namespaceAndName.getNamespace(), namespaceAndName.getName(), browserResult.getRevision(), path) + "?offset=" + (offset + BrowseCommandRequest.DEFAULT_REQUEST_LIMIT))); + } Embedded.Builder embeddedBuilder = embeddedBuilder(); applyEnrichers(links, embeddedBuilder, namespaceAndName, browserResult, fileObject); diff --git a/scm-webapp/src/main/java/sonia/scm/api/v2/resources/BrowserResultToFileObjectDtoMapper.java b/scm-webapp/src/main/java/sonia/scm/api/v2/resources/BrowserResultToFileObjectDtoMapper.java index f9304881e7..0b069fcbe4 100644 --- a/scm-webapp/src/main/java/sonia/scm/api/v2/resources/BrowserResultToFileObjectDtoMapper.java +++ b/scm-webapp/src/main/java/sonia/scm/api/v2/resources/BrowserResultToFileObjectDtoMapper.java @@ -1,14 +1,19 @@ package sonia.scm.api.v2.resources; +import com.google.common.annotations.VisibleForTesting; import de.otto.edison.hal.Embedded; import de.otto.edison.hal.Links; import org.mapstruct.Context; import org.mapstruct.Mapper; import org.mapstruct.Mapping; +import org.mapstruct.ObjectFactory; import org.mapstruct.Qualifier; import sonia.scm.repository.BrowserResult; import sonia.scm.repository.FileObject; import sonia.scm.repository.NamespaceAndName; +import sonia.scm.repository.SubRepository; +import sonia.scm.repository.spi.BrowseCommand; +import sonia.scm.repository.spi.BrowseCommandRequest; import javax.inject.Inject; import java.lang.annotation.ElementType; @@ -19,19 +24,23 @@ import java.time.Instant; import java.util.Optional; import java.util.OptionalLong; +import static de.otto.edison.hal.Embedded.embeddedBuilder; +import static de.otto.edison.hal.Link.link; + @Mapper public abstract class BrowserResultToFileObjectDtoMapper extends BaseFileObjectDtoMapper { - FileObjectDto map(BrowserResult browserResult, @Context NamespaceAndName namespaceAndName) { - FileObjectDto fileObjectDto = fileObjectToDto(browserResult.getFile(), namespaceAndName, browserResult); + FileObjectDto map(BrowserResult browserResult, NamespaceAndName namespaceAndName, int offset) { + FileObjectDto fileObjectDto = fileObjectToDto(browserResult.getFile(), namespaceAndName, browserResult, offset); fileObjectDto.setRevision(browserResult.getRevision()); + return fileObjectDto; } @Mapping(target = "attributes", ignore = true) // We do not map HAL attributes @Mapping(target = "children", qualifiedBy = Children.class) @Children - protected abstract FileObjectDto fileObjectToDto(FileObject fileObject, @Context NamespaceAndName namespaceAndName, @Context BrowserResult browserResult); + protected abstract FileObjectDto fileObjectToDto(FileObject fileObject, @Context NamespaceAndName namespaceAndName, @Context BrowserResult browserResult, @Context Integer offset); @Override void applyEnrichers(Links.Builder links, Embedded.Builder embeddedBuilder, NamespaceAndName namespaceAndName, BrowserResult browserResult, FileObject fileObject) { diff --git a/scm-webapp/src/main/java/sonia/scm/api/v2/resources/FileObjectDto.java b/scm-webapp/src/main/java/sonia/scm/api/v2/resources/FileObjectDto.java index b273f241dc..1d39c90f37 100644 --- a/scm-webapp/src/main/java/sonia/scm/api/v2/resources/FileObjectDto.java +++ b/scm-webapp/src/main/java/sonia/scm/api/v2/resources/FileObjectDto.java @@ -15,7 +15,6 @@ import java.util.OptionalLong; @Getter @Setter -@NoArgsConstructor public class FileObjectDto extends HalRepresentation { private String name; private String path; @@ -31,6 +30,7 @@ public class FileObjectDto extends HalRepresentation { private String revision; private boolean partialResult; private boolean computationAborted; + private boolean truncated; public FileObjectDto(Links links, Embedded embedded) { super(links, embedded); diff --git a/scm-webapp/src/main/java/sonia/scm/api/v2/resources/SourceRootResource.java b/scm-webapp/src/main/java/sonia/scm/api/v2/resources/SourceRootResource.java index dfd7dc8b5b..5e909c71b8 100644 --- a/scm-webapp/src/main/java/sonia/scm/api/v2/resources/SourceRootResource.java +++ b/scm-webapp/src/main/java/sonia/scm/api/v2/resources/SourceRootResource.java @@ -9,11 +9,12 @@ import sonia.scm.repository.api.RepositoryServiceFactory; import sonia.scm.web.VndMediaType; import javax.inject.Inject; +import javax.ws.rs.DefaultValue; import javax.ws.rs.GET; import javax.ws.rs.Path; import javax.ws.rs.PathParam; import javax.ws.rs.Produces; -import javax.ws.rs.core.Response; +import javax.ws.rs.QueryParam; import java.io.IOException; import java.net.URLDecoder; @@ -36,38 +37,39 @@ public class SourceRootResource { @Path("") @Produces(VndMediaType.SOURCE) @Operation(summary = "List of sources", description = "Returns all sources for repository head.", tags = "Repository") - public Response getAllWithoutRevision(@PathParam("namespace") String namespace, @PathParam("name") String name) throws IOException { - return getSource(namespace, name, "/", null); + public FileObjectDto getAllWithoutRevision(@PathParam("namespace") String namespace, @PathParam("name") String name, @DefaultValue("0") @QueryParam("offset") int offset) throws IOException { + return getSource(namespace, name, "/", null, offset); } @GET @Path("{revision}") @Produces(VndMediaType.SOURCE) @Operation(summary = "List of sources by revision", description = "Returns all sources for the given revision.", tags = "Repository") - public Response getAll(@PathParam("namespace") String namespace, @PathParam("name") String name, @PathParam("revision") String revision) throws IOException { - return getSource(namespace, name, "/", revision); + public FileObjectDto getAll(@PathParam("namespace") String namespace, @PathParam("name") String name, @PathParam("revision") String revision, @DefaultValue("0") @QueryParam("offset") int offset) throws IOException { + return getSource(namespace, name, "/", revision, offset); } @GET @Path("{revision}/{path: .*}") @Produces(VndMediaType.SOURCE) @Operation(summary = "List of sources by revision in path", description = "Returns all sources for the given revision in a specific path.", tags = "Repository") - public Response get(@PathParam("namespace") String namespace, @PathParam("name") String name, @PathParam("revision") String revision, @PathParam("path") String path) throws IOException { - return getSource(namespace, name, path, revision); + public FileObjectDto get(@PathParam("namespace") String namespace, @PathParam("name") String name, @PathParam("revision") String revision, @PathParam("path") String path, @DefaultValue("0") @QueryParam("offset") int offset) throws IOException { + return getSource(namespace, name, path, revision, offset); } - private Response getSource(String namespace, String repoName, String path, String revision) throws IOException { + private FileObjectDto getSource(String namespace, String repoName, String path, String revision, int offset) throws IOException { NamespaceAndName namespaceAndName = new NamespaceAndName(namespace, repoName); try (RepositoryService repositoryService = serviceFactory.create(namespaceAndName)) { BrowseCommandBuilder browseCommand = repositoryService.getBrowseCommand(); browseCommand.setPath(path); + browseCommand.setOffset(offset); if (revision != null && !revision.isEmpty()) { browseCommand.setRevision(URLDecoder.decode(revision, "UTF-8")); } BrowserResult browserResult = browseCommand.getBrowserResult(); if (browserResult != null) { - return Response.ok(browserResultToFileObjectDtoMapper.map(browserResult, namespaceAndName)).build(); + return browserResultToFileObjectDtoMapper.map(browserResult, namespaceAndName, offset); } else { throw notFound(entity("Source", path).in("Revision", revision).in(namespaceAndName)); } diff --git a/scm-webapp/src/test/java/sonia/scm/api/v2/resources/BrowserResultToFileObjectDtoMapperTest.java b/scm-webapp/src/test/java/sonia/scm/api/v2/resources/BrowserResultToFileObjectDtoMapperTest.java index 273cc25018..dc264530fd 100644 --- a/scm-webapp/src/test/java/sonia/scm/api/v2/resources/BrowserResultToFileObjectDtoMapperTest.java +++ b/scm-webapp/src/test/java/sonia/scm/api/v2/resources/BrowserResultToFileObjectDtoMapperTest.java @@ -66,7 +66,7 @@ public class BrowserResultToFileObjectDtoMapperTest { public void shouldMapAttributesCorrectly() { BrowserResult browserResult = createBrowserResult(); - FileObjectDto dto = mapper.map(browserResult, new NamespaceAndName("foo", "bar")); + FileObjectDto dto = mapper.map(browserResult, new NamespaceAndName("foo", "bar"), 0); assertEqualAttributes(browserResult, dto); } @@ -76,7 +76,7 @@ public class BrowserResultToFileObjectDtoMapperTest { BrowserResult browserResult = createBrowserResult(); NamespaceAndName namespaceAndName = new NamespaceAndName("foo", "bar"); - FileObjectDto dto = mapper.map(browserResult, namespaceAndName); + FileObjectDto dto = mapper.map(browserResult, namespaceAndName, 0); assertThat(dto.getEmbedded().getItemsBy("children")).hasSize(2); } @@ -86,7 +86,7 @@ public class BrowserResultToFileObjectDtoMapperTest { BrowserResult browserResult = createBrowserResult(); NamespaceAndName namespaceAndName = new NamespaceAndName("foo", "bar"); - FileObjectDto dto = mapper.map(browserResult, namespaceAndName); + FileObjectDto dto = mapper.map(browserResult, namespaceAndName, 0); assertThat(dto.getLinks().getLinkBy("self").get().getHref()).contains("path"); }