Display images in diffs

Committed-by: Eduard Heimbuch <eduard.heimbuch@cloudogu.com>
Co-authored-by: René Pfeuffer <rene.pfeuffer@cloudogu.com>
This commit is contained in:
Konstantin Schaper
2023-03-22 11:17:22 +01:00
committed by SCM-Manager
parent 68110ee6b3
commit d257c8616c
22 changed files with 1775 additions and 1017 deletions

View File

@@ -0,0 +1,2 @@
- type: added
description: Display images in diffs

View File

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

View File

@@ -65,12 +65,12 @@ public class GitDiffResultCommandTest extends AbstractGitCommandTestBase {
Iterator<DiffFile> 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();
}

View File

@@ -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<ContentType> {
@@ -39,9 +41,14 @@ function getContentType(url: string): Promise<ContentType> {
});
}
export const useContentType = (url: string): ApiResultWithFetching<ContentType> => {
const { isLoading, isFetching, error, data } = useQuery<ContentType, Error>(["contentType", url], () =>
getContentType(url)
export const useContentType = (
url: string,
options: Pick<UseQueryOptions<ContentType, Error>, "enabled"> = {}
): ApiResultWithFetching<ContentType> => {
const { isLoading, isFetching, error, data } = useQuery<ContentType, Error>(
["contentType", url],
() => getContentType(url),
options
);
return {
isLoading,

View File

@@ -108,4 +108,4 @@
"publishConfig": {
"access": "public"
}
}
}

View File

@@ -24,7 +24,6 @@
import React, { ReactNode, useEffect, useState } from "react";
import { storiesOf } from "@storybook/react";
import Diff from "./Diff";
// @ts-ignore
import parser from "gitdiff-parser";
import simpleDiff from "../__resources__/Diff.simple";
import hunksDiff from "../__resources__/Diff.hunks";
@@ -40,6 +39,10 @@ import { two } from "../__resources__/changesets";
import { Changeset, FileDiff } from "@scm-manager/ui-types";
import JumpToFileButton from "./JumpToFileButton";
import Button from "../buttons/Button";
// @ts-ignore ignore unknown png
import hitchhikerImg from "../__resources__/hitchhiker.png";
// @ts-ignore ignore unknown jpg
import marvinImg from "../__resources__/marvin.jpg";
const diffFiles = parser.parse(simpleDiff);
@@ -153,6 +156,85 @@ storiesOf("Repositories/Diff", module)
const binaryDiffFiles = parser.parse(binaryDiff);
return <Diff diff={binaryDiffFiles} />;
})
.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 <Diff diff={binaryDiffFiles} />;
})
.add("SyntaxHighlighting", () => {
const filesWithLanguage = diffFiles.map((file: FileDiff) => {
const ext = getPath(file).split(".")[1];

View File

@@ -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<DiffFileProps> = props => (
const DiffFile: FC<DiffFileProps> = (props) => (
<Suspense fallback={<Loading />}>
<LazyDiffFile {...props} />
</Suspense>

View File

@@ -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<Props, State> {
static defaultProps: Partial<Props> = {
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<Props>) {
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<HTMLDivElement>) => {
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 (
<HunkExpandDivider>
<HunkExpandLink
icon={"fa-angle-double-up"}
onClick={this.expandHead(expandableHunk, expandableHunk.maxExpandHeadRange)}
text={this.props.t("diff.expandComplete", { count: expandableHunk.maxExpandHeadRange })}
/>
</HunkExpandDivider>
);
} else {
return (
<HunkExpandDivider>
<HunkExpandLink
icon={"fa-angle-up"}
onClick={this.expandHead(expandableHunk, 10)}
text={this.props.t("diff.expandByLines", { count: 10 })}
/>{" "}
<HunkExpandLink
icon={"fa-angle-double-up"}
onClick={this.expandHead(expandableHunk, expandableHunk.maxExpandHeadRange)}
text={this.props.t("diff.expandComplete", { count: expandableHunk.maxExpandHeadRange })}
/>
</HunkExpandDivider>
);
}
}
// hunk header must be defined
return <span />;
};
createHunkFooter = (expandableHunk: ExpandableHunk) => {
if (expandableHunk.maxExpandBottomRange > 0) {
if (expandableHunk.maxExpandBottomRange <= 10) {
return (
<HunkExpandDivider>
<HunkExpandLink
icon={"fa-angle-double-down"}
onClick={this.expandBottom(expandableHunk, expandableHunk.maxExpandBottomRange)}
text={this.props.t("diff.expandComplete", { count: expandableHunk.maxExpandBottomRange })}
/>
</HunkExpandDivider>
);
} else {
return (
<HunkExpandDivider>
<HunkExpandLink
icon={"fa-angle-down"}
onClick={this.expandBottom(expandableHunk, 10)}
text={this.props.t("diff.expandByLines", { count: 10 })}
/>{" "}
<HunkExpandLink
icon={"fa-angle-double-down"}
onClick={this.expandBottom(expandableHunk, expandableHunk.maxExpandBottomRange)}
text={this.props.t("diff.expandComplete", { count: expandableHunk.maxExpandBottomRange })}
/>
</HunkExpandDivider>
);
}
}
// hunk footer must be defined
return <span />;
};
createLastHunkFooter = (expandableHunk: ExpandableHunk) => {
if (expandableHunk.maxExpandBottomRange !== 0) {
return (
<HunkExpandDivider>
<HunkExpandLink
icon={"fa-angle-down"}
onClick={this.expandBottom(expandableHunk, 10)}
text={this.props.t("diff.expandLastBottomByLines", { count: 10 })}
/>{" "}
<HunkExpandLink
icon={"fa-angle-double-down"}
onClick={this.expandBottom(expandableHunk, expandableHunk.maxExpandBottomRange)}
text={this.props.t("diff.expandLastBottomComplete")}
/>
</HunkExpandDivider>
);
}
// hunk header must be defined
return <span />;
};
expandHead = (expandableHunk: ExpandableHunk, count: number) => {
return () => {
return expandableHunk.expandHead(count).then(this.diffExpanded).catch(this.diffExpansionFailed);
};
};
expandBottom = (expandableHunk: ExpandableHunk, count: number) => {
return () => {
return expandableHunk.expandBottom(count).then(this.diffExpanded).catch(this.diffExpansionFailed);
};
};
diffExpanded = (newFile: 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(
<Decoration>
<hr className="my-2" />
</Decoration>
);
}
const gutterEvents = this.createGutterEvents(hunk);
items.push(
<StyledHunk
key={"hunk-" + hunk.content}
hunk={expandableHunk.hunk}
widgets={this.collectHunkAnnotations(hunk)}
gutterEvents={gutterEvents}
className={this.props.hunkClass ? this.props.hunkClass(hunk) : null}
icon={this.props.hunkGutterHoverIcon}
actionable={!!gutterEvents}
highlightLineOnHover={this.props.highlightLineOnHover}
/>
);
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} <Icon name="arrow-right" color="inherit" alt={t("diff.renamedTo")} /> {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 (
<Tag
className={classNames("has-text-weight-normal", "ml-3")}
rounded={true}
outlined={true}
color={color}
label={value}
/>
);
};
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 = (
<div className="panel-block p-0">
{fileAnnotations}
<TokenizedDiffView className={viewType} viewType={viewType} file={file}>
{(hunks: HunkType[]) =>
hunks?.map((hunk, n) => {
return this.renderHunk(file, diffExpander.getHunk(n), n);
})
}
</TokenizedDiffView>
</div>
);
let icon = <Icon name="angle-right" color="inherit" alt={t("diff.showContent")} />;
let body = null;
if (!collapsed) {
icon = <Icon name="angle-down" color="inherit" alt={t("diff.hideContent")} />;
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 ? (
<OpenInFullscreenButton
modalTitle={modalTitle}
modalBody={<MarginlessModalContent>{innerContent}</MarginlessModalContent>}
/>
) : null;
const sideBySideToggle = file?.hunks?.length && (
<MenuContext.Consumer>
{({ setCollapsed }) => (
<DiffButton
icon={sideBySide ? "align-left" : "columns"}
tooltip={t(sideBySide ? "diff.combined" : "diff.sideBySide")}
onClick={() =>
this.toggleSideBySide(() => {
if (this.state.sideBySide) {
setCollapsed(true);
}
})
}
/>
)}
</MenuContext.Consumer>
);
const headerButtons = (
<div className={classNames("level-right", "is-flex", "ml-auto")}>
<ButtonGroup>
{sideBySideToggle}
{openInFullscreen}
{fileControls}
</ButtonGroup>
</div>
);
let errorModal;
if (expansionError) {
errorModal = (
<Modal
title={t("diff.expansionFailed")}
closeFunction={() => this.setState({ expansionError: undefined })}
body={<ErrorNotification error={expansionError} />}
active={true}
/>
);
}
return (
<DiffFilePanel
className={classNames("panel", "is-size-6")}
collapsed={(file && file.isBinary) || collapsed}
id={this.getAnchorId(file)}
>
{errorModal}
<PanelHeading className="panel-heading" sticky={stickyHeader}>
<div className={classNames("level", "is-flex-wrap-wrap")}>
<FullWidthTitleHeader
className={classNames("level-left", "is-flex", "is-clickable")}
onClick={this.toggleCollapse}
title={this.hoverFileTitle(file)}
id={this.getAnchorId(file)}
>
{collapseIcon}
<h4 className={classNames("has-text-weight-bold", "is-ellipsis-overflow", "is-size-6", "ml-1")}>
{this.renderFileTitle(file)}
</h4>
{this.renderChangeTag(file)}
</FullWidthTitleHeader>
{headerButtons}
</div>
</PanelHeading>
{body}
</DiffFilePanel>
);
}
}
export default withTranslation("repos")(DiffFile);

View File

@@ -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<Props> = ({ 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 (
<CompareContainer className="is-flex">
<CompareImageContainer>
<CompareImageLabel className="has-text-danger">{t("diff.changes.delete")}</CompareImageLabel>
<CompareImage className="has-border-danger" src={oldFileLink} alt="" />
</CompareImageContainer>
<CompareImageContainer>
<CompareImageLabel className="has-text-success">{t("diff.changes.add")}</CompareImageLabel>
<CompareImage className="has-border-success" src={newFileLink} alt="" />
</CompareImageContainer>
</CompareContainer>
);
} else if (isImage) {
return <img src={newFileLink || oldFileLink} alt="" />;
}
return null;
};
export default BinaryDiffFileContent;

View File

@@ -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<Props> = ({ 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 (
<Tag
className={classNames("has-text-weight-normal", "ml-3")}
rounded={true}
outlined={true}
color={color}
label={value}
/>
);
};
export default ChangeTag;

View File

@@ -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<Props> = ({
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(
<HunkHeader
key={"hunkHeader-" + hunk.content}
expandableHunk={expandableHunk}
diffExpanded={diffExpanded}
diffExpansionFailed={diffExpansionFailed}
/>
);
} else if (i > 0) {
items.push(
<Decoration key={"decoration-" + hunk.content}>
<hr className="my-2" />
</Decoration>
);
}
items.push(
<StyledHunk
key={"hunk-" + hunk.content}
hunk={expandableHunk.hunk}
widgets={collectHunkAnnotations(hunk, file, annotationFactory)}
gutterEvents={gutterEvents}
className={hunkClass ? hunkClass(hunk) : null}
icon={hunkGutterHoverIcon}
actionable={!!gutterEvents}
highlightLineOnHover={highlightLineOnHover}
/>
);
if (file._links?.lines) {
if (i === (file.hunks ?? []).length - 1) {
items.push(
<LastHunkFooter
key={"lastHunkFooter-" + hunk.content}
expandableHunk={expandableHunk}
diffExpanded={diffExpanded}
diffExpansionFailed={diffExpansionFailed}
/>
);
} else {
items.push(
<HunkFooter
key={"hunkFooter-" + hunk.content}
expandableHunk={expandableHunk}
diffExpanded={diffExpanded}
diffExpansionFailed={diffExpansionFailed}
/>
);
}
}
return <>{items}</>;
};
export default DiffFileHunk;

View File

@@ -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<Props> = ({ file }) => {
const [t] = useTranslation("repos");
if (file.oldPath !== file.newPath && (file.type === "copy" || file.type === "rename")) {
return (
<>
{file.oldPath} <Icon name="arrow-right" color="inherit" alt={t("diff.renamedTo")} /> {file.newPath}
</>
);
} else if (file.type === "delete") {
return <>{file.oldPath}</>;
}
return <>{file.newPath}</>;
};
export default FileTitle;

View File

@@ -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<Props> = ({ 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 (
<HunkExpandDivider>
<HunkExpandLink
icon={"fa-angle-double-down"}
onClick={expandBottom(expandableHunk, expandableHunk.maxExpandBottomRange)}
text={t("diff.expandComplete", { count: expandableHunk.maxExpandBottomRange })}
/>
</HunkExpandDivider>
);
} else {
return (
<HunkExpandDivider>
<HunkExpandLink
icon={"fa-angle-down"}
onClick={expandBottom(expandableHunk, 10)}
text={t("diff.expandByLines", { count: 10 })}
/>{" "}
<HunkExpandLink
icon={"fa-angle-double-down"}
onClick={expandBottom(expandableHunk, expandableHunk.maxExpandBottomRange)}
text={t("diff.expandComplete", { count: expandableHunk.maxExpandBottomRange })}
/>
</HunkExpandDivider>
);
}
}
// hunk footer must be defined
return <tfoot />;
};
export default HunkFooter;

View File

@@ -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<Props> = ({ 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 (
<HunkExpandDivider>
<HunkExpandLink
icon={"fa-angle-double-up"}
onClick={expandHead(expandableHunk, expandableHunk.maxExpandHeadRange)}
text={t("diff.expandComplete", { count: expandableHunk.maxExpandHeadRange })}
/>
</HunkExpandDivider>
);
} else {
return (
<HunkExpandDivider>
<HunkExpandLink
icon={"fa-angle-up"}
onClick={expandHead(expandableHunk, 10)}
text={t("diff.expandByLines", { count: 10 })}
/>{" "}
<HunkExpandLink
icon={"fa-angle-double-up"}
onClick={expandHead(expandableHunk, expandableHunk.maxExpandHeadRange)}
text={t("diff.expandComplete", { count: expandableHunk.maxExpandHeadRange })}
/>
</HunkExpandDivider>
);
}
}
// hunk header must be defined
return <thead />;
};
export default HunkHeader;

View File

@@ -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<Props> = ({ 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 (
<HunkExpandDivider>
<HunkExpandLink
icon={"fa-angle-down"}
onClick={expandBottom(expandableHunk, 10)}
text={t("diff.expandLastBottomByLines", { count: 10 })}
/>{" "}
<HunkExpandLink
icon={"fa-angle-double-down"}
onClick={expandBottom(expandableHunk, expandableHunk.maxExpandBottomRange)}
text={t("diff.expandLastBottomComplete")}
/>
</HunkExpandDivider>
);
}
// hunk header must be defined
return <tfoot />;
};
export default LastHunkFooter;

View File

@@ -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<Props> = ({
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<Error | null | undefined>();
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<HTMLDivElement>) => {
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 && (
<MenuContext.Consumer>
{({ setCollapsed }) => (
<DiffButton
icon={sideBySide ? "align-left" : "columns"}
tooltip={t(sideBySide ? "diff.combined" : "diff.sideBySide")}
onClick={() =>
toggleSideBySide(() => {
if (sideBySide) {
setCollapsed(true);
}
})
}
/>
)}
</MenuContext.Consumer>
),
[canRenderSideBySide, sideBySide, t, toggleSideBySide]
);
const errorModal = useMemo(
() =>
expansionError ? (
<Modal
title={t("diff.expansionFailed")}
closeFunction={() => setExpansionError(undefined)}
body={<ErrorNotification error={expansionError} />}
active={true}
/>
) : null,
[expansionError, t]
);
const innerContent = useMemo(
() => (
<div className="panel-block p-0">
{fileAnnotationFactory ? fileAnnotationFactory(file) : null}
{hasContent ? (
<TokenizedDiffView className={viewType} viewType={viewType} file={file}>
{(hunks: HunkType[]) =>
hunks?.map((hunk, n) => (
<DiffFileHunk
key={hunk.content}
file={file}
expandableHunk={diffExpander.getHunk(n)}
i={n}
diffExpanded={setFile}
diffExpansionFailed={setExpansionError}
annotationFactory={annotationFactory}
onClick={onClick}
hunkClass={hunkClass}
hunkGutterHoverIcon={hunkGutterHoverIcon}
highlightLineOnHover={highlightLineOnHover}
markConflicts={markConflicts}
/>
))
}
</TokenizedDiffView>
) : (
<BinaryDiffFileContent
newContentType={newContentType}
oldContentType={oldContentType}
newFileLink={newFileLink}
oldFileLink={oldFileLink}
sideBySide={sideBySide}
/>
)}
</div>
),
[
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 ? (
<OpenInFullscreenButton
modalTitle={file.type === "delete" ? file.oldPath : file.newPath}
modalBody={<MarginlessModalContent>{innerContent}</MarginlessModalContent>}
/>
) : null,
[canRenderContent, file, innerContent]
);
const collapseIcon = useMemo(
() =>
isCollapsed ? (
<Icon name="angle-right" color="inherit" alt={t("diff.showContent")} />
) : (
<Icon name="angle-down" color="inherit" alt={t("diff.hideContent")} />
),
[isCollapsed, t]
);
return (
<DiffFilePanel
className={classNames("panel", "is-size-6")}
collapsed={!canRenderContent || isCollapsed}
id={getAnchorId(file)}
>
{errorModal}
<PanelHeading className="panel-heading" sticky={stickyHeader}>
<div className={classNames("level", "is-flex-wrap-wrap")}>
<FullWidthTitleHeader
className={classNames("level-left", "is-flex", "is-clickable")}
onClick={toggleCollapse}
title={hoverFileTitle(file)}
id={getAnchorId(file)}
>
{canRenderContent ? collapseIcon : null}
<h4 className={classNames("has-text-weight-bold", "is-ellipsis-overflow", "is-size-6", "ml-1")}>
<FileTitle file={file} />
</h4>
<ChangeTag file={file} />
</FullWidthTitleHeader>
<div className={classNames("level-right", "is-flex", "ml-auto")}>
<ButtonGroup>
{sideBySideToggle}
{openInFullscreen}
{fileControlFactory ? fileControlFactory(file, setCollapsed) : null}
</ButtonGroup>
</div>
</div>
</PanelHeading>
{body}
</DiffFilePanel>
);
};
export default DiffFile;

View File

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

View File

@@ -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<Collapsible>`
/* 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;
`
: ""}
`;

View File

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

View File

@@ -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 }) => <JumpToFileButton tooltip={label} link={url} />) : null;
return links ? links.map(({ url, label }) => <JumpToFileButton key={url} tooltip={label} link={url} />) : null;
};
const titleComponent = (

View File

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