mirror of
https://github.com/scm-manager/scm-manager.git
synced 2025-11-07 14:05:44 +01:00
Add table to diff view
Pushed-by: Florian Scholdei<florian.scholdei@cloudogu.com> Pushed-by: Viktor Egorov<viktor.egorov-extern@cloudogu.com> Pushed-by: k8s-git-ops<admin@cloudogu.com> Committed-by: Thomas Zerr<thomas.zerr@cloudogu.com> Co-authored-by: Viktor<viktor.egorov@triology.de> Co-authored-by: Thomas Zerr<thomas.zerr@cloudogu.com> Pushed-by: Thomas Zerr<thomas.zerr@cloudogu.com>
This commit is contained in:
2
gradle/changelog/add_file_tree_to_diffs.yml
Normal file
2
gradle/changelog/add_file_tree_to_diffs.yml
Normal file
@@ -0,0 +1,2 @@
|
||||
- type: added
|
||||
description: A file tree is now visible while inspecting a changeset
|
||||
@@ -26,6 +26,9 @@ package sonia.scm.repository.api;
|
||||
|
||||
import lombok.Value;
|
||||
|
||||
import java.util.Collections;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.Map;
|
||||
import java.util.Optional;
|
||||
|
||||
import static java.util.Optional.empty;
|
||||
@@ -54,12 +57,22 @@ public interface DiffResult extends Iterable<DiffFile> {
|
||||
|
||||
/**
|
||||
* This function returns statistics if they are supported.
|
||||
*
|
||||
* @since 3.4.0
|
||||
*/
|
||||
default Optional<DiffStatistics> getStatistics() {
|
||||
return empty();
|
||||
}
|
||||
|
||||
/**
|
||||
* This function returns all file paths wrapped in a tree
|
||||
*
|
||||
* @since 3.5.0
|
||||
*/
|
||||
default Optional<DiffTreeNode> getDiffTree() {
|
||||
return empty();
|
||||
}
|
||||
|
||||
@Value
|
||||
class DiffStatistics {
|
||||
/**
|
||||
@@ -76,4 +89,45 @@ public interface DiffResult extends Iterable<DiffFile> {
|
||||
int deleted;
|
||||
}
|
||||
|
||||
@Value
|
||||
class DiffTreeNode {
|
||||
|
||||
String nodeName;
|
||||
Map<String, DiffTreeNode> children = new LinkedHashMap<>();
|
||||
Optional<DiffFile.ChangeType> changeType;
|
||||
|
||||
public Map<String, DiffTreeNode> getChildren() {
|
||||
return Collections.unmodifiableMap(children);
|
||||
}
|
||||
|
||||
public static DiffTreeNode createRootNode() {
|
||||
return new DiffTreeNode("", Optional.empty());
|
||||
}
|
||||
|
||||
private DiffTreeNode(String nodeName, Optional<DiffFile.ChangeType> changeType) {
|
||||
this.nodeName = nodeName;
|
||||
this.changeType = changeType;
|
||||
}
|
||||
|
||||
public void addChild(String path, DiffFile.ChangeType changeType) {
|
||||
traverseAndAddChild(path.split("/"), 0, changeType);
|
||||
}
|
||||
|
||||
private void traverseAndAddChild(String[] pathSegments, int index, DiffFile.ChangeType changeType) {
|
||||
if (index == pathSegments.length) {
|
||||
return;
|
||||
}
|
||||
|
||||
String currentPathSegment = pathSegments[index];
|
||||
DiffTreeNode child = children.get(currentPathSegment);
|
||||
|
||||
if (child == null) {
|
||||
boolean isFilename = index == pathSegments.length - 1;
|
||||
child = new DiffTreeNode(currentPathSegment, isFilename ? Optional.of(changeType) : Optional.empty());
|
||||
children.put(currentPathSegment, child);
|
||||
}
|
||||
|
||||
child.traverseAndAddChild(pathSegments, index + 1, changeType);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,78 @@
|
||||
/*
|
||||
* MIT License
|
||||
*
|
||||
* Copyright (c) 2020-present Cloudogu GmbH and Contributors
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
* of this software and associated documentation files (the "Software"), to deal
|
||||
* in the Software without restriction, including without limitation the rights
|
||||
* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
* copies of the Software, and to permit persons to whom the Software is
|
||||
* furnished to do so, subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
* SOFTWARE.
|
||||
*/
|
||||
|
||||
package sonia.scm.repository.api;
|
||||
|
||||
import org.junit.Test;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
public class DiffTreeNodeTest {
|
||||
|
||||
@Test
|
||||
public void shouldCreateTree() {
|
||||
DiffResult.DiffTreeNode root = DiffResult.DiffTreeNode.createRootNode();
|
||||
root.addChild("test/a.txt", DiffFile.ChangeType.MODIFY);
|
||||
root.addChild("b.txt", DiffFile.ChangeType.DELETE);
|
||||
root.addChild("test/c.txt", DiffFile.ChangeType.COPY);
|
||||
root.addChild("scm-manager/d.txt", DiffFile.ChangeType.RENAME);
|
||||
root.addChild("test/scm-manager/e.txt", DiffFile.ChangeType.ADD);
|
||||
|
||||
assertThat(root.getNodeName()).isEmpty();
|
||||
assertThat(root.getChangeType()).isEmpty();
|
||||
assertThat(root.getChildren()).containsOnlyKeys("test", "b.txt", "scm-manager");
|
||||
|
||||
assertThat(root.getChildren().get("b.txt").getNodeName()).isEqualTo("b.txt");
|
||||
assertThat(root.getChildren().get("b.txt").getChangeType()).hasValue(DiffFile.ChangeType.DELETE);
|
||||
assertThat(root.getChildren().get("b.txt").getChildren()).isEmpty();
|
||||
|
||||
assertThat(root.getChildren().get("test").getNodeName()).isEqualTo("test");
|
||||
assertThat(root.getChildren().get("test").getChangeType()).isEmpty();
|
||||
assertThat(root.getChildren().get("test").getChildren()).containsOnlyKeys("a.txt", "c.txt", "scm-manager");
|
||||
|
||||
assertThat(root.getChildren().get("test").getChildren().get("a.txt").getNodeName()).isEqualTo("a.txt");
|
||||
assertThat(root.getChildren().get("test").getChildren().get("a.txt").getChangeType()).hasValue(DiffFile.ChangeType.MODIFY);
|
||||
assertThat(root.getChildren().get("test").getChildren().get("a.txt").getChildren()).isEmpty();
|
||||
|
||||
assertThat(root.getChildren().get("test").getChildren().get("c.txt").getNodeName()).isEqualTo("c.txt");
|
||||
assertThat(root.getChildren().get("test").getChildren().get("c.txt").getChangeType()).hasValue(DiffFile.ChangeType.COPY);
|
||||
assertThat(root.getChildren().get("test").getChildren().get("c.txt").getChildren()).isEmpty();
|
||||
|
||||
assertThat(root.getChildren().get("test").getChildren().get("scm-manager").getNodeName()).isEqualTo("scm-manager");
|
||||
assertThat(root.getChildren().get("test").getChildren().get("scm-manager").getChangeType()).isEmpty();
|
||||
assertThat(root.getChildren().get("test").getChildren().get("scm-manager").getChildren()).containsOnlyKeys("e.txt");
|
||||
|
||||
assertThat(root.getChildren().get("test").getChildren().get("scm-manager").getChildren().get("e.txt").getNodeName()).isEqualTo("e.txt");
|
||||
assertThat(root.getChildren().get("test").getChildren().get("scm-manager").getChildren().get("e.txt").getChangeType()).hasValue(DiffFile.ChangeType.ADD);
|
||||
assertThat(root.getChildren().get("test").getChildren().get("scm-manager").getChildren().get("e.txt").getChildren()).isEmpty();
|
||||
|
||||
assertThat(root.getChildren().get("scm-manager").getNodeName()).isEqualTo("scm-manager");
|
||||
assertThat(root.getChildren().get("scm-manager").getChangeType()).isEmpty();
|
||||
assertThat(root.getChildren().get("scm-manager").getChildren()).containsOnlyKeys("d.txt");
|
||||
|
||||
assertThat(root.getChildren().get("scm-manager").getChildren().get("d.txt").getNodeName()).isEqualTo("d.txt");
|
||||
assertThat(root.getChildren().get("scm-manager").getChildren().get("d.txt").getChangeType()).hasValue(DiffFile.ChangeType.RENAME);
|
||||
assertThat(root.getChildren().get("scm-manager").getChildren().get("d.txt").getChildren()).isEmpty();
|
||||
}
|
||||
}
|
||||
@@ -57,6 +57,8 @@ public class GitDiffResult implements DiffResult {
|
||||
private final int offset;
|
||||
private final Integer limit;
|
||||
|
||||
private DiffTreeNode tree;
|
||||
|
||||
public GitDiffResult(Repository scmRepository,
|
||||
org.eclipse.jgit.lib.Repository repository,
|
||||
Differ.Diff diff,
|
||||
@@ -210,4 +212,32 @@ public class GitDiffResult implements DiffResult {
|
||||
DiffStatistics stats = new DiffStatistics(addCounter, modifiedCounter, deletedCounter);
|
||||
return Optional.of(stats);
|
||||
}
|
||||
|
||||
@Override
|
||||
public Optional<DiffTreeNode> getDiffTree() {
|
||||
if (this.tree == null) {
|
||||
tree = DiffTreeNode.createRootNode();
|
||||
|
||||
for (DiffEntry diffEntry : diffEntries) {
|
||||
DiffEntry.Side side = DiffEntry.Side.NEW;
|
||||
if (diffEntry.getChangeType() == DiffEntry.ChangeType.DELETE) {
|
||||
side = DiffEntry.Side.OLD;
|
||||
}
|
||||
DiffEntry.ChangeType type = diffEntry.getChangeType();
|
||||
String path = diffEntry.getPath(side);
|
||||
tree.addChild(path, mapChangeType(type));
|
||||
}
|
||||
}
|
||||
return Optional.of(tree);
|
||||
}
|
||||
|
||||
private DiffFile.ChangeType mapChangeType(DiffEntry.ChangeType changeType) {
|
||||
return switch (changeType) {
|
||||
case ADD -> DiffFile.ChangeType.ADD;
|
||||
case MODIFY -> DiffFile.ChangeType.MODIFY;
|
||||
case DELETE -> DiffFile.ChangeType.DELETE;
|
||||
case COPY -> DiffFile.ChangeType.COPY;
|
||||
case RENAME -> DiffFile.ChangeType.RENAME;
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -33,8 +33,10 @@ import sonia.scm.repository.api.IgnoreWhitespaceLevel;
|
||||
import java.io.ByteArrayOutputStream;
|
||||
import java.io.IOException;
|
||||
import java.util.Iterator;
|
||||
import java.util.Optional;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.assertj.core.api.InstanceOfAssertFactories.OPTIONAL;
|
||||
import static org.junit.Assert.assertEquals;
|
||||
|
||||
public class GitDiffResultCommandTest extends AbstractGitCommandTestBase {
|
||||
@@ -189,6 +191,15 @@ public class GitDiffResultCommandTest extends AbstractGitCommandTestBase {
|
||||
assertThat(diffResult.getStatistics()).get().extracting("added").isEqualTo(0);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void shouldCreateFileTree() throws IOException {
|
||||
DiffResult.DiffTreeNode root = DiffResult.DiffTreeNode.createRootNode();
|
||||
root.addChild("a.txt", DiffFile.ChangeType.MODIFY);
|
||||
root.addChild("b.txt", DiffFile.ChangeType.DELETE);
|
||||
DiffResult diffResult = createDiffResult("3f76a12f08a6ba0dc988c68b7f0b2cd190efc3c4");
|
||||
assertEquals(Optional.of(root),diffResult.getDiffTree());
|
||||
}
|
||||
|
||||
@Test
|
||||
public void shouldNotIgnoreWhiteSpace() throws IOException {
|
||||
GitDiffResultCommand gitDiffResultCommand = new GitDiffResultCommand(createContext());
|
||||
|
||||
@@ -144,4 +144,7 @@ export const getPageFromMatch = urls.getPageFromMatch;
|
||||
|
||||
export { default as useGeneratedId } from "./useGeneratedId";
|
||||
|
||||
export { getAnchorId as getDiffAnchorId } from "./repos/diff/helpers";
|
||||
export { default as DiffFileTree } from "./repos/diff/DiffFileTree";
|
||||
export { FileTreeContent } from "./repos/diff/styledElements";
|
||||
export { getFileNameFromHash } from "./repos/diffs";
|
||||
export { getAnchorId as getDiffAnchorId } from "./repos/diff/helpers";
|
||||
|
||||
@@ -21,20 +21,24 @@
|
||||
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
* SOFTWARE.
|
||||
*/
|
||||
import React, { FC, useState } from "react";
|
||||
import React, { FC, useEffect, useState } from "react";
|
||||
import DiffFile from "./DiffFile";
|
||||
import { DiffObjectProps, FileControlFactory } from "./DiffTypes";
|
||||
import { FileDiff } from "@scm-manager/ui-types";
|
||||
import { escapeWhitespace } from "./diffs";
|
||||
import { getAnchorSelector, getFileNameFromHash } from "./diffs";
|
||||
import Notification from "../Notification";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useLocation } from "react-router-dom";
|
||||
import useScrollToElement from "../useScrollToElement";
|
||||
import { getAnchorId } from "./diff/helpers";
|
||||
|
||||
type Props = DiffObjectProps & {
|
||||
diff: FileDiff[];
|
||||
fileControlFactory?: FileControlFactory;
|
||||
ignoreWhitespace?: string;
|
||||
fetchNextPage?: () => void;
|
||||
isFetchingNextPage?: boolean;
|
||||
isDataPartial?: boolean;
|
||||
};
|
||||
|
||||
const createKey = (file: FileDiff, ignoreWhitespace?: string) => {
|
||||
@@ -43,23 +47,59 @@ const createKey = (file: FileDiff, ignoreWhitespace?: string) => {
|
||||
return `${file.oldPath}@${file.oldRevision}/${file.newPath}@${file.newRevision}?${ignoreWhitespace}`;
|
||||
};
|
||||
|
||||
const getAnchorSelector = (uriHashContent: string) => {
|
||||
return "#" + escapeWhitespace(decodeURIComponent(uriHashContent));
|
||||
const getFile = (files: FileDiff[] | undefined, path: string): FileDiff | undefined => {
|
||||
return files?.find((e) => (e.type !== "delete" && e.newPath === path) || (e.type === "delete" && e.oldPath === path));
|
||||
};
|
||||
|
||||
const Diff: FC<Props> = ({ diff, ignoreWhitespace, ...fileProps }) => {
|
||||
const selectFromHash = (hash: string) => {
|
||||
const fileName = getFileNameFromHash(hash);
|
||||
return fileName ? getAnchorSelector(fileName) : undefined;
|
||||
};
|
||||
|
||||
const jumpToBottom = () => {
|
||||
window.scrollTo(0, document.body.scrollHeight);
|
||||
};
|
||||
|
||||
const Diff: FC<Props> = ({
|
||||
diff,
|
||||
ignoreWhitespace,
|
||||
fetchNextPage,
|
||||
isFetchingNextPage,
|
||||
isDataPartial,
|
||||
...fileProps
|
||||
}) => {
|
||||
const [t] = useTranslation("repos");
|
||||
const [contentRef, setContentRef] = useState<HTMLElement | null>();
|
||||
const { hash } = useLocation();
|
||||
|
||||
useEffect(() => {
|
||||
if (isFetchingNextPage) {
|
||||
jumpToBottom();
|
||||
}
|
||||
}, [isFetchingNextPage]);
|
||||
|
||||
useScrollToElement(
|
||||
contentRef,
|
||||
() => {
|
||||
const match = hash.match(/^#diff-(.*)$/);
|
||||
if (match) {
|
||||
return getAnchorSelector(match[1]);
|
||||
if (isFetchingNextPage === undefined || isDataPartial === undefined || fetchNextPage === undefined) {
|
||||
return selectFromHash(hash);
|
||||
}
|
||||
|
||||
const encodedFileName = getFileNameFromHash(hash);
|
||||
if (!encodedFileName) {
|
||||
return;
|
||||
}
|
||||
|
||||
const selectedFile = getFile(diff, decodeURIComponent(encodedFileName));
|
||||
if (selectedFile) {
|
||||
return getAnchorSelector(getAnchorId(selectedFile));
|
||||
}
|
||||
|
||||
if (isDataPartial && !isFetchingNextPage) {
|
||||
fetchNextPage();
|
||||
}
|
||||
},
|
||||
hash
|
||||
[hash]
|
||||
);
|
||||
|
||||
return (
|
||||
|
||||
@@ -21,7 +21,7 @@
|
||||
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
* SOFTWARE.
|
||||
*/
|
||||
import React, { FC, useState } from "react";
|
||||
import React, { FC, useCallback, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { NotFoundError, useDiff } from "@scm-manager/ui-api";
|
||||
import ErrorNotification from "../ErrorNotification";
|
||||
@@ -32,6 +32,11 @@ import Diff from "./Diff";
|
||||
import { DiffObjectProps } from "./DiffTypes";
|
||||
import DiffStatistics from "./DiffStatistics";
|
||||
import { DiffDropDown } from "../index";
|
||||
import DiffFileTree from "./diff/DiffFileTree";
|
||||
import { FileTree } from "@scm-manager/ui-types";
|
||||
import { DiffContent, FileTreeContent } from "./diff/styledElements";
|
||||
import { useHistory, useLocation } from "react-router-dom";
|
||||
import { getFileNameFromHash } from "./diffs";
|
||||
|
||||
type Props = DiffObjectProps & {
|
||||
url: string;
|
||||
@@ -60,9 +65,17 @@ const PartialNotification: FC<NotificationProps> = ({ fetchNextPage, isFetchingN
|
||||
const LoadingDiff: FC<Props> = ({ url, limit, refetchOnWindowFocus, ...props }) => {
|
||||
const [ignoreWhitespace, setIgnoreWhitespace] = useState(false);
|
||||
const [collapsed, setCollapsed] = useState(false);
|
||||
const location = useLocation();
|
||||
const history = useHistory();
|
||||
|
||||
const fetchNextPageAndResetAnchor = () => {
|
||||
history.push("#");
|
||||
fetchNextPage();
|
||||
};
|
||||
|
||||
const evaluateWhiteSpace = () => {
|
||||
return ignoreWhitespace ? "ALL" : "NONE"
|
||||
}
|
||||
return ignoreWhitespace ? "ALL" : "NONE";
|
||||
};
|
||||
const { error, isLoading, data, fetchNextPage, isFetchingNextPage } = useDiff(url, {
|
||||
limit,
|
||||
refetchOnWindowFocus,
|
||||
@@ -70,6 +83,26 @@ const LoadingDiff: FC<Props> = ({ url, limit, refetchOnWindowFocus, ...props })
|
||||
});
|
||||
const [t] = useTranslation("repos");
|
||||
|
||||
const getFirstFile = useCallback((tree: FileTree): string => {
|
||||
if (Object.keys(tree.children).length === 0) {
|
||||
return tree.nodeName;
|
||||
}
|
||||
|
||||
for (const key in tree.children) {
|
||||
let path;
|
||||
if (tree.nodeName !== "") {
|
||||
path = tree.nodeName + "/";
|
||||
} else {
|
||||
path = tree.nodeName;
|
||||
}
|
||||
const result = path + getFirstFile(tree.children[key]);
|
||||
if (result) {
|
||||
return result;
|
||||
}
|
||||
}
|
||||
return "";
|
||||
}, []);
|
||||
|
||||
const ignoreWhitespaces = () => {
|
||||
setIgnoreWhitespace(!ignoreWhitespace);
|
||||
};
|
||||
@@ -78,6 +111,10 @@ const LoadingDiff: FC<Props> = ({ url, limit, refetchOnWindowFocus, ...props })
|
||||
setCollapsed(!collapsed);
|
||||
};
|
||||
|
||||
const setFilePath = (path: string) => {
|
||||
history.push(`#diff-${encodeURIComponent(path)}`);
|
||||
};
|
||||
|
||||
if (error) {
|
||||
if (error instanceof NotFoundError) {
|
||||
return <Notification type="info">{t("changesets.noChangesets")}</Notification>;
|
||||
@@ -89,7 +126,17 @@ const LoadingDiff: FC<Props> = ({ url, limit, refetchOnWindowFocus, ...props })
|
||||
return null;
|
||||
} else {
|
||||
return (
|
||||
<>
|
||||
<div className="is-flex has-gap-4 mb-4 mt-4 is-justify-content-space-between">
|
||||
<FileTreeContent className={"is-three-quarters"}>
|
||||
{data?.tree && (
|
||||
<DiffFileTree
|
||||
tree={data.tree}
|
||||
currentFile={decodeURIComponent(getFileNameFromHash(location.hash) ?? "")}
|
||||
setCurrentFile={setFilePath}
|
||||
/>
|
||||
)}
|
||||
</FileTreeContent>
|
||||
<DiffContent>
|
||||
<div className="is-flex has-gap-4 mb-4 mt-4 is-justify-content-space-between">
|
||||
<DiffStatistics data={data.statistics} />
|
||||
<DiffDropDown collapseDiffs={collapseDiffs} ignoreWhitespaces={ignoreWhitespaces} renderOnMount={true} />
|
||||
@@ -98,12 +145,16 @@ const LoadingDiff: FC<Props> = ({ url, limit, refetchOnWindowFocus, ...props })
|
||||
defaultCollapse={collapsed}
|
||||
diff={data.files}
|
||||
ignoreWhitespace={evaluateWhiteSpace()}
|
||||
fetchNextPage={fetchNextPage}
|
||||
isFetchingNextPage={isFetchingNextPage}
|
||||
isDataPartial={data.partial}
|
||||
{...props}
|
||||
/>
|
||||
{data.partial ? (
|
||||
<PartialNotification fetchNextPage={fetchNextPage} isFetchingNextPage={isFetchingNextPage} />
|
||||
<PartialNotification fetchNextPage={fetchNextPageAndResetAnchor} isFetchingNextPage={isFetchingNextPage} />
|
||||
) : null}
|
||||
</>
|
||||
</DiffContent>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
131
scm-ui/ui-components/src/repos/diff/DiffFileTree.tsx
Normal file
131
scm-ui/ui-components/src/repos/diff/DiffFileTree.tsx
Normal file
@@ -0,0 +1,131 @@
|
||||
/*
|
||||
* 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 { FileTree } from "@scm-manager/ui-types";
|
||||
import React, { FC } from "react";
|
||||
import { FileDiffContainer, FileDiffContent, FileDiffContentTitle } from "./styledElements";
|
||||
import { Icon } from "@scm-manager/ui-core";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import styled from "styled-components";
|
||||
|
||||
type Props = { tree: FileTree; currentFile: string; setCurrentFile: (path: string) => void };
|
||||
|
||||
const StyledIcon = styled(Icon)`
|
||||
min-width: 1.5rem;
|
||||
`;
|
||||
|
||||
const DiffFileTree: FC<Props> = ({ tree, currentFile, setCurrentFile }) => {
|
||||
const [t] = useTranslation("repos");
|
||||
|
||||
return (
|
||||
<FileDiffContainer className={"mt-4 py-3 pr-2"}>
|
||||
<FileDiffContentTitle className={"ml-4 pb-4 title is-6"}>{t("changesets.diffTreeTitle")}</FileDiffContentTitle>
|
||||
<FileDiffContent>
|
||||
{Object.keys(tree.children).map((key) => (
|
||||
<TreeNode
|
||||
key={key}
|
||||
node={tree.children[key]}
|
||||
parentPath={""}
|
||||
currentFile={currentFile}
|
||||
setCurrentFile={setCurrentFile}
|
||||
/>
|
||||
))}
|
||||
</FileDiffContent>
|
||||
</FileDiffContainer>
|
||||
);
|
||||
};
|
||||
|
||||
export default DiffFileTree;
|
||||
|
||||
type NodeProps = { node: FileTree; parentPath: string; currentFile: string; setCurrentFile: (path: string) => void };
|
||||
|
||||
const addPath = (parentPath: string, path: string) => {
|
||||
if ("" === parentPath) {
|
||||
return path;
|
||||
}
|
||||
return parentPath + "/" + path;
|
||||
};
|
||||
|
||||
const TreeNode: FC<NodeProps> = ({ node, parentPath, currentFile, setCurrentFile }) => {
|
||||
const [t] = useTranslation("repos");
|
||||
|
||||
return (
|
||||
<li>
|
||||
{Object.keys(node.children).length > 0 ? (
|
||||
<ul className={"py-1 pr-1 pl-3"}>
|
||||
<li className={"is-flex has-text-grey"}>
|
||||
<StyledIcon alt={t("diff.showContent")}>folder</StyledIcon>
|
||||
<div className={"ml-1"}>{node.nodeName}</div>
|
||||
</li>
|
||||
{Object.keys(node.children).map((key) => (
|
||||
<TreeNode
|
||||
key={key}
|
||||
node={node.children[key]}
|
||||
parentPath={addPath(parentPath, node.nodeName)}
|
||||
currentFile={currentFile}
|
||||
setCurrentFile={setCurrentFile}
|
||||
/>
|
||||
))}
|
||||
</ul>
|
||||
) : (
|
||||
<TreeFile
|
||||
path={node.nodeName}
|
||||
parentPath={parentPath}
|
||||
currentFile={currentFile}
|
||||
setCurrentFile={setCurrentFile}
|
||||
/>
|
||||
)}
|
||||
</li>
|
||||
);
|
||||
};
|
||||
|
||||
type FileProps = { path: string; parentPath: string; currentFile: string; setCurrentFile: (path: string) => void };
|
||||
|
||||
export const TreeFileContent = styled.li`
|
||||
cursor: pointer;
|
||||
`;
|
||||
|
||||
const TreeFile: FC<FileProps> = ({ path, parentPath, currentFile, setCurrentFile }) => {
|
||||
const [t] = useTranslation("repos");
|
||||
const completePath = addPath(parentPath, path);
|
||||
|
||||
const isCurrentFile = () => {
|
||||
return currentFile === completePath;
|
||||
};
|
||||
|
||||
return (
|
||||
<TreeFileContent className={"is-flex py-1 pl-3"} onClick={() => setCurrentFile(completePath)}>
|
||||
{isCurrentFile() ? (
|
||||
<StyledIcon style={{ minWidth: "1.5rem" }} key={completePath + "file"} alt={t("diff.showContent")}>
|
||||
file
|
||||
</StyledIcon>
|
||||
) : (
|
||||
<StyledIcon style={{ minWidth: "1.5rem" }} key={completePath + "file"} type="far" alt={t("diff.showContent")}>
|
||||
file
|
||||
</StyledIcon>
|
||||
)}
|
||||
<div className={"ml-1"}>{path}</div>
|
||||
</TreeFileContent>
|
||||
);
|
||||
};
|
||||
@@ -90,3 +90,32 @@ export const PanelHeading = styled.div<{ sticky?: boolean | number }>`
|
||||
}
|
||||
}}
|
||||
`;
|
||||
|
||||
export const FileTreeContent = styled.div`
|
||||
min-width: 25%;
|
||||
max-width: 25%;
|
||||
`;
|
||||
|
||||
export const DiffContent = styled.div`
|
||||
width: 100%;
|
||||
`;
|
||||
|
||||
export const FileDiffContainer = styled.div`
|
||||
border: 1px solid var(--scm-border-color);
|
||||
border-radius: 1rem;
|
||||
position: sticky;
|
||||
top: 5rem;
|
||||
`;
|
||||
|
||||
export const FileDiffContentTitle = styled.div`
|
||||
border-bottom: 1px solid var(--scm-border-color);
|
||||
box-shadow: 0 24px 3px -24px var(--scm-border-color);
|
||||
`;
|
||||
|
||||
export const FileDiffContent = styled.ul`
|
||||
overflow: auto;
|
||||
@supports (-moz-appearance: none) {
|
||||
max-height: calc(100vh - 11rem);
|
||||
}
|
||||
max-height: calc(100svh - 11rem);
|
||||
`;
|
||||
|
||||
@@ -44,3 +44,13 @@ export function createHunkIdentifierFromContext(ctx: BaseContext) {
|
||||
export function escapeWhitespace(path: string) {
|
||||
return path?.toLowerCase().replace(/\W/g, "-");
|
||||
}
|
||||
|
||||
export function getAnchorSelector(uriHashContent: string) {
|
||||
return "#" + escapeWhitespace(decodeURIComponent(uriHashContent));
|
||||
}
|
||||
|
||||
export function getFileNameFromHash(hash: string) {
|
||||
const matcher = new RegExp(/^#diff-(.*)$/, "g");
|
||||
const match = matcher.exec(hash);
|
||||
return match ? match[1] : undefined;
|
||||
}
|
||||
|
||||
@@ -27,6 +27,7 @@ import classNames from "classnames";
|
||||
|
||||
type Props = React.HTMLProps<HTMLElement> & {
|
||||
children?: string;
|
||||
type?: string;
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -41,11 +42,11 @@ type Props = React.HTMLProps<HTMLElement> & {
|
||||
* @see https://bulma.io/documentation/elements/icon/
|
||||
* @see https://fontawesome.com/search?o=r&m=free
|
||||
*/
|
||||
const Icon = React.forwardRef<HTMLElement, Props>(({ children, className, ...props }, ref) => {
|
||||
const Icon = React.forwardRef<HTMLElement, Props>(({ children, className, type = "fas", ...props }, ref) => {
|
||||
return (
|
||||
<span className={classNames(className, "icon")} aria-hidden="true" {...props} ref={ref}>
|
||||
<i
|
||||
className={classNames(`fas fa-fw fa-${children}`, {
|
||||
className={classNames(`${type} fa-fw fa-${children}`, {
|
||||
"fa-xs": className?.includes("is-small"),
|
||||
"fa-lg": className?.includes("is-medium"),
|
||||
"fa-2x": className?.includes("is-large"),
|
||||
|
||||
@@ -30,6 +30,7 @@ export type Diff = HalRepresentation & {
|
||||
files: FileDiff[];
|
||||
partial: boolean;
|
||||
statistics?: Statistics;
|
||||
tree?: FileTree;
|
||||
};
|
||||
|
||||
export type FileDiff = {
|
||||
@@ -54,7 +55,13 @@ export type Statistics = {
|
||||
added: number;
|
||||
deleted: number;
|
||||
modified: number;
|
||||
}
|
||||
};
|
||||
|
||||
export type FileTree = {
|
||||
nodeName: string;
|
||||
children: { [key: string]: FileTree };
|
||||
changeType: string;
|
||||
};
|
||||
|
||||
export type Hunk = {
|
||||
changes: Change[];
|
||||
|
||||
@@ -271,7 +271,8 @@
|
||||
"activateWhitespace": "Whitespace-Änderungen einblenden",
|
||||
"moreDiffsAvailable": "Es sind weitere Diffs verfügbar",
|
||||
"loadMore": "Weitere laden",
|
||||
"showModifiedFiles": "<tag>{{newFiles}}</tag> hinzugefügte, <tag>{{modified}}</tag> geänderte, <tag>{{deleted}}</tag> gelöschte Dateien"
|
||||
"showModifiedFiles": "<tag>{{newFiles}}</tag> hinzugefügte, <tag>{{modified}}</tag> geänderte, <tag>{{deleted}}</tag> gelöschte Dateien",
|
||||
"diffTreeTitle": "Diff-Liste"
|
||||
},
|
||||
"changeset": {
|
||||
"label": "Changeset",
|
||||
|
||||
@@ -271,7 +271,8 @@
|
||||
"activateWhitespace": "Show whitespaces changes",
|
||||
"moreDiffsAvailable": "There are more diffs available",
|
||||
"loadMore": "Load more",
|
||||
"showModifiedFiles": "<tag>{{newFiles}}</tag> added, <tag>{{modified}}</tag> modified, <tag>{{deleted}}</tag> deleted"
|
||||
"showModifiedFiles": "<tag>{{newFiles}}</tag> added, <tag>{{modified}}</tag> modified, <tag>{{deleted}}</tag> deleted",
|
||||
"diffTreeTitle": "Diff-List"
|
||||
},
|
||||
"changeset": {
|
||||
"label": "Changeset",
|
||||
|
||||
@@ -31,10 +31,11 @@ import de.otto.edison.hal.Links;
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.Data;
|
||||
import lombok.EqualsAndHashCode;
|
||||
import sonia.scm.repository.api.DiffResult;
|
||||
import sonia.scm.repository.api.DiffFile;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Optional;
|
||||
|
||||
@Data
|
||||
@EqualsAndHashCode(callSuper = false)
|
||||
@@ -47,6 +48,7 @@ public class DiffResultDto extends HalRepresentation {
|
||||
private List<FileDto> files;
|
||||
private boolean partial;
|
||||
private DiffStatisticsDto statistics;
|
||||
private DiffTreeNodeDto tree;
|
||||
|
||||
@Data
|
||||
@EqualsAndHashCode(callSuper = false)
|
||||
@@ -81,6 +83,15 @@ public class DiffResultDto extends HalRepresentation {
|
||||
private int modified;
|
||||
}
|
||||
|
||||
@Data
|
||||
@EqualsAndHashCode(callSuper = false)
|
||||
@AllArgsConstructor
|
||||
public static class DiffTreeNodeDto {
|
||||
private String nodeName;
|
||||
private Map<String, DiffTreeNodeDto> children;
|
||||
private Optional<DiffFile.ChangeType> changeType;
|
||||
}
|
||||
|
||||
@Data
|
||||
@JsonInclude(JsonInclude.Include.NON_DEFAULT)
|
||||
public static class HunkDto {
|
||||
|
||||
@@ -35,7 +35,9 @@ import sonia.scm.repository.api.DiffResult;
|
||||
import sonia.scm.repository.api.Hunk;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Optional;
|
||||
import java.util.OptionalInt;
|
||||
|
||||
@@ -99,6 +101,19 @@ class DiffResultToDiffResultDtoMapper {
|
||||
}
|
||||
}
|
||||
|
||||
private DiffResultDto.DiffTreeNodeDto mapDiffTreeNodeDto(DiffResult.DiffTreeNode node) {
|
||||
if(node == null){
|
||||
return null;
|
||||
}
|
||||
Map<String, DiffResultDto.DiffTreeNodeDto> list = new LinkedHashMap<>();
|
||||
if(node.getChildren() != null) {
|
||||
for(Map.Entry<String, DiffResult.DiffTreeNode> entry : node.getChildren().entrySet()) {
|
||||
list.put(entry.getKey(), mapDiffTreeNodeDto(entry.getValue()));
|
||||
}
|
||||
}
|
||||
return new DiffResultDto.DiffTreeNodeDto(node.getNodeName(), list, node.getChangeType());
|
||||
}
|
||||
|
||||
private void setFiles(DiffResult result, DiffResultDto dto, Repository repository, String revision) {
|
||||
List<DiffResultDto.FileDto> files = new ArrayList<>();
|
||||
for (DiffFile file : result) {
|
||||
@@ -106,6 +121,8 @@ class DiffResultToDiffResultDtoMapper {
|
||||
}
|
||||
dto.setFiles(files);
|
||||
Optional<DiffResult.DiffStatistics> statistics = result.getStatistics();
|
||||
Optional<DiffResult.DiffTreeNode> diffTree = result.getDiffTree();
|
||||
|
||||
if (statistics.isPresent()) {
|
||||
DiffResult.DiffStatistics diffStatistics = statistics.get();
|
||||
DiffResultDto.DiffStatisticsDto diffStatisticsDto = new DiffResultDto.DiffStatisticsDto(
|
||||
@@ -115,6 +132,7 @@ class DiffResultToDiffResultDtoMapper {
|
||||
);
|
||||
dto.setStatistics(diffStatisticsDto);
|
||||
}
|
||||
diffTree.ifPresent(diffTreeNode -> dto.setTree(new DiffResultDto.DiffTreeNodeDto(diffTreeNode.getNodeName(), mapDiffTreeNodeDto(diffTreeNode).getChildren(), diffTreeNode.getChangeType())));
|
||||
dto.setPartial(result.isPartial());
|
||||
}
|
||||
|
||||
|
||||
@@ -39,6 +39,7 @@ import sonia.scm.repository.api.IgnoreWhitespaceLevel;
|
||||
import java.util.Arrays;
|
||||
import java.util.Collections;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Optional;
|
||||
import java.util.OptionalInt;
|
||||
|
||||
@@ -181,6 +182,7 @@ class DiffResultToDiffResultDtoMapperTest {
|
||||
void shouldMapStatistics() {
|
||||
DiffResult result = createResult();
|
||||
when(result.getStatistics()).thenReturn(of(new DiffResult.DiffStatistics(1, 2, 3)));
|
||||
when(result.getDiffTree()).thenReturn(of(DiffResult.DiffTreeNode.createRootNode()));
|
||||
|
||||
DiffResultDto.DiffStatisticsDto dto = mapper.mapForIncoming(REPOSITORY, result, "feature/some", "master").getStatistics();
|
||||
|
||||
@@ -189,6 +191,38 @@ class DiffResultToDiffResultDtoMapperTest {
|
||||
assertThat(dto.getDeleted()).isEqualTo(3);
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldMapDiffTree() {
|
||||
DiffResult result = createResult();
|
||||
DiffResult.DiffTreeNode root = DiffResult.DiffTreeNode.createRootNode();
|
||||
root.addChild("a.txt", DiffFile.ChangeType.MODIFY);
|
||||
root.addChild("b.txt", DiffFile.ChangeType.DELETE);
|
||||
root.addChild("victory/road/c.txt", DiffFile.ChangeType.ADD);
|
||||
root.addChild("victory/road/d.txt", DiffFile.ChangeType.RENAME);
|
||||
root.addChild("indigo/plateau/e.txt", DiffFile.ChangeType.COPY);
|
||||
when(result.getDiffTree()).thenReturn(of(root));
|
||||
|
||||
DiffResultDto.DiffTreeNodeDto actualTree = mapper.mapForIncoming(REPOSITORY, result, "feature/some", "master").getTree();
|
||||
|
||||
DiffResultDto.DiffTreeNodeDto expectedTree = new DiffResultDto.DiffTreeNodeDto("", Map.of(
|
||||
"a.txt", new DiffResultDto.DiffTreeNodeDto("a.txt", Map.of(), Optional.of(DiffFile.ChangeType.MODIFY)),
|
||||
"b.txt", new DiffResultDto.DiffTreeNodeDto("b.txt", Map.of(),Optional.of(DiffFile.ChangeType.DELETE)),
|
||||
"victory", new DiffResultDto.DiffTreeNodeDto("victory", Map.of(
|
||||
"road", new DiffResultDto.DiffTreeNodeDto("road", Map.of(
|
||||
"c.txt", new DiffResultDto.DiffTreeNodeDto("c.txt", Map.of(), Optional.of(DiffFile.ChangeType.ADD)),
|
||||
"d.txt", new DiffResultDto.DiffTreeNodeDto("d.txt", Map.of(), Optional.of(DiffFile.ChangeType.RENAME))
|
||||
),Optional.empty())
|
||||
),Optional.empty()),
|
||||
"indigo", new DiffResultDto.DiffTreeNodeDto("indigo", Map.of(
|
||||
"plateau", new DiffResultDto.DiffTreeNodeDto("plateau", Map.of(
|
||||
"e.txt", new DiffResultDto.DiffTreeNodeDto("e.txt", Map.of(), Optional.of(DiffFile.ChangeType.COPY))
|
||||
), Optional.empty())
|
||||
), Optional.empty())
|
||||
), Optional.empty());
|
||||
|
||||
assertThat(actualTree).isEqualTo(expectedTree);
|
||||
}
|
||||
|
||||
private void mockPartialResult(DiffResult result) {
|
||||
when(result.getLimit()).thenReturn(of(10));
|
||||
when(result.getOffset()).thenReturn(20);
|
||||
|
||||
Reference in New Issue
Block a user