scm-ui: new repository layout

This commit is contained in:
Sebastian Sdorra
2019-10-07 10:57:09 +02:00
parent 09c7def874
commit c05798e254
417 changed files with 3620 additions and 52971 deletions

View File

@@ -0,0 +1,27 @@
//@flow
import React from "react";
import DiffFile from "./DiffFile";
import type {DiffObjectProps, File} from "./DiffTypes";
type Props = DiffObjectProps & {
diff: File[]
};
class Diff extends React.Component<Props> {
static defaultProps = {
sideBySide: false
};
render() {
const { diff, ...fileProps } = this.props;
return (
<>
{diff.map((file, index) => (
<DiffFile key={index} file={file} {...fileProps} />
))}
</>
);
}
}
export default Diff;

View File

@@ -0,0 +1,313 @@
//@flow
import React from "react";
import {
Change,
Diff as DiffComponent,
DiffObjectProps,
File,
getChangeKey,
Hunk
} from "react-diff-view";
import injectSheets from "react-jss";
import classNames from "classnames";
import { translate } from "react-i18next";
import { Button, ButtonGroup } from "../buttons";
import Tag from "../Tag";
const styles = {
panel: {
fontSize: "1rem"
},
/* breaks into a second row
when buttons and title become too long */
level: {
flexWrap: "wrap"
},
titleHeader: {
display: "flex",
maxWidth: "100%",
cursor: "pointer"
},
title: {
marginLeft: ".25rem",
fontSize: "1rem"
},
/* align child to right */
buttonHeader: {
display: "flex",
marginLeft: "auto"
},
hunkDivider: {
margin: ".5rem 0"
},
changeType: {
marginLeft: ".75rem"
},
diff: {
/* column sizing */
"& > colgroup .diff-gutter-col": {
width: "3.25rem"
},
/* prevent following content from moving down */
"& > .diff-gutter:empty:hover::after": {
fontSize: "0.7rem"
},
/* smaller font size for code */
"& .diff-line": {
fontSize: "0.75rem"
},
/* comment padding for sideBySide view */
"&.split .diff-widget-content .is-indented-line": {
paddingLeft: "3.25rem"
},
/* comment padding for combined view */
"&.unified .diff-widget-content .is-indented-line": {
paddingLeft: "6.5rem"
}
}
};
type Props = DiffObjectProps & {
file: File,
collapsible: true,
// context props
classes: any,
t: string => string
};
type State = {
collapsed: boolean,
sideBySide: boolean
};
class DiffFile extends React.Component<Props, State> {
constructor(props: Props) {
super(props);
this.state = {
collapsed: false,
sideBySide: false
};
}
toggleCollapse = () => {
if (this.props.collapsable) {
this.setState(state => ({
collapsed: !state.collapsed
}));
}
};
toggleSideBySide = () => {
this.setState(state => ({
sideBySide: !state.sideBySide
}));
};
setCollapse = (collapsed: boolean) => {
this.setState({
collapsed
});
};
createHunkHeader = (hunk: Hunk, i: number) => {
const { classes } = this.props;
if (i > 0) {
return <hr className={classes.hunkDivider} />;
}
return null;
};
collectHunkAnnotations = (hunk: Hunk) => {
const { annotationFactory, file } = this.props;
if (annotationFactory) {
return annotationFactory({
hunk,
file
});
}
};
handleClickEvent = (change: Change, hunk: Hunk) => {
const { file, onClick } = this.props;
const context = {
changeId: getChangeKey(change),
change,
hunk,
file
};
if (onClick) {
onClick(context);
}
};
createCustomEvents = (hunk: Hunk) => {
const { onClick } = this.props;
if (onClick) {
return {
gutter: {
onClick: (change: Change) => {
this.handleClickEvent(change, hunk);
}
}
};
}
};
renderHunk = (hunk: Hunk, i: number) => {
return (
<Hunk
key={hunk.content}
hunk={hunk}
header={this.createHunkHeader(hunk, i)}
widgets={this.collectHunkAnnotations(hunk)}
customEvents={this.createCustomEvents(hunk)}
/>
);
};
renderFileTitle = (file: any) => {
if (
file.oldPath !== file.newPath &&
(file.type === "copy" || file.type === "rename")
) {
return (
<>
{file.oldPath} <i className="fa fa-arrow-right" /> {file.newPath}
</>
);
} else if (file.type === "delete") {
return file.oldPath;
}
return file.newPath;
};
hoverFileTitle = (file: any) => {
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: any) => {
const { t, classes } = 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 is-outlined"
: value === "deleted"
? "danger is-outlined"
: "info is-outlined";
return (
<Tag
className={classNames(
"is-rounded",
"has-text-weight-normal",
classes.changeType
)}
color={color}
label={value}
/>
);
};
render() {
const {
file,
fileControlFactory,
fileAnnotationFactory,
collapsible,
classes,
t
} = this.props;
const { collapsed, sideBySide } = this.state;
const viewType = sideBySide ? "split" : "unified";
let body = null;
let icon = "fa fa-angle-right";
if (!collapsed) {
const fileAnnotations = fileAnnotationFactory
? fileAnnotationFactory(file)
: null;
icon = "fa fa-angle-down";
body = (
<div className="panel-block is-paddingless">
{fileAnnotations}
<DiffComponent
className={classNames(viewType, classes.diff)}
viewType={viewType}
>
{file.hunks.map(this.renderHunk)}
</DiffComponent>
</div>
);
}
const collapseIcon = collapsible ? <i className={icon} /> : null;
const fileControls = fileControlFactory
? fileControlFactory(file, this.setCollapse)
: null;
return (
<div className={classNames("panel", classes.panel)}>
<div className="panel-heading">
<div className={classNames("level", classes.level)}>
<div
className={classNames("level-left", classes.titleHeader)}
onClick={this.toggleCollapse}
title={this.hoverFileTitle(file)}
>
{collapseIcon}
<span
className={classNames("is-ellipsis-overflow", classes.title)}
>
{this.renderFileTitle(file)}
</span>
{this.renderChangeTag(file)}
</div>
<div className={classNames("level-right", classes.buttonHeader)}>
<ButtonGroup>
<Button
action={this.toggleSideBySide}
className="reduced-mobile"
>
<span className="icon is-small">
<i
className={classNames(
"fas",
sideBySide ? "fa-align-left" : "fa-columns"
)}
/>
</span>
<span>
{t(sideBySide ? "diff.combined" : "diff.sideBySide")}
</span>
</Button>
{fileControls}
</ButtonGroup>
</div>
</div>
</div>
{body}
</div>
);
}
}
export default injectSheets(styles)(translate("repos")(DiffFile));

