better file compare

This commit is contained in:
Radek Davidek 2026-02-20 21:46:46 +01:00
parent bcfa68f30d
commit 561beb1e3e

View File

@ -1,54 +1,81 @@
package cz.kamma.kfmanager.ui; package cz.kamma.kfmanager.ui;
import cz.kamma.kfmanager.config.AppConfig; import cz.kamma.kfmanager.config.AppConfig;
import javax.swing.*; import javax.swing.*;
import javax.swing.text.*; import javax.swing.text.BadLocationException;
import javax.swing.BoundedRangeModel;
import javax.swing.text.Element;
import javax.swing.text.Style;
import javax.swing.text.StyleConstants;
import javax.swing.text.StyledDocument;
import java.awt.*; import java.awt.*;
import java.awt.event.ActionEvent; import java.awt.event.ActionEvent;
import java.awt.event.KeyEvent; import java.awt.event.KeyEvent;
import java.io.BufferedReader;
import java.io.File; import java.io.File;
import java.io.FileReader;
import java.io.IOException; import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.util.ArrayList; import java.util.ArrayList;
import java.util.List; import java.util.List;
import java.util.Locale;
/** /**
* Window for comparing two files side by side * Window for comparing two files side by side.
*/ */
public class FileComparisonDialog extends JFrame { public class FileComparisonDialog extends JFrame {
private final AppConfig config; private final AppConfig config;
private final File file1;
private final File file2;
private final List<String> sourceLines1 = new ArrayList<>();
private final List<String> sourceLines2 = new ArrayList<>();
private List<AlignedLine> alignedLines = new ArrayList<>();
private List<Integer> visibleIndices = new ArrayList<>();
private List<Integer> differenceVisibleRows = new ArrayList<>();
private JTextPane textPane1; private JTextPane textPane1;
private JTextPane textPane2; private JTextPane textPane2;
private JTextArea lineNumbers1; private JTextArea lineNumbers1;
private JTextArea lineNumbers2; private JTextArea lineNumbers2;
private JScrollPane scroll1; private JScrollPane scroll1;
private JScrollPane scroll2; private JScrollPane scroll2;
private List<String> lines1 = new ArrayList<>();
private List<String> lines2 = new ArrayList<>(); private JCheckBox smartAlignCheck;
private int selectedLine1 = 0; private JCheckBox ignoreCaseCheck;
private int selectedLine2 = 0; private JCheckBox ignoreTrimCheck;
private JCheckBox ignoreWhitespaceCheck;
private JCheckBox onlyDifferencesCheck;
private JLabel statusLabel;
private int selectedVisibleRow = -1;
private int selectedLeftVisibleRow = -1;
private int selectedRightVisibleRow = -1;
private int selectedDifferencePointer = -1;
private int manualAnchorLeftLine = -1;
private int manualAnchorRightLine = -1;
public FileComparisonDialog(Window parent, File file1, File file2, AppConfig config) { public FileComparisonDialog(Window parent, File file1, File file2, AppConfig config) {
super("Compare Files: " + file1.getName() + " vs " + file2.getName()); super("Compare Files: " + file1.getName() + " vs " + file2.getName());
this.file1 = file1;
this.file2 = file2;
this.config = config; this.config = config;
try { try {
this.lines1 = readLines(file1); sourceLines1.addAll(readLines(file1));
this.lines2 = readLines(file2); sourceLines2.addAll(readLines(file2));
performSmartAlignment();
} catch (IOException e) { } catch (IOException e) {
JOptionPane.showMessageDialog(this, "Error reading files: " + e.getMessage(), "Error", JOptionPane.ERROR_MESSAGE); JOptionPane.showMessageDialog(parent, "Error reading files: " + e.getMessage(), "Error", JOptionPane.ERROR_MESSAGE);
} }
initComponents(); initComponents();
updateDisplay(); refreshComparison(true);
setSize(1000, 700); setSize(1100, 760);
setLocationRelativeTo(parent); setLocationRelativeTo(parent);
setDefaultCloseOperation(JFrame.DISPOSE_ON_CLOSE); setDefaultCloseOperation(JFrame.DISPOSE_ON_CLOSE);
// Close on ESC
getRootPane().getInputMap(JComponent.WHEN_IN_FOCUSED_WINDOW).put(KeyStroke.getKeyStroke(KeyEvent.VK_ESCAPE, 0), "close"); getRootPane().getInputMap(JComponent.WHEN_IN_FOCUSED_WINDOW).put(KeyStroke.getKeyStroke(KeyEvent.VK_ESCAPE, 0), "close");
getRootPane().getActionMap().put("close", new AbstractAction() { getRootPane().getActionMap().put("close", new AbstractAction() {
@Override @Override
@ -59,152 +86,373 @@ public class FileComparisonDialog extends JFrame {
} }
private void initComponents() { private void initComponents() {
setLayout(new BorderLayout()); setLayout(new BorderLayout(6, 6));
add(createTopToolbar(), BorderLayout.NORTH);
JPanel centerPanel = new JPanel(new GridLayout(1, 2, 5, 0)); add(createCenterPanel(), BorderLayout.CENTER);
add(createBottomBar(), BorderLayout.SOUTH);
textPane1 = new JTextPane();
textPane1.setEditable(false);
textPane1.setFont(new Font("Monospaced", Font.PLAIN, 12));
TextPaneListener listener1 = new TextPaneListener();
textPane1.addMouseListener(listener1);
scroll1 = new JScrollPane(textPane1);
lineNumbers1 = new JTextArea("1");
lineNumbers1.setEditable(false);
lineNumbers1.setBackground(Color.LIGHT_GRAY);
lineNumbers1.setForeground(Color.DARK_GRAY);
lineNumbers1.setFont(new Font("Monospaced", Font.PLAIN, 12));
lineNumbers1.setMargin(new Insets(0, 5, 0, 5));
scroll1.setRowHeaderView(lineNumbers1);
textPane2 = new JTextPane();
textPane2.setEditable(false);
textPane2.setFont(new Font("Monospaced", Font.PLAIN, 12));
TextPaneListener listener2 = new TextPaneListener();
textPane2.addMouseListener(listener2);
scroll2 = new JScrollPane(textPane2);
lineNumbers2 = new JTextArea("1");
lineNumbers2.setEditable(false);
lineNumbers2.setBackground(Color.LIGHT_GRAY);
lineNumbers2.setForeground(Color.DARK_GRAY);
lineNumbers2.setFont(new Font("Monospaced", Font.PLAIN, 12));
lineNumbers2.setMargin(new Insets(0, 5, 0, 5));
scroll2.setRowHeaderView(lineNumbers2);
// Synchronize scrolling
scroll1.getVerticalScrollBar().setModel(scroll2.getVerticalScrollBar().getModel());
centerPanel.add(scroll1);
centerPanel.add(scroll2);
add(centerPanel, BorderLayout.CENTER);
JPanel bottomPanel = new JPanel(new FlowLayout(FlowLayout.RIGHT));
JButton syncButton = new JButton("Synchronize from here");
syncButton.addActionListener(e -> synchronizeFromHere());
bottomPanel.add(syncButton);
JButton closeButton = new JButton("Close");
closeButton.addActionListener(e -> dispose());
bottomPanel.add(closeButton);
add(bottomPanel, BorderLayout.SOUTH);
// Apply appearance from config
applyAppearance(); applyAppearance();
} }
private class TextPaneListener extends java.awt.event.MouseAdapter { private JComponent createTopToolbar() {
@Override JPanel panel = new JPanel(new FlowLayout(FlowLayout.LEFT, 8, 4));
public void mousePressed(java.awt.event.MouseEvent e) {
handleSelection(e);
if (e.isPopupTrigger()) showMenu(e);
}
@Override
public void mouseReleased(java.awt.event.MouseEvent e) {
handleSelection(e);
if (e.isPopupTrigger()) showMenu(e);
}
private void handleSelection(java.awt.event.MouseEvent e) {
if (SwingUtilities.isLeftMouseButton(e)) {
JTextPane pane = (JTextPane) e.getComponent();
@SuppressWarnings("deprecation")
int pos = pane.viewToModel(e.getPoint());
if (pos >= 0) {
try {
Element root = pane.getDocument().getDefaultRootElement();
int lineIdx = root.getElementIndex(pos);
if (pane == textPane1) selectedLine1 = lineIdx; smartAlignCheck = new JCheckBox("Smart align", true);
else if (pane == textPane2) selectedLine2 = lineIdx; ignoreCaseCheck = new JCheckBox("Ignore case", false);
ignoreTrimCheck = new JCheckBox("Ignore leading/trailing spaces", false);
ignoreWhitespaceCheck = new JCheckBox("Ignore all whitespace changes", false);
onlyDifferencesCheck = new JCheckBox("Show only differences", false);
Element line = root.getElement(lineIdx); JButton prevDiff = new JButton("Previous difference");
int start = line.getStartOffset(); JButton nextDiff = new JButton("Next difference");
int end = Math.min(line.getEndOffset(), pane.getDocument().getLength()); JButton synchronizeButton = new JButton("Synchronize from selected rows");
JButton clearSyncButton = new JButton("Clear sync");
JButton reloadButton = new JButton("Reload");
// Use invokeLater to ensure selection happens after default UI behavior smartAlignCheck.addActionListener(e -> refreshComparison(false));
SwingUtilities.invokeLater(() -> { ignoreCaseCheck.addActionListener(e -> refreshComparison(false));
pane.requestFocusInWindow(); ignoreTrimCheck.addActionListener(e -> refreshComparison(false));
pane.setCaretPosition(start); ignoreWhitespaceCheck.addActionListener(e -> refreshComparison(false));
pane.moveCaretPosition(end); onlyDifferencesCheck.addActionListener(e -> refreshComparison(true));
}); reloadButton.addActionListener(e -> reloadAndRefresh());
} catch (Exception ex) {
// ignore prevDiff.addActionListener(e -> jumpToDifference(-1));
} nextDiff.addActionListener(e -> jumpToDifference(1));
} synchronizeButton.addActionListener(e -> synchronizeFromSelectedRows());
clearSyncButton.addActionListener(e -> clearSynchronization());
panel.add(new JLabel("Options:"));
panel.add(smartAlignCheck);
panel.add(ignoreCaseCheck);
panel.add(ignoreTrimCheck);
panel.add(ignoreWhitespaceCheck);
panel.add(onlyDifferencesCheck);
panel.add(prevDiff);
panel.add(nextDiff);
panel.add(synchronizeButton);
panel.add(clearSyncButton);
panel.add(reloadButton);
return panel;
}
private JComponent createCenterPanel() {
JPanel panel = new JPanel(new GridLayout(1, 2, 6, 0));
textPane1 = createTextPane(true);
textPane2 = createTextPane(false);
scroll1 = new JScrollPane(textPane1);
scroll2 = new JScrollPane(textPane2);
lineNumbers1 = createLineNumberArea();
lineNumbers2 = createLineNumberArea();
scroll1.setRowHeaderView(lineNumbers1);
scroll2.setRowHeaderView(lineNumbers2);
// Keep vertical scrolling synchronized in both directions.
BoundedRangeModel sharedModel = scroll1.getVerticalScrollBar().getModel();
scroll2.getVerticalScrollBar().setModel(sharedModel);
panel.add(scroll1);
panel.add(scroll2);
return panel;
}
private JComponent createBottomBar() {
JPanel bar = new JPanel(new BorderLayout(8, 0));
statusLabel = new JLabel(" ");
bar.add(statusLabel, BorderLayout.CENTER);
JPanel buttons = new JPanel(new FlowLayout(FlowLayout.RIGHT, 6, 2));
JButton closeButton = new JButton("Close");
closeButton.addActionListener(e -> dispose());
buttons.add(closeButton);
bar.add(buttons, BorderLayout.EAST);
return bar;
}
private JTextPane createTextPane(boolean isLeftPane) {
JTextPane pane = new JTextPane();
pane.setEditable(false);
pane.setFont(new Font("Monospaced", Font.PLAIN, 12));
pane.addMouseListener(new java.awt.event.MouseAdapter() {
@Override
public void mousePressed(java.awt.event.MouseEvent e) {
selectLineAtClick((JTextPane) e.getComponent(), e.getPoint(), isLeftPane);
} }
});
return pane;
}
private JTextArea createLineNumberArea() {
JTextArea area = new JTextArea("1");
area.setEditable(false);
area.setBackground(Color.LIGHT_GRAY);
area.setForeground(Color.DARK_GRAY);
area.setFont(new Font("Monospaced", Font.PLAIN, 12));
area.setMargin(new Insets(0, 5, 0, 5));
return area;
}
private void reloadAndRefresh() {
sourceLines1.clear();
sourceLines2.clear();
try {
sourceLines1.addAll(readLines(file1));
sourceLines2.addAll(readLines(file2));
} catch (IOException e) {
JOptionPane.showMessageDialog(this, "Error reading files: " + e.getMessage(), "Error", JOptionPane.ERROR_MESSAGE);
} }
private void showMenu(java.awt.event.MouseEvent e) { refreshComparison(true);
JPopupMenu menu = new JPopupMenu(); }
JMenuItem syncItem = new JMenuItem("Synchronize from here");
syncItem.addActionListener(event -> synchronizeFromHere()); private void synchronizeFromSelectedRows() {
menu.add(syncItem); if (selectedLeftVisibleRow < 0 || selectedRightVisibleRow < 0) {
menu.show(e.getComponent(), e.getX(), e.getY()); JOptionPane.showMessageDialog(this,
"Select one line in left pane and one line in right pane first.",
"Synchronize",
JOptionPane.INFORMATION_MESSAGE);
return;
}
if (selectedLeftVisibleRow >= visibleIndices.size() || selectedRightVisibleRow >= visibleIndices.size()) {
return;
}
AlignedLine leftLine = alignedLines.get(visibleIndices.get(selectedLeftVisibleRow));
AlignedLine rightLine = alignedLines.get(visibleIndices.get(selectedRightVisibleRow));
if (leftLine.leftNumber <= 0 || rightLine.rightNumber <= 0) {
JOptionPane.showMessageDialog(this,
"Selected rows must contain real lines in both panes (not empty gap rows).",
"Synchronize",
JOptionPane.WARNING_MESSAGE);
return;
}
manualAnchorLeftLine = leftLine.leftNumber;
manualAnchorRightLine = rightLine.rightNumber;
refreshComparison(false);
focusAnchorRow();
}
private void clearSynchronization() {
manualAnchorLeftLine = -1;
manualAnchorRightLine = -1;
refreshComparison(false);
}
private void focusAnchorRow() {
if (manualAnchorLeftLine <= 0 || manualAnchorRightLine <= 0) return;
for (int visibleRow = 0; visibleRow < visibleIndices.size(); visibleRow++) {
AlignedLine line = alignedLines.get(visibleIndices.get(visibleRow));
if (line.leftNumber == manualAnchorLeftLine && line.rightNumber == manualAnchorRightLine) {
selectedVisibleRow = visibleRow;
selectedLeftVisibleRow = visibleRow;
selectedRightVisibleRow = visibleRow;
render();
updateStatus();
return;
}
} }
} }
private void synchronizeFromHere() { private void refreshComparison(boolean resetSelection) {
try { ComparisonOptions options = getOptions();
int line1 = selectedLine1; List<String> comparableLeft = buildComparableLines(sourceLines1, options);
int line2 = selectedLine2; List<String> comparableRight = buildComparableLines(sourceLines2, options);
alignedLines = alignWithOptionalAnchor(sourceLines1, sourceLines2, comparableLeft, comparableRight, options);
if (line1 < line2) { visibleIndices = new ArrayList<>();
int diff = line2 - line1; differenceVisibleRows = new ArrayList<>();
for (int i = 0; i < diff; i++) {
lines1.add(line1, null); for (int i = 0; i < alignedLines.size(); i++) {
} AlignedLine line = alignedLines.get(i);
} else if (line2 < line1) { if (!options.onlyDifferences || line.different) {
int diff = line1 - line2; int visibleRow = visibleIndices.size();
for (int i = 0; i < diff; i++) { visibleIndices.add(i);
lines2.add(line2, null); if (line.different) differenceVisibleRows.add(visibleRow);
}
} }
updateDisplay(false);
// Restore selection and scroll to the newly synced line
int newLineIdx = Math.max(line1, line2);
selectedLine1 = newLineIdx;
selectedLine2 = newLineIdx;
SwingUtilities.invokeLater(() -> {
Element newRoot = textPane1.getDocument().getDefaultRootElement();
if (newLineIdx < newRoot.getElementCount()) {
Element lineElem = newRoot.getElement(newLineIdx);
int start = lineElem.getStartOffset();
int end = Math.min(lineElem.getEndOffset(), textPane1.getDocument().getLength());
textPane1.requestFocusInWindow();
textPane1.setCaretPosition(start);
textPane1.moveCaretPosition(end);
textPane2.setCaretPosition(start);
textPane2.moveCaretPosition(end);
}
});
} catch (Exception ex) {
ex.printStackTrace();
} }
if (resetSelection) {
selectedVisibleRow = -1;
selectedLeftVisibleRow = -1;
selectedRightVisibleRow = -1;
selectedDifferencePointer = differenceVisibleRows.isEmpty() ? -1 : 0;
} else if (selectedVisibleRow >= visibleIndices.size()) {
selectedVisibleRow = visibleIndices.isEmpty() ? -1 : visibleIndices.size() - 1;
selectedLeftVisibleRow = selectedVisibleRow;
selectedRightVisibleRow = selectedVisibleRow;
}
render();
updateStatus();
}
private ComparisonOptions getOptions() {
ComparisonOptions options = new ComparisonOptions();
options.smartAlign = smartAlignCheck.isSelected();
options.ignoreCase = ignoreCaseCheck.isSelected();
options.ignoreTrim = ignoreTrimCheck.isSelected();
options.ignoreWhitespace = ignoreWhitespaceCheck.isSelected();
options.onlyDifferences = onlyDifferencesCheck.isSelected();
return options;
}
private void render() {
try {
StyledDocument doc1 = textPane1.getStyledDocument();
StyledDocument doc2 = textPane2.getStyledDocument();
doc1.remove(0, doc1.getLength());
doc2.remove(0, doc2.getLength());
StringBuilder ln1 = new StringBuilder();
StringBuilder ln2 = new StringBuilder();
Color bg = textPane1.getBackground();
boolean dark = isDark(bg);
Style normal1 = createStyle(textPane1, "normal1", null, null);
Style normal2 = createStyle(textPane2, "normal2", null, null);
Style changed1 = createStyle(textPane1, "changed1", dark ? new Color(95, 45, 45) : new Color(255, 220, 220), null);
Style changed2 = createStyle(textPane2, "changed2", dark ? new Color(95, 45, 45) : new Color(255, 220, 220), null);
Style onlyLeft = createStyle(textPane1, "onlyLeft", dark ? new Color(85, 62, 40) : new Color(255, 236, 210), null);
Style onlyRight = createStyle(textPane2, "onlyRight", dark ? new Color(45, 78, 55) : new Color(220, 255, 230), null);
Style selected1 = createStyle(textPane1, "selected1", dark ? new Color(45, 65, 105) : new Color(210, 230, 255), null);
Style selected2 = createStyle(textPane2, "selected2", dark ? new Color(45, 65, 105) : new Color(210, 230, 255), null);
for (int visibleRow = 0; visibleRow < visibleIndices.size(); visibleRow++) {
AlignedLine line = alignedLines.get(visibleIndices.get(visibleRow));
boolean selected = visibleRow == selectedVisibleRow
|| visibleRow == selectedLeftVisibleRow
|| visibleRow == selectedRightVisibleRow;
Style style1;
Style style2;
if (selected) {
style1 = selected1;
style2 = selected2;
} else if (!line.different) {
style1 = normal1;
style2 = normal2;
} else if (line.left == null) {
style1 = changed1;
style2 = onlyRight;
} else if (line.right == null) {
style1 = onlyLeft;
style2 = changed2;
} else {
style1 = changed1;
style2 = changed2;
}
doc1.insertString(doc1.getLength(), (line.left != null ? line.left : "") + "\n", style1);
doc2.insertString(doc2.getLength(), (line.right != null ? line.right : "") + "\n", style2);
ln1.append(line.leftNumber > 0 ? line.leftNumber : "").append('\n');
ln2.append(line.rightNumber > 0 ? line.rightNumber : "").append('\n');
}
lineNumbers1.setText(ln1.toString());
lineNumbers2.setText(ln2.toString());
if (selectedVisibleRow < 0 && !visibleIndices.isEmpty()) {
scrollToVisibleRow(0);
} else if (selectedVisibleRow >= 0) {
scrollToVisibleRow(selectedVisibleRow);
} else {
textPane1.setCaretPosition(0);
textPane2.setCaretPosition(0);
}
} catch (BadLocationException e) {
JOptionPane.showMessageDialog(this, "Error updating display: " + e.getMessage(), "Error", JOptionPane.ERROR_MESSAGE);
}
}
private Style createStyle(JTextPane pane, String name, Color background, Color foreground) {
Style style = pane.addStyle(name, null);
if (background != null) {
StyleConstants.setBackground(style, background);
}
if (foreground != null) {
StyleConstants.setForeground(style, foreground);
} else {
StyleConstants.setForeground(style, pane.getForeground());
}
return style;
}
private void jumpToDifference(int direction) {
if (differenceVisibleRows.isEmpty()) {
Toolkit.getDefaultToolkit().beep();
return;
}
if (selectedDifferencePointer < 0) {
selectedDifferencePointer = 0;
} else {
int size = differenceVisibleRows.size();
selectedDifferencePointer = (selectedDifferencePointer + direction + size) % size;
}
selectedVisibleRow = differenceVisibleRows.get(selectedDifferencePointer);
selectedLeftVisibleRow = selectedVisibleRow;
selectedRightVisibleRow = selectedVisibleRow;
render();
updateStatus();
}
private void selectLineAtClick(JTextPane pane, Point point, boolean isLeftPane) {
int pos = pane.viewToModel2D(point);
if (pos < 0) return;
Element root = pane.getDocument().getDefaultRootElement();
int lineIndex = root.getElementIndex(pos);
if (lineIndex < 0 || lineIndex >= visibleIndices.size()) return;
selectedVisibleRow = lineIndex;
if (isLeftPane) {
selectedLeftVisibleRow = lineIndex;
} else {
selectedRightVisibleRow = lineIndex;
}
selectedDifferencePointer = -1;
for (int i = 0; i < differenceVisibleRows.size(); i++) {
if (differenceVisibleRows.get(i) == selectedVisibleRow) {
selectedDifferencePointer = i;
break;
}
}
render();
updateStatus();
}
private void scrollToVisibleRow(int row) {
if (row < 0) return;
try {
Element root1 = textPane1.getDocument().getDefaultRootElement();
Element root2 = textPane2.getDocument().getDefaultRootElement();
if (row >= root1.getElementCount() || row >= root2.getElementCount()) return;
Element line1 = root1.getElement(row);
Element line2 = root2.getElement(row);
int pos1 = line1.getStartOffset();
int pos2 = line2.getStartOffset();
textPane1.setCaretPosition(pos1);
textPane2.setCaretPosition(pos2);
} catch (Exception ignored) {
// no-op
}
}
private void updateStatus() {
int different = 0;
for (AlignedLine line : alignedLines) {
if (line.different) different++;
}
String alignMode = smartAlignCheck.isSelected() ? "smart" : "by position";
String visibleInfo = onlyDifferencesCheck.isSelected()
? "showing only differences"
: "showing all lines";
String syncInfo = (manualAnchorLeftLine > 0 && manualAnchorRightLine > 0)
? " | sync L" + manualAnchorLeftLine + " -> R" + manualAnchorRightLine
: "";
statusLabel.setText("Differences: " + different + " | " + visibleInfo + " | align: " + alignMode + syncInfo);
} }
private void applyAppearance() { private void applyAppearance() {
@ -213,28 +461,24 @@ public class FileComparisonDialog extends JFrame {
textPane1.setBackground(bg); textPane1.setBackground(bg);
textPane2.setBackground(bg); textPane2.setBackground(bg);
boolean dark = isDark(bg); boolean dark = isDark(bg);
textPane1.setForeground(dark ? Color.WHITE : Color.BLACK); Color fg = dark ? Color.WHITE : Color.BLACK;
textPane2.setForeground(dark ? Color.WHITE : Color.BLACK); textPane1.setForeground(fg);
textPane1.setCaretColor(dark ? Color.WHITE : Color.BLACK); textPane2.setForeground(fg);
textPane2.setCaretColor(dark ? Color.WHITE : Color.BLACK); textPane1.setCaretColor(fg);
textPane2.setCaretColor(fg);
if (lineNumbers1 != null) { lineNumbers1.setBackground(dark ? bg.brighter() : bg.darker());
lineNumbers1.setBackground(dark ? bg.brighter() : bg.darker()); lineNumbers1.setForeground(dark ? Color.LIGHT_GRAY : Color.DARK_GRAY);
lineNumbers1.setForeground(dark ? Color.LIGHT_GRAY : Color.DARK_GRAY); lineNumbers2.setBackground(dark ? bg.brighter() : bg.darker());
} lineNumbers2.setForeground(dark ? Color.LIGHT_GRAY : Color.DARK_GRAY);
if (lineNumbers2 != null) {
lineNumbers2.setBackground(dark ? bg.brighter() : bg.darker());
lineNumbers2.setForeground(dark ? Color.LIGHT_GRAY : Color.DARK_GRAY);
}
} }
Font f = config.getGlobalFont(); Font f = config.getGlobalFont();
if (f != null) { if (f != null) {
// Use monospaced variant of the font if possible, or just the same size
Font mono = new Font("Monospaced", Font.PLAIN, f.getSize()); Font mono = new Font("Monospaced", Font.PLAIN, f.getSize());
textPane1.setFont(mono); textPane1.setFont(mono);
textPane2.setFont(mono); textPane2.setFont(mono);
if (lineNumbers1 != null) lineNumbers1.setFont(mono); lineNumbers1.setFont(mono);
if (lineNumbers2 != null) lineNumbers2.setFont(mono); lineNumbers2.setFont(mono);
} }
} }
@ -242,128 +486,183 @@ public class FileComparisonDialog extends JFrame {
return (0.299 * c.getRed() + 0.587 * c.getGreen() + 0.114 * c.getBlue()) / 255 < 0.5; return (0.299 * c.getRed() + 0.587 * c.getGreen() + 0.114 * c.getBlue()) / 255 < 0.5;
} }
private void updateDisplay() { private List<AlignedLine> alignWithOptionalAnchor(List<String> left, List<String> right,
updateDisplay(true); List<String> comparableLeft, List<String> comparableRight,
} ComparisonOptions options) {
boolean validAnchor =
private void updateDisplay(boolean resetCaret) { manualAnchorLeftLine > 0
try { && manualAnchorRightLine > 0
textPane1.setText(""); && manualAnchorLeftLine <= left.size()
textPane2.setText(""); && manualAnchorRightLine <= right.size();
StyledDocument doc1 = textPane1.getStyledDocument(); if (!validAnchor) {
StyledDocument doc2 = textPane2.getStyledDocument(); return options.smartAlign
StringBuilder ln1 = new StringBuilder(); ? alignSmart(left, right, comparableLeft, comparableRight)
StringBuilder ln2 = new StringBuilder(); : alignByPosition(left, right, comparableLeft, comparableRight);
Style diffStyle = textPane1.addStyle("diff", null);
Color bg = textPane1.getBackground();
boolean dark = isDark(bg);
StyleConstants.setBackground(diffStyle, dark ? new Color(100, 30, 30) : new Color(255, 200, 200));
StyleConstants.setForeground(diffStyle, dark ? Color.WHITE : Color.BLACK);
int maxLines = Math.max(lines1.size(), lines2.size());
int counter1 = 1;
int counter2 = 1;
for (int i = 0; i < maxLines; i++) {
String l1 = i < lines1.size() ? lines1.get(i) : null;
String l2 = i < lines2.size() ? lines2.get(i) : null;
boolean different = (l1 == null && l2 != null) || (l1 != null && l2 == null) || (l1 != null && l2 != null && !l1.equals(l2));
Style s = different ? diffStyle : null;
if (l1 != null) {
doc1.insertString(doc1.getLength(), l1 + "\n", s);
ln1.append(counter1++).append("\n");
} else {
doc1.insertString(doc1.getLength(), "\n", s);
ln1.append("\n");
}
if (l2 != null) {
doc2.insertString(doc2.getLength(), l2 + "\n", s);
ln2.append(counter2++).append("\n");
} else {
doc2.insertString(doc2.getLength(), "\n", s);
ln2.append("\n");
}
}
if (lineNumbers1 != null) lineNumbers1.setText(ln1.toString());
if (lineNumbers2 != null) lineNumbers2.setText(ln2.toString());
if (resetCaret) {
textPane1.setCaretPosition(0);
textPane2.setCaretPosition(0);
}
} catch (Exception e) {
JOptionPane.showMessageDialog(this, "Error updating display: " + e.getMessage(), "Error", JOptionPane.ERROR_MESSAGE);
} }
int leftAnchorIndex = manualAnchorLeftLine - 1;
int rightAnchorIndex = manualAnchorRightLine - 1;
List<AlignedLine> result = new ArrayList<>();
List<AlignedLine> prefix = options.smartAlign
? alignSmartRange(left, right, comparableLeft, comparableRight, 0, leftAnchorIndex, 0, rightAnchorIndex)
: alignByPositionRange(left, right, comparableLeft, comparableRight, 0, leftAnchorIndex, 0, rightAnchorIndex);
result.addAll(prefix);
result.add(new AlignedLine(
left.get(leftAnchorIndex),
right.get(rightAnchorIndex),
manualAnchorLeftLine,
manualAnchorRightLine,
!equalsComparable(comparableLeft.get(leftAnchorIndex), comparableRight.get(rightAnchorIndex))
));
List<AlignedLine> suffix = options.smartAlign
? alignSmartRange(left, right, comparableLeft, comparableRight, leftAnchorIndex + 1, left.size(), rightAnchorIndex + 1, right.size())
: alignByPositionRange(left, right, comparableLeft, comparableRight, leftAnchorIndex + 1, left.size(), rightAnchorIndex + 1, right.size());
result.addAll(suffix);
return result;
} }
private void performSmartAlignment() { private List<AlignedLine> alignByPosition(List<String> left, List<String> right, List<String> comparableLeft, List<String> comparableRight) {
if (lines1.isEmpty() || lines2.isEmpty()) return; return alignByPositionRange(left, right, comparableLeft, comparableRight, 0, left.size(), 0, right.size());
}
private List<AlignedLine> alignByPositionRange(List<String> left, List<String> right, List<String> comparableLeft, List<String> comparableRight,
int leftStart, int leftEnd, int rightStart, int rightEnd) {
int leftLen = leftEnd - leftStart;
int rightLen = rightEnd - rightStart;
int max = Math.max(leftLen, rightLen);
List<AlignedLine> result = new ArrayList<>(max);
for (int i = 0; i < max; i++) {
int li = leftStart + i;
int ri = rightStart + i;
String l = li < leftEnd ? left.get(li) : null;
String r = ri < rightEnd ? right.get(ri) : null;
String cl = li < leftEnd ? comparableLeft.get(li) : null;
String cr = ri < rightEnd ? comparableRight.get(ri) : null;
int leftNum = li < leftEnd ? li + 1 : -1;
int rightNum = ri < rightEnd ? ri + 1 : -1;
result.add(new AlignedLine(l, r, leftNum, rightNum, !equalsComparable(cl, cr)));
}
return result;
}
private List<AlignedLine> alignSmart(List<String> left, List<String> right, List<String> comparableLeft, List<String> comparableRight) {
return alignSmartRange(left, right, comparableLeft, comparableRight, 0, left.size(), 0, right.size());
}
private List<AlignedLine> alignSmartRange(List<String> left, List<String> right, List<String> comparableLeft, List<String> comparableRight,
int leftStart, int leftEnd, int rightStart, int rightEnd) {
int n = leftEnd - leftStart;
int m = rightEnd - rightStart;
if (n == 0 && m == 0) return new ArrayList<>();
// Skip alignment for very large files to avoid O(N^2) memory/time issues
int maxLines = config.getMaxCompareLines(); int maxLines = config.getMaxCompareLines();
if (lines1.size() > maxLines || lines2.size() > maxLines) return; if (n > maxLines || m > maxLines) {
return alignByPositionRange(left, right, comparableLeft, comparableRight, leftStart, leftEnd, rightStart, rightEnd);
}
int n = lines1.size(); int[][] lcs = new int[n + 1][m + 1];
int m = lines2.size();
int[][] dp = new int[n + 1][m + 1];
// LCS computation with similarity check (exact or trimmed match)
for (int i = 1; i <= n; i++) { for (int i = 1; i <= n; i++) {
for (int j = 1; j <= m; j++) { for (int j = 1; j <= m; j++) {
String s1 = lines1.get(i - 1); if (equalsComparable(comparableLeft.get(leftStart + i - 1), comparableRight.get(rightStart + j - 1))) {
String s2 = lines2.get(j - 1); lcs[i][j] = lcs[i - 1][j - 1] + 1;
if (s1.equals(s2) || (!s1.trim().isEmpty() && s1.trim().equals(s2.trim()))) {
dp[i][j] = dp[i - 1][j - 1] + 1;
} else { } else {
dp[i][j] = Math.max(dp[i - 1][j], dp[i][j - 1]); lcs[i][j] = Math.max(lcs[i - 1][j], lcs[i][j - 1]);
} }
} }
} }
List<String> aligned1 = new ArrayList<>(); List<AlignedLine> reversed = new ArrayList<>();
List<String> aligned2 = new ArrayList<>(); int i = n;
int i = n, j = m; int j = m;
while (i > 0 || j > 0) { while (i > 0 || j > 0) {
if (i > 0 && j > 0) { if (i > 0 && j > 0 && equalsComparable(comparableLeft.get(leftStart + i - 1), comparableRight.get(rightStart + j - 1))) {
String s1 = lines1.get(i - 1); int li = leftStart + i - 1;
String s2 = lines2.get(j - 1); int ri = rightStart + j - 1;
if (s1.equals(s2) || (!s1.trim().isEmpty() && s1.trim().equals(s2.trim()))) { String l = left.get(li);
aligned1.add(0, s1); String r = right.get(ri);
aligned2.add(0, s2); reversed.add(new AlignedLine(l, r, li + 1, ri + 1, !equalsComparable(comparableLeft.get(li), comparableRight.get(ri))));
i--; i--;
j--; j--;
continue; } else if (j > 0 && (i == 0 || lcs[i][j - 1] >= lcs[i - 1][j])) {
} int ri = rightStart + j - 1;
} reversed.add(new AlignedLine(null, right.get(ri), -1, ri + 1, true));
if (j > 0 && (i == 0 || dp[i][j - 1] >= dp[i - 1][j])) {
aligned1.add(0, null); // Added gap marker
aligned2.add(0, lines2.get(j - 1));
j--; j--;
} else if (i > 0) { } else if (i > 0) {
aligned1.add(0, lines1.get(i - 1)); int li = leftStart + i - 1;
aligned2.add(0, null); // Added gap marker reversed.add(new AlignedLine(left.get(li), null, li + 1, -1, true));
i--; i--;
} }
} }
this.lines1 = aligned1; List<AlignedLine> result = new ArrayList<>(reversed.size());
this.lines2 = aligned2; for (int idx = reversed.size() - 1; idx >= 0; idx--) {
result.add(reversed.get(idx));
}
return result;
}
private List<String> buildComparableLines(List<String> lines, ComparisonOptions options) {
List<String> comparable = new ArrayList<>(lines.size());
for (String line : lines) {
comparable.add(normalize(line, options));
}
return comparable;
}
private boolean equalsComparable(String left, String right) {
if (left == null || right == null) return left == right;
return left.equals(right);
}
private String normalize(String text, ComparisonOptions options) {
if (text == null) return "";
String normalized = text;
if (options.ignoreTrim) {
normalized = normalized.trim();
}
if (options.ignoreWhitespace) {
normalized = normalized.replaceAll("\\s+", "");
}
if (options.ignoreCase) {
normalized = normalized.toLowerCase(Locale.ROOT);
}
return normalized;
} }
private List<String> readLines(File f) throws IOException { private List<String> readLines(File f) throws IOException {
List<String> lines = new ArrayList<>(); try {
try (BufferedReader br = new BufferedReader(new FileReader(f))) { return Files.readAllLines(f.toPath(), StandardCharsets.UTF_8);
String line; } catch (Exception e) {
while ((line = br.readLine()) != null) { return Files.readAllLines(f.toPath());
lines.add(line); }
} }
private static class ComparisonOptions {
boolean smartAlign;
boolean ignoreCase;
boolean ignoreTrim;
boolean ignoreWhitespace;
boolean onlyDifferences;
}
private static class AlignedLine {
final String left;
final String right;
final int leftNumber;
final int rightNumber;
final boolean different;
AlignedLine(String left, String right, int leftNumber, int rightNumber, boolean different) {
this.left = left;
this.right = right;
this.leftNumber = leftNumber;
this.rightNumber = rightNumber;
this.different = different;
} }
return lines;
} }
} }