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() {
JPanel panel = new JPanel(new FlowLayout(FlowLayout.LEFT, 8, 4));
smartAlignCheck = new JCheckBox("Smart align", true);
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);
JButton prevDiff = new JButton("Previous difference");
JButton nextDiff = new JButton("Next difference");
JButton synchronizeButton = new JButton("Synchronize from selected rows");
JButton clearSyncButton = new JButton("Clear sync");
JButton reloadButton = new JButton("Reload");
smartAlignCheck.addActionListener(e -> refreshComparison(false));
ignoreCaseCheck.addActionListener(e -> refreshComparison(false));
ignoreTrimCheck.addActionListener(e -> refreshComparison(false));
ignoreWhitespaceCheck.addActionListener(e -> refreshComparison(false));
onlyDifferencesCheck.addActionListener(e -> refreshComparison(true));
reloadButton.addActionListener(e -> reloadAndRefresh());
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 @Override
public void mousePressed(java.awt.event.MouseEvent e) { public void mousePressed(java.awt.event.MouseEvent e) {
handleSelection(e); selectLineAtClick((JTextPane) e.getComponent(), e.getPoint(), isLeftPane);
if (e.isPopupTrigger()) showMenu(e);
} }
@Override });
public void mouseReleased(java.awt.event.MouseEvent e) { return pane;
handleSelection(e);
if (e.isPopupTrigger()) showMenu(e);
} }
private void handleSelection(java.awt.event.MouseEvent e) {
if (SwingUtilities.isLeftMouseButton(e)) { private JTextArea createLineNumberArea() {
JTextPane pane = (JTextPane) e.getComponent(); JTextArea area = new JTextArea("1");
@SuppressWarnings("deprecation") area.setEditable(false);
int pos = pane.viewToModel(e.getPoint()); area.setBackground(Color.LIGHT_GRAY);
if (pos >= 0) { 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 { try {
sourceLines1.addAll(readLines(file1));
sourceLines2.addAll(readLines(file2));
} catch (IOException e) {
JOptionPane.showMessageDialog(this, "Error reading files: " + e.getMessage(), "Error", JOptionPane.ERROR_MESSAGE);
}
refreshComparison(true);
}
private void synchronizeFromSelectedRows() {
if (selectedLeftVisibleRow < 0 || selectedRightVisibleRow < 0) {
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 refreshComparison(boolean resetSelection) {
ComparisonOptions options = getOptions();
List<String> comparableLeft = buildComparableLines(sourceLines1, options);
List<String> comparableRight = buildComparableLines(sourceLines2, options);
alignedLines = alignWithOptionalAnchor(sourceLines1, sourceLines2, comparableLeft, comparableRight, options);
visibleIndices = new ArrayList<>();
differenceVisibleRows = new ArrayList<>();
for (int i = 0; i < alignedLines.size(); i++) {
AlignedLine line = alignedLines.get(i);
if (!options.onlyDifferences || line.different) {
int visibleRow = visibleIndices.size();
visibleIndices.add(i);
if (line.different) differenceVisibleRows.add(visibleRow);
}
}
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(); Element root = pane.getDocument().getDefaultRootElement();
int lineIdx = root.getElementIndex(pos); int lineIndex = root.getElementIndex(pos);
if (lineIndex < 0 || lineIndex >= visibleIndices.size()) return;
if (pane == textPane1) selectedLine1 = lineIdx; selectedVisibleRow = lineIndex;
else if (pane == textPane2) selectedLine2 = lineIdx; if (isLeftPane) {
selectedLeftVisibleRow = lineIndex;
Element line = root.getElement(lineIdx); } else {
int start = line.getStartOffset(); selectedRightVisibleRow = lineIndex;
int end = Math.min(line.getEndOffset(), pane.getDocument().getLength()); }
selectedDifferencePointer = -1;
// Use invokeLater to ensure selection happens after default UI behavior for (int i = 0; i < differenceVisibleRows.size(); i++) {
SwingUtilities.invokeLater(() -> { if (differenceVisibleRows.get(i) == selectedVisibleRow) {
pane.requestFocusInWindow(); selectedDifferencePointer = i;
pane.setCaretPosition(start); break;
pane.moveCaretPosition(end);
});
} catch (Exception ex) {
// ignore
} }
} }
} render();
} updateStatus();
private void showMenu(java.awt.event.MouseEvent e) {
JPopupMenu menu = new JPopupMenu();
JMenuItem syncItem = new JMenuItem("Synchronize from here");
syncItem.addActionListener(event -> synchronizeFromHere());
menu.add(syncItem);
menu.show(e.getComponent(), e.getX(), e.getY());
}
} }
private void synchronizeFromHere() { private void scrollToVisibleRow(int row) {
if (row < 0) return;
try { try {
int line1 = selectedLine1; Element root1 = textPane1.getDocument().getDefaultRootElement();
int line2 = selectedLine2; Element root2 = textPane2.getDocument().getDefaultRootElement();
if (row >= root1.getElementCount() || row >= root2.getElementCount()) return;
if (line1 < line2) { Element line1 = root1.getElement(row);
int diff = line2 - line1; Element line2 = root2.getElement(row);
for (int i = 0; i < diff; i++) { int pos1 = line1.getStartOffset();
lines1.add(line1, null); int pos2 = line2.getStartOffset();
}
} else if (line2 < line1) { textPane1.setCaretPosition(pos1);
int diff = line1 - line2; textPane2.setCaretPosition(pos2);
for (int i = 0; i < diff; i++) { } catch (Exception ignored) {
lines2.add(line2, null); // no-op
} }
} }
updateDisplay(false);
// Restore selection and scroll to the newly synced line private void updateStatus() {
int newLineIdx = Math.max(line1, line2); int different = 0;
selectedLine1 = newLineIdx; for (AlignedLine line : alignedLines) {
selectedLine2 = newLineIdx; if (line.different) different++;
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();
} }
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);
}
if (lineNumbers2 != null) {
lineNumbers2.setBackground(dark ? bg.brighter() : bg.darker()); lineNumbers2.setBackground(dark ? bg.brighter() : bg.darker());
lineNumbers2.setForeground(dark ? Color.LIGHT_GRAY : Color.DARK_GRAY); 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 =
manualAnchorLeftLine > 0
&& manualAnchorRightLine > 0
&& manualAnchorLeftLine <= left.size()
&& manualAnchorRightLine <= right.size();
if (!validAnchor) {
return options.smartAlign
? alignSmart(left, right, comparableLeft, comparableRight)
: alignByPosition(left, right, comparableLeft, comparableRight);
} }
private void updateDisplay(boolean resetCaret) { int leftAnchorIndex = manualAnchorLeftLine - 1;
try { int rightAnchorIndex = manualAnchorRightLine - 1;
textPane1.setText("");
textPane2.setText("");
StyledDocument doc1 = textPane1.getStyledDocument();
StyledDocument doc2 = textPane2.getStyledDocument();
StringBuilder ln1 = new StringBuilder();
StringBuilder ln2 = new StringBuilder();
Style diffStyle = textPane1.addStyle("diff", null); List<AlignedLine> result = new ArrayList<>();
Color bg = textPane1.getBackground(); List<AlignedLine> prefix = options.smartAlign
boolean dark = isDark(bg); ? alignSmartRange(left, right, comparableLeft, comparableRight, 0, leftAnchorIndex, 0, rightAnchorIndex)
StyleConstants.setBackground(diffStyle, dark ? new Color(100, 30, 30) : new Color(255, 200, 200)); : alignByPositionRange(left, right, comparableLeft, comparableRight, 0, leftAnchorIndex, 0, rightAnchorIndex);
StyleConstants.setForeground(diffStyle, dark ? Color.WHITE : Color.BLACK); result.addAll(prefix);
int maxLines = Math.max(lines1.size(), lines2.size()); result.add(new AlignedLine(
int counter1 = 1; left.get(leftAnchorIndex),
int counter2 = 1; right.get(rightAnchorIndex),
manualAnchorLeftLine,
manualAnchorRightLine,
!equalsComparable(comparableLeft.get(leftAnchorIndex), comparableRight.get(rightAnchorIndex))
));
for (int i = 0; i < maxLines; i++) { List<AlignedLine> suffix = options.smartAlign
String l1 = i < lines1.size() ? lines1.get(i) : null; ? alignSmartRange(left, right, comparableLeft, comparableRight, leftAnchorIndex + 1, left.size(), rightAnchorIndex + 1, right.size())
String l2 = i < lines2.size() ? lines2.get(i) : null; : alignByPositionRange(left, right, comparableLeft, comparableRight, leftAnchorIndex + 1, left.size(), rightAnchorIndex + 1, right.size());
result.addAll(suffix);
boolean different = (l1 == null && l2 != null) || (l1 != null && l2 == null) || (l1 != null && l2 != null && !l1.equals(l2)); return result;
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) { private List<AlignedLine> alignByPosition(List<String> left, List<String> right, List<String> comparableLeft, List<String> comparableRight) {
doc2.insertString(doc2.getLength(), l2 + "\n", s); return alignByPositionRange(left, right, comparableLeft, comparableRight, 0, left.size(), 0, right.size());
ln2.append(counter2++).append("\n");
} else {
doc2.insertString(doc2.getLength(), "\n", s);
ln2.append("\n");
}
} }
if (lineNumbers1 != null) lineNumbers1.setText(ln1.toString()); private List<AlignedLine> alignByPositionRange(List<String> left, List<String> right, List<String> comparableLeft, List<String> comparableRight,
if (lineNumbers2 != null) lineNumbers2.setText(ln2.toString()); int leftStart, int leftEnd, int rightStart, int rightEnd) {
int leftLen = leftEnd - leftStart;
if (resetCaret) { int rightLen = rightEnd - rightStart;
textPane1.setCaretPosition(0); int max = Math.max(leftLen, rightLen);
textPane2.setCaretPosition(0); List<AlignedLine> result = new ArrayList<>(max);
} for (int i = 0; i < max; i++) {
} catch (Exception e) { int li = leftStart + i;
JOptionPane.showMessageDialog(this, "Error updating display: " + e.getMessage(), "Error", JOptionPane.ERROR_MESSAGE); 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 void performSmartAlignment() { private List<AlignedLine> alignSmart(List<String> left, List<String> right, List<String> comparableLeft, List<String> comparableRight) {
if (lines1.isEmpty() || lines2.isEmpty()) return; 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);
} }
} }
return lines;
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;
}
} }
} }