mirror of
https://github.com/scm-manager/scm-manager.git
synced 2025-11-08 14:35:45 +01:00
Feature Partial Diff (#1581)
With this pull request, diffs for Git are loaded in chunks. This means, that for diffs with a lot of files only a part of them are loaded. In the UI a button will be displayed to load more. In the REST API, the number of files can be specified. This only works for diffs, that are delivered as "parsed" diffs. Currently, this is only available for Git. Co-authored-by: Sebastian Sdorra <sebastian.sdorra@cloudogu.com>
This commit is contained in:
2
gradle/changelog/partial_diff.yaml
Normal file
2
gradle/changelog/partial_diff.yaml
Normal file
@@ -0,0 +1,2 @@
|
||||
- type: added
|
||||
description: Partial diff ([#1581](https://github.com/scm-manager/scm-manager/issues/1581))
|
||||
@@ -30,11 +30,11 @@ import sonia.scm.repository.spi.DiffCommandRequest;
|
||||
|
||||
import java.util.Set;
|
||||
|
||||
abstract class AbstractDiffCommandBuilder <T extends AbstractDiffCommandBuilder> {
|
||||
abstract class AbstractDiffCommandBuilder <T extends AbstractDiffCommandBuilder, R extends DiffCommandRequest> {
|
||||
|
||||
|
||||
/** request for the diff command implementation */
|
||||
final DiffCommandRequest request = new DiffCommandRequest();
|
||||
final R request = createRequest();
|
||||
|
||||
private final Set<Feature> supportedFeatures;
|
||||
|
||||
@@ -89,4 +89,6 @@ abstract class AbstractDiffCommandBuilder <T extends AbstractDiffCommandBuilder>
|
||||
}
|
||||
|
||||
abstract T self();
|
||||
|
||||
abstract R createRequest();
|
||||
}
|
||||
|
||||
@@ -31,6 +31,7 @@ import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import sonia.scm.repository.Feature;
|
||||
import sonia.scm.repository.spi.DiffCommand;
|
||||
import sonia.scm.repository.spi.DiffCommandRequest;
|
||||
|
||||
import java.io.ByteArrayOutputStream;
|
||||
import java.io.IOException;
|
||||
@@ -54,13 +55,10 @@ import java.util.Set;
|
||||
* System.out.println(content);
|
||||
* </code></pre>
|
||||
*
|
||||
*
|
||||
* TODO check current behavior.
|
||||
*
|
||||
* @author Sebastian Sdorra
|
||||
* @since 1.17
|
||||
*/
|
||||
public final class DiffCommandBuilder extends AbstractDiffCommandBuilder<DiffCommandBuilder>
|
||||
public final class DiffCommandBuilder extends AbstractDiffCommandBuilder<DiffCommandBuilder, DiffCommandRequest>
|
||||
{
|
||||
|
||||
/**
|
||||
@@ -161,6 +159,11 @@ public final class DiffCommandBuilder extends AbstractDiffCommandBuilder<DiffCom
|
||||
return this;
|
||||
}
|
||||
|
||||
@Override
|
||||
DiffCommandRequest createRequest() {
|
||||
return new DiffCommandRequest();
|
||||
}
|
||||
|
||||
@FunctionalInterface
|
||||
public interface OutputStreamConsumer {
|
||||
void accept(OutputStream outputStream) throws IOException;
|
||||
|
||||
@@ -24,9 +24,25 @@
|
||||
|
||||
package sonia.scm.repository.api;
|
||||
|
||||
import java.util.Optional;
|
||||
|
||||
import static java.util.Optional.empty;
|
||||
|
||||
public interface DiffResult extends Iterable<DiffFile> {
|
||||
|
||||
String getOldRevision();
|
||||
|
||||
String getNewRevision();
|
||||
|
||||
default boolean isPartial() {
|
||||
return false;
|
||||
}
|
||||
|
||||
default int getOffset() {
|
||||
return 0;
|
||||
}
|
||||
|
||||
default Optional<Integer> getLimit() {
|
||||
return empty();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -29,11 +29,12 @@ import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import sonia.scm.repository.Feature;
|
||||
import sonia.scm.repository.spi.DiffResultCommand;
|
||||
import sonia.scm.repository.spi.DiffResultCommandRequest;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.util.Set;
|
||||
|
||||
public class DiffResultCommandBuilder extends AbstractDiffCommandBuilder<DiffResultCommandBuilder> {
|
||||
public class DiffResultCommandBuilder extends AbstractDiffCommandBuilder<DiffResultCommandBuilder, DiffResultCommandRequest> {
|
||||
|
||||
private static final Logger LOG = LoggerFactory.getLogger(DiffResultCommandBuilder.class);
|
||||
|
||||
@@ -44,6 +45,31 @@ public class DiffResultCommandBuilder extends AbstractDiffCommandBuilder<DiffRes
|
||||
this.diffResultCommand = diffResultCommand;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets an offset for the first file diff entry that will be created in the result. If there are less entries than the
|
||||
* given offset, an empty result will be created.
|
||||
*
|
||||
* @param offset The number of the first diff file entry that will be added to the result.
|
||||
* @return This builder instance.
|
||||
* @since 2.15.0
|
||||
*/
|
||||
public DiffResultCommandBuilder setOffset(Integer offset) {
|
||||
request.setOffset(offset);
|
||||
return this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets a limit for the file diff entries that will be created.
|
||||
*
|
||||
* @param limit The maximum number of file diff entries that will be created in the result.
|
||||
* @return This builder instance.
|
||||
* @since 2.15.0
|
||||
*/
|
||||
public DiffResultCommandBuilder setLimit(Integer limit) {
|
||||
request.setLimit(limit);
|
||||
return this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the content of the difference as parsed objects.
|
||||
*
|
||||
@@ -62,4 +88,9 @@ public class DiffResultCommandBuilder extends AbstractDiffCommandBuilder<DiffRes
|
||||
DiffResultCommandBuilder self() {
|
||||
return this;
|
||||
}
|
||||
|
||||
@Override
|
||||
DiffResultCommandRequest createRequest() {
|
||||
return new DiffResultCommandRequest();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -37,7 +37,7 @@ import sonia.scm.repository.api.DiffFormat;
|
||||
* @since 1.17
|
||||
*/
|
||||
@EqualsAndHashCode(callSuper = true)
|
||||
public final class DiffCommandRequest extends FileBaseCommandRequest
|
||||
public class DiffCommandRequest extends FileBaseCommandRequest
|
||||
implements Validateable {
|
||||
|
||||
private static final long serialVersionUID = 4026911212676859626L;
|
||||
|
||||
@@ -30,4 +30,8 @@ import java.io.IOException;
|
||||
|
||||
public interface DiffResultCommand {
|
||||
DiffResult getDiffResult(DiffCommandRequest request) throws IOException;
|
||||
|
||||
default DiffResult getDiffResult(DiffResultCommandRequest request) throws IOException {
|
||||
return getDiffResult((DiffCommandRequest) request);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
/*
|
||||
* 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.spi;
|
||||
|
||||
import lombok.EqualsAndHashCode;
|
||||
import lombok.Getter;
|
||||
import lombok.Setter;
|
||||
|
||||
@Getter
|
||||
@Setter
|
||||
@EqualsAndHashCode(callSuper = true)
|
||||
public class DiffResultCommandRequest extends DiffCommandRequest {
|
||||
|
||||
private Integer offset;
|
||||
private Integer limit;
|
||||
}
|
||||
@@ -36,7 +36,11 @@ import javax.inject.Inject;
|
||||
import java.io.ByteArrayOutputStream;
|
||||
import java.io.IOException;
|
||||
import java.util.Iterator;
|
||||
import java.util.stream.Collectors;
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
import java.util.stream.Stream;
|
||||
|
||||
import static java.util.Optional.ofNullable;
|
||||
|
||||
public class GitDiffResultCommand extends AbstractGitCommand implements DiffResultCommand {
|
||||
|
||||
@@ -47,17 +51,31 @@ public class GitDiffResultCommand extends AbstractGitCommand implements DiffResu
|
||||
|
||||
public DiffResult getDiffResult(DiffCommandRequest diffCommandRequest) throws IOException {
|
||||
org.eclipse.jgit.lib.Repository repository = open();
|
||||
return new GitDiffResult(repository, Differ.diff(repository, diffCommandRequest));
|
||||
return new GitDiffResult(repository, Differ.diff(repository, diffCommandRequest), 0, null);
|
||||
}
|
||||
|
||||
@Override
|
||||
public DiffResult getDiffResult(DiffResultCommandRequest request) throws IOException {
|
||||
org.eclipse.jgit.lib.Repository repository = open();
|
||||
int offset = request.getOffset() == null ? 0 : request.getOffset();
|
||||
return new GitDiffResult(repository, Differ.diff(repository, request), offset, request.getLimit());
|
||||
}
|
||||
|
||||
private class GitDiffResult implements DiffResult {
|
||||
|
||||
private final org.eclipse.jgit.lib.Repository repository;
|
||||
private final Differ.Diff diff;
|
||||
private final List<DiffEntry> diffEntries;
|
||||
|
||||
private GitDiffResult(org.eclipse.jgit.lib.Repository repository, Differ.Diff diff) {
|
||||
private final int offset;
|
||||
private final Integer limit;
|
||||
|
||||
private GitDiffResult(org.eclipse.jgit.lib.Repository repository, Differ.Diff diff, int offset, Integer limit) {
|
||||
this.repository = repository;
|
||||
this.diff = diff;
|
||||
this.offset = offset;
|
||||
this.limit = limit;
|
||||
this.diffEntries = diff.getEntries();
|
||||
}
|
||||
|
||||
@Override
|
||||
@@ -70,12 +88,32 @@ public class GitDiffResultCommand extends AbstractGitCommand implements DiffResu
|
||||
return GitUtil.getId(diff.getCommit().getId());
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean isPartial() {
|
||||
return limit != null && limit + offset < diffEntries.size();
|
||||
}
|
||||
|
||||
@Override
|
||||
public int getOffset() {
|
||||
return offset;
|
||||
}
|
||||
|
||||
@Override
|
||||
public Optional<Integer> getLimit() {
|
||||
return ofNullable(limit);
|
||||
}
|
||||
|
||||
@Override
|
||||
public Iterator<DiffFile> iterator() {
|
||||
return diff.getEntries()
|
||||
Stream<DiffEntry> diffEntryStream = diffEntries
|
||||
.stream()
|
||||
.skip(offset);
|
||||
if (limit != null) {
|
||||
diffEntryStream = diffEntryStream.limit(limit);
|
||||
}
|
||||
return diffEntryStream
|
||||
.map(diffEntry -> new GitDiffFile(repository, diffEntry))
|
||||
.collect(Collectors.<DiffFile>toList())
|
||||
.map(DiffFile.class::cast)
|
||||
.iterator();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -55,6 +55,8 @@ public class GitDiffResultCommandTest extends AbstractGitCommandTestBase {
|
||||
DiffFile b = iterator.next();
|
||||
assertThat(b.getOldPath()).isEqualTo("b.txt");
|
||||
assertThat(b.getNewPath()).isEqualTo("/dev/null");
|
||||
|
||||
assertThat(diffResult.isPartial()).isFalse();
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -69,6 +71,8 @@ public class GitDiffResultCommandTest extends AbstractGitCommandTestBase {
|
||||
DiffFile b = iterator.next();
|
||||
assertThat(b.getOldRevision()).isEqualTo("61780798228d17af2d34fce4cfbdf35556832472");
|
||||
assertThat(b.getNewRevision()).isEqualTo("0000000000000000000000000000000000000000");
|
||||
|
||||
assertThat(iterator.hasNext()).isFalse();
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -119,10 +123,55 @@ public class GitDiffResultCommandTest extends AbstractGitCommandTestBase {
|
||||
assertThat(renameB.iterator().hasNext()).isFalse();
|
||||
}
|
||||
|
||||
@Test
|
||||
public void shouldLimitResult() throws IOException {
|
||||
DiffResult diffResult = createDiffResult("3f76a12f08a6ba0dc988c68b7f0b2cd190efc3c4", null, 1);
|
||||
Iterator<DiffFile> iterator = diffResult.iterator();
|
||||
|
||||
DiffFile a = iterator.next();
|
||||
assertThat(a.getNewPath()).isEqualTo("a.txt");
|
||||
assertThat(a.getOldPath()).isEqualTo("a.txt");
|
||||
|
||||
assertThat(iterator.hasNext()).isFalse();
|
||||
|
||||
assertThat(diffResult.isPartial()).isTrue();
|
||||
assertThat(diffResult.getLimit()).get().isEqualTo(1);
|
||||
assertThat(diffResult.getOffset()).isZero();
|
||||
}
|
||||
|
||||
@Test
|
||||
public void shouldSetOffsetForResult() throws IOException {
|
||||
DiffResult diffResult = createDiffResult("3f76a12f08a6ba0dc988c68b7f0b2cd190efc3c4", 1, null);
|
||||
Iterator<DiffFile> iterator = diffResult.iterator();
|
||||
|
||||
DiffFile b = iterator.next();
|
||||
assertThat(b.getOldPath()).isEqualTo("b.txt");
|
||||
assertThat(b.getNewPath()).isEqualTo("/dev/null");
|
||||
|
||||
assertThat(iterator.hasNext()).isFalse();
|
||||
|
||||
assertThat(diffResult.isPartial()).isFalse();
|
||||
}
|
||||
|
||||
@Test
|
||||
public void shouldNotBePartialWhenResultCountMatchesLimit() throws IOException {
|
||||
DiffResult diffResult = createDiffResult("3f76a12f08a6ba0dc988c68b7f0b2cd190efc3c4", 0, 2);
|
||||
|
||||
assertThat(diffResult.isPartial()).isFalse();
|
||||
assertThat(diffResult.getLimit()).get().isEqualTo(2);
|
||||
assertThat(diffResult.getOffset()).isZero();
|
||||
}
|
||||
|
||||
private DiffResult createDiffResult(String s) throws IOException {
|
||||
return createDiffResult(s, null, null);
|
||||
}
|
||||
|
||||
private DiffResult createDiffResult(String s, Integer offset, Integer limit) throws IOException {
|
||||
GitDiffResultCommand gitDiffResultCommand = new GitDiffResultCommand(createContext());
|
||||
DiffCommandRequest diffCommandRequest = new DiffCommandRequest();
|
||||
DiffResultCommandRequest diffCommandRequest = new DiffResultCommandRequest();
|
||||
diffCommandRequest.setRevision(s);
|
||||
diffCommandRequest.setOffset(offset);
|
||||
diffCommandRequest.setLimit(limit);
|
||||
|
||||
return gitDiffResultCommand.getDiffResult(diffCommandRequest);
|
||||
}
|
||||
|
||||
@@ -29,7 +29,8 @@
|
||||
"fetch-mock-jest": "^1.5.1",
|
||||
"query-string": "5",
|
||||
"react": "^17.0.1",
|
||||
"react-query": "^3.5.16"
|
||||
"react-query": "^3.5.16",
|
||||
"gitdiff-parser": "^0.1.2"
|
||||
},
|
||||
"babel": {
|
||||
"presets": [
|
||||
|
||||
225
scm-ui/ui-api/src/diff.test.ts
Normal file
225
scm-ui/ui-api/src/diff.test.ts
Normal file
@@ -0,0 +1,225 @@
|
||||
/*
|
||||
* 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.
|
||||
*/
|
||||
|
||||
import fetchMock from "fetch-mock-jest";
|
||||
import { Diff } from "@scm-manager/ui-types";
|
||||
import { act, renderHook } from "@testing-library/react-hooks";
|
||||
import { useDiff } from "./diff";
|
||||
import createWrapper from "./tests/createWrapper";
|
||||
|
||||
describe("Test diff", () => {
|
||||
const simpleDiff: Diff = {
|
||||
files: [
|
||||
{
|
||||
oldPath: "/dev/null",
|
||||
newPath: "0.txt",
|
||||
oldEndingNewLine: true,
|
||||
newEndingNewLine: true,
|
||||
oldRevision: "0000000000000000000000000000000000000000",
|
||||
newRevision: "573541ac9702dd3969c9bc859d2b91ec1f7e6e56",
|
||||
type: "add",
|
||||
language: "text",
|
||||
hunks: [
|
||||
{
|
||||
content: "@@ -0,0 +1 @@",
|
||||
newStart: 1,
|
||||
newLines: 1,
|
||||
changes: [
|
||||
{
|
||||
content: "0",
|
||||
type: "insert",
|
||||
lineNumber: 1,
|
||||
isInsert: true
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
_links: {
|
||||
lines: {
|
||||
href:
|
||||
"/api/v2/repositories/scmadmin/HeartOfGold-git/content/one_to_onehundred/0.txt?start={start}&end={end}",
|
||||
templated: true
|
||||
}
|
||||
}
|
||||
}
|
||||
],
|
||||
partial: false,
|
||||
_links: {
|
||||
self: {
|
||||
href: "/api/v2/diff"
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const partialDiff1: Diff = {
|
||||
files: [
|
||||
{
|
||||
oldPath: "/dev/null",
|
||||
newPath: "0.txt",
|
||||
oldEndingNewLine: true,
|
||||
newEndingNewLine: true,
|
||||
oldRevision: "0000000000000000000000000000000000000000",
|
||||
newRevision: "573541ac9702dd3969c9bc859d2b91ec1f7e6e56",
|
||||
type: "add",
|
||||
language: "text",
|
||||
hunks: [
|
||||
{
|
||||
content: "@@ -0,0 +1 @@",
|
||||
newStart: 1,
|
||||
newLines: 1,
|
||||
changes: [
|
||||
{
|
||||
content: "0",
|
||||
type: "insert",
|
||||
lineNumber: 1,
|
||||
isInsert: true
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
_links: {
|
||||
lines: {
|
||||
href:
|
||||
"/api/v2/repositories/scmadmin/HeartOfGold-git/content/one_to_onehundred/0.txt?start={start}&end={end}",
|
||||
templated: true
|
||||
}
|
||||
}
|
||||
}
|
||||
],
|
||||
partial: true,
|
||||
_links: {
|
||||
self: {
|
||||
href: "/diff"
|
||||
},
|
||||
next: {
|
||||
href: "/diff?offset=1&limit=1"
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const partialDiff2: Diff = {
|
||||
files: [
|
||||
{
|
||||
oldPath: "/dev/null",
|
||||
newPath: "1.txt",
|
||||
oldEndingNewLine: true,
|
||||
newEndingNewLine: true,
|
||||
oldRevision: "0000000000000000000000000000000000000000",
|
||||
newRevision: "573541ac9702dd3969c9bc859d2b91ec1f7e6e56",
|
||||
type: "add",
|
||||
language: "text",
|
||||
hunks: [
|
||||
{
|
||||
content: "@@ -0,0 +1 @@",
|
||||
newStart: 1,
|
||||
newLines: 1,
|
||||
changes: [
|
||||
{
|
||||
content: "1",
|
||||
type: "insert",
|
||||
lineNumber: 1,
|
||||
isInsert: true
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
_links: {
|
||||
lines: {
|
||||
href:
|
||||
"/api/v2/repositories/scmadmin/HeartOfGold-git/content/one_to_onehundred/1.txt?start={start}&end={end}",
|
||||
templated: true
|
||||
}
|
||||
}
|
||||
}
|
||||
],
|
||||
partial: false,
|
||||
_links: {
|
||||
self: {
|
||||
href: "/diff"
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
fetchMock.reset();
|
||||
});
|
||||
|
||||
it("should return simple parsed diff", async () => {
|
||||
fetchMock.getOnce("/api/v2/diff", {
|
||||
body: simpleDiff,
|
||||
headers: { "Content-Type": "application/vnd.scmm-diffparsed+json;v=2" }
|
||||
});
|
||||
const { result, waitFor } = renderHook(() => useDiff("/diff"), {
|
||||
wrapper: createWrapper()
|
||||
});
|
||||
await waitFor(() => !!result.current.data);
|
||||
expect(result.current.data).toEqual(simpleDiff);
|
||||
});
|
||||
|
||||
it("should parse and return textual diff", async () => {
|
||||
fetchMock.getOnce("/api/v2/diff", {
|
||||
body: `diff --git a/new.txt b/new.txt
|
||||
--- a/new.txt
|
||||
+++ b/new.txt
|
||||
@@ -1,1 +1,1 @@
|
||||
-i am old!
|
||||
\\ No newline at end of file
|
||||
+i am new!
|
||||
\\ No newline at end of file
|
||||
`,
|
||||
headers: { "Content-Type": "text/plain" }
|
||||
});
|
||||
const { result, waitFor } = renderHook(() => useDiff("/diff"), {
|
||||
wrapper: createWrapper()
|
||||
});
|
||||
await waitFor(() => !!result.current.data);
|
||||
expect(result.current.data?.files).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("should return parsed diff in multiple chunks", async () => {
|
||||
fetchMock.getOnce("/api/v2/diff?limit=1", {
|
||||
body: partialDiff1,
|
||||
headers: { "Content-Type": "application/vnd.scmm-diffparsed+json;v=2" }
|
||||
});
|
||||
fetchMock.getOnce("/api/v2/diff?offset=1&limit=1", {
|
||||
body: partialDiff2,
|
||||
headers: { "Content-Type": "application/vnd.scmm-diffparsed+json;v=2" }
|
||||
});
|
||||
const { result, waitFor, waitForNextUpdate } = renderHook(() => useDiff("/diff?limit=1"), {
|
||||
wrapper: createWrapper()
|
||||
});
|
||||
await waitFor(() => !!result.current.data);
|
||||
expect(result.current.data).toEqual(partialDiff1);
|
||||
|
||||
await act(() => {
|
||||
const { fetchNextPage } = result.current;
|
||||
fetchNextPage();
|
||||
return waitForNextUpdate();
|
||||
});
|
||||
|
||||
await waitFor(() => !result.current.isFetchingNextPage);
|
||||
|
||||
expect(result.current.data).toEqual({ ...partialDiff2, files: [partialDiff1.files[0], partialDiff2.files[0]] });
|
||||
});
|
||||
});
|
||||
86
scm-ui/ui-api/src/diff.ts
Normal file
86
scm-ui/ui-api/src/diff.ts
Normal file
@@ -0,0 +1,86 @@
|
||||
/*
|
||||
* 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.
|
||||
*/
|
||||
|
||||
import parser from "gitdiff-parser";
|
||||
|
||||
import { useInfiniteQuery } from "react-query";
|
||||
import { apiClient } from "./apiclient";
|
||||
import { Diff, Link } from "@scm-manager/ui-types";
|
||||
|
||||
type UseDiffOptions = {
|
||||
limit?: number;
|
||||
};
|
||||
|
||||
export const useDiff = (link: string, options: UseDiffOptions = {}) => {
|
||||
let initialLink = link;
|
||||
if (options.limit) {
|
||||
initialLink = `${initialLink}?limit=${options.limit}`;
|
||||
}
|
||||
const { isLoading, error, data, isFetchingNextPage, fetchNextPage } = useInfiniteQuery<Diff, Error, Diff>(
|
||||
["link", link],
|
||||
({ pageParam }) => {
|
||||
return apiClient.get(pageParam || initialLink).then(response => {
|
||||
const contentType = response.headers.get("Content-Type");
|
||||
if (contentType && contentType.toLowerCase() === "application/vnd.scmm-diffparsed+json;v=2") {
|
||||
return response.json();
|
||||
} else {
|
||||
return response
|
||||
.text()
|
||||
.then(parser.parse)
|
||||
.then(data => {
|
||||
return {
|
||||
files: data,
|
||||
partial: false,
|
||||
_links: {}
|
||||
};
|
||||
});
|
||||
}
|
||||
});
|
||||
},
|
||||
{
|
||||
getNextPageParam: lastPage => (lastPage._links.next as Link)?.href
|
||||
}
|
||||
);
|
||||
|
||||
return {
|
||||
isLoading,
|
||||
error,
|
||||
isFetchingNextPage,
|
||||
fetchNextPage: () => {
|
||||
fetchNextPage();
|
||||
},
|
||||
data: merge(data?.pages)
|
||||
};
|
||||
};
|
||||
|
||||
const merge = (diffs?: Diff[]): Diff | undefined => {
|
||||
if (!diffs || diffs.length === 0) {
|
||||
return;
|
||||
}
|
||||
const joinedFiles = diffs.flatMap(diff => diff.files);
|
||||
return {
|
||||
...diffs[diffs.length - 1],
|
||||
files: joinedFiles
|
||||
};
|
||||
};
|
||||
@@ -44,6 +44,7 @@ export * from "./repository-roles";
|
||||
export * from "./permissions";
|
||||
export * from "./sources";
|
||||
export * from "./import";
|
||||
export * from "./diff";
|
||||
|
||||
export { default as ApiProvider } from "./ApiProvider";
|
||||
export * from "./ApiProvider";
|
||||
|
||||
@@ -52,7 +52,8 @@
|
||||
"react-test-renderer": "^17.0.1",
|
||||
"storybook-addon-i18next": "^1.3.0",
|
||||
"to-camel-case": "^1.0.0",
|
||||
"worker-plugin": "^3.2.0"
|
||||
"worker-plugin": "^3.2.0",
|
||||
"gitdiff-parser": "^0.1.2"
|
||||
},
|
||||
"dependencies": {
|
||||
"@scm-manager/ui-api": "^2.14.2-SNAPSHOT",
|
||||
@@ -60,7 +61,6 @@
|
||||
"@scm-manager/ui-types": "^2.14.2-SNAPSHOT",
|
||||
"classnames": "^2.2.6",
|
||||
"date-fns": "^2.4.1",
|
||||
"gitdiff-parser": "^0.1.2",
|
||||
"lowlight": "^1.13.0",
|
||||
"prism-themes": "^1.4.0",
|
||||
"query-string": "5",
|
||||
|
||||
@@ -30,14 +30,14 @@ import simpleDiff from "../__resources__/Diff.simple";
|
||||
import hunksDiff from "../__resources__/Diff.hunks";
|
||||
import binaryDiff from "../__resources__/Diff.binary";
|
||||
import markdownDiff from "../__resources__/Diff.markdown";
|
||||
import { DiffEventContext, File, FileControlFactory } from "./DiffTypes";
|
||||
import { DiffEventContext, FileControlFactory } from "./DiffTypes";
|
||||
import Toast from "../toast/Toast";
|
||||
import { getPath } from "./diffs";
|
||||
import DiffButton from "./DiffButton";
|
||||
import styled from "styled-components";
|
||||
import { MemoryRouter } from "react-router-dom";
|
||||
import { one, two } from "../__resources__/changesets";
|
||||
import { Changeset } from "@scm-manager/ui-types";
|
||||
import { Changeset, FileDiff } from "@scm-manager/ui-types";
|
||||
import JumpToFileButton from "./JumpToFileButton";
|
||||
|
||||
const diffFiles = parser.parse(simpleDiff);
|
||||
@@ -141,7 +141,7 @@ storiesOf("Diff", module)
|
||||
return <Diff diff={binaryDiffFiles} />;
|
||||
})
|
||||
.add("SyntaxHighlighting", () => {
|
||||
const filesWithLanguage = diffFiles.map((file: File) => {
|
||||
const filesWithLanguage = diffFiles.map((file: FileDiff) => {
|
||||
const ext = getPath(file).split(".")[1];
|
||||
if (ext === "tsx") {
|
||||
file.language = "typescript";
|
||||
@@ -160,7 +160,7 @@ storiesOf("Diff", module)
|
||||
<Diff diff={diffFiles} defaultCollapse={(oldPath, newPath) => oldPath.endsWith(".java")} />
|
||||
))
|
||||
.add("Expandable", () => {
|
||||
const filesWithLanguage = diffFiles.map((file: File) => {
|
||||
const filesWithLanguage = diffFiles.map((file: FileDiff) => {
|
||||
file._links = { lines: { href: "http://example.com/" } };
|
||||
return file;
|
||||
});
|
||||
|
||||
@@ -23,7 +23,8 @@
|
||||
*/
|
||||
import React from "react";
|
||||
import DiffFile from "./DiffFile";
|
||||
import { DiffObjectProps, File, FileControlFactory } from "./DiffTypes";
|
||||
import { DiffObjectProps, FileControlFactory } from "./DiffTypes";
|
||||
import { FileDiff } from "@scm-manager/ui-types";
|
||||
import { escapeWhitespace } from "./diffs";
|
||||
import Notification from "../Notification";
|
||||
import { WithTranslation, withTranslation } from "react-i18next";
|
||||
@@ -32,7 +33,7 @@ import { RouteComponentProps, withRouter } from "react-router-dom";
|
||||
type Props = RouteComponentProps &
|
||||
WithTranslation &
|
||||
DiffObjectProps & {
|
||||
diff: File[];
|
||||
diff: FileDiff[];
|
||||
fileControlFactory?: FileControlFactory;
|
||||
};
|
||||
|
||||
|
||||
@@ -23,7 +23,7 @@
|
||||
*/
|
||||
import fetchMock from "fetch-mock";
|
||||
import DiffExpander from "./DiffExpander";
|
||||
import { File, Hunk } from "./DiffTypes";
|
||||
import { FileDiff, Hunk } from "@scm-manager/ui-types";
|
||||
|
||||
const HUNK_0: Hunk = {
|
||||
content: "@@ -1,8 +1,8 @@",
|
||||
@@ -94,7 +94,7 @@ const HUNK_3: Hunk = {
|
||||
{ content: "line", type: "normal", oldLineNumber: 38, newLineNumber: 40, isNormal: true }
|
||||
]
|
||||
};
|
||||
const TEST_CONTENT_WITH_HUNKS: File = {
|
||||
const TEST_CONTENT_WITH_HUNKS: FileDiff = {
|
||||
hunks: [HUNK_0, HUNK_1, HUNK_2, HUNK_3],
|
||||
newEndingNewLine: true,
|
||||
newPath: "src/main/js/CommitMessage.js",
|
||||
@@ -112,7 +112,7 @@ const TEST_CONTENT_WITH_HUNKS: File = {
|
||||
}
|
||||
};
|
||||
|
||||
const TEST_CONTENT_WITH_NEW_BINARY_FILE: File = {
|
||||
const TEST_CONTENT_WITH_NEW_BINARY_FILE: FileDiff = {
|
||||
oldPath: "/dev/null",
|
||||
newPath: "src/main/fileUploadV2.png",
|
||||
oldEndingNewLine: true,
|
||||
@@ -122,7 +122,7 @@ const TEST_CONTENT_WITH_NEW_BINARY_FILE: File = {
|
||||
type: "add"
|
||||
};
|
||||
|
||||
const TEST_CONTENT_WITH_NEW_TEXT_FILE: File = {
|
||||
const TEST_CONTENT_WITH_NEW_TEXT_FILE: FileDiff = {
|
||||
oldPath: "/dev/null",
|
||||
newPath: "src/main/markdown/README.md",
|
||||
oldEndingNewLine: true,
|
||||
@@ -151,7 +151,7 @@ const TEST_CONTENT_WITH_NEW_TEXT_FILE: File = {
|
||||
}
|
||||
};
|
||||
|
||||
const TEST_CONTENT_WITH_DELETED_TEXT_FILE: File = {
|
||||
const TEST_CONTENT_WITH_DELETED_TEXT_FILE: FileDiff = {
|
||||
oldPath: "README.md",
|
||||
newPath: "/dev/null",
|
||||
oldEndingNewLine: true,
|
||||
@@ -171,7 +171,7 @@ const TEST_CONTENT_WITH_DELETED_TEXT_FILE: File = {
|
||||
_links: { lines: { href: "http://localhost:8081/dev/null?start={start}&end={end}", templated: true } }
|
||||
};
|
||||
|
||||
const TEST_CONTENT_WITH_DELETED_LINES_AT_END: File = {
|
||||
const TEST_CONTENT_WITH_DELETED_LINES_AT_END: FileDiff = {
|
||||
oldPath: "pom.xml",
|
||||
newPath: "pom.xml",
|
||||
oldEndingNewLine: true,
|
||||
@@ -214,7 +214,7 @@ const TEST_CONTENT_WITH_DELETED_LINES_AT_END: File = {
|
||||
}
|
||||
};
|
||||
|
||||
const TEST_CONTENT_WITH_ALL_LINES_REMOVED_FROM_FILE: File = {
|
||||
const TEST_CONTENT_WITH_ALL_LINES_REMOVED_FROM_FILE: FileDiff = {
|
||||
oldPath: "pom.xml",
|
||||
newPath: "pom.xml",
|
||||
oldEndingNewLine: true,
|
||||
@@ -281,7 +281,7 @@ describe("with hunks the diff expander", () => {
|
||||
const expandedHunk = diffExpander.getHunk(1).hunk;
|
||||
const subsequentHunk = diffExpander.getHunk(2).hunk;
|
||||
fetchMock.get("http://localhost:8081/scm/api/v2/content/abc/CommitMessage.js?start=20&end=21", "new line 1");
|
||||
let newFile: File;
|
||||
let newFile: FileDiff;
|
||||
await diffExpander
|
||||
.getHunk(1)
|
||||
.expandBottom(1)
|
||||
@@ -306,7 +306,7 @@ describe("with hunks the diff expander", () => {
|
||||
"http://localhost:8081/scm/api/v2/content/abc/CommitMessage.js?start=8&end=13",
|
||||
"new line 9\nnew line 10\nnew line 11\nnew line 12\nnew line 13"
|
||||
);
|
||||
let newFile: File;
|
||||
let newFile: FileDiff;
|
||||
await diffExpander
|
||||
.getHunk(1)
|
||||
.expandHead(5)
|
||||
@@ -335,7 +335,7 @@ describe("with hunks the diff expander", () => {
|
||||
"http://localhost:8081/scm/api/v2/content/abc/CommitMessage.js?start=40&end=50",
|
||||
"new line 40\nnew line 41\nnew line 42"
|
||||
);
|
||||
let newFile: File;
|
||||
let newFile: FileDiff;
|
||||
await diffExpander
|
||||
.getHunk(3)
|
||||
.expandBottom(10)
|
||||
@@ -348,7 +348,7 @@ describe("with hunks the diff expander", () => {
|
||||
"http://localhost:8081/scm/api/v2/content/abc/CommitMessage.js?start=40&end=-1",
|
||||
"new line 40\nnew line 41\nnew line 42"
|
||||
);
|
||||
let newFile: File;
|
||||
let newFile: FileDiff;
|
||||
await diffExpander
|
||||
.getHunk(3)
|
||||
.expandBottom(-1)
|
||||
|
||||
@@ -23,13 +23,12 @@
|
||||
*/
|
||||
|
||||
import { apiClient } from "@scm-manager/ui-components";
|
||||
import { Change, File, Hunk } from "./DiffTypes";
|
||||
import { Link } from "@scm-manager/ui-types";
|
||||
import { Change, FileDiff, Hunk, Link } from "@scm-manager/ui-types";
|
||||
|
||||
class DiffExpander {
|
||||
file: File;
|
||||
file: FileDiff;
|
||||
|
||||
constructor(file: File) {
|
||||
constructor(file: FileDiff) {
|
||||
this.file = file;
|
||||
}
|
||||
|
||||
@@ -73,7 +72,7 @@ class DiffExpander {
|
||||
}
|
||||
};
|
||||
|
||||
expandHead: (n: number, count: number) => Promise<File> = (n, count) => {
|
||||
expandHead: (n: number, count: number) => Promise<FileDiff> = (n, count) => {
|
||||
const start = this.minLineNumber(n) - Math.min(count, this.computeMaxExpandHeadRange(n)) - 1;
|
||||
const end = this.minLineNumber(n) - 1;
|
||||
return this.loadLines(start, end).then(lines => {
|
||||
@@ -90,7 +89,7 @@ class DiffExpander {
|
||||
});
|
||||
};
|
||||
|
||||
expandBottom: (n: number, count: number) => Promise<File> = (n, count) => {
|
||||
expandBottom: (n: number, count: number) => Promise<FileDiff> = (n, count) => {
|
||||
const maxExpandBottomRange = this.computeMaxExpandBottomRange(n);
|
||||
const start = this.maxLineNumber(n);
|
||||
const end =
|
||||
@@ -191,8 +190,8 @@ export type ExpandableHunk = {
|
||||
hunk: Hunk;
|
||||
maxExpandHeadRange: number;
|
||||
maxExpandBottomRange: number;
|
||||
expandHead: (count: number) => Promise<File>;
|
||||
expandBottom: (count: number) => Promise<File>;
|
||||
expandHead: (count: number) => Promise<FileDiff>;
|
||||
expandBottom: (count: number) => Promise<FileDiff>;
|
||||
};
|
||||
|
||||
export default DiffExpander;
|
||||
|
||||
@@ -30,7 +30,8 @@ import { Decoration, getChangeKey, Hunk } from "react-diff-view";
|
||||
import { ButtonGroup } from "../buttons";
|
||||
import Tag from "../Tag";
|
||||
import Icon from "../Icon";
|
||||
import { Change, ChangeEvent, DiffObjectProps, File, Hunk as HunkType } from "./DiffTypes";
|
||||
import { Change, FileDiff, Hunk as HunkType } from "@scm-manager/ui-types";
|
||||
import { ChangeEvent, DiffObjectProps } from "./DiffTypes";
|
||||
import TokenizedDiffView from "./TokenizedDiffView";
|
||||
import DiffButton from "./DiffButton";
|
||||
import { MenuContext, OpenInFullscreenButton } from "@scm-manager/ui-components";
|
||||
@@ -45,7 +46,7 @@ const EMPTY_ANNOTATION_FACTORY = {};
|
||||
|
||||
type Props = DiffObjectProps &
|
||||
WithTranslation & {
|
||||
file: File;
|
||||
file: FileDiff;
|
||||
};
|
||||
|
||||
type Collapsible = {
|
||||
@@ -53,7 +54,7 @@ type Collapsible = {
|
||||
};
|
||||
|
||||
type State = Collapsible & {
|
||||
file: File;
|
||||
file: FileDiff;
|
||||
sideBySide?: boolean;
|
||||
diffExpander: DiffExpander;
|
||||
expansionError?: any;
|
||||
@@ -257,7 +258,7 @@ class DiffFile extends React.Component<Props, State> {
|
||||
};
|
||||
};
|
||||
|
||||
diffExpanded = (newFile: File) => {
|
||||
diffExpanded = (newFile: FileDiff) => {
|
||||
this.setState({ file: newFile, diffExpander: new DiffExpander(newFile) });
|
||||
};
|
||||
|
||||
@@ -303,7 +304,7 @@ class DiffFile extends React.Component<Props, State> {
|
||||
}
|
||||
};
|
||||
|
||||
renderHunk = (file: File, expandableHunk: ExpandableHunk, i: number) => {
|
||||
renderHunk = (file: FileDiff, expandableHunk: ExpandableHunk, i: number) => {
|
||||
const hunk = expandableHunk.hunk;
|
||||
if (this.props.markConflicts && hunk.changes) {
|
||||
this.markConflicts(hunk);
|
||||
@@ -353,7 +354,7 @@ class DiffFile extends React.Component<Props, State> {
|
||||
}
|
||||
};
|
||||
|
||||
getAnchorId(file: File) {
|
||||
getAnchorId(file: FileDiff) {
|
||||
let path: string;
|
||||
if (file.type === "delete") {
|
||||
path = file.oldPath;
|
||||
@@ -363,7 +364,7 @@ class DiffFile extends React.Component<Props, State> {
|
||||
return escapeWhitespace(path);
|
||||
}
|
||||
|
||||
renderFileTitle = (file: File) => {
|
||||
renderFileTitle = (file: FileDiff) => {
|
||||
if (file.oldPath !== file.newPath && (file.type === "copy" || file.type === "rename")) {
|
||||
return (
|
||||
<>
|
||||
@@ -376,7 +377,7 @@ class DiffFile extends React.Component<Props, State> {
|
||||
return file.newPath;
|
||||
};
|
||||
|
||||
hoverFileTitle = (file: File): string => {
|
||||
hoverFileTitle = (file: FileDiff): string => {
|
||||
if (file.oldPath !== file.newPath && (file.type === "copy" || file.type === "rename")) {
|
||||
return `${file.oldPath} > ${file.newPath}`;
|
||||
} else if (file.type === "delete") {
|
||||
@@ -385,7 +386,7 @@ class DiffFile extends React.Component<Props, State> {
|
||||
return file.newPath;
|
||||
};
|
||||
|
||||
renderChangeTag = (file: File) => {
|
||||
renderChangeTag = (file: FileDiff) => {
|
||||
const { t } = this.props;
|
||||
if (!file.type) {
|
||||
return;
|
||||
@@ -401,7 +402,7 @@ class DiffFile extends React.Component<Props, State> {
|
||||
return <ChangeTypeTag className={classNames("is-rounded", "has-text-weight-normal")} color={color} label={value} />;
|
||||
};
|
||||
|
||||
hasContent = (file: File) => file && !file.isBinary && file.hunks && file.hunks.length > 0;
|
||||
hasContent = (file: FileDiff) => file && !file.isBinary && file.hunks && file.hunks.length > 0;
|
||||
|
||||
render() {
|
||||
const { fileControlFactory, fileAnnotationFactory, t } = this.props;
|
||||
|
||||
@@ -24,55 +24,7 @@
|
||||
|
||||
import { ReactNode } from "react";
|
||||
import { DefaultCollapsed } from "./defaultCollapsed";
|
||||
import { Links } from "@scm-manager/ui-types";
|
||||
|
||||
// We place the types here and not in @scm-manager/ui-types,
|
||||
// because they represent not a real scm-manager related type.
|
||||
// This types represents only the required types for the Diff related components,
|
||||
// such as every other component does with its Props.
|
||||
|
||||
export type FileChangeType = "add" | "modify" | "delete" | "copy" | "rename";
|
||||
|
||||
export type File = {
|
||||
hunks?: Hunk[];
|
||||
newEndingNewLine: boolean;
|
||||
newMode?: string;
|
||||
newPath: string;
|
||||
newRevision?: string;
|
||||
oldEndingNewLine: boolean;
|
||||
oldMode?: string;
|
||||
oldPath: string;
|
||||
oldRevision?: string;
|
||||
type: FileChangeType;
|
||||
language?: string;
|
||||
// TODO does this property exists?
|
||||
isBinary?: boolean;
|
||||
_links?: Links;
|
||||
};
|
||||
|
||||
export type Hunk = {
|
||||
changes: Change[];
|
||||
content: string;
|
||||
oldStart?: number;
|
||||
newStart?: number;
|
||||
oldLines?: number;
|
||||
newLines?: number;
|
||||
fullyExpanded?: boolean;
|
||||
expansion?: boolean;
|
||||
};
|
||||
|
||||
export type ChangeType = "insert" | "delete" | "normal" | "conflict";
|
||||
|
||||
export type Change = {
|
||||
content: string;
|
||||
isNormal?: boolean;
|
||||
isInsert?: boolean;
|
||||
isDelete?: boolean;
|
||||
lineNumber?: number;
|
||||
newLineNumber?: number;
|
||||
oldLineNumber?: number;
|
||||
type: ChangeType;
|
||||
};
|
||||
import { Change, Hunk, FileDiff as File } from "@scm-manager/ui-types";
|
||||
|
||||
export type ChangeEvent = {
|
||||
change: Change;
|
||||
|
||||
@@ -21,93 +21,72 @@
|
||||
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
* SOFTWARE.
|
||||
*/
|
||||
import React from "react";
|
||||
import { apiClient, NotFoundError } from "@scm-manager/ui-api";
|
||||
import React, { FC } from "react";
|
||||
import { NotFoundError, useDiff } from "@scm-manager/ui-api";
|
||||
import ErrorNotification from "../ErrorNotification";
|
||||
// @ts-ignore
|
||||
import parser from "gitdiff-parser";
|
||||
import Notification from "../Notification";
|
||||
|
||||
import Loading from "../Loading";
|
||||
import Diff from "./Diff";
|
||||
import { DiffObjectProps, File } from "./DiffTypes";
|
||||
import { Notification } from "../index";
|
||||
import { withTranslation, WithTranslation } from "react-i18next";
|
||||
import { DiffObjectProps } from "./DiffTypes";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import Button from "../buttons/Button";
|
||||
import styled from "styled-components";
|
||||
|
||||
type Props = WithTranslation &
|
||||
DiffObjectProps & {
|
||||
url: string;
|
||||
};
|
||||
|
||||
type State = {
|
||||
diff?: File[];
|
||||
loading: boolean;
|
||||
error?: Error;
|
||||
type Props = DiffObjectProps & {
|
||||
url: string;
|
||||
limit?: number;
|
||||
};
|
||||
|
||||
class LoadingDiff extends React.Component<Props, State> {
|
||||
static defaultProps = {
|
||||
sideBySide: false
|
||||
};
|
||||
type NotificationProps = {
|
||||
fetchNextPage: () => void;
|
||||
isFetchingNextPage: boolean;
|
||||
};
|
||||
|
||||
constructor(props: Props) {
|
||||
super(props);
|
||||
this.state = {
|
||||
loading: true
|
||||
};
|
||||
}
|
||||
const StyledNotification = styled(Notification)`
|
||||
margin-top: 1.5rem;
|
||||
`;
|
||||
|
||||
componentDidMount() {
|
||||
this.fetchDiff();
|
||||
}
|
||||
const PartialNotification: FC<NotificationProps> = ({ fetchNextPage, isFetchingNextPage }) => {
|
||||
const [t] = useTranslation("repos");
|
||||
return (
|
||||
<StyledNotification type="info">
|
||||
<div className="columns is-centered">
|
||||
<div className="column">{t("changesets.moreDiffsAvailable")}</div>
|
||||
<Button label={t("changesets.loadMore")} action={fetchNextPage} loading={isFetchingNextPage} />
|
||||
</div>
|
||||
</StyledNotification>
|
||||
);
|
||||
};
|
||||
|
||||
componentDidUpdate(prevProps: Props) {
|
||||
if (prevProps.url !== this.props.url) {
|
||||
this.fetchDiff();
|
||||
const LoadingDiff: FC<Props> = ({ url, limit, ...props }) => {
|
||||
const { error, isLoading, data, fetchNextPage, isFetchingNextPage } = useDiff(url, { limit });
|
||||
const [t] = useTranslation("repos");
|
||||
|
||||
if (error) {
|
||||
if (error instanceof NotFoundError) {
|
||||
return <Notification type="info">{t("changesets.noChangesets")}</Notification>;
|
||||
}
|
||||
return <ErrorNotification error={error} />;
|
||||
} else if (isLoading) {
|
||||
return <Loading />;
|
||||
} else if (!data?.files) {
|
||||
return null;
|
||||
} else {
|
||||
return (
|
||||
<>
|
||||
<Diff diff={data.files} {...props} />
|
||||
{data.partial ? (
|
||||
<PartialNotification fetchNextPage={fetchNextPage} isFetchingNextPage={isFetchingNextPage} />
|
||||
) : null}
|
||||
</>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
fetchDiff = () => {
|
||||
const { url } = this.props;
|
||||
this.setState({ loading: true });
|
||||
apiClient
|
||||
.get(url)
|
||||
.then(response => {
|
||||
const contentType = response.headers.get("Content-Type");
|
||||
if (contentType && contentType.toLowerCase() === "application/vnd.scmm-diffparsed+json;v=2") {
|
||||
return response.json().then(data => data.files);
|
||||
} else {
|
||||
return response.text().then(parser.parse);
|
||||
}
|
||||
})
|
||||
.then((diff: File[]) => {
|
||||
this.setState({
|
||||
loading: false,
|
||||
diff: diff
|
||||
});
|
||||
})
|
||||
.catch((error: Error) => {
|
||||
this.setState({
|
||||
loading: false,
|
||||
error
|
||||
});
|
||||
});
|
||||
};
|
||||
LoadingDiff.defaultProps = {
|
||||
limit: 25,
|
||||
sideBySide: false
|
||||
};
|
||||
|
||||
render() {
|
||||
const { diff, loading, error } = this.state;
|
||||
if (error) {
|
||||
if (error instanceof NotFoundError) {
|
||||
return <Notification type="info">{this.props.t("changesets.noChangesets")}</Notification>;
|
||||
}
|
||||
return <ErrorNotification error={error} />;
|
||||
} else if (loading) {
|
||||
return <Loading />;
|
||||
} else if (!diff) {
|
||||
return null;
|
||||
} else {
|
||||
return <Diff diff={diff} {...this.props} />;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export default withTranslation("repos")(LoadingDiff);
|
||||
export default LoadingDiff;
|
||||
|
||||
@@ -25,7 +25,7 @@ import React, { FC } from "react";
|
||||
import styled from "styled-components";
|
||||
// @ts-ignore we have no typings for react-diff-view
|
||||
import { Diff, useTokenizeWorker } from "react-diff-view";
|
||||
import { File } from "./DiffTypes";
|
||||
import { FileDiff } from "@scm-manager/ui-types";
|
||||
import { determineLanguage } from "../languages";
|
||||
|
||||
// @ts-ignore no types for css modules
|
||||
@@ -65,7 +65,7 @@ const tokenize = new Worker("./Tokenize.worker.ts", { name: "tokenizer", type: "
|
||||
tokenize.postMessage({ theme });
|
||||
|
||||
type Props = {
|
||||
file: File;
|
||||
file: FileDiff;
|
||||
viewType: "split" | "unified";
|
||||
className?: string;
|
||||
};
|
||||
|
||||
@@ -22,11 +22,11 @@
|
||||
* SOFTWARE.
|
||||
*/
|
||||
|
||||
import { File, FileChangeType, Hunk } from "./DiffTypes";
|
||||
import { FileChangeType, Hunk, FileDiff } from "@scm-manager/ui-types";
|
||||
import { getPath, createHunkIdentifier, createHunkIdentifierFromContext, escapeWhitespace } from "./diffs";
|
||||
|
||||
describe("tests for diff util functions", () => {
|
||||
const file = (type: FileChangeType, oldPath: string, newPath: string): File => {
|
||||
const file = (type: FileChangeType, oldPath: string, newPath: string): FileDiff => {
|
||||
return {
|
||||
hunks: [],
|
||||
type: type,
|
||||
|
||||
@@ -22,16 +22,17 @@
|
||||
* SOFTWARE.
|
||||
*/
|
||||
|
||||
import { BaseContext, File, Hunk } from "./DiffTypes";
|
||||
import { BaseContext } from "./DiffTypes";
|
||||
import { FileDiff, Hunk } from "@scm-manager/ui-types";
|
||||
|
||||
export function getPath(file: File) {
|
||||
export function getPath(file: FileDiff) {
|
||||
if (file.type === "delete") {
|
||||
return file.oldPath;
|
||||
}
|
||||
return file.newPath;
|
||||
}
|
||||
|
||||
export function createHunkIdentifier(file: File, hunk: Hunk) {
|
||||
export function createHunkIdentifier(file: FileDiff, hunk: Hunk) {
|
||||
const path = getPath(file);
|
||||
return `${file.type}_${path}_${hunk.content}`;
|
||||
}
|
||||
|
||||
@@ -25,11 +25,6 @@
|
||||
import * as diffs from "./diffs";
|
||||
|
||||
import {
|
||||
File,
|
||||
FileChangeType,
|
||||
Hunk,
|
||||
Change,
|
||||
ChangeType,
|
||||
BaseContext,
|
||||
AnnotationFactory,
|
||||
AnnotationFactoryContext,
|
||||
@@ -37,6 +32,7 @@ import {
|
||||
DiffEventContext
|
||||
} from "./DiffTypes";
|
||||
|
||||
import { FileDiff as File, FileChangeType, Hunk, Change, ChangeType } from "@scm-manager/ui-types";
|
||||
export { diffs };
|
||||
|
||||
export * from "./annotate";
|
||||
|
||||
73
scm-ui/ui-types/src/Diff.ts
Normal file
73
scm-ui/ui-types/src/Diff.ts
Normal file
@@ -0,0 +1,73 @@
|
||||
/*
|
||||
* 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.
|
||||
*/
|
||||
|
||||
import { HalRepresentation, Links } from "./hal";
|
||||
|
||||
export type FileChangeType = "add" | "modify" | "delete" | "copy" | "rename";
|
||||
|
||||
export type Diff = HalRepresentation & {
|
||||
files: FileDiff[];
|
||||
partial: boolean;
|
||||
};
|
||||
|
||||
export type FileDiff = {
|
||||
hunks?: Hunk[];
|
||||
newEndingNewLine: boolean;
|
||||
newMode?: string;
|
||||
newPath: string;
|
||||
newRevision?: string;
|
||||
oldEndingNewLine: boolean;
|
||||
oldMode?: string;
|
||||
oldPath: string;
|
||||
oldRevision?: string;
|
||||
type: FileChangeType;
|
||||
language?: string;
|
||||
// TODO does this property exists?
|
||||
isBinary?: boolean;
|
||||
_links?: Links;
|
||||
};
|
||||
|
||||
export type Hunk = {
|
||||
changes: Change[];
|
||||
content: string;
|
||||
oldStart?: number;
|
||||
newStart?: number;
|
||||
oldLines?: number;
|
||||
newLines?: number;
|
||||
fullyExpanded?: boolean;
|
||||
expansion?: boolean;
|
||||
};
|
||||
|
||||
export type ChangeType = "insert" | "delete" | "normal" | "conflict";
|
||||
|
||||
export type Change = {
|
||||
content: string;
|
||||
isNormal?: boolean;
|
||||
isInsert?: boolean;
|
||||
isDelete?: boolean;
|
||||
lineNumber?: number;
|
||||
newLineNumber?: number;
|
||||
oldLineNumber?: number;
|
||||
type: ChangeType;
|
||||
};
|
||||
@@ -64,3 +64,5 @@ export * from "./NamespaceStrategies";
|
||||
export * from "./LoginInfo";
|
||||
|
||||
export * from "./Admin";
|
||||
|
||||
export * from "./Diff";
|
||||
|
||||
@@ -203,7 +203,9 @@
|
||||
"errorSubtitle": "Changesets konnten nicht abgerufen werden",
|
||||
"noChangesets": "Keine Changesets in diesem Branch gefunden. Die Commits könnten gelöscht worden sein.",
|
||||
"branchSelectorLabel": "Branches",
|
||||
"collapseDiffs": "Auf-/Zuklappen"
|
||||
"collapseDiffs": "Auf-/Zuklappen",
|
||||
"moreDiffsAvailable": "Es sind weitere Diffs verfügbar",
|
||||
"loadMore": "Weitere laden"
|
||||
},
|
||||
"changeset": {
|
||||
"label": "Changeset",
|
||||
|
||||
@@ -203,7 +203,9 @@
|
||||
"errorSubtitle": "Could not fetch changesets",
|
||||
"noChangesets": "No changesets found for this branch. The commits could have been removed.",
|
||||
"branchSelectorLabel": "Branches",
|
||||
"collapseDiffs": "Collapse"
|
||||
"collapseDiffs": "Collapse",
|
||||
"moreDiffsAvailable": "There are more diffs available",
|
||||
"loadMore": "Load more"
|
||||
},
|
||||
"changeset": {
|
||||
"label": "Changeset",
|
||||
|
||||
@@ -42,6 +42,7 @@ public class DiffResultDto extends HalRepresentation {
|
||||
}
|
||||
|
||||
private List<FileDto> files;
|
||||
private boolean partial;
|
||||
|
||||
@Data
|
||||
@EqualsAndHashCode(callSuper = false)
|
||||
|
||||
@@ -39,6 +39,7 @@ import java.util.List;
|
||||
import java.util.Optional;
|
||||
import java.util.OptionalInt;
|
||||
|
||||
import static de.otto.edison.hal.Link.link;
|
||||
import static de.otto.edison.hal.Link.linkBuilder;
|
||||
import static de.otto.edison.hal.Links.linkingTo;
|
||||
|
||||
@@ -55,23 +56,57 @@ class DiffResultToDiffResultDtoMapper {
|
||||
}
|
||||
|
||||
public DiffResultDto mapForIncoming(Repository repository, DiffResult result, String source, String target) {
|
||||
DiffResultDto dto = new DiffResultDto(linkingTo().self(resourceLinks.incoming().diffParsed(repository.getNamespace(), repository.getName(), source, target)).build());
|
||||
String baseLink = resourceLinks.incoming().diffParsed(repository.getNamespace(), repository.getName(), source, target);
|
||||
Links.Builder links = linkingTo().self(createSelfLink(result, baseLink));
|
||||
appendNextChunkLinkIfNeeded(links, result, baseLink);
|
||||
DiffResultDto dto = new DiffResultDto(links.build());
|
||||
setFiles(result, dto, repository, source);
|
||||
return dto;
|
||||
}
|
||||
|
||||
public DiffResultDto mapForRevision(Repository repository, DiffResult result, String revision) {
|
||||
DiffResultDto dto = new DiffResultDto(linkingTo().self(resourceLinks.diff().parsed(repository.getNamespace(), repository.getName(), revision)).build());
|
||||
String baseLink = resourceLinks.diff().parsed(repository.getNamespace(), repository.getName(), revision);
|
||||
Links.Builder links = linkingTo().self(createSelfLink(result, baseLink));
|
||||
appendNextChunkLinkIfNeeded(links, result, baseLink);
|
||||
DiffResultDto dto = new DiffResultDto(links.build());
|
||||
setFiles(result, dto, repository, revision);
|
||||
return dto;
|
||||
}
|
||||
|
||||
private String createSelfLink(DiffResult result, String baseLink) {
|
||||
if (result.getOffset() > 0 || result.getLimit().isPresent()) {
|
||||
return createLinkWithLimitAndOffset(baseLink, result.getOffset(), result.getLimit().orElse(null));
|
||||
} else {
|
||||
return baseLink;
|
||||
}
|
||||
}
|
||||
|
||||
private void appendNextChunkLinkIfNeeded(Links.Builder links, DiffResult result, String baseLink) {
|
||||
if (result.isPartial()) {
|
||||
Optional<Integer> limit = result.getLimit();
|
||||
if (limit.isPresent()) {
|
||||
links.single(link("next", createLinkWithLimitAndOffset(baseLink, result.getOffset() + limit.get(), limit.get())));
|
||||
} else {
|
||||
throw new IllegalStateException("a result cannot be partial without a limit");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private String createLinkWithLimitAndOffset(String baseLink, int offset, Integer limit) {
|
||||
if (limit == null) {
|
||||
return String.format("%s?offset=%s", baseLink, offset);
|
||||
} else {
|
||||
return String.format("%s?offset=%s&limit=%s", baseLink, offset, limit);
|
||||
}
|
||||
}
|
||||
|
||||
private void setFiles(DiffResult result, DiffResultDto dto, Repository repository, String revision) {
|
||||
List<DiffResultDto.FileDto> files = new ArrayList<>();
|
||||
for (DiffFile file : result) {
|
||||
files.add(mapFile(file, repository, revision));
|
||||
}
|
||||
dto.setFiles(files);
|
||||
dto.setPartial(result.isPartial());
|
||||
}
|
||||
|
||||
private DiffResultDto.FileDto mapFile(DiffFile file, Repository repository, String revision) {
|
||||
@@ -119,7 +154,6 @@ class DiffResultToDiffResultDtoMapper {
|
||||
dto.setOldPath(oldPath);
|
||||
dto.setOldRevision(file.getOldRevision());
|
||||
|
||||
|
||||
Optional<Language> language = ContentTypeResolver.resolve(path).getLanguage();
|
||||
language.ifPresent(value -> dto.setLanguage(ProgrammingLanguages.getValue(value)));
|
||||
|
||||
|
||||
@@ -39,6 +39,7 @@ import sonia.scm.util.HttpUtil;
|
||||
import sonia.scm.web.VndMediaType;
|
||||
|
||||
import javax.inject.Inject;
|
||||
import javax.validation.constraints.Min;
|
||||
import javax.validation.constraints.Pattern;
|
||||
import javax.ws.rs.DefaultValue;
|
||||
import javax.ws.rs.GET;
|
||||
@@ -142,10 +143,18 @@ public class DiffRootResource {
|
||||
schema = @Schema(implementation = ErrorDto.class)
|
||||
)
|
||||
)
|
||||
public DiffResultDto getParsed(@PathParam("namespace") String namespace, @PathParam("name") String name, @PathParam("revision") String revision) throws IOException {
|
||||
public DiffResultDto getParsed(@PathParam("namespace") String namespace,
|
||||
@PathParam("name") String name,
|
||||
@PathParam("revision") String revision,
|
||||
@QueryParam("limit") @Min(1) Integer limit,
|
||||
@QueryParam("offset") @Min(0) Integer offset) throws IOException {
|
||||
HttpUtil.checkForCRLFInjection(revision);
|
||||
try (RepositoryService repositoryService = serviceFactory.create(new NamespaceAndName(namespace, name))) {
|
||||
DiffResult diffResult = repositoryService.getDiffResultCommand().setRevision(revision).getDiffResult();
|
||||
DiffResult diffResult = repositoryService.getDiffResultCommand()
|
||||
.setRevision(revision)
|
||||
.setLimit(limit)
|
||||
.setOffset(offset)
|
||||
.getDiffResult();
|
||||
return parsedDiffMapper.mapForRevision(repositoryService.getRepository(), diffResult, revision);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -43,6 +43,7 @@ import sonia.scm.repository.api.RepositoryServiceFactory;
|
||||
import sonia.scm.util.HttpUtil;
|
||||
import sonia.scm.web.VndMediaType;
|
||||
|
||||
import javax.validation.constraints.Min;
|
||||
import javax.validation.constraints.Pattern;
|
||||
import javax.ws.rs.DefaultValue;
|
||||
import javax.ws.rs.GET;
|
||||
@@ -240,15 +241,19 @@ public class IncomingRootResource {
|
||||
schema = @Schema(implementation = ErrorDto.class)
|
||||
))
|
||||
public Response incomingDiffParsed(@PathParam("namespace") String namespace,
|
||||
@PathParam("name") String name,
|
||||
@PathParam("source") String source,
|
||||
@PathParam("target") String target) throws IOException {
|
||||
@PathParam("name") String name,
|
||||
@PathParam("source") String source,
|
||||
@PathParam("target") String target,
|
||||
@QueryParam("limit") @Min(1) Integer limit,
|
||||
@QueryParam("offset") @Min(0) Integer offset) throws IOException {
|
||||
HttpUtil.checkForCRLFInjection(source);
|
||||
HttpUtil.checkForCRLFInjection(target);
|
||||
try (RepositoryService repositoryService = serviceFactory.create(new NamespaceAndName(namespace, name))) {
|
||||
DiffResult diffResult = repositoryService.getDiffResultCommand()
|
||||
.setRevision(source)
|
||||
.setAncestorChangeset(target)
|
||||
.setLimit(limit)
|
||||
.setOffset(offset)
|
||||
.getDiffResult();
|
||||
return Response.ok(parsedDiffMapper.mapForIncoming(repositoryService.getRepository(), diffResult, source, target)).build();
|
||||
}
|
||||
|
||||
@@ -63,6 +63,7 @@ import static org.junit.Assert.assertEquals;
|
||||
import static org.junit.Assert.fail;
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.Mockito.mock;
|
||||
import static org.mockito.Mockito.verify;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
@RunWith(MockitoJUnitRunner.Silent.class)
|
||||
@@ -153,6 +154,38 @@ public class DiffResourceTest extends RepositoryTestBase {
|
||||
.contains("\"self\":{\"href\":\"http://self\"}");
|
||||
}
|
||||
|
||||
@Test
|
||||
public void shouldGetParsedDiffsWithOffset() throws Exception {
|
||||
DiffResult diffResult = mock(DiffResult.class);
|
||||
when(diffResultCommandBuilder.getDiffResult()).thenReturn(diffResult);
|
||||
when(diffResultToDiffResultDtoMapper.mapForRevision(REPOSITORY, diffResult, "revision"))
|
||||
.thenReturn(new DiffResultDto(Links.linkingTo().self("http://self").build()));
|
||||
MockHttpRequest request = MockHttpRequest
|
||||
.get(DIFF_URL + "revision/parsed?offset=42")
|
||||
.accept(VndMediaType.DIFF_PARSED);
|
||||
MockHttpResponse response = new MockHttpResponse();
|
||||
|
||||
dispatcher.invoke(request, response);
|
||||
|
||||
verify(diffResultCommandBuilder).setOffset(42);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void shouldGetParsedDiffsWithLimit() throws Exception {
|
||||
DiffResult diffResult = mock(DiffResult.class);
|
||||
when(diffResultCommandBuilder.getDiffResult()).thenReturn(diffResult);
|
||||
when(diffResultToDiffResultDtoMapper.mapForRevision(REPOSITORY, diffResult, "revision"))
|
||||
.thenReturn(new DiffResultDto(Links.linkingTo().self("http://self").build()));
|
||||
MockHttpRequest request = MockHttpRequest
|
||||
.get(DIFF_URL + "revision/parsed?limit=42")
|
||||
.accept(VndMediaType.DIFF_PARSED);
|
||||
MockHttpResponse response = new MockHttpResponse();
|
||||
|
||||
dispatcher.invoke(request, response);
|
||||
|
||||
verify(diffResultCommandBuilder).setLimit(42);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void shouldGet404OnMissingRepository() throws URISyntaxException {
|
||||
when(serviceFactory.create(any(NamespaceAndName.class))).thenThrow(new NotFoundException("Text", "x"));
|
||||
|
||||
@@ -35,13 +35,13 @@ import sonia.scm.repository.api.DiffResult;
|
||||
import sonia.scm.repository.api.Hunk;
|
||||
|
||||
import java.util.Arrays;
|
||||
import java.util.Collections;
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
import java.util.OptionalInt;
|
||||
|
||||
import static java.net.URI.create;
|
||||
import static java.util.Collections.emptyIterator;
|
||||
import static java.util.Optional.of;
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.mockito.Mockito.mock;
|
||||
import static org.mockito.Mockito.when;
|
||||
@@ -86,6 +86,21 @@ class DiffResultToDiffResultDtoMapperTest {
|
||||
.isEqualTo("/scm/api/v2/repositories/space/X/diff/123/parsed");
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldCreateNextLinkForRevision() {
|
||||
DiffResult result = createResult();
|
||||
mockPartialResult(result);
|
||||
|
||||
DiffResultDto dto = mapper.mapForRevision(REPOSITORY, result, "123");
|
||||
|
||||
Optional<Link> nextLink = dto.getLinks().getLinkBy("next");
|
||||
assertThat(nextLink)
|
||||
.isPresent()
|
||||
.get()
|
||||
.extracting("href")
|
||||
.isEqualTo("/scm/api/v2/repositories/space/X/diff/123/parsed?offset=30&limit=10");
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldCreateLinkToLoadMoreLinesForFilesWithHunks() {
|
||||
DiffResultDto dto = mapper.mapForRevision(REPOSITORY, createResult(), "123");
|
||||
@@ -111,6 +126,55 @@ class DiffResultToDiffResultDtoMapperTest {
|
||||
.isEqualTo("/scm/api/v2/repositories/space/X/incoming/feature%2Fsome/master/diff/parsed");
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldCreateSelfLinkForIncomingWithOffset() {
|
||||
DiffResult result = createResult();
|
||||
when(result.getOffset()).thenReturn(25);
|
||||
DiffResultDto dto = mapper.mapForIncoming(REPOSITORY, result, "feature/some", "master");
|
||||
|
||||
Optional<Link> selfLink = dto.getLinks().getLinkBy("self");
|
||||
assertThat(selfLink)
|
||||
.isPresent()
|
||||
.get()
|
||||
.extracting("href")
|
||||
.isEqualTo("/scm/api/v2/repositories/space/X/incoming/feature%2Fsome/master/diff/parsed?offset=25");
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldCreateSelfLinkForIncomingWithLimit() {
|
||||
DiffResult result = createResult();
|
||||
when(result.getLimit()).thenReturn(of(25));
|
||||
DiffResultDto dto = mapper.mapForIncoming(REPOSITORY, result, "feature/some", "master");
|
||||
|
||||
Optional<Link> selfLink = dto.getLinks().getLinkBy("self");
|
||||
assertThat(selfLink)
|
||||
.isPresent()
|
||||
.get()
|
||||
.extracting("href")
|
||||
.isEqualTo("/scm/api/v2/repositories/space/X/incoming/feature%2Fsome/master/diff/parsed?offset=0&limit=25");
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldCreateNextLinkForIncoming() {
|
||||
DiffResult result = createResult();
|
||||
mockPartialResult(result);
|
||||
|
||||
DiffResultDto dto = mapper.mapForIncoming(REPOSITORY, result, "feature/some", "master");
|
||||
|
||||
Optional<Link> nextLink = dto.getLinks().getLinkBy("next");
|
||||
assertThat(nextLink)
|
||||
.isPresent()
|
||||
.get()
|
||||
.extracting("href")
|
||||
.isEqualTo("/scm/api/v2/repositories/space/X/incoming/feature%2Fsome/master/diff/parsed?offset=30&limit=10");
|
||||
}
|
||||
|
||||
private void mockPartialResult(DiffResult result) {
|
||||
when(result.getLimit()).thenReturn(of(10));
|
||||
when(result.getOffset()).thenReturn(20);
|
||||
when(result.isPartial()).thenReturn(true);
|
||||
}
|
||||
|
||||
private DiffResult createResult() {
|
||||
return result(
|
||||
addedFile("A.java", "abc"),
|
||||
|
||||
@@ -39,6 +39,7 @@ import org.junit.After;
|
||||
import org.junit.Before;
|
||||
import org.junit.Test;
|
||||
import org.junit.runner.RunWith;
|
||||
import org.mockito.Answers;
|
||||
import org.mockito.InjectMocks;
|
||||
import org.mockito.Mock;
|
||||
import org.mockito.junit.MockitoJUnitRunner;
|
||||
@@ -71,6 +72,7 @@ import static org.junit.Assert.assertTrue;
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.ArgumentMatchers.anyString;
|
||||
import static org.mockito.Mockito.mock;
|
||||
import static org.mockito.Mockito.verify;
|
||||
import static org.mockito.Mockito.when;
|
||||
import static sonia.scm.repository.api.DiffFormat.NATIVE;
|
||||
|
||||
@@ -97,9 +99,9 @@ public class IncomingRootResourceTest extends RepositoryTestBase {
|
||||
@Mock
|
||||
private LogCommandBuilder logCommandBuilder;
|
||||
|
||||
@Mock
|
||||
@Mock(answer = Answers.RETURNS_SELF)
|
||||
private DiffCommandBuilder diffCommandBuilder;
|
||||
@Mock
|
||||
@Mock(answer = Answers.RETURNS_SELF)
|
||||
private DiffResultCommandBuilder diffResultCommandBuilder;
|
||||
|
||||
@Mock
|
||||
@@ -199,9 +201,6 @@ public class IncomingRootResourceTest extends RepositoryTestBase {
|
||||
|
||||
@Test
|
||||
public void shouldGetDiffs() throws Exception {
|
||||
when(diffCommandBuilder.setRevision("src_changeset_id")).thenReturn(diffCommandBuilder);
|
||||
when(diffCommandBuilder.setAncestorChangeset("target_changeset_id")).thenReturn(diffCommandBuilder);
|
||||
when(diffCommandBuilder.setFormat(NATIVE)).thenReturn(diffCommandBuilder);
|
||||
when(diffCommandBuilder.retrieveContent()).thenReturn(output -> {});
|
||||
MockHttpRequest request = MockHttpRequest
|
||||
.get(INCOMING_DIFF_URL + "src_changeset_id/target_changeset_id/diff")
|
||||
@@ -217,12 +216,13 @@ public class IncomingRootResourceTest extends RepositoryTestBase {
|
||||
assertThat(response.getOutputHeaders().containsKey(expectedHeader)).isTrue();
|
||||
assertThat((String) response.getOutputHeaders().get("Content-Disposition").get(0))
|
||||
.contains(expectedValue);
|
||||
verify(diffCommandBuilder).setRevision("src_changeset_id");
|
||||
verify(diffCommandBuilder).setAncestorChangeset("target_changeset_id");
|
||||
verify(diffCommandBuilder).setFormat(NATIVE);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void shouldGetParsedDiffs() throws Exception {
|
||||
when(diffResultCommandBuilder.setRevision("src_changeset_id")).thenReturn(diffResultCommandBuilder);
|
||||
when(diffResultCommandBuilder.setAncestorChangeset("target_changeset_id")).thenReturn(diffResultCommandBuilder);
|
||||
DiffResult diffResult = mock(DiffResult.class);
|
||||
when(diffResultCommandBuilder.getDiffResult()).thenReturn(diffResult);
|
||||
when(diffResultToDiffResultDtoMapper.mapForIncoming(REPOSITORY, diffResult, "src_changeset_id", "target_changeset_id"))
|
||||
@@ -239,6 +239,42 @@ public class IncomingRootResourceTest extends RepositoryTestBase {
|
||||
.isEqualTo(200);
|
||||
assertThat(response.getContentAsString())
|
||||
.contains("\"self\":{\"href\":\"http://self\"}");
|
||||
verify(diffResultCommandBuilder).setRevision("src_changeset_id");
|
||||
verify(diffResultCommandBuilder).setAncestorChangeset("target_changeset_id");
|
||||
}
|
||||
|
||||
@Test
|
||||
public void shouldGetParsedDiffsWithLimit() throws Exception {
|
||||
DiffResult diffResult = mock(DiffResult.class);
|
||||
when(diffResultCommandBuilder.getDiffResult()).thenReturn(diffResult);
|
||||
when(diffResultToDiffResultDtoMapper.mapForIncoming(REPOSITORY, diffResult, "src_changeset_id", "target_changeset_id"))
|
||||
.thenReturn(new DiffResultDto(Links.linkingTo().self("http://self").build()));
|
||||
|
||||
MockHttpRequest request = MockHttpRequest
|
||||
.get(INCOMING_DIFF_URL + "src_changeset_id/target_changeset_id/diff/parsed?limit=42")
|
||||
.accept(VndMediaType.DIFF_PARSED);
|
||||
MockHttpResponse response = new MockHttpResponse();
|
||||
|
||||
dispatcher.invoke(request, response);
|
||||
|
||||
verify(diffResultCommandBuilder).setLimit(42);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void shouldGetParsedDiffsWithOffset() throws Exception {
|
||||
DiffResult diffResult = mock(DiffResult.class);
|
||||
when(diffResultCommandBuilder.getDiffResult()).thenReturn(diffResult);
|
||||
when(diffResultToDiffResultDtoMapper.mapForIncoming(REPOSITORY, diffResult, "src_changeset_id", "target_changeset_id"))
|
||||
.thenReturn(new DiffResultDto(Links.linkingTo().self("http://self").build()));
|
||||
|
||||
MockHttpRequest request = MockHttpRequest
|
||||
.get(INCOMING_DIFF_URL + "src_changeset_id/target_changeset_id/diff/parsed?offset=42")
|
||||
.accept(VndMediaType.DIFF_PARSED);
|
||||
MockHttpResponse response = new MockHttpResponse();
|
||||
|
||||
dispatcher.invoke(request, response);
|
||||
|
||||
verify(diffResultCommandBuilder).setOffset(42);
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -256,9 +292,6 @@ public class IncomingRootResourceTest extends RepositoryTestBase {
|
||||
|
||||
@Test
|
||||
public void shouldGet404OnMissingRevision() throws Exception {
|
||||
when(diffCommandBuilder.setRevision(anyString())).thenReturn(diffCommandBuilder);
|
||||
when(diffCommandBuilder.setAncestorChangeset(anyString())).thenReturn(diffCommandBuilder);
|
||||
when(diffCommandBuilder.setFormat(any())).thenReturn(diffCommandBuilder);
|
||||
when(diffCommandBuilder.retrieveContent()).thenThrow(new NotFoundException("Text", "x"));
|
||||
|
||||
MockHttpRequest request = MockHttpRequest
|
||||
@@ -273,9 +306,6 @@ public class IncomingRootResourceTest extends RepositoryTestBase {
|
||||
|
||||
@Test
|
||||
public void shouldGet400OnCrlfInjection() throws Exception {
|
||||
when(diffCommandBuilder.setRevision(anyString())).thenReturn(diffCommandBuilder);
|
||||
when(diffCommandBuilder.setAncestorChangeset(anyString())).thenReturn(diffCommandBuilder);
|
||||
when(diffCommandBuilder.setFormat(any())).thenReturn(diffCommandBuilder);
|
||||
when(diffCommandBuilder.retrieveContent()).thenThrow(new NotFoundException("Text", "x"));
|
||||
MockHttpRequest request = MockHttpRequest
|
||||
.get(INCOMING_DIFF_URL + "ny%0D%0ASet-cookie:%20Tamper=3079675143472450634/ny%0D%0ASet-cookie:%20Tamper=3079675143472450634/diff")
|
||||
@@ -290,9 +320,6 @@ public class IncomingRootResourceTest extends RepositoryTestBase {
|
||||
|
||||
@Test
|
||||
public void shouldGet400OnUnknownFormat() throws Exception {
|
||||
when(diffCommandBuilder.setRevision(anyString())).thenReturn(diffCommandBuilder);
|
||||
when(diffCommandBuilder.setAncestorChangeset(anyString())).thenReturn(diffCommandBuilder);
|
||||
when(diffCommandBuilder.setFormat(any())).thenReturn(diffCommandBuilder);
|
||||
when(diffCommandBuilder.retrieveContent()).thenThrow(new NotFoundException("Test", "test"));
|
||||
MockHttpRequest request = MockHttpRequest
|
||||
.get(INCOMING_DIFF_URL + "src_changeset_id/target_changeset_id/diff?format=Unknown")
|
||||
|
||||
Reference in New Issue
Block a user