mirror of
https://github.com/scm-manager/scm-manager.git
synced 2026-07-06 23:58:46 +02:00
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:
committed by
SCM-Manager
parent
68110ee6b3
commit
d257c8616c
2
gradle/changelog/image_diff.yaml
Normal file
2
gradle/changelog/image_diff.yaml
Normal file
@@ -0,0 +1,2 @@
|
||||
- type: added
|
||||
description: Display images in diffs
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -108,4 +108,4 @@
|
||||
"publishConfig": {
|
||||
"access": "public"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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];
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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);
|
||||
@@ -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;
|
||||
56
scm-ui/ui-components/src/repos/diff/ChangeTag.tsx
Normal file
56
scm-ui/ui-components/src/repos/diff/ChangeTag.tsx
Normal 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;
|
||||
149
scm-ui/ui-components/src/repos/diff/DiffFileHunk.tsx
Normal file
149
scm-ui/ui-components/src/repos/diff/DiffFileHunk.tsx
Normal 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;
|
||||
46
scm-ui/ui-components/src/repos/diff/FileTitle.tsx
Normal file
46
scm-ui/ui-components/src/repos/diff/FileTitle.tsx
Normal 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;
|
||||
75
scm-ui/ui-components/src/repos/diff/HunkFooter.tsx
Normal file
75
scm-ui/ui-components/src/repos/diff/HunkFooter.tsx
Normal 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;
|
||||
75
scm-ui/ui-components/src/repos/diff/HunkHeader.tsx
Normal file
75
scm-ui/ui-components/src/repos/diff/HunkHeader.tsx
Normal 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;
|
||||
63
scm-ui/ui-components/src/repos/diff/LastHunkFooter.tsx
Normal file
63
scm-ui/ui-components/src/repos/diff/LastHunkFooter.tsx
Normal 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;
|
||||
288
scm-ui/ui-components/src/repos/diff/LazyDiffFile.tsx
Normal file
288
scm-ui/ui-components/src/repos/diff/LazyDiffFile.tsx
Normal 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;
|
||||
76
scm-ui/ui-components/src/repos/diff/helpers.ts
Normal file
76
scm-ui/ui-components/src/repos/diff/helpers.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
};
|
||||
91
scm-ui/ui-components/src/repos/diff/styledElements.tsx
Normal file
91
scm-ui/ui-components/src/repos/diff/styledElements.tsx
Normal 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;
|
||||
`
|
||||
: ""}
|
||||
`;
|
||||
32
scm-ui/ui-components/src/repos/diff/types.ts
Normal file
32
scm-ui/ui-components/src/repos/diff/types.ts
Normal 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;
|
||||
@@ -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 = (
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user