diff --git a/src/main/java/cz/kamma/kfmanager/ui/FileComparisonDialog.java b/src/main/java/cz/kamma/kfmanager/ui/FileComparisonDialog.java index 9506868..a8660f2 100644 --- a/src/main/java/cz/kamma/kfmanager/ui/FileComparisonDialog.java +++ b/src/main/java/cz/kamma/kfmanager/ui/FileComparisonDialog.java @@ -1,369 +1,668 @@ -package cz.kamma.kfmanager.ui; - -import cz.kamma.kfmanager.config.AppConfig; -import javax.swing.*; -import javax.swing.text.*; -import java.awt.*; -import java.awt.event.ActionEvent; -import java.awt.event.KeyEvent; -import java.io.BufferedReader; -import java.io.File; -import java.io.FileReader; -import java.io.IOException; -import java.util.ArrayList; -import java.util.List; - -/** - * Window for comparing two files side by side - */ -public class FileComparisonDialog extends JFrame { - private final AppConfig config; - private JTextPane textPane1; - private JTextPane textPane2; - private JTextArea lineNumbers1; - private JTextArea lineNumbers2; - private JScrollPane scroll1; - private JScrollPane scroll2; - private List lines1 = new ArrayList<>(); - private List lines2 = new ArrayList<>(); - private int selectedLine1 = 0; - private int selectedLine2 = 0; - - public FileComparisonDialog(Window parent, File file1, File file2, AppConfig config) { - super("Compare Files: " + file1.getName() + " vs " + file2.getName()); - this.config = config; - - try { - this.lines1 = readLines(file1); - this.lines2 = readLines(file2); - performSmartAlignment(); - } catch (IOException e) { - JOptionPane.showMessageDialog(this, "Error reading files: " + e.getMessage(), "Error", JOptionPane.ERROR_MESSAGE); - } - - initComponents(); - updateDisplay(); - - setSize(1000, 700); - setLocationRelativeTo(parent); - setDefaultCloseOperation(JFrame.DISPOSE_ON_CLOSE); - - // Close on ESC - getRootPane().getInputMap(JComponent.WHEN_IN_FOCUSED_WINDOW).put(KeyStroke.getKeyStroke(KeyEvent.VK_ESCAPE, 0), "close"); - getRootPane().getActionMap().put("close", new AbstractAction() { - @Override - public void actionPerformed(ActionEvent e) { - dispose(); - } - }); - } - - private void initComponents() { - setLayout(new BorderLayout()); - - JPanel centerPanel = new JPanel(new GridLayout(1, 2, 5, 0)); - - 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(); - } - - private class TextPaneListener extends java.awt.event.MouseAdapter { - @Override - 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; - else if (pane == textPane2) selectedLine2 = lineIdx; - - Element line = root.getElement(lineIdx); - int start = line.getStartOffset(); - int end = Math.min(line.getEndOffset(), pane.getDocument().getLength()); - - // Use invokeLater to ensure selection happens after default UI behavior - SwingUtilities.invokeLater(() -> { - pane.requestFocusInWindow(); - pane.setCaretPosition(start); - pane.moveCaretPosition(end); - }); - } catch (Exception ex) { - // ignore - } - } - } - } - 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() { - try { - int line1 = selectedLine1; - int line2 = selectedLine2; - - if (line1 < line2) { - int diff = line2 - line1; - for (int i = 0; i < diff; i++) { - lines1.add(line1, null); - } - } else if (line2 < line1) { - int diff = line1 - line2; - for (int i = 0; i < diff; i++) { - lines2.add(line2, null); - } - } - 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(); - } - } - - private void applyAppearance() { - Color bg = config.getBackgroundColor(); - if (bg != null) { - textPane1.setBackground(bg); - textPane2.setBackground(bg); - boolean dark = isDark(bg); - textPane1.setForeground(dark ? Color.WHITE : Color.BLACK); - textPane2.setForeground(dark ? Color.WHITE : Color.BLACK); - textPane1.setCaretColor(dark ? Color.WHITE : Color.BLACK); - textPane2.setCaretColor(dark ? Color.WHITE : Color.BLACK); - - if (lineNumbers1 != null) { - lineNumbers1.setBackground(dark ? bg.brighter() : bg.darker()); - lineNumbers1.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(); - 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()); - textPane1.setFont(mono); - textPane2.setFont(mono); - if (lineNumbers1 != null) lineNumbers1.setFont(mono); - if (lineNumbers2 != null) lineNumbers2.setFont(mono); - } - } - - private boolean isDark(Color c) { - return (0.299 * c.getRed() + 0.587 * c.getGreen() + 0.114 * c.getBlue()) / 255 < 0.5; - } - - private void updateDisplay() { - updateDisplay(true); - } - - private void updateDisplay(boolean resetCaret) { - try { - 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); - 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); - } - } - - private void performSmartAlignment() { - if (lines1.isEmpty() || lines2.isEmpty()) return; - - // Skip alignment for very large files to avoid O(N^2) memory/time issues - int maxLines = config.getMaxCompareLines(); - if (lines1.size() > maxLines || lines2.size() > maxLines) return; - - int n = lines1.size(); - 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 j = 1; j <= m; j++) { - String s1 = lines1.get(i - 1); - String s2 = lines2.get(j - 1); - if (s1.equals(s2) || (!s1.trim().isEmpty() && s1.trim().equals(s2.trim()))) { - dp[i][j] = dp[i - 1][j - 1] + 1; - } else { - dp[i][j] = Math.max(dp[i - 1][j], dp[i][j - 1]); - } - } - } - - List aligned1 = new ArrayList<>(); - List aligned2 = new ArrayList<>(); - int i = n, j = m; - while (i > 0 || j > 0) { - if (i > 0 && j > 0) { - String s1 = lines1.get(i - 1); - String s2 = lines2.get(j - 1); - if (s1.equals(s2) || (!s1.trim().isEmpty() && s1.trim().equals(s2.trim()))) { - aligned1.add(0, s1); - aligned2.add(0, s2); - i--; - j--; - continue; - } - } - - 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--; - } else if (i > 0) { - aligned1.add(0, lines1.get(i - 1)); - aligned2.add(0, null); // Added gap marker - i--; - } - } - - this.lines1 = aligned1; - this.lines2 = aligned2; - } - - private List readLines(File f) throws IOException { - List lines = new ArrayList<>(); - try (BufferedReader br = new BufferedReader(new FileReader(f))) { - String line; - while ((line = br.readLine()) != null) { - lines.add(line); - } - } - return lines; - } +package cz.kamma.kfmanager.ui; + +import cz.kamma.kfmanager.config.AppConfig; + +import javax.swing.*; +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.event.ActionEvent; +import java.awt.event.KeyEvent; +import java.io.File; +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.util.ArrayList; +import java.util.List; +import java.util.Locale; + +/** + * Window for comparing two files side by side. + */ +public class FileComparisonDialog extends JFrame { + private final AppConfig config; + private final File file1; + private final File file2; + + private final List sourceLines1 = new ArrayList<>(); + private final List sourceLines2 = new ArrayList<>(); + + private List alignedLines = new ArrayList<>(); + private List visibleIndices = new ArrayList<>(); + private List differenceVisibleRows = new ArrayList<>(); + + private JTextPane textPane1; + private JTextPane textPane2; + private JTextArea lineNumbers1; + private JTextArea lineNumbers2; + private JScrollPane scroll1; + private JScrollPane scroll2; + + private JCheckBox smartAlignCheck; + private JCheckBox ignoreCaseCheck; + 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) { + super("Compare Files: " + file1.getName() + " vs " + file2.getName()); + this.file1 = file1; + this.file2 = file2; + this.config = config; + + try { + sourceLines1.addAll(readLines(file1)); + sourceLines2.addAll(readLines(file2)); + } catch (IOException e) { + JOptionPane.showMessageDialog(parent, "Error reading files: " + e.getMessage(), "Error", JOptionPane.ERROR_MESSAGE); + } + + initComponents(); + refreshComparison(true); + + setSize(1100, 760); + setLocationRelativeTo(parent); + setDefaultCloseOperation(JFrame.DISPOSE_ON_CLOSE); + + getRootPane().getInputMap(JComponent.WHEN_IN_FOCUSED_WINDOW).put(KeyStroke.getKeyStroke(KeyEvent.VK_ESCAPE, 0), "close"); + getRootPane().getActionMap().put("close", new AbstractAction() { + @Override + public void actionPerformed(ActionEvent e) { + dispose(); + } + }); + } + + private void initComponents() { + setLayout(new BorderLayout(6, 6)); + add(createTopToolbar(), BorderLayout.NORTH); + add(createCenterPanel(), BorderLayout.CENTER); + add(createBottomBar(), BorderLayout.SOUTH); + applyAppearance(); + } + + 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 + 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); + } + 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 comparableLeft = buildComparableLines(sourceLines1, options); + List 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(); + 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() { + Color bg = config.getBackgroundColor(); + if (bg != null) { + textPane1.setBackground(bg); + textPane2.setBackground(bg); + boolean dark = isDark(bg); + Color fg = dark ? Color.WHITE : Color.BLACK; + textPane1.setForeground(fg); + textPane2.setForeground(fg); + textPane1.setCaretColor(fg); + textPane2.setCaretColor(fg); + + lineNumbers1.setBackground(dark ? bg.brighter() : bg.darker()); + 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); + } + Font f = config.getGlobalFont(); + if (f != null) { + Font mono = new Font("Monospaced", Font.PLAIN, f.getSize()); + textPane1.setFont(mono); + textPane2.setFont(mono); + lineNumbers1.setFont(mono); + lineNumbers2.setFont(mono); + } + } + + private boolean isDark(Color c) { + return (0.299 * c.getRed() + 0.587 * c.getGreen() + 0.114 * c.getBlue()) / 255 < 0.5; + } + + private List alignWithOptionalAnchor(List left, List right, + List comparableLeft, List 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); + } + + int leftAnchorIndex = manualAnchorLeftLine - 1; + int rightAnchorIndex = manualAnchorRightLine - 1; + + List result = new ArrayList<>(); + List 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 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 List alignByPosition(List left, List right, List comparableLeft, List comparableRight) { + return alignByPositionRange(left, right, comparableLeft, comparableRight, 0, left.size(), 0, right.size()); + } + + private List alignByPositionRange(List left, List right, List comparableLeft, List comparableRight, + int leftStart, int leftEnd, int rightStart, int rightEnd) { + int leftLen = leftEnd - leftStart; + int rightLen = rightEnd - rightStart; + int max = Math.max(leftLen, rightLen); + List 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 alignSmart(List left, List right, List comparableLeft, List comparableRight) { + return alignSmartRange(left, right, comparableLeft, comparableRight, 0, left.size(), 0, right.size()); + } + + private List alignSmartRange(List left, List right, List comparableLeft, List 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<>(); + + int maxLines = config.getMaxCompareLines(); + if (n > maxLines || m > maxLines) { + return alignByPositionRange(left, right, comparableLeft, comparableRight, leftStart, leftEnd, rightStart, rightEnd); + } + + int[][] lcs = new int[n + 1][m + 1]; + + for (int i = 1; i <= n; i++) { + for (int j = 1; j <= m; j++) { + if (equalsComparable(comparableLeft.get(leftStart + i - 1), comparableRight.get(rightStart + j - 1))) { + lcs[i][j] = lcs[i - 1][j - 1] + 1; + } else { + lcs[i][j] = Math.max(lcs[i - 1][j], lcs[i][j - 1]); + } + } + } + + List reversed = new ArrayList<>(); + int i = n; + int j = m; + while (i > 0 || j > 0) { + if (i > 0 && j > 0 && equalsComparable(comparableLeft.get(leftStart + i - 1), comparableRight.get(rightStart + j - 1))) { + int li = leftStart + i - 1; + int ri = rightStart + j - 1; + String l = left.get(li); + String r = right.get(ri); + reversed.add(new AlignedLine(l, r, li + 1, ri + 1, !equalsComparable(comparableLeft.get(li), comparableRight.get(ri)))); + i--; + j--; + } 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)); + j--; + } else if (i > 0) { + int li = leftStart + i - 1; + reversed.add(new AlignedLine(left.get(li), null, li + 1, -1, true)); + i--; + } + } + + List result = new ArrayList<>(reversed.size()); + for (int idx = reversed.size() - 1; idx >= 0; idx--) { + result.add(reversed.get(idx)); + } + return result; + } + + private List buildComparableLines(List lines, ComparisonOptions options) { + List 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 readLines(File f) throws IOException { + try { + return Files.readAllLines(f.toPath(), StandardCharsets.UTF_8); + } catch (Exception e) { + return Files.readAllLines(f.toPath()); + } + } + + 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; + } + } }