View File

@@ -0,0 +1,74 @@
// @flow
import * as React from "react";
// We place the types here and not in @scm-manager/ui-types,
// because they represent not a real scm-manager related type.
// This types represents only the required types for the Diff related components,
// such as every other component does with its Props.
export type FileChangeType = "add" | "modify" | "delete" | "copy" | "rename";
export type File = {
hunks: Hunk[],
newEndingNewLine: boolean,
newMode?: string,
newPath: string,
newRevision?: string,
oldEndingNewLine: boolean,
oldMode?: string,
oldPath: string,
oldRevision?: string,
type: FileChangeType
};
export type Hunk = {
changes: Change[],
content: string
};
export type ChangeType = "insert" | "delete" | "normal";
export type Change = {
content: string,
isNormal?: boolean,
isInsert?: boolean,
isDelete?: boolean,
lineNumber?: number,
newLineNumber?: number,
oldLineNumber?: number,
type: ChangeType
};
export type BaseContext = {
hunk: Hunk,
file: File
};
export type AnnotationFactoryContext = BaseContext;
export type FileAnnotationFactory = (file: File) => React.Node[];
// key = change id, value = react component
export type AnnotationFactory = (
context: AnnotationFactoryContext
) => {
[string]: any
};
export type DiffEventContext = BaseContext & {
changeId: string,
change: Change
};
export type DiffEventHandler = (context: DiffEventContext) => void;
export type FileControlFactory = (file: File, setCollapseState: (boolean) => void) => ?React.Node;
export type DiffObjectProps = {
sideBySide: boolean,
onClick?: DiffEventHandler,
fileControlFactory?: FileControlFactory,
fileAnnotationFactory?: FileAnnotationFactory,
annotationFactory?: AnnotationFactory
};

