931 lines
42 KiB
Java
931 lines
42 KiB
Java
import javax.swing.*;
|
|
import javax.swing.border.EmptyBorder;
|
|
import javax.swing.border.TitledBorder;
|
|
import javax.swing.filechooser.FileSystemView;
|
|
import javax.swing.tree.*;
|
|
import java.awt.*;
|
|
import java.awt.event.*;
|
|
import java.io.File;
|
|
import java.io.IOException;
|
|
import java.util.ArrayList;
|
|
import java.util.Collections;
|
|
import java.util.Hashtable;
|
|
import java.util.List;
|
|
|
|
/**
|
|
* DuplicateFinderGUI - Swing GUI wrapper around DuplicateFinder (CLI core).
|
|
*
|
|
* Enhanced: tree view + selective delete.
|
|
* - Uses Hashtable<String, List<String>> md5 -> files core
|
|
* - JTree with checkboxes for selective deletion
|
|
* - Auto-select (keep first), clear, delete, open folder, expand/collapse
|
|
* - Background SwingWorker scan with progress
|
|
*/
|
|
public class DuplicateFinderGUI extends JFrame {
|
|
|
|
private JTextField txtDirectory;
|
|
private JButton btnBrowse;
|
|
private JButton btnFind;
|
|
private JButton btnCancel;
|
|
private JButton btnClear;
|
|
private JCheckBox chkRecursive;
|
|
|
|
private JProgressBar progressBar;
|
|
private JLabel lblStatus;
|
|
private JLabel lblSummary;
|
|
|
|
// Tree
|
|
private JTree tree;
|
|
private DefaultTreeModel treeModel;
|
|
private DefaultMutableTreeNode rootNode;
|
|
private JScrollPane treeScroll;
|
|
|
|
// Tree actions
|
|
private JButton btnAutoSelect;
|
|
private JButton btnClearSelection;
|
|
private JButton btnDeleteSelected;
|
|
private JButton btnExpandAll;
|
|
private JButton btnCollapseAll;
|
|
private JButton btnOpenFolder;
|
|
private JLabel lblCheckedInfo;
|
|
|
|
private JFileChooser dirChooser;
|
|
private ScanWorker currentWorker;
|
|
|
|
// Data holder for tree nodes
|
|
private static class CheckedNode {
|
|
String text; // display text
|
|
String filePath; // null for group, absolute path for file
|
|
String md5; // for group
|
|
long fileSize; // for file
|
|
boolean isGroup;
|
|
boolean checked;
|
|
|
|
CheckedNode(String text, String filePath, String md5, long fileSize, boolean isGroup) {
|
|
this.text = text;
|
|
this.filePath = filePath;
|
|
this.md5 = md5;
|
|
this.fileSize = fileSize;
|
|
this.isGroup = isGroup;
|
|
this.checked = false;
|
|
}
|
|
|
|
@Override
|
|
public String toString() {
|
|
return text;
|
|
}
|
|
}
|
|
|
|
public DuplicateFinderGUI() {
|
|
super("Duplicate File Finder - MD5 Hashtable");
|
|
setDefaultCloseOperation(JFrame.EXIT_ON_CLOSE);
|
|
setSize(1000, 700);
|
|
setLocationRelativeTo(null);
|
|
initComponents();
|
|
}
|
|
|
|
private void initComponents() {
|
|
try {
|
|
UIManager.setLookAndFeel(UIManager.getSystemLookAndFeelClassName());
|
|
} catch (Exception ignored) {}
|
|
|
|
// Top panel - directory selection
|
|
JPanel pnlTop = new JPanel(new GridBagLayout());
|
|
pnlTop.setBorder(new EmptyBorder(10, 10, 10, 10));
|
|
GridBagConstraints gbc = new GridBagConstraints();
|
|
|
|
JLabel lblDir = new JLabel("Directory:");
|
|
txtDirectory = new JTextField(40);
|
|
txtDirectory.setToolTipText("Enter or browse to the directory to scan");
|
|
txtDirectory.setText(new File(System.getProperty("user.home")).getAbsolutePath());
|
|
|
|
btnBrowse = new JButton("Browse...");
|
|
chkRecursive = new JCheckBox("Recursive (include subdirectories)", true);
|
|
btnFind = new JButton("Find Duplicates");
|
|
btnFind.setFont(btnFind.getFont().deriveFont(Font.BOLD));
|
|
btnCancel = new JButton("Cancel");
|
|
btnCancel.setEnabled(false);
|
|
btnClear = new JButton("Clear Results");
|
|
|
|
gbc.insets = new Insets(5,5,5,5);
|
|
gbc.gridx = 0; gbc.gridy = 0; gbc.anchor = GridBagConstraints.WEST;
|
|
pnlTop.add(lblDir, gbc);
|
|
gbc.gridx = 1; gbc.gridy = 0; gbc.fill = GridBagConstraints.HORIZONTAL; gbc.weightx = 1.0;
|
|
pnlTop.add(txtDirectory, gbc);
|
|
gbc.gridx = 2; gbc.gridy = 0; gbc.fill = GridBagConstraints.NONE; gbc.weightx = 0;
|
|
pnlTop.add(btnBrowse, gbc);
|
|
gbc.gridx = 0; gbc.gridy = 1; gbc.gridwidth = 3; gbc.anchor = GridBagConstraints.WEST;
|
|
pnlTop.add(chkRecursive, gbc);
|
|
JPanel pnlButtons = new JPanel(new FlowLayout(FlowLayout.LEFT, 10, 0));
|
|
pnlButtons.add(btnFind);
|
|
pnlButtons.add(btnCancel);
|
|
pnlButtons.add(btnClear);
|
|
gbc.gridx = 0; gbc.gridy = 2; gbc.gridwidth = 3; gbc.fill = GridBagConstraints.NONE;
|
|
pnlTop.add(pnlButtons, gbc);
|
|
|
|
// Progress
|
|
progressBar = new JProgressBar(0, 100);
|
|
progressBar.setStringPainted(true);
|
|
progressBar.setString("Idle");
|
|
lblStatus = new JLabel("Ready. Select a directory and press Find Duplicates.");
|
|
lblSummary = new JLabel(" ");
|
|
|
|
JPanel pnlProgress = new JPanel(new BorderLayout(5,5));
|
|
pnlProgress.setBorder(new EmptyBorder(0, 10, 10, 10));
|
|
pnlProgress.add(progressBar, BorderLayout.NORTH);
|
|
pnlProgress.add(lblStatus, BorderLayout.CENTER);
|
|
pnlProgress.add(lblSummary, BorderLayout.SOUTH);
|
|
|
|
JPanel pnlNorth = new JPanel(new BorderLayout());
|
|
pnlNorth.add(pnlTop, BorderLayout.NORTH);
|
|
pnlNorth.add(pnlProgress, BorderLayout.SOUTH);
|
|
|
|
// Tree
|
|
rootNode = new DefaultMutableTreeNode(new CheckedNode("No scan yet - press Find Duplicates", null, null, 0, true));
|
|
treeModel = new DefaultTreeModel(rootNode);
|
|
tree = new JTree(treeModel);
|
|
tree.setRootVisible(false);
|
|
tree.setShowsRootHandles(true);
|
|
tree.getSelectionModel().setSelectionMode(TreeSelectionModel.SINGLE_TREE_SELECTION);
|
|
tree.setCellRenderer(new CheckBoxTreeCellRenderer());
|
|
tree.setRowHeight(22);
|
|
tree.setToggleClickCount(2); // double click to expand, single click handled for checkbox
|
|
// Mouse listener for checkbox toggling
|
|
tree.addMouseListener(new MouseAdapter() {
|
|
@Override
|
|
public void mousePressed(MouseEvent e) {
|
|
int row = tree.getRowForLocation(e.getX(), e.getY());
|
|
TreePath path = tree.getPathForLocation(e.getX(), e.getY());
|
|
if (row == -1 || path == null) return;
|
|
DefaultMutableTreeNode node = (DefaultMutableTreeNode) path.getLastPathComponent();
|
|
Object obj = node.getUserObject();
|
|
if (!(obj instanceof CheckedNode)) return;
|
|
CheckedNode data = (CheckedNode) obj;
|
|
// Only toggle checkbox if click is on checkbox area OR on file/group node with single click
|
|
// For simplicity toggle on any click on the node, but let expand/collapse handle via toggleClickCount=2
|
|
// Determine if click was on expand handle? JTree handles that before us, but we can still toggle.
|
|
// We'll check if click is on the left ~30px where checkbox is, otherwise ignore for group expand convenience
|
|
// But many users expect click on label toggles checkbox too - so we toggle anyway except when double-click expand?
|
|
// Use: if the path is already selected and click is single, toggle. Simpler: always toggle.
|
|
Rectangle bounds = tree.getRowBounds(row);
|
|
if (bounds == null) return;
|
|
// Allow toggle if click is within checkbox+label area (not far right)
|
|
// For usability, toggle on any click inside row
|
|
boolean newState = !data.checked;
|
|
setNodeChecked(node, newState);
|
|
// Update parent group state to reflect children? If all children checked, group checked; if none, unchecked
|
|
updateParentGroupState(node);
|
|
tree.repaint();
|
|
updateCheckedInfo();
|
|
}
|
|
});
|
|
// Space bar toggles checkbox of selected node
|
|
tree.addKeyListener(new KeyAdapter() {
|
|
@Override
|
|
public void keyPressed(KeyEvent e) {
|
|
if (e.getKeyCode() == KeyEvent.VK_SPACE) {
|
|
TreePath path = tree.getSelectionPath();
|
|
if (path != null) {
|
|
DefaultMutableTreeNode node = (DefaultMutableTreeNode) path.getLastPathComponent();
|
|
Object obj = node.getUserObject();
|
|
if (obj instanceof CheckedNode) {
|
|
CheckedNode data = (CheckedNode) obj;
|
|
setNodeChecked(node, !data.checked);
|
|
updateParentGroupState(node);
|
|
tree.repaint();
|
|
updateCheckedInfo();
|
|
e.consume();
|
|
}
|
|
}
|
|
}
|
|
}
|
|
});
|
|
// Selection listener to show file details / enable open folder
|
|
tree.addTreeSelectionListener(e -> {
|
|
TreePath path = tree.getSelectionPath();
|
|
if (path != null) {
|
|
DefaultMutableTreeNode node = (DefaultMutableTreeNode) path.getLastPathComponent();
|
|
Object obj = node.getUserObject();
|
|
if (obj instanceof CheckedNode) {
|
|
CheckedNode data = (CheckedNode) obj;
|
|
if (!data.isGroup && data.filePath != null) {
|
|
btnOpenFolder.setEnabled(true);
|
|
lblStatus.setText(data.filePath + " (" + data.fileSize + " bytes)");
|
|
} else if (data.isGroup) {
|
|
btnOpenFolder.setEnabled(false);
|
|
lblStatus.setText(data.text);
|
|
} else {
|
|
btnOpenFolder.setEnabled(false);
|
|
}
|
|
}
|
|
} else {
|
|
btnOpenFolder.setEnabled(false);
|
|
}
|
|
});
|
|
|
|
treeScroll = new JScrollPane(tree);
|
|
treeScroll.setBorder(new TitledBorder("Duplicate Groups (check files to delete)"));
|
|
|
|
// Tree action buttons
|
|
btnAutoSelect = new JButton("Auto-Select (keep 1 per group)");
|
|
btnAutoSelect.setToolTipText("Checks all but the first file in each duplicate group - safe default to keep one copy");
|
|
btnAutoSelect.setEnabled(false);
|
|
btnClearSelection = new JButton("Clear Checks");
|
|
btnClearSelection.setEnabled(false);
|
|
btnDeleteSelected = new JButton("Delete Checked Files");
|
|
btnDeleteSelected.setEnabled(false);
|
|
btnDeleteSelected.setBackground(new Color(255, 200, 200));
|
|
btnExpandAll = new JButton("Expand All");
|
|
btnExpandAll.setEnabled(false);
|
|
btnCollapseAll = new JButton("Collapse All");
|
|
btnCollapseAll.setEnabled(false);
|
|
btnOpenFolder = new JButton("Open Location");
|
|
btnOpenFolder.setEnabled(false);
|
|
btnOpenFolder.setToolTipText("Open containing folder of selected file");
|
|
|
|
lblCheckedInfo = new JLabel("No files checked");
|
|
lblCheckedInfo.setFont(lblCheckedInfo.getFont().deriveFont(Font.ITALIC));
|
|
|
|
JPanel pnlTreeActions = new JPanel(new FlowLayout(FlowLayout.LEFT, 8, 5));
|
|
pnlTreeActions.add(btnAutoSelect);
|
|
pnlTreeActions.add(btnClearSelection);
|
|
pnlTreeActions.add(btnDeleteSelected);
|
|
pnlTreeActions.add(btnOpenFolder);
|
|
pnlTreeActions.add(new JSeparator(SwingConstants.VERTICAL));
|
|
pnlTreeActions.add(btnExpandAll);
|
|
pnlTreeActions.add(btnCollapseAll);
|
|
pnlTreeActions.add(lblCheckedInfo);
|
|
|
|
JPanel pnlCenter = new JPanel(new BorderLayout());
|
|
pnlCenter.add(treeScroll, BorderLayout.CENTER);
|
|
pnlCenter.add(pnlTreeActions, BorderLayout.SOUTH);
|
|
|
|
// Popup menu
|
|
JPopupMenu popup = new JPopupMenu();
|
|
JMenuItem miDelete = new JMenuItem("Delete Checked Files");
|
|
JMenuItem miAuto = new JMenuItem("Auto-Select (keep 1)");
|
|
JMenuItem miClear = new JMenuItem("Clear Checks");
|
|
JMenuItem miOpen = new JMenuItem("Open Location");
|
|
JMenuItem miCopyPath = new JMenuItem("Copy Path");
|
|
popup.add(miAuto);
|
|
popup.add(miClear);
|
|
popup.addSeparator();
|
|
popup.add(miDelete);
|
|
popup.addSeparator();
|
|
popup.add(miOpen);
|
|
popup.add(miCopyPath);
|
|
tree.setComponentPopupMenu(popup);
|
|
// Popup actions
|
|
miDelete.addActionListener(e -> deleteCheckedFiles());
|
|
miAuto.addActionListener(e -> autoSelectKeepOne());
|
|
miClear.addActionListener(e -> clearChecks());
|
|
miOpen.addActionListener(e -> openSelectedFolder());
|
|
miCopyPath.addActionListener(e -> copySelectedPath());
|
|
|
|
// Menu bar
|
|
JMenuBar menuBar = new JMenuBar();
|
|
JMenu mnuFile = new JMenu("File");
|
|
JMenuItem miBrowse = new JMenuItem("Browse...");
|
|
JMenuItem miExit = new JMenuItem("Exit");
|
|
mnuFile.add(miBrowse);
|
|
mnuFile.addSeparator();
|
|
mnuFile.add(miExit);
|
|
JMenu mnuEdit = new JMenu("Edit");
|
|
JMenuItem miSelectAll = new JMenuItem("Auto-Select Keep One");
|
|
JMenuItem miDeleteMenu = new JMenuItem("Delete Checked...");
|
|
mnuEdit.add(miSelectAll);
|
|
mnuEdit.add(miDeleteMenu);
|
|
JMenu mnuView = new JMenu("View");
|
|
JMenuItem miExpand = new JMenuItem("Expand All");
|
|
JMenuItem miCollapse = new JMenuItem("Collapse All");
|
|
mnuView.add(miExpand);
|
|
mnuView.add(miCollapse);
|
|
JMenu mnuHelp = new JMenu("Help");
|
|
JMenuItem miAbout = new JMenuItem("About");
|
|
mnuHelp.add(miAbout);
|
|
menuBar.add(mnuFile);
|
|
menuBar.add(mnuEdit);
|
|
menuBar.add(mnuView);
|
|
menuBar.add(mnuHelp);
|
|
setJMenuBar(menuBar);
|
|
|
|
dirChooser = new JFileChooser(FileSystemView.getFileSystemView());
|
|
dirChooser.setFileSelectionMode(JFileChooser.DIRECTORIES_ONLY);
|
|
dirChooser.setAcceptAllFileFilterUsed(false);
|
|
dirChooser.setDialogTitle("Select Directory to Scan");
|
|
|
|
setLayout(new BorderLayout());
|
|
add(pnlNorth, BorderLayout.NORTH);
|
|
add(pnlCenter, BorderLayout.CENTER);
|
|
|
|
// Listeners
|
|
btnBrowse.addActionListener(e -> browse());
|
|
miBrowse.addActionListener(e -> browse());
|
|
miExit.addActionListener(e -> dispose());
|
|
miAbout.addActionListener(e -> JOptionPane.showMessageDialog(this,
|
|
"Duplicate File Finder\n\nFinds duplicate files by MD5 Hashtable.\nHashtable<MD5, List<filename>> - groups with >1 file are duplicates.\n\nTree view: check unwanted duplicates, then Delete.\nSafe auto-select keeps 1 file per group.\n\nCLI: java DuplicateFinder <directory>\nGUI: java DuplicateFinderGUI\n\nJava " + System.getProperty("java.version"),
|
|
"About", JOptionPane.INFORMATION_MESSAGE));
|
|
|
|
btnFind.addActionListener(e -> startScan());
|
|
btnCancel.addActionListener(e -> cancelScan());
|
|
btnClear.addActionListener(e -> clearResults());
|
|
txtDirectory.addActionListener(e -> startScan());
|
|
|
|
btnAutoSelect.addActionListener(e -> autoSelectKeepOne());
|
|
miSelectAll.addActionListener(e -> autoSelectKeepOne());
|
|
btnClearSelection.addActionListener(e -> clearChecks());
|
|
btnDeleteSelected.addActionListener(e -> deleteCheckedFiles());
|
|
miDeleteMenu.addActionListener(e -> deleteCheckedFiles());
|
|
btnExpandAll.addActionListener(e -> expandAll());
|
|
btnCollapseAll.addActionListener(e -> collapseAll());
|
|
miExpand.addActionListener(e -> expandAll());
|
|
miCollapse.addActionListener(e -> collapseAll());
|
|
btnOpenFolder.addActionListener(e -> openSelectedFolder());
|
|
}
|
|
|
|
// Renderer with checkbox + label
|
|
private class CheckBoxTreeCellRenderer extends JPanel implements TreeCellRenderer {
|
|
private JCheckBox checkBox = new JCheckBox();
|
|
private JLabel iconLabel = new JLabel();
|
|
private JLabel textLabel = new JLabel();
|
|
private JLabel sizeLabel = new JLabel();
|
|
|
|
CheckBoxTreeCellRenderer() {
|
|
setLayout(new FlowLayout(FlowLayout.LEFT, 4, 0));
|
|
setOpaque(false);
|
|
checkBox.setOpaque(false);
|
|
textLabel.setOpaque(false);
|
|
sizeLabel.setOpaque(false);
|
|
sizeLabel.setFont(sizeLabel.getFont().deriveFont(Font.ITALIC, 11f));
|
|
sizeLabel.setForeground(Color.GRAY);
|
|
add(checkBox);
|
|
add(iconLabel);
|
|
add(textLabel);
|
|
add(sizeLabel);
|
|
}
|
|
|
|
@Override
|
|
public Component getTreeCellRendererComponent(JTree tree, Object value, boolean selected, boolean expanded, boolean leaf, int row, boolean hasFocus) {
|
|
DefaultMutableTreeNode node = (DefaultMutableTreeNode) value;
|
|
Object obj = node.getUserObject();
|
|
if (!(obj instanceof CheckedNode)) {
|
|
textLabel.setText(obj.toString());
|
|
checkBox.setVisible(false);
|
|
iconLabel.setIcon(null);
|
|
sizeLabel.setText("");
|
|
return this;
|
|
}
|
|
CheckedNode data = (CheckedNode) obj;
|
|
// Root invisible case
|
|
if (node == rootNode && rootNode.getChildCount()==0) {
|
|
// shouldn't happen
|
|
}
|
|
checkBox.setVisible(true);
|
|
checkBox.setSelected(data.checked);
|
|
checkBox.setEnabled(tree.isEnabled());
|
|
textLabel.setText(data.text);
|
|
// Icon: group vs file, also indicate checked for deletion (red?)
|
|
if (data.isGroup) {
|
|
iconLabel.setIcon(UIManager.getIcon("FileView.directoryIcon"));
|
|
// Show md5 truncated - guard against null (placeholder root nodes)
|
|
if (data.md5 != null && data.md5.length() >= 8) {
|
|
sizeLabel.setText(" [" + data.md5.substring(0, 8) + "...]");
|
|
} else if (data.md5 != null && !data.md5.isEmpty()) {
|
|
sizeLabel.setText(" [" + data.md5 + "]");
|
|
} else {
|
|
sizeLabel.setText("");
|
|
}
|
|
textLabel.setFont(textLabel.getFont().deriveFont(Font.BOLD));
|
|
} else {
|
|
iconLabel.setIcon(UIManager.getIcon("FileView.fileIcon"));
|
|
textLabel.setFont(textLabel.getFont().deriveFont(Font.PLAIN));
|
|
sizeLabel.setText(" (" + data.fileSize + " bytes)");
|
|
}
|
|
// Selection coloring
|
|
if (selected) {
|
|
setBackground(UIManager.getColor("Tree.selectionBackground"));
|
|
setOpaque(true);
|
|
textLabel.setForeground(UIManager.getColor("Tree.selectionForeground"));
|
|
sizeLabel.setForeground(UIManager.getColor("Tree.selectionForeground"));
|
|
} else {
|
|
setOpaque(false);
|
|
textLabel.setForeground(UIManager.getColor("Tree.textForeground"));
|
|
sizeLabel.setForeground(Color.GRAY);
|
|
if (data.checked && !data.isGroup) {
|
|
textLabel.setForeground(new Color(180, 0, 0));
|
|
}
|
|
}
|
|
// Strike through for checked? Could add but keep simple
|
|
return this;
|
|
}
|
|
}
|
|
|
|
private void setNodeChecked(DefaultMutableTreeNode node, boolean checked) {
|
|
Object obj = node.getUserObject();
|
|
if (!(obj instanceof CheckedNode)) return;
|
|
CheckedNode data = (CheckedNode) obj;
|
|
data.checked = checked;
|
|
// If group, propagate to children
|
|
if (data.isGroup) {
|
|
for (int i = 0; i < node.getChildCount(); i++) {
|
|
DefaultMutableTreeNode child = (DefaultMutableTreeNode) node.getChildAt(i);
|
|
setNodeChecked(child, checked);
|
|
}
|
|
}
|
|
}
|
|
|
|
private void updateParentGroupState(DefaultMutableTreeNode changedNode) {
|
|
// Update ancestors: group checked if all children checked, unchecked if none, otherwise? We treat as unchecked if not all.
|
|
DefaultMutableTreeNode parent = (DefaultMutableTreeNode) changedNode.getParent();
|
|
if (parent == null || parent == rootNode) return;
|
|
Object pObj = parent.getUserObject();
|
|
if (!(pObj instanceof CheckedNode)) return;
|
|
CheckedNode pData = (CheckedNode) pObj;
|
|
if (!pData.isGroup) return;
|
|
boolean allChecked = true;
|
|
boolean anyChecked = false;
|
|
for (int i = 0; i < parent.getChildCount(); i++) {
|
|
DefaultMutableTreeNode child = (DefaultMutableTreeNode) parent.getChildAt(i);
|
|
CheckedNode cData = (CheckedNode) child.getUserObject();
|
|
if (cData.checked) anyChecked = true;
|
|
else allChecked = false;
|
|
}
|
|
// If all children checked, group checked; else unchecked (could be indeterminate but we use unchecked)
|
|
pData.checked = allChecked;
|
|
// Recurse up
|
|
updateParentGroupState(parent);
|
|
}
|
|
|
|
private void browse() {
|
|
String current = txtDirectory.getText().trim();
|
|
if (!current.isEmpty()) {
|
|
File f = new File(current);
|
|
if (f.exists()) {
|
|
dirChooser.setCurrentDirectory(f.isDirectory() ? f : f.getParentFile());
|
|
}
|
|
}
|
|
int ret = dirChooser.showOpenDialog(this);
|
|
if (ret == JFileChooser.APPROVE_OPTION) {
|
|
File sel = dirChooser.getSelectedFile();
|
|
txtDirectory.setText(sel.getAbsolutePath());
|
|
}
|
|
}
|
|
|
|
private void startScan() {
|
|
String dirPath = txtDirectory.getText().trim();
|
|
if (dirPath.isEmpty()) {
|
|
JOptionPane.showMessageDialog(this, "Please enter a directory path.", "Error", JOptionPane.ERROR_MESSAGE);
|
|
return;
|
|
}
|
|
File root = new File(dirPath);
|
|
if (!root.exists()) {
|
|
JOptionPane.showMessageDialog(this, "Path does not exist:\n" + dirPath, "Error", JOptionPane.ERROR_MESSAGE);
|
|
return;
|
|
}
|
|
if (!root.isDirectory() && !root.isFile()) {
|
|
JOptionPane.showMessageDialog(this, "Not a directory or file:\n" + dirPath, "Error", JOptionPane.ERROR_MESSAGE);
|
|
return;
|
|
}
|
|
boolean recursive = chkRecursive.isSelected();
|
|
btnFind.setEnabled(false);
|
|
btnBrowse.setEnabled(false);
|
|
btnCancel.setEnabled(true);
|
|
btnClear.setEnabled(false);
|
|
btnAutoSelect.setEnabled(false);
|
|
btnClearSelection.setEnabled(false);
|
|
btnDeleteSelected.setEnabled(false);
|
|
btnExpandAll.setEnabled(false);
|
|
btnCollapseAll.setEnabled(false);
|
|
// Clear tree placeholder
|
|
rootNode.removeAllChildren();
|
|
rootNode.setUserObject(new CheckedNode("Scanning: " + root.getAbsolutePath() + " ...", null, null, 0, true));
|
|
treeModel.reload();
|
|
progressBar.setIndeterminate(true);
|
|
progressBar.setString("Scanning...");
|
|
lblStatus.setText("Collecting files...");
|
|
lblSummary.setText(" ");
|
|
lblCheckedInfo.setText("Scanning...");
|
|
currentWorker = new ScanWorker(root, recursive);
|
|
currentWorker.execute();
|
|
}
|
|
|
|
private void cancelScan() {
|
|
if (currentWorker != null && !currentWorker.isDone()) {
|
|
currentWorker.cancel(true);
|
|
lblStatus.setText("Cancelling...");
|
|
}
|
|
}
|
|
|
|
private void clearResults() {
|
|
rootNode.removeAllChildren();
|
|
rootNode.setUserObject(new CheckedNode("No scan yet - press Find Duplicates", null, null, 0, true));
|
|
treeModel.reload();
|
|
lblStatus.setText("Cleared.");
|
|
lblSummary.setText(" ");
|
|
lblCheckedInfo.setText("No files checked");
|
|
progressBar.setValue(0);
|
|
progressBar.setString("Idle");
|
|
btnAutoSelect.setEnabled(false);
|
|
btnClearSelection.setEnabled(false);
|
|
btnDeleteSelected.setEnabled(false);
|
|
btnExpandAll.setEnabled(false);
|
|
btnCollapseAll.setEnabled(false);
|
|
btnOpenFolder.setEnabled(false);
|
|
btnClear.setEnabled(false);
|
|
}
|
|
|
|
// Tree actions
|
|
private void autoSelectKeepOne() {
|
|
// For each group, check all but first file (sorted)
|
|
int totalChecked = 0;
|
|
for (int i = 0; i < rootNode.getChildCount(); i++) {
|
|
DefaultMutableTreeNode groupNode = (DefaultMutableTreeNode) rootNode.getChildAt(i);
|
|
for (int j = 0; j < groupNode.getChildCount(); j++) {
|
|
DefaultMutableTreeNode fileNode = (DefaultMutableTreeNode) groupNode.getChildAt(j);
|
|
CheckedNode data = (CheckedNode) fileNode.getUserObject();
|
|
boolean shouldCheck = (j != 0); // keep first
|
|
data.checked = shouldCheck;
|
|
if (shouldCheck) totalChecked++;
|
|
}
|
|
// Update group checkbox to reflect not all checked (so unchecked)
|
|
CheckedNode gData = (CheckedNode) groupNode.getUserObject();
|
|
gData.checked = false;
|
|
}
|
|
tree.repaint();
|
|
updateCheckedInfo();
|
|
if (totalChecked > 0) {
|
|
lblStatus.setText("Auto-selected " + totalChecked + " files (keeping 1 per group). Review and press Delete if correct.");
|
|
}
|
|
}
|
|
|
|
private void clearChecks() {
|
|
for (int i = 0; i < rootNode.getChildCount(); i++) {
|
|
DefaultMutableTreeNode groupNode = (DefaultMutableTreeNode) rootNode.getChildAt(i);
|
|
setNodeChecked(groupNode, false);
|
|
}
|
|
tree.repaint();
|
|
updateCheckedInfo();
|
|
lblStatus.setText("All checks cleared.");
|
|
}
|
|
|
|
private void expandAll() {
|
|
for (int i = 0; i < tree.getRowCount(); i++) {
|
|
tree.expandRow(i);
|
|
}
|
|
}
|
|
|
|
private void collapseAll() {
|
|
for (int i = tree.getRowCount() - 1; i >= 0; i--) {
|
|
tree.collapseRow(i);
|
|
}
|
|
}
|
|
|
|
private void openSelectedFolder() {
|
|
TreePath path = tree.getSelectionPath();
|
|
if (path == null) return;
|
|
DefaultMutableTreeNode node = (DefaultMutableTreeNode) path.getLastPathComponent();
|
|
CheckedNode data = (CheckedNode) node.getUserObject();
|
|
String filePath = data.filePath;
|
|
if (filePath == null && data.isGroup && node.getChildCount() > 0) {
|
|
// use first child
|
|
DefaultMutableTreeNode first = (DefaultMutableTreeNode) node.getChildAt(0);
|
|
CheckedNode fData = (CheckedNode) first.getUserObject();
|
|
filePath = fData.filePath;
|
|
}
|
|
if (filePath == null) return;
|
|
File f = new File(filePath);
|
|
File dir = f.getParentFile();
|
|
if (dir == null || !dir.exists()) return;
|
|
try {
|
|
if (Desktop.isDesktopSupported()) {
|
|
Desktop.getDesktop().open(dir);
|
|
} else {
|
|
// Fallback for Windows
|
|
Runtime.getRuntime().exec(new String[]{"explorer", dir.getAbsolutePath()});
|
|
}
|
|
} catch (IOException ex) {
|
|
JOptionPane.showMessageDialog(this, "Cannot open folder:\n" + ex.getMessage(), "Error", JOptionPane.ERROR_MESSAGE);
|
|
}
|
|
}
|
|
|
|
private void copySelectedPath() {
|
|
TreePath path = tree.getSelectionPath();
|
|
if (path == null) return;
|
|
DefaultMutableTreeNode node = (DefaultMutableTreeNode) path.getLastPathComponent();
|
|
CheckedNode data = (CheckedNode) node.getUserObject();
|
|
String toCopy = data.filePath != null ? data.filePath : data.text;
|
|
Toolkit.getDefaultToolkit().getSystemClipboard().setContents(new java.awt.datatransfer.StringSelection(toCopy), null);
|
|
lblStatus.setText("Copied to clipboard: " + toCopy);
|
|
}
|
|
|
|
private List<File> getCheckedFiles() {
|
|
List<File> checked = new ArrayList<File>();
|
|
for (int i = 0; i < rootNode.getChildCount(); i++) {
|
|
DefaultMutableTreeNode groupNode = (DefaultMutableTreeNode) rootNode.getChildAt(i);
|
|
for (int j = 0; j < groupNode.getChildCount(); j++) {
|
|
DefaultMutableTreeNode fileNode = (DefaultMutableTreeNode) groupNode.getChildAt(j);
|
|
CheckedNode data = (CheckedNode) fileNode.getUserObject();
|
|
if (data.checked && !data.isGroup && data.filePath != null) {
|
|
checked.add(new File(data.filePath));
|
|
}
|
|
}
|
|
}
|
|
return checked;
|
|
}
|
|
|
|
private void updateCheckedInfo() {
|
|
List<File> checked = getCheckedFiles();
|
|
long totalBytes = 0;
|
|
for (File f : checked) totalBytes += f.length();
|
|
if (checked.isEmpty()) {
|
|
lblCheckedInfo.setText("No files checked");
|
|
btnDeleteSelected.setEnabled(false);
|
|
btnClearSelection.setEnabled(false);
|
|
} else {
|
|
lblCheckedInfo.setText(checked.size() + " file(s) checked, " + totalBytes + " bytes to free");
|
|
btnDeleteSelected.setEnabled(true);
|
|
btnClearSelection.setEnabled(true);
|
|
}
|
|
}
|
|
|
|
private void deleteCheckedFiles() {
|
|
List<File> toDelete = getCheckedFiles();
|
|
if (toDelete.isEmpty()) {
|
|
JOptionPane.showMessageDialog(this, "No files checked for deletion.\nCheck files in the tree (click checkbox) first.", "No selection", JOptionPane.INFORMATION_MESSAGE);
|
|
return;
|
|
}
|
|
|
|
// Warn if deleting all files in any group
|
|
List<String> warnings = new ArrayList<String>();
|
|
for (int i = 0; i < rootNode.getChildCount(); i++) {
|
|
DefaultMutableTreeNode groupNode = (DefaultMutableTreeNode) rootNode.getChildAt(i);
|
|
int checkedInGroup = 0;
|
|
for (int j = 0; j < groupNode.getChildCount(); j++) {
|
|
DefaultMutableTreeNode fileNode = (DefaultMutableTreeNode) groupNode.getChildAt(j);
|
|
CheckedNode d = (CheckedNode) fileNode.getUserObject();
|
|
if (d.checked) checkedInGroup++;
|
|
}
|
|
if (checkedInGroup == groupNode.getChildCount() && groupNode.getChildCount() > 0) {
|
|
CheckedNode gData = (CheckedNode) groupNode.getUserObject();
|
|
String shortMd5 = (gData.md5 != null && gData.md5.length() >= 8) ? gData.md5.substring(0,8) : (gData.md5 != null ? gData.md5 : "unknown");
|
|
warnings.add("Group " + (i+1) + " (" + shortMd5 + "): ALL " + checkedInGroup + " files checked - no copy will remain!");
|
|
}
|
|
}
|
|
|
|
// Build confirmation message
|
|
long totalBytes = 0;
|
|
for (File f : toDelete) totalBytes += f.length();
|
|
StringBuilder msg = new StringBuilder();
|
|
msg.append("Permanently delete ").append(toDelete.size()).append(" checked file(s)?\n");
|
|
msg.append("(").append(totalBytes).append(" bytes will be freed)\n\n");
|
|
int showMax = Math.min(toDelete.size(), 15);
|
|
for (int i = 0; i < showMax; i++) {
|
|
msg.append("- ").append(toDelete.get(i).getAbsolutePath()).append("\n");
|
|
}
|
|
if (toDelete.size() > showMax) {
|
|
msg.append("... and ").append(toDelete.size() - showMax).append(" more\n");
|
|
}
|
|
if (!warnings.isEmpty()) {
|
|
msg.append("\nWARNINGS:\n");
|
|
for (String w : warnings) msg.append("! ").append(w).append("\n");
|
|
msg.append("\nConsider keeping at least one per group.\n");
|
|
}
|
|
msg.append("\nThis cannot be undone. Continue?");
|
|
|
|
int confirm = JOptionPane.showConfirmDialog(this, msg.toString(), "Confirm Delete", JOptionPane.YES_NO_OPTION, JOptionPane.WARNING_MESSAGE);
|
|
if (confirm != JOptionPane.YES_OPTION) return;
|
|
|
|
// Perform deletion
|
|
int success = 0;
|
|
int failed = 0;
|
|
List<String> failedFiles = new ArrayList<String>();
|
|
// Need to collect nodes to remove after file deletion to avoid concurrent modification
|
|
List<DefaultMutableTreeNode> nodesToRemove = new ArrayList<DefaultMutableTreeNode>();
|
|
// Map file path -> node
|
|
for (int i = 0; i < rootNode.getChildCount(); i++) {
|
|
DefaultMutableTreeNode groupNode = (DefaultMutableTreeNode) rootNode.getChildAt(i);
|
|
for (int j = 0; j < groupNode.getChildCount(); j++) {
|
|
DefaultMutableTreeNode fileNode = (DefaultMutableTreeNode) groupNode.getChildAt(j);
|
|
CheckedNode d = (CheckedNode) fileNode.getUserObject();
|
|
if (d.checked) {
|
|
File f = new File(d.filePath);
|
|
try {
|
|
// Extra safety: ensure file still exists
|
|
if (!f.exists()) {
|
|
failed++;
|
|
failedFiles.add(d.filePath + " (not found)");
|
|
continue;
|
|
}
|
|
if (f.delete()) {
|
|
success++;
|
|
nodesToRemove.add(fileNode);
|
|
} else {
|
|
// Try writable check
|
|
f.setWritable(true);
|
|
if (f.delete()) {
|
|
success++;
|
|
nodesToRemove.add(fileNode);
|
|
} else {
|
|
failed++;
|
|
failedFiles.add(d.filePath + " (delete returned false)");
|
|
}
|
|
}
|
|
} catch (SecurityException ex) {
|
|
failed++;
|
|
failedFiles.add(d.filePath + " (" + ex.getMessage() + ")");
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// Remove nodes from tree model
|
|
for (DefaultMutableTreeNode n : nodesToRemove) {
|
|
treeModel.removeNodeFromParent(n);
|
|
}
|
|
// Remove now-empty/singular groups (no longer duplicates)
|
|
List<DefaultMutableTreeNode> emptyGroups = new ArrayList<DefaultMutableTreeNode>();
|
|
for (int i = 0; i < rootNode.getChildCount(); i++) {
|
|
DefaultMutableTreeNode groupNode = (DefaultMutableTreeNode) rootNode.getChildAt(i);
|
|
if (groupNode.getChildCount() < 2) {
|
|
emptyGroups.add(groupNode);
|
|
}
|
|
}
|
|
for (DefaultMutableTreeNode g : emptyGroups) {
|
|
treeModel.removeNodeFromParent(g);
|
|
}
|
|
// If no groups left, show placeholder
|
|
if (rootNode.getChildCount() == 0) {
|
|
lblSummary.setText("All duplicate groups cleared.");
|
|
} else {
|
|
// Update summary
|
|
int remainingGroups = rootNode.getChildCount();
|
|
int remainingFiles = 0;
|
|
for (int i = 0; i < rootNode.getChildCount(); i++) {
|
|
remainingFiles += ((DefaultMutableTreeNode) rootNode.getChildAt(i)).getChildCount();
|
|
}
|
|
lblSummary.setText(remainingGroups + " group(s) remaining, " + remainingFiles + " files");
|
|
}
|
|
tree.repaint();
|
|
updateCheckedInfo();
|
|
|
|
// Result dialog
|
|
StringBuilder result = new StringBuilder();
|
|
result.append("Deleted ").append(success).append(" file(s).\n");
|
|
if (failed > 0) {
|
|
result.append("Failed to delete ").append(failed).append(" file(s):\n");
|
|
for (String s : failedFiles) result.append("- ").append(s).append("\n");
|
|
JOptionPane.showMessageDialog(this, result.toString(), "Delete Result - Partial Failure", JOptionPane.WARNING_MESSAGE);
|
|
} else {
|
|
result.append("Successfully freed ").append(totalBytes).append(" bytes (estimated).\n");
|
|
if (success > 0) {
|
|
// Show info but not too annoying if many deletes
|
|
lblStatus.setText("Deleted " + success + " file(s).");
|
|
}
|
|
// Only show dialog if success and not too frequent? Show lightweight
|
|
if (success <= 5) {
|
|
JOptionPane.showMessageDialog(this, result.toString(), "Delete Complete", JOptionPane.INFORMATION_MESSAGE);
|
|
}
|
|
}
|
|
// Update checked info already done
|
|
}
|
|
|
|
private class ScanWorker extends SwingWorker<Hashtable<String, List<String>>, String> {
|
|
private final File root;
|
|
private final boolean recursive;
|
|
private volatile boolean cancelled = false;
|
|
private int totalFiles = 0;
|
|
|
|
ScanWorker(File root, boolean recursive) {
|
|
this.root = root;
|
|
this.recursive = recursive;
|
|
}
|
|
|
|
@Override
|
|
protected Hashtable<String, List<String>> doInBackground() throws Exception {
|
|
List<File> allFiles = DuplicateFinder.collectFiles(root, recursive);
|
|
totalFiles = allFiles.size();
|
|
if (totalFiles == 0) {
|
|
return new Hashtable<String, List<String>>();
|
|
}
|
|
SwingUtilities.invokeLater(() -> {
|
|
progressBar.setIndeterminate(false);
|
|
progressBar.setMaximum(totalFiles);
|
|
progressBar.setValue(0);
|
|
});
|
|
DuplicateFinder.ProgressCallback cb = new DuplicateFinder.ProgressCallback() {
|
|
@Override
|
|
public void onProgress(int current, int total, String file) {
|
|
if (isCancelled()) { cancelled = true; return; }
|
|
final int cur = current;
|
|
SwingUtilities.invokeLater(() -> {
|
|
progressBar.setValue(cur);
|
|
progressBar.setString(cur + " / " + total);
|
|
lblStatus.setText("Hashing: " + file);
|
|
});
|
|
}
|
|
@Override
|
|
public boolean isCancelled() { return ScanWorker.this.isCancelled() || cancelled; }
|
|
};
|
|
Hashtable<String, List<String>> fullTable = DuplicateFinder.buildHashTableWithProgress(root, recursive, cb);
|
|
if (isCancelled() || cancelled) return new Hashtable<String, List<String>>();
|
|
return DuplicateFinder.filterDuplicates(fullTable);
|
|
}
|
|
|
|
@Override
|
|
protected void done() {
|
|
btnFind.setEnabled(true);
|
|
btnBrowse.setEnabled(true);
|
|
btnCancel.setEnabled(false);
|
|
btnClear.setEnabled(true);
|
|
progressBar.setIndeterminate(false);
|
|
if (isCancelled()) {
|
|
lblStatus.setText("Cancelled.");
|
|
rootNode.removeAllChildren();
|
|
rootNode.setUserObject(new CheckedNode("Cancelled", null, null, 0, true));
|
|
treeModel.reload();
|
|
progressBar.setString("Cancelled");
|
|
return;
|
|
}
|
|
try {
|
|
Hashtable<String, List<String>> dupes = get();
|
|
displayResultsTree(dupes, totalFiles);
|
|
} catch (Exception ex) {
|
|
Throwable cause = ex.getCause() != null ? ex.getCause() : ex;
|
|
lblStatus.setText("Error: " + cause.getMessage());
|
|
JOptionPane.showMessageDialog(DuplicateFinderGUI.this, "Error during scan:\n" + cause.getMessage(), "Error", JOptionPane.ERROR_MESSAGE);
|
|
progressBar.setString("Error");
|
|
cause.printStackTrace();
|
|
}
|
|
}
|
|
}
|
|
|
|
private void displayResultsTree(Hashtable<String, List<String>> dupes, int totalScanned) {
|
|
rootNode.removeAllChildren();
|
|
if (dupes.isEmpty()) {
|
|
rootNode.setUserObject(new CheckedNode("No duplicates found (" + totalScanned + " files scanned)", null, null, 0, true));
|
|
treeModel.reload();
|
|
lblStatus.setText("Done - no duplicates found.");
|
|
lblSummary.setText("Scanned " + totalScanned + " files - no duplicates.");
|
|
progressBar.setValue(progressBar.getMaximum());
|
|
progressBar.setString("Done");
|
|
btnAutoSelect.setEnabled(false);
|
|
btnExpandAll.setEnabled(false);
|
|
btnCollapseAll.setEnabled(false);
|
|
return;
|
|
}
|
|
List<String> hashes = new ArrayList<String>(dupes.keySet());
|
|
Collections.sort(hashes);
|
|
int groupIdx = 1;
|
|
int totalDupFiles = 0;
|
|
for (String hash : hashes) {
|
|
List<String> files = dupes.get(hash);
|
|
Collections.sort(files);
|
|
totalDupFiles += files.size();
|
|
// Determine common size (all duplicates have same content so same size)
|
|
long size = new File(files.get(0)).length();
|
|
String groupText = "Group " + groupIdx + " - " + files.size() + " files - " + size + " bytes each - MD5: " + hash;
|
|
CheckedNode groupData = new CheckedNode(groupText, null, hash, size, true);
|
|
DefaultMutableTreeNode groupNode = new DefaultMutableTreeNode(groupData);
|
|
for (String path : files) {
|
|
File f = new File(path);
|
|
String fileText = f.getName() + " [" + f.getParent() + "]";
|
|
CheckedNode fileData = new CheckedNode(fileText, path, hash, f.length(), false);
|
|
DefaultMutableTreeNode fileNode = new DefaultMutableTreeNode(fileData);
|
|
groupNode.add(fileNode);
|
|
}
|
|
rootNode.add(groupNode);
|
|
groupIdx++;
|
|
}
|
|
// Root label is summary
|
|
rootNode.setUserObject(new CheckedNode("Duplicates: " + dupes.size() + " groups, " + totalDupFiles + " files", null, null, 0, true));
|
|
treeModel.reload();
|
|
// Expand all groups
|
|
expandAll();
|
|
lblStatus.setText("Done - found " + dupes.size() + " duplicate group(s). Check unwanted files and delete.");
|
|
lblSummary.setText("Scanned " + totalScanned + " files, " + totalDupFiles + " files in " + dupes.size() + " duplicate group(s).");
|
|
progressBar.setValue(progressBar.getMaximum());
|
|
progressBar.setString("Done");
|
|
btnAutoSelect.setEnabled(true);
|
|
btnExpandAll.setEnabled(true);
|
|
btnCollapseAll.setEnabled(true);
|
|
// enable clear selection only after something checked, but enable button itself? Keep disabled until checked
|
|
updateCheckedInfo();
|
|
// select first group
|
|
if (rootNode.getChildCount() > 0) {
|
|
TreePath path = new TreePath(((DefaultMutableTreeNode) rootNode.getChildAt(0)).getPath());
|
|
tree.setSelectionPath(path);
|
|
}
|
|
}
|
|
|
|
public static void main(String[] args) {
|
|
final String startDir = (args.length > 0) ? args[0] : null;
|
|
SwingUtilities.invokeLater(() -> {
|
|
DuplicateFinderGUI gui = new DuplicateFinderGUI();
|
|
if (startDir != null && !startDir.trim().isEmpty()) {
|
|
gui.txtDirectory.setText(startDir);
|
|
}
|
|
gui.setVisible(true);
|
|
});
|
|
}
|
|
}
|