diff --git a/gradle/changelog/image_diff.yaml b/gradle/changelog/image_diff.yaml new file mode 100644 index 0000000000..0ee4714830 --- /dev/null +++ b/gradle/changelog/image_diff.yaml @@ -0,0 +1,2 @@ +- type: added + description: Display images in diffs diff --git a/scm-plugins/scm-git-plugin/src/main/java/sonia/scm/repository/spi/GitDiffResultCommand.java b/scm-plugins/scm-git-plugin/src/main/java/sonia/scm/repository/spi/GitDiffResultCommand.java index df287292ee..85eedbdb49 100644 --- a/scm-plugins/scm-git-plugin/src/main/java/sonia/scm/repository/spi/GitDiffResultCommand.java +++ b/scm-plugins/scm-git-plugin/src/main/java/sonia/scm/repository/spi/GitDiffResultCommand.java @@ -88,7 +88,7 @@ public class GitDiffResultCommand extends AbstractGitCommand implements DiffResu @Override public String getOldRevision() { - return GitUtil.getId(diff.getCommit().getParent(0).getId()); + return diff.getCommit().getParentCount() > 0 ? GitUtil.getId(diff.getCommit().getParent(0).getId()) : null; } @Override @@ -124,73 +124,72 @@ public class GitDiffResultCommand extends AbstractGitCommand implements DiffResu .map(DiffFile.class::cast) .iterator(); } - } - private class GitDiffFile implements DiffFile { + private class GitDiffFile implements DiffFile { - private final org.eclipse.jgit.lib.Repository repository; - private final DiffEntry diffEntry; + private final org.eclipse.jgit.lib.Repository repository; + private final DiffEntry diffEntry; - private GitDiffFile(org.eclipse.jgit.lib.Repository repository, DiffEntry diffEntry) { - this.repository = repository; - this.diffEntry = diffEntry; - } - - @Override - public String getOldRevision() { - return GitUtil.getId(diffEntry.getOldId().toObjectId()); - } - - @Override - public String getNewRevision() { - return GitUtil.getId(diffEntry.getNewId().toObjectId()); - } - - @Override - public String getOldPath() { - return diffEntry.getOldPath(); - } - - @Override - public String getNewPath() { - return diffEntry.getNewPath(); - } - - @Override - public ChangeType getChangeType() { - switch (diffEntry.getChangeType()) { - case ADD: - return ChangeType.ADD; - case MODIFY: - return ChangeType.MODIFY; - case RENAME: - return ChangeType.RENAME; - case DELETE: - return ChangeType.DELETE; - case COPY: - return ChangeType.COPY; - default: - throw new IllegalArgumentException("Unknown change type: " + diffEntry.getChangeType()); + private GitDiffFile(org.eclipse.jgit.lib.Repository repository, DiffEntry diffEntry) { + this.repository = repository; + this.diffEntry = diffEntry; } - } - @Override - public Iterator iterator() { - String content = format(repository, diffEntry); - GitHunkParser parser = new GitHunkParser(); - return parser.parse(content).iterator(); - } - - private String format(org.eclipse.jgit.lib.Repository repository, DiffEntry entry) { - try (ByteArrayOutputStream baos = new ByteArrayOutputStream(); DiffFormatter formatter = new DiffFormatter(baos)) { - formatter.setRepository(repository); - formatter.format(entry); - return baos.toString(StandardCharsets.UTF_8); - } catch (IOException ex) { - throw new InternalRepositoryException(GitDiffResultCommand.this.repository, "failed to format diff entry", ex); + @Override + public String getOldRevision() { + return GitDiffResult.this.getOldRevision(); } + + @Override + public String getNewRevision() { + return GitDiffResult.this.getNewRevision(); + } + + @Override + public String getOldPath() { + return diffEntry.getOldPath(); + } + + @Override + public String getNewPath() { + return diffEntry.getNewPath(); + } + + @Override + public ChangeType getChangeType() { + switch (diffEntry.getChangeType()) { + case ADD: + return ChangeType.ADD; + case MODIFY: + return ChangeType.MODIFY; + case RENAME: + return ChangeType.RENAME; + case DELETE: + return ChangeType.DELETE; + case COPY: + return ChangeType.COPY; + default: + throw new IllegalArgumentException("Unknown change type: " + diffEntry.getChangeType()); + } + } + + @Override + public Iterator iterator() { + String content = format(repository, diffEntry); + GitHunkParser parser = new GitHunkParser(); + return parser.parse(content).iterator(); + } + + private String format(org.eclipse.jgit.lib.Repository repository, DiffEntry entry) { + try (ByteArrayOutputStream baos = new ByteArrayOutputStream(); DiffFormatter formatter = new DiffFormatter(baos)) { + formatter.setRepository(repository); + formatter.format(entry); + return baos.toString(StandardCharsets.UTF_8); + } catch (IOException ex) { + throw new InternalRepositoryException(GitDiffResultCommand.this.repository, "failed to format diff entry", ex); + } + } + } - } - } 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 7f4c5c20d4..e10b24a44d 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 @@ -65,12 +65,12 @@ public class GitDiffResultCommandTest extends AbstractGitCommandTestBase { Iterator iterator = diffResult.iterator(); DiffFile a = iterator.next(); - assertThat(a.getOldRevision()).isEqualTo("78981922613b2afb6025042ff6bd878ac1994e85"); - assertThat(a.getNewRevision()).isEqualTo("1dc60c7504f4326bc83b9b628c384ec8d7e57096"); + assertThat(a.getOldRevision()).isEqualTo("592d797cd36432e591416e8b2b98154f4f163411"); + assertThat(a.getNewRevision()).isEqualTo("3f76a12f08a6ba0dc988c68b7f0b2cd190efc3c4"); DiffFile b = iterator.next(); - assertThat(b.getOldRevision()).isEqualTo("61780798228d17af2d34fce4cfbdf35556832472"); - assertThat(b.getNewRevision()).isEqualTo("0000000000000000000000000000000000000000"); + assertThat(b.getOldRevision()).isEqualTo("592d797cd36432e591416e8b2b98154f4f163411"); + assertThat(b.getNewRevision()).isEqualTo("3f76a12f08a6ba0dc988c68b7f0b2cd190efc3c4"); assertThat(iterator.hasNext()).isFalse(); } diff --git a/scm-ui/ui-api/src/contentType.ts b/scm-ui/ui-api/src/contentType.ts index 4eb6585713..c769163f69 100644 --- a/scm-ui/ui-api/src/contentType.ts +++ b/scm-ui/ui-api/src/contentType.ts @@ -25,6 +25,8 @@ import { apiClient } from "./apiclient"; import { useQuery } from "react-query"; import { ApiResultWithFetching } from "./base"; import type { ContentType } from "@scm-manager/ui-types"; +import { UseQueryOptions } from "react-query/types/react/types"; + export type { ContentType } from "@scm-manager/ui-types"; function getContentType(url: string): Promise { @@ -39,9 +41,14 @@ function getContentType(url: string): Promise { }); } -export const useContentType = (url: string): ApiResultWithFetching => { - const { isLoading, isFetching, error, data } = useQuery(["contentType", url], () => - getContentType(url) +export const useContentType = ( + url: string, + options: Pick, "enabled"> = {} +): ApiResultWithFetching => { + const { isLoading, isFetching, error, data } = useQuery( + ["contentType", url], + () => getContentType(url), + options ); return { isLoading, diff --git a/scm-ui/ui-components/package.json b/scm-ui/ui-components/package.json index 0464b2bf7c..d4415d8870 100644 --- a/scm-ui/ui-components/package.json +++ b/scm-ui/ui-components/package.json @@ -108,4 +108,4 @@ "publishConfig": { "access": "public" } -} \ No newline at end of file +} diff --git a/scm-ui/ui-components/src/__snapshots__/storyshots.test.ts.snap b/scm-ui/ui-components/src/__snapshots__/storyshots.test.ts.snap index 8d7e54f337..c36dbf9455 100644 --- a/scm-ui/ui-components/src/__snapshots__/storyshots.test.ts.snap +++ b/scm-ui/ui-components/src/__snapshots__/storyshots.test.ts.snap @@ -22860,17 +22860,17 @@ exports[`Storyshots Repositories/Diff Binaries 1`] = ` >
- 0 -
+ />
-
- - - - - - -
-
@@ -23158,17 +23138,17 @@ exports[`Storyshots Repositories/Diff Changing Content 1`] = `
- +
- +
- +
- +
- +
- +
`; +exports[`Storyshots Repositories/Diff Images 1`] = ` +
+
+
+
+
+
+

+ test.png +

+ + add + +
+
+
+
+
+
+
+
+
+
+
+

+ test.png +

+ + delete + +
+
+
+
+
+
+
+
+
+
+
+

+ test.png +

+ + modify + +
+
+
+
+
+
+
+
+
+
+
+

+ newFileName.png + + + + test.png +

+ + rename + +
+
+
+
+
+
+
+
+
+
+
+

+ newFileName.png + + + + test.png +

+ + copy + +
+
+
+
+
+
+
+
+
+`; + exports[`Storyshots Repositories/Diff Line Annotation 1`] = `
- +
- + ; }) + .add("Images", () => { + const binaryDiffFiles: FileDiff[] = [ + { + type: "add", + newPath: "test.png", + oldPath: "/dev/null", + isBinary: true, + newEndingNewLine: false, + oldEndingNewLine: false, + _links: { + newFile: { + href: `${window.location.protocol}//${window.location.host}/${hitchhikerImg}`, + }, + }, + }, + { + type: "delete", + newPath: "/dev/null", + oldPath: "test.png", + isBinary: true, + newEndingNewLine: false, + oldEndingNewLine: false, + _links: { + oldFile: { + href: `${window.location.protocol}//${window.location.host}/${hitchhikerImg}`, + }, + }, + }, + { + type: "modify", + newPath: "test.png", + oldPath: "test.png", + isBinary: true, + newEndingNewLine: false, + oldEndingNewLine: false, + _links: { + oldFile: { + href: `${window.location.protocol}//${window.location.host}/${hitchhikerImg}`, + }, + newFile: { + href: `${window.location.protocol}//${window.location.host}/${marvinImg}`, + }, + }, + }, + { + type: "rename", + newPath: "test.png", + oldPath: "newFileName.png", + isBinary: true, + newEndingNewLine: false, + oldEndingNewLine: false, + _links: { + oldFile: { + href: `${window.location.protocol}//${window.location.host}/${hitchhikerImg}`, + }, + newFile: { + href: `${window.location.protocol}//${window.location.host}/${hitchhikerImg}`, + }, + }, + }, + { + type: "copy", + newPath: "test.png", + oldPath: "newFileName.png", + isBinary: true, + newEndingNewLine: false, + oldEndingNewLine: false, + _links: { + oldFile: { + href: `${window.location.protocol}//${window.location.host}/${hitchhikerImg}`, + }, + newFile: { + href: `${window.location.protocol}//${window.location.host}/${hitchhikerImg}`, + }, + }, + }, + ]; + return ; + }) .add("SyntaxHighlighting", () => { const filesWithLanguage = diffFiles.map((file: FileDiff) => { const ext = getPath(file).split(".")[1]; diff --git a/scm-ui/ui-components/src/repos/DiffFile.tsx b/scm-ui/ui-components/src/repos/DiffFile.tsx index 05c7aa1bfe..46bc3b2150 100644 --- a/scm-ui/ui-components/src/repos/DiffFile.tsx +++ b/scm-ui/ui-components/src/repos/DiffFile.tsx @@ -23,12 +23,12 @@ */ import React, { FC, Suspense } from "react"; -import { DiffFileProps } from "./LazyDiffFile"; +import type { DiffFileProps } from "./diff/types"; import Loading from "../Loading"; -const LazyDiffFile = React.lazy(() => import("./LazyDiffFile")); +const LazyDiffFile = React.lazy(() => import("./diff/LazyDiffFile")); -const DiffFile: FC = props => ( +const DiffFile: FC = (props) => ( }> diff --git a/scm-ui/ui-components/src/repos/LazyDiffFile.tsx b/scm-ui/ui-components/src/repos/LazyDiffFile.tsx deleted file mode 100644 index ab349dc98d..0000000000 --- a/scm-ui/ui-components/src/repos/LazyDiffFile.tsx +++ /dev/null @@ -1,573 +0,0 @@ -/* - * 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 from "react"; -import { withTranslation, WithTranslation } from "react-i18next"; -import classNames from "classnames"; -import styled from "styled-components"; -// @ts-ignore -import { Decoration, getChangeKey, Hunk } from "react-diff-view"; -import { ButtonGroup } from "../buttons"; -import Tag from "../Tag"; -import Icon from "../Icon"; -import { Change, FileDiff, Hunk as HunkType } from "@scm-manager/ui-types"; -import { ChangeEvent, DiffObjectProps } from "./DiffTypes"; -import TokenizedDiffView from "./TokenizedDiffView"; -import DiffButton from "./DiffButton"; -import { MenuContext, OpenInFullscreenButton } from "@scm-manager/ui-components"; -import DiffExpander, { ExpandableHunk } from "./DiffExpander"; -import HunkExpandLink from "./HunkExpandLink"; -import { Modal } from "../modals"; -import ErrorNotification from "../ErrorNotification"; -import HunkExpandDivider from "./HunkExpandDivider"; -import { escapeWhitespace } from "./diffs"; - -const EMPTY_ANNOTATION_FACTORY = {}; - -type Props = DiffFileProps & WithTranslation; - -export type DiffFileProps = DiffObjectProps & { - file: FileDiff; -}; - -type Collapsible = { - collapsed?: boolean; -}; - -type State = Collapsible & { - file: FileDiff; - sideBySide?: boolean; - diffExpander: DiffExpander; - expansionError?: any; -}; - -const StyledHunk = styled(Hunk)` - ${(props) => { - let style = props.icon - ? ` - .diff-gutter:hover::after { - font-size: inherit; - margin-left: 0.5em; - font-family: "Font Awesome 5 Free"; - content: "${props.icon}"; - color: var(--scm-column-selection); - } - ` - : ""; - if (!props.actionable) { - style += ` - .diff-gutter { - cursor: default; - } - `; - } - if (props.highlightLineOnHover) { - style += ` - tr.diff-line:hover > td { - background-color: var(--sh-selected-color); - } - `; - } - return style; - }} -`; - -const DiffFilePanel = styled.div` - /* remove bottom border for collapsed panels */ - ${(props: Collapsible) => (props.collapsed ? "border-bottom: none;" : "")}; -`; - -const FullWidthTitleHeader = styled.div` - max-width: 100%; -`; - -const MarginlessModalContent = styled.div` - margin: -1.25rem; - - & .panel-block { - flex-direction: column; - align-items: stretch; - } -`; - -const PanelHeading = styled.div<{ sticky: boolean }>` - ${(props) => - props.sticky - ? ` - position: sticky; - top: 52px; - z-index: 1; - ` - : ""} -`; - -class DiffFile extends React.Component { - static defaultProps: Partial = { - defaultCollapse: false, - markConflicts: true, - }; - - constructor(props: Props) { - super(props); - this.state = { - collapsed: this.defaultCollapse(), - sideBySide: props.sideBySide, - diffExpander: new DiffExpander(props.file), - file: props.file, - }; - } - - componentDidUpdate(prevProps: Readonly) { - if (!this.props.isCollapsed && this.props.defaultCollapse !== prevProps.defaultCollapse) { - this.setState({ - collapsed: this.defaultCollapse(), - }); - } - } - - defaultCollapse: () => boolean = () => { - const { defaultCollapse, file } = this.props; - if (typeof defaultCollapse === "boolean") { - return defaultCollapse; - } else if (typeof defaultCollapse === "function") { - return defaultCollapse(file.oldPath, file.newPath); - } else { - return false; - } - }; - - toggleCollapse = (event: React.MouseEvent) => { - const { onCollapseStateChange, isCollapsed } = this.props; - const { file, collapsed } = this.state; - if (this.hasContent(file)) { - if (onCollapseStateChange) { - onCollapseStateChange(file); - } else { - this.setState((state) => ({ - collapsed: !state.collapsed, - })); - } - } - if (this.props.stickyHeader) { - const element = document.getElementById(event.currentTarget.id); - // Prevent skipping diffs on collapsing long ones because of the sticky header - // We jump to the start of the diff and afterwards go slightly up to show the diff header right under the page header - // Only scroll if diff is not collapsed and is using the "sticky" mode - const pageHeaderSize = 50; - if (element && (isCollapsed ? !isCollapsed(file) : !collapsed) && element.getBoundingClientRect().top < pageHeaderSize) { - element.scrollIntoView(); - window.scrollBy(0, -pageHeaderSize); - } - } - }; - - toggleSideBySide = (callback: () => void) => { - this.setState( - (state) => ({ - sideBySide: !state.sideBySide, - }), - () => callback() - ); - }; - - setCollapse = (collapsed: boolean) => { - const { onCollapseStateChange } = this.props; - if (onCollapseStateChange) { - onCollapseStateChange(this.state.file, collapsed); - } else { - this.setState({ - collapsed, - }); - } - }; - - createHunkHeader = (expandableHunk: ExpandableHunk) => { - if (expandableHunk.maxExpandHeadRange > 0) { - if (expandableHunk.maxExpandHeadRange <= 10) { - return ( - - - - ); - } else { - return ( - - {" "} - - - ); - } - } - // hunk header must be defined - return ; - }; - - createHunkFooter = (expandableHunk: ExpandableHunk) => { - if (expandableHunk.maxExpandBottomRange > 0) { - if (expandableHunk.maxExpandBottomRange <= 10) { - return ( - - - - ); - } else { - return ( - - {" "} - - - ); - } - } - // hunk footer must be defined - return ; - }; - - createLastHunkFooter = (expandableHunk: ExpandableHunk) => { - if (expandableHunk.maxExpandBottomRange !== 0) { - return ( - - {" "} - - - ); - } - // hunk header must be defined - return ; - }; - - expandHead = (expandableHunk: ExpandableHunk, count: number) => { - return () => { - return expandableHunk.expandHead(count).then(this.diffExpanded).catch(this.diffExpansionFailed); - }; - }; - - expandBottom = (expandableHunk: ExpandableHunk, count: number) => { - return () => { - return expandableHunk.expandBottom(count).then(this.diffExpanded).catch(this.diffExpansionFailed); - }; - }; - - diffExpanded = (newFile: FileDiff) => { - this.setState({ file: newFile, diffExpander: new DiffExpander(newFile) }); - }; - - diffExpansionFailed = (err: any) => { - this.setState({ expansionError: err }); - }; - - collectHunkAnnotations = (hunk: HunkType) => { - const { annotationFactory } = this.props; - const { file } = this.state; - if (annotationFactory) { - return annotationFactory({ - hunk, - file, - }); - } else { - return EMPTY_ANNOTATION_FACTORY; - } - }; - - handleClickEvent = (change: Change, hunk: HunkType) => { - const { onClick } = this.props; - const { file } = this.state; - const context = { - changeId: getChangeKey(change), - change, - hunk, - file, - }; - if (onClick) { - onClick(context); - } - }; - - createGutterEvents = (hunk: HunkType) => { - const { onClick } = this.props; - if (onClick) { - return { - onClick: (event: ChangeEvent) => { - this.handleClickEvent(event.change, hunk); - }, - }; - } - }; - - renderHunk = (file: FileDiff, expandableHunk: ExpandableHunk, i: number) => { - const hunk = expandableHunk.hunk; - if (this.props.markConflicts && hunk.changes) { - this.markConflicts(hunk); - } - const items = []; - if (file._links?.lines) { - items.push(this.createHunkHeader(expandableHunk)); - } else if (i > 0) { - items.push( - -
-
- ); - } - - const gutterEvents = this.createGutterEvents(hunk); - - items.push( - - ); - if (file._links?.lines) { - if (i === file.hunks!.length - 1) { - items.push(this.createLastHunkFooter(expandableHunk)); - } else { - items.push(this.createHunkFooter(expandableHunk)); - } - } - return items; - }; - - markConflicts = (hunk: HunkType) => { - let inConflict = false; - for (let i = 0; i < hunk.changes.length; ++i) { - if (hunk.changes[i].content === "<<<<<<< HEAD") { - inConflict = true; - } - if (inConflict) { - hunk.changes[i].type = "conflict"; - } - if (hunk.changes[i].content.startsWith(">>>>>>>")) { - inConflict = false; - } - } - }; - - getAnchorId(file: FileDiff) { - let path: string; - if (file.type === "delete") { - path = file.oldPath; - } else { - path = file.newPath; - } - return escapeWhitespace(path); - } - - renderFileTitle = (file: FileDiff) => { - const { t } = this.props; - if (file.oldPath !== file.newPath && (file.type === "copy" || file.type === "rename")) { - return ( - <> - {file.oldPath} {file.newPath} - - ); - } else if (file.type === "delete") { - return file.oldPath; - } - return file.newPath; - }; - - hoverFileTitle = (file: FileDiff): string => { - if (file.oldPath !== file.newPath && (file.type === "copy" || file.type === "rename")) { - return `${file.oldPath} > ${file.newPath}`; - } else if (file.type === "delete") { - return file.oldPath; - } - return file.newPath; - }; - - renderChangeTag = (file: FileDiff) => { - const { t } = this.props; - if (!file.type) { - return; - } - const key = "diff.changes." + file.type; - let value = t(key); - if (key === value) { - value = file.type; - } - - const color = value === "added" ? "success" : value === "deleted" ? "danger" : "info"; - return ( - - ); - }; - - isCollapsed = () => { - const { file, isCollapsed } = this.props; - if (isCollapsed) { - return isCollapsed(file); - } - return this.state.collapsed; - }; - - hasContent = (file: FileDiff) => file && !file.isBinary && file.hunks && file.hunks.length > 0; - - render() { - const { fileControlFactory, fileAnnotationFactory, stickyHeader = false, t } = this.props; - const { file, sideBySide, diffExpander, expansionError } = this.state; - const viewType = sideBySide ? "split" : "unified"; - const collapsed = this.isCollapsed(); - - const fileAnnotations = fileAnnotationFactory ? fileAnnotationFactory(file) : null; - const innerContent = ( -
- {fileAnnotations} - - {(hunks: HunkType[]) => - hunks?.map((hunk, n) => { - return this.renderHunk(file, diffExpander.getHunk(n), n); - }) - } - -
- ); - let icon = ; - let body = null; - if (!collapsed) { - icon = ; - body = innerContent; - } - const collapseIcon = this.hasContent(file) ? icon : null; - const fileControls = fileControlFactory ? fileControlFactory(file, this.setCollapse) : null; - const modalTitle = file.type === "delete" ? file.oldPath : file.newPath; - const openInFullscreen = file?.hunks?.length ? ( - {innerContent}} - /> - ) : null; - const sideBySideToggle = file?.hunks?.length && ( - - {({ setCollapsed }) => ( - - this.toggleSideBySide(() => { - if (this.state.sideBySide) { - setCollapsed(true); - } - }) - } - /> - )} - - ); - const headerButtons = ( -
- - {sideBySideToggle} - {openInFullscreen} - {fileControls} - -
- ); - - let errorModal; - if (expansionError) { - errorModal = ( - this.setState({ expansionError: undefined })} - body={} - active={true} - /> - ); - } - - return ( - - {errorModal} - -
- - {collapseIcon} -

- {this.renderFileTitle(file)} -

- {this.renderChangeTag(file)} -
- {headerButtons} -
-
- {body} -
- ); - } -} - -export default withTranslation("repos")(DiffFile); diff --git a/scm-ui/ui-components/src/repos/diff/BinaryDiffFileContent.tsx b/scm-ui/ui-components/src/repos/diff/BinaryDiffFileContent.tsx new file mode 100644 index 0000000000..d58f35ae0a --- /dev/null +++ b/scm-ui/ui-components/src/repos/diff/BinaryDiffFileContent.tsx @@ -0,0 +1,89 @@ +/* + * 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"; +import { ContentType } from "@scm-manager/ui-types"; +import styled from "styled-components"; +import { useTranslation } from "react-i18next"; + +const CompareContainer = styled.div` + gap: 1rem; + padding: 1rem; +`; + +const CompareImage = styled.img` + border-width: 3px; + border-style: solid; + width: 100%; +`; + +const CompareImageContainer = styled.div` + flex: 1; + width: 50%; +`; + +const CompareImageLabel = styled.div` + text-transform: capitalize; +`; + +const isImageMediaType = (mediaType?: string) => (mediaType ? mediaType.match(/^image\/.+/g) : false); + +export const canDisplayBinaryFile = (oldContentType?: ContentType, newContentType?: ContentType) => + isImageMediaType(newContentType?.type) || isImageMediaType(oldContentType?.type); + +type Props = { + oldContentType?: ContentType; + newContentType?: ContentType; + oldFileLink?: string; + newFileLink?: string; + sideBySide?: boolean; +}; + +const BinaryDiffFileContent: FC = ({ oldContentType, newContentType, oldFileLink, newFileLink, sideBySide }) => { + const isNewFileImage = isImageMediaType(newContentType?.type); + const isOldFileImage = isImageMediaType(oldContentType?.type); + const isImage = isNewFileImage || isOldFileImage; + const isChangedImage = isNewFileImage && isOldFileImage; + const [t] = useTranslation("repos"); + + if (isChangedImage && oldFileLink && newFileLink) { + return ( + + + {t("diff.changes.delete")} + + + + {t("diff.changes.add")} + + + + ); + } else if (isImage) { + return ; + } + return null; +}; + +export default BinaryDiffFileContent; diff --git a/scm-ui/ui-components/src/repos/diff/ChangeTag.tsx b/scm-ui/ui-components/src/repos/diff/ChangeTag.tsx new file mode 100644 index 0000000000..55f28bc0af --- /dev/null +++ b/scm-ui/ui-components/src/repos/diff/ChangeTag.tsx @@ -0,0 +1,56 @@ +/* + * 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 { FileDiff } from "@scm-manager/ui-types"; +import Tag from "../../Tag"; +import classNames from "classnames"; +import React, { FC } from "react"; +import { useTranslation } from "react-i18next"; + +type Props = { file: FileDiff }; + +const ChangeTag: FC = ({ file }) => { + const [t] = useTranslation("repos"); + if (!file.type) { + return null; + } + const key = "diff.changes." + file.type; + let value = t(key); + if (key === value) { + value = file.type; + } + + const color = value === "added" ? "success" : value === "deleted" ? "danger" : "info"; + return ( + + ); +}; + +export default ChangeTag; diff --git a/scm-ui/ui-components/src/repos/diff/DiffFileHunk.tsx b/scm-ui/ui-components/src/repos/diff/DiffFileHunk.tsx new file mode 100644 index 0000000000..f4ca276332 --- /dev/null +++ b/scm-ui/ui-components/src/repos/diff/DiffFileHunk.tsx @@ -0,0 +1,149 @@ +/* + * 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 { Change, Hunk as HunkType } from "@scm-manager/ui-types"; +import { ExpandableHunk } from "../DiffExpander"; +import React, { FC, useCallback, useMemo } from "react"; +import { StyledHunk } from "./styledElements"; +import LastHunkFooter from "./LastHunkFooter"; +import { DiffExpandedCallback, DiffFileProps, ErrorHandler } from "./types"; +import HunkFooter from "./HunkFooter"; +// @ts-ignore react-diff-view does not provide types +import { Decoration, getChangeKey } from "react-diff-view"; +import { collectHunkAnnotations, markConflicts } from "./helpers"; +import HunkHeader from "./HunkHeader"; +import { ChangeEvent } from "../DiffTypes"; + +type Props = Pick< + DiffFileProps, + | "annotationFactory" + | "onClick" + | "file" + | "markConflicts" + | "hunkClass" + | "hunkGutterHoverIcon" + | "highlightLineOnHover" +> & { + expandableHunk: ExpandableHunk; + i: number; + diffExpanded: DiffExpandedCallback; + diffExpansionFailed: ErrorHandler; +}; + +const DiffFileHunk: FC = ({ + expandableHunk, + file, + i, + markConflicts: shouldMarkConflicts, + diffExpanded, + diffExpansionFailed, + annotationFactory, + onClick, + hunkClass, + hunkGutterHoverIcon, + highlightLineOnHover, +}) => { + const hunk = useMemo(() => expandableHunk.hunk, [expandableHunk]); + + const handleClickEvent = useCallback( + (change: Change, hunk: HunkType) => { + const context = { + changeId: getChangeKey(change), + change, + hunk, + file, + }; + if (onClick) { + onClick(context); + } + }, + [file, onClick] + ); + + const gutterEvents = useMemo( + () => + onClick && { + onClick: (event: ChangeEvent) => handleClickEvent(event.change, hunk), + }, + [handleClickEvent, hunk, onClick] + ); + + if (shouldMarkConflicts && hunk.changes) { + markConflicts(hunk); + } + const items: React.ReactNode[] = []; + if (file._links?.lines) { + items.push( + + ); + } else if (i > 0) { + items.push( + +
+
+ ); + } + + items.push( + + ); + if (file._links?.lines) { + if (i === (file.hunks ?? []).length - 1) { + items.push( + + ); + } else { + items.push( + + ); + } + } + return <>{items}; +}; + +export default DiffFileHunk; diff --git a/scm-ui/ui-components/src/repos/diff/FileTitle.tsx b/scm-ui/ui-components/src/repos/diff/FileTitle.tsx new file mode 100644 index 0000000000..97eea2fd40 --- /dev/null +++ b/scm-ui/ui-components/src/repos/diff/FileTitle.tsx @@ -0,0 +1,46 @@ +/* + * 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 { FileDiff } from "@scm-manager/ui-types"; +import Icon from "../../Icon"; +import React, { FC } from "react"; +import { useTranslation } from "react-i18next"; + +type Props = { file: FileDiff }; + +const FileTitle: FC = ({ file }) => { + const [t] = useTranslation("repos"); + if (file.oldPath !== file.newPath && (file.type === "copy" || file.type === "rename")) { + return ( + <> + {file.oldPath} {file.newPath} + + ); + } else if (file.type === "delete") { + return <>{file.oldPath}; + } + return <>{file.newPath}; +}; + +export default FileTitle; diff --git a/scm-ui/ui-components/src/repos/diff/HunkFooter.tsx b/scm-ui/ui-components/src/repos/diff/HunkFooter.tsx new file mode 100644 index 0000000000..3dd1c3b33e --- /dev/null +++ b/scm-ui/ui-components/src/repos/diff/HunkFooter.tsx @@ -0,0 +1,75 @@ +/* + * 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 { ExpandableHunk } from "../DiffExpander"; +import HunkExpandDivider from "../HunkExpandDivider"; +import HunkExpandLink from "../HunkExpandLink"; +import React, { FC, useCallback } from "react"; +import { DiffExpandedCallback, ErrorHandler } from "./types"; +import { useTranslation } from "react-i18next"; + +type Props = { expandableHunk: ExpandableHunk; diffExpanded: DiffExpandedCallback; diffExpansionFailed: ErrorHandler }; + +const HunkFooter: FC = ({ expandableHunk, diffExpanded, diffExpansionFailed }) => { + const [t] = useTranslation("repos"); + + const expandBottom = useCallback( + (expandableHunk: ExpandableHunk, count: number) => () => + expandableHunk.expandBottom(count).then(diffExpanded).catch(diffExpansionFailed), + [diffExpanded, diffExpansionFailed] + ); + + if (expandableHunk.maxExpandBottomRange > 0) { + if (expandableHunk.maxExpandBottomRange <= 10) { + return ( + + + + ); + } else { + return ( + + {" "} + + + ); + } + } + // hunk footer must be defined + return ; +}; + +export default HunkFooter; diff --git a/scm-ui/ui-components/src/repos/diff/HunkHeader.tsx b/scm-ui/ui-components/src/repos/diff/HunkHeader.tsx new file mode 100644 index 0000000000..be22127977 --- /dev/null +++ b/scm-ui/ui-components/src/repos/diff/HunkHeader.tsx @@ -0,0 +1,75 @@ +/* + * 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, useCallback } from "react"; +import { ExpandableHunk } from "../DiffExpander"; +import HunkExpandDivider from "../HunkExpandDivider"; +import HunkExpandLink from "../HunkExpandLink"; +import { useTranslation } from "react-i18next"; +import { DiffExpandedCallback, ErrorHandler } from "./types"; + +type Props = { expandableHunk: ExpandableHunk; diffExpanded: DiffExpandedCallback; diffExpansionFailed: ErrorHandler }; + +const HunkHeader: FC = ({ expandableHunk, diffExpanded, diffExpansionFailed }) => { + const [t] = useTranslation("repos"); + + const expandHead = useCallback( + (expandableHunk: ExpandableHunk, count: number) => () => + expandableHunk.expandHead(count).then(diffExpanded).catch(diffExpansionFailed), + [diffExpanded, diffExpansionFailed] + ); + + if (expandableHunk.maxExpandHeadRange > 0) { + if (expandableHunk.maxExpandHeadRange <= 10) { + return ( + + + + ); + } else { + return ( + + {" "} + + + ); + } + } + // hunk header must be defined + return ; +}; + +export default HunkHeader; diff --git a/scm-ui/ui-components/src/repos/diff/LastHunkFooter.tsx b/scm-ui/ui-components/src/repos/diff/LastHunkFooter.tsx new file mode 100644 index 0000000000..85fe56b1d7 --- /dev/null +++ b/scm-ui/ui-components/src/repos/diff/LastHunkFooter.tsx @@ -0,0 +1,63 @@ +/* + * 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 { ExpandableHunk } from "../DiffExpander"; +import HunkExpandDivider from "../HunkExpandDivider"; +import HunkExpandLink from "../HunkExpandLink"; +import React, { FC, useCallback } from "react"; +import { useTranslation } from "react-i18next"; +import { DiffExpandedCallback, ErrorHandler } from "./types"; + +type Props = { expandableHunk: ExpandableHunk; diffExpanded: DiffExpandedCallback; diffExpansionFailed: ErrorHandler }; + +const LastHunkFooter: FC = ({ expandableHunk, diffExpanded, diffExpansionFailed }) => { + const [t] = useTranslation("repos"); + + const expandBottom = useCallback( + (expandableHunk: ExpandableHunk, count: number) => () => + expandableHunk.expandBottom(count).then(diffExpanded).catch(diffExpansionFailed), + [diffExpanded, diffExpansionFailed] + ); + + if (expandableHunk.maxExpandBottomRange !== 0) { + return ( + + {" "} + + + ); + } + // hunk header must be defined + return ; +}; + +export default LastHunkFooter; diff --git a/scm-ui/ui-components/src/repos/diff/LazyDiffFile.tsx b/scm-ui/ui-components/src/repos/diff/LazyDiffFile.tsx new file mode 100644 index 0000000000..ee75e6cdaf --- /dev/null +++ b/scm-ui/ui-components/src/repos/diff/LazyDiffFile.tsx @@ -0,0 +1,288 @@ +/* + * 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, useCallback, useEffect, useMemo, useState } from "react"; +import { useTranslation } from "react-i18next"; +import classNames from "classnames"; +import { ButtonGroup } from "../../buttons"; +import Icon from "../../Icon"; +import { Hunk as HunkType, Link } from "@scm-manager/ui-types"; +import TokenizedDiffView from "../TokenizedDiffView"; +import DiffButton from "../DiffButton"; +import { MenuContext, OpenInFullscreenButton } from "@scm-manager/ui-components"; +import DiffExpander from "../DiffExpander"; +import { Modal } from "../../modals"; +import ErrorNotification from "../../ErrorNotification"; +import FileTitle from "./FileTitle"; +import { DiffFilePanel, FullWidthTitleHeader, MarginlessModalContent, PanelHeading } from "./styledElements"; +import ChangeTag from "./ChangeTag"; +import { getAnchorId, hasContent as determineHasContent, hoverFileTitle } from "./helpers"; +import DiffFileHunk from "./DiffFileHunk"; +import { DiffFileProps } from "./types"; +import { useContentType } from "@scm-manager/ui-api"; +import BinaryDiffFileContent, { canDisplayBinaryFile } from "./BinaryDiffFileContent"; + +type Props = DiffFileProps; + +const DiffFile: FC = ({ + file: fileProp, + isCollapsed: isCollapsedProp, + onCollapseStateChange, + defaultCollapse: defaultCollapseProp, + stickyHeader, + sideBySide: sideBySideProp, + markConflicts = true, + fileControlFactory, + fileAnnotationFactory, + onClick, + annotationFactory, + hunkGutterHoverIcon, + hunkClass, + highlightLineOnHover, +}) => { + const [t] = useTranslation("repos"); + const [collapsed, setCollapsed] = useState(true); + const [file, setFile] = useState(fileProp); + const [sideBySide, setSideBySide] = useState(sideBySideProp); + const diffExpander = useMemo(() => new DiffExpander(file), [file]); + const [expansionError, setExpansionError] = useState(); + const viewType = useMemo(() => (sideBySide ? "split" : "unified"), [sideBySide]); + const hasContent = useMemo(() => determineHasContent(file), [file]); + const newFileLink = (file._links?.newFile as Link)?.href; + const oldFileLink = (file._links?.oldFile as Link)?.href; + const { data: newContentType } = useContentType(newFileLink, { enabled: !hasContent && !!newFileLink }); + const { data: oldContentType } = useContentType(oldFileLink, { enabled: !hasContent && !!oldFileLink }); + const canRenderContent = useMemo( + () => + hasContent || + (["add", "modify", "delete"].includes(file.type) && canDisplayBinaryFile(oldContentType, newContentType)), + [file, hasContent, newContentType, oldContentType] + ); + const canRenderSideBySide = useMemo(() => hasContent && file.type === "modify", [file, hasContent]); + + const isCollapsed = useMemo(() => { + if (isCollapsedProp) { + return isCollapsedProp(fileProp); + } + return collapsed; + }, [collapsed, fileProp, isCollapsedProp]); + + useEffect(() => { + if (!isCollapsedProp) { + let defaultCollapse = !hasContent; + if (typeof defaultCollapseProp === "boolean") { + defaultCollapse = defaultCollapseProp; + } else if (typeof defaultCollapseProp === "function") { + defaultCollapse = defaultCollapseProp(file.oldPath, file.newPath); + } + + setCollapsed(defaultCollapse); + } + }, [defaultCollapseProp, file, hasContent, isCollapsedProp]); + + const toggleCollapse = useCallback( + (event: React.MouseEvent) => { + if (canRenderContent) { + if (onCollapseStateChange) { + onCollapseStateChange(file); + } else { + setCollapsed((prev) => !prev); + } + } + if (stickyHeader) { + const element = document.getElementById(event.currentTarget.id); + // Prevent skipping diffs on collapsing long ones because of the sticky header + // We jump to the start of the diff and afterwards go slightly up to show the diff header right under the page header + // Only scroll if diff is not collapsed and is using the "sticky" mode + const pageHeaderSize = 50; + if ( + element && + (isCollapsedProp ? !isCollapsedProp(file) : !collapsed) && + element.getBoundingClientRect().top < pageHeaderSize + ) { + element.scrollIntoView(); + window.scrollBy(0, -pageHeaderSize); + } + } + }, + [collapsed, file, canRenderContent, isCollapsedProp, onCollapseStateChange, stickyHeader] + ); + + const toggleSideBySide = useCallback((callback: () => void) => { + setSideBySide((prev) => !prev); + callback(); + }, []); + + const sideBySideToggle = useMemo( + () => + canRenderSideBySide && ( + + {({ setCollapsed }) => ( + + toggleSideBySide(() => { + if (sideBySide) { + setCollapsed(true); + } + }) + } + /> + )} + + ), + [canRenderSideBySide, sideBySide, t, toggleSideBySide] + ); + + const errorModal = useMemo( + () => + expansionError ? ( + setExpansionError(undefined)} + body={} + active={true} + /> + ) : null, + [expansionError, t] + ); + + const innerContent = useMemo( + () => ( +
+ {fileAnnotationFactory ? fileAnnotationFactory(file) : null} + {hasContent ? ( + + {(hunks: HunkType[]) => + hunks?.map((hunk, n) => ( + + )) + } + + ) : ( + + )} +
+ ), + [ + annotationFactory, + diffExpander, + file, + fileAnnotationFactory, + hasContent, + highlightLineOnHover, + hunkClass, + hunkGutterHoverIcon, + markConflicts, + newContentType, + newFileLink, + oldContentType, + oldFileLink, + onClick, + sideBySide, + viewType, + ] + ); + + const body = useMemo( + () => (!isCollapsed && canRenderContent ? innerContent : null), + [canRenderContent, innerContent, isCollapsed] + ); + + const openInFullscreen = useMemo( + () => + canRenderContent ? ( + {innerContent}} + /> + ) : null, + [canRenderContent, file, innerContent] + ); + + const collapseIcon = useMemo( + () => + isCollapsed ? ( + + ) : ( + + ), + [isCollapsed, t] + ); + + return ( + + {errorModal} + +
+ + {canRenderContent ? collapseIcon : null} +

+ +

+ +
+
+ + {sideBySideToggle} + {openInFullscreen} + {fileControlFactory ? fileControlFactory(file, setCollapsed) : null} + +
+
+
+ {body} +
+ ); +}; + +export default DiffFile; diff --git a/scm-ui/ui-components/src/repos/diff/helpers.ts b/scm-ui/ui-components/src/repos/diff/helpers.ts new file mode 100644 index 0000000000..32112bebf0 --- /dev/null +++ b/scm-ui/ui-components/src/repos/diff/helpers.ts @@ -0,0 +1,76 @@ +/* + * 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 { FileDiff, Hunk as HunkType } from "@scm-manager/ui-types"; +import { escapeWhitespace } from "../diffs"; +import { AnnotationFactory } from "../DiffTypes"; + +const EMPTY_ANNOTATION_FACTORY = {}; + +export const collectHunkAnnotations = (hunk: HunkType, file: FileDiff, annotationFactory?: AnnotationFactory) => { + if (annotationFactory) { + return annotationFactory({ + hunk, + file, + }); + } else { + return EMPTY_ANNOTATION_FACTORY; + } +}; + +export const getAnchorId = (file: FileDiff) => { + let path: string; + if (file.type === "delete") { + path = file.oldPath; + } else { + path = file.newPath; + } + return escapeWhitespace(path); +}; + +export const hasContent = (file: FileDiff) => file && !file.isBinary && file.hunks && file.hunks.length > 0; + +export const hoverFileTitle = (file: FileDiff): string => { + if (file.oldPath !== file.newPath && (file.type === "copy" || file.type === "rename")) { + return `${file.oldPath} > ${file.newPath}`; + } else if (file.type === "delete") { + return file.oldPath; + } + return file.newPath; +}; + +export const markConflicts = (hunk: HunkType) => { + let inConflict = false; + for (let i = 0; i < hunk.changes.length; ++i) { + if (hunk.changes[i].content === "<<<<<<< HEAD") { + inConflict = true; + } + if (inConflict) { + hunk.changes[i].type = "conflict"; + } + if (hunk.changes[i].content.startsWith(">>>>>>>")) { + inConflict = false; + } + } +}; diff --git a/scm-ui/ui-components/src/repos/diff/styledElements.tsx b/scm-ui/ui-components/src/repos/diff/styledElements.tsx new file mode 100644 index 0000000000..369ba349a0 --- /dev/null +++ b/scm-ui/ui-components/src/repos/diff/styledElements.tsx @@ -0,0 +1,91 @@ +/* + * 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 styled from "styled-components"; +// @ts-ignore react-diff-view does not provide types +import { Hunk } from "react-diff-view"; + +export type Collapsible = { + collapsed?: boolean; +}; + +export const StyledHunk = styled(Hunk)` + ${(props) => { + let style = props.icon + ? ` + .diff-gutter:hover::after { + font-size: inherit; + margin-left: 0.5em; + font-family: "Font Awesome 5 Free"; + content: "${props.icon}"; + color: var(--scm-column-selection); + } + ` + : ""; + if (!props.actionable) { + style += ` + .diff-gutter { + cursor: default; + } + `; + } + if (props.highlightLineOnHover) { + style += ` + tr.diff-line:hover > td { + background-color: var(--sh-selected-color); + } + `; + } + return style; + }} +`; + +export const DiffFilePanel = styled.div` + /* remove bottom border for collapsed panels */ + ${(props: Collapsible) => (props.collapsed ? "border-bottom: none;" : "")}; +`; + +export const FullWidthTitleHeader = styled.div` + max-width: 100%; +`; + +export const MarginlessModalContent = styled.div` + margin: -1.25rem; + + & .panel-block { + flex-direction: column; + align-items: stretch; + } +`; + +export const PanelHeading = styled.div<{ sticky?: boolean }>` + ${(props) => + props.sticky + ? ` + position: sticky; + top: 52px; + z-index: 1; + ` + : ""} +`; diff --git a/scm-ui/ui-components/src/repos/diff/types.ts b/scm-ui/ui-components/src/repos/diff/types.ts new file mode 100644 index 0000000000..8f42c75db1 --- /dev/null +++ b/scm-ui/ui-components/src/repos/diff/types.ts @@ -0,0 +1,32 @@ +/* + * 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 { FileDiff } from "@scm-manager/ui-types"; +import { DiffObjectProps } from "../DiffTypes"; + +export type DiffFileProps = DiffObjectProps & { + file: FileDiff; +}; +export type DiffExpandedCallback = (newFile: FileDiff) => void; +export type ErrorHandler = (error: Error | null | undefined) => void; diff --git a/scm-ui/ui-webapp/src/repos/containers/RepositoryRoot.tsx b/scm-ui/ui-webapp/src/repos/containers/RepositoryRoot.tsx index 17e7c1b7f8..8637b2bc3c 100644 --- a/scm-ui/ui-webapp/src/repos/containers/RepositoryRoot.tsx +++ b/scm-ui/ui-webapp/src/repos/containers/RepositoryRoot.tsx @@ -60,7 +60,12 @@ import SourceExtensions from "../sources/containers/SourceExtensions"; import TagsOverview from "../tags/container/TagsOverview"; import CompareRoot from "../compare/CompareRoot"; import TagRoot from "../tags/container/TagRoot"; -import { RepositoryContextProvider, useIndexLinks, useNamespaceAndNameContext, useRepository } from "@scm-manager/ui-api"; +import { + RepositoryContextProvider, + useIndexLinks, + useNamespaceAndNameContext, + useRepository, +} from "@scm-manager/ui-api"; import styled from "styled-components"; import { useShortcut } from "@scm-manager/ui-shortcuts"; @@ -204,7 +209,7 @@ const RepositoryRoot = () => { } } - return links ? links.map(({ url, label }) => ) : null; + return links ? links.map(({ url, label }) => ) : null; }; const titleComponent = ( diff --git a/scm-webapp/src/main/java/sonia/scm/api/v2/resources/DiffResultToDiffResultDtoMapper.java b/scm-webapp/src/main/java/sonia/scm/api/v2/resources/DiffResultToDiffResultDtoMapper.java index c528606449..398e5f8ea7 100644 --- a/scm-webapp/src/main/java/sonia/scm/api/v2/resources/DiffResultToDiffResultDtoMapper.java +++ b/scm-webapp/src/main/java/sonia/scm/api/v2/resources/DiffResultToDiffResultDtoMapper.java @@ -116,6 +116,12 @@ class DiffResultToDiffResultDtoMapper { if (file.iterator().hasNext()) { links.single(linkBuilder("lines", resourceLinks.source().content(repository.getNamespace(), repository.getName(), revision, file.getNewPath()) + "?start={start}&end={end}").build()); } + if (!file.getChangeType().equals(DiffFile.ChangeType.ADD)) { + links.single(linkBuilder("oldFile", resourceLinks.source().content(repository.getNamespace(), repository.getName(), file.getOldRevision(), file.getOldPath())).build()); + } + if (!file.getChangeType().equals(DiffFile.ChangeType.DELETE)) { + links.single(linkBuilder("newFile", resourceLinks.source().content(repository.getNamespace(), repository.getName(), file.getNewRevision(), file.getNewPath())).build()); + } DiffResultDto.FileDto dto = new DiffResultDto.FileDto(links.build()); // ??? dto.setOldEndingNewLine(true);