Files
SCM-Manager/scm-ui/ui-components/src/repos/LazyDiffFile.tsx
Florian Scholdei 63d6c765ea Correct z-index equal weighting in diff header
Committed-by: Eduard Heimbuch <eduard.heimbuch@cloudogu.com>
2023-01-31 10:11:08 +01:00

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