Files
SCM-Manager/scm-ui/ui-components/src/repos/DiffFile.tsx

404 lines
12 KiB
TypeScript
Raw Normal View History

/*
* 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";
2019-10-21 14:10:48 +02:00
// @ts-ignore
import { Decoration, getChangeKey, Hunk } from "react-diff-view";
2020-02-13 15:08:17 +01:00
import { ButtonGroup } from "../buttons";
import Tag from "../Tag";
import Icon from "../Icon";
import { Change, ChangeEvent, DiffObjectProps, File, Hunk as HunkType } from "./DiffTypes";
2020-01-22 12:08:28 +01:00
import TokenizedDiffView from "./TokenizedDiffView";
import DiffButton from "./DiffButton";
2020-02-28 13:15:27 +01:00
import { MenuContext } from "@scm-manager/ui-components";
2020-05-27 17:23:00 +02:00
import DiffExpander, { ExpandableHunk } from "./DiffExpander";
2020-01-06 15:59:16 +01:00
const EMPTY_ANNOTATION_FACTORY = {};
2019-02-26 15:00:05 +01:00
type Props = DiffObjectProps &
WithTranslation & {
file: File;
};
type Collapsible = {
collapsed?: boolean;
2019-02-27 11:56:50 +01:00
};
2019-02-26 15:00:05 +01:00
type State = Collapsible & {
2020-05-29 14:00:14 +02:00
file: File;
2019-12-19 09:51:44 +01:00
sideBySide?: boolean;
2020-05-27 17:23:00 +02:00
diffExpander: DiffExpander;
2019-02-27 11:56:50 +01:00
};
2019-02-26 15:00:05 +01:00
const DiffFilePanel = styled.div`
2019-10-10 11:37:14 +02:00
/* remove bottom border for collapsed panels */
${(props: Collapsible) => (props.collapsed ? "border-bottom: none;" : "")};
`;
const FlexWrapLevel = styled.div`
/* breaks into a second row
when buttons and title become too long */
flex-wrap: wrap;
`;
const FullWidthTitleHeader = styled.div`
max-width: 100%;
`;
const TitleWrapper = styled.span`
margin-left: 0.25rem;
`;
const ButtonWrapper = styled.div`
/* align child to right */
margin-left: auto;
`;
2020-05-27 17:23:00 +02:00
const HunkDivider = styled.div`
background: #33b2e8;
font-size: 0.7rem;
`;
const ChangeTypeTag = styled(Tag)`
margin-left: 0.75rem;
`;
2019-02-26 15:00:05 +01:00
class DiffFile extends React.Component<Props, State> {
static defaultProps: Partial<Props> = {
2020-01-08 13:45:18 +01:00
defaultCollapse: false,
markConflicts: true
2019-10-10 11:37:14 +02:00
};
2019-02-26 15:00:05 +01:00
constructor(props: Props) {
super(props);
this.state = {
collapsed: this.defaultCollapse(),
2020-05-27 17:23:00 +02:00
sideBySide: props.sideBySide,
2020-05-29 14:00:14 +02:00
diffExpander: new DiffExpander(props.file),
file: props.file
2019-02-26 15:00:05 +01:00
};
}
2020-02-13 15:08:17 +01:00
componentDidUpdate(prevProps: Readonly<Props>, prevState: Readonly<State>, snapshot?: any): void {
if (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;
2019-10-10 11:37:14 +02:00
}
};
2019-10-10 11:37:14 +02:00
2019-02-26 15:00:05 +01:00
toggleCollapse = () => {
2020-05-29 14:00:14 +02:00
const { file } = this.state;
if (this.hasContent(file)) {
this.setState(state => ({
collapsed: !state.collapsed
}));
}
2019-02-26 15:00:05 +01:00
};
2020-03-02 14:13:51 +01:00
toggleSideBySide = (callback: () => void) => {
2020-02-28 13:15:27 +01:00
this.setState(
state => ({
sideBySide: !state.sideBySide
}),
2020-03-02 14:13:51 +01:00
() => callback()
2020-02-28 13:15:27 +01:00
);
};
setCollapse = (collapsed: boolean) => {
this.setState({
collapsed
});
};
2020-05-29 14:00:14 +02:00
diffExpanded = (newFile: File) => {
this.setState({ file: newFile, diffExpander: new DiffExpander(newFile) });
};
2020-05-27 17:23:00 +02:00
createHunkHeader = (expandableHunk: ExpandableHunk) => {
if (expandableHunk.maxExpandHeadRange > 0) {
if (expandableHunk.maxExpandHeadRange <= 10) {
return (
<Decoration>
<HunkDivider>
<span onClick={() => expandableHunk.expandHead(expandableHunk.maxExpandHeadRange, this.diffExpanded)}>
{this.props.t("diff.expandHeadComplete", { count: expandableHunk.maxExpandHeadRange })}
</span>
</HunkDivider>
</Decoration>
);
} else {
return (
<Decoration>
<HunkDivider>
<span onClick={() => expandableHunk.expandHead(10, this.diffExpanded)}>
{this.props.t("diff.expandHeadByLines", { count: 10 })}
</span>{" "}
<span onClick={() => expandableHunk.expandHead(expandableHunk.maxExpandHeadRange, this.diffExpanded)}>
{this.props.t("diff.expandHeadComplete", { count: expandableHunk.maxExpandHeadRange })}
</span>
</HunkDivider>
</Decoration>
);
}
2020-05-27 17:23:00 +02:00
}
// hunk header must be defined
return <span />;
};
createHunkFooter = (expandableHunk: ExpandableHunk) => {
if (expandableHunk.maxExpandBottomRange > 0) {
if (expandableHunk.maxExpandBottomRange <= 10) {
return (
<Decoration>
<HunkDivider>
<span onClick={() => expandableHunk.expandBottom(expandableHunk.maxExpandBottomRange, this.diffExpanded)}>
{this.props.t("diff.expandBottomComplete", { count: expandableHunk.maxExpandBottomRange })}
</span>
</HunkDivider>
</Decoration>
);
} else {
return (
<Decoration>
<HunkDivider>
<span onClick={() => expandableHunk.expandBottom(10, this.diffExpanded)}>
{this.props.t("diff.expandBottomByLines", { count: 10 })}
</span>{" "}
<span onClick={() => expandableHunk.expandBottom(expandableHunk.maxExpandBottomRange, this.diffExpanded)}>
{this.props.t("diff.expandBottomComplete", { count: expandableHunk.maxExpandBottomRange })}
</span>
</HunkDivider>
</Decoration>
);
}
2019-02-27 11:56:50 +01:00
}
2020-01-08 09:57:57 +01:00
// hunk header must be defined
return <span />;
2019-02-27 11:56:50 +01:00
};
collectHunkAnnotations = (hunk: HunkType) => {
2020-05-29 14:00:14 +02:00
const { annotationFactory } = this.props;
const { file } = this.state;
2019-02-27 11:56:50 +01:00
if (annotationFactory) {
return annotationFactory({
hunk,
file
2019-02-27 11:56:50 +01:00
});
2020-01-06 15:59:16 +01:00
} else {
return EMPTY_ANNOTATION_FACTORY;
2019-02-27 11:56:50 +01:00
}
};
handleClickEvent = (change: Change, hunk: HunkType) => {
2020-05-29 14:00:14 +02:00
const { onClick } = this.props;
const { file } = this.state;
2019-02-27 11:56:50 +01:00
const context = {
changeId: getChangeKey(change),
change,
hunk,
file
2019-02-27 11:56:50 +01:00
};
if (onClick) {
onClick(context);
2019-02-26 15:00:05 +01:00
}
2019-02-27 11:56:50 +01:00
};
2020-01-06 15:59:16 +01:00
createGutterEvents = (hunk: HunkType) => {
2019-02-27 11:56:50 +01:00
const { onClick } = this.props;
if (onClick) {
return {
2020-01-06 15:59:16 +01:00
onClick: (event: ChangeEvent) => {
this.handleClickEvent(event.change, hunk);
}
2019-02-27 11:56:50 +01:00
};
}
};
2020-05-27 17:23:00 +02:00
renderHunk = (file: File, expandableHunk: ExpandableHunk, i: number) => {
const hunk = expandableHunk.hunk;
2020-01-08 16:35:46 +01:00
if (this.props.markConflicts && hunk.changes) {
2020-01-08 13:45:18 +01:00
this.markConflicts(hunk);
}
2020-05-27 17:23:00 +02:00
const items = [];
if (file._links?.lines) {
items.push(this.createHunkHeader(expandableHunk));
}
items.push(
2020-01-08 13:45:18 +01:00
<Hunk
key={"hunk-" + hunk.content}
2020-05-27 17:23:00 +02:00
hunk={expandableHunk.hunk}
2020-01-08 13:45:18 +01:00
widgets={this.collectHunkAnnotations(hunk)}
gutterEvents={this.createGutterEvents(hunk)}
/>
2020-05-27 17:23:00 +02:00
);
if (file._links?.lines) {
items.push(this.createHunkFooter(expandableHunk));
}
return items;
2020-01-08 13:45:18 +01:00
};
markConflicts = (hunk: HunkType) => {
2020-01-08 12:58:13 +01:00
let inConflict = false;
2020-01-08 13:45:18 +01:00
for (let i = 0; i < hunk.changes.length; ++i) {
2020-01-08 12:58:13 +01:00
if (hunk.changes[i].content === "<<<<<<< HEAD") {
inConflict = true;
}
if (inConflict) {
hunk.changes[i].type = "conflict";
}
if (hunk.changes[i].content.startsWith(">>>>>>>")) {
inConflict = false;
}
}
2019-02-26 15:00:05 +01:00
};
renderFileTitle = (file: File) => {
2019-10-21 10:57:56 +02:00
if (file.oldPath !== file.newPath && (file.type === "copy" || file.type === "rename")) {
2019-02-27 11:56:50 +01:00
return (
<>
2019-10-21 10:57:56 +02:00
{file.oldPath} <Icon name="arrow-right" color="inherit" /> {file.newPath}
2019-02-27 11:56:50 +01:00
</>
);
} else if (file.type === "delete") {
2019-02-26 15:00:05 +01:00
return file.oldPath;
}
return file.newPath;
};
hoverFileTitle = (file: File): string => {
2019-10-21 10:57:56 +02:00
if (file.oldPath !== file.newPath && (file.type === "copy" || file.type === "rename")) {
return `${file.oldPath} > ${file.newPath}`;
} else if (file.type === "delete") {
2019-05-26 14:10:59 +02:00
return file.oldPath;
}
return file.newPath;
};
renderChangeTag = (file: File) => {
const { t } = this.props;
if (!file.type) {
return;
}
const key = "diff.changes." + file.type;
2019-02-26 15:00:05 +01:00
let value = t(key);
if (key === value) {
value = file.type;
}
const color =
2019-10-21 10:57:56 +02:00
value === "added" ? "success is-outlined" : value === "deleted" ? "danger is-outlined" : "info is-outlined";
2019-10-21 10:57:56 +02:00
return <ChangeTypeTag className={classNames("is-rounded", "has-text-weight-normal")} color={color} label={value} />;
2019-02-26 15:00:05 +01:00
};
hasContent = (file: File) => file && !file.isBinary && file.hunks && file.hunks.length > 0;
2019-02-26 15:00:05 +01:00
render() {
2020-05-29 14:00:14 +02:00
const { fileControlFactory, fileAnnotationFactory, t } = this.props;
const { file, collapsed, sideBySide, diffExpander } = this.state;
const viewType = sideBySide ? "split" : "unified";
2019-02-26 15:00:05 +01:00
let body = null;
let icon = "angle-right";
2019-02-26 15:00:05 +01:00
if (!collapsed) {
2019-10-21 10:57:56 +02:00
const fileAnnotations = fileAnnotationFactory ? fileAnnotationFactory(file) : null;
icon = "angle-down";
2019-02-26 15:00:05 +01:00
body = (
<div className="panel-block is-paddingless">
{fileAnnotations}
2020-01-22 12:08:28 +01:00
<TokenizedDiffView className={viewType} viewType={viewType} file={file}>
2020-05-29 14:00:14 +02:00
{(hunks: HunkType[]) =>
hunks?.map((hunk, n) => {
return this.renderHunk(file, diffExpander.getHunk(n), n);
})
}
2020-01-22 12:08:28 +01:00
</TokenizedDiffView>
2019-02-26 15:00:05 +01:00
</div>
);
}
const collapseIcon = this.hasContent(file) ? <Icon name={icon} color="inherit" /> : null;
2019-10-21 10:57:56 +02:00
const fileControls = fileControlFactory ? fileControlFactory(file, this.setCollapse) : null;
const sideBySideToggle =
file.hunks && file.hunks.length > 0 ? (
<ButtonWrapper className={classNames("level-right", "is-flex")}>
<ButtonGroup>
2020-02-28 13:15:27 +01:00
<MenuContext.Consumer>
{({ setCollapsed }) => (
2020-02-28 13:15:27 +01:00
<DiffButton
icon={sideBySide ? "align-left" : "columns"}
tooltip={t(sideBySide ? "diff.combined" : "diff.sideBySide")}
2020-03-31 17:11:16 +02:00
onClick={() =>
this.toggleSideBySide(() => {
if (this.state.sideBySide) {
setCollapsed(true);
}
})
}
2020-02-28 13:15:27 +01:00
/>
)}
</MenuContext.Consumer>
{fileControls}
</ButtonGroup>
</ButtonWrapper>
) : null;
return (
2019-10-21 10:57:56 +02:00
<DiffFilePanel className={classNames("panel", "is-size-6")} collapsed={(file && file.isBinary) || collapsed}>
<div className="panel-heading">
<FlexWrapLevel className="level">
<FullWidthTitleHeader
2019-10-21 10:57:56 +02:00
className={classNames("level-left", "is-flex", "has-cursor-pointer")}
onClick={this.toggleCollapse}
2019-05-26 14:10:59 +02:00
title={this.hoverFileTitle(file)}
>
{collapseIcon}
2019-10-21 10:57:56 +02:00
<TitleWrapper className={classNames("is-ellipsis-overflow", "is-size-6")}>
2019-02-27 11:56:50 +01:00
{this.renderFileTitle(file)}
</TitleWrapper>
2019-05-26 14:10:59 +02:00
{this.renderChangeTag(file)}
</FullWidthTitleHeader>
{sideBySideToggle}
</FlexWrapLevel>
2019-02-26 15:00:05 +01:00
</div>
{body}
</DiffFilePanel>
);
2019-02-26 15:00:05 +01:00
}
}
export default withTranslation("repos")(DiffFile);