diff --git a/gradle/changelog/collapse_folders.yaml b/gradle/changelog/collapse_folders.yaml new file mode 100644 index 0000000000..1d06e3e9bb --- /dev/null +++ b/gradle/changelog/collapse_folders.yaml @@ -0,0 +1,2 @@ +- type: added + description: Collapses folders in code view which only have a folder as their only child ([#1951](https://github.com/scm-manager/scm-manager/pull/1951)) 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 1e90695ea5..d3e8669a7c 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 @@ -21,7 +21,7 @@ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE * SOFTWARE. */ - + package sonia.scm.repository.api; //~--- non-JDK imports -------------------------------------------------------- @@ -85,7 +85,6 @@ public final class BrowseCommandBuilder * * @param cacheManager cache manager * @param browseCommand implementation of the {@link BrowseCommand} - * @param browseCommand * @param repository repository to query * @param preProcessorUtil */ @@ -126,7 +125,7 @@ public final class BrowseCommandBuilder * @throws IOException */ public BrowserResult getBrowserResult() throws IOException { - BrowserResult result = null; + BrowserResult result; if (disableCache) { @@ -136,7 +135,7 @@ public final class BrowseCommandBuilder request); } - result = browseCommand.getBrowserResult(request); + result = computeBrowserResult(); } else { @@ -151,7 +150,7 @@ public final class BrowseCommandBuilder logger.debug("create browser result for {}", request); } - result = browseCommand.getBrowserResult(request); + result = computeBrowserResult(); if (result != null) { @@ -160,7 +159,7 @@ public final class BrowseCommandBuilder } else if (logger.isDebugEnabled()) { - logger.debug("retrive browser result from cache for {}", request); + logger.debug("retrieve browser result from cache for {}", request); } } @@ -172,6 +171,14 @@ public final class BrowseCommandBuilder return result; } + private BrowserResult computeBrowserResult() throws IOException { + BrowserResult result = browseCommand.getBrowserResult(request); + if (result != null && !request.isRecursive() && request.isCollapse()) { + new BrowserResultCollapser().collapseFolders(browseCommand, request, result.getFile()); + } + return result; + } + //~--- set methods ---------------------------------------------------------- /** @@ -320,6 +327,18 @@ public final class BrowseCommandBuilder return this; } + /** + * Collapse folders with only one sub-folder until a folder is empty, contains files or has more than one sub-folder + * and return the path to such folder as a single item. + * + * @param collapse {@code true} if folders with only one sub-folder should be collapsed, otherwise {@code false}. + * @since 2.31.0 + */ + public BrowseCommandBuilder setCollapse(boolean collapse) { + request.setCollapse(collapse); + return this; + } + private void updateCache(BrowserResult updatedResult) { if (!disableCache) { CacheKey key = new CacheKey(repository, request); @@ -354,7 +373,7 @@ public final class BrowseCommandBuilder public CacheKey(Repository repository, BrowseCommandRequest request) { this.repositoryId = repository.getId(); - this.request = request; + this.request = request.clone(); } //~--- methods ------------------------------------------------------------ diff --git a/scm-core/src/main/java/sonia/scm/repository/api/BrowserResultCollapser.java b/scm-core/src/main/java/sonia/scm/repository/api/BrowserResultCollapser.java new file mode 100644 index 0000000000..63f6aa542d --- /dev/null +++ b/scm-core/src/main/java/sonia/scm/repository/api/BrowserResultCollapser.java @@ -0,0 +1,80 @@ +/* + * MIT License + * + * Copyright (c) 2020-present Cloudogu GmbH and Contributors + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ + +package sonia.scm.repository.api; + +import sonia.scm.repository.BrowserResult; +import sonia.scm.repository.FileObject; +import sonia.scm.repository.spi.BrowseCommand; +import sonia.scm.repository.spi.BrowseCommandRequest; + +import java.io.IOException; +import java.util.ArrayList; +import java.util.List; + +class BrowserResultCollapser { + + private BrowseCommand browseCommand; + private BrowseCommandRequest request; + + public void collapseFolders(BrowseCommand browseCommand, BrowseCommandRequest request, FileObject fo) throws IOException { + if (!fo.isDirectory()) { + return; + } + this.browseCommand = browseCommand; + this.request = new BrowseCommandRequest(); + this.request.setRevision(request.getRevision()); + this.request.setDisableLastCommit(true); + this.request.setLimit(2); + + List collapsedChildren = new ArrayList<>(); + for (FileObject child : fo.getChildren()) { + if (child.isDirectory()) { + child = traverseFolder(child); + } + collapsedChildren.add(child); + } + fo.setChildren(collapsedChildren); + } + + private FileObject traverseFolder(FileObject parent) throws IOException { + request.setPath(parent.getPath()); + BrowserResult result = browseCommand.getBrowserResult(request); + if (isCollapsible(result.getFile())) { + FileObject child = result.getFile().getChildren().iterator().next(); + child.setName(parent.getName() + "/" + child.getName()); + return traverseFolder(child); + } + return parent; + } + + private boolean isCollapsible(FileObject fo) { + if (fo.getChildren().size() != 1) { + return false; + } + FileObject child = fo.getChildren().iterator().next(); + return child.isDirectory() && child.getSubRepository() == null; + } + +} 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 c27400970f..69ae01ad6b 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 @@ -31,19 +31,29 @@ import sonia.scm.repository.BrowserResult; import java.util.function.Consumer; /** - * * @author Sebastian Sdorra * @since 1.17 */ @EqualsAndHashCode(callSuper = true) @ToString -public final class BrowseCommandRequest extends FileBaseCommandRequest -{ +public final class BrowseCommandRequest extends FileBaseCommandRequest { public static final int DEFAULT_REQUEST_LIMIT = 100; private static final long serialVersionUID = 7956624623516803183L; + + private boolean disableLastCommit = false; + private boolean disableSubRepositoryDetection = false; + private boolean recursive = false; + 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). + @EqualsAndHashCode.Exclude + private final transient Consumer updater; + private int offset; + private boolean collapse; public BrowseCommandRequest() { this(null); @@ -54,16 +64,12 @@ public final class BrowseCommandRequest extends FileBaseCommandRequest } @Override - public BrowseCommandRequest clone() - { - BrowseCommandRequest clone = null; + public BrowseCommandRequest clone() { + BrowseCommandRequest clone; - try - { + try { clone = (BrowseCommandRequest) super.clone(); - } - catch (CloneNotSupportedException e) - { + } catch (CloneNotSupportedException e) { // this shouldn't happen, since we are Cloneable throw new InternalError("CatCommandRequest seems not to be cloneable"); @@ -73,56 +79,94 @@ public final class BrowseCommandRequest extends FileBaseCommandRequest } /** - * True to disable the last commit. - * - * - * @param disableLastCommit true to disable the last commit + * Returns true if the last commit is disabled. * + * @return true if the last commit is disabled * @since 1.26 */ - public void setDisableLastCommit(boolean disableLastCommit) - { + public boolean isDisableLastCommit() { + return disableLastCommit; + } + + /** + * True to disable the last commit. + * + * @param disableLastCommit true to disable the last commit + * @since 1.26 + */ + public void setDisableLastCommit(boolean disableLastCommit) { this.disableLastCommit = disableLastCommit; } + /** + * Returns true if the detection of sub repositories is disabled. + * + * @return true if sub repository detection is disabled. + * @since 1.26 + */ + public boolean isDisableSubRepositoryDetection() { + return disableSubRepositoryDetection; + } + /** * Enable or Disable sub repository detection. Default is enabled. * - * * @param disableSubRepositoryDetection true to disable sub repository detection - * * @since 1.26 */ public void setDisableSubRepositoryDetection( - boolean disableSubRepositoryDetection) - { + boolean disableSubRepositoryDetection) { this.disableSubRepositoryDetection = disableSubRepositoryDetection; } + /** + * Returns true if recursive file object browsing is enabled. + * + * @return true recursive is enabled + * @since 1.26 + */ + public boolean isRecursive() { + return recursive; + } + /** * True to enable recursive file object browsing. * - * * @param recursive true to enable recursive browsing - * * @since 1.26 */ - public void setRecursive(boolean recursive) - { + public void setRecursive(boolean recursive) { this.recursive = recursive; } + /** + * Returns the limit for the number of result files. + * + * @since 2.0.0 + */ + public int getLimit() { + return limit; + } + /** * 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; } + /** + * The number of the entry, the result start with. All preceding entries will be omitted. + * + * @since 2.0.0 + */ + public int getOffset() { + return offset; + } + /** * Proceed the list from the given number on (zero based). * @@ -134,63 +178,25 @@ public final class BrowseCommandRequest extends FileBaseCommandRequest this.offset = offset; } - //~--- get methods ---------------------------------------------------------- - /** - * Returns true if the last commit is disabled. + * Returns whether empty folders are collapsed until a folder has content and return the path to such folder as a + * single item or not. * - * - * @return true if the last commit is disabled - * - * @since 1.26 + * @return {@code true} if empty folders are collapsed, otherwise {@code false} + * @since 2.30.3 */ - public boolean isDisableLastCommit() - { - return disableLastCommit; + public boolean isCollapse() { + return collapse; } /** - * Returns true if the detection of sub repositories is disabled. + * Collapse empty folders until a folder has content and return the path to such folder as a single item. * - * - * @return true if sub repository detection is disabled. - * - * @since 1.26 + * @param collapse {@code true} if empty folders should be collapsed, otherwise {@code false}. + * @since 2.30.3 */ - public boolean isDisableSubRepositoryDetection() - { - return disableSubRepositoryDetection; - } - - /** - * Returns true if recursive file object browsing is enabled. - * - * - * @return true recursive is enabled - * - * @since 1.26 - */ - public boolean isRecursive() - { - 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 setCollapse(boolean collapse) { + this.collapse = collapse; } public void updateCache(BrowserResult update) { @@ -199,23 +205,4 @@ public final class BrowseCommandRequest extends FileBaseCommandRequest } } - //~--- fields --------------------------------------------------------------- - - /** disable last commit */ - private boolean disableLastCommit = false; - - /** disable detection of sub repositories */ - private boolean disableSubRepositoryDetection = false; - - /** 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). - @EqualsAndHashCode.Exclude - private final transient Consumer updater; } diff --git a/scm-core/src/test/java/sonia/scm/repository/api/BrowserResultCollapserTest.java b/scm-core/src/test/java/sonia/scm/repository/api/BrowserResultCollapserTest.java new file mode 100644 index 0000000000..0c629728ba --- /dev/null +++ b/scm-core/src/test/java/sonia/scm/repository/api/BrowserResultCollapserTest.java @@ -0,0 +1,309 @@ +/* + * MIT License + * + * Copyright (c) 2020-present Cloudogu GmbH and Contributors + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ + +package sonia.scm.repository.api; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.mockito.stubbing.Answer; +import sonia.scm.repository.BrowserResult; +import sonia.scm.repository.FileObject; +import sonia.scm.repository.SubRepository; +import sonia.scm.repository.spi.BrowseCommand; +import sonia.scm.repository.spi.BrowseCommandRequest; + +import java.util.Collection; +import java.util.HashMap; +import java.util.Map; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +class BrowserResultCollapserTest { + + @Mock + private BrowseCommand browseCommand; + + private Map browseResults; + + @BeforeEach + void setUp() throws Exception { + browseResults = new HashMap<>(); + when(browseCommand.getBrowserResult(any(BrowseCommandRequest.class))) + .thenAnswer( + (Answer) invocation -> { + BrowseCommandRequest request = (BrowseCommandRequest) invocation.getArguments()[0]; + return createBrowserResult(browseResults.get(request.getPath())); + } + ); + } + + /* + / + ├─ folder_a + │ └─ file + └─ folder_b + └─ file + */ + @Test + void collapseFoldersShouldNotCollapseNonEmptyFolder() throws Exception { + FileObject root = createFolder(null, ""); + + FileObject folder_a = createFolder(root, "folder_a"); + createFile(folder_a); + + FileObject folder_b = createFolder(root, "folder_b"); + createFile(folder_b); + + BrowserResult result = new BrowserResult("revision", root); + BrowseCommandRequest request = new BrowseCommandRequest(); + + new BrowserResultCollapser().collapseFolders(browseCommand, request, result.getFile()); + + FileObject f = result.getFile(); + Collection children = f.getChildren(); + assertThat(children).hasSize(2); + assertContains(children, "folder_a", "folder_a"); + assertContains(children, "folder_b", "folder_b"); + } + + /* + / + ├─ folder_a + │ └─ file + └─ folder_b + └─ subfolder + └─ file + */ + @Test + void collapseFoldersShouldCollapseFolderWithJustOneSubFolder() throws Exception { + FileObject root = createFolder(null, ""); + + FileObject folder_a = createFolder(root, "folder_a"); + createFile(folder_a); + + FileObject folder_b = createFolder(root, "folder_b"); + FileObject subfolder = createFolder(folder_b, "subfolder"); + createFile(subfolder); + + BrowserResult result = new BrowserResult("revision", root); + BrowseCommandRequest request = new BrowseCommandRequest(); + + new BrowserResultCollapser().collapseFolders(browseCommand, request, result.getFile()); + + FileObject f = result.getFile(); + Collection children = f.getChildren(); + assertThat(children).hasSize(2); + assertContains(children, "folder_a", "folder_a"); + assertContains(children, "folder_b/subfolder", "folder_b/subfolder"); + } + + /* + / + ├─ folder_a + │ └─ file + ├─ folder_b + │ ├─ subfolder_a + │ │ └─ subfolder_a_subfolder + │ │ └─ file + │ └─ subfolder_b + └─ folder_c + └─ subfolder_a + └─ file + */ + @Test + void collapseFoldersShouldNotCollapseFolderWithMoreThanSingleSubFolder() throws Exception { + FileObject root = createFolder(null, ""); + + FileObject folder_a = createFolder(root, "folder_a"); + createFile(folder_a); + + FileObject folder_b = createFolder(root, "folder_b"); + FileObject subfolder_a = createFolder(folder_b, "subfolder_a"); + FileObject subfolder_a_subfolder = createFolder(subfolder_a, "subfolder_a_subfolder"); + createFile(subfolder_a_subfolder); + createFolder(folder_b, "subfolder_b"); + + FileObject folder_c = createFolder(root, "folder_c"); + FileObject subfolder_b = createFolder(folder_c, "subfolder_b"); + createFile(subfolder_b); + + FileObject folder_d = createFolder(root, "folder_d"); + createFolder(folder_d, "subfolder_c"); + + BrowserResult result = new BrowserResult("revision", root); + BrowseCommandRequest request = new BrowseCommandRequest(); + + new BrowserResultCollapser().collapseFolders(browseCommand, request, result.getFile()); + + FileObject f = result.getFile(); + Collection children = f.getChildren(); + assertThat(children).hasSize(4); + assertContains(children, "folder_a", "folder_a"); + assertContains(children, "folder_b", "folder_b"); + assertContains(children, "folder_c/subfolder_b", "folder_c/subfolder_b"); + assertContains(children, "folder_d/subfolder_c", "folder_d/subfolder_c"); + } + + /* + / + ├─ folder_a + │ └─ file + └─ folder_b + └─ subrepository + */ + @Test + void collapseFoldersShouldNotCollapseSubRepositoryFolder() throws Exception { + FileObject root = createFolder(null, ""); + + FileObject folder_a = createFolder(root, "folder_a"); + createFile(folder_a); + + FileObject folder_b = createFolder(root, "folder_b"); + FileObject subfolder = createFolder(folder_b, "subfolder"); + subfolder.setSubRepository(mock(SubRepository.class)); + + BrowserResult result = new BrowserResult("revision", root); + BrowseCommandRequest request = new BrowseCommandRequest(); + + new BrowserResultCollapser().collapseFolders(browseCommand, request, result.getFile()); + + FileObject f = result.getFile(); + Collection children = f.getChildren(); + assertThat(children).hasSize(2); + assertContains(children, "folder_a", "folder_a"); + assertContains(children, "folder_b", "folder_b"); + } + + /* + / + ├─ scm-plugins + │ ├─ build.gradle + │ └─ gradle.lockfile + ├─ scm-server + │ ├─ src + │ │ └─ main + │ │ └─ java + │ │ └─ sonia + │ │ └─ scm + │ │ └─ server + │ │ ├─ ScmServer.java + │ │ ├─ ScmServerDaemon.java + │ │ └─ ScmServerException.java + │ ├─ build.gradle + │ └─ gradle.lockfile + ├─ scm-test + │ ├─ build.gradle + │ └─ gradle.lockfile + ├─ .dockerignore + └─ .editorconfig + */ + @Test + void collapseFoldersShouldWorkProperlyWithRealLifeExample() throws Exception { + FileObject root = createFolder(null, ""); + + FileObject scmPlugins = createFolder(root, "scm-plugins"); + createFile(scmPlugins, "build.gradle"); + createFile(scmPlugins, "gradle.lockfile"); + + FileObject scmServer = createFolder(root, "scm-server"); + FileObject src = createFolder(scmServer, "src"); + FileObject main = createFolder(src, "main"); + FileObject java = createFolder(main, "java"); + FileObject sonia = createFolder(java, "sonia"); + FileObject scm = createFolder(sonia, "scm"); + FileObject server = createFolder(scm, "server"); + createFile(server, "ScmServer.java"); + createFile(server, "ScmServerDaemon.java"); + createFile(server, "ScmServerException.java"); + createFile(scmServer, "build.gradle"); + createFile(scmServer, "gradle.lockfile"); + + FileObject scmTest = createFolder(root, "scm-test"); + createFile(scmTest, "build.gradle"); + createFile(scmTest, "gradle.lockfile"); + + createFile(root, ".dockerignore"); + createFile(root, ".editorconfig"); + + BrowserResult result = new BrowserResult("revision", scmServer); + BrowseCommandRequest request = new BrowseCommandRequest(); + + new BrowserResultCollapser().collapseFolders(browseCommand, request, result.getFile()); + + FileObject f = result.getFile(); + Collection children = f.getChildren(); + assertThat(children).hasSize(3); + assertContains(children, "src/main/java/sonia/scm/server", "scm-server/src/main/java/sonia/scm/server"); + assertContains(children, "build.gradle", "scm-server/build.gradle"); + assertContains(children, "gradle.lockfile", "scm-server/gradle.lockfile"); + } + + private void assertContains(Collection children, String name, String path) { + assertThat(children) + .as("%s not found", name) + .anyMatch(c -> c.getName().equals(name) && c.getPath().equals(path)); + } + + private BrowserResult createBrowserResult(FileObject f) { + return new BrowserResult("revision", f); + } + + private FileObject createFolder(FileObject parent, String name) { + FileObject f = createFileObject(parent, name); + f.setDirectory(true); + if (parent != null) { + parent.addChild(f); + } + return f; + } + + private void createFile(FileObject parent) { + createFile(parent, "file"); + } + + private void createFile(FileObject parent, String name) { + FileObject f = createFileObject(parent, name); + f.setDirectory(false); + if (parent != null) { + parent.addChild(f); + } + } + + private FileObject createFileObject(FileObject parent, String name) { + FileObject f = new FileObject(); + f.setName(name); + String path = (parent != null && !parent.getPath().equals("") ? parent.getPath() + "/" : "") + name; + f.setPath(path); + browseResults.put(path, f); + return f; + } + +} 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 8378285e52..35ae14b98f 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 @@ -128,6 +128,8 @@ public class GitBrowseCommand extends AbstractGitCommand throws IOException { logger.debug("try to create browse result for {}", request); + resultCount = 0; + this.request = request; repo = open(); revId = computeRevIdToBrowse(); 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 f0de5b2d17..64877a1aa7 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 @@ -167,7 +167,11 @@ public class HgFileviewCommand extends AbstractCommand HgInputStream stream = launchStream(); - return new HgFileviewCommandResultReader(stream, disableLastCommit).parseResult(); + try { + return new HgFileviewCommandResultReader(stream, disableLastCommit).parseResult(); + } finally { + stream.close(); + } } /** 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 ccd5fed4c8..f52ac9a67b 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 @@ -191,6 +191,23 @@ public class HgBrowseCommandTest extends AbstractHgCommandTestBase { assertThat(root.isTruncated()).isTrue(); } + @Test + public void testMultipleCallsOfSameRequest() throws IOException { + BrowseCommandRequest request = new BrowseCommandRequest(); + request.setLimit(1); + + for (int i = 0; i < 5; ++i) { + HgBrowseCommand hgBrowseCommand = new HgBrowseCommand(cmdContext); + BrowserResult result = hgBrowseCommand.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(); 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 6342268058..dd3ad615d0 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 @@ -80,6 +80,8 @@ public class SvnBrowseCommand extends AbstractSvnCommand logger.debug("browser repository {} in path \"{}\" at revision {}", repository, path, revisionNumber); } + resultCount = 0; + BrowserResult result = null; try { diff --git a/scm-ui/ui-api/src/sources.test.ts b/scm-ui/ui-api/src/sources.test.ts index bafb5b6f51..0af56ad761 100644 --- a/scm-ui/ui-api/src/sources.test.ts +++ b/scm-ui/ui-api/src/sources.test.ts @@ -126,7 +126,7 @@ describe("Test sources hooks", () => { describe("useSources tests", () => { it("should return root directory", async () => { const queryClient = createInfiniteCachingClient(); - fetchMock.getOnce("/api/v2/src", rootDirectory); + fetchMock.getOnce("/api/v2/src?collapse=true", rootDirectory); const { result, waitFor } = renderHook(() => useSources(puzzle42), { wrapper: createWrapper(undefined, queryClient), }); @@ -136,7 +136,7 @@ describe("Test sources hooks", () => { it("should return file from url with revision and path", async () => { const queryClient = createInfiniteCachingClient(); - fetchMock.getOnce("/api/v2/src/abc/README.md", readmeMd); + fetchMock.getOnce("/api/v2/src/abc/README.md?collapse=true", readmeMd); const { result, waitFor } = renderHook(() => useSources(puzzle42, { revision: "abc", path: "README.md" }), { wrapper: createWrapper(undefined, queryClient), }); @@ -146,7 +146,7 @@ describe("Test sources hooks", () => { it("should fetch next page", async () => { const queryClient = createInfiniteCachingClient(); - fetchMock.getOnce("/api/v2/src", mainDirectoryTruncated); + fetchMock.getOnce("/api/v2/src?collapse=true", mainDirectoryTruncated); fetchMock.getOnce("/api/v2/src/2", mainDirectory); const { result, waitFor, waitForNextUpdate } = renderHook(() => useSources(puzzle42), { wrapper: createWrapper(undefined, queryClient), @@ -168,7 +168,7 @@ describe("Test sources hooks", () => { it("should refetch if partial files exists", async () => { const queryClient = createInfiniteCachingClient(); fetchMock.get( - "/api/v2/src", + "/api/v2/src?collapse=true", { ...mainDirectory, _embedded: { @@ -180,7 +180,7 @@ describe("Test sources hooks", () => { } ); fetchMock.get( - "/api/v2/src", + "/api/v2/src?collapse=true", { ...mainDirectory, _embedded: { @@ -206,9 +206,9 @@ describe("Test sources hooks", () => { it("should not refetch if computation is aborted", async () => { const queryClient = createInfiniteCachingClient(); - fetchMock.getOnce("/api/v2/src/abc/main/special.md", sepecialMdComputationAborted, { repeat: 1 }); + fetchMock.getOnce("/api/v2/src/abc/main/special.md?collapse=true", sepecialMdComputationAborted, { repeat: 1 }); // should never be called - fetchMock.getOnce("/api/v2/src/abc/main/special.md", sepecialMd, { + fetchMock.getOnce("/api/v2/src/abc/main/special.md?collapse=true", sepecialMd, { repeat: 1, overwriteRoutes: false, }); diff --git a/scm-ui/ui-api/src/sources.ts b/scm-ui/ui-api/src/sources.ts index c0fb20d324..b9bbf260e2 100644 --- a/scm-ui/ui-api/src/sources.ts +++ b/scm-ui/ui-api/src/sources.ts @@ -28,17 +28,20 @@ import * as urls from "./urls"; import { useInfiniteQuery } from "react-query"; import { repoQueryKey } from "./keys"; import { useEffect } from "react"; +import { createQueryString } from "./utils"; export type UseSourcesOptions = { revision?: string; path?: string; refetchPartialInterval?: number; enabled?: boolean; + collapse?: boolean; }; const UseSourcesDefaultOptions: UseSourcesOptions = { enabled: true, refetchPartialInterval: 3000, + collapse: true }; export const useSources = (repository: Repository, opts: UseSourcesOptions = UseSourcesDefaultOptions) => { @@ -48,7 +51,7 @@ export const useSources = (repository: Repository, opts: UseSourcesOptions = Use }; const link = createSourcesLink(repository, options); const { isLoading, error, data, isFetchingNextPage, fetchNextPage, refetch } = useInfiniteQuery( - repoQueryKey(repository, "sources", options.revision || "", options.path || ""), + repoQueryKey(repository, "sources", options.revision || "", options.path || "", options.collapse ? "collapse" : ""), ({ pageParam }) => { return apiClient.get(pageParam || link).then((response) => response.json()); }, @@ -93,6 +96,9 @@ const createSourcesLink = (repository: Repository, options: UseSourcesOptions) = link = urls.concat(link, options.path); } } + if (options.collapse) { + return `${link}?${createQueryString({ collapse: "true" })}`; + } return link; }; 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 1256742804..791576c9e1 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 @@ -60,32 +60,33 @@ public class SourceRootResource { @Path("") @Produces(VndMediaType.SOURCE) @Operation(summary = "List of sources", description = "Returns all sources for repository head.", tags = "Repository") - 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); + public FileObjectDto getAllWithoutRevision(@PathParam("namespace") String namespace, @PathParam("name") String name, @DefaultValue("0") @QueryParam("offset") int offset, @DefaultValue("false") @QueryParam("collapse") boolean collapse) throws IOException { + return getSource(namespace, name, "/", null, offset, collapse); } @GET @Path("{revision}") @Produces(VndMediaType.SOURCE) @Operation(summary = "List of sources by revision", description = "Returns all sources for the given revision.", tags = "Repository") - 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); + public FileObjectDto getAll(@PathParam("namespace") String namespace, @PathParam("name") String name, @PathParam("revision") String revision, @DefaultValue("0") @QueryParam("offset") int offset, @DefaultValue("false") @QueryParam("collapse") boolean collapse) throws IOException { + return getSource(namespace, name, "/", revision, offset, collapse); } @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 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); + 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, @DefaultValue("false") @QueryParam("collapse") boolean collapse) throws IOException { + return getSource(namespace, name, path, revision, offset, collapse); } - private FileObjectDto getSource(String namespace, String repoName, String path, String revision, int offset) throws IOException { + private FileObjectDto getSource(String namespace, String repoName, String path, String revision, int offset, boolean collapse) throws IOException { NamespaceAndName namespaceAndName = new NamespaceAndName(namespace, repoName); try (RepositoryService repositoryService = serviceFactory.create(namespaceAndName)) { BrowseCommandBuilder browseCommand = repositoryService.getBrowseCommand(); browseCommand.setPath(path); browseCommand.setOffset(offset); + browseCommand.setCollapse(collapse); if (revision != null && !revision.isEmpty()) { browseCommand.setRevision(revision); } diff --git a/scm-webapp/src/test/java/sonia/scm/api/v2/resources/SourceRootResourceTest.java b/scm-webapp/src/test/java/sonia/scm/api/v2/resources/SourceRootResourceTest.java index c3f6d5b185..b03c36e3e8 100644 --- a/scm-webapp/src/test/java/sonia/scm/api/v2/resources/SourceRootResourceTest.java +++ b/scm-webapp/src/test/java/sonia/scm/api/v2/resources/SourceRootResourceTest.java @@ -24,7 +24,6 @@ package sonia.scm.api.v2.resources; -import com.google.inject.util.Providers; import org.jboss.resteasy.mock.MockHttpRequest; import org.jboss.resteasy.mock.MockHttpResponse; import org.junit.Before; @@ -47,13 +46,13 @@ import java.net.URI; import java.net.URISyntaxException; import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; - @RunWith(MockitoJUnitRunner.Silent.class) public class SourceRootResourceTest extends RepositoryTestBase { - private RestDispatcher dispatcher = new RestDispatcher(); + private final RestDispatcher dispatcher = new RestDispatcher(); private final URI baseUri = URI.create("/"); private final ResourceLinks resourceLinks = ResourceLinksMock.createMock(baseUri); @@ -64,12 +63,9 @@ public class SourceRootResourceTest extends RepositoryTestBase { @Mock private BrowseCommandBuilder browseCommandBuilder; - private BrowserResultToFileObjectDtoMapper browserResultToFileObjectDtoMapper; - - @Before public void prepareEnvironment() { - browserResultToFileObjectDtoMapper = Mappers.getMapper(BrowserResultToFileObjectDtoMapper.class); + BrowserResultToFileObjectDtoMapper browserResultToFileObjectDtoMapper = Mappers.getMapper(BrowserResultToFileObjectDtoMapper.class); browserResultToFileObjectDtoMapper.setResourceLinks(resourceLinks); when(serviceFactory.create(new NamespaceAndName("space", "repo"))).thenReturn(service); when(service.getBrowseCommand()).thenReturn(browseCommandBuilder); @@ -87,9 +83,9 @@ public class SourceRootResourceTest extends RepositoryTestBase { dispatcher.invoke(request, response); assertThat(response.getStatus()).isEqualTo(200); - System.out.println(response.getContentAsString()); - assertThat(response.getContentAsString()).contains("\"revision\":\"revision\""); - assertThat(response.getContentAsString()).contains("\"children\":"); + String content = response.getContentAsString(); + assertThat(content).contains("\"revision\":\"revision\""); + assertThat(content).contains("\"children\":"); } @Test @@ -129,6 +125,26 @@ public class SourceRootResourceTest extends RepositoryTestBase { assertThat(response.getStatus()).isEqualTo(404); } + @Test + public void collapseShouldBeFalseByDefault() throws Exception { + MockHttpRequest request = MockHttpRequest.get("/" + RepositoryRootResource.REPOSITORIES_PATH_V2 + "space/repo/sources"); + MockHttpResponse response = new MockHttpResponse(); + + dispatcher.invoke(request, response); + + verify(browseCommandBuilder).setCollapse(false); + } + + @Test + public void shouldSetCollapseToTrue() throws Exception { + MockHttpRequest request = MockHttpRequest.get("/" + RepositoryRootResource.REPOSITORIES_PATH_V2 + "space/repo/sources?collapse=true"); + MockHttpResponse response = new MockHttpResponse(); + + dispatcher.invoke(request, response); + + verify(browseCommandBuilder).setCollapse(true); + } + private BrowserResult createBrowserResult() { return new BrowserResult("revision", createFileObject()); }