mirror of
https://github.com/scm-manager/scm-manager.git
synced 2025-11-08 14:35:45 +01:00
Merged in feature/diff_annotations (pull request #211)
Feature/diff annotations
This commit is contained in:
@@ -2,9 +2,17 @@
|
|||||||
import React from "react";
|
import React from "react";
|
||||||
import Button, { type ButtonProps } from "./Button";
|
import Button, { type ButtonProps } from "./Button";
|
||||||
|
|
||||||
class SubmitButton extends React.Component<ButtonProps> {
|
type SubmitButtonProps = ButtonProps & {
|
||||||
|
scrollToTop: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
class SubmitButton extends React.Component<SubmitButtonProps> {
|
||||||
|
static defaultProps = {
|
||||||
|
scrollToTop: true
|
||||||
|
};
|
||||||
|
|
||||||
render() {
|
render() {
|
||||||
const { action } = this.props;
|
const { action, scrollToTop } = this.props;
|
||||||
return (
|
return (
|
||||||
<Button
|
<Button
|
||||||
type="submit"
|
type="submit"
|
||||||
@@ -12,9 +20,11 @@ class SubmitButton extends React.Component<ButtonProps> {
|
|||||||
{...this.props}
|
{...this.props}
|
||||||
action={(event) => {
|
action={(event) => {
|
||||||
if (action) {
|
if (action) {
|
||||||
action(event)
|
action(event);
|
||||||
}
|
}
|
||||||
|
if (scrollToTop) {
|
||||||
window.scrollTo(0, 0);
|
window.scrollTo(0, 0);
|
||||||
|
}
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -37,3 +37,16 @@ export * from "./layout";
|
|||||||
export * from "./modals";
|
export * from "./modals";
|
||||||
export * from "./navigation";
|
export * from "./navigation";
|
||||||
export * from "./repos";
|
export * from "./repos";
|
||||||
|
|
||||||
|
// not sure if it is required
|
||||||
|
export type {
|
||||||
|
File,
|
||||||
|
FileChangeType,
|
||||||
|
Hunk,
|
||||||
|
Change,
|
||||||
|
BaseContext,
|
||||||
|
AnnotationFactory,
|
||||||
|
AnnotationFactoryContext,
|
||||||
|
DiffEventHandler,
|
||||||
|
DiffEventContext
|
||||||
|
} from "./repos";
|
||||||
|
|||||||
@@ -1,32 +1,27 @@
|
|||||||
//@flow
|
//@flow
|
||||||
import React from "react";
|
import React from "react";
|
||||||
import DiffFile from "./DiffFile";
|
import DiffFile from "./DiffFile";
|
||||||
|
import type { DiffObjectProps } from "./DiffTypes";
|
||||||
|
|
||||||
type Props = {
|
type Props = DiffObjectProps & {
|
||||||
diff: any,
|
diff: any
|
||||||
sideBySide: boolean
|
|
||||||
};
|
};
|
||||||
|
|
||||||
class Diff extends React.Component<Props> {
|
class Diff extends React.Component<Props> {
|
||||||
|
|
||||||
static defaultProps = {
|
static defaultProps = {
|
||||||
sideBySide: false
|
sideBySide: false
|
||||||
};
|
};
|
||||||
|
|
||||||
renderFile = (file: any, i: number) => {
|
|
||||||
const { sideBySide } = this.props;
|
|
||||||
return <DiffFile key={i} file={file} sideBySide={sideBySide} />;
|
|
||||||
};
|
|
||||||
|
|
||||||
render() {
|
render() {
|
||||||
const { diff } = this.props;
|
const { diff, ...fileProps } = this.props;
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
{diff.map(this.renderFile)}
|
{diff.map((file, index) => (
|
||||||
|
<DiffFile key={index} file={file} {...fileProps} />
|
||||||
|
))}
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export default Diff;
|
export default Diff;
|
||||||
|
|||||||
@@ -1,15 +1,22 @@
|
|||||||
//@flow
|
//@flow
|
||||||
import React from "react";
|
import React from "react";
|
||||||
import { Hunk, Diff as DiffComponent } from "react-diff-view";
|
import {
|
||||||
|
Hunk,
|
||||||
|
Diff as DiffComponent,
|
||||||
|
getChangeKey,
|
||||||
|
Change,
|
||||||
|
DiffObjectProps,
|
||||||
|
File
|
||||||
|
} from "react-diff-view";
|
||||||
import injectSheets from "react-jss";
|
import injectSheets from "react-jss";
|
||||||
import classNames from "classnames";
|
import classNames from "classnames";
|
||||||
import {translate} from "react-i18next";
|
import { translate } from "react-i18next";
|
||||||
|
|
||||||
const styles = {
|
const styles = {
|
||||||
panel: {
|
panel: {
|
||||||
fontSize: "1rem"
|
fontSize: "1rem"
|
||||||
},
|
},
|
||||||
header: {
|
titleHeader: {
|
||||||
cursor: "pointer"
|
cursor: "pointer"
|
||||||
},
|
},
|
||||||
title: {
|
title: {
|
||||||
@@ -18,23 +25,24 @@ const styles = {
|
|||||||
},
|
},
|
||||||
hunkDivider: {
|
hunkDivider: {
|
||||||
margin: ".5rem 0"
|
margin: ".5rem 0"
|
||||||
|
},
|
||||||
|
changeType: {
|
||||||
|
marginLeft: ".75rem"
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
type Props = {
|
type Props = DiffObjectProps & {
|
||||||
file: any,
|
file: File,
|
||||||
sideBySide: boolean,
|
|
||||||
// context props
|
// context props
|
||||||
classes: any,
|
classes: any,
|
||||||
t: string => string
|
t: string => string
|
||||||
}
|
};
|
||||||
|
|
||||||
type State = {
|
type State = {
|
||||||
collapsed: boolean
|
collapsed: boolean
|
||||||
}
|
};
|
||||||
|
|
||||||
class DiffFile extends React.Component<Props, State> {
|
class DiffFile extends React.Component<Props, State> {
|
||||||
|
|
||||||
constructor(props: Props) {
|
constructor(props: Props) {
|
||||||
super(props);
|
super(props);
|
||||||
this.state = {
|
this.state = {
|
||||||
@@ -43,23 +51,77 @@ class DiffFile extends React.Component<Props, State> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
toggleCollapse = () => {
|
toggleCollapse = () => {
|
||||||
this.setState((state) => ({
|
this.setState(state => ({
|
||||||
collapsed: ! state.collapsed
|
collapsed: !state.collapsed
|
||||||
}));
|
}));
|
||||||
};
|
};
|
||||||
|
|
||||||
renderHunk = (hunk: any, i: number) => {
|
createHunkHeader = (hunk: Hunk, i: number) => {
|
||||||
const { classes } = this.props;
|
const { classes } = this.props;
|
||||||
let header = null;
|
|
||||||
if (i > 0) {
|
if (i > 0) {
|
||||||
header = <hr className={classes.hunkDivider} />;
|
return <hr className={classes.hunkDivider} />;
|
||||||
}
|
}
|
||||||
return <Hunk key={hunk.content} hunk={hunk} header={header} />;
|
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) => {
|
renderFileTitle = (file: any) => {
|
||||||
if (file.oldPath !== file.newPath && (file.type === "copy" || file.type === "rename")) {
|
if (
|
||||||
return (<>{file.oldPath} <i className="fa fa-arrow-right" /> {file.newPath}</>);
|
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") {
|
} else if (file.type === "delete") {
|
||||||
return file.oldPath;
|
return file.oldPath;
|
||||||
}
|
}
|
||||||
@@ -73,48 +135,61 @@ class DiffFile extends React.Component<Props, State> {
|
|||||||
if (key === value) {
|
if (key === value) {
|
||||||
value = file.type;
|
value = file.type;
|
||||||
}
|
}
|
||||||
return (
|
return <span className="tag is-info has-text-weight-normal">{value}</span>;
|
||||||
<span className="tag is-info has-text-weight-normal">
|
|
||||||
{value}
|
|
||||||
</span>
|
|
||||||
);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
render() {
|
render() {
|
||||||
const { file, sideBySide, classes } = this.props;
|
const {
|
||||||
|
file,
|
||||||
|
fileControlFactory,
|
||||||
|
fileAnnotationFactory,
|
||||||
|
sideBySide,
|
||||||
|
classes
|
||||||
|
} = this.props;
|
||||||
const { collapsed } = this.state;
|
const { collapsed } = this.state;
|
||||||
const viewType = sideBySide ? "split" : "unified";
|
const viewType = sideBySide ? "split" : "unified";
|
||||||
|
|
||||||
let body = null;
|
let body = null;
|
||||||
let icon = "fa fa-angle-right";
|
let icon = "fa fa-angle-right";
|
||||||
if (!collapsed) {
|
if (!collapsed) {
|
||||||
|
const fileAnnotations = fileAnnotationFactory
|
||||||
|
? fileAnnotationFactory(file)
|
||||||
|
: null;
|
||||||
icon = "fa fa-angle-down";
|
icon = "fa fa-angle-down";
|
||||||
body = (
|
body = (
|
||||||
<div className="panel-block is-paddingless is-size-7">
|
<div className="panel-block is-paddingless is-size-7">
|
||||||
|
{fileAnnotations}
|
||||||
<DiffComponent viewType={viewType}>
|
<DiffComponent viewType={viewType}>
|
||||||
{ file.hunks.map(this.renderHunk) }
|
{file.hunks.map(this.renderHunk)}
|
||||||
</DiffComponent>
|
</DiffComponent>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const fileControls = fileControlFactory ? fileControlFactory(file) : null;
|
||||||
return (
|
return (
|
||||||
<div className={classNames("panel", classes.panel)}>
|
<div className={classNames("panel", classes.panel)}>
|
||||||
<div className={classNames("panel-heading", classes.header)} onClick={this.toggleCollapse}>
|
<div className="panel-heading">
|
||||||
<div className="level">
|
<div className="level">
|
||||||
<div className="level-left">
|
<div
|
||||||
<i className={icon} /><span className={classes.title}>{this.renderFileTitle(file)}</span>
|
className={classNames("level-left", classes.titleHeader)}
|
||||||
</div>
|
onClick={this.toggleCollapse}
|
||||||
<div className="level-right">
|
>
|
||||||
|
<i className={icon} />
|
||||||
|
<span className={classes.title}>
|
||||||
|
{this.renderFileTitle(file)}
|
||||||
|
</span>
|
||||||
|
<span className={classes.changeType}>
|
||||||
{this.renderChangeTag(file)}
|
{this.renderChangeTag(file)}
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
<div className="level-right">{fileControls}</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{body}
|
{body}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export default injectSheets(styles)(translate("repos")(DiffFile));
|
export default injectSheets(styles)(translate("repos")(DiffFile));
|
||||||
|
|||||||
@@ -0,0 +1,69 @@
|
|||||||
|
// @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 Change = {
|
||||||
|
content: string,
|
||||||
|
isNormal: boolean,
|
||||||
|
newLineNumber: number,
|
||||||
|
oldLineNumber: number,
|
||||||
|
type: string
|
||||||
|
};
|
||||||
|
|
||||||
|
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) => ?React.Node;
|
||||||
|
|
||||||
|
export type DiffObjectProps = {
|
||||||
|
sideBySide: boolean,
|
||||||
|
onClick?: DiffEventHandler,
|
||||||
|
fileControlFactory?: FileControlFactory,
|
||||||
|
fileAnnotationFactory?: FileAnnotationFactory,
|
||||||
|
annotationFactory?: AnnotationFactory
|
||||||
|
};
|
||||||
@@ -6,10 +6,10 @@ import parser from "gitdiff-parser";
|
|||||||
|
|
||||||
import Loading from "../Loading";
|
import Loading from "../Loading";
|
||||||
import Diff from "./Diff";
|
import Diff from "./Diff";
|
||||||
|
import type {DiffObjectProps} from "./DiffTypes";
|
||||||
|
|
||||||
type Props = {
|
type Props = DiffObjectProps & {
|
||||||
url: string,
|
url: string
|
||||||
sideBySide: boolean
|
|
||||||
};
|
};
|
||||||
|
|
||||||
type State = {
|
type State = {
|
||||||
@@ -71,7 +71,7 @@ class LoadingDiff extends React.Component<Props, State> {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
else {
|
else {
|
||||||
return <Diff diff={diff} />;
|
return <Diff diff={diff} {...this.props} />;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
18
scm-ui-components/packages/ui-components/src/repos/diffs.js
Normal file
18
scm-ui-components/packages/ui-components/src/repos/diffs.js
Normal 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);
|
||||||
|
}
|
||||||
@@ -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 @@");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,5 +1,20 @@
|
|||||||
// @flow
|
// @flow
|
||||||
|
import * as diffs from "./diffs";
|
||||||
|
export { diffs };
|
||||||
|
|
||||||
export * from "./changesets";
|
export * from "./changesets";
|
||||||
|
|
||||||
export { default as Diff } from "./Diff";
|
export { default as Diff } from "./Diff";
|
||||||
export { default as LoadingDiff } from "./LoadingDiff";
|
export { default as LoadingDiff } from "./LoadingDiff";
|
||||||
|
|
||||||
|
export type {
|
||||||
|
File,
|
||||||
|
FileChangeType,
|
||||||
|
Hunk,
|
||||||
|
Change,
|
||||||
|
BaseContext,
|
||||||
|
AnnotationFactory,
|
||||||
|
AnnotationFactoryContext,
|
||||||
|
DiffEventHandler,
|
||||||
|
DiffEventContext
|
||||||
|
} from "./DiffTypes";
|
||||||
|
|||||||
Reference in New Issue
Block a user