diff --git a/client.diff/src/main/java/com/github/gumtreediff/client/diff/webdiff/DirectoryDiffView.java b/client.diff/src/main/java/com/github/gumtreediff/client/diff/webdiff/DirectoryDiffView.java index 16adb0d07..76d4b20b7 100644 --- a/client.diff/src/main/java/com/github/gumtreediff/client/diff/webdiff/DirectoryDiffView.java +++ b/client.diff/src/main/java/com/github/gumtreediff/client/diff/webdiff/DirectoryDiffView.java @@ -64,7 +64,8 @@ public static HtmlTag build(DirectoryComparator comparator) { span("" + comparator.getDeletedFiles().size()).withClasses("badge", "badge-secondary")) ).withClasses("card-title", "mb-0") ).withClasses("card-header", "bg-danger"), - iff(comparator.getDeletedFiles().size() > 0, AddedOrDeletedFiles.build(comparator.getDeletedFiles(), comparator.getSrc())) + iff(comparator.getDeletedFiles().size() > 0, + DeletedFiles.build(comparator.getDeletedFiles(), comparator.getSrc())) ).withClass("card") ).withClass("col"), div( @@ -75,7 +76,8 @@ public static HtmlTag build(DirectoryComparator comparator) { span("" + comparator.getAddedFiles().size()).withClasses("badge", "badge-secondary")) ).withClasses("card-title", "mb-0") ).withClasses("card-header", "bg-success"), - iff(comparator.getAddedFiles().size() > 0, AddedOrDeletedFiles.build(comparator.getAddedFiles(), comparator.getSrc())) + iff(comparator.getAddedFiles().size() > 0, + AddedFiles.build(comparator.getAddedFiles(), comparator.getDst())) ).withClass("card") ).withClass("col") ).withClasses("row", "mb-3") @@ -88,34 +90,75 @@ private class ModifiedFiles { public static Tag build(List> files, DirectoryComparator comparator) { return table( tbody( - each(files, (id, file) -> tr( - td(comparator.getSrc().toAbsolutePath().relativize(file.first.toPath().toAbsolutePath()).toString()), - td( - div( + each(files, (id, file) -> { + String srcRelPath = comparator.getSrc().toAbsolutePath() + .relativize(file.first.toPath().toAbsolutePath()).toString(); + String dstRelPath = comparator.getDst().toAbsolutePath() + .relativize(file.second.toPath().toAbsolutePath()).toString(); + boolean isRenamed = !srcRelPath.equals(dstRelPath); + return tr( + td( + isRenamed + ? join(text(srcRelPath), rawHtml(" → "), text(dstRelPath)) + : text(srcRelPath) + ), + td( div( - iff(TreeGenerators.getInstance().hasGeneratorForFile(file.first.getAbsolutePath()), join( - a("monaco").withHref("/monaco-diff/" + id).withClasses("btn", "btn-primary", "btn-sm"), - a("classic").withHref("/vanilla-diff/" + id).withClasses("btn", "btn-primary", "btn-sm") - ) - ), - a("monaco-native").withHref("/monaco-native-diff/" + id).withClasses("btn", "btn-primary", "btn-sm"), - a("mergely").withHref("/mergely-diff/" + id).withClasses("btn", "btn-primary", "btn-sm"), - a("raw").withHref("/raw-diff/" + id).withClasses("btn", "btn-primary", "btn-sm") - ).withClass("btn-group") - ).withClasses("btn-toolbar", "justify-content-end") - ) - )) + div( + iff(TreeGenerators.getInstance().hasGeneratorForFile(file.first.getAbsolutePath()), join( + a("monaco").withHref("/monaco-diff/" + id).withClasses("btn", "btn-primary", "btn-sm"), + a("classic").withHref("/vanilla-diff/" + id).withClasses("btn", "btn-primary", "btn-sm") + ) + ), + a("monaco-native").withHref("/monaco-native-diff/" + id).withClasses("btn", "btn-primary", "btn-sm"), + a("mergely").withHref("/mergely-diff/" + id).withClasses("btn", "btn-primary", "btn-sm"), + a("raw").withHref("/raw-diff/" + id).withClasses("btn", "btn-primary", "btn-sm"), + iff(isRenamed, + button("unpair").withClasses("btn", "btn-warning", "btn-sm", "ms-2") + .attr("onclick", "unpairFiles(" + id + ")")) + ).withClass("btn-group") + ).withClasses("btn-toolbar", "justify-content-end") + ) + ); + }) + ) + ).withClasses("table", "card-table", "table-striped", "table-condensed", "mb-0"); + } + } + + private static class DeletedFiles { + + public static Tag build(Set files, Path root) { + return table( + tbody( + each(files, file -> { + String relPath = root.relativize(file.toPath()).toString(); + return tr( + td(relPath) + ).attr("draggable", "true") + .attr("data-path", relPath) + .attr("data-side", "deleted") + .withClass("draggable-file"); + }) ) ).withClasses("table", "card-table", "table-striped", "table-condensed", "mb-0"); } } - private static class AddedOrDeletedFiles { + private static class AddedFiles { public static Tag build(Set files, Path root) { return table( tbody( - each(files, file -> tr(td(root.relativize(file.toPath()).toString()))) + each(files, file -> { + String relPath = root.relativize(file.toPath()).toString(); + return tr( + td(relPath) + ).attr("draggable", "true") + .attr("data-path", relPath) + .attr("data-side", "added") + .withClass("draggable-file"); + }) ) ).withClasses("table", "card-table", "table-striped", "table-condensed", "mb-0"); } @@ -130,7 +173,12 @@ public static Tag build() { title("GumTree"), link().withRel("stylesheet").withType("text/css").withHref(WebDiff.BOOTSTRAP_CSS_URL), script().withType("text/javascript").withSrc(WebDiff.BOOTSTRAP_JS_URL), - script().withType("text/javascript").withSrc("/dist/shortcuts.js") + script().withType("text/javascript").withSrc("/dist/shortcuts.js"), + script().withType("text/javascript").withSrc("/dist/dragdrop.js").attr("defer", "defer"), + rawHtml("") ); } } diff --git a/client.diff/src/main/java/com/github/gumtreediff/client/diff/webdiff/WebDiff.java b/client.diff/src/main/java/com/github/gumtreediff/client/diff/webdiff/WebDiff.java index 15cfe1d40..777b9eec5 100644 --- a/client.diff/src/main/java/com/github/gumtreediff/client/diff/webdiff/WebDiff.java +++ b/client.diff/src/main/java/com/github/gumtreediff/client/diff/webdiff/WebDiff.java @@ -133,6 +133,21 @@ public void configureSpark(final DirectoryComparator comparator, int port) { Pair pair = comparator.getModifiedFiles().get(id); return readFile(pair.second.getAbsolutePath(), Charset.defaultCharset()); }); + post("/pair-files", (request, response) -> { + String srcPath = request.queryParams("src"); + String dstPath = request.queryParams("dst"); + File srcFile = new File(comparator.getSrc().toFile(), srcPath); + File dstFile = new File(comparator.getDst().toFile(), dstPath); + comparator.pairFiles(srcFile, dstFile); + response.redirect("/list"); + return ""; + }); + post("/unpair-files", (request, response) -> { + int id = Integer.parseInt(request.queryParams("id")); + comparator.unpairFiles(id); + response.redirect("/list"); + return ""; + }); get("/quit", (request, response) -> { System.exit(0); return ""; diff --git a/client.diff/src/main/resources/web/dist/dragdrop.js b/client.diff/src/main/resources/web/dist/dragdrop.js new file mode 100644 index 000000000..c94be277e --- /dev/null +++ b/client.diff/src/main/resources/web/dist/dragdrop.js @@ -0,0 +1,81 @@ +document.addEventListener("DOMContentLoaded", function () { + var dragSource = null; + var rows = document.querySelectorAll("tr.draggable-file"); + + rows.forEach(function (row) { + row.style.cursor = "grab"; + + row.addEventListener("dragstart", function (e) { + dragSource = { + path: row.getAttribute("data-path"), + side: row.getAttribute("data-side") + }; + e.dataTransfer.effectAllowed = "move"; + e.dataTransfer.setData("text/plain", ""); + row.style.opacity = "0.5"; + }); + + row.addEventListener("dragend", function () { + dragSource = null; + row.style.opacity = ""; + document.querySelectorAll("tr.drag-over").forEach(function (el) { + el.classList.remove("drag-over"); + }); + }); + + row.addEventListener("dragover", function (e) { + if (!dragSource || dragSource.side === row.getAttribute("data-side")) return; + e.preventDefault(); + e.dataTransfer.dropEffect = "move"; + }); + + row.addEventListener("dragenter", function (e) { + if (!dragSource || dragSource.side === row.getAttribute("data-side")) return; + e.preventDefault(); + row.classList.add("drag-over"); + }); + + row.addEventListener("dragleave", function (e) { + if (e.relatedTarget && row.contains(e.relatedTarget)) return; + row.classList.remove("drag-over"); + }); + + row.addEventListener("drop", function (e) { + e.preventDefault(); + e.stopPropagation(); + row.classList.remove("drag-over"); + + if (!dragSource) return; + + var dropSide = row.getAttribute("data-side"); + var dropPath = row.getAttribute("data-path"); + + if (dragSource.side === dropSide) return; + + var srcPath, dstPath; + if (dragSource.side === "deleted") { + srcPath = dragSource.path; + dstPath = dropPath; + } else { + srcPath = dropPath; + dstPath = dragSource.path; + } + + dragSource = null; + + var form = document.createElement("form"); + form.method = "POST"; + form.action = "/pair-files?src=" + encodeURIComponent(srcPath) + "&dst=" + encodeURIComponent(dstPath); + document.body.appendChild(form); + form.submit(); + }); + }); +}); + +function unpairFiles(id) { + var form = document.createElement("form"); + form.method = "POST"; + form.action = "/unpair-files?id=" + id; + document.body.appendChild(form); + form.submit(); +} diff --git a/core/src/main/java/com/github/gumtreediff/io/DirectoryComparator.java b/core/src/main/java/com/github/gumtreediff/io/DirectoryComparator.java index 5ec8d0948..e3e3992b3 100644 --- a/core/src/main/java/com/github/gumtreediff/io/DirectoryComparator.java +++ b/core/src/main/java/com/github/gumtreediff/io/DirectoryComparator.java @@ -128,6 +128,22 @@ public Set getAddedFiles() { return addedFiles; } + public void pairFiles(File srcFile, File dstFile) { + if (!deletedFiles.remove(srcFile)) + throw new IllegalArgumentException("File " + srcFile + " is not in the deleted files set."); + if (!addedFiles.remove(dstFile)) + throw new IllegalArgumentException("File " + dstFile + " is not in the added files set."); + modifiedFiles.add(new Pair<>(srcFile, dstFile)); + } + + public void unpairFiles(int id) { + if (id < 0 || id >= modifiedFiles.size()) + throw new IllegalArgumentException("Invalid pair id: " + id); + Pair pair = modifiedFiles.remove(id); + deletedFiles.add(pair.first); + addedFiles.add(pair.second); + } + private File toSrcFile(String s) { return new File(src.toFile(), s); } diff --git a/core/src/test/java/com/github/gumtreediff/test/TestDirectoryComparator.java b/core/src/test/java/com/github/gumtreediff/test/TestDirectoryComparator.java index c68450c92..8611725fd 100644 --- a/core/src/test/java/com/github/gumtreediff/test/TestDirectoryComparator.java +++ b/core/src/test/java/com/github/gumtreediff/test/TestDirectoryComparator.java @@ -22,6 +22,8 @@ import com.github.gumtreediff.io.DirectoryComparator; import org.junit.jupiter.api.Test; +import java.io.File; + import static org.junit.jupiter.api.Assertions.*; public class TestDirectoryComparator { @@ -70,4 +72,50 @@ public void testDirectoryComparatorOnFileAndFolder() { "src/test/resources"); }); } + + @Test + public void testPairAndUnpairFiles() { + DirectoryComparator cmp = new DirectoryComparator("src/test/resources/diff/left", + "src/test/resources/diff/right"); + cmp.compare(); + assertEquals(1, cmp.getModifiedFiles().size()); + assertEquals(2, cmp.getDeletedFiles().size()); + assertEquals(2, cmp.getAddedFiles().size()); + + File srcFile = cmp.getDeletedFiles().stream() + .filter(f -> f.getName().equals("renamedLeft")).findFirst().get(); + File dstFile = cmp.getAddedFiles().stream() + .filter(f -> f.getName().equals("renamedRight")).findFirst().get(); + + cmp.pairFiles(srcFile, dstFile); + assertEquals(2, cmp.getModifiedFiles().size()); + assertEquals(1, cmp.getDeletedFiles().size()); + assertEquals(1, cmp.getAddedFiles().size()); + assertEquals("renamedLeft", cmp.getModifiedFiles().get(1).first.getName()); + assertEquals("renamedRight", cmp.getModifiedFiles().get(1).second.getName()); + + cmp.unpairFiles(1); + assertEquals(1, cmp.getModifiedFiles().size()); + assertEquals(2, cmp.getDeletedFiles().size()); + assertEquals(2, cmp.getAddedFiles().size()); + } + + @Test + public void testPairFilesInvalidArguments() { + DirectoryComparator cmp = new DirectoryComparator("src/test/resources/diff/left", + "src/test/resources/diff/right"); + cmp.compare(); + + File validDeleted = cmp.getDeletedFiles().iterator().next(); + File validAdded = cmp.getAddedFiles().iterator().next(); + + assertThrows(IllegalArgumentException.class, () -> + cmp.pairFiles(new File("nonexistent"), validAdded)); + assertThrows(IllegalArgumentException.class, () -> + cmp.pairFiles(validDeleted, new File("nonexistent"))); + assertThrows(IllegalArgumentException.class, () -> + cmp.unpairFiles(-1)); + assertThrows(IllegalArgumentException.class, () -> + cmp.unpairFiles(999)); + } }