merge with develop

This commit is contained in:
Sebastian Sdorra
2020-06-11 08:21:06 +02:00
18 changed files with 5514 additions and 616 deletions

View File

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

File diff suppressed because it is too large Load Diff

View File

@@ -114,4 +114,11 @@ storiesOf("Diff", module)
})
.add("CollapsingWithFunction", () => (
<Diff diff={diffFiles} defaultCollapse={(oldPath, newPath) => oldPath.endsWith(".java")} />
));
))
.add("Expandable", () => {
const filesWithLanguage = diffFiles.map((file: File) => {
file._links = { lines: { href: "http://example.com/" } };
return file;
});
return <Diff diff={filesWithLanguage} />;
});

View File

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

View File

@@ -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<File> = (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<File> = (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<File>;
expandBottom: (count: number) => Promise<File>;
};
export default DiffExpander;

View File

@@ -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<Props, State> {
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<Props, State> {
};
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<Props, State> {
});
};
createHunkHeader = (hunk: HunkType, i: number) => {
if (i > 0) {
return <HunkDivider />;
createHunkHeader = (expandableHunk: ExpandableHunk) => {
if (expandableHunk.maxExpandHeadRange > 0) {
if (expandableHunk.maxExpandHeadRange <= 10) {
return (
<HunkExpandDivider>
<HunkExpandLink
icon={"fa-angle-double-up"}
onClick={this.expandHead(expandableHunk, expandableHunk.maxExpandHeadRange)}
text={this.props.t("diff.expandComplete", { count: expandableHunk.maxExpandHeadRange })}
/>
</HunkExpandDivider>
);
} else {
return (
<HunkExpandDivider>
<HunkExpandLink
icon={"fa-angle-up"}
onClick={this.expandHead(expandableHunk, 10)}
text={this.props.t("diff.expandByLines", { count: 10 })}
/>{" "}
<HunkExpandLink
icon={"fa-angle-double-up"}
onClick={this.expandHead(expandableHunk, expandableHunk.maxExpandHeadRange)}
text={this.props.t("diff.expandComplete", { count: expandableHunk.maxExpandHeadRange })}
/>
</HunkExpandDivider>
);
}
}
// hunk header must be defined
return <span />;
};
createHunkFooter = (expandableHunk: ExpandableHunk) => {
if (expandableHunk.maxExpandBottomRange > 0) {
if (expandableHunk.maxExpandBottomRange <= 10) {
return (
<HunkExpandDivider>
<HunkExpandLink
icon={"fa-angle-double-down"}
onClick={this.expandBottom(expandableHunk, expandableHunk.maxExpandBottomRange)}
text={this.props.t("diff.expandComplete", { count: expandableHunk.maxExpandBottomRange })}
/>
</HunkExpandDivider>
);
} else {
return (
<HunkExpandDivider>
<HunkExpandLink
icon={"fa-angle-down"}
onClick={this.expandBottom(expandableHunk, 10)}
text={this.props.t("diff.expandByLines", { count: 10 })}
/>{" "}
<HunkExpandLink
icon={"fa-angle-double-down"}
onClick={this.expandBottom(expandableHunk, expandableHunk.maxExpandBottomRange)}
text={this.props.t("diff.expandComplete", { count: expandableHunk.maxExpandBottomRange })}
/>
</HunkExpandDivider>
);
}
}
// hunk footer must be defined
return <span />;
};
createLastHunkFooter = (expandableHunk: ExpandableHunk) => {
if (expandableHunk.maxExpandBottomRange !== 0) {
return (
<HunkExpandDivider>
<HunkExpandLink
icon={"fa-angle-down"}
onClick={this.expandBottom(expandableHunk, 10)}
text={this.props.t("diff.expandLastBottomByLines", { count: 10 })}
/>{" "}
<HunkExpandLink
icon={"fa-angle-double-down"}
onClick={this.expandBottom(expandableHunk, expandableHunk.maxExpandBottomRange)}
text={this.props.t("diff.expandLastBottomComplete")}
/>
</HunkExpandDivider>
);
}
// hunk header must be defined
return <span />;
};
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<Props, State> {
};
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<Props, State> {
}
};
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 [
<Decoration key={"decoration-" + hunk.content}>{this.createHunkHeader(hunk, i)}</Decoration>,
const items = [];
if (file._links?.lines) {
items.push(this.createHunkHeader(expandableHunk));
} else if (i > 0) {
items.push(<Decoration><HunkDivider /></Decoration>);
}
items.push(
<Hunk
key={"hunk-" + hunk.content}
hunk={hunk}
hunk={expandableHunk.hunk}
widgets={this.collectHunkAnnotations(hunk)}
gutterEvents={this.createGutterEvents(hunk)}
className={this.props.hunkClass ? this.props.hunkClass(hunk) : null}
/>
];
);
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<Props, State> {
return <ChangeTypeTag className={classNames("is-rounded", "has-text-weight-normal")} color={color} label={value} />;
};
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<Props, State> {
<div className="panel-block is-paddingless">
{fileAnnotations}
<TokenizedDiffView className={viewType} viewType={viewType} file={file}>
{(hunks: HunkType[]) => this.concat(hunks.map(this.renderHunk))}
{(hunks: HunkType[]) =>
hunks?.map((hunk, n) => {
return this.renderHunk(file, diffExpander.getHunk(n), n);
})
}
</TokenizedDiffView>
</div>
);
@@ -306,8 +435,21 @@ class DiffFile extends React.Component<Props, State> {
</ButtonWrapper>
) : null;
let errorModal;
if (expansionError) {
errorModal = (
<Modal
title={t("diff.expansionFailed")}
closeFunction={() => this.setState({ expansionError: undefined })}
body={<ErrorNotification error={expansionError} />}
active={true}
/>
);
}
return (
<DiffFilePanel className={classNames("panel", "is-size-6")} collapsed={(file && file.isBinary) || collapsed}>
{errorModal}
<div className="panel-heading">
<FlexWrapLevel className="level">
<FullWidthTitleHeader

View File

@@ -24,6 +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.
@@ -33,7 +34,7 @@ import { DefaultCollapsed } from "./defaultCollapsed";
export type FileChangeType = "add" | "modify" | "delete" | "copy" | "rename";
export type File = {
hunks: Hunk[];
hunks?: Hunk[];
newEndingNewLine: boolean;
newMode?: string;
newPath: string;
@@ -46,11 +47,18 @@ export type File = {
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";
@@ -103,4 +111,5 @@ export type DiffObjectProps = {
annotationFactory?: AnnotationFactory;
markConflicts?: boolean;
defaultCollapse?: DefaultCollapsed;
hunkClass?: (hunk: Hunk) => string;
};

View File

@@ -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 (
<Decoration>
<HunkDivider>{children}</HunkDivider>
</Decoration>
);
};
export default HunkExpandDivider;

View File

@@ -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<any>;
};
const ExpandLink = styled.span`
cursor: pointer;
`;
const HunkExpandLink: FC<Props> = ({ icon, text, onClick }) => {
const [t] = useTranslation("repos");
const [loading, setLoading] = useState(false);
const onClickWithLoadingMarker = () => {
if (loading) {
return;
}
setLoading(true);
onClick().then(() => setLoading(false));
};
return (
<ExpandLink onClick={onClickWithLoadingMarker}>
<i className={classNames("fa", icon)} /> {loading ? t("diff.expanding") : text}
</ExpandLink>
);
};
export default HunkExpandLink;

View File

@@ -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.",

View File

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

View File

@@ -27,6 +27,7 @@ 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 <code>HEAD</code> on the same URL. If a programming languge could be
* recognized, this will be given in the header <code>Language</code>.
*
* @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);

View File

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

View File

@@ -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<DiffResultDto.FileDto> 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);

View File

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

View File

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

View File

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

View File

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