View File

@@ -0,0 +1,81 @@
//@flow
import React from "react";
import {apiClient} from "../apiclient";
import ErrorNotification from "../ErrorNotification";
import parser from "gitdiff-parser";
import Loading from "../Loading";
import Diff from "./Diff";
import type {DiffObjectProps, File} from "./DiffTypes";
type Props = DiffObjectProps & {
url: string
};
type State = {
diff?: File[],
loading: boolean,
error?: Error
};
class LoadingDiff extends React.Component<Props, State> {
static defaultProps = {
sideBySide: false
};
constructor(props: Props) {
super(props);
this.state = {
loading: true
};
}
componentDidMount() {
this.fetchDiff();
}
componentDidUpdate(prevProps: Props) {
if(prevProps.url !== this.props.url){
this.fetchDiff();
}
}
fetchDiff = () => {
const { url } = this.props;
apiClient
.get(url)
.then(response => response.text())
.then(parser.parse)
// $FlowFixMe
.then((diff: File[]) => {
this.setState({
loading: false,
diff: diff
});
})
.catch(error => {
this.setState({
loading: false,
error
});
});
};
render() {
const { diff, loading, error } = this.state;
if (error) {
return <ErrorNotification error={error} />;
} else if (loading) {
return <Loading />;
} else if(!diff){
return null;
}
else {
return <Diff diff={diff} {...this.props} />;
}
}
}
export default LoadingDiff;

View File

@@ -0,0 +1,55 @@
//@flow
import React from "react";
import type { Changeset } from "@scm-manager/ui-types";
import { ExtensionPoint } from "@scm-manager/ui-extensions";
import { translate } from "react-i18next";
type Props = {
changeset: Changeset,
// context props
t: string => string
};
class ChangesetAuthor extends React.Component<Props> {
render() {
const { changeset } = this.props;
if (!changeset.author) {
return null;
}
const { name, mail } = changeset.author;
if (mail) {
return this.withExtensionPoint(this.renderWithMail(name, mail));
}
return this.withExtensionPoint(<>{name}</>);
}
renderWithMail(name: string, mail: string) {
const { t } = this.props;
return (
<a
href={"mailto:" + mail}
title={t("changeset.author.mailto") + " " + mail}
>
{name}
</a>
);
}
withExtensionPoint(child: any) {
const { t } = this.props;
return (
<>
{t("changeset.author.prefix")} {child}
<ExtensionPoint
name="changesets.author.suffix"
props={{ changeset: this.props.changeset }}
renderAll={true}
/>
</>
);
}
}
export default translate("repos")(ChangesetAuthor);

View File

@@ -0,0 +1,40 @@
//@flow
import React from "react";
import type { Changeset, Repository } from "@scm-manager/ui-types";
import { ButtonAddons, Button } from "../../buttons";
import { createChangesetLink, createSourcesLink } from "./changesets";
import { translate } from "react-i18next";
type Props = {
repository: Repository,
changeset: Changeset,
// context props
t: string => string
};
class ChangesetButtonGroup extends React.Component<Props> {
render() {
const { repository, changeset, t } = this.props;
const changesetLink = createChangesetLink(repository, changeset);
const sourcesLink = createSourcesLink(repository, changeset);
return (
<ButtonAddons className="is-marginless">
<Button
link={changesetLink}
icon="exchange-alt"
label={t("changeset.buttons.details")}
reducedMobile={true}
/>
<Button
link={sourcesLink}
icon="code"
label={t("changeset.buttons.sources")}
reducedMobile={true}
/>
</ButtonAddons>
);
}
}
export default translate("repos")(ChangesetButtonGroup);

View File

@@ -0,0 +1,37 @@
//@flow
import React from "react";
import type { Changeset } from "@scm-manager/ui-types";
import LoadingDiff from "../LoadingDiff";
import Notification from "../../Notification";
import {translate} from "react-i18next";
type Props = {
changeset: Changeset,
// context props
t: string => string
};
class ChangesetDiff extends React.Component<Props> {
isDiffSupported(changeset: Changeset) {
return !!changeset._links.diff;
}
createUrl(changeset: Changeset) {
return changeset._links.diff.href + "?format=GIT";
}
render() {
const { changeset, t } = this.props;
if (!this.isDiffSupported(changeset)) {
return <Notification type="danger">{t("changeset.diffNotSupported")}</Notification>;
} else {
const url = this.createUrl(changeset);
return <LoadingDiff url={url} />;
}
}
}
export default translate("repos")(ChangesetDiff);

