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:
Matthias Thieroff
2022-02-15 10:59:32 +01:00
committed by GitHub
parent 8d9c18c23c
commit 44f0046f25
13 changed files with 572 additions and 127 deletions

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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