diff --git a/CNAME b/CNAME deleted file mode 100644 index 8ea31073..00000000 --- a/CNAME +++ /dev/null @@ -1 +0,0 @@ -www.luoboduner.com \ No newline at end of file diff --git a/pom.xml b/pom.xml index b353ba96..7c751f71 100644 --- a/pom.xml +++ b/pom.xml @@ -55,6 +55,7 @@ 1.70 3.0.0 2.13.3 + 4.16 @@ -77,6 +78,14 @@ + + + + io.github.java-diff-utils + java-diff-utils + ${java-diff.version} + + junit junit diff --git a/readme.md b/readme.md index 08cfeb78..20269da5 100644 --- a/readme.md +++ b/readme.md @@ -53,6 +53,9 @@ Windows • Linux • macOS ![Overview](screen_shoot/theme.png) +- 并排对比:![TextDiff-并排](/screen_shoot/textdiff_side_by_side.png) +- 统一差异:![TextDiff-统一差异](/screen_shoot/textdiff_unified.png) + ## 下载 [https://github.com/rememberber/MooTool/releases](https://github.com/rememberber/MooTool/releases) diff --git a/screen_shoot/textdiff_side_by_side.png b/screen_shoot/textdiff_side_by_side.png new file mode 100644 index 00000000..f97505a2 Binary files /dev/null and b/screen_shoot/textdiff_side_by_side.png differ diff --git a/screen_shoot/textdiff_unified.png b/screen_shoot/textdiff_unified.png new file mode 100644 index 00000000..0972bac4 Binary files /dev/null and b/screen_shoot/textdiff_unified.png differ diff --git a/src/main/java/com/luoboduner/moo/tool/bean/textdiff/DiffInfo.java b/src/main/java/com/luoboduner/moo/tool/bean/textdiff/DiffInfo.java new file mode 100644 index 00000000..38048e87 --- /dev/null +++ b/src/main/java/com/luoboduner/moo/tool/bean/textdiff/DiffInfo.java @@ -0,0 +1,12 @@ +package com.luoboduner.moo.tool.bean.textdiff; + +import com.luoboduner.moo.tool.enums.DiffTypeEnum; + +/** + * 差异信息类,用于UI高亮显示 + * @author CassianFlorin + * @email flowercard591@gmail.com + * @date 2025/9/27 18:37 + */ +public record DiffInfo(int lineNumber, String content, DiffTypeEnum type) { +} diff --git a/src/main/java/com/luoboduner/moo/tool/bean/textdiff/DiffResult.java b/src/main/java/com/luoboduner/moo/tool/bean/textdiff/DiffResult.java new file mode 100644 index 00000000..3aa58220 --- /dev/null +++ b/src/main/java/com/luoboduner/moo/tool/bean/textdiff/DiffResult.java @@ -0,0 +1,11 @@ +package com.luoboduner.moo.tool.bean.textdiff; + +import java.util.List; + +/** + * 差异结果类 + * @author CassianFlorin + * @email flowercard591@gmail.com + * @date 2025/9/27 18:57 + */ +public record DiffResult(List leftDiffs, List rightDiffs) {} diff --git a/src/main/java/com/luoboduner/moo/tool/bean/textdiff/Span.java b/src/main/java/com/luoboduner/moo/tool/bean/textdiff/Span.java new file mode 100644 index 00000000..453d3b1d --- /dev/null +++ b/src/main/java/com/luoboduner/moo/tool/bean/textdiff/Span.java @@ -0,0 +1,11 @@ +package com.luoboduner.moo.tool.bean.textdiff; + +import com.luoboduner.moo.tool.enums.UnifiedSpanTypeEnum; + +/** + * @author CassianFlorin + * @email flowercard591@gmail.com + * @date 2025/9/27 18:54 + */ +public record Span(int start, int end, UnifiedSpanTypeEnum type) { +} diff --git a/src/main/java/com/luoboduner/moo/tool/bean/textdiff/TextDiffSegment.java b/src/main/java/com/luoboduner/moo/tool/bean/textdiff/TextDiffSegment.java new file mode 100644 index 00000000..3f98b9b5 --- /dev/null +++ b/src/main/java/com/luoboduner/moo/tool/bean/textdiff/TextDiffSegment.java @@ -0,0 +1,18 @@ +package com.luoboduner.moo.tool.bean.textdiff; + +import com.luoboduner.moo.tool.enums.DiffTypeEnum; + +/** + * 用于 UI 的精细化段(按字符区间) + * + * @param leftStart -1 表示无 + * @param leftEnd -1 表示无 + * @param rightStart -1 表示无 + * @param rightEnd -1 表示无 + * + * @author CassianFlorin + * @email flowercard591@gmail.com + * @date 2025/9/27 18:52 + */ +public record TextDiffSegment(DiffTypeEnum type, int leftStart, int leftEnd, int rightStart, int rightEnd) { +} diff --git a/src/main/java/com/luoboduner/moo/tool/bean/textdiff/UIDiff.java b/src/main/java/com/luoboduner/moo/tool/bean/textdiff/UIDiff.java new file mode 100644 index 00000000..ecbf6c67 --- /dev/null +++ b/src/main/java/com/luoboduner/moo/tool/bean/textdiff/UIDiff.java @@ -0,0 +1,12 @@ +package com.luoboduner.moo.tool.bean.textdiff; + + +import java.util.List; + +/** + * @author CassianFlorin + * @email flowercard591@gmail.com + * @date 2025/9/27 18:31 + */ +public record UIDiff(List segments) { +} diff --git a/src/main/java/com/luoboduner/moo/tool/bean/textdiff/UnifiedView.java b/src/main/java/com/luoboduner/moo/tool/bean/textdiff/UnifiedView.java new file mode 100644 index 00000000..0e271f40 --- /dev/null +++ b/src/main/java/com/luoboduner/moo/tool/bean/textdiff/UnifiedView.java @@ -0,0 +1,11 @@ +package com.luoboduner.moo.tool.bean.textdiff; + +import java.util.List; + +/** + * @author CassianFlorin + * @email flowercard591@gmail.com + * @date 2025/9/27 18:54 + */ +public record UnifiedView(String text, List lineSpans, List charSpans, int charEventCount) { +} diff --git a/src/main/java/com/luoboduner/moo/tool/enums/DiffTypeEnum.java b/src/main/java/com/luoboduner/moo/tool/enums/DiffTypeEnum.java new file mode 100644 index 00000000..8d40a779 --- /dev/null +++ b/src/main/java/com/luoboduner/moo/tool/enums/DiffTypeEnum.java @@ -0,0 +1,15 @@ +package com.luoboduner.moo.tool.enums; + +/** + * @author CassianFlorin + * @email flowercard591@gmail.com + * @date 2025/9/27 18:42 + */ +public enum DiffTypeEnum { + + EQUAL, // 相同 + INSERT, // 插入 + DELETE, // 删除 + CHANGE // 修改 + +} diff --git a/src/main/java/com/luoboduner/moo/tool/enums/UnifiedSpanTypeEnum.java b/src/main/java/com/luoboduner/moo/tool/enums/UnifiedSpanTypeEnum.java new file mode 100644 index 00000000..95e9121e --- /dev/null +++ b/src/main/java/com/luoboduner/moo/tool/enums/UnifiedSpanTypeEnum.java @@ -0,0 +1,18 @@ +package com.luoboduner.moo.tool.enums; + +/** + * @author CassianFlorin + * @email flowercard591@gmail.com + * @date 2025/9/27 18:55 + */ +public enum UnifiedSpanTypeEnum { + + ADD_LINE, + DEL_LINE, + HUNK_LINE, + HEADER_LINE, + ADD_CHAR, + DEL_CHAR, + CHANGE_CHAR + +} diff --git a/src/main/java/com/luoboduner/moo/tool/service/DiffService.java b/src/main/java/com/luoboduner/moo/tool/service/DiffService.java new file mode 100644 index 00000000..24fdfa09 --- /dev/null +++ b/src/main/java/com/luoboduner/moo/tool/service/DiffService.java @@ -0,0 +1,344 @@ +package com.luoboduner.moo.tool.service; + +import com.github.difflib.DiffUtils; +import com.github.difflib.UnifiedDiffUtils; +import com.github.difflib.patch.AbstractDelta; +import com.github.difflib.patch.DeltaType; +import com.github.difflib.patch.Patch; +import com.luoboduner.moo.tool.bean.textdiff.Span; +import com.luoboduner.moo.tool.bean.textdiff.TextDiffSegment; +import com.luoboduner.moo.tool.bean.textdiff.UIDiff; +import com.luoboduner.moo.tool.bean.textdiff.UnifiedView; +import com.luoboduner.moo.tool.enums.DiffTypeEnum; +import com.luoboduner.moo.tool.enums.UnifiedSpanTypeEnum; +import lombok.extern.slf4j.Slf4j; + +import java.util.ArrayList; +import java.util.List; + +/** + *
+ *     差异服务
+ *     计算 diff 并生成 unified diff(行级)
+ * 
+ * + * @author CassianFlorin + * @email flowercard591@gmail.com + * @date 2025/9/27 10:51 + */ +@Slf4j +public class DiffService { + + /** + * 简单行切分器 + * + * @param text 文本 + * @return 行列表 + */ + private static List toLines(String text) { + // -1 保留末尾空格 + return List.of(text.split("\\R", -1)); + } + + public static UIDiff getSegmentsForUI(String oldText, String newText, boolean ignoreWhitespace) { + return getSegmentsForUI(oldText, newText, ignoreWhitespace, false); + } + + public static UIDiff getSegmentsForUI(String oldText, String newText, boolean ignoreWhitespace, boolean charOnly) { + List oldLines = toLines(oldText); + List newLines = toLines(newText); + Patch patch = DiffUtils.diff(oldLines, newLines); + + // 计算每一行的起始字符偏移(含换行) + int[] leftLineStarts = computeLineStartOffsets(oldText); + int[] rightLineStarts = computeLineStartOffsets(newText); + + List textDiffSegments = new ArrayList<>(); + + for (AbstractDelta delta : patch.getDeltas()) { + int leftPos = delta.getSource().getPosition(); + int rightPos = delta.getTarget().getPosition(); + List leftLines = delta.getSource().getLines(); + List rightLines = delta.getTarget().getLines(); + DeltaType type = delta.getType(); + + switch (type) { + case DELETE: { + if (charOnly) { + // 仅字符模式:整行删除不计入字符级差异 + break; + } + for (int i = 0; i < leftLines.size(); i++) { + int lineIdx = leftPos + i; + int start = safeLineStart(leftLineStarts, lineIdx); + int end = start + leftLines.get(i).length(); + if (!(ignoreWhitespace && isAllWhitespace(leftLines.get(i)))) + textDiffSegments.add(new TextDiffSegment(DiffTypeEnum.DELETE, start, end, -1, -1)); + } + break; + } + case INSERT: { + if (charOnly) { + // 仅字符模式:整行插入不计入字符级差异 + break; + } + for (int i = 0; i < rightLines.size(); i++) { + int lineIdx = rightPos + i; + int start = safeLineStart(rightLineStarts, lineIdx); + int end = start + rightLines.get(i).length(); + if (!(ignoreWhitespace && isAllWhitespace(rightLines.get(i)))) + textDiffSegments.add(new TextDiffSegment(DiffTypeEnum.INSERT, -1, -1, start, end)); + } + break; + } + case CHANGE: { + int pairCount = Math.min(leftLines.size(), rightLines.size()); + // 对应行做字符级 diff + for (int i = 0; i < pairCount; i++) { + String a = leftLines.get(i); + String b = rightLines.get(i); + int aLineStart = safeLineStart(leftLineStarts, leftPos + i); + int bLineStart = safeLineStart(rightLineStarts, rightPos + i); + // 使用 DiffUtils 在字符层面做 diff + List ac = toChars(a); + List bc = toChars(b); + Patch charPatch = DiffUtils.diff(ac, bc); + for (AbstractDelta cDelta : charPatch.getDeltas()) { + int aStart = aLineStart + cDelta.getSource().getPosition(); + int aEnd = aStart + cDelta.getSource().getLines().size(); + int bStart = bLineStart + cDelta.getTarget().getPosition(); + int bEnd = bStart + cDelta.getTarget().getLines().size(); + if (cDelta.getType() == DeltaType.DELETE) { + if (!(ignoreWhitespace && isAllWhitespace(a.substring( + Math.min(a.length(), Math.max(0, cDelta.getSource().getPosition())), + Math.min(a.length(), Math.max(0, cDelta.getSource().getPosition() + cDelta.getSource().getLines().size())) + )))) { + textDiffSegments.add(new TextDiffSegment(DiffTypeEnum.DELETE, aStart, aEnd, -1, -1)); + } + } else if (cDelta.getType() == DeltaType.INSERT) { + if (!(ignoreWhitespace && isAllWhitespace(b.substring( + Math.min(b.length(), Math.max(0, cDelta.getTarget().getPosition())), + Math.min(b.length(), Math.max(0, cDelta.getTarget().getPosition() + cDelta.getTarget().getLines().size())) + )))) { + textDiffSegments.add(new TextDiffSegment(DiffTypeEnum.INSERT, -1, -1, bStart, bEnd)); + } + } else { // CHANGE + String aSub = a.substring( + Math.min(a.length(), Math.max(0, cDelta.getSource().getPosition())), + Math.min(a.length(), Math.max(0, cDelta.getSource().getPosition() + cDelta.getSource().getLines().size()))); + String bSub = b.substring( + Math.min(b.length(), Math.max(0, cDelta.getTarget().getPosition())), + Math.min(b.length(), Math.max(0, cDelta.getTarget().getPosition() + cDelta.getTarget().getLines().size()))); + if (!(ignoreWhitespace && equalsIgnoreWhitespace(aSub, bSub))) { + textDiffSegments.add(new TextDiffSegment(DiffTypeEnum.CHANGE, aStart, aEnd, bStart, bEnd)); + } + } + } + } + if (!charOnly) { + // 左侧多余:视为整行删除 + for (int i = pairCount; i < leftLines.size(); i++) { + int aLineStart = safeLineStart(leftLineStarts, leftPos + i); + if (!(ignoreWhitespace && isAllWhitespace(leftLines.get(i)))) + textDiffSegments.add(new TextDiffSegment(DiffTypeEnum.DELETE, aLineStart, aLineStart + leftLines.get(i).length(), -1, -1)); + } + // 右侧多余:视为整行插入 + for (int i = pairCount; i < rightLines.size(); i++) { + int bLineStart = safeLineStart(rightLineStarts, rightPos + i); + if (!(ignoreWhitespace && isAllWhitespace(rightLines.get(i)))) + textDiffSegments.add(new TextDiffSegment(DiffTypeEnum.INSERT, -1, -1, bLineStart, bLineStart + rightLines.get(i).length())); + } + } + break; + } + case EQUAL: + // 不产生片段 + break; + } + } + + return new UIDiff(textDiffSegments); + } + + private static int[] computeLineStartOffsets(String text) { + int n = 1; // 至少一行 + for (int i = 0; i < text.length(); i++) { + char c = text.charAt(i); + if (c == '\n') n++; + } + int[] starts = new int[n]; + int idx = 0; + starts[idx++] = 0; + for (int i = 0; i < text.length(); i++) { + if (text.charAt(i) == '\n') { + if (idx < n) starts[idx++] = i + 1; + } + } + return starts; + } + + private static int safeLineStart(int[] starts, int lineIndex) { + if (lineIndex < 0) { + return 0; + } + if (lineIndex >= starts.length) { + return (starts.length == 0 ? 0 : starts[starts.length - 1]); + } + return starts[lineIndex]; + } + + public static List toChars(String s) { + List list = new ArrayList<>(s.length()); + for (int i = 0; i < s.length(); i++) { + list.add(String.valueOf(s.charAt(i))); + } + return list; + } + + public static boolean isAllWhitespace(String s) { + for (int i = 0; i < s.length(); i++) { + if (!Character.isWhitespace(s.charAt(i))) { + return false; + } + } + return true; + } + + public static boolean equalsIgnoreWhitespace(String a, String b) { + int i = 0, j = 0; + while (i < a.length() && j < b.length()) { + char ca = a.charAt(i); + char cb = b.charAt(j); + if (Character.isWhitespace(ca)) { + i++; + continue; + } + if (Character.isWhitespace(cb)) { + j++; + continue; + } + if (ca != cb) return false; + i++; + j++; + } + while (i < a.length()) { + if (!Character.isWhitespace(a.charAt(i++))) return false; + } + while (j < b.length()) { + if (!Character.isWhitespace(b.charAt(j++))) return false; + } + return true; + } + + /** + * 构建统一差异视图(包含文本与高亮区间) + */ + public static UnifiedView buildUnifiedView(String oldText, String newText, int context, boolean ignoreWhitespace) { + List oldLines = toLines(oldText); + List newLines = toLines(newText); + Patch patch = DiffUtils.diff(oldLines, newLines); + List unifiedLines = UnifiedDiffUtils.generateUnifiedDiff("old", "new", oldLines, patch, context); + + String unifiedText = String.join("\n", unifiedLines); + List lineSpans = new ArrayList<>(); + List charSpans = new ArrayList<>(); + int charEvents = 0; + + int offset = 0; + List minusStarts = new ArrayList<>(); + List minusTexts = new ArrayList<>(); + List plusStarts = new ArrayList<>(); + List plusTexts = new ArrayList<>(); + + for (int i = 0; i < unifiedLines.size(); i++) { + String line = unifiedLines.get(i); + int lineStart = offset; + int lineLen = line.length(); + int lineEnd = lineStart + lineLen; + + if (line.startsWith("@@")) { + lineSpans.add(new Span(lineStart, lineEnd, UnifiedSpanTypeEnum.HUNK_LINE)); + charEvents += addIntralineSpans(minusStarts, minusTexts, plusStarts, plusTexts, charSpans, ignoreWhitespace); + minusStarts.clear(); + minusTexts.clear(); + plusStarts.clear(); + plusTexts.clear(); + } else if (line.startsWith("+")) { + if (!(ignoreWhitespace && isAllWhitespace(line.substring(1)))) { + lineSpans.add(new Span(lineStart, lineEnd, UnifiedSpanTypeEnum.ADD_LINE)); + } + plusStarts.add(lineStart); + plusTexts.add(line.substring(1)); + } else if (line.startsWith("-")) { + if (!(ignoreWhitespace && isAllWhitespace(line.substring(1)))) { + lineSpans.add(new Span(lineStart, lineEnd, UnifiedSpanTypeEnum.DEL_LINE)); + } + minusStarts.add(lineStart); + minusTexts.add(line.substring(1)); + } else if (line.startsWith("---") || line.startsWith("+++")) { + lineSpans.add(new Span(lineStart, lineEnd, UnifiedSpanTypeEnum.HEADER_LINE)); + } + + offset = (i < unifiedLines.size() - 1) ? (lineEnd + 1) : lineEnd; + } + + charEvents += addIntralineSpans(minusStarts, minusTexts, plusStarts, plusTexts, charSpans, ignoreWhitespace); + + return new UnifiedView(unifiedText, lineSpans, charSpans, charEvents); + } + + private static int addIntralineSpans(List minusStarts, List minusTexts, + List plusStarts, List plusTexts, + List out, boolean ignoreWhitespace) { + int events = 0; + int pairs = Math.min(minusTexts.size(), plusTexts.size()); + for (int i = 0; i < pairs; i++) { + String a = minusTexts.get(i); + String b = plusTexts.get(i); + int aBase = minusStarts.get(i) + 1; // 跳过前缀符号 + int bBase = plusStarts.get(i) + 1; + Patch charPatch = DiffUtils.diff(toChars(a), toChars(b)); + for (AbstractDelta d : charPatch.getDeltas()) { + int aStart = aBase + d.getSource().getPosition(); + int aEnd = aStart + d.getSource().getLines().size(); + int bStart = bBase + d.getTarget().getPosition(); + int bEnd = bStart + d.getTarget().getLines().size(); + switch (d.getType()) { + case DELETE: + if (ignoreWhitespace && isAllWhitespace(safeSub(a, d.getSource().getPosition(), d.getSource().getLines().size()))) + continue; + out.add(new Span(aStart, aEnd, UnifiedSpanTypeEnum.DEL_CHAR)); + events++; + break; + case INSERT: + if (ignoreWhitespace && isAllWhitespace(safeSub(b, d.getTarget().getPosition(), d.getTarget().getLines().size()))) + continue; + out.add(new Span(bStart, bEnd, UnifiedSpanTypeEnum.ADD_CHAR)); + events++; + break; + case CHANGE: + String aSub = safeSub(a, d.getSource().getPosition(), d.getSource().getLines().size()); + String bSub = safeSub(b, d.getTarget().getPosition(), d.getTarget().getLines().size()); + if (ignoreWhitespace && equalsIgnoreWhitespace(aSub, bSub)) continue; + if (!aSub.isEmpty()) out.add(new Span(aStart, aEnd, UnifiedSpanTypeEnum.CHANGE_CHAR)); + if (!bSub.isEmpty()) out.add(new Span(bStart, bEnd, UnifiedSpanTypeEnum.CHANGE_CHAR)); + events++; + break; + case EQUAL: + break; + } + } + } + return events; + } + + private static String safeSub(String s, int pos, int len) { + int start = Math.max(0, Math.min(pos, s.length())); + int end = Math.max(0, Math.min(pos + len, s.length())); + if (end < start) { + end = start; + } + return s.substring(start, end); + } + +} diff --git a/src/main/java/com/luoboduner/moo/tool/ui/FuncConsts.java b/src/main/java/com/luoboduner/moo/tool/ui/FuncConsts.java index 4a4ea98d..bb334281 100644 --- a/src/main/java/com/luoboduner/moo/tool/ui/FuncConsts.java +++ b/src/main/java/com/luoboduner/moo/tool/ui/FuncConsts.java @@ -26,4 +26,9 @@ public class FuncConsts { * 二维码 */ public static final String QR_CODE = "QrCode"; + + /** + * 文本对比 + */ + public static final String TEXT_DIFF = "TextDiff"; } diff --git a/src/main/java/com/luoboduner/moo/tool/ui/Init.java b/src/main/java/com/luoboduner/moo/tool/ui/Init.java index bae6a3ea..0b0e5a19 100644 --- a/src/main/java/com/luoboduner/moo/tool/ui/Init.java +++ b/src/main/java/com/luoboduner/moo/tool/ui/Init.java @@ -213,6 +213,7 @@ public class Init { ThreadUtil.execute(ImageForm::init); ThreadUtil.execute(VariablesForm::init); ThreadUtil.execute(YmlPropertiesForm::init); + ThreadUtil.execute(TextDiffForm::init); ThreadUtil.execute(FileReformattingForm::init); // 检查新版版 diff --git a/src/main/java/com/luoboduner/moo/tool/ui/form/MainWindow.form b/src/main/java/com/luoboduner/moo/tool/ui/form/MainWindow.form index 5c157982..dfba9cf1 100644 --- a/src/main/java/com/luoboduner/moo/tool/ui/form/MainWindow.form +++ b/src/main/java/com/luoboduner/moo/tool/ui/form/MainWindow.form @@ -210,6 +210,15 @@ + + + + + + + + + diff --git a/src/main/java/com/luoboduner/moo/tool/ui/form/MainWindow.java b/src/main/java/com/luoboduner/moo/tool/ui/form/MainWindow.java index f580fb0f..4067fbae 100644 --- a/src/main/java/com/luoboduner/moo/tool/ui/form/MainWindow.java +++ b/src/main/java/com/luoboduner/moo/tool/ui/form/MainWindow.java @@ -47,11 +47,12 @@ public class MainWindow { private JPanel pdfPanel; private JPanel variablesPanel; private JPanel ymlProperties; + private JPanel textDiffPanel; private JPanel aboutPanel; private static MainWindow mainWindow; - private static final String[] ICON_PATH = {"icon/smile.svg", "icon/edit.svg", "icon/time.svg", "icon/json.svg", "icon/translate.svg", "icon/check.svg", "icon/global.svg", "icon/exchange.svg", "icon/QRcode.svg", "icon/method.svg", "icon/calculate.svg", "icon/network.svg", "icon/color.svg", "icon/image.svg", "icon/schedule.svg", "icon/reg.svg", "icon/java.svg", "icon/format_painter.svg", "icon/pdf.svg", "icon/fx.svg", "icon/suffix-yml.svg"}; + private static final String[] ICON_PATH = {"icon/smile.svg", "icon/edit.svg", "icon/time.svg", "icon/json.svg", "icon/translate.svg", "icon/check.svg", "icon/global.svg", "icon/exchange.svg", "icon/QRcode.svg", "icon/method.svg", "icon/calculate.svg", "icon/network.svg", "icon/color.svg", "icon/image.svg", "icon/schedule.svg", "icon/reg.svg", "icon/java.svg", "icon/format_painter.svg", "icon/pdf.svg", "icon/fx.svg", "icon/suffix-yml.svg", "icon/find.svg"}; private MainWindow() { } @@ -96,6 +97,7 @@ public class MainWindow { mainWindow.getPdfPanel().add(PdfForm.getInstance().getPdfPanel(), gridConstraints); mainWindow.getVariablesPanel().add(VariablesForm.getInstance().getVariablesPanel(), gridConstraints); mainWindow.getYmlProperties().add(YmlPropertiesForm.getInstance().getYmlPropertiesPanel(), gridConstraints); + mainWindow.getTextDiffPanel().add(TextDiffForm.getInstance().getTextDiffPanel(), gridConstraints); mainWindow.getMainPanel().updateUI(); TabListener.addListeners(); @@ -285,6 +287,9 @@ public class MainWindow { ymlProperties = new JPanel(); ymlProperties.setLayout(new GridLayoutManager(1, 1, new Insets(0, 0, 0, 0), -1, -1)); tabbedPane.addTab("配置文件转换", ymlProperties); + textDiffPanel = new JPanel(); + textDiffPanel.setLayout(new GridLayoutManager(1, 1, new Insets(0, 0, 0, 0), -1, -1)); + tabbedPane.addTab("文本差异比对", textDiffPanel); } /** diff --git a/src/main/java/com/luoboduner/moo/tool/ui/form/func/TextDiffForm.java b/src/main/java/com/luoboduner/moo/tool/ui/form/func/TextDiffForm.java new file mode 100644 index 00000000..8f007578 --- /dev/null +++ b/src/main/java/com/luoboduner/moo/tool/ui/form/func/TextDiffForm.java @@ -0,0 +1,966 @@ +package com.luoboduner.moo.tool.ui.form.func; + +import cn.hutool.log.Log; +import cn.hutool.log.LogFactory; +import com.formdev.flatlaf.extras.FlatSVGIcon; +import com.intellij.uiDesigner.core.GridConstraints; +import com.intellij.uiDesigner.core.GridLayoutManager; +import com.intellij.uiDesigner.core.Spacer; +import com.luoboduner.moo.tool.bean.textdiff.UIDiff; +import com.luoboduner.moo.tool.bean.textdiff.UnifiedView; +import com.luoboduner.moo.tool.dao.TFuncContentMapper; +import com.luoboduner.moo.tool.domain.TFuncContent; +import com.luoboduner.moo.tool.service.DiffService; +import com.luoboduner.moo.tool.ui.FuncConsts; +import com.luoboduner.moo.tool.ui.listener.func.TextDiffListener; +import com.luoboduner.moo.tool.util.MybatisUtil; +import com.luoboduner.moo.tool.util.ScrollUtil; +import com.luoboduner.moo.tool.util.SqliteUtil; +import com.luoboduner.moo.tool.util.UndoUtil; +import lombok.Getter; +import org.fife.ui.rsyntaxtextarea.RSyntaxTextArea; +import org.fife.ui.rsyntaxtextarea.SyntaxConstants; +import org.fife.ui.rtextarea.RTextScrollPane; + +import javax.swing.BorderFactory; +import javax.swing.JButton; +import javax.swing.JCheckBox; +import javax.swing.JComboBox; +import javax.swing.JComponent; +import javax.swing.JLabel; +import javax.swing.JPanel; +import javax.swing.JScrollBar; +import javax.swing.JScrollPane; +import javax.swing.JSplitPane; +import javax.swing.border.TitledBorder; +import javax.swing.text.BadLocationException; +import javax.swing.text.DefaultHighlighter; +import javax.swing.text.Highlighter; +import java.awt.Color; +import java.awt.Dimension; +import java.awt.Insets; +import java.awt.Toolkit; +import java.util.ArrayList; +import java.util.List; +import java.util.Objects; + +/** + *
+ * TextDiffForm - 文本对比功能界面
+ * 
+ * + * @author CassianFlorin + * @email flowercard591@gmail.com + * @date 2025/9/27 10:51 + */ +@Getter +public class TextDiffForm { + private JPanel textDiffPanel; + private JPanel leftPanel; + private JPanel rightPanel; + private JPanel toolBarPanel; + private JButton compareButton; + private JButton clearButton; + private JButton swapButton; + private JButton copyDiffButton; + private JButton prevDiffButton; + private JButton nextDiffButton; + private JComboBox highlightModeComboBox; + private JLabel statusLabel; + private JCheckBox realTimeCheckBox; + private JCheckBox ignoreWhitespaceCheckBox; + private JComboBox displayModeComboBox; + private JSplitPane mainSplitPane; + private JScrollPane leftScrollPane; + private JScrollPane rightScrollPane; + private JScrollPane unifiedScrollPane; + private JPanel unifiedPanel; + + private RSyntaxTextArea leftTextArea; + private RSyntaxTextArea rightTextArea; + private RSyntaxTextArea unifiedTextArea; + + // Diff highlight painters + private final DefaultHighlighter.DefaultHighlightPainter delPainter = + new DefaultHighlighter.DefaultHighlightPainter(new Color(255, 204, 204)); // light red for deletions + private final DefaultHighlighter.DefaultHighlightPainter insPainter = + new DefaultHighlighter.DefaultHighlightPainter(new Color(204, 255, 204)); // light green for insertions + private final DefaultHighlighter.DefaultHighlightPainter changePainter = + new DefaultHighlighter.DefaultHighlightPainter(new Color(255, 249, 196)); // light yellow for modifications + + // 滚动同步状态标记,避免循环触发 + private boolean syncingScroll = false; + + // 行级高亮(带透明度) + private final DefaultHighlighter.DefaultHighlightPainter delLinePainter = + new DefaultHighlighter.DefaultHighlightPainter(new Color(255, 102, 102, 60)); + private final DefaultHighlighter.DefaultHighlightPainter insLinePainter = + new DefaultHighlighter.DefaultHighlightPainter(new Color(0, 153, 0, 60)); + private final DefaultHighlighter.DefaultHighlightPainter changeLinePainter = + new DefaultHighlighter.DefaultHighlightPainter(new Color(255, 193, 7, 60)); + + // 统一视图:hunk 或标题行高亮 + private final DefaultHighlighter.DefaultHighlightPainter headerLinePainter = + new DefaultHighlighter.DefaultHighlightPainter(new Color(230, 230, 230)); + + // 差异导航锚点与索引(按左/右侧段的中点行) + private final List leftNavOffsets = new ArrayList<>(); + private final List rightNavOffsets = new ArrayList<>(); + private int navIndex = -1; + + private enum HighlightMode { CHAR_ONLY, LINE_ONLY, BOTH } + private HighlightMode highlightMode = HighlightMode.BOTH; + + private static TextDiffForm textDiffForm; + + private static final Log logger = LogFactory.get(); + + private static final TFuncContentMapper funcContentMapper = MybatisUtil.getSqlSession().getMapper(TFuncContentMapper.class); + + private TextDiffForm() { + UndoUtil.register(this); + initTextAreas(); + } + + public static TextDiffForm getInstance() { + if (textDiffForm == null) { + textDiffForm = new TextDiffForm(); + } + return textDiffForm; + } + + public static void init() { + textDiffForm = getInstance(); + textDiffForm.initForm(); + } + + /** + * 初始化文本区域 + */ + private void initTextAreas() { + // 左侧文本区域 + leftTextArea = new RSyntaxTextArea(); + leftTextArea.setSyntaxEditingStyle(SyntaxConstants.SYNTAX_STYLE_NONE); + leftTextArea.setCodeFoldingEnabled(true); + leftTextArea.setAntiAliasingEnabled(true); + leftTextArea.setAutoIndentEnabled(true); + leftTextArea.setMarkOccurrences(true); + leftTextArea.setPaintMarkOccurrencesBorder(true); + leftTextArea.setTabSize(4); + leftTextArea.setCaretPosition(0); + + // 右侧文本区域 + rightTextArea = new RSyntaxTextArea(); + rightTextArea.setSyntaxEditingStyle(SyntaxConstants.SYNTAX_STYLE_NONE); + rightTextArea.setCodeFoldingEnabled(true); + rightTextArea.setAntiAliasingEnabled(true); + rightTextArea.setAutoIndentEnabled(true); + rightTextArea.setMarkOccurrences(true); + rightTextArea.setPaintMarkOccurrencesBorder(true); + rightTextArea.setTabSize(4); + rightTextArea.setCaretPosition(0); + + // 统一diff显示区域 + unifiedTextArea = new RSyntaxTextArea(); + unifiedTextArea.setSyntaxEditingStyle(SyntaxConstants.SYNTAX_STYLE_JAVA); + unifiedTextArea.setCodeFoldingEnabled(true); + unifiedTextArea.setAntiAliasingEnabled(true); + unifiedTextArea.setEditable(false); + unifiedTextArea.setTabSize(4); + unifiedTextArea.setCaretPosition(0); + + // 创建滚动面板 + leftScrollPane = new RTextScrollPane(leftTextArea); + rightScrollPane = new RTextScrollPane(rightTextArea); + unifiedScrollPane = new RTextScrollPane(unifiedTextArea); + + ScrollUtil.smoothPane(leftScrollPane); + ScrollUtil.smoothPane(rightScrollPane); + ScrollUtil.smoothPane(unifiedScrollPane); + + // 同步左右滚动(便于并排对比时直观比照) + setupScrollSync(); + + // 将滚动面板添加到相应的面板中 + if (leftPanel != null) { + leftPanel.add( + leftScrollPane, + new GridConstraints( + 0, + 0, + 1, + 1, + GridConstraints.ANCHOR_CENTER, + GridConstraints.FILL_BOTH, + GridConstraints.SIZEPOLICY_CAN_SHRINK | GridConstraints.SIZEPOLICY_CAN_GROW, + GridConstraints.SIZEPOLICY_CAN_SHRINK | GridConstraints.SIZEPOLICY_CAN_GROW, + null, + null, + null, + 0, + false + ) + ); + } + if (rightPanel != null) { + rightPanel.add( + rightScrollPane, + new GridConstraints( + 0, + 0, + 1, + 1, + GridConstraints.ANCHOR_CENTER, + GridConstraints.FILL_BOTH, + GridConstraints.SIZEPOLICY_CAN_SHRINK | GridConstraints.SIZEPOLICY_CAN_GROW, + GridConstraints.SIZEPOLICY_CAN_SHRINK | GridConstraints.SIZEPOLICY_CAN_GROW, + null, + null, + null, + 0, + false + ) + ); + } + if (unifiedPanel != null) { + unifiedPanel.add(unifiedScrollPane, + new GridConstraints(0, + 0, + 1, + 1, + GridConstraints.ANCHOR_CENTER, + GridConstraints.FILL_BOTH, + GridConstraints.SIZEPOLICY_CAN_SHRINK | GridConstraints.SIZEPOLICY_CAN_GROW, + GridConstraints.SIZEPOLICY_CAN_SHRINK | GridConstraints.SIZEPOLICY_CAN_GROW, + null, + null, + null, + 0, + false + ) + ); + } + } + + /** + * 左右窗口竖向滚动同步 + */ + private void setupScrollSync() { + if (!(leftScrollPane instanceof RTextScrollPane) || !(rightScrollPane instanceof RTextScrollPane)) { + return; + } + // 获取滚动条 + JScrollBar leftBar = leftScrollPane.getVerticalScrollBar(); + JScrollBar rightBar = rightScrollPane.getVerticalScrollBar(); + if (leftBar == null || rightBar == null) { + return; + } + + // 滚动条滚动时,同步滚动 + leftBar.addAdjustmentListener(e -> { + if (syncingScroll) return; + syncingScroll = true; + rightBar.setValue(e.getValue()); + syncingScroll = false; + }); + rightBar.addAdjustmentListener(e -> { + if (syncingScroll) return; + syncingScroll = true; + leftBar.setValue(e.getValue()); + syncingScroll = false; + }); + } + + /** + * 初始化 + */ + public void initForm() { + TextDiffListener.addListeners(); + + // 设置默认显示模式 + displayModeComboBox.addItem("并排对比"); + displayModeComboBox.addItem("统一差异"); + displayModeComboBox.setSelectedIndex(0); + + // 高亮模式(默认仅字符,避免把相同部分也高亮) + highlightModeComboBox.addItem("双层高亮"); + highlightModeComboBox.addItem("仅字符"); + highlightModeComboBox.addItem("仅整行"); + highlightModeComboBox.setSelectedIndex(1); + highlightModeComboBox.addActionListener(e -> { + switch ((String) Objects.requireNonNull(highlightModeComboBox.getSelectedItem())) { + case "仅字符": + highlightMode = HighlightMode.CHAR_ONLY; break; + case "仅整行": + highlightMode = HighlightMode.LINE_ONLY; break; + default: + highlightMode = HighlightMode.BOTH; + } + if (realTimeCheckBox.isSelected()) { + performDiff(); + } + }); + + // 默认开启实时对比 + realTimeCheckBox.setSelected(true); + + // 初始化布局 + updateDisplayMode(); + + // 恢复上次的内容 + initContent(); + } + + /** + * 初始化内容 + */ + private void initContent() { + try { + TFuncContent tFuncContent = funcContentMapper.selectByFunc(FuncConsts.TEXT_DIFF + "_left"); + if (tFuncContent != null) { + leftTextArea.setText(tFuncContent.getContent()); + } + + tFuncContent = funcContentMapper.selectByFunc(FuncConsts.TEXT_DIFF + "_right"); + if (tFuncContent != null) { + rightTextArea.setText(tFuncContent.getContent()); + } + } catch (Exception e) { + logger.error("初始化文本对比内容失败", e); + } + } + + /** + * 保存内容 + */ + public void save() { + try { + saveLeftContent(); + saveRightContent(); + } catch (Exception e) { + logger.error("保存文本对比内容失败", e); + } + } + + /** + * 保存左侧内容 + */ + private void saveLeftContent() { + String text = leftTextArea.getText(); + String now = SqliteUtil.nowDateForSqlite(); + String funcKey = FuncConsts.TEXT_DIFF + "_left"; + + saveContent(text, now, funcKey); + } + + /** + * 保存内容 + * @param text 文本 + * @param now 时间 + * @param funcKey 函数 + */ + private void saveContent(String text, String now, String funcKey) { + TFuncContent tFuncContent = funcContentMapper.selectByFunc(funcKey); + if (tFuncContent == null) { + tFuncContent = new TFuncContent(); + tFuncContent.setFunc(funcKey); + tFuncContent.setContent(text); + tFuncContent.setCreateTime(now); + tFuncContent.setModifiedTime(now); + funcContentMapper.insert(tFuncContent); + } else { + tFuncContent.setContent(text); + tFuncContent.setModifiedTime(now); + funcContentMapper.updateByPrimaryKeySelective(tFuncContent); + } + } + + /** + * 保存右侧内容 + */ + private void saveRightContent() { + String text = rightTextArea.getText(); + String now = SqliteUtil.nowDateForSqlite(); + String funcKey = FuncConsts.TEXT_DIFF + "_right"; + + saveContent(text, now, funcKey); + } + + /** + * 更新显示模式 + */ + public void updateDisplayMode() { + String selectedMode = (String) displayModeComboBox.getSelectedItem(); + + if ("并排对比".equals(selectedMode)) { + // 显示并排对比模式 + mainSplitPane.setLeftComponent(leftScrollPane); + mainSplitPane.setRightComponent(rightScrollPane); + mainSplitPane.setDividerLocation(0.5); + unifiedPanel.setVisible(false); + } else { + // 显示统一差异模式 + mainSplitPane.setLeftComponent(leftScrollPane); + mainSplitPane.setRightComponent(rightScrollPane); + mainSplitPane.setDividerLocation(0.5); + unifiedPanel.setVisible(true); + } + + textDiffPanel.revalidate(); + textDiffPanel.repaint(); + } + + /** + * 执行文本对比 + */ + public void performDiff() { + String leftText = leftTextArea.getText(); + String rightText = rightTextArea.getText(); + + if (leftText.isEmpty() && rightText.isEmpty()) { + statusLabel.setText("请输入要对比的文本"); + return; + } + + try { + // 统一差异视图(文本 + 高亮区间) + boolean ignoreWs = ignoreWhitespaceCheckBox != null && ignoreWhitespaceCheckBox.isSelected(); + var unifiedView = DiffService.buildUnifiedView(leftText, rightText, 3, ignoreWs); + unifiedTextArea.setText(unifiedView.text()); + + // 获取用于UI高亮的差异信息(基于字符区间) + boolean charOnlyNav = (highlightMode == HighlightMode.CHAR_ONLY); + var diffResult = DiffService.getSegmentsForUI(leftText, rightText, ignoreWs, charOnlyNav); + + // 构建导航锚点:每个差异段恰好追加一项(左/右没有则为 -1) + leftNavOffsets.clear(); + rightNavOffsets.clear(); + navIndex = -1; + if (diffResult.segments() != null) { + for (var seg : diffResult.segments()) { + int lMid = (seg.leftStart() >= 0 && seg.leftEnd() >= 0) + ? (seg.leftStart() + seg.leftEnd()) / 2 : -1; + int rMid = (seg.rightStart() >= 0 && seg.rightEnd() >= 0) + ? (seg.rightStart() + seg.rightEnd()) / 2 : -1; + leftNavOffsets.add(lMid); + rightNavOffsets.add(rMid); + } + } + + // 左右并排的字符/行高亮 + applyDiffHighlight(diffResult); + // 统一差异视图的行级/字符级高亮 + applyUnifiedSpans(unifiedView); + + // 更新状态:与可跳转点一致 + int changes = (diffResult.segments() != null) ? diffResult.segments().size() : 0; + if (highlightMode == HighlightMode.CHAR_ONLY) { + statusLabel.setText("字符差异 " + changes + " 处"); + } else { + statusLabel.setText("对比完成,共发现 " + changes + " 处差异"); + } + + } catch (Exception e) { + logger.error("执行文本对比失败", e); + statusLabel.setText("对比失败:" + e.getMessage()); + } + } + + /** + * 清除两侧与统一视图上的所有高亮 + */ + private void clearHighlights() { + if (leftTextArea != null && leftTextArea.getHighlighter() != null) { + leftTextArea.getHighlighter().removeAllHighlights(); + } + if (rightTextArea != null && rightTextArea.getHighlighter() != null) { + rightTextArea.getHighlighter().removeAllHighlights(); + } + if (unifiedTextArea != null && unifiedTextArea.getHighlighter() != null) { + unifiedTextArea.getHighlighter().removeAllHighlights(); + } + } + + /** + * 应用差异高亮显示 + */ + private void applyDiffHighlight(UIDiff diffResult) { + clearHighlights(); + if (diffResult == null || diffResult.segments() == null) { + return; + } + + // 删除、插入、修改 + for (var seg : diffResult.segments()) { + switch (seg.type()) { + case DELETE: { + if (highlightMode != HighlightMode.LINE_ONLY && seg.leftStart() >= 0 && seg.leftEnd() >= 0) + safeAddHighlight(leftTextArea, seg.leftStart(), seg.leftEnd(), delPainter); + if (highlightMode != HighlightMode.CHAR_ONLY && seg.leftStart() >= 0 && seg.leftEnd() >= 0) + safeAddLineHighlights(leftTextArea, seg.leftStart(), seg.leftEnd(), delLinePainter); + break; + } + case INSERT: { + if (highlightMode != HighlightMode.LINE_ONLY && seg.rightStart() >= 0 && seg.rightEnd() >= 0) + safeAddHighlight(rightTextArea, seg.rightStart(), seg.rightEnd(), insPainter); + if (highlightMode != HighlightMode.CHAR_ONLY && seg.rightStart() >= 0 && seg.rightEnd() >= 0) + safeAddLineHighlights(rightTextArea, seg.rightStart(), seg.rightEnd(), insLinePainter); + break; + } + case CHANGE: { + if (highlightMode != HighlightMode.LINE_ONLY) { + if (seg.leftStart() >= 0 && seg.leftEnd() >= 0) + safeAddHighlight(leftTextArea, seg.leftStart(), seg.leftEnd(), changePainter); + if (seg.rightStart() >= 0 && seg.rightEnd() >= 0) + safeAddHighlight(rightTextArea, seg.rightStart(), seg.rightEnd(), changePainter); + } + if (highlightMode != HighlightMode.CHAR_ONLY) { + if (seg.leftStart() >= 0 && seg.leftEnd() >= 0) + safeAddLineHighlights(leftTextArea, seg.leftStart(), seg.leftEnd(), changeLinePainter); + if (seg.rightStart() >= 0 && seg.rightEnd() >= 0) + safeAddLineHighlights(rightTextArea, seg.rightStart(), seg.rightEnd(), changeLinePainter); + } + break; + } + default: + break; + } + } + } + + /** + * 在给定 TextArea 上安全添加高亮(自动裁剪区间并捕获异常) + */ + private void safeAddHighlight(RSyntaxTextArea ta, int start, int end, Highlighter.HighlightPainter painter) { + if (ta == null || painter == null) { + return; + } + try { + int docLen = ta.getDocument().getLength(); + int s = Math.max(0, Math.min(start, docLen)); + int e = Math.max(0, Math.min(end, docLen)); + if (e > s) { + ta.getHighlighter().addHighlight(s, e, painter); + } + } catch (BadLocationException ex) { + logger.warn("添加高亮失败: {}", ex.getMessage()); + } + } + + /** + * 以行为单位添加高亮:把 [start, end) 覆盖到完整的行区间并逐行高亮 + */ + private void safeAddLineHighlights(RSyntaxTextArea ta, int start, int end, Highlighter.HighlightPainter painter) { + if (ta == null || painter == null) return; + try { + int docLen = ta.getDocument().getLength(); + int s = Math.max(0, Math.min(start, docLen)); + int e = Math.max(0, Math.min(end, docLen)); + if (e <= s) return; + + int startLine = ta.getLineOfOffset(s); + int endLine = ta.getLineOfOffset(Math.max(s, e - 1)); + for (int line = startLine; line <= endLine; line++) { + int ls = ta.getLineStartOffset(line); + int le = ta.getLineEndOffset(line); + // 为整行添加一层半透明底色,便于两侧比照 + ta.getHighlighter().addHighlight(ls, le, painter); + } + } catch (BadLocationException ex) { + logger.warn("添加行级高亮失败: {}", ex.getMessage()); + } + } + + private void gotoDiff(int index) { + if (leftNavOffsets.isEmpty() && rightNavOffsets.isEmpty()) { + return; + } + int max = Math.max(leftNavOffsets.size(), rightNavOffsets.size()); + navIndex = ((index % max) + max) % max; // 环绕 + int lOff = (navIndex < leftNavOffsets.size()) ? leftNavOffsets.get(navIndex) : -1; + int rOff = (navIndex < rightNavOffsets.size()) ? rightNavOffsets.get(navIndex) : -1; + if (lOff >= 0) { + centerOnOffset(leftTextArea, lOff); + } + if (rOff >= 0) { + centerOnOffset(rightTextArea, rOff); + } + statusLabel.setText("跳至第 " + (navIndex + 1) + "/" + max + " 处差异"); + } + + private void centerOnOffset(RSyntaxTextArea ta, int offset) { + try { + int line = ta.getLineOfOffset(Math.max(0, Math.min(offset, ta.getDocument().getLength()))); + int start = ta.getLineStartOffset(line); + ta.setCaretPosition(start); + ta.requestFocusInWindow(); + } catch (BadLocationException ignore) { } + } + + private void gotoNextDiff() { gotoDiff(navIndex + 1); } + private void gotoPrevDiff() { gotoDiff(navIndex - 1); } + + /** + * 统一差异视图高亮: + * - 行级:+ 绿色, - 红色, @@ hunk 行黄色, 文件头灰色 + * - 字符级:对同一 hunk 中的 +/- 行按序配对做字符级 diff,仅高亮变更范围 + * 渲染统一差异视图的高亮(使用服务层返回的区间) + */ + private void applyUnifiedSpans(UnifiedView view) { + if (view == null) return; + // 行级底色 + if (view.lineSpans() != null) { + for (var sp : view.lineSpans()) { + switch (sp.type()) { + case ADD_LINE -> safeAddLineHighlights(unifiedTextArea, sp.start(), sp.end(), insLinePainter); + case DEL_LINE -> safeAddLineHighlights(unifiedTextArea, sp.start(), sp.end(), delLinePainter); + case HUNK_LINE -> safeAddLineHighlights(unifiedTextArea, sp.start(), sp.end(), changeLinePainter); + case HEADER_LINE -> safeAddLineHighlights(unifiedTextArea, sp.start(), sp.end(), headerLinePainter); + default -> {} + } + } + } + // 字符级高亮 + if (view.charSpans() != null && highlightMode != HighlightMode.LINE_ONLY) { + for (var sp : view.charSpans()) { + switch (sp.type()) { + case ADD_CHAR -> safeAddHighlight(unifiedTextArea, sp.start(), sp.end(), insPainter); + case DEL_CHAR -> safeAddHighlight(unifiedTextArea, sp.start(), sp.end(), delPainter); + case CHANGE_CHAR -> safeAddHighlight(unifiedTextArea, sp.start(), sp.end(), changePainter); + default -> {} + } + } + } + } + + /** + * 清空所有文本 + */ + public void clearAll() { + clearHighlights(); + leftTextArea.setText(""); + rightTextArea.setText(""); + unifiedTextArea.setText(""); + statusLabel.setText("已清空"); + } + + /** + * 交换左右文本 + */ + public void swapTexts() { + String leftText = leftTextArea.getText(); + String rightText = rightTextArea.getText(); + + leftTextArea.setText(rightText); + rightTextArea.setText(leftText); + + // 交换后也做一次对比,确保高亮与内容同步 + performDiff(); + + statusLabel.setText("已交换文本"); + } + + /** + * 复制差异结果 + */ + public void copyDiffResult() { + String diffText = unifiedTextArea.getText(); + if (!diffText.isEmpty()) { + Toolkit.getDefaultToolkit().getSystemClipboard() + .setContents(new java.awt.datatransfer.StringSelection(diffText), null); + statusLabel.setText("差异结果已复制到剪贴板"); + } else { + statusLabel.setText("没有差异结果可复制"); + } + } + + { +// GUI initializer generated by IntelliJ IDEA GUI Designer +// >>> IMPORTANT!! <<< +// DO NOT EDIT OR ADD ANY CODE HERE! + $$$setupUI$$$(); + prevDiffButton.addActionListener(e -> gotoPrevDiff()); + nextDiffButton.addActionListener(e -> gotoNextDiff()); + } + + /** + * Method generated by IntelliJ IDEA GUI Designer + * >>> IMPORTANT!! <<< + * DO NOT edit this method OR call it in your code! + * + * @noinspection ALL + */ + private void $$$setupUI$$$() { + textDiffPanel = new JPanel(); + textDiffPanel.setLayout(new GridLayoutManager(3, 1, new Insets(10, 10, 10, 10), -1, -1)); + + // 工具栏面板 + toolBarPanel = new JPanel(); + toolBarPanel.setLayout(new GridLayoutManager(1, 12, new Insets(0, 0, 10, 0), -1, -1)); + textDiffPanel.add(toolBarPanel, new GridConstraints(0, + 0, + 1, + 1, + GridConstraints.ANCHOR_CENTER, + GridConstraints.FILL_BOTH, + GridConstraints.SIZEPOLICY_CAN_SHRINK | GridConstraints.SIZEPOLICY_CAN_GROW, + GridConstraints.SIZEPOLICY_FIXED, + null, + null, + null, + 0, + false)); + + compareButton = new JButton(); + compareButton.setText("对比"); + compareButton.setIcon(new FlatSVGIcon("icon/find.svg")); + toolBarPanel.add(compareButton, new GridConstraints(0, + 0, + 1, + 1, + GridConstraints.ANCHOR_CENTER, + GridConstraints.FILL_HORIZONTAL, + GridConstraints.SIZEPOLICY_CAN_SHRINK | GridConstraints.SIZEPOLICY_CAN_GROW, + GridConstraints.SIZEPOLICY_FIXED, + null, + null, + null, + 0, + false)); + + clearButton = new JButton(); + clearButton.setText("清空"); + clearButton.setIcon(new FlatSVGIcon("icon/remove.svg")); + toolBarPanel.add(clearButton, new GridConstraints(0, + 1, + 1, + 1, + GridConstraints.ANCHOR_CENTER, + GridConstraints.FILL_HORIZONTAL, + GridConstraints.SIZEPOLICY_CAN_SHRINK | GridConstraints.SIZEPOLICY_CAN_GROW, + GridConstraints.SIZEPOLICY_FIXED, + null, + null, + null, + 0, + false)); + + swapButton = new JButton(); + swapButton.setText("交换"); + swapButton.setIcon(new FlatSVGIcon("icon/exchange.svg")); + toolBarPanel.add(swapButton, new GridConstraints(0, + 2, + 1, + 1, + GridConstraints.ANCHOR_CENTER, + GridConstraints.FILL_HORIZONTAL, + GridConstraints.SIZEPOLICY_CAN_SHRINK | GridConstraints.SIZEPOLICY_CAN_GROW, + GridConstraints.SIZEPOLICY_FIXED, + null, + null, + null, + 0, + false)); + + copyDiffButton = new JButton(); + copyDiffButton.setText("复制差异"); + copyDiffButton.setIcon(new FlatSVGIcon("icon/copy.svg")); + toolBarPanel.add(copyDiffButton, new GridConstraints(0, + 3, + 1, + 1, + GridConstraints.ANCHOR_CENTER, + GridConstraints.FILL_HORIZONTAL, + GridConstraints.SIZEPOLICY_CAN_SHRINK | GridConstraints.SIZEPOLICY_CAN_GROW, + GridConstraints.SIZEPOLICY_FIXED, + null, + null, + null, + 0, + false)); + + prevDiffButton = new JButton(); + prevDiffButton.setText("上一处"); + toolBarPanel.add(prevDiffButton, new GridConstraints(0, + 4, + 1, + 1, + GridConstraints.ANCHOR_CENTER, + GridConstraints.FILL_HORIZONTAL, + GridConstraints.SIZEPOLICY_CAN_SHRINK | GridConstraints.SIZEPOLICY_CAN_GROW, + GridConstraints.SIZEPOLICY_FIXED, + null, + null, + null, + 0, + false)); + + nextDiffButton = new JButton(); + nextDiffButton.setText("下一处"); + toolBarPanel.add(nextDiffButton, + new GridConstraints(0, + 5, + 1, + 1, + GridConstraints.ANCHOR_CENTER, + GridConstraints.FILL_HORIZONTAL, + GridConstraints.SIZEPOLICY_CAN_SHRINK | GridConstraints.SIZEPOLICY_CAN_GROW, + GridConstraints.SIZEPOLICY_FIXED, + null, + null, + null, + 0, + false)); + + realTimeCheckBox = new JCheckBox(); + realTimeCheckBox.setText("实时对比"); + toolBarPanel.add(realTimeCheckBox, new GridConstraints(0, + 6, + 1, + 1, + GridConstraints.ANCHOR_WEST, + GridConstraints.FILL_NONE, + GridConstraints.SIZEPOLICY_CAN_SHRINK | GridConstraints.SIZEPOLICY_CAN_GROW, + GridConstraints.SIZEPOLICY_FIXED, + null, + null, + null, + 0, + false)); + + ignoreWhitespaceCheckBox = new JCheckBox(); + ignoreWhitespaceCheckBox.setText("忽略空白"); + toolBarPanel.add(ignoreWhitespaceCheckBox, new GridConstraints(0, + 7, + 1, + 1, + GridConstraints.ANCHOR_WEST, + GridConstraints.FILL_NONE, + GridConstraints.SIZEPOLICY_CAN_SHRINK | GridConstraints.SIZEPOLICY_CAN_GROW, + GridConstraints.SIZEPOLICY_FIXED, + null, + null, + null, + 0, + false)); + + highlightModeComboBox = new JComboBox<>(); + toolBarPanel.add(highlightModeComboBox, new GridConstraints(0, + 8, + 1, + 1, + GridConstraints.ANCHOR_WEST, + GridConstraints.FILL_HORIZONTAL, + GridConstraints.SIZEPOLICY_CAN_GROW, + GridConstraints.SIZEPOLICY_FIXED, + null, + null, + null, + 0, + false)); + + displayModeComboBox = new JComboBox<>(); + toolBarPanel.add(displayModeComboBox, new GridConstraints(0, + 9, + 1, + 1, + GridConstraints.ANCHOR_WEST, + GridConstraints.FILL_HORIZONTAL, + GridConstraints.SIZEPOLICY_CAN_GROW, + GridConstraints.SIZEPOLICY_FIXED, + null, + null, + null, + 0, + false)); + + final Spacer spacer1 = new Spacer(); + toolBarPanel.add(spacer1, new GridConstraints(0, + 10, + 1, + 1, + GridConstraints.ANCHOR_CENTER, + GridConstraints.FILL_HORIZONTAL, + GridConstraints.SIZEPOLICY_WANT_GROW, + 1, + null, + null, + null, + 0, + false)); + + statusLabel = new JLabel(); + statusLabel.setText("准备就绪"); + toolBarPanel.add(statusLabel, new GridConstraints(0, + 11, + 1, + 1, + GridConstraints.ANCHOR_WEST, + GridConstraints.FILL_NONE, + GridConstraints.SIZEPOLICY_FIXED, + GridConstraints.SIZEPOLICY_FIXED, + null, + null, + null, + 0, + false)); + + // 主分割面板 + mainSplitPane = new JSplitPane(); + mainSplitPane.setDividerLocation(400); + mainSplitPane.setResizeWeight(0.5); + textDiffPanel.add(mainSplitPane, new GridConstraints(1, + 0, + 1, + 1, + GridConstraints.ANCHOR_CENTER, + GridConstraints.FILL_BOTH, + GridConstraints.SIZEPOLICY_CAN_SHRINK | GridConstraints.SIZEPOLICY_CAN_GROW, + GridConstraints.SIZEPOLICY_CAN_SHRINK | GridConstraints.SIZEPOLICY_CAN_GROW, + null, + new Dimension(200, 200), + null, + 0, + false)); + + // 左侧面板 + leftPanel = new JPanel(); + leftPanel.setLayout(new GridLayoutManager(1, 1, new Insets(0, 0, 0, 5), -1, -1)); + leftPanel.setBorder(BorderFactory.createTitledBorder(null, "原文本", TitledBorder.DEFAULT_JUSTIFICATION, TitledBorder.DEFAULT_POSITION, null, null)); + + // 右侧面板 + rightPanel = new JPanel(); + rightPanel.setLayout(new GridLayoutManager(1, 1, new Insets(0, 5, 0, 0), -1, -1)); + rightPanel.setBorder(BorderFactory.createTitledBorder(null, "新文本", TitledBorder.DEFAULT_JUSTIFICATION, TitledBorder.DEFAULT_POSITION, null, null)); + + // 设置分割面板的左右组件 + mainSplitPane.setLeftComponent(leftPanel); + mainSplitPane.setRightComponent(rightPanel); + + // 统一差异面板 + unifiedPanel = new JPanel(); + unifiedPanel.setLayout(new GridLayoutManager(1, 1, new Insets(10, 0, 0, 0), -1, -1)); + unifiedPanel.setBorder(BorderFactory.createTitledBorder(null, "统一差异格式", TitledBorder.DEFAULT_JUSTIFICATION, TitledBorder.DEFAULT_POSITION, null, null)); + unifiedPanel.setVisible(false); + textDiffPanel.add(unifiedPanel, new GridConstraints(2, + 0, + 1, + 1, + GridConstraints.ANCHOR_CENTER, + GridConstraints.FILL_BOTH, + GridConstraints.SIZEPOLICY_CAN_SHRINK | GridConstraints.SIZEPOLICY_CAN_GROW, + GridConstraints.SIZEPOLICY_CAN_SHRINK | GridConstraints.SIZEPOLICY_CAN_GROW, + null, + new Dimension(-1, 200), + null, + 0, + false)); + } + + /** + * @noinspection ALL + */ + public JComponent $$$getRootComponent$$$() { + return textDiffPanel; + } +} diff --git a/src/main/java/com/luoboduner/moo/tool/ui/listener/func/QuickNoteListener.java b/src/main/java/com/luoboduner/moo/tool/ui/listener/func/QuickNoteListener.java index 96e4016a..44fb863d 100644 --- a/src/main/java/com/luoboduner/moo/tool/ui/listener/func/QuickNoteListener.java +++ b/src/main/java/com/luoboduner/moo/tool/ui/listener/func/QuickNoteListener.java @@ -1094,76 +1094,94 @@ public class QuickNoteListener { public static void format() { try { QuickNoteForm quickNoteForm = QuickNoteForm.getInstance(); - String text = QuickNoteForm.quickNoteRSyntaxTextViewerManager.getTextByName(selectedName); - - String format; - String selectedSyntax = (String) quickNoteForm.getSyntaxComboBox().getSelectedItem(); + final String text = QuickNoteForm.quickNoteRSyntaxTextViewerManager.getTextByName(selectedName); + final String selectedSyntax = (String) quickNoteForm.getSyntaxComboBox().getSelectedItem(); if (StringUtils.isBlank(text) || StringUtils.isEmpty(selectedSyntax)) { return; } - switch ("text/" + selectedSyntax) { - case SyntaxConstants.SYNTAX_STYLE_SQL: - switch (App.config.getSqlDialect()) { - case "MariaDB": - format = SqlFormatter.of(Dialect.MariaDb).format(text, " "); + executorService.submit(() -> { + try { + String formatted; + switch ("text/" + selectedSyntax) { + case SyntaxConstants.SYNTAX_STYLE_SQL: + switch (App.config.getSqlDialect()) { + case "MariaDB": + formatted = SqlFormatter.of(Dialect.MariaDb).format(text, " "); + break; + case "MySQL": + formatted = SqlFormatter.of(Dialect.MySql).format(text, " "); + break; + case "PostgreSQL": + formatted = SqlFormatter.of(Dialect.PostgreSql).format(text, " "); + break; + case "IBM DB2": + formatted = SqlFormatter.of(Dialect.Db2).format(text, " "); + break; + case "Oracle PL/SQL": + formatted = SqlFormatter.of(Dialect.PlSql).format(text, " "); + break; + case "Couchbase N1QL": + formatted = SqlFormatter.of(Dialect.N1ql).format(text, " "); + break; + case "Amazon Redshift": + formatted = SqlFormatter.of(Dialect.Redshift).format(text, " "); + break; + case "Spark": + formatted = SqlFormatter.of(Dialect.SparkSql).format(text, " "); + break; + case "SQL Server Transact-SQL": + formatted = SqlFormatter.of(Dialect.TSql).format(text, " "); + break; + default: + formatted = SqlFormatter.of(Dialect.StandardSql).format(text, " "); + } break; - case "MySQL": - format = SqlFormatter.of(Dialect.MySql).format(text, " "); + case SyntaxConstants.SYNTAX_STYLE_JSON: + case SyntaxConstants.SYNTAX_STYLE_JSON_WITH_COMMENTS: + try { + formatted = JSONUtil.toJsonPrettyStr(text); + } catch (Exception e1) { + log.error(ExceptionUtils.getStackTrace(e1)); + formatted = JSONUtil.formatJsonStr(text); + } break; - case "PostgreSQL": - format = SqlFormatter.of(Dialect.PostgreSql).format(text, " "); + + case SyntaxConstants.SYNTAX_STYLE_XML: + formatted = CodeFormatterFactory.getFormatter(CodeFormatterFactory.FormatterType.XML).format(text); break; - case "IBM DB2": - format = SqlFormatter.of(Dialect.Db2).format(text, " "); - break; - case "Oracle PL/SQL": - format = SqlFormatter.of(Dialect.PlSql).format(text, " "); - break; - case "Couchbase N1QL": - format = SqlFormatter.of(Dialect.N1ql).format(text, " "); - break; - case "Amazon Redshift": - format = SqlFormatter.of(Dialect.Redshift).format(text, " "); - break; - case "Spark": - format = SqlFormatter.of(Dialect.SparkSql).format(text, " "); - break; - case "SQL Server Transact-SQL": - format = SqlFormatter.of(Dialect.TSql).format(text, " "); + + case SyntaxConstants.SYNTAX_STYLE_JAVA: + formatted = CodeFormatterFactory.getFormatter(CodeFormatterFactory.FormatterType.JAVA).format(text); break; default: - format = SqlFormatter.of(Dialect.StandardSql).format(text, " "); + SwingUtilities.invokeLater(() -> JOptionPane.showMessageDialog(App.mainFrame, "尚不支持对该语言格式化!\n", "不支持该语言", + JOptionPane.INFORMATION_MESSAGE)); + return; } - break; - case SyntaxConstants.SYNTAX_STYLE_JSON: - case SyntaxConstants.SYNTAX_STYLE_JSON_WITH_COMMENTS: - try { - format = JSONUtil.toJsonPrettyStr(text); - } catch (Exception e1) { - log.error(ExceptionUtils.getStackTrace(e1)); - format = JSONUtil.formatJsonStr(text); - } - break; - case SyntaxConstants.SYNTAX_STYLE_JAVA: - format = CodeFormatterFactory.getFormatter(CodeFormatterFactory.FormatterType.JAVA).format(text); - break; - default: - JOptionPane.showMessageDialog(App.mainFrame, "尚不支持对该语言格式化!\n", "不支持该语言", - JOptionPane.INFORMATION_MESSAGE); - return; - } - - QuickNoteForm.quickNoteRSyntaxTextViewerManager.getCurrentRSyntaxTextArea().setText(format); - QuickNoteForm.quickNoteRSyntaxTextViewerManager.getCurrentRSyntaxTextArea().setCaretPosition(0); - - QuickNoteIndicatorTools.showTips("已格式化:" + selectedName, QuickNoteIndicatorTools.TipsLevel.SUCCESS); + final String result = formatted; + SwingUtilities.invokeLater(() -> { + try { + QuickNoteForm.quickNoteRSyntaxTextViewerManager.getCurrentRSyntaxTextArea().setText(result); + QuickNoteForm.quickNoteRSyntaxTextViewerManager.getCurrentRSyntaxTextArea().setCaretPosition(0); + QuickNoteIndicatorTools.showTips("已格式化:" + selectedName, QuickNoteIndicatorTools.TipsLevel.SUCCESS); + } catch (Exception ex) { + JOptionPane.showMessageDialog(App.mainFrame, "格式化失败!\n\n" + ex.getMessage(), "失败", + JOptionPane.ERROR_MESSAGE); + log.error(ExceptionUtils.getStackTrace(ex)); + } + }); + } catch (Exception e) { + SwingUtilities.invokeLater(() -> JOptionPane.showMessageDialog(App.mainFrame, "格式化失败!\n\n" + e.getMessage(), "失败", + JOptionPane.ERROR_MESSAGE)); + log.error(ExceptionUtils.getStackTrace(e)); + } + }); } catch (Exception e) { JOptionPane.showMessageDialog(App.mainFrame, "格式化失败!\n\n" + e.getMessage(), "失败", JOptionPane.ERROR_MESSAGE); log.error(ExceptionUtils.getStackTrace(e)); } - } } diff --git a/src/main/java/com/luoboduner/moo/tool/ui/listener/func/TextDiffListener.java b/src/main/java/com/luoboduner/moo/tool/ui/listener/func/TextDiffListener.java new file mode 100644 index 00000000..3bbc2cce --- /dev/null +++ b/src/main/java/com/luoboduner/moo/tool/ui/listener/func/TextDiffListener.java @@ -0,0 +1,133 @@ +package com.luoboduner.moo.tool.ui.listener.func; + +import com.luoboduner.moo.tool.ui.form.func.TextDiffForm; +import lombok.extern.slf4j.Slf4j; + +import javax.swing.SwingUtilities; +import javax.swing.event.DocumentEvent; +import javax.swing.event.DocumentListener; + +/** + *
+ * TextDiffListener - 文本对比功能事件监听器
+ * 
+ * + * @author CassianFlorin + * @email flowercard591@gmail.com + * @date 2025/9/27 10:51 + */ +@Slf4j +public class TextDiffListener { + + /** + * 添加事件监听器 + */ + public static void addListeners() { + TextDiffForm textDiffForm = TextDiffForm.getInstance(); + + // 对比按钮事件 + textDiffForm.getCompareButton().addActionListener(e -> { + textDiffForm.performDiff(); + }); + + // 清空按钮事件 + textDiffForm.getClearButton().addActionListener(e -> { + textDiffForm.clearAll(); + }); + + // 交换按钮事件 + textDiffForm.getSwapButton().addActionListener(e -> { + textDiffForm.swapTexts(); + }); + + // 复制差异按钮事件 + textDiffForm.getCopyDiffButton().addActionListener(e -> { + textDiffForm.copyDiffResult(); + }); + + // 显示模式切换事件 + textDiffForm.getDisplayModeComboBox().addActionListener(e -> { + textDiffForm.updateDisplayMode(); + // 如果当前有内容,重新执行对比 + if (!textDiffForm.getLeftTextArea().getText().isEmpty() || + !textDiffForm.getRightTextArea().getText().isEmpty()) { + textDiffForm.performDiff(); + } + }); + + // 实时对比复选框事件 + textDiffForm.getRealTimeCheckBox().addActionListener(e -> { + if (textDiffForm.getRealTimeCheckBox().isSelected()) { + // 开启实时对比时立即执行一次对比 + textDiffForm.performDiff(); + } + }); + + // 忽略空白差异复选框事件:切换时立即重算 + textDiffForm.getIgnoreWhitespaceCheckBox().addActionListener(e -> { + textDiffForm.performDiff(); + }); + + // 文本变化监听器 - 实时对比 + DocumentListener realTimeDocumentListener = new DocumentListener() { + @Override + public void insertUpdate(DocumentEvent e) { + if (textDiffForm.getRealTimeCheckBox().isSelected()) { + // 延迟执行,避免频繁对比 + javax.swing.SwingUtilities.invokeLater(textDiffForm::performDiff); + } + } + + @Override + public void removeUpdate(DocumentEvent e) { + if (textDiffForm.getRealTimeCheckBox().isSelected()) { + javax.swing.SwingUtilities.invokeLater(textDiffForm::performDiff); + } + } + + @Override + public void changedUpdate(DocumentEvent e) { + if (textDiffForm.getRealTimeCheckBox().isSelected()) { + javax.swing.SwingUtilities.invokeLater(textDiffForm::performDiff); + } + } + }; + + // 为左右文本区域添加文档监听器 + textDiffForm.getLeftTextArea().getDocument().addDocumentListener(realTimeDocumentListener); + textDiffForm.getRightTextArea().getDocument().addDocumentListener(realTimeDocumentListener); + + // 内容保存监听器 + DocumentListener saveDocumentListener = new DocumentListener() { + @Override + public void insertUpdate(DocumentEvent e) { + saveContent(); + } + + @Override + public void removeUpdate(DocumentEvent e) { + saveContent(); + } + + @Override + public void changedUpdate(DocumentEvent e) { + saveContent(); + } + + private void saveContent() { + // 延迟保存,避免频繁IO操作 + SwingUtilities.invokeLater(() -> { + try { + textDiffForm.save(); + } catch (Exception ex) { + log.error("保存文本对比内容失败", ex); + } + }); + } + }; + + // 为左右文本区域添加保存监听器 + textDiffForm.getLeftTextArea().getDocument().addDocumentListener(saveDocumentListener); + textDiffForm.getRightTextArea().getDocument().addDocumentListener(saveDocumentListener); + } +} diff --git a/src/main/java/com/luoboduner/moo/tool/util/FileReformatUtil.java b/src/main/java/com/luoboduner/moo/tool/util/FileReformatUtil.java index 5f8f8b0d..6b3c1c49 100755 --- a/src/main/java/com/luoboduner/moo/tool/util/FileReformatUtil.java +++ b/src/main/java/com/luoboduner/moo/tool/util/FileReformatUtil.java @@ -1,92 +1,45 @@ -package com.luoboduner.moo.tool.util; - -import de.hunsicker.jalopy.Jalopy; -import de.hunsicker.jalopy.storage.Convention; -import de.hunsicker.jalopy.storage.ConventionKeys; -import org.jsoup.Jsoup; -import org.w3c.dom.Document; -import org.w3c.dom.Element; - -import javax.xml.parsers.DocumentBuilder; -import javax.xml.parsers.DocumentBuilderFactory; -import javax.xml.transform.OutputKeys; -import javax.xml.transform.Transformer; -import javax.xml.transform.TransformerFactory; -import javax.xml.transform.dom.DOMSource; -import javax.xml.transform.stream.StreamResult; -import java.io.File; -import java.io.StringWriter; - -public class FileReformatUtil { - - private static DocumentBuilderFactory factory = DocumentBuilderFactory.newInstance(); - private static TransformerFactory transformerFactory = TransformerFactory.newInstance(); - private static Jalopy jalopy = new Jalopy(); - - public static String reformatXML(File file, int spaceNum) throws Exception { - StringWriter res = new StringWriter(); - try { - // 创建DocumentBuilder对象 - DocumentBuilder builder = factory.newDocumentBuilder(); - - // 解析XML文件并返回Document对象 - Document document = builder.parse(file); - // 获取文档的根元素 - Element rootElement = document.getDocumentElement(); - - // 设置格式化属性,这里使用缩进和换行 - document.setXmlStandalone(true); - document.setXmlVersion("1.0"); - rootElement.normalize(); - - // 创建Transformer对象 - Transformer transformer = transformerFactory.newTransformer(); - - // 设置输出格式化属性,这里使用缩进和换行 - transformer.setOutputProperty(OutputKeys.INDENT, "yes"); - transformer.setOutputProperty(OutputKeys.ENCODING, "UTF-8"); - transformer.setOutputProperty("{http://xml.apache.org/xslt}indent-amount", String.valueOf(spaceNum)); - - // 创建DOMSource对象 - DOMSource source = new DOMSource(document); - - // 创建StreamResult对象,指定输出位置 - StreamResult result = new StreamResult(res); - - // 格式化XML并输出结果 - transformer.transform(source, result); - - return res.toString(); - } catch (Exception e) { - throw e; - } finally { - res.close(); - } - } - - public static String reformatHTML(File file, int spaceNum) throws Exception { - return Jsoup.parse(file, "UTF-8") - .outputSettings(new org.jsoup.nodes.Document.OutputSettings().indentAmount(spaceNum)) - .outerHtml(); - } - - public static String reformatJAVA(File file, int spaceNum) throws Exception { - Convention instance = Convention.getInstance(); - instance.put(ConventionKeys.SPACE_BEFORE_BRACES, "true"); - instance.put(ConventionKeys.SPACE_BEFORE_BRACKETS, "true"); - instance.put(ConventionKeys.INDENT_SIZE, String.valueOf(spaceNum)); - - try (StringWriter stringWriter = new StringWriter()) { - jalopy.setInput(file); - jalopy.setOutput(stringWriter); - boolean result = jalopy.format(); - if (!result) { - throw new Exception("格式化失败!"); - } - - return stringWriter.toString(); - } catch (Exception e) { - throw e; - } - } -} +package com.luoboduner.moo.tool.util; + +import com.luoboduner.moo.tool.util.codeformatter.XmlFormatting; +import de.hunsicker.jalopy.Jalopy; +import de.hunsicker.jalopy.storage.Convention; +import de.hunsicker.jalopy.storage.ConventionKeys; +import org.jsoup.Jsoup; + +import java.io.File; +import java.io.StringWriter; + +public class FileReformatUtil { + + private static Jalopy jalopy = new Jalopy(); + + public static String reformatXML(File file, int spaceNum) throws Exception { + return XmlFormatting.formatFile(file, spaceNum); + } + + public static String reformatHTML(File file, int spaceNum) throws Exception { + return Jsoup.parse(file, "UTF-8") + .outputSettings(new org.jsoup.nodes.Document.OutputSettings().indentAmount(spaceNum)) + .outerHtml(); + } + + public static String reformatJAVA(File file, int spaceNum) throws Exception { + Convention instance = Convention.getInstance(); + instance.put(ConventionKeys.SPACE_BEFORE_BRACES, "true"); + instance.put(ConventionKeys.SPACE_BEFORE_BRACKETS, "true"); + instance.put(ConventionKeys.INDENT_SIZE, String.valueOf(spaceNum)); + + try (StringWriter stringWriter = new StringWriter()) { + jalopy.setInput(file); + jalopy.setOutput(stringWriter); + boolean result = jalopy.format(); + if (!result) { + throw new Exception("格式化失败!"); + } + + return stringWriter.toString(); + } catch (Exception e) { + throw e; + } + } +} diff --git a/src/main/java/com/luoboduner/moo/tool/util/XmlReformatUtil.java b/src/main/java/com/luoboduner/moo/tool/util/XmlReformatUtil.java index d2b9f37a..60df2671 100755 --- a/src/main/java/com/luoboduner/moo/tool/util/XmlReformatUtil.java +++ b/src/main/java/com/luoboduner/moo/tool/util/XmlReformatUtil.java @@ -1,90 +1,25 @@ -package com.luoboduner.moo.tool.util; - -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; -import org.w3c.dom.Document; -import org.w3c.dom.Element; -import org.xml.sax.InputSource; - -import javax.xml.parsers.DocumentBuilder; -import javax.xml.parsers.DocumentBuilderFactory; -import javax.xml.transform.OutputKeys; -import javax.xml.transform.Transformer; -import javax.xml.transform.TransformerFactory; -import javax.xml.transform.dom.DOMSource; -import javax.xml.transform.stream.StreamResult; -import java.io.ByteArrayInputStream; -import java.io.File; -import java.io.StringWriter; - -public class XmlReformatUtil { - // log - private static final Logger logger = LoggerFactory.getLogger(XmlReformatUtil.class); - - private static DocumentBuilderFactory factory = DocumentBuilderFactory.newInstance(); - private static TransformerFactory transformerFactory = TransformerFactory.newInstance(); - - public static String reformat(File file, int spaceNum) throws Exception { - StringWriter res = new StringWriter(); - try { - // 创建DocumentBuilder对象 - DocumentBuilder builder = factory.newDocumentBuilder(); - - // 解析XML文件并返回Document对象 - Document document = builder.parse(file); - // 获取文档的根元素 - Element rootElement = document.getDocumentElement(); - - // 设置格式化属性,这里使用缩进和换行 - document.setXmlStandalone(true); - document.setXmlVersion("1.0"); - rootElement.normalize(); - - // 创建Transformer对象 - Transformer transformer = transformerFactory.newTransformer(); - - // 设置输出格式化属性,这里使用缩进和换行 - transformer.setOutputProperty(OutputKeys.INDENT, "yes"); - transformer.setOutputProperty(OutputKeys.ENCODING,"UTF-8"); - transformer.setOutputProperty("{http://xml.apache.org/xslt}indent-amount", String.valueOf(spaceNum)); - - // 创建DOMSource对象 - DOMSource source = new DOMSource(document); - - // 创建StreamResult对象,指定输出位置 - StreamResult result = new StreamResult(res); - - // 格式化XML并输出结果 - transformer.transform(source, result); - - return res.toString(); - } catch (Exception e) { - throw e; - } finally { - res.close(); - } - } - - public static String format(String xmlStr) { - try { - DocumentBuilder builder = factory.newDocumentBuilder(); - Document document = builder.parse(new InputSource(new ByteArrayInputStream(xmlStr.getBytes("utf-8")))); - Element rootElement = document.getDocumentElement(); - document.setXmlStandalone(true); - document.setXmlVersion("1.0"); - rootElement.normalize(); - Transformer transformer = transformerFactory.newTransformer(); - transformer.setOutputProperty(OutputKeys.INDENT, "yes"); - transformer.setOutputProperty(OutputKeys.ENCODING,"UTF-8"); - transformer.setOutputProperty("{http://xml.apache.org/xslt}indent-amount", "4"); - DOMSource source = new DOMSource(document); - StringWriter res = new StringWriter(); - StreamResult result = new StreamResult(res); - transformer.transform(source, result); - return res.toString(); - } catch (Exception e) { - logger.error("XML格式化失败", e); - return xmlStr; - } - } -} +package com.luoboduner.moo.tool.util; + +import com.luoboduner.moo.tool.util.codeformatter.XmlFormatting; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.io.File; + +public class XmlReformatUtil { + // log + private static final Logger logger = LoggerFactory.getLogger(XmlReformatUtil.class); + + public static String reformat(File file, int spaceNum) throws Exception { + return XmlFormatting.formatFile(file, spaceNum); + } + + public static String format(String xmlStr) { + try { + return XmlFormatting.formatString(xmlStr, 4); + } catch (Exception e) { + logger.error("XML格式化失败", e); + return xmlStr; + } + } +} diff --git a/src/main/java/com/luoboduner/moo/tool/util/codeformatter/XmlFormatter.java b/src/main/java/com/luoboduner/moo/tool/util/codeformatter/XmlFormatter.java index 0a7cccb6..86a57cc1 100644 --- a/src/main/java/com/luoboduner/moo/tool/util/codeformatter/XmlFormatter.java +++ b/src/main/java/com/luoboduner/moo/tool/util/codeformatter/XmlFormatter.java @@ -1,38 +1,14 @@ package com.luoboduner.moo.tool.util.codeformatter; -import org.w3c.dom.Document; -import org.xml.sax.InputSource; - -import javax.xml.parsers.DocumentBuilder; -import javax.xml.parsers.DocumentBuilderFactory; -import javax.xml.transform.OutputKeys; -import javax.xml.transform.Transformer; -import javax.xml.transform.TransformerFactory; -import javax.xml.transform.dom.DOMSource; -import javax.xml.transform.stream.StreamResult; -import java.io.StringReader; -import java.io.StringWriter; - public class XmlFormatter implements CodeFormatter { public String format(String input) { try { - DocumentBuilderFactory factory = DocumentBuilderFactory.newInstance(); - factory.setNamespaceAware(true); - DocumentBuilder builder = factory.newDocumentBuilder(); - Document document = builder.parse(new InputSource(new StringReader(input))); - - TransformerFactory transformerFactory = TransformerFactory.newInstance(); - Transformer transformer = transformerFactory.newTransformer(); - transformer.setOutputProperty(OutputKeys.INDENT, "yes"); - transformer.setOutputProperty("{http://xml.apache.org/xslt}indent-amount", "4"); - - StringWriter writer = new StringWriter(); - transformer.transform(new DOMSource(document), new StreamResult(writer)); - return writer.toString(); + // Delegate to centralized safe XML formatter with default indent 4 + return XmlFormatting.formatString(input, 4); } catch (Exception e) { throw new RuntimeException("Error formatting XML", e); } } -} \ No newline at end of file +} diff --git a/src/main/java/com/luoboduner/moo/tool/util/codeformatter/XmlFormatting.java b/src/main/java/com/luoboduner/moo/tool/util/codeformatter/XmlFormatting.java new file mode 100644 index 00000000..6206ef8f --- /dev/null +++ b/src/main/java/com/luoboduner/moo/tool/util/codeformatter/XmlFormatting.java @@ -0,0 +1,137 @@ +package com.luoboduner.moo.tool.util.codeformatter; + +import org.jsoup.Jsoup; +import org.jsoup.nodes.Document; +import org.jsoup.parser.Parser; +import org.w3c.dom.Element; +import org.xml.sax.InputSource; + +import javax.xml.XMLConstants; +import javax.xml.parsers.DocumentBuilder; +import javax.xml.parsers.DocumentBuilderFactory; +import javax.xml.transform.OutputKeys; +import javax.xml.transform.Transformer; +import javax.xml.transform.TransformerFactory; +import javax.xml.transform.dom.DOMSource; +import javax.xml.transform.stream.StreamResult; +import java.io.ByteArrayInputStream; +import java.io.File; +import java.io.StringWriter; + +/** + * XML格式化工具 + * + * @author CassianFlorin + * @email flowercard591@gmail.com + * @date 2025/9/27 22:15 + */ +public final class XmlFormatting { + + private XmlFormatting() { + } + + public static String formatString(String xml, int indent) throws Exception { + try { + return formatWithJsoup(xml, indent); + } catch (Exception ignore) { + // fallback to JAXP secure DOM formatting + org.w3c.dom.Document document = parseString(xml); + return transform(document, indent); + } + } + + public static String formatFile(File file, int indent) throws Exception { + try { + String content = readFileUtf8(file); + return formatWithJsoup(content, indent); + } catch (Exception ignore) { + org.w3c.dom.Document document = parseFile(file); + return transform(document, indent); + } + } + + private static String formatWithJsoup(String xml, int indent) throws Exception { + Document doc = Jsoup.parse(xml, "", Parser.xmlParser()); + doc.outputSettings(new Document.OutputSettings() + .prettyPrint(true) + .indentAmount(indent) + .syntax(Document.OutputSettings.Syntax.xml)); + return doc.outerHtml(); + } + + private static org.w3c.dom.Document parseString(String xml) throws Exception { + DocumentBuilderFactory factory = secureDocumentBuilderFactory(); + DocumentBuilder builder = factory.newDocumentBuilder(); + InputSource source = new InputSource(new ByteArrayInputStream(xml.getBytes("UTF-8"))); + org.w3c.dom.Document document = builder.parse(source); + normalize(document); + return document; + } + + private static org.w3c.dom.Document parseFile(File file) throws Exception { + DocumentBuilderFactory factory = secureDocumentBuilderFactory(); + DocumentBuilder builder = factory.newDocumentBuilder(); + org.w3c.dom.Document document = builder.parse(file); + normalize(document); + return document; + } + + private static void normalize(org.w3c.dom.Document document) { + Element rootElement = document.getDocumentElement(); + document.setXmlStandalone(true); + document.setXmlVersion("1.0"); + if (rootElement != null) { + rootElement.normalize(); + } + } + + private static String transform(org.w3c.dom.Document document, int indent) throws Exception { + TransformerFactory transformerFactory = TransformerFactory.newInstance(); + try { + transformerFactory.setFeature(XMLConstants.FEATURE_SECURE_PROCESSING, true); + } catch (Throwable ignore) { + // Some TransformerFactory implementations may not support this feature; ignore to keep compatibility + } + Transformer transformer = transformerFactory.newTransformer(); + transformer.setOutputProperty(OutputKeys.INDENT, "yes"); + transformer.setOutputProperty(OutputKeys.ENCODING, "UTF-8"); + transformer.setOutputProperty("{http://xml.apache.org/xslt}indent-amount", String.valueOf(indent)); + + StringWriter res = new StringWriter(); + transformer.transform(new DOMSource(document), new StreamResult(res)); + return res.toString(); + } + + private static String readFileUtf8(File file) throws Exception { + byte[] bytes = java.nio.file.Files.readAllBytes(file.toPath()); + return new String(bytes, "UTF-8"); + } + + private static DocumentBuilderFactory secureDocumentBuilderFactory() throws Exception { + DocumentBuilderFactory factory = DocumentBuilderFactory.newInstance(); + factory.setNamespaceAware(true); + factory.setXIncludeAware(false); + try { + // Enable secure processing + factory.setFeature(XMLConstants.FEATURE_SECURE_PROCESSING, true); + } catch (Throwable ignore) { + // keep going for max compatibility with various parsers + } + try { + // Disallow loading external DTDs and external entities to avoid blocking/XXE + factory.setFeature("http://xml.org/sax/features/external-general-entities", false); + } catch (Throwable ignore) { + } + try { + factory.setFeature("http://xml.org/sax/features/external-parameter-entities", false); + } catch (Throwable ignore) { + } + try { + factory.setFeature("http://apache.org/xml/features/nonvalidating/load-external-dtd", false); + } catch (Throwable ignore) { + } + // Do not expand entity references automatically + factory.setExpandEntityReferences(false); + return factory; + } +} diff --git a/src/main/resources/db_init.sql b/src/main/resources/db_init.sql index 00faacf1..ddead61e 100644 --- a/src/main/resources/db_init.sql +++ b/src/main/resources/db_init.sql @@ -111,4 +111,30 @@ create table if not exists t_func_content remark text, create_time datetime, modified_time datetime +); + +-- text diff +-- 文档主表 +CREATE TABLE IF NOT EXISTS doc ( + doc_id INTEGER PRIMARY KEY AUTOINCREMENT, + name TEXT NOT NULL UNIQUE +); + +-- 版本表 +CREATE TABLE IF NOT EXISTS doc_revision ( + rev_id INTEGER PRIMARY KEY AUTOINCREMENT, + doc_id INTEGER NOT NULL, + created_at DATETIME NOT NULL DEFAULT (datetime('now')), + content TEXT NOT NULL, + FOREIGN KEY (doc_id) REFERENCES doc(doc_id) +); + +-- 缓存行级 unified diff(从前一版本到本版本) +CREATE TABLE IF NOT EXISTS doc_diff_cache ( + from_rev_id INTEGER NOT NULL, + to_rev_id INTEGER NOT NULL, + unified TEXT NOT NULL, + PRIMARY KEY (from_rev_id, to_rev_id), + FOREIGN KEY (from_rev_id) REFERENCES doc_revision(rev_id), + FOREIGN KEY (to_rev_id) REFERENCES doc_revision(rev_id) ); \ No newline at end of file