View File

@@ -0,0 +1,46 @@
//@flow
import {Link} from "react-router-dom";
import React from "react";
import type {Changeset, Repository} from "@scm-manager/ui-types";
import { createChangesetLink } from "./changesets";
type Props = {
repository: Repository,
changeset: Changeset,
link: boolean
};
export default class ChangesetId extends React.Component<Props> {
static defaultProps = {
link: true
};
shortId = (changeset: Changeset) => {
return changeset.id.substr(0, 7);
};
renderLink = () => {
const { repository, changeset } = this.props;
const link = createChangesetLink(repository, changeset);
return (
<Link to={link}>
{this.shortId(changeset)}
</Link>
);
};
renderText = () => {
const { changeset } = this.props;
return this.shortId(changeset);
};
render() {
const { link } = this.props;
if (link) {
return this.renderLink();
}
return this.renderText();
}
}

View File

@@ -0,0 +1,28 @@
// @flow
import ChangesetRow from "./ChangesetRow";
import React from "react";
import type { Changeset, Repository } from "@scm-manager/ui-types";
type Props = {
repository: Repository,
changesets: Changeset[]
};
class ChangesetList extends React.Component<Props> {
render() {
const { repository, changesets } = this.props;
const content = changesets.map(changeset => {
return (
<ChangesetRow
key={changeset.id}
repository={repository}
changeset={changeset}
/>
);
});
return <>{content}</>;
}
}
export default ChangesetList;

View File

@@ -0,0 +1,138 @@
//@flow
import React from "react";
import type { Changeset, Repository } from "@scm-manager/ui-types";
import classNames from "classnames";
import { Interpolate, translate } from "react-i18next";
import ChangesetId from "./ChangesetId";
import injectSheet from "react-jss";
import { DateFromNow } from "../..";
import ChangesetAuthor from "./ChangesetAuthor";
import { parseDescription } from "./changesets";
import { AvatarWrapper, AvatarImage } from "../../avatar";
import { ExtensionPoint } from "@scm-manager/ui-extensions";
import ChangesetTags from "./ChangesetTags";
import ChangesetButtonGroup from "./ChangesetButtonGroup";
const styles = {
changeset: {
// & references parent rule
// have a look at https://cssinjs.org/jss-plugin-nested?v=v10.0.0-alpha.9
"& + &": {
borderTop: "1px solid rgba(219, 219, 219, 0.5)",
marginTop: "1rem",
paddingTop: "1rem"
}
},
avatarFigure: {
marginTop: ".5rem",
marginRight: ".5rem"
},
avatarImage: {
height: "35px",
width: "35px"
},
metadata: {
marginLeft: 0
},
authorMargin: {
marginTop: "0.5rem"
},
isVcentered: {
alignSelf: "center"
},
flexVcenter: {
display: "flex",
alignItems: "center",
justifyContent: "flex-end"
}
};
type Props = {
repository: Repository,
changeset: Changeset,
t: any,
classes: any
};
class ChangesetRow extends React.Component<Props> {
createChangesetId = (changeset: Changeset) => {
const { repository } = this.props;
return <ChangesetId changeset={changeset} repository={repository} />;
};
render() {
const { repository, changeset, classes } = this.props;
const description = parseDescription(changeset.description);
const changesetId = this.createChangesetId(changeset);
const dateFromNow = <DateFromNow date={changeset.date} />;
return (
<div className={classes.changeset}>
<div className="columns is-gapless is-mobile">
<div className="column is-three-fifths">
<div className="columns is-gapless">
<div className="column is-four-fifths">
<div className="media">
<AvatarWrapper>
<figure
className={classNames(classes.avatarFigure, "media-left")}
>
<div className={classNames("image", classes.avatarImage)}>
<AvatarImage person={changeset.author} />
</div>
</figure>
</AvatarWrapper>
<div className={classNames(classes.metadata, "media-right")}>
<h4 className="has-text-weight-bold is-ellipsis-overflow">
<ExtensionPoint
name="changeset.description"
props={{ changeset, value: description.title }}
renderAll={false}
>
{description.title}
</ExtensionPoint>
</h4>
<p className="is-hidden-touch">
<Interpolate
i18nKey="changeset.summary"
id={changesetId}
time={dateFromNow}
/>
</p>
<p className="is-hidden-desktop">
<Interpolate
i18nKey="changeset.shortSummary"
id={changesetId}
time={dateFromNow}
/>
</p>
<p className={classNames("is-size-7", classes.authorMargin)}>
<ChangesetAuthor changeset={changeset} />
</p>
</div>
</div>
</div>
<div className={classNames("column", classes.isVcentered)}>
<ChangesetTags changeset={changeset} />
</div>
</div>
</div>
<div className={classNames("column", classes.flexVcenter)}>
<ChangesetButtonGroup
repository={repository}
changeset={changeset}
/>
<ExtensionPoint
name="changeset.right"
props={{ repository, changeset }}
renderAll={true}
/>
</div>
</div>
</div>
);
}
}
export default injectSheet(styles)(translate("repos")(ChangesetRow));

