FindDuplicateFiles/DuplicateFinderGUI.java
2026-08-29 04:31:57 -04:00

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);
});
}
}