diff --git a/CHANGELOG.md b/CHANGELOG.md index 224c813496..223f58ed74 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,8 +6,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## Unreleased ### Added -- Show commit contributors in table on changeset details view ([#1169](https://github.com/scm-manager/scm-manager/pull/1169)) - Option to configure jvm parameter of docker container with env JAVA_OPTS or with arguments ([#1175](https://github.com/scm-manager/scm-manager/pull/1175)) +- Added links in diff views to expand the gaps between "hunks" ([#1178](https://github.com/scm-manager/scm-manager/pull/1178)) +- Show commit contributors in table on changeset details view ([#1169](https://github.com/scm-manager/scm-manager/pull/1169)) + ### Fixed - Avoid caching of detected browser language ([#1176](https://github.com/scm-manager/scm-manager/pull/1176)) - Fixes configuration of jetty listener address with system property `jetty.host` ([#1173](https://github.com/scm-manager/scm-manager/pull/1173), [#1174](https://github.com/scm-manager/scm-manager/pull/1174)) diff --git a/scm-ui/ui-components/src/__snapshots__/storyshots.test.ts.snap b/scm-ui/ui-components/src/__snapshots__/storyshots.test.ts.snap index ad6e06d4e9..7406de61c8 100644 --- a/scm-ui/ui-components/src/__snapshots__/storyshots.test.ts.snap +++ b/scm-ui/ui-components/src/__snapshots__/storyshots.test.ts.snap @@ -1875,18 +1875,6 @@ exports[`Storyshots Diff Binaries 1`] = ` /> - - - - - - - @@ -2550,18 +2538,6 @@ exports[`Storyshots Diff CollapsingWithFunction 1`] = ` /> - - - - - - - @@ -3402,18 +3378,6 @@ exports[`Storyshots Diff CollapsingWithFunction 1`] = ` /> - - - - - - - @@ -3850,18 +3814,6 @@ exports[`Storyshots Diff CollapsingWithFunction 1`] = ` /> - - - - - - - @@ -4419,18 +4371,6 @@ exports[`Storyshots Diff Default 1`] = ` /> - - - - - - - @@ -4994,18 +4934,6 @@ exports[`Storyshots Diff Default 1`] = ` /> - - - - - - - @@ -5846,18 +5774,6 @@ exports[`Storyshots Diff Default 1`] = ` /> - - - - - - - @@ -6294,18 +6210,6 @@ exports[`Storyshots Diff Default 1`] = ` /> - - - - - - - @@ -6742,18 +6646,6 @@ exports[`Storyshots Diff Default 1`] = ` /> - - - - - - - @@ -7797,18 +7689,6 @@ exports[`Storyshots Diff Default 1`] = ` /> - - - - - - - @@ -8251,6 +8131,4312 @@ exports[`Storyshots Diff Default 1`] = ` `; +exports[`Storyshots Diff Expandable 1`] = ` +
+
+
+
+
+ + + src/main/java/com/cloudogu/scm/review/events/EventListener.java + + + modify + +
+
+
+
+ + + + + +
+
+
+
+
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ 1 + + 1 + + package com.cloudogu.scm.review.events; +
+ 2 + + 2 + + +
+ 3 + + + import com.cloudogu.scm.review.comment.service.BasicComment; +
+ 4 + + + import com.cloudogu.scm.review.comment.service.BasicCommentEvent; +
+ 5 + + + import com.cloudogu.scm.review.comment.service.CommentEvent; +
+ 6 + + + import com.cloudogu.scm.review.comment.service.ReplyEvent; +
+ 7 + + 3 + + import com.cloudogu.scm.review.pullrequest.service.BasicPullRequestEvent; +
+ 8 + + 4 + + import com.cloudogu.scm.review.pullrequest.service.PullRequest; +
+ 9 + + + import com.cloudogu.scm.review.pullrequest.service.PullRequestEvent; +
+ 10 + + 5 + + import com.github.legman.Subscribe; +
+ 11 + + + import lombok.Data; +
+ 12 + + 6 + + import org.apache.shiro.SecurityUtils; +
+ 13 + + 7 + + import org.apache.shiro.subject.PrincipalCollection; +
+ 14 + + 8 + + import org.apache.shiro.subject.Subject; +
+ 15 + + 9 + + import sonia.scm.EagerSingleton; +
+ 16 + + + import sonia.scm.HandlerEventType; +
+ 17 + + + import sonia.scm.event.HandlerEvent; +
+ 18 + + 10 + + import sonia.scm.plugin.Extension; +
+ 19 + + 11 + + import sonia.scm.repository.Repository; +
+ 20 + + 12 + + import sonia.scm.security.SessionId; +
+
+ + + + diff.expandLastBottomByLines + + + + + + diff.expandLastBottomComplete + +
+
+
+
+
+
+
+
+ + + src/main/js/ChangeNotification.tsx + + + modify + +
+
+
+
+ + + + + +
+
+
+
+
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+
+ + + + diff.expandComplete + +
+
+ 2 + + 2 + + import { Link } from "@scm-manager/ui-types"; +
+ 3 + + 3 + + import { apiClient, Toast, ToastButtons, ToastButton } from "@scm-manager/ui-components"; +
+ 4 + + 4 + + import { PullRequest } from "./types/PullRequest"; +
+ + 5 + + import { useTranslation } from "react-i18next"; +
+ 5 + + 6 + + +
+ 6 + + 7 + + type HandlerProps = { +
+ 7 + + 8 + + url: string; +
+
+ + + + diff.expandComplete + +
+
+
+ + + + diff.expandComplete + +
+
+ 15 + + 16 + + pullRequest: setEvent +
+ 16 + + 17 + + }); +
+ 17 + + 18 + + }, [url]); +
+ + 19 + + const { t } = useTranslation("plugins"); +
+ 18 + + 20 + + if (event) { +
+ 19 + + 21 + + return ( +
+ 20 + + + <Toast type="warning" title="New Changes"> +
+ 21 + + + <p>The underlying Pull-Request has changed. Press reload to see the changes.</p> +
+ 22 + + + <p>Warning: Non saved modification will be lost.</p> +
+ + 22 + + <Toast type="warning" title={t("scm-review-plugin.changeNotification.title")}> +
+ + 23 + + <p>{t("scm-review-plugin.changeNotification.description")}</p> +
+ + 24 + + <p>{t("scm-review-plugin.changeNotification.modificationWarning")}</p> +
+ 23 + + 25 + + <ToastButtons> +
+ 24 + + + <ToastButton icon="redo" onClick={reload}>Reload</ToastButton> +
+ 25 + + + <ToastButton icon="times" onClick={() => setEvent(undefined)}>Ignore</ToastButton> +
+ + 26 + + <ToastButton icon="redo" onClick={reload}> +
+ + 27 + + {t("scm-review-plugin.changeNotification.buttons.reload")} +
+ + 28 + + </ToastButton> +
+ + 29 + + <ToastButton icon="times" onClick={() => setEvent(undefined)}> +
+ + 30 + + {t("scm-review-plugin.changeNotification.buttons.ignore")} +
+ + 31 + + </ToastButton> +
+ 26 + + 32 + + </ToastButtons> +
+ 27 + + 33 + + </Toast> +
+ 28 + + 34 + + ); +
+
+ + + + diff.expandLastBottomByLines + + + + + + diff.expandLastBottomComplete + +
+
+
+
+
+
+
+
+ + + src/main/resources/locales/de/plugins.json + + + modify + +
+
+
+
+ + + + + +
+
+
+
+
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+
+ + + + diff.expandByLines + + + + + + diff.expandComplete + +
+
+ 181 + + 181 + + "titleClickable": "Der Kommentar bezieht sich auf eine ältere Version des Source- oder Target-Branches. Klicken Sie hier, um den ursprünglichen Kontext zu sehen." +
+ 182 + + 182 + + } +
+ 183 + + 183 + + } +
+ + 184 + + }, +
+ + 185 + + "changeNotification": { +
+ + 186 + + "title": "Neue Änderungen", +
+ + 187 + + "description": "An diesem Pull Request wurden Änderungen vorgenommen. Laden Sie die Seite neu um diese anzuzeigen.", +
+ + 188 + + "modificationWarning": "Warnung: Nicht gespeicherte Eingaben gehen verloren.", +
+ + 189 + + "buttons": { +
+ + 190 + + "reload": "Neu laden", +
+ + 191 + + "ignore": "Ignorieren" +
+ + 192 + + } +
+ 184 + + 193 + + } +
+ 185 + + 194 + + }, +
+ 186 + + 195 + + "permissions": { +
+
+ + + + diff.expandLastBottomByLines + + + + + + diff.expandLastBottomComplete + +
+
+
+
+
+
+
+
+ + + src/main/resources/locales/en/plugins.json + + + modify + +
+
+
+
+ + + + + +
+
+
+
+
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+
+ + + + diff.expandByLines + + + + + + diff.expandComplete + +
+
+ 181 + + 181 + + "titleClickable": "The comment is related to an older of the source or target branch. Click here to see the original context." +
+ 182 + + 182 + + } +
+ 183 + + 183 + + } +
+ + 184 + + }, +
+ + 185 + + "changeNotification": { +
+ + 186 + + "title": "New Changes", +
+ + 187 + + "description": "The underlying Pull-Request has changed. Press reload to see the changes.", +
+ + 188 + + "modificationWarning": "Warning: Non saved modification will be lost.", +
+ + 189 + + "buttons": { +
+ + 190 + + "reload": "Reload", +
+ + 191 + + "ignore": "Ignore" +
+ + 192 + + } +
+ 184 + + 193 + + } +
+ 185 + + 194 + + }, +
+ 186 + + 195 + + "permissions": { +
+
+ + + + diff.expandLastBottomByLines + + + + + + diff.expandLastBottomComplete + +
+
+
+
+
+
+
+
+ + + src/test/java/com/cloudogu/scm/review/events/ClientTest.java + + + modify + +
+
+
+
+ + + + + +
+
+
+
+
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+
+ + + + diff.expandComplete + +
+
+ 7 + + 7 + + import org.mockito.Mock; +
+ 8 + + 8 + + import org.mockito.junit.jupiter.MockitoExtension; +
+ 9 + + 9 + + import sonia.scm.security.SessionId; +
+ + 10 + + +
+ 10 + + 11 + + import javax.ws.rs.sse.OutboundSseEvent; +
+ 11 + + 12 + + import javax.ws.rs.sse.SseEventSink; +
+ 12 + + + +
+ 13 + + 13 + + import java.time.Clock; +
+ 14 + + 14 + + import java.time.Instant; +
+ 15 + + 15 + + import java.time.LocalDateTime; +
+ 16 + + 16 + + import java.time.ZoneOffset; +
+ 17 + + 17 + + import java.time.temporal.ChronoField; +
+ 18 + + + import java.time.temporal.ChronoUnit; +
+ 19 + + + import java.time.temporal.TemporalField; +
+ 20 + + 18 + + import java.util.concurrent.CompletableFuture; +
+ 21 + + 19 + + import java.util.concurrent.CompletionStage; +
+ 22 + + + import java.util.concurrent.atomic.AtomicLong; +
+ 23 + + 20 + + import java.util.concurrent.atomic.AtomicReference; +
+ 24 + + 21 + + +
+ 25 + + 22 + + import static java.time.temporal.ChronoUnit.MINUTES; +
+
+ + + + diff.expandByLines + + + + + + diff.expandComplete + +
+
+
+ + + + diff.expandByLines + + + + + + diff.expandComplete + +
+
+ 83 + + 80 + + +
+ 84 + + 81 + + @Test +
+ 85 + + 82 + + @SuppressWarnings("unchecked") +
+ 86 + + + void shouldCloseEventSinkOnFailure() throws InterruptedException { +
+ + 83 + + void shouldCloseEventSinkOnFailure() { +
+ 87 + + 84 + + CompletionStage future = CompletableFuture.supplyAsync(() -> { +
+ 88 + + 85 + + throw new RuntimeException("failed to send message"); +
+ 89 + + 86 + + }); +
+
+ + + + diff.expandComplete + +
+
+
+ + + + diff.expandComplete + +
+
+ 91 + + 88 + + +
+ 92 + + 89 + + client.send(message); +
+ 93 + + 90 + + +
+ 94 + + + Thread.sleep(50L); +
+ 95 + + + +
+ 96 + + + verify(eventSink).close(); +
+ + 91 + + verify(eventSink, timeout(50L)).close(); +
+ 97 + + 92 + + } +
+ 98 + + 93 + + +
+ 99 + + 94 + + @Test +
+
+ + + + diff.expandLastBottomByLines + + + + + + diff.expandLastBottomComplete + +
+
+
+
+
+
+
+
+ + + Main.java + + + modify + +
+
+
+
+ + + + + +
+
+
+
+
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ + 1 + + import java.io.PrintStream; +
+ 1 + + 2 + + import java.util.Arrays; +
+ 2 + + 3 + + +
+ 3 + + 4 + + class Main { +
+ + 5 + + private static final PrintStream OUT = System.out; +
+ + 6 + + +
+ 4 + + 7 + + public static void main(String[] args) { +
+ + 8 + + <<<<<<< HEAD +
+ 5 + + 9 + + System.out.println("Expect nothing more to happen."); +
+ 6 + + 10 + + System.out.println("The command line parameters are:"); +
+ 7 + + 11 + + Arrays.stream(args).map(arg -> "- " + arg).forEach(System.out::println); +
+ + 12 + + ======= +
+ + 13 + + OUT.println("Expect nothing more to happen."); +
+ + 14 + + OUT.println("Parameters:"); +
+ + 15 + + Arrays.stream(args).map(arg -> "- " + arg).forEach(OUT::println); +
+ + 16 + + >>>>>>> feature/use_constant +
+ 8 + + 17 + + } +
+ 9 + + 18 + + } +
+
+ + + + diff.expandLastBottomByLines + + + + + + diff.expandLastBottomComplete + +
+
+
+
+
+`; + exports[`Storyshots Diff File Annotation 1`] = `
- - - - - - - @@ -8910,18 +13084,6 @@ exports[`Storyshots Diff File Annotation 1`] = ` /> - - - - - - - @@ -9766,18 +13928,6 @@ exports[`Storyshots Diff File Annotation 1`] = ` /> - - - - - - - @@ -10218,18 +14368,6 @@ exports[`Storyshots Diff File Annotation 1`] = ` /> - - - - - - - @@ -10670,18 +14808,6 @@ exports[`Storyshots Diff File Annotation 1`] = ` /> - - - - - - - @@ -11729,18 +15855,6 @@ exports[`Storyshots Diff File Annotation 1`] = ` /> - - - - - - - @@ -12277,18 +16391,6 @@ exports[`Storyshots Diff File Controls 1`] = ` /> - - - - - - - @@ -12870,18 +16972,6 @@ exports[`Storyshots Diff File Controls 1`] = ` /> - - - - - - - @@ -13740,18 +17830,6 @@ exports[`Storyshots Diff File Controls 1`] = ` /> - - - - - - - @@ -14206,18 +18284,6 @@ exports[`Storyshots Diff File Controls 1`] = ` /> - - - - - - - @@ -14672,18 +18738,6 @@ exports[`Storyshots Diff File Controls 1`] = ` /> - - - - - - - @@ -15745,18 +19799,6 @@ exports[`Storyshots Diff File Controls 1`] = ` /> - - - - - - - @@ -16275,18 +20317,6 @@ exports[`Storyshots Diff Hunks 1`] = ` /> - - - - - - - @@ -17112,18 +21142,6 @@ exports[`Storyshots Diff Line Annotation 1`] = ` /> - - - - - - - @@ -17699,18 +21717,6 @@ exports[`Storyshots Diff Line Annotation 1`] = ` /> - - - - - - - @@ -18563,18 +22569,6 @@ exports[`Storyshots Diff Line Annotation 1`] = ` /> - - - - - - - @@ -19011,18 +23005,6 @@ exports[`Storyshots Diff Line Annotation 1`] = ` /> - - - - - - - @@ -19459,18 +23441,6 @@ exports[`Storyshots Diff Line Annotation 1`] = ` /> - - - - - - - @@ -20514,18 +24484,6 @@ exports[`Storyshots Diff Line Annotation 1`] = ` /> - - - - - - - @@ -21056,18 +25014,6 @@ exports[`Storyshots Diff OnClick 1`] = ` /> - - - - - - - @@ -21671,18 +25617,6 @@ exports[`Storyshots Diff OnClick 1`] = ` /> - - - - - - - @@ -22585,18 +26519,6 @@ exports[`Storyshots Diff OnClick 1`] = ` /> - - - - - - - @@ -23063,18 +26985,6 @@ exports[`Storyshots Diff OnClick 1`] = ` /> - - - - - - - @@ -23541,18 +27451,6 @@ exports[`Storyshots Diff OnClick 1`] = ` /> - - - - - - - @@ -24672,18 +28570,6 @@ exports[`Storyshots Diff OnClick 1`] = ` /> - - - - - - - @@ -25239,18 +29125,6 @@ exports[`Storyshots Diff Side-By-Side 1`] = ` /> - - - - - - - @@ -25907,18 +29781,6 @@ exports[`Storyshots Diff Side-By-Side 1`] = ` /> - - - - - - - @@ -26849,18 +30711,6 @@ exports[`Storyshots Diff Side-By-Side 1`] = ` /> - - - - - - - @@ -27349,18 +31199,6 @@ exports[`Storyshots Diff Side-By-Side 1`] = ` /> - - - - - - - @@ -27849,18 +31687,6 @@ exports[`Storyshots Diff Side-By-Side 1`] = ` /> - - - - - - - @@ -29073,18 +32899,6 @@ exports[`Storyshots Diff Side-By-Side 1`] = ` /> - - - - - - - @@ -29675,18 +33489,6 @@ exports[`Storyshots Diff SyntaxHighlighting 1`] = ` /> - - - - - - - @@ -30250,18 +34052,6 @@ exports[`Storyshots Diff SyntaxHighlighting 1`] = ` /> - - - - - - - @@ -31102,18 +34892,6 @@ exports[`Storyshots Diff SyntaxHighlighting 1`] = ` /> - - - - - - - @@ -31550,18 +35328,6 @@ exports[`Storyshots Diff SyntaxHighlighting 1`] = ` /> - - - - - - - @@ -31998,18 +35764,6 @@ exports[`Storyshots Diff SyntaxHighlighting 1`] = ` /> - - - - - - - @@ -33053,18 +36807,6 @@ exports[`Storyshots Diff SyntaxHighlighting 1`] = ` /> - - - - - - - diff --git a/scm-ui/ui-components/src/repos/Diff.stories.tsx b/scm-ui/ui-components/src/repos/Diff.stories.tsx index 861be0d364..7032aeb74f 100644 --- a/scm-ui/ui-components/src/repos/Diff.stories.tsx +++ b/scm-ui/ui-components/src/repos/Diff.stories.tsx @@ -114,4 +114,11 @@ storiesOf("Diff", module) }) .add("CollapsingWithFunction", () => ( oldPath.endsWith(".java")} /> - )); + )) + .add("Expandable", () => { + const filesWithLanguage = diffFiles.map((file: File) => { + file._links = { lines: { href: "http://example.com/" } }; + return file; + }); + return ; + }); diff --git a/scm-ui/ui-components/src/repos/DiffExpander.test.ts b/scm-ui/ui-components/src/repos/DiffExpander.test.ts new file mode 100644 index 0000000000..3534cf77d2 --- /dev/null +++ b/scm-ui/ui-components/src/repos/DiffExpander.test.ts @@ -0,0 +1,404 @@ +/* + * 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"; +import DiffExpander from "./DiffExpander"; +import { File, Hunk } from "./DiffTypes"; + +const HUNK_0: Hunk = { + content: "@@ -1,8 +1,8 @@", + oldStart: 1, + newStart: 1, + oldLines: 8, + newLines: 8, + changes: [ + { content: "line", type: "normal", oldLineNumber: 1, newLineNumber: 1, isNormal: true }, + { content: "line", type: "normal", oldLineNumber: 2, newLineNumber: 2, isNormal: true }, + { content: "line", type: "delete", lineNumber: 3, isDelete: true }, + { content: "line", type: "delete", lineNumber: 4, isDelete: true }, + { content: "line", type: "delete", lineNumber: 5, isDelete: true }, + { content: "line", type: "insert", lineNumber: 3, isInsert: true }, + { content: "line", type: "insert", lineNumber: 4, isInsert: true }, + { content: "line", type: "insert", lineNumber: 5, isInsert: true }, + { content: "line", type: "normal", oldLineNumber: 6, newLineNumber: 6, isNormal: true }, + { content: "line", type: "normal", oldLineNumber: 7, newLineNumber: 7, isNormal: true }, + { content: "line", type: "normal", oldLineNumber: 8, newLineNumber: 8, isNormal: true } + ] +}; +const HUNK_1: Hunk = { + content: "@@ -14,6 +14,7 @@", + oldStart: 14, + newStart: 14, + oldLines: 6, + newLines: 7, + changes: [ + { content: "line", type: "normal", oldLineNumber: 14, newLineNumber: 14, isNormal: true }, + { content: "line", type: "normal", oldLineNumber: 15, newLineNumber: 15, isNormal: true }, + { content: "line", type: "normal", oldLineNumber: 16, newLineNumber: 16, isNormal: true }, + { content: "line", type: "insert", lineNumber: 17, isInsert: true }, + { content: "line", type: "normal", oldLineNumber: 17, newLineNumber: 18, isNormal: true }, + { content: "line", type: "normal", oldLineNumber: 18, newLineNumber: 19, isNormal: true }, + { content: "line", type: "normal", oldLineNumber: 19, newLineNumber: 20, isNormal: true } + ] +}; +const HUNK_2: Hunk = { + content: "@@ -21,7 +22,7 @@", + oldStart: 21, + newStart: 22, + oldLines: 7, + newLines: 7, + changes: [ + { content: "line", type: "normal", oldLineNumber: 21, newLineNumber: 22, isNormal: true }, + { content: "line", type: "normal", oldLineNumber: 22, newLineNumber: 23, isNormal: true }, + { content: "line", type: "normal", oldLineNumber: 23, newLineNumber: 24, isNormal: true }, + { content: "line", type: "delete", lineNumber: 24, isDelete: true }, + { content: "line", type: "insert", lineNumber: 25, isInsert: true }, + { content: "line", type: "normal", oldLineNumber: 25, newLineNumber: 26, isNormal: true }, + { content: "line", type: "normal", oldLineNumber: 26, newLineNumber: 27, isNormal: true }, + { content: "line", type: "normal", oldLineNumber: 27, newLineNumber: 28, isNormal: true } + ] +}; +const HUNK_3: Hunk = { + content: "@@ -33,6 +34,7 @@", + oldStart: 33, + newStart: 34, + oldLines: 6, + newLines: 7, + changes: [ + { content: "line", type: "normal", oldLineNumber: 33, newLineNumber: 34, isNormal: true }, + { content: "line", type: "normal", oldLineNumber: 34, newLineNumber: 35, isNormal: true }, + { content: "line", type: "normal", oldLineNumber: 35, newLineNumber: 36, isNormal: true }, + { content: "line", type: "insert", lineNumber: 37, isInsert: true }, + { content: "line", type: "normal", oldLineNumber: 36, newLineNumber: 38, isNormal: true }, + { content: "line", type: "normal", oldLineNumber: 37, newLineNumber: 39, isNormal: true }, + { content: "line", type: "normal", oldLineNumber: 38, newLineNumber: 40, isNormal: true } + ] +}; +const TEST_CONTENT_WITH_HUNKS: File = { + hunks: [HUNK_0, HUNK_1, HUNK_2, HUNK_3], + newEndingNewLine: true, + newPath: "src/main/js/CommitMessage.js", + newRevision: "4305a8df175b7bec25acbe542a13fbe2a718a608", + oldEndingNewLine: true, + oldPath: "src/main/js/CommitMessage.js", + oldRevision: "e05c8495bb1dc7505d73af26210c8ff4825c4500", + type: "modify", + language: "javascript", + _links: { + lines: { + href: "http://localhost:8081/scm/api/v2/content/abc/CommitMessage.js?start={start}&end={end}", + templated: true + } + } +}; + +const TEST_CONTENT_WITH_NEW_BINARY_FILE: File = { + oldPath: "/dev/null", + newPath: "src/main/fileUploadV2.png", + oldEndingNewLine: true, + newEndingNewLine: true, + oldRevision: "0000000000000000000000000000000000000000", + newRevision: "86c370aae0727d628a5438f79a5cdd45752b9d99", + type: "add" +}; + +const TEST_CONTENT_WITH_NEW_TEXT_FILE: File = { + oldPath: "/dev/null", + newPath: "src/main/markdown/README.md", + oldEndingNewLine: true, + newEndingNewLine: true, + oldRevision: "0000000000000000000000000000000000000000", + newRevision: "4e173d365d796b9a9e7562fcd0ef90398ae37046", + type: "add", + language: "markdown", + hunks: [ + { + content: "@@ -0,0 +1,2 @@", + newStart: 1, + newLines: 2, + changes: [ + { content: "line 1", type: "insert", lineNumber: 1, isInsert: true }, + { content: "line 2", type: "insert", lineNumber: 2, isInsert: true } + ] + } + ], + _links: { + lines: { + href: + "http://localhost:8081/scm/api/v2/repositories/scm-manager/scm-editor-plugin/content/c63898d35520ee47bcc3a8291660979918715762/src/main/markdown/README.md?start={start}&end={end}", + templated: true + } + } +}; + +const TEST_CONTENT_WITH_DELETED_TEXT_FILE: File = { + oldPath: "README.md", + newPath: "/dev/null", + oldEndingNewLine: true, + newEndingNewLine: true, + oldRevision: "4875ab3b7a1bb117e1948895148557fc5c0b6f75", + newRevision: "0000000000000000000000000000000000000000", + type: "delete", + language: "markdown", + hunks: [ + { + content: "@@ -1 +0,0 @@", + oldStart: 1, + oldLines: 1, + changes: [{ content: "# scm-editor-plugin", type: "delete", lineNumber: 1, isDelete: true }] + } + ], + _links: { lines: { href: "http://localhost:8081/dev/null?start={start}&end={end}", templated: true } } +}; + +const TEST_CONTENT_WITH_DELETED_LINES_AT_END: File = { + oldPath: "pom.xml", + newPath: "pom.xml", + oldEndingNewLine: true, + newEndingNewLine: true, + oldRevision: "b207512c0eab22536c9e5173afbe54cc3a24a22e", + newRevision: "5347c3fe0c2c4d4de7f308ae61bd5546460d7e93", + type: "modify", + language: "xml", + hunks: [ + { + content: "@@ -108,15 +108,3 @@", + oldStart: 108, + newStart: 108, + oldLines: 15, + newLines: 3, + changes: [ + { content: "line", type: "normal", oldLineNumber: 108, newLineNumber: 108, isNormal: true }, + { content: "line", type: "normal", oldLineNumber: 109, newLineNumber: 109, isNormal: true }, + { content: "line", type: "normal", oldLineNumber: 110, newLineNumber: 110, isNormal: true }, + { content: "line", type: "delete", lineNumber: 111, isDelete: true }, + { content: "line", type: "delete", lineNumber: 112, isDelete: true }, + { content: "line", type: "delete", lineNumber: 113, isDelete: true }, + { content: "line", type: "delete", lineNumber: 114, isDelete: true }, + { content: "line", type: "delete", lineNumber: 115, isDelete: true }, + { content: "line", type: "delete", lineNumber: 116, isDelete: true }, + { content: "line", type: "delete", lineNumber: 117, isDelete: true }, + { content: "line", type: "delete", lineNumber: 118, isDelete: true }, + { content: "line", type: "delete", lineNumber: 119, isDelete: true }, + { content: "line", type: "delete", lineNumber: 120, isDelete: true }, + { content: "line", type: "delete", lineNumber: 121, isDelete: true }, + { content: "line", type: "delete", lineNumber: 122, isDelete: true } + ] + } + ], + _links: { + lines: { + href: "http://localhost:8081/scm/api/v2/content/abc/CommitMessage.js?start={start}&end={end}", + templated: true + } + } +}; + +const TEST_CONTENT_WITH_ALL_LINES_REMOVED_FROM_FILE: File = { + oldPath: "pom.xml", + newPath: "pom.xml", + oldEndingNewLine: true, + newEndingNewLine: true, + oldRevision: "2cc811c64f71ceda28f1ec0d97e1973395b299ff", + newRevision: "e69de29bb2d1d6434b8b29ae775ad8c2e48c5391", + type: "modify", + language: "xml", + hunks: [ + { + content: "@@ -1,3 +0,0 @@", + oldStart: 1, + oldLines: 3, + changes: [ + { content: "line", type: "delete", lineNumber: 1, isDelete: true }, + { content: "line", type: "delete", lineNumber: 2, isDelete: true }, + { content: "line", type: "delete", lineNumber: 3, isDelete: true } + ] + } + ], + _links: { + lines: { + href: + "http://localhost:8081/scm/api/v2/repositories/scm-manager/scm-editor-plugin/content/b313a7690f028c77df98417c1ed6cba67e5692ec/pom.xml?start={start}&end={end}", + templated: true + } + } +}; + +describe("with hunks the diff expander", () => { + const diffExpander = new DiffExpander(TEST_CONTENT_WITH_HUNKS); + + afterEach(() => { + fetchMock.reset(); + fetchMock.restore(); + }); + + it("should have hunk count from origin", () => { + expect(diffExpander.hunkCount()).toBe(4); + }); + + it("should return correct hunk", () => { + expect(diffExpander.getHunk(1).hunk).toBe(HUNK_1); + }); + + it("should return max expand head range for first hunk", () => { + expect(diffExpander.getHunk(0).maxExpandHeadRange).toBe(0); + }); + + it("should return max expand head range for hunks in the middle", () => { + expect(diffExpander.getHunk(1).maxExpandHeadRange).toBe(5); + }); + + it("should return max expand bottom range for hunks in the middle", () => { + expect(diffExpander.getHunk(1).maxExpandBottomRange).toBe(1); + }); + + it("should return a really bix number for the expand bottom range of the last hunk", () => { + expect(diffExpander.getHunk(3).maxExpandBottomRange).toBe(-1); + }); + it("should create new hunk with new line from api client at the bottom", async () => { + expect(diffExpander.getHunk(1).hunk.changes.length).toBe(7); + const oldHunkCount = diffExpander.hunkCount(); + 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; + await diffExpander + .getHunk(1) + .expandBottom(1) + .then(file => (newFile = file)); + expect(fetchMock.done()).toBe(true); + expect(newFile!.hunks!.length).toBe(oldHunkCount + 1); + expect(newFile!.hunks![1]).toBe(expandedHunk); + + const newHunk = newFile!.hunks![2]; + expect(newHunk.changes.length).toBe(1); + expect(newHunk.changes[0].content).toBe("new line 1"); + expect(newHunk.expansion).toBe(true); + + expect(newFile!.hunks![3]).toBe(subsequentHunk); + }); + it("should create new hunk with new line from api client at the top", async () => { + expect(diffExpander.getHunk(1).hunk.changes.length).toBe(7); + const oldHunkCount = diffExpander.hunkCount(); + const expandedHunk = diffExpander.getHunk(1).hunk; + const preceedingHunk = diffExpander.getHunk(0).hunk; + fetchMock.get( + "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; + await diffExpander + .getHunk(1) + .expandHead(5) + .then(file => (newFile = file)); + expect(fetchMock.done()).toBe(true); + expect(newFile!.hunks!.length).toBe(oldHunkCount + 1); + expect(newFile!.hunks![0]).toBe(preceedingHunk); + expect(newFile!.hunks![2]).toBe(expandedHunk); + + const newHunk = newFile!.hunks![1]; + expect(newHunk.changes.length).toBe(5); + expect(newHunk.changes[0].content).toBe("new line 9"); + expect(newHunk.changes[0].oldLineNumber).toBe(9); + expect(newHunk.changes[0].newLineNumber).toBe(9); + expect(newHunk.changes[1].content).toBe("new line 10"); + expect(newHunk.changes[1].oldLineNumber).toBe(10); + expect(newHunk.changes[1].newLineNumber).toBe(10); + expect(newHunk.changes[4].content).toBe("new line 13"); + expect(newHunk.changes[4].oldLineNumber).toBe(13); + expect(newHunk.changes[4].newLineNumber).toBe(13); + expect(newHunk.expansion).toBe(true); + }); + it("should set fully expanded to true if expanded completely", async () => { + const oldHunkCount = diffExpander.hunkCount(); + fetchMock.get( + "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; + await diffExpander + .getHunk(3) + .expandBottom(10) + .then(file => (newFile = file)); + expect(newFile!.hunks!.length).toBe(oldHunkCount + 1); + expect(newFile!.hunks![4].fullyExpanded).toBe(true); + }); + it("should set end to -1 if requested to expand to the end", async () => { + fetchMock.get( + "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; + await diffExpander + .getHunk(3) + .expandBottom(-1) + .then(file => (newFile = file)); + await fetchMock.flush(true); + expect(newFile!.hunks![4].fullyExpanded).toBe(true); + }); +}); + +describe("for a new file with text input the diff expander", () => { + const diffExpander = new DiffExpander(TEST_CONTENT_WITH_NEW_TEXT_FILE); + it("should create answer for single hunk", () => { + expect(diffExpander.hunkCount()).toBe(1); + }); + it("should neither give expandable lines for top nor bottom", () => { + const hunk = diffExpander.getHunk(0); + expect(hunk.maxExpandHeadRange).toBe(0); + expect(hunk.maxExpandBottomRange).toBe(0); + }); +}); + +describe("for a deleted file with text input the diff expander", () => { + const diffExpander = new DiffExpander(TEST_CONTENT_WITH_DELETED_TEXT_FILE); + it("should create answer for single hunk", () => { + expect(diffExpander.hunkCount()).toBe(1); + }); + it("should neither give expandable lines for top nor bottom", () => { + const hunk = diffExpander.getHunk(0); + expect(hunk.maxExpandHeadRange).toBe(0); + expect(hunk.maxExpandBottomRange).toBe(0); + }); +}); + +describe("for a new file with binary input the diff expander", () => { + const diffExpander = new DiffExpander(TEST_CONTENT_WITH_NEW_BINARY_FILE); + it("should create answer for no hunk", () => { + expect(diffExpander.hunkCount()).toBe(0); + }); +}); + +describe("with deleted lines at the end", () => { + const diffExpander = new DiffExpander(TEST_CONTENT_WITH_DELETED_LINES_AT_END); + it("should not be expandable", () => { + expect(diffExpander.getHunk(0)!.maxExpandBottomRange).toBe(0); + }); +}); + +describe("with all lines removed from file", () => { + const diffExpander = new DiffExpander(TEST_CONTENT_WITH_ALL_LINES_REMOVED_FROM_FILE); + it("should not be expandable", () => { + expect(diffExpander.getHunk(0)!.maxExpandBottomRange).toBe(0); + }); +}); diff --git a/scm-ui/ui-components/src/repos/DiffExpander.ts b/scm-ui/ui-components/src/repos/DiffExpander.ts new file mode 100644 index 0000000000..63ec2b6c6d --- /dev/null +++ b/scm-ui/ui-components/src/repos/DiffExpander.ts @@ -0,0 +1,198 @@ +/* + * 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 { apiClient } from "@scm-manager/ui-components"; +import { Change, File, Hunk } from "./DiffTypes"; +import { Link } from "@scm-manager/ui-types"; + +class DiffExpander { + file: File; + + constructor(file: File) { + this.file = file; + } + + hunkCount = () => { + if (this.file.hunks) { + return this.file.hunks.length; + } else { + return 0; + } + }; + + minLineNumber: (n: number) => number = (n: number) => { + return this.file.hunks![n]!.newStart!; + }; + + maxLineNumber: (n: number) => number = (n: number) => { + return this.file.hunks![n]!.newStart! + this.file.hunks![n]!.newLines! - 1; + }; + + computeMaxExpandHeadRange = (n: number) => { + if (this.file.type === "delete") { + return 0; + } else if (n === 0) { + return this.minLineNumber(n) - 1; + } + return this.minLineNumber(n) - this.maxLineNumber(n - 1) - 1; + }; + + computeMaxExpandBottomRange = (n: number) => { + if (this.file.type === "add" || this.file.type === "delete") { + return 0; + } + const changes = this.file.hunks![n].changes; + if (changes[changes.length - 1].type === "normal") { + if (n === this.file!.hunks!.length - 1) { + return this.file!.hunks![this.file!.hunks!.length - 1].fullyExpanded ? 0 : -1; + } + return this.minLineNumber(n + 1) - this.maxLineNumber(n) - 1; + } else { + return 0; + } + }; + + expandHead: (n: number, count: number) => Promise = (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 => { + const hunk = this.file.hunks![n]; + + const newHunk = this.createNewHunk( + hunk.oldStart! - lines.length, + hunk.newStart! - lines.length, + lines, + lines.length + ); + + return this.addHunkToFile(newHunk, n); + }); + }; + + expandBottom: (n: number, count: number) => Promise = (n, count) => { + const maxExpandBottomRange = this.computeMaxExpandBottomRange(n); + const start = this.maxLineNumber(n); + const end = + count > 0 + ? start + Math.min(count, maxExpandBottomRange > 0 ? maxExpandBottomRange : Number.MAX_SAFE_INTEGER) + : -1; + return this.loadLines(start, end).then(lines => { + const hunk = this.file.hunks![n]; + + const newHunk: Hunk = this.createNewHunk( + this.getMaxOldLineNumber(hunk.changes) + 1, + this.getMaxNewLineNumber(hunk.changes) + 1, + lines, + count + ); + + return this.addHunkToFile(newHunk, n + 1); + }); + }; + + loadLines = (start: number, end: number) => { + const lineRequestUrl = (this.file._links!.lines as Link).href + .replace("{start}", start.toString()) + .replace("{end}", end.toString()); + return apiClient + .get(lineRequestUrl) + .then(response => response.text()) + .then(text => text.split("\n")) + .then(lines => (lines[lines.length - 1] === "" ? lines.slice(0, lines.length - 1) : lines)); + }; + + addHunkToFile = (newHunk: Hunk, position: number) => { + const newHunks: Hunk[] = []; + this.file.hunks!.forEach((oldHunk: Hunk, i: number) => { + if (i === position) { + newHunks.push(newHunk); + } + newHunks.push(oldHunk); + }); + if (position === newHunks.length) { + newHunks.push(newHunk); + } + return { ...this.file, hunks: newHunks }; + }; + + createNewHunk = (oldFirstLineNumber: number, newFirstLineNumber: number, lines: string[], requestedLines: number) => { + const newChanges: Change[] = []; + + let oldLineNumber: number = oldFirstLineNumber; + let newLineNumber: number = newFirstLineNumber; + + lines.forEach(line => { + newChanges.push({ + content: line, + type: "normal", + oldLineNumber, + newLineNumber, + isNormal: true + }); + oldLineNumber += 1; + newLineNumber += 1; + }); + + return { + changes: newChanges, + content: "", + oldStart: oldFirstLineNumber, + newStart: newFirstLineNumber, + oldLines: lines.length, + newLines: lines.length, + expansion: true, + fullyExpanded: requestedLines < 0 || lines.length < requestedLines + }; + }; + + getMaxOldLineNumber = (newChanges: Change[]) => { + const lastChange = newChanges[newChanges.length - 1]; + return lastChange.oldLineNumber || lastChange.lineNumber!; + }; + + getMaxNewLineNumber = (newChanges: Change[]) => { + const lastChange = newChanges[newChanges.length - 1]; + return lastChange.newLineNumber || lastChange.lineNumber!; + }; + + getHunk: (n: number) => ExpandableHunk = n => { + return { + maxExpandHeadRange: this.computeMaxExpandHeadRange(n), + maxExpandBottomRange: this.computeMaxExpandBottomRange(n), + expandHead: (count: number) => this.expandHead(n, count), + expandBottom: (count: number) => this.expandBottom(n, count), + hunk: this.file?.hunks![n] + }; + }; +} + +export type ExpandableHunk = { + hunk: Hunk; + maxExpandHeadRange: number; + maxExpandBottomRange: number; + expandHead: (count: number) => Promise; + expandBottom: (count: number) => Promise; +}; + +export default DiffExpander; diff --git a/scm-ui/ui-components/src/repos/DiffFile.tsx b/scm-ui/ui-components/src/repos/DiffFile.tsx index 4b042f0c4d..98fa9959e3 100644 --- a/scm-ui/ui-components/src/repos/DiffFile.tsx +++ b/scm-ui/ui-components/src/repos/DiffFile.tsx @@ -34,6 +34,11 @@ import { Change, ChangeEvent, DiffObjectProps, File, Hunk as HunkType } from "./ import TokenizedDiffView from "./TokenizedDiffView"; import DiffButton from "./DiffButton"; import { MenuContext } from "@scm-manager/ui-components"; +import DiffExpander, { ExpandableHunk } from "./DiffExpander"; +import HunkExpandLink from "./HunkExpandLink"; +import { Modal } from "../modals"; +import ErrorNotification from "../ErrorNotification"; +import HunkExpandDivider from "./HunkExpandDivider"; const EMPTY_ANNOTATION_FACTORY = {}; @@ -47,7 +52,10 @@ type Collapsible = { }; type State = Collapsible & { + file: File; sideBySide?: boolean; + diffExpander: DiffExpander; + expansionError?: any; }; const DiffFilePanel = styled.div` @@ -92,7 +100,9 @@ class DiffFile extends React.Component { super(props); this.state = { collapsed: this.defaultCollapse(), - sideBySide: props.sideBySide + sideBySide: props.sideBySide, + diffExpander: new DiffExpander(props.file), + file: props.file }; } @@ -116,7 +126,7 @@ class DiffFile extends React.Component { }; toggleCollapse = () => { - const { file } = this.props; + const { file } = this.state; if (this.hasContent(file)) { this.setState(state => ({ collapsed: !state.collapsed @@ -139,16 +149,122 @@ class DiffFile extends React.Component { }); }; - createHunkHeader = (hunk: HunkType, i: number) => { - if (i > 0) { - return ; + createHunkHeader = (expandableHunk: ExpandableHunk) => { + if (expandableHunk.maxExpandHeadRange > 0) { + if (expandableHunk.maxExpandHeadRange <= 10) { + return ( + + + + ); + } else { + return ( + + {" "} + + + ); + } } // hunk header must be defined return ; }; + createHunkFooter = (expandableHunk: ExpandableHunk) => { + if (expandableHunk.maxExpandBottomRange > 0) { + if (expandableHunk.maxExpandBottomRange <= 10) { + return ( + + + + ); + } else { + return ( + + {" "} + + + ); + } + } + // hunk footer must be defined + return ; + }; + + createLastHunkFooter = (expandableHunk: ExpandableHunk) => { + if (expandableHunk.maxExpandBottomRange !== 0) { + return ( + + {" "} + + + ); + } + // hunk header must be defined + return ; + }; + + expandHead = (expandableHunk: ExpandableHunk, count: number) => { + return () => { + return expandableHunk + .expandHead(count) + .then(this.diffExpanded) + .catch(this.diffExpansionFailed); + }; + }; + + expandBottom = (expandableHunk: ExpandableHunk, count: number) => { + return () => { + return expandableHunk + .expandBottom(count) + .then(this.diffExpanded) + .catch(this.diffExpansionFailed); + }; + }; + + diffExpanded = (newFile: File) => { + this.setState({ file: newFile, diffExpander: new DiffExpander(newFile) }); + }; + + diffExpansionFailed = (err: any) => { + this.setState({ expansionError: err }); + }; + collectHunkAnnotations = (hunk: HunkType) => { - const { annotationFactory, file } = this.props; + const { annotationFactory } = this.props; + const { file } = this.state; if (annotationFactory) { return annotationFactory({ hunk, @@ -160,7 +276,8 @@ class DiffFile extends React.Component { }; handleClickEvent = (change: Change, hunk: HunkType) => { - const { file, onClick } = this.props; + const { onClick } = this.props; + const { file } = this.state; const context = { changeId: getChangeKey(change), change, @@ -183,19 +300,35 @@ class DiffFile extends React.Component { } }; - renderHunk = (hunk: HunkType, i: number) => { + renderHunk = (file: File, expandableHunk: ExpandableHunk, i: number) => { + const hunk = expandableHunk.hunk; if (this.props.markConflicts && hunk.changes) { this.markConflicts(hunk); } - return [ - {this.createHunkHeader(hunk, i)}, + const items = []; + if (file._links?.lines) { + items.push(this.createHunkHeader(expandableHunk)); + } else if (i > 0) { + items.push(); + } + + items.push( - ]; + ); + if (file._links?.lines) { + if (i === file.hunks!.length - 1) { + items.push(this.createLastHunkFooter(expandableHunk)); + } else { + items.push(this.createHunkFooter(expandableHunk)); + } + } + return items; }; markConflicts = (hunk: HunkType) => { @@ -251,19 +384,11 @@ class DiffFile extends React.Component { return ; }; - concat = (array: object[][]) => { - if (array.length > 0) { - return array.reduce((a, b) => a.concat(b)); - } else { - return []; - } - }; - hasContent = (file: File) => file && !file.isBinary && file.hunks && file.hunks.length > 0; render() { - const { file, fileControlFactory, fileAnnotationFactory, t } = this.props; - const { collapsed, sideBySide } = this.state; + const { fileControlFactory, fileAnnotationFactory, t } = this.props; + const { file, collapsed, sideBySide, diffExpander, expansionError } = this.state; const viewType = sideBySide ? "split" : "unified"; let body = null; @@ -275,7 +400,11 @@ class DiffFile extends React.Component {
{fileAnnotations} - {(hunks: HunkType[]) => this.concat(hunks.map(this.renderHunk))} + {(hunks: HunkType[]) => + hunks?.map((hunk, n) => { + return this.renderHunk(file, diffExpander.getHunk(n), n); + }) + }
); @@ -306,8 +435,21 @@ class DiffFile extends React.Component { ) : null; + let errorModal; + if (expansionError) { + errorModal = ( + this.setState({ expansionError: undefined })} + body={} + active={true} + /> + ); + } + return ( + {errorModal}
string; }; diff --git a/scm-ui/ui-components/src/repos/HunkExpandDivider.tsx b/scm-ui/ui-components/src/repos/HunkExpandDivider.tsx new file mode 100644 index 0000000000..db2da6c247 --- /dev/null +++ b/scm-ui/ui-components/src/repos/HunkExpandDivider.tsx @@ -0,0 +1,43 @@ +/* + * 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 React, { FC } from "react"; +// @ts-ignore +import { Decoration } from "react-diff-view"; +import styled from "styled-components"; + +const HunkDivider = styled.div` + background: #98d8f3; + font-size: 0.7rem; + padding-left: 1.78em; +`; + +const HunkExpandDivider: FC = ({ children }) => { + return ( + + {children} + + ); +}; + +export default HunkExpandDivider; diff --git a/scm-ui/ui-components/src/repos/HunkExpandLink.tsx b/scm-ui/ui-components/src/repos/HunkExpandLink.tsx new file mode 100644 index 0000000000..0b337c5bd2 --- /dev/null +++ b/scm-ui/ui-components/src/repos/HunkExpandLink.tsx @@ -0,0 +1,58 @@ +/* + * 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 React, { FC, useState } from "react"; +import { useTranslation } from "react-i18next"; +import classNames from "classnames"; +import styled from "styled-components"; + +type Props = { + icon: string; + text: string; + onClick: () => Promise; +}; + +const ExpandLink = styled.span` + cursor: pointer; +`; + +const HunkExpandLink: FC = ({ icon, text, onClick }) => { + const [t] = useTranslation("repos"); + const [loading, setLoading] = useState(false); + + const onClickWithLoadingMarker = () => { + if (loading) { + return; + } + setLoading(true); + onClick().then(() => setLoading(false)); + }; + + return ( + + {loading ? t("diff.expanding") : text} + + ); +}; + +export default HunkExpandLink; diff --git a/scm-ui/ui-webapp/public/locales/de/repos.json b/scm-ui/ui-webapp/public/locales/de/repos.json index 091105dbed..a4be334204 100644 --- a/scm-ui/ui-webapp/public/locales/de/repos.json +++ b/scm-ui/ui-webapp/public/locales/de/repos.json @@ -197,7 +197,15 @@ "diff": { "sideBySide": "Zur zweispaltigen Ansicht wechseln", "combined": "Zur kombinierten Ansicht wechseln", - "noDiffFound": "Kein Diff zwischen den ausgewählten Branches gefunden." + "noDiffFound": "Kein Diff zwischen den ausgewählten Branches gefunden.", + "expandByLines": "{{count}} weitere Zeile laden", + "expandByLines_plural": "{{count}} weitere Zeilen laden", + "expandComplete": "{{count}} verbleibende Zeile laden", + "expandComplete_plural": "Alle {{count}} verbleibenden Zeilen laden", + "expandLastBottomByLines": "Bis zu {{count}} weitere Zeilen laden", + "expandLastBottomComplete": "Alle verbleibenden Zeilen laden", + "expanding": "Zeilen werden geladen ...", + "expansionFailed": "Fehler beim Laden der zusätzlichen Zeilen" }, "fileUpload": { "clickHere": "Klicken Sie hier um Ihre Datei hochzuladen.", diff --git a/scm-ui/ui-webapp/public/locales/en/repos.json b/scm-ui/ui-webapp/public/locales/en/repos.json index e0ae23925f..e7884bff57 100644 --- a/scm-ui/ui-webapp/public/locales/en/repos.json +++ b/scm-ui/ui-webapp/public/locales/en/repos.json @@ -204,7 +204,15 @@ }, "sideBySide": "Switch to side-by-side view", "combined": "Switch to combined view", - "noDiffFound": "No Diff between the selected branches found." + "noDiffFound": "No Diff between the selected branches found.", + "expandByLines": "load {{count}} more line", + "expandByLines_plural": "load {{count}} more lines", + "expandComplete": "load {{count}} remaining line", + "expandComplete_plural": "load all {{count}} remaining lines", + "expandLastBottomByLines": "load up to {{count}} more lines", + "expandLastBottomComplete": "load all remaining lines", + "expanding": "loading lines ...", + "expansionFailed": "Error while loading additional lines" }, "fileUpload": { "clickHere": "Click here to select your file", diff --git a/scm-webapp/src/main/java/sonia/scm/api/v2/resources/ContentResource.java b/scm-webapp/src/main/java/sonia/scm/api/v2/resources/ContentResource.java index 8cd62a4bc6..74d1d2deeb 100644 --- a/scm-webapp/src/main/java/sonia/scm/api/v2/resources/ContentResource.java +++ b/scm-webapp/src/main/java/sonia/scm/api/v2/resources/ContentResource.java @@ -21,12 +21,13 @@ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE * SOFTWARE. */ - + package sonia.scm.api.v2.resources; import com.github.sdorra.spotter.ContentType; import com.github.sdorra.spotter.ContentTypes; import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; import io.swagger.v3.oas.annotations.media.Content; import io.swagger.v3.oas.annotations.media.Schema; import io.swagger.v3.oas.annotations.responses.ApiResponse; @@ -44,12 +45,14 @@ import javax.ws.rs.GET; import javax.ws.rs.HEAD; import javax.ws.rs.Path; import javax.ws.rs.PathParam; +import javax.ws.rs.QueryParam; import javax.ws.rs.WebApplicationException; import javax.ws.rs.core.Response; import javax.ws.rs.core.Response.Status; import javax.ws.rs.core.StreamingOutput; import java.io.IOException; import java.io.InputStream; +import java.io.OutputStream; import java.util.Arrays; public class ContentResource { @@ -68,11 +71,12 @@ public class ContentResource { * Returns the content of a file for the given revision in the repository. The content type depends on the file * content and can be discovered calling HEAD on the same URL. If a programming languge could be * recognized, this will be given in the header Language. - * - * @param namespace the namespace of the repository + * @param namespace the namespace of the repository * @param name the name of the repository * @param revision the revision * @param path The path of the file + * @param start + * @param end */ @GET @Path("{revision}/{path: .*}") @@ -94,8 +98,25 @@ public class ContentResource { mediaType = VndMediaType.ERROR_TYPE, schema = @Schema(implementation = ErrorDto.class) )) - public Response get(@PathParam("namespace") String namespace, @PathParam("name") String name, @PathParam("revision") String revision, @PathParam("path") String path) { - StreamingOutput stream = createStreamingOutput(namespace, name, revision, path); + @Parameter( + name = "start", + description = "If set, the content will be returned from this line on. The first line is line number 0. " + + "If omitted, the output will start with the first line." + ) + @Parameter( + name = "end", + description = "If set, the content will be returned excluding the given line number and following." + + "The first line ist line number 0. " + + "If set to -1, no lines will be excluded (this equivalent to omitting this parameter" + ) + public Response get( + @PathParam("namespace") String namespace, + @PathParam("name") String name, + @PathParam("revision") String revision, + @PathParam("path") String path, + @QueryParam("start") Integer start, + @QueryParam("end") Integer end) { + StreamingOutput stream = createStreamingOutput(namespace, name, revision, path, start, end); try (RepositoryService repositoryService = serviceFactory.create(new NamespaceAndName(namespace, name))) { Response.ResponseBuilder responseBuilder = Response.ok(stream); return createContentHeader(namespace, name, revision, path, repositoryService, responseBuilder); @@ -105,11 +126,23 @@ public class ContentResource { } } - private StreamingOutput createStreamingOutput(@PathParam("namespace") String namespace, @PathParam("name") String name, @PathParam("revision") String revision, @PathParam("path") String path) { + private StreamingOutput createStreamingOutput(String namespace, String name, String revision, String path, Integer start, Integer end) { + Integer effectiveEnd; + if (end != null && end < 0) { + effectiveEnd = null; + } else { + effectiveEnd = end; + } return os -> { + OutputStream sourceOut; + if (start != null || effectiveEnd != null) { + sourceOut = new LineFilteredOutputStream(os, start, effectiveEnd); + } else { + sourceOut = os; + } try (RepositoryService repositoryService = serviceFactory.create(new NamespaceAndName(namespace, name))) { - repositoryService.getCatCommand().setRevision(revision).retriveContent(os, path); - os.close(); + repositoryService.getCatCommand().setRevision(revision).retriveContent(sourceOut, path); + sourceOut.close(); } catch (NotFoundException e) { LOG.debug(e.getMessage()); throw new WebApplicationException(Status.NOT_FOUND); diff --git a/scm-webapp/src/main/java/sonia/scm/api/v2/resources/DiffResultDto.java b/scm-webapp/src/main/java/sonia/scm/api/v2/resources/DiffResultDto.java index 8df431632a..aa1f617945 100644 --- a/scm-webapp/src/main/java/sonia/scm/api/v2/resources/DiffResultDto.java +++ b/scm-webapp/src/main/java/sonia/scm/api/v2/resources/DiffResultDto.java @@ -21,7 +21,7 @@ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE * SOFTWARE. */ - + package sonia.scm.api.v2.resources; import com.fasterxml.jackson.annotation.JsonInclude; @@ -43,7 +43,11 @@ public class DiffResultDto extends HalRepresentation { @Data @JsonInclude(JsonInclude.Include.NON_DEFAULT) - public static class FileDto { + public static class FileDto extends HalRepresentation { + + public FileDto(Links links) { + super(links); + } private String oldPath; private String newPath; diff --git a/scm-webapp/src/main/java/sonia/scm/api/v2/resources/DiffResultToDiffResultDtoMapper.java b/scm-webapp/src/main/java/sonia/scm/api/v2/resources/DiffResultToDiffResultDtoMapper.java index 5057950149..0384ed7d45 100644 --- a/scm-webapp/src/main/java/sonia/scm/api/v2/resources/DiffResultToDiffResultDtoMapper.java +++ b/scm-webapp/src/main/java/sonia/scm/api/v2/resources/DiffResultToDiffResultDtoMapper.java @@ -27,6 +27,7 @@ package sonia.scm.api.v2.resources; import com.github.sdorra.spotter.ContentTypes; import com.github.sdorra.spotter.Language; import com.google.inject.Inject; +import de.otto.edison.hal.Links; import sonia.scm.repository.Repository; import sonia.scm.repository.api.DiffFile; import sonia.scm.repository.api.DiffLine; @@ -38,6 +39,7 @@ import java.util.List; import java.util.Optional; import java.util.OptionalInt; +import static de.otto.edison.hal.Link.linkBuilder; import static de.otto.edison.hal.Links.linkingTo; /** @@ -54,26 +56,30 @@ 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()); - setFiles(result, dto); + 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()); - setFiles(result, dto); + setFiles(result, dto, repository, revision); return dto; } - private void setFiles(DiffResult result, DiffResultDto dto) { + private void setFiles(DiffResult result, DiffResultDto dto, Repository repository, String revision) { List files = new ArrayList<>(); for (DiffFile file : result) { - files.add(mapFile(file)); + files.add(mapFile(file, repository, revision)); } dto.setFiles(files); } - private DiffResultDto.FileDto mapFile(DiffFile file) { - DiffResultDto.FileDto dto = new DiffResultDto.FileDto(); + private DiffResultDto.FileDto mapFile(DiffFile file, Repository repository, String revision) { + Links.Builder links = linkingTo(); + if (file.iterator().hasNext()) { + links.single(linkBuilder("lines", resourceLinks.source().content(repository.getNamespace(), repository.getName(), revision, file.getNewPath()) + "?start={start}&end={end}").build()); + } + DiffResultDto.FileDto dto = new DiffResultDto.FileDto(links.build()); // ??? dto.setOldEndingNewLine(true); dto.setNewEndingNewLine(true); diff --git a/scm-webapp/src/main/java/sonia/scm/api/v2/resources/LineFilteredOutputStream.java b/scm-webapp/src/main/java/sonia/scm/api/v2/resources/LineFilteredOutputStream.java new file mode 100644 index 0000000000..1a2411c643 --- /dev/null +++ b/scm-webapp/src/main/java/sonia/scm/api/v2/resources/LineFilteredOutputStream.java @@ -0,0 +1,94 @@ +/* + * 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.api.v2.resources; + +import java.io.IOException; +import java.io.OutputStream; + +class LineFilteredOutputStream extends OutputStream { + private final OutputStream target; + private final int start; + private final Integer end; + + private Character lastLineBreakCharacter; + private int currentLine = 0; + + LineFilteredOutputStream(OutputStream target, Integer start, Integer end) { + this.target = target; + this.start = start == null ? 0 : start; + this.end = end == null ? Integer.MAX_VALUE : end; + } + + @Override + public void write(int b) throws IOException { + switch (b) { + case '\n': + case '\r': + if (lastLineBreakCharacter == null) { + keepLineBreakInMind((char) b); + } else if (lastLineBreakCharacter == b) { + if (currentLine > start && currentLine <= end) { + target.write('\n'); + } + ++currentLine; + } else { + if (currentLine > start && currentLine <= end) { + target.write('\n'); + } + lastLineBreakCharacter = null; + } + break; + default: + if (lastLineBreakCharacter != null && currentLine > start && currentLine <= end) { + target.write('\n'); + } + lastLineBreakCharacter = null; + if (currentLine >= start && currentLine < end) { + target.write(b); + } + } + } + + @Override + public void write(byte[] b, int off, int len) throws IOException { + if (currentLine > end) { + return; + } + super.write(b, off, len); + } + + public void keepLineBreakInMind(char b) { + lastLineBreakCharacter = b; + ++currentLine; + } + + @Override + public void close() throws IOException { + if (lastLineBreakCharacter != null && currentLine >= start && currentLine < end) { + target.write('\n'); + } + target.close(); + } +} diff --git a/scm-webapp/src/test/java/sonia/scm/api/v2/resources/ContentResourceTest.java b/scm-webapp/src/test/java/sonia/scm/api/v2/resources/ContentResourceTest.java index a1f12bba2b..7cb067a965 100644 --- a/scm-webapp/src/test/java/sonia/scm/api/v2/resources/ContentResourceTest.java +++ b/scm-webapp/src/test/java/sonia/scm/api/v2/resources/ContentResourceTest.java @@ -21,7 +21,7 @@ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE * SOFTWARE. */ - + package sonia.scm.api.v2.resources; import com.google.common.io.Resources; @@ -89,7 +89,7 @@ public class ContentResourceTest { public void shouldReadSimpleFile() throws Exception { mockContent("file", "Hello".getBytes()); - Response response = contentResource.get(NAMESPACE, REPO_NAME, REV, "file"); + Response response = contentResource.get(NAMESPACE, REPO_NAME, REV, "file", null, null); assertEquals(200, response.getStatus()); ByteArrayOutputStream baos = readOutputStream(response); @@ -97,15 +97,39 @@ public class ContentResourceTest { assertEquals("Hello", baos.toString()); } + @Test + public void shouldLimitOutputByLines() throws Exception { + mockContent("file", "line 1\nline 2\nline 3\nline 4".getBytes()); + + Response response = contentResource.get(NAMESPACE, REPO_NAME, REV, "file", 1, 3); + assertEquals(200, response.getStatus()); + + ByteArrayOutputStream baos = readOutputStream(response); + + assertEquals("line 2\nline 3\n", baos.toString()); + } + + @Test + public void shouldNotLimitOutputWhenEndLessThanZero() throws Exception { + mockContent("file", "line 1\nline 2\nline 3\nline 4".getBytes()); + + Response response = contentResource.get(NAMESPACE, REPO_NAME, REV, "file", 1, -1); + assertEquals(200, response.getStatus()); + + ByteArrayOutputStream baos = readOutputStream(response); + + assertEquals("line 2\nline 3\nline 4", baos.toString()); + } + @Test public void shouldHandleMissingFile() { - Response response = contentResource.get(NAMESPACE, REPO_NAME, REV, "doesNotExist"); + Response response = contentResource.get(NAMESPACE, REPO_NAME, REV, "doesNotExist", null, null); assertEquals(404, response.getStatus()); } @Test public void shouldHandleMissingRepository() { - Response response = contentResource.get("no", "repo", REV, "anything"); + Response response = contentResource.get("no", "repo", REV, "anything", null, null); assertEquals(404, response.getStatus()); } @@ -113,7 +137,7 @@ public class ContentResourceTest { public void shouldRecognizeTikaSourceCode() throws Exception { mockContentFromResource("SomeGoCode.go"); - Response response = contentResource.get(NAMESPACE, REPO_NAME, REV, "SomeGoCode.go"); + Response response = contentResource.get(NAMESPACE, REPO_NAME, REV, "SomeGoCode.go", null, null); assertEquals(200, response.getStatus()); assertEquals("golang", response.getHeaderString("X-Programming-Language")); @@ -124,7 +148,7 @@ public class ContentResourceTest { public void shouldRecognizeSpecialSourceCode() throws Exception { mockContentFromResource("Dockerfile"); - Response response = contentResource.get(NAMESPACE, REPO_NAME, REV, "Dockerfile"); + Response response = contentResource.get(NAMESPACE, REPO_NAME, REV, "Dockerfile", null, null); assertEquals(200, response.getStatus()); assertEquals("dockerfile", response.getHeaderString("X-Programming-Language")); @@ -135,7 +159,7 @@ public class ContentResourceTest { public void shouldHandleRandomByteFile() throws Exception { mockContentFromResource("JustBytes"); - Response response = contentResource.get(NAMESPACE, REPO_NAME, REV, "JustBytes"); + Response response = contentResource.get(NAMESPACE, REPO_NAME, REV, "JustBytes", null, null); assertEquals(200, response.getStatus()); assertFalse(response.getHeaders().containsKey("Language")); @@ -158,7 +182,7 @@ public class ContentResourceTest { public void shouldHandleEmptyFile() throws Exception { mockContent("empty", new byte[]{}); - Response response = contentResource.get(NAMESPACE, REPO_NAME, REV, "empty"); + Response response = contentResource.get(NAMESPACE, REPO_NAME, REV, "empty", null, null); assertEquals(200, response.getStatus()); assertFalse(response.getHeaders().containsKey("Language")); diff --git a/scm-webapp/src/test/java/sonia/scm/api/v2/resources/DiffResultToDiffResultDtoMapperTest.java b/scm-webapp/src/test/java/sonia/scm/api/v2/resources/DiffResultToDiffResultDtoMapperTest.java index 291b04e00c..8fca3ddff4 100644 --- a/scm-webapp/src/test/java/sonia/scm/api/v2/resources/DiffResultToDiffResultDtoMapperTest.java +++ b/scm-webapp/src/test/java/sonia/scm/api/v2/resources/DiffResultToDiffResultDtoMapperTest.java @@ -86,6 +86,19 @@ class DiffResultToDiffResultDtoMapperTest { .isEqualTo("/scm/api/v2/repositories/space/X/diff/123/parsed"); } + @Test + void shouldCreateLinkToLoadMoreLinesForFilesWithHunks() { + DiffResultDto dto = mapper.mapForRevision(REPOSITORY, createResult(), "123"); + + assertThat(dto.getFiles().get(0).getLinks().getLinkBy("lines")) + .isNotPresent(); + assertThat(dto.getFiles().get(1).getLinks().getLinkBy("lines")) + .isPresent() + .get() + .extracting("href") + .isEqualTo("/scm/api/v2/repositories/space/X/content/123/B.ts?start={start}&end={end}"); + } + @Test void shouldCreateSelfLinkForIncoming() { DiffResultDto dto = mapper.mapForIncoming(REPOSITORY, createResult(), "feature/some", "master"); diff --git a/scm-webapp/src/test/java/sonia/scm/api/v2/resources/LineFilteredOutputStreamTest.java b/scm-webapp/src/test/java/sonia/scm/api/v2/resources/LineFilteredOutputStreamTest.java new file mode 100644 index 0000000000..58a4042dd7 --- /dev/null +++ b/scm-webapp/src/test/java/sonia/scm/api/v2/resources/LineFilteredOutputStreamTest.java @@ -0,0 +1,103 @@ +/* + * 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.api.v2.resources; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; + +import java.io.ByteArrayOutputStream; +import java.io.IOException; + +import static org.assertj.core.api.Assertions.assertThat; + +class LineFilteredOutputStreamTest { + + static final String INPUT_LF = "line 1\nline 2\nline 3\nline 4"; + static final String INPUT_CR_LF = "line 1\r\nline 2\r\nline 3\r\nline 4"; + static final String INPUT_CR = "line 1\rline 2\rline 3\rline 4"; + + ByteArrayOutputStream target = new ByteArrayOutputStream(); + + @ParameterizedTest + @ValueSource(strings = {INPUT_LF, INPUT_CR_LF, INPUT_CR}) + void shouldNotFilterIfStartAndEndAreNotSet(String input) throws IOException { + try (LineFilteredOutputStream filtered = new LineFilteredOutputStream(target, null, null)) { + filtered.write(input.getBytes()); + } + + assertThat(target.toString()).isEqualTo(INPUT_LF); + } + + @ParameterizedTest + @ValueSource(strings = {INPUT_LF, INPUT_CR_LF, INPUT_CR}) + void shouldNotFilterIfStartAndEndAreSetToLimits(String input) throws IOException { + try (LineFilteredOutputStream filtered = new LineFilteredOutputStream(target, 0, 4)) { + filtered.write(input.getBytes()); + } + + assertThat(target.toString()).isEqualTo(INPUT_LF); + } + + @ParameterizedTest + @ValueSource(strings = {INPUT_LF, INPUT_CR_LF, INPUT_CR}) + void shouldRemoveFirstLinesIfStartIsSetGreaterThat1(String input) throws IOException { + LineFilteredOutputStream filtered = new LineFilteredOutputStream(target, 2, null); + + filtered.write(input.getBytes()); + + assertThat(target.toString()).isEqualTo("line 3\nline 4"); + } + + @ParameterizedTest + @ValueSource(strings = {INPUT_LF, INPUT_CR_LF, INPUT_CR}) + void shouldOmitLastLinesIfEndIsSetLessThatLength(String input) throws IOException { + LineFilteredOutputStream filtered = new LineFilteredOutputStream(target, null, 2); + + filtered.write(input.getBytes()); + + assertThat(target.toString()).isEqualTo("line 1\nline 2\n"); + } + + @ParameterizedTest + @ValueSource(strings = {"line 1\n\nline 2\n\nline 3", "line 1\r\n\r\nline 2\r\n\r\nline 3"}) + void shouldHandleDoubleBlankLinesCorrectly(String input) throws IOException { + LineFilteredOutputStream filtered = new LineFilteredOutputStream(target, 4, null); + + filtered.write(input.getBytes()); + + assertThat(target.toString()).isEqualTo("line 3"); + } + + @ParameterizedTest + @ValueSource(strings = {"line 1\n\n\nline 2\n\n\nline 3", "line 1\r\n\r\n\r\nline 2\r\n\r\n\r\nline 3"}) + void shouldHandleTripleBlankLinesCorrectly(String input) throws IOException { + LineFilteredOutputStream filtered = new LineFilteredOutputStream(target, 4, 6); + + filtered.write(input.getBytes()); + + assertThat(target.toString()).isEqualTo("\n\n"); + } +}