View File

@@ -0,0 +1,17 @@
//@flow
import React from "react";
import type { Tag } from "@scm-manager/ui-types";
import ChangesetTagBase from "./ChangesetTagBase";
type Props = {
tag: Tag
};
class ChangesetTag extends React.Component<Props> {
render() {
const { tag } = this.props;
return <ChangesetTagBase icon="tag" label={tag.name} />;
}
}
export default ChangesetTag;

View File

@@ -0,0 +1,19 @@
//@flow
import React from "react";
import Tag from "../../Tag";
type Props = {
icon: string,
label: string
};
class ChangesetTagBase extends React.Component<Props> {
render() {
const { icon, label } = this.props;
return (
<Tag color="info" icon={icon} label={label} />
);
}
}
export default ChangesetTagBase;

View File

@@ -0,0 +1,31 @@
//@flow
import React from "react";
import type { Changeset} from "@scm-manager/ui-types";
import ChangesetTag from "./ChangesetTag";
import ChangesetTagsCollapsed from "./ChangesetTagsCollapsed";
type Props = {
changeset: Changeset
};
class ChangesetTags extends React.Component<Props> {
getTags = () => {
const { changeset } = this.props;
return changeset._embedded.tags || [];
};
render() {
const tags = this.getTags();
if (tags.length === 1) {
return <ChangesetTag tag={tags[0]} />;
} else if (tags.length > 1) {
return <ChangesetTagsCollapsed tags={tags} />;
} else {
return null;
}
}
}
export default ChangesetTags;

View File

@@ -0,0 +1,30 @@
//@flow
import React from "react";
import { translate } from "react-i18next";
import type { Tag } from "@scm-manager/ui-types";
import Tooltip from "../../Tooltip";
import ChangesetTagBase from "./ChangesetTagBase";
type Props = {
tags: Tag[],
// context props
t: string => string
};
class ChangesetTagsCollapsed extends React.Component<Props> {
render() {
const { tags, t } = this.props;
const message = tags.map(tag => tag.name).join(", ");
return (
<Tooltip location="top" message={message}>
<ChangesetTagBase
icon="tags"
label={tags.length + " " + t("changeset.tags")}
/>
</Tooltip>
);
}
}
export default translate("repos")(ChangesetTagsCollapsed);

View File

@@ -0,0 +1,35 @@
// @flow
import type { Changeset, Repository } from "@scm-manager/ui-types";
export type Description = {
title: string,
message: string
};
export function createChangesetLink(repository: Repository, changeset: Changeset) {
return `/repo/${repository.namespace}/${repository.name}/changeset/${changeset.id}`;
}
export function createSourcesLink(repository: Repository, changeset: Changeset) {
return `/repo/${repository.namespace}/${repository.name}/sources/${changeset.id}`;
}
export function parseDescription(description?: string): Description {
const desc = description ? description : "";
const lineBreak = desc.indexOf("\n");
let title;
let message = "";
if (lineBreak > 0) {
title = desc.substring(0, lineBreak);
message = desc.substring(lineBreak + 1);
} else {
title = desc;
}
return {
title,
message
};
}

