From 99b7b92fbe4ef86b8d787efaab456af0d7751233 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ren=C3=A9=20Pfeuffer?= Date: Wed, 27 May 2020 17:23:00 +0200 Subject: [PATCH] Introduce expandable diffs --- .../src/repos/DiffExpander.test.ts | 312 ++++++++++++++++++ .../ui-components/src/repos/DiffExpander.ts | 61 ++++ scm-ui/ui-components/src/repos/DiffFile.tsx | 62 ++-- scm-ui/ui-components/src/repos/DiffTypes.ts | 6 + 4 files changed, 420 insertions(+), 21 deletions(-) create mode 100644 scm-ui/ui-components/src/repos/DiffExpander.test.ts create mode 100644 scm-ui/ui-components/src/repos/DiffExpander.ts diff --git a/scm-ui/ui-components/src/repos/DiffExpander.test.ts b/scm-ui/ui-components/src/repos/DiffExpander.test.ts new file mode 100644 index 0000000000..c4f640cf20 --- /dev/null +++ b/scm-ui/ui-components/src/repos/DiffExpander.test.ts @@ -0,0 +1,312 @@ +/* + * 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 DiffExpander from "./DiffExpander"; + +const HUNK_0 = { + content: "@@ -1,8 +1,8 @@", + oldStart: 1, + newStart: 1, + oldLines: 8, + newLines: 8, + changes: [ + { + content: "// @flow", + type: "normal", + oldLineNumber: 1, + newLineNumber: 1, + isNormal: true + }, + { + content: 'import React from "react";', + type: "normal", + oldLineNumber: 2, + newLineNumber: 2, + isNormal: true + }, + { + content: 'import { translate } from "react-i18next";', + type: "delete", + lineNumber: 3, + isDelete: true + }, + { + content: 'import { Textarea } from "@scm-manager/ui-components";', + type: "delete", + lineNumber: 4, + isDelete: true + }, + { + content: 'import type { Me } from "@scm-manager/ui-types";', + type: "delete", + lineNumber: 5, + isDelete: true + }, + { + content: 'import {translate} from "react-i18next";', + type: "insert", + lineNumber: 3, + isInsert: true + }, + { + content: 'import {Textarea} from "@scm-manager/ui-components";', + type: "insert", + lineNumber: 4, + isInsert: true + }, + { + content: 'import type {Me} from "@scm-manager/ui-types";', + type: "insert", + lineNumber: 5, + isInsert: true + }, + { + content: 'import injectSheet from "react-jss";', + type: "normal", + oldLineNumber: 6, + newLineNumber: 6, + isNormal: true + }, + { + content: "", + type: "normal", + oldLineNumber: 7, + newLineNumber: 7, + isNormal: true + }, + { + content: "const styles = {", + type: "normal", + oldLineNumber: 8, + newLineNumber: 8, + isNormal: true + } + ] +}; +const HUNK_1 = { + content: "@@ -14,6 +14,7 @@", + oldStart: 14, + newStart: 14, + oldLines: 6, + newLines: 7, + changes: [ + { + content: "type Props = {", + type: "normal", + oldLineNumber: 14, + newLineNumber: 14, + isNormal: true + }, + { + content: " me: Me,", + type: "normal", + oldLineNumber: 15, + newLineNumber: 15, + isNormal: true + }, + { + content: " onChange: string => void,", + type: "normal", + oldLineNumber: 16, + newLineNumber: 16, + isNormal: true + }, + { + content: " disabled: boolean,", + type: "insert", + lineNumber: 17, + isInsert: true + }, + { + content: " //context props", + type: "normal", + oldLineNumber: 17, + newLineNumber: 18, + isNormal: true + }, + { + content: " t: string => string,", + type: "normal", + oldLineNumber: 18, + newLineNumber: 19, + isNormal: true + }, + { + content: " classes: any", + type: "normal", + oldLineNumber: 19, + newLineNumber: 20, + isNormal: true + } + ] +}; +const HUNK_2 = { + content: "@@ -21,7 +22,7 @@", + oldStart: 21, + newStart: 22, + oldLines: 7, + newLines: 7, + changes: [ + { + content: "", + type: "normal", + oldLineNumber: 21, + newLineNumber: 22, + isNormal: true + }, + { + content: "class CommitMessage extends React.Component {", + type: "normal", + oldLineNumber: 22, + newLineNumber: 23, + isNormal: true + }, + { + content: " render() {", + type: "normal", + oldLineNumber: 23, + newLineNumber: 24, + isNormal: true + }, + { + content: " const { t, classes, me, onChange } = this.props;", + type: "delete", + lineNumber: 24, + isDelete: true + }, + { + content: " const {t, classes, me, onChange, disabled} = this.props;", + type: "insert", + lineNumber: 25, + isInsert: true + }, + { + content: " return (", + type: "normal", + oldLineNumber: 25, + newLineNumber: 26, + isNormal: true + }, + { + content: " <>", + type: "normal", + oldLineNumber: 26, + newLineNumber: 27, + isNormal: true + }, + { + content: "
", + type: "normal", + oldLineNumber: 27, + newLineNumber: 28, + isNormal: true + } + ] +}; +const HUNK_3 = { + content: "@@ -33,6 +34,7 @@", + oldStart: 33, + newStart: 34, + oldLines: 6, + newLines: 7, + changes: [ + { + content: " onChange(message)}", + type: "normal", + oldLineNumber: 35, + newLineNumber: 36, + isNormal: true + }, + { + content: " disabled={disabled}", + type: "insert", + lineNumber: 37, + isInsert: true + }, + { + content: " />", + type: "normal", + oldLineNumber: 36, + newLineNumber: 38, + isNormal: true + }, + { + content: " ", + type: "normal", + oldLineNumber: 37, + newLineNumber: 39, + isNormal: true + }, + { + content: " );", + type: "normal", + oldLineNumber: 38, + newLineNumber: 40, + isNormal: true + } + ] +}; +const TEST_CONTENT = { + oldPath: "src/main/js/CommitMessage.js", + newPath: "src/main/js/CommitMessage.js", + oldEndingNewLine: true, + newEndingNewLine: true, + oldRevision: "e05c8495bb1dc7505d73af26210c8ff4825c4500", + newRevision: "4305a8df175b7bec25acbe542a13fbe2a718a608", + type: "modify", + language: "javascript", + hunks: [HUNK_0, HUNK_1, HUNK_2, HUNK_3], + _links: { + lines: { + href: + "http://localhost:8081/scm/api/v2/repositories/scm-manager/scm-editor-plugin/content/f7a23064f3f2418f26140a9545559e72d595feb5/src/main/js/CommitMessage.js?start={start}?end={end}", + templated: true + } + } +}; + +describe("diff expander", () => { + const diffExpander = new DiffExpander(TEST_CONTENT); + it("should have hunk count from origin", () => { + expect(diffExpander.hunkCount()).toBe(4); + }); + + it("should return correct hunk", () => { + expect(diffExpander.getHunk(1).hunk).toBe(HUNK_1); + }); +}); diff --git a/scm-ui/ui-components/src/repos/DiffExpander.ts b/scm-ui/ui-components/src/repos/DiffExpander.ts new file mode 100644 index 0000000000..a6a135e7b7 --- /dev/null +++ b/scm-ui/ui-components/src/repos/DiffExpander.ts @@ -0,0 +1,61 @@ +/* + * 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 { File, Hunk } from "./DiffTypes"; + +class DiffExpander { + file: File; + + constructor(file: File) { + this.file = file; + } + + hunkCount = () => { + return this.file.hunks.length; + }; + + getHunk: (n: number) => ExpandableHunk = (n: number) => { + return { + maxExpandHeadRange: 10, + maxExpandBottomRange: 10, + expandHead: () => { + console.log("expand head", n); + }, + expandBottom: () => { + console.log("expand bottom", n); + }, + hunk: this.file.hunks[n] + }; + }; +} + +export type ExpandableHunk = { + hunk: Hunk; + maxExpandHeadRange: number; + maxExpandBottomRange: number; + expandHead: () => void; + expandBottom: () => void; +}; + +export default DiffExpander; diff --git a/scm-ui/ui-components/src/repos/DiffFile.tsx b/scm-ui/ui-components/src/repos/DiffFile.tsx index 4b042f0c4d..e2b4a36f7d 100644 --- a/scm-ui/ui-components/src/repos/DiffFile.tsx +++ b/scm-ui/ui-components/src/repos/DiffFile.tsx @@ -34,6 +34,7 @@ import { Change, ChangeEvent, DiffObjectProps, File, Hunk as HunkType } from "./ import TokenizedDiffView from "./TokenizedDiffView"; import DiffButton from "./DiffButton"; import { MenuContext } from "@scm-manager/ui-components"; +import DiffExpander, { ExpandableHunk } from "./DiffExpander"; const EMPTY_ANNOTATION_FACTORY = {}; @@ -48,6 +49,7 @@ type Collapsible = { type State = Collapsible & { sideBySide?: boolean; + diffExpander: DiffExpander; }; const DiffFilePanel = styled.div` @@ -74,8 +76,9 @@ const ButtonWrapper = styled.div` margin-left: auto; `; -const HunkDivider = styled.hr` - margin: 0.5rem 0; +const HunkDivider = styled.div` + background: #33b2e8; + font-size: 0.7rem; `; const ChangeTypeTag = styled(Tag)` @@ -92,7 +95,8 @@ class DiffFile extends React.Component { super(props); this.state = { collapsed: this.defaultCollapse(), - sideBySide: props.sideBySide + sideBySide: props.sideBySide, + diffExpander: new DiffExpander(props.file) }; } @@ -139,9 +143,25 @@ class DiffFile extends React.Component { }); }; - createHunkHeader = (hunk: HunkType, i: number) => { - if (i > 0) { - return ; + createHunkHeader = (expandableHunk: ExpandableHunk) => { + if (expandableHunk.maxExpandHeadRange > 0) { + return ( + + {"Load first n lines"} + + ); + } + // hunk header must be defined + return ; + }; + + createHunkFooter = (expandableHunk: ExpandableHunk) => { + if (expandableHunk.maxExpandBottomRange > 0) { + return ( + + {"Load last n lines"} + + ); } // hunk header must be defined return ; @@ -183,19 +203,27 @@ class DiffFile extends React.Component { } }; - renderHunk = (hunk: HunkType, i: number) => { + renderHunk = (file: File, expandableHunk: ExpandableHunk, i: number) => { + const hunk = expandableHunk.hunk; if (this.props.markConflicts && hunk.changes) { this.markConflicts(hunk); } - return [ - {this.createHunkHeader(hunk, i)}, + const items = []; + if (file._links?.lines) { + items.push(this.createHunkHeader(expandableHunk)); + } + items.push( - ]; + ); + if (file._links?.lines) { + items.push(this.createHunkFooter(expandableHunk)); + } + return items; }; markConflicts = (hunk: HunkType) => { @@ -251,19 +279,11 @@ class DiffFile extends React.Component { return ; }; - concat = (array: object[][]) => { - if (array.length > 0) { - return array.reduce((a, b) => a.concat(b)); - } else { - return []; - } - }; - hasContent = (file: File) => file && !file.isBinary && file.hunks && file.hunks.length > 0; render() { const { file, fileControlFactory, fileAnnotationFactory, t } = this.props; - const { collapsed, sideBySide } = this.state; + const { collapsed, sideBySide, diffExpander } = this.state; const viewType = sideBySide ? "split" : "unified"; let body = null; @@ -275,7 +295,7 @@ class DiffFile extends React.Component {
{fileAnnotations} - {(hunks: HunkType[]) => this.concat(hunks.map(this.renderHunk))} + {(hunks: HunkType[]) => hunks.map((hunk, n) => this.renderHunk(file, diffExpander.getHunk(n), n))}
); diff --git a/scm-ui/ui-components/src/repos/DiffTypes.ts b/scm-ui/ui-components/src/repos/DiffTypes.ts index 7ab15e5750..aba76d422d 100644 --- a/scm-ui/ui-components/src/repos/DiffTypes.ts +++ b/scm-ui/ui-components/src/repos/DiffTypes.ts @@ -24,6 +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. @@ -46,11 +47,16 @@ export type File = { 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; }; export type ChangeType = "insert" | "delete" | "normal" | "conflict";