mirror of
https://github.com/scm-manager/scm-manager.git
synced 2025-11-08 06:25:45 +01:00
562 lines
16 KiB
TypeScript
562 lines
16 KiB
TypeScript
/*
|
|
* 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 = () => {
|
|
const { onCollapseStateChange } = this.props;
|
|
const { file } = this.state;
|
|
if (this.hasContent(file)) {
|
|
if (onCollapseStateChange) {
|
|
onCollapseStateChange(file);
|
|
} else {
|
|
this.setState((state) => ({
|
|
collapsed: !state.collapsed,
|
|
}));
|
|
}
|
|
}
|
|
};
|
|
|
|
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)}
|
|
>
|
|
{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);
|