mirror of
https://github.com/scm-manager/scm-manager.git
synced 2026-01-07 08:02:09 +01:00
Collapse folders with only one child folder (#1951)
Collapses a folder in code view which only has another folder as its only child. This lets you access a sub-folder which has content directly instead of navigating down the folder tree by clicking every folder separately. Co-authored-by: René Pfeuffer <rene.pfeuffer@cloudogu.com>
This commit is contained in:
committed by
GitHub
parent
8d9c18c23c
commit
44f0046f25
2
gradle/changelog/collapse_folders.yaml
Normal file
2
gradle/changelog/collapse_folders.yaml
Normal file
@@ -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))
|
||||
@@ -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 ------------------------------------------------------------
|
||||
|
||||
@@ -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<FileObject> 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;
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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<BrowserResult> 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 <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;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 <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).
|
||||
@EqualsAndHashCode.Exclude
|
||||
private final transient Consumer<BrowserResult> updater;
|
||||
}
|
||||
|
||||
@@ -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<String, FileObject> browseResults;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() throws Exception {
|
||||
browseResults = new HashMap<>();
|
||||
when(browseCommand.getBrowserResult(any(BrowseCommandRequest.class)))
|
||||
.thenAnswer(
|
||||
(Answer<BrowserResult>) 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<FileObject> 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<FileObject> 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<FileObject> 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<FileObject> 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<FileObject> 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<FileObject> 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;
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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();
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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<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();
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
@@ -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<File, Error, File>(
|
||||
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;
|
||||
};
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user