diff --git a/gradle/changelog/add_file_tree_to_diffs.yml b/gradle/changelog/add_file_tree_to_diffs.yml new file mode 100644 index 0000000000..3cac85362d --- /dev/null +++ b/gradle/changelog/add_file_tree_to_diffs.yml @@ -0,0 +1,2 @@ +- type: added + description: A file tree is now visible while inspecting a changeset diff --git a/scm-core/src/main/java/sonia/scm/repository/api/DiffResult.java b/scm-core/src/main/java/sonia/scm/repository/api/DiffResult.java index a140e7da41..e48b5e7928 100644 --- a/scm-core/src/main/java/sonia/scm/repository/api/DiffResult.java +++ b/scm-core/src/main/java/sonia/scm/repository/api/DiffResult.java @@ -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 { /** * This function returns statistics if they are supported. + * * @since 3.4.0 */ default Optional getStatistics() { return empty(); } + /** + * This function returns all file paths wrapped in a tree + * + * @since 3.5.0 + */ + default Optional getDiffTree() { + return empty(); + } + @Value class DiffStatistics { /** @@ -76,4 +89,45 @@ public interface DiffResult extends Iterable { int deleted; } + @Value + class DiffTreeNode { + + String nodeName; + Map children = new LinkedHashMap<>(); + Optional changeType; + + public Map getChildren() { + return Collections.unmodifiableMap(children); + } + + public static DiffTreeNode createRootNode() { + return new DiffTreeNode("", Optional.empty()); + } + + private DiffTreeNode(String nodeName, Optional 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); + } + } } diff --git a/scm-core/src/test/java/sonia/scm/repository/api/DiffTreeNodeTest.java b/scm-core/src/test/java/sonia/scm/repository/api/DiffTreeNodeTest.java new file mode 100644 index 0000000000..3ea4f0adee --- /dev/null +++ b/scm-core/src/test/java/sonia/scm/repository/api/DiffTreeNodeTest.java @@ -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(); + } +} diff --git a/scm-plugins/scm-git-plugin/src/main/java/sonia/scm/repository/spi/GitDiffResult.java b/scm-plugins/scm-git-plugin/src/main/java/sonia/scm/repository/spi/GitDiffResult.java index d37c472c2f..9458b0c755 100644 --- a/scm-plugins/scm-git-plugin/src/main/java/sonia/scm/repository/spi/GitDiffResult.java +++ b/scm-plugins/scm-git-plugin/src/main/java/sonia/scm/repository/spi/GitDiffResult.java @@ -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 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; + }; + } } diff --git a/scm-plugins/scm-git-plugin/src/test/java/sonia/scm/repository/spi/GitDiffResultCommandTest.java b/scm-plugins/scm-git-plugin/src/test/java/sonia/scm/repository/spi/GitDiffResultCommandTest.java index 531c84e7cb..e7b250c908 100644 --- a/scm-plugins/scm-git-plugin/src/test/java/sonia/scm/repository/spi/GitDiffResultCommandTest.java +++ b/scm-plugins/scm-git-plugin/src/test/java/sonia/scm/repository/spi/GitDiffResultCommandTest.java @@ -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()); diff --git a/scm-ui/ui-components/src/index.ts b/scm-ui/ui-components/src/index.ts index 01c68d401e..16c219cee5 100644 --- a/scm-ui/ui-components/src/index.ts +++ b/scm-ui/ui-components/src/index.ts @@ -91,7 +91,7 @@ export { default as copyToClipboard } from "./CopyToClipboard"; export { createA11yId } from "./createA11yId"; export { useSecondaryNavigation } from "./useSecondaryNavigation"; export { default as useScrollToElement } from "./useScrollToElement"; -export { default as DiffDropDown } from "./repos/DiffDropDown"; +export { default as DiffDropDown } from "./repos/DiffDropDown"; export { default as comparators } from "./comparators"; @@ -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"; diff --git a/scm-ui/ui-components/src/repos/Diff.tsx b/scm-ui/ui-components/src/repos/Diff.tsx index 2169fc3dfe..c5cf111f99 100644 --- a/scm-ui/ui-components/src/repos/Diff.tsx +++ b/scm-ui/ui-components/src/repos/Diff.tsx @@ -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 = ({ 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 = ({ + diff, + ignoreWhitespace, + fetchNextPage, + isFetchingNextPage, + isDataPartial, + ...fileProps +}) => { const [t] = useTranslation("repos"); const [contentRef, setContentRef] = useState(); 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 ( diff --git a/scm-ui/ui-components/src/repos/LoadingDiff.tsx b/scm-ui/ui-components/src/repos/LoadingDiff.tsx index 150bb1bf9c..630c07fc25 100644 --- a/scm-ui/ui-components/src/repos/LoadingDiff.tsx +++ b/scm-ui/ui-components/src/repos/LoadingDiff.tsx @@ -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 = ({ fetchNextPage, isFetchingN const LoadingDiff: FC = ({ 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 = ({ 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 = ({ url, limit, refetchOnWindowFocus, ...props }) setCollapsed(!collapsed); }; + const setFilePath = (path: string) => { + history.push(`#diff-${encodeURIComponent(path)}`); + }; + if (error) { if (error instanceof NotFoundError) { return {t("changesets.noChangesets")}; @@ -89,21 +126,35 @@ const LoadingDiff: FC = ({ url, limit, refetchOnWindowFocus, ...props }) return null; } else { return ( - <> -
- - -
- - {data.partial ? ( - - ) : null} - +
+ + {data?.tree && ( + + )} + + +
+ + +
+ + {data.partial ? ( + + ) : null} +
+
); } }; diff --git a/scm-ui/ui-components/src/repos/diff/DiffFileTree.tsx b/scm-ui/ui-components/src/repos/diff/DiffFileTree.tsx new file mode 100644 index 0000000000..58d18fce1f --- /dev/null +++ b/scm-ui/ui-components/src/repos/diff/DiffFileTree.tsx @@ -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 = ({ tree, currentFile, setCurrentFile }) => { + const [t] = useTranslation("repos"); + + return ( + + {t("changesets.diffTreeTitle")} + + {Object.keys(tree.children).map((key) => ( + + ))} + + + ); +}; + +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 = ({ node, parentPath, currentFile, setCurrentFile }) => { + const [t] = useTranslation("repos"); + + return ( +
  • + {Object.keys(node.children).length > 0 ? ( +
      +
    • + folder +
      {node.nodeName}
      +
    • + {Object.keys(node.children).map((key) => ( + + ))} +
    + ) : ( + + )} +
  • + ); +}; + +type FileProps = { path: string; parentPath: string; currentFile: string; setCurrentFile: (path: string) => void }; + +export const TreeFileContent = styled.li` + cursor: pointer; +`; + +const TreeFile: FC = ({ path, parentPath, currentFile, setCurrentFile }) => { + const [t] = useTranslation("repos"); + const completePath = addPath(parentPath, path); + + const isCurrentFile = () => { + return currentFile === completePath; + }; + + return ( + setCurrentFile(completePath)}> + {isCurrentFile() ? ( + + file + + ) : ( + + file + + )} +
    {path}
    +
    + ); +}; diff --git a/scm-ui/ui-components/src/repos/diff/styledElements.tsx b/scm-ui/ui-components/src/repos/diff/styledElements.tsx index 16546f3298..4ae304adaa 100644 --- a/scm-ui/ui-components/src/repos/diff/styledElements.tsx +++ b/scm-ui/ui-components/src/repos/diff/styledElements.tsx @@ -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); +`; diff --git a/scm-ui/ui-components/src/repos/diffs.ts b/scm-ui/ui-components/src/repos/diffs.ts index a692bdb8dd..a2c3113587 100644 --- a/scm-ui/ui-components/src/repos/diffs.ts +++ b/scm-ui/ui-components/src/repos/diffs.ts @@ -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; +} diff --git a/scm-ui/ui-core/src/base/buttons/Icon.tsx b/scm-ui/ui-core/src/base/buttons/Icon.tsx index 106051a7af..f940fda19a 100644 --- a/scm-ui/ui-core/src/base/buttons/Icon.tsx +++ b/scm-ui/ui-core/src/base/buttons/Icon.tsx @@ -27,6 +27,7 @@ import classNames from "classnames"; type Props = React.HTMLProps & { children?: string; + type?: string; }; /** @@ -41,11 +42,11 @@ type Props = React.HTMLProps & { * @see https://bulma.io/documentation/elements/icon/ * @see https://fontawesome.com/search?o=r&m=free */ -const Icon = React.forwardRef(({ children, className, ...props }, ref) => { +const Icon = React.forwardRef(({ children, className, type = "fas", ...props }, ref) => { return (