Feature Partial Diff (#1581)

With this pull request, diffs for Git are loaded in chunks. This means, that for diffs with a lot of files only a part of them are loaded. In the UI a button will be displayed to load more. In the REST API, the number of files can be specified. This only works for diffs, that are delivered as "parsed" diffs. Currently, this is only available for Git.

Co-authored-by: Sebastian Sdorra <sebastian.sdorra@cloudogu.com>
This commit is contained in:
René Pfeuffer
2021-03-12 13:52:17 +01:00
committed by GitHub
parent e66553705f
commit 84c8e02bf1
37 changed files with 900 additions and 223 deletions

View File

@@ -30,14 +30,14 @@ import simpleDiff from "../__resources__/Diff.simple";
import hunksDiff from "../__resources__/Diff.hunks";
import binaryDiff from "../__resources__/Diff.binary";
import markdownDiff from "../__resources__/Diff.markdown";
import { DiffEventContext, File, FileControlFactory } from "./DiffTypes";
import { DiffEventContext, FileControlFactory } from "./DiffTypes";
import Toast from "../toast/Toast";
import { getPath } from "./diffs";
import DiffButton from "./DiffButton";
import styled from "styled-components";
import { MemoryRouter } from "react-router-dom";
import { one, two } from "../__resources__/changesets";
import { Changeset } from "@scm-manager/ui-types";
import { Changeset, FileDiff } from "@scm-manager/ui-types";
import JumpToFileButton from "./JumpToFileButton";
const diffFiles = parser.parse(simpleDiff);
@@ -141,7 +141,7 @@ storiesOf("Diff", module)
return <Diff diff={binaryDiffFiles} />;
})
.add("SyntaxHighlighting", () => {
const filesWithLanguage = diffFiles.map((file: File) => {
const filesWithLanguage = diffFiles.map((file: FileDiff) => {
const ext = getPath(file).split(".")[1];
if (ext === "tsx") {
file.language = "typescript";
@@ -160,7 +160,7 @@ storiesOf("Diff", module)
<Diff diff={diffFiles} defaultCollapse={(oldPath, newPath) => oldPath.endsWith(".java")} />
))
.add("Expandable", () => {
const filesWithLanguage = diffFiles.map((file: File) => {
const filesWithLanguage = diffFiles.map((file: FileDiff) => {
file._links = { lines: { href: "http://example.com/" } };
return file;
});

View File

@@ -23,7 +23,8 @@
*/
import React from "react";
import DiffFile from "./DiffFile";
import { DiffObjectProps, File, FileControlFactory } from "./DiffTypes";
import { DiffObjectProps, FileControlFactory } from "./DiffTypes";
import { FileDiff } from "@scm-manager/ui-types";
import { escapeWhitespace } from "./diffs";
import Notification from "../Notification";
import { WithTranslation, withTranslation } from "react-i18next";
@@ -32,7 +33,7 @@ import { RouteComponentProps, withRouter } from "react-router-dom";
type Props = RouteComponentProps &
WithTranslation &
DiffObjectProps & {
diff: File[];
diff: FileDiff[];
fileControlFactory?: FileControlFactory;
};

View File

@@ -23,7 +23,7 @@
*/
import fetchMock from "fetch-mock";
import DiffExpander from "./DiffExpander";
import { File, Hunk } from "./DiffTypes";
import { FileDiff, Hunk } from "@scm-manager/ui-types";
const HUNK_0: Hunk = {
content: "@@ -1,8 +1,8 @@",
@@ -94,7 +94,7 @@ const HUNK_3: Hunk = {
{ content: "line", type: "normal", oldLineNumber: 38, newLineNumber: 40, isNormal: true }
]
};
const TEST_CONTENT_WITH_HUNKS: File = {
const TEST_CONTENT_WITH_HUNKS: FileDiff = {
hunks: [HUNK_0, HUNK_1, HUNK_2, HUNK_3],
newEndingNewLine: true,
newPath: "src/main/js/CommitMessage.js",
@@ -112,7 +112,7 @@ const TEST_CONTENT_WITH_HUNKS: File = {
}
};
const TEST_CONTENT_WITH_NEW_BINARY_FILE: File = {
const TEST_CONTENT_WITH_NEW_BINARY_FILE: FileDiff = {
oldPath: "/dev/null",
newPath: "src/main/fileUploadV2.png",
oldEndingNewLine: true,
@@ -122,7 +122,7 @@ const TEST_CONTENT_WITH_NEW_BINARY_FILE: File = {
type: "add"
};
const TEST_CONTENT_WITH_NEW_TEXT_FILE: File = {
const TEST_CONTENT_WITH_NEW_TEXT_FILE: FileDiff = {
oldPath: "/dev/null",
newPath: "src/main/markdown/README.md",
oldEndingNewLine: true,
@@ -151,7 +151,7 @@ const TEST_CONTENT_WITH_NEW_TEXT_FILE: File = {
}
};
const TEST_CONTENT_WITH_DELETED_TEXT_FILE: File = {
const TEST_CONTENT_WITH_DELETED_TEXT_FILE: FileDiff = {
oldPath: "README.md",
newPath: "/dev/null",
oldEndingNewLine: true,
@@ -171,7 +171,7 @@ const TEST_CONTENT_WITH_DELETED_TEXT_FILE: File = {
_links: { lines: { href: "http://localhost:8081/dev/null?start={start}&end={end}", templated: true } }
};
const TEST_CONTENT_WITH_DELETED_LINES_AT_END: File = {
const TEST_CONTENT_WITH_DELETED_LINES_AT_END: FileDiff = {
oldPath: "pom.xml",
newPath: "pom.xml",
oldEndingNewLine: true,
@@ -214,7 +214,7 @@ const TEST_CONTENT_WITH_DELETED_LINES_AT_END: File = {
}
};
const TEST_CONTENT_WITH_ALL_LINES_REMOVED_FROM_FILE: File = {
const TEST_CONTENT_WITH_ALL_LINES_REMOVED_FROM_FILE: FileDiff = {
oldPath: "pom.xml",
newPath: "pom.xml",
oldEndingNewLine: true,
@@ -281,7 +281,7 @@ describe("with hunks the diff expander", () => {
const expandedHunk = diffExpander.getHunk(1).hunk;
const subsequentHunk = diffExpander.getHunk(2).hunk;
fetchMock.get("http://localhost:8081/scm/api/v2/content/abc/CommitMessage.js?start=20&end=21", "new line 1");
let newFile: File;
let newFile: FileDiff;
await diffExpander
.getHunk(1)
.expandBottom(1)
@@ -306,7 +306,7 @@ describe("with hunks the diff expander", () => {
"http://localhost:8081/scm/api/v2/content/abc/CommitMessage.js?start=8&end=13",
"new line 9\nnew line 10\nnew line 11\nnew line 12\nnew line 13"
);
let newFile: File;
let newFile: FileDiff;
await diffExpander
.getHunk(1)
.expandHead(5)
@@ -335,7 +335,7 @@ describe("with hunks the diff expander", () => {
"http://localhost:8081/scm/api/v2/content/abc/CommitMessage.js?start=40&end=50",
"new line 40\nnew line 41\nnew line 42"
);
let newFile: File;
let newFile: FileDiff;
await diffExpander
.getHunk(3)
.expandBottom(10)
@@ -348,7 +348,7 @@ describe("with hunks the diff expander", () => {
"http://localhost:8081/scm/api/v2/content/abc/CommitMessage.js?start=40&end=-1",
"new line 40\nnew line 41\nnew line 42"
);
let newFile: File;
let newFile: FileDiff;
await diffExpander
.getHunk(3)
.expandBottom(-1)

View File

@@ -23,13 +23,12 @@
*/
import { apiClient } from "@scm-manager/ui-components";
import { Change, File, Hunk } from "./DiffTypes";
import { Link } from "@scm-manager/ui-types";
import { Change, FileDiff, Hunk, Link } from "@scm-manager/ui-types";
class DiffExpander {
file: File;
file: FileDiff;
constructor(file: File) {
constructor(file: FileDiff) {
this.file = file;
}
@@ -73,7 +72,7 @@ class DiffExpander {
}
};
expandHead: (n: number, count: number) => Promise<File> = (n, count) => {
expandHead: (n: number, count: number) => Promise<FileDiff> = (n, count) => {
const start = this.minLineNumber(n) - Math.min(count, this.computeMaxExpandHeadRange(n)) - 1;
const end = this.minLineNumber(n) - 1;
return this.loadLines(start, end).then(lines => {
@@ -90,7 +89,7 @@ class DiffExpander {
});
};
expandBottom: (n: number, count: number) => Promise<File> = (n, count) => {
expandBottom: (n: number, count: number) => Promise<FileDiff> = (n, count) => {
const maxExpandBottomRange = this.computeMaxExpandBottomRange(n);
const start = this.maxLineNumber(n);
const end =
@@ -191,8 +190,8 @@ export type ExpandableHunk = {
hunk: Hunk;
maxExpandHeadRange: number;
maxExpandBottomRange: number;
expandHead: (count: number) => Promise<File>;
expandBottom: (count: number) => Promise<File>;
expandHead: (count: number) => Promise<FileDiff>;
expandBottom: (count: number) => Promise<FileDiff>;
};
export default DiffExpander;

View File

@@ -30,7 +30,8 @@ import { Decoration, getChangeKey, Hunk } from "react-diff-view";
import { ButtonGroup } from "../buttons";
import Tag from "../Tag";
import Icon from "../Icon";
import { Change, ChangeEvent, DiffObjectProps, File, Hunk as HunkType } from "./DiffTypes";
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";
@@ -45,7 +46,7 @@ const EMPTY_ANNOTATION_FACTORY = {};
type Props = DiffObjectProps &
WithTranslation & {
file: File;
file: FileDiff;
};
type Collapsible = {
@@ -53,7 +54,7 @@ type Collapsible = {
};
type State = Collapsible & {
file: File;
file: FileDiff;
sideBySide?: boolean;
diffExpander: DiffExpander;
expansionError?: any;
@@ -257,7 +258,7 @@ class DiffFile extends React.Component<Props, State> {
};
};
diffExpanded = (newFile: File) => {
diffExpanded = (newFile: FileDiff) => {
this.setState({ file: newFile, diffExpander: new DiffExpander(newFile) });
};
@@ -303,7 +304,7 @@ class DiffFile extends React.Component<Props, State> {
}
};
renderHunk = (file: File, expandableHunk: ExpandableHunk, i: number) => {
renderHunk = (file: FileDiff, expandableHunk: ExpandableHunk, i: number) => {
const hunk = expandableHunk.hunk;
if (this.props.markConflicts && hunk.changes) {
this.markConflicts(hunk);
@@ -353,7 +354,7 @@ class DiffFile extends React.Component<Props, State> {
}
};
getAnchorId(file: File) {
getAnchorId(file: FileDiff) {
let path: string;
if (file.type === "delete") {
path = file.oldPath;
@@ -363,7 +364,7 @@ class DiffFile extends React.Component<Props, State> {
return escapeWhitespace(path);
}
renderFileTitle = (file: File) => {
renderFileTitle = (file: FileDiff) => {
if (file.oldPath !== file.newPath && (file.type === "copy" || file.type === "rename")) {
return (
<>
@@ -376,7 +377,7 @@ class DiffFile extends React.Component<Props, State> {
return file.newPath;
};
hoverFileTitle = (file: File): string => {
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") {
@@ -385,7 +386,7 @@ class DiffFile extends React.Component<Props, State> {
return file.newPath;
};
renderChangeTag = (file: File) => {
renderChangeTag = (file: FileDiff) => {
const { t } = this.props;
if (!file.type) {
return;
@@ -401,7 +402,7 @@ class DiffFile extends React.Component<Props, State> {
return <ChangeTypeTag className={classNames("is-rounded", "has-text-weight-normal")} color={color} label={value} />;
};
hasContent = (file: File) => file && !file.isBinary && file.hunks && file.hunks.length > 0;
hasContent = (file: FileDiff) => file && !file.isBinary && file.hunks && file.hunks.length > 0;
render() {
const { fileControlFactory, fileAnnotationFactory, t } = this.props;

View File

@@ -24,55 +24,7 @@
import { ReactNode } from "react";
import { DefaultCollapsed } from "./defaultCollapsed";
import { Links } from "@scm-manager/ui-types";
// 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;
language?: string;
// TODO does this property exists?
isBinary?: boolean;
_links?: Links;
};
export type Hunk = {
changes: Change[];
content: string;
oldStart?: number;
newStart?: number;
oldLines?: number;
newLines?: number;
fullyExpanded?: boolean;
expansion?: boolean;
};
export type ChangeType = "insert" | "delete" | "normal" | "conflict";
export type Change = {
content: string;
isNormal?: boolean;
isInsert?: boolean;
isDelete?: boolean;
lineNumber?: number;
newLineNumber?: number;
oldLineNumber?: number;
type: ChangeType;
};
import { Change, Hunk, FileDiff as File } from "@scm-manager/ui-types";
export type ChangeEvent = {
change: Change;

View File

@@ -21,93 +21,72 @@
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
* SOFTWARE.
*/
import React from "react";
import { apiClient, NotFoundError } from "@scm-manager/ui-api";
import React, { FC } from "react";
import { NotFoundError, useDiff } from "@scm-manager/ui-api";
import ErrorNotification from "../ErrorNotification";
// @ts-ignore
import parser from "gitdiff-parser";
import Notification from "../Notification";
import Loading from "../Loading";
import Diff from "./Diff";
import { DiffObjectProps, File } from "./DiffTypes";
import { Notification } from "../index";
import { withTranslation, WithTranslation } from "react-i18next";
import { DiffObjectProps } from "./DiffTypes";
import { useTranslation } from "react-i18next";
import Button from "../buttons/Button";
import styled from "styled-components";
type Props = WithTranslation &
DiffObjectProps & {
url: string;
};
type State = {
diff?: File[];
loading: boolean;
error?: Error;
type Props = DiffObjectProps & {
url: string;
limit?: number;
};
class LoadingDiff extends React.Component<Props, State> {
static defaultProps = {
sideBySide: false
};
type NotificationProps = {
fetchNextPage: () => void;
isFetchingNextPage: boolean;
};
constructor(props: Props) {
super(props);
this.state = {
loading: true
};
}
const StyledNotification = styled(Notification)`
margin-top: 1.5rem;
`;
componentDidMount() {
this.fetchDiff();
}
const PartialNotification: FC<NotificationProps> = ({ fetchNextPage, isFetchingNextPage }) => {
const [t] = useTranslation("repos");
return (
<StyledNotification type="info">
<div className="columns is-centered">
<div className="column">{t("changesets.moreDiffsAvailable")}</div>
<Button label={t("changesets.loadMore")} action={fetchNextPage} loading={isFetchingNextPage} />
</div>
</StyledNotification>
);
};
componentDidUpdate(prevProps: Props) {
if (prevProps.url !== this.props.url) {
this.fetchDiff();
const LoadingDiff: FC<Props> = ({ url, limit, ...props }) => {
const { error, isLoading, data, fetchNextPage, isFetchingNextPage } = useDiff(url, { limit });
const [t] = useTranslation("repos");
if (error) {
if (error instanceof NotFoundError) {
return <Notification type="info">{t("changesets.noChangesets")}</Notification>;
}
return <ErrorNotification error={error} />;
} else if (isLoading) {
return <Loading />;
} else if (!data?.files) {
return null;
} else {
return (
<>
<Diff diff={data.files} {...props} />
{data.partial ? (
<PartialNotification fetchNextPage={fetchNextPage} isFetchingNextPage={isFetchingNextPage} />
) : null}
</>
);
}
};
fetchDiff = () => {
const { url } = this.props;
this.setState({ loading: true });
apiClient
.get(url)
.then(response => {
const contentType = response.headers.get("Content-Type");
if (contentType && contentType.toLowerCase() === "application/vnd.scmm-diffparsed+json;v=2") {
return response.json().then(data => data.files);
} else {
return response.text().then(parser.parse);
}
})
.then((diff: File[]) => {
this.setState({
loading: false,
diff: diff
});
})
.catch((error: Error) => {
this.setState({
loading: false,
error
});
});
};
LoadingDiff.defaultProps = {
limit: 25,
sideBySide: false
};
render() {
const { diff, loading, error } = this.state;
if (error) {
if (error instanceof NotFoundError) {
return <Notification type="info">{this.props.t("changesets.noChangesets")}</Notification>;
}
return <ErrorNotification error={error} />;
} else if (loading) {
return <Loading />;
} else if (!diff) {
return null;
} else {
return <Diff diff={diff} {...this.props} />;
}
}
}
export default withTranslation("repos")(LoadingDiff);
export default LoadingDiff;

View File

@@ -25,7 +25,7 @@ import React, { FC } from "react";
import styled from "styled-components";
// @ts-ignore we have no typings for react-diff-view
import { Diff, useTokenizeWorker } from "react-diff-view";
import { File } from "./DiffTypes";
import { FileDiff } from "@scm-manager/ui-types";
import { determineLanguage } from "../languages";
// @ts-ignore no types for css modules
@@ -65,7 +65,7 @@ const tokenize = new Worker("./Tokenize.worker.ts", { name: "tokenizer", type: "
tokenize.postMessage({ theme });
type Props = {
file: File;
file: FileDiff;
viewType: "split" | "unified";
className?: string;
};

View File

@@ -22,11 +22,11 @@
* SOFTWARE.
*/
import { File, FileChangeType, Hunk } from "./DiffTypes";
import { FileChangeType, Hunk, FileDiff } from "@scm-manager/ui-types";
import { getPath, createHunkIdentifier, createHunkIdentifierFromContext, escapeWhitespace } from "./diffs";
describe("tests for diff util functions", () => {
const file = (type: FileChangeType, oldPath: string, newPath: string): File => {
const file = (type: FileChangeType, oldPath: string, newPath: string): FileDiff => {
return {
hunks: [],
type: type,

View File

@@ -22,16 +22,17 @@
* SOFTWARE.
*/
import { BaseContext, File, Hunk } from "./DiffTypes";
import { BaseContext } from "./DiffTypes";
import { FileDiff, Hunk } from "@scm-manager/ui-types";
export function getPath(file: File) {
export function getPath(file: FileDiff) {
if (file.type === "delete") {
return file.oldPath;
}
return file.newPath;
}
export function createHunkIdentifier(file: File, hunk: Hunk) {
export function createHunkIdentifier(file: FileDiff, hunk: Hunk) {
const path = getPath(file);
return `${file.type}_${path}_${hunk.content}`;
}

View File

@@ -25,11 +25,6 @@
import * as diffs from "./diffs";
import {
File,
FileChangeType,
Hunk,
Change,
ChangeType,
BaseContext,
AnnotationFactory,
AnnotationFactoryContext,
@@ -37,6 +32,7 @@ import {
DiffEventContext
} from "./DiffTypes";
import { FileDiff as File, FileChangeType, Hunk, Change, ChangeType } from "@scm-manager/ui-types";
export { diffs };
export * from "./annotate";