View File

@@ -0,0 +1,22 @@
// @flow
import {parseDescription} from "./changesets";
describe("parseDescription tests", () => {
it("should return a description with title and message", () => {
const desc = parseDescription("Hello\nTrillian");
expect(desc.title).toBe("Hello");
expect(desc.message).toBe("Trillian");
});
it("should return a description with title and without message", () => {
const desc = parseDescription("Hello Trillian");
expect(desc.title).toBe("Hello Trillian");
});
it("should return an empty description for undefined", () => {
const desc = parseDescription();
expect(desc.title).toBe("");
expect(desc.message).toBe("");
});
});

View File

@@ -0,0 +1,13 @@
// @flow
import * as changesets from "./changesets";
export { changesets };
export { default as ChangesetAuthor } from "./ChangesetAuthor";
export { default as ChangesetButtonGroup } from "./ChangesetButtonGroup";
export { default as ChangesetDiff } from "./ChangesetDiff";
export { default as ChangesetId } from "./ChangesetId";
export { default as ChangesetList } from "./ChangesetList";
export { default as ChangesetRow } from "./ChangesetRow";
export { default as ChangesetTag } from "./ChangesetTag";
export { default as ChangesetTags } from "./ChangesetTags";
export { default as ChangesetTagsCollapsed } from "./ChangesetTagsCollapsed";

View File

@@ -0,0 +1,18 @@
// @flow
import type { BaseContext, File, Hunk } from "./DiffTypes";
export function getPath(file: File) {
if (file.type === "delete") {
return file.oldPath;
}
return file.newPath;
}
export function createHunkIdentifier(file: File, hunk: Hunk) {
const path = getPath(file);
return `${file.type}_${path}_${hunk.content}`;
}
export function createHunkIdentifierFromContext(ctx: BaseContext) {
return createHunkIdentifier(ctx.file, ctx.hunk);
}

View File

@@ -0,0 +1,76 @@
// @flow
import type { File, FileChangeType, Hunk } from "./DiffTypes";
import {
getPath,
createHunkIdentifier,
createHunkIdentifierFromContext
} from "./diffs";
describe("tests for diff util functions", () => {
const file = (
type: FileChangeType,
oldPath: string,
newPath: string
): File => {
return {
hunks: [],
type: type,
oldPath,
newPath,
newEndingNewLine: true,
oldEndingNewLine: true
};
};
const add = (path: string) => {
return file("add", "/dev/null", path);
};
const rm = (path: string) => {
return file("delete", path, "/dev/null");
};
const modify = (path: string) => {
return file("modify", path, path);
};
const createHunk = (content: string): Hunk => {
return {
content,
changes: []
};
};
describe("getPath tests", () => {
it("should pick the new path, for type add", () => {
const file = add("/etc/passwd");
const path = getPath(file);
expect(path).toBe("/etc/passwd");
});
it("should pick the old path, for type delete", () => {
const file = rm("/etc/passwd");
const path = getPath(file);
expect(path).toBe("/etc/passwd");
});
});
describe("createHunkIdentifier tests", () => {
it("should create identifier", () => {
const file = modify("/etc/passwd");
const hunk = createHunk("@@ -1,18 +1,15 @@");
const identifier = createHunkIdentifier(file, hunk);
expect(identifier).toBe("modify_/etc/passwd_@@ -1,18 +1,15 @@");
});
});
describe("createHunkIdentifierFromContext tests", () => {
it("should create identifier", () => {
const identifier = createHunkIdentifierFromContext({
file: rm("/etc/passwd"),
hunk: createHunk("@@ -1,42 +1,39 @@")
});
expect(identifier).toBe("delete_/etc/passwd_@@ -1,42 +1,39 @@");
});
});
});

View File

@@ -0,0 +1,23 @@
// @flow
import * as diffs from "./diffs";
export { diffs };
export * from "./changesets";
export { default as Diff } from "./Diff";
export { default as DiffFile } from "./DiffFile";
export { default as LoadingDiff } from "./LoadingDiff";
export type {
File,
FileChangeType,
Hunk,
Change,
ChangeType,
BaseContext,
AnnotationFactory,
AnnotationFactoryContext,
DiffEventHandler,
DiffEventContext
} from "./DiffTypes";