Merge pull request #1032 from scm-manager/feature/browse_commit_with_limit

Feature browse sources with limit and offset
This commit is contained in:
Sebastian Sdorra
2020-03-12 11:30:36 +01:00
committed by GitHub
29 changed files with 1693 additions and 410 deletions

View File

@@ -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

View File

@@ -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<FileObject> children = new ArrayList<>();
private boolean truncated;
}

View File

@@ -300,6 +300,35 @@ public final class BrowseCommandBuilder
return this;
}
/**
* Limit the number of result files to <code>limit</code> 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 <b>not</b> 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 <b>not</b>
* 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);

View File

@@ -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 <T> void sort(List<T> entries, Function<T, Boolean> isDirectory, Function<T, String> 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;
}
});
}
}

View File

@@ -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 <code>limit</code> 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 <code>limit</code> 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<BrowserResult> updater;

View File

@@ -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<Entry> 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;
}
}
}

View File

@@ -744,14 +744,13 @@ public final class GitUtil
public static Optional<LfsPointer> 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<LfsPointer> getLfsPointer(org.eclipse.jgit.lib.Repository repo, TreeWalk treeWalk, Attributes attributes) throws IOException {
public static Optional<LfsPointer> 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));
}

View File

@@ -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> lfsPointer = getLfsPointer(repo, path, commit, treeWalk);
Optional<LfsPointer> 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<FileObject> 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<TreeEntry> entries) throws IOException {
List<FileObject> files = Lists.newArrayList();
Iterator<TreeEntry> 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<TreeEntry> createTree(String path, TreeEntry parent, org.eclipse.jgit.lib.Repository repo, BrowseCommandRequest request, TreeWalk treeWalk) throws IOException {
List<TreeEntry> 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<TreeEntry> 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<LfsPointer> getLfsPointer(org.eclipse.jgit.lib.Repository repo, String path, RevCommit commit, TreeWalk treeWalk) {
private Optional<LfsPointer> 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<TreeEntry> 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<TreeEntry> getChildren() {
return children;
}
void setChildren(List<TreeEntry> children) {
sort(children, TreeEntry::isDirectory, TreeEntry::getNameString);
this.children = children;
}
}
}

View File

@@ -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<FileObject> 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<FileObject> 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<FileObject> 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<FileObject> 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<FileObject> 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<FileObject> foList = root.getChildren();
assertThat(foList)
.extracting("name")
.containsExactly("c", "a.txt");
FileObject c = findFile(foList, "c");
Collection<FileObject> 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<FileObject> foList = root.getChildren();
assertThat(foList)
.extracting("name")
.containsExactly("c");
FileObject c = findFile(foList, "c");
Collection<FileObject> 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<FileObject> foList = root.getChildren();
assertThat(foList)
.extracting("name")
.containsExactly("c", "a.txt", "b.txt", "f.txt");
FileObject c = findFile(foList, "c");
Collection<FileObject> cChildren = c.getChildren();
assertThat(cChildren)
.extracting("name")
.containsExactly("e.txt");
}
private FileObject findFile(Collection<FileObject> foList, String name) {
return foList.stream()
.filter(f -> name.equals(f.getName()))

View File

@@ -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);
}

View File

@@ -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 <code>limit</code> 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<FileObject> 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;
}
}

View File

@@ -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<FileObject> 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<FileObject> 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<FileObject> 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<FileObject> 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;
}
}

View File

@@ -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()

View File

@@ -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)

View File

@@ -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<FileObject> 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<FileObject> 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<FileObject> 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<FileObject> 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<FileObject> foList = root.getChildren();
assertThat(foList)
.extracting("name")
.containsExactly("c", "a.txt");
FileObject c = getFileObject(foList, "c");
Collection<FileObject> 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<FileObject> foList = root.getChildren();
assertThat(foList)
.extracting("name")
.containsExactly("c");
FileObject c = getFileObject(foList, "c");
Collection<FileObject> 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<FileObject> foList = root.getChildren();
assertThat(foList)
.extracting("name")
.containsExactly("c", "a.txt", "b.txt", "f.txt");
FileObject c = getFileObject(foList, "c");
Collection<FileObject> cChildren = c.getChildren();
assertThat(cChildren)
.extracting("name")
.containsExactly("e.txt");
}
//~--- get methods ----------------------------------------------------------
/**
@@ -210,8 +317,7 @@ public class HgBrowseCommandTest extends AbstractHgCommandTestBase {
Collection<FileObject> 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;
}

View File

@@ -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<FileObject> 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);
}
}
}

View File

@@ -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<SVNDirEntry> entries = svnRepository.getDir(parent.getPath(), revisionNumber, null, (Collection) null);
for (SVNDirEntry entry : entries)
{
List<SVNDirEntry> entries = new ArrayList<>(svnRepository.getDir(parent.getPath(), revisionNumber, null, (Collection) null));
sort(entries, entry -> entry.getKind() == SVNNodeKind.DIR, SVNDirEntry::getName);
for (Iterator<SVNDirEntry> 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);
}
}

View File

@@ -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<FileObject> foList = getRootFromTip(new BrowseCommandRequest());
BrowserResult result = createCommand().getBrowserResult(new BrowseCommandRequest());
FileObject a = getFileObject(foList, "a.txt");
FileObject c = getFileObject(foList, "c");
assertNotNull(result);
Collection<FileObject> foList = result.getFile().getChildren();
assertThat(foList).extracting("name").containsExactly("c", "a.txt");
Iterator<FileObject> 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<FileObject> 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<FileObject> 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<FileObject> foList = getRootFromTip(request);
BrowserResult result = createCommand().getBrowserResult(request);
assertNotNull(result);
Collection<FileObject> foList1 = result.getFile().getChildren();
assertNotNull(foList1);
assertFalse(foList1.isEmpty());
assertEquals(2, foList1.size());
Collection<FileObject> 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<FileObject> 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<FileObject> 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<FileObject> foList = root.getChildren();
assertThat(foList)
.extracting("name")
.containsExactly("c", "a.txt");
FileObject c = getFileObject(foList, "c");
Collection<FileObject> 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<FileObject> foList = root.getChildren();
assertThat(foList)
.extracting("name")
.containsExactly("c");
FileObject c = getFileObject(foList, "c");
Collection<FileObject> 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<FileObject> foList = root.getChildren();
assertThat(foList)
.extracting("name")
.containsExactly("c", "a.txt");
FileObject c = getFileObject(foList, "c");
Collection<FileObject> 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<FileObject> getRootFromTip(BrowseCommandRequest request) {
BrowserResult result = createCommand().getBrowserResult(request);
assertNotNull(result);
Collection<FileObject> foList = result.getFile().getChildren();
assertNotNull(foList);
assertFalse(foList.isEmpty());
assertEquals(2, foList.size());
return foList;
}
}

View File

@@ -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;

View File

@@ -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",

View File

@@ -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",

View File

@@ -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<Props, State> {
constructor(props: Props) {
super(props);
this.state = {};
this.state = { stoppableUpdateHandler: [] };
}
componentDidUpdate(prevProps: Readonly<Props>, prevState: Readonly<State>): 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 (
<Notification type={"info"}>
<div className={"columns is-centered"}>
<div className={"column"}>{t("sources.moreFilesAvailable", { count: fileCount })}</div>
<Button label={t("sources.loadMore")} action={this.loadMore} />
</div>
</Notification>
);
}
};
render() {
const { error, loading, tree } = this.props;
const { hunks } = this.props;
if (error) {
return <ErrorNotification error={error} />;
}
if (loading) {
return <Loading />;
}
if (!tree) {
if (!hunks || hunks.length === 0) {
return null;
}
return <div className="panel-block">{this.renderSourcesTable()}</div>;
if (hunks[0]?.error) {
return <ErrorNotification error={hunks[0].error} />;
}
return (
<div className="panel-block">
{this.renderSourcesTable()}
{this.renderTruncatedInfo()}
</div>
);
}
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<Props, State> {
});
}
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 <Loading />;
}
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 (
<table className="table table-hover table-sm is-fullwidth">
<thead>
<tr>
<FixedWidthTh />
<th>{t("sources.file-tree.name")}</th>
<th className="is-hidden-mobile">{t("sources.file-tree.length")}</th>
<th className="is-hidden-mobile">{t("sources.file-tree.commitDate")}</th>
<th className="is-hidden-touch">{t("sources.file-tree.description")}</th>
{binder.hasExtension("repos.sources.tree.row.right") && <th className="is-hidden-mobile" />}
</tr>
</thead>
<tbody>
{files.map(file => (
<FileTreeLeaf key={file.name} file={file} baseUrl={baseUrlWithRevision} />
))}
</tbody>
</table>
<>
<table className="table table-hover table-sm is-fullwidth">
<thead>
<tr>
<FixedWidthTh />
<th>{t("sources.file-tree.name")}</th>
<th className="is-hidden-mobile">{t("sources.file-tree.length")}</th>
<th className="is-hidden-mobile">{t("sources.file-tree.commitDate")}</th>
<th className="is-hidden-touch">{t("sources.file-tree.description")}</th>
{binder.hasExtension("repos.sources.tree.row.right") && <th className="is-hidden-mobile" />}
</tr>
</thead>
<tbody>
{files.map((file: any) => (
<FileTreeLeaf key={file.name} file={file} baseUrl={baseUrlWithRevision} />
))}
</tbody>
</table>
{hunks[hunks.length - 1].loading && <Loading />}
{hunks[hunks.length - 1].error && <ErrorNotification error={hunks[hunks.length - 1].error} />}
</>
);
}
return <Notification type="info">{t("sources.noSources")}</Notification>;
@@ -153,24 +194,36 @@ class FileTree extends React.Component<Props, State> {
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
};
};

View File

@@ -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);
});
});

View File

@@ -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;
}

View File

@@ -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);

View File

@@ -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) {

View File

@@ -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);

View File

@@ -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));
}

View File

@@ -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");
}