Skip to content

Instantly share code, notes, and snippets.

@oavs
Created June 6, 2025 12:28
Show Gist options
  • Save oavs/55d0f3ca78290e4d7e91c8fb0d9b44f9 to your computer and use it in GitHub Desktop.
Save oavs/55d0f3ca78290e4d7e91c8fb0d9b44f9 to your computer and use it in GitHub Desktop.
"""
Duplicate File Finder GUI v13.0 PyQt6 - Windows 11 Look
REFACTORED VERSION - Simplified Button Logic
Key Improvements in v13.0:
1. Simplified Button Logic - Each button has ONE clear purpose
2. Separated Search Functions - search_duplicates() vs show_trash() are now separate
3. Removed Context-Dependent Buttons - No more buttons that change meaning
4. Silent Toggle Behavior - No popup interruptions when toggling selection
5. Clear State Management - Simple view_mode instead of complex state checking
6. Instant View Toggle - "View Trash" button changes color immediately for instant feedback
7. SAME VISUAL STYLE - All styling, colors, and appearance preserved exactly
"""
import os
import hashlib
import shutil
import threading
import sys
from datetime import datetime
from PyQt6.QtWidgets import *
from PyQt6.QtCore import *
from PyQt6.QtGui import *
from PyQt6.QtPrintSupport import QPrinter, QPrintPreviewDialog
try:
from PyQt6.QtCore import PYQT_VERSION_STR
except ImportError:
PYQT_VERSION_STR = "Unknown"
from PIL import Image, ImageOps
import io
import logging
import traceback
import json
import glob
import weakref
# Constants - Keep everything the same
THUMBNAIL_SIZE = 64
HOVER_SIZE = 160
ENLARGED_SIZE = 400
VERSION = "13.0"
TRASH_FOLDER = ".dup_trash"
BATCH_SIZE = 50
HELP_PANEL_WIDTH = 320
MAIN_WINDOW_MIN_WIDTH = 800
MAIN_WINDOW_INITIAL_WIDTH = 900
RECENT_FOLDERS_FILE = "duplicate_finder_recent.json"
MAX_RECENT_FOLDERS = 10
ASSET_SIZE_THRESHOLD = 102400
MIN_FOLDERS_FOR_ASSET = 3
# Set up crash logging with rotation
def setup_logging():
"""Set up logging configuration for crash reports with rotation"""
try:
log_file = os.path.join(os.path.dirname(os.path.abspath(__file__)), 'duplicate_finder_crash.log')
if os.path.exists(log_file) and os.path.getsize(log_file) > 1024*1024:
backup_log = log_file.replace('.log', '_backup.log')
if os.path.exists(backup_log):
os.remove(backup_log)
os.rename(log_file, backup_log)
logging.basicConfig(
level=logging.DEBUG,
format='%(asctime)s - %(name)s - %(levelname)s - %(message)s',
handlers=[
logging.FileHandler(log_file, mode='a'),
logging.StreamHandler(sys.stdout)
]
)
logging.info(f"=== NEW SESSION: Duplicate Finder v{VERSION} Started ===")
logging.info(f"Python version: {sys.version}")
logging.info(f"PyQt6 version: {PYQT_VERSION_STR}")
return logging.getLogger(__name__)
except Exception as e:
print(f"Failed to setup logging: {e}")
logger = logging.getLogger(__name__)
handler = logging.StreamHandler(sys.stdout)
logger.addHandler(handler)
logger.setLevel(logging.DEBUG)
return logger
# Global logger
logger = setup_logging()
def handle_exception(exc_type, exc_value, exc_traceback):
"""Global exception handler for uncaught exceptions"""
if issubclass(exc_type, KeyboardInterrupt):
sys.__excepthook__(exc_type, exc_value, exc_traceback)
return
logger.critical("Uncaught exception", exc_info=(exc_type, exc_value, exc_traceback))
error_msg = ''.join(traceback.format_exception(exc_type, exc_value, exc_traceback))
try:
app = QApplication.instance()
if app:
QMessageBox.critical(None, "Critical Error",
f"An unexpected error occurred:\n\n{exc_type.__name__}: {exc_value}\n\n"
f"Please check duplicate_finder_crash.log for details.")
except:
pass
sys.__excepthook__(exc_type, exc_value, exc_traceback)
sys.excepthook = handle_exception
# Enhanced file type support
SUPPORTED_EXTENSIONS = {
"Images": [".jpg", ".jpeg", ".png", ".gif", ".bmp", ".tiff", ".tif", ".webp", ".svg", ".ico", ".raw", ".cr2", ".nef", ".arw", ".dng", ".heic", ".heif", ".psd"],
"Videos": [".mp4", ".mov", ".avi", ".mkv", ".flv", ".wmv", ".webm", ".m4v", ".mpg", ".mpeg", ".3gp", ".asf", ".rm", ".rmvb", ".vob", ".ts", ".mts", ".m2ts"],
"Documents": [".pdf", ".docx", ".doc", ".pptx", ".ppt", ".xlsx", ".xls", ".odt", ".ods", ".odp", ".rtf", ".pages", ".numbers", ".key", ".txt", ".csv"],
"PDFs": [".pdf"],
"Text": [".txt", ".csv", ".log", ".json", ".xml", ".html", ".htm", ".css", ".js", ".py", ".java", ".cpp", ".c", ".h", ".php", ".sql", ".md", ".yaml", ".yml", ".ini", ".cfg", ".conf"],
"Audio": [".mp3", ".wav", ".flac", ".aac", ".ogg", ".wma", ".m4a", ".opus", ".ape", ".alac", ".dsd", ".pcm"],
"Archives": [".zip", ".rar", ".7z", ".tar", ".gz", ".bz2", ".xz", ".tar.gz", ".tar.bz2", ".tar.xz", ".iso", ".dmg", ".cab"],
"Executables": [".exe", ".msi", ".deb", ".rpm", ".pkg", ".dmg", ".app", ".appx", ".bat", ".cmd", ".sh"],
"Data": [".pst", ".ost", ".mdb", ".accdb", ".sqlite", ".db"],
"All": []
}
THEMES = {
"light": {
"background": "#fafafa",
"surface": "#ffffff",
"primary": "#0078d4",
"text": "#323130",
"button": "#f3f2f1",
"button_text": "#323130",
"hover": "#e1dfdd",
"header": "#f8f8f8",
"accent": "#0078d4",
"border": "#e5e5e5",
"info_bg": "#f0f8ff",
"selected_bg": "#e3f2fd",
"selected_border": "#0078d4",
"progress_bg": "#e0e0e0",
"progress_fill": "#4CAF50"
}
}
class ClickableIconLabel(QLabel):
clicked = pyqtSignal()
def __init__(self, text, callback=None):
super().__init__(text)
self.callback = callback
self.setFont(QFont("Segoe UI Symbol", 12))
self.setCursor(Qt.CursorShape.PointingHandCursor)
self.setStyleSheet("""
QLabel {
padding: 4px;
border-radius: 3px;
}
QLabel:hover {
background-color: rgba(0, 120, 212, 0.1);
border: 1px solid rgba(0, 120, 212, 0.3);
}
""")
if callback:
self.clicked.connect(callback)
def mousePressEvent(self, event):
if event.button() == Qt.MouseButton.LeftButton:
self.clicked.emit()
super().mousePressEvent(event)
class SafeSearchThread(QThread):
"""Thread-safe search implementation with proper cleanup and FIXED counting"""
progress_update = pyqtSignal(int, int, str)
file_found = pyqtSignal(str, str, int)
finished_signal = pyqtSignal(int, int, int)
def __init__(self, folder_path, extensions, stop_flag, protect_assets, parent=None):
super().__init__(parent)
self.folder_path = folder_path
self.extensions = extensions
self.stop_flag = stop_flag
self.protect_assets = protect_assets
self._is_running = False
self._parent_ref = weakref.ref(parent) if parent else None
self.finished.connect(self.cleanup)
def cleanup(self):
"""Clean up thread resources"""
self._is_running = False
logger.info("Search thread cleaned up successfully")
def stop_safely(self):
"""Safe thread termination"""
self.stop_flag[0] = True
if self.isRunning():
self.wait(3000)
if self.isRunning():
logger.warning("Thread did not terminate gracefully")
def get_file_content_hash(self, file_path):
"""Calculate MD5 hash of entire file content with better error handling."""
try:
h = hashlib.md5()
with open(file_path, 'rb') as fd:
while True:
if self.stop_flag[0]:
return None
chunk = fd.read(65536)
if not chunk:
break
h.update(chunk)
return h.hexdigest()
except (OSError, IOError, UnicodeDecodeError) as e:
logger.warning(f"Error hashing {file_path}: {e}")
return None
except Exception as e:
logger.error(f"Unexpected error hashing {file_path}: {e}")
return None
def is_common_asset(self, file_path, file_size, hash_dict, file_hash):
"""Check if file might be a common asset based on location patterns"""
if not self.protect_assets:
return False
if file_hash in hash_dict:
try:
current_dir = os.path.dirname(file_path)
for existing_path, existing_size in hash_dict[file_hash]:
existing_dir = os.path.dirname(existing_path)
if current_dir != existing_dir:
logger.info(f"Skipping cross-folder duplicate: {os.path.basename(file_path)} (different folders)")
return True
except Exception as e:
logger.error(f"Error in asset protection check: {e}")
return False
def run(self):
"""Enhanced run method with better memory management and error handling"""
self._is_running = True
try:
if not os.path.isdir(self.folder_path):
logger.error(f"Invalid folder path: {self.folder_path}")
return
logger.info(f"Starting scan of folder: {self.folder_path}")
logger.info(f"File type filter: {self.extensions if self.extensions else 'All files (no filter)'}")
logger.info(f"Protect common assets: {self.protect_assets}")
# Phase 1: Collect all files
self.progress_update.emit(0, 100, "Collecting files...")
all_files = []
file_count = 0
for root, dirs, files in os.walk(self.folder_path):
if self.stop_flag[0]:
logger.info("Scan stopped during file collection")
return
if os.path.basename(root) == TRASH_FOLDER:
continue
for file in files:
if self.stop_flag[0]:
return
try:
if self.extensions:
file_ext = os.path.splitext(file)[1].lower()
if file_ext not in self.extensions:
continue
full_path = os.path.join(root, file)
try:
file_size = os.path.getsize(full_path)
if file_size > 1024 * 1024 * 1024: # 1GB
logger.warning(f"Skipping large file (>1GB): {full_path}")
continue
except (OSError, IOError) as e:
logger.warning(f"Cannot access file {full_path}: {e}")
continue
all_files.append((full_path, file_size))
file_count += 1
if file_count % 100 == 0:
self.progress_update.emit(file_count, file_count, f"Collecting files... {file_count} found")
if file_count % 10000 == 0:
logger.info(f"Collected {file_count} files, checking memory usage")
except Exception as e:
logger.error(f"Error processing file {file}: {e}")
continue
total = len(all_files)
logger.info(f"Found {total} files to scan")
if total == 0:
self.finished_signal.emit(0, 0, 0)
return
# Phase 2: Hash and find duplicates
hash_dict = {}
duplicate_count = 0
actual_duplicate_files = 0
total_duplicate_size = 0
for idx, (path, file_size) in enumerate(all_files, 1):
if self.stop_flag[0]:
logger.info("Scan stopped by user")
return
if idx % 5 == 0 or idx < 10:
progress_percent = int((idx / total) * 100)
self.progress_update.emit(idx, total, f"Processing file {idx} of {total} ({progress_percent}%)")
try:
file_hash = self.get_file_content_hash(path)
if file_hash and not self.stop_flag[0]:
if self.is_common_asset(path, file_size, hash_dict, file_hash):
continue
if file_hash in hash_dict:
if len(hash_dict[file_hash]) == 1:
original_path, original_size = hash_dict[file_hash][0]
self.file_found.emit(original_path, file_hash, original_size)
total_duplicate_size += original_size
hash_dict[file_hash].append((path, file_size))
self.file_found.emit(path, file_hash, file_size)
actual_duplicate_files += 1
total_duplicate_size += file_size
else:
hash_dict[file_hash] = [(path, file_size)]
if idx % 20 == 0:
self.msleep(1)
except Exception as e:
logger.error(f"Error processing {path}: {e}", exc_info=True)
continue
logger.info(f"Scan completed. Found {actual_duplicate_files} duplicate files out of {total} total files")
logger.info(f"Total duplicate size: {total_duplicate_size} bytes")
if not self.stop_flag[0]:
self.finished_signal.emit(actual_duplicate_files, total, total_duplicate_size)
except Exception as e:
logger.critical(f"Critical error in search thread: {e}", exc_info=True)
if not self.stop_flag[0]:
self.finished_signal.emit(0, 0, 0)
finally:
self._is_running = False
class EnlargedImageWidget(QWidget):
clicked = pyqtSignal()
def __init__(self, parent=None):
super().__init__(parent)
self.setFixedSize(ENLARGED_SIZE, ENLARGED_SIZE)
self.setAttribute(Qt.WidgetAttribute.WA_TransparentForMouseEvents, False)
self.pixmap = None
def set_image(self, pixmap):
self.pixmap = pixmap
self.update()
def paintEvent(self, event):
if self.pixmap:
painter = QPainter(self)
painter.setRenderHint(QPainter.RenderHint.Antialiasing)
painter.setRenderHint(QPainter.RenderHint.SmoothPixmapTransform)
shadow_rect = self.rect().adjusted(5, 5, -5, -5)
painter.fillRect(shadow_rect, QColor(0, 0, 0, 50))
painter.setPen(QPen(QColor(200, 200, 200), 2))
painter.drawRect(self.rect().adjusted(1, 1, -1, -1))
x = (self.width() - self.pixmap.width()) // 2
y = (self.height() - self.pixmap.height()) // 2
painter.drawPixmap(x, y, self.pixmap)
def mousePressEvent(self, event):
if event.button() == Qt.MouseButton.LeftButton:
self.clicked.emit()
class HoverImageLabel(QLabel):
def __init__(self, path, main_window):
super().__init__()
self.path = path
self.main_window = main_window
self.setFixedSize(THUMBNAIL_SIZE, THUMBNAIL_SIZE)
self.setAlignment(Qt.AlignmentFlag.AlignCenter)
self.setStyleSheet("border: 1px solid #ccc;")
def enterEvent(self, event):
self.main_window.show_enlarged_image(self.path)
def leaveEvent(self, event):
pass
class NumericTableWidgetItem(QTableWidgetItem):
"""Custom table widget item for proper numeric sorting"""
def __lt__(self, other):
return self.data(Qt.ItemDataRole.UserRole) < other.data(Qt.ItemDataRole.UserRole)
class DuplicateFinderApp(QMainWindow):
def __init__(self):
super().__init__()
logger.info("Starting DuplicateFinderApp initialization...")
# SIMPLIFIED STATE MANAGEMENT
self.view_mode = "normal" # "normal" or "trash" - clear and simple
self.folder_path = ""
self.stop_flag = [False]
self.current_theme = "light"
self.original_main_width = MAIN_WINDOW_INITIAL_WIDTH
self.user_stretched_width = None
self.is_programmatic_resize = False
self.duplicates = []
self.cached_duplicates = []
self.check_states = {}
self.undo_stack = []
self.deletion_history = []
self.last_search_completed = False
self.group_counter = 0
self.current_group = {}
self.total_files_scanned = 0
self.total_groups_found = 0
self.recent_folders = []
self.recent_menu = None
self.enlarged_widget = None
self.total_duplicate_size = 0
self.file_sizes = {}
self.protect_assets = True
self.search_thread = None
self.setWindowTitle(f"Duplicate Finder v{VERSION}")
try:
self.recent_folders = self.load_recent_folders()
logger.info(f"Loaded {len(self.recent_folders)} recent folders")
except Exception as e:
logger.error(f"Error loading recent folders: {e}")
self.recent_folders = []
try:
self.setup_ui()
self.apply_theme("light")
self.set_initial_size()
logger.info("Application initialized successfully")
except Exception as e:
logger.critical(f"Failed to setup UI: {e}", exc_info=True)
raise
def create_df_logo(self, size=80):
"""Create high-resolution logo with separated reversed D and F"""
render_size = size * 3
logo_label = QLabel()
logo_label.setFixedSize(size, size)
pixmap = QPixmap(render_size, render_size)
pixmap.fill(Qt.GlobalColor.transparent)
painter = QPainter(pixmap)
painter.setRenderHint(QPainter.RenderHint.Antialiasing, True)
painter.setRenderHint(QPainter.RenderHint.SmoothPixmapTransform, True)
painter.setRenderHint(QPainter.RenderHint.TextAntialiasing, True)
scale = render_size / 40.0
gradient = QRadialGradient(render_size/2, render_size/2, render_size/2)
gradient.setColorAt(0, QColor(94, 164, 184))
gradient.setColorAt(1, QColor(74, 144, 164))
painter.setBrush(QBrush(gradient))
painter.setPen(Qt.PenStyle.NoPen)
painter.drawEllipse(int(1*scale), int(1*scale), int(38*scale), int(38*scale))
shadow_gradient = QRadialGradient(render_size/2, render_size/2, render_size/2)
shadow_gradient.setColorAt(0.8, QColor(0, 0, 0, 0))
shadow_gradient.setColorAt(1, QColor(0, 0, 0, 40))
painter.setBrush(QBrush(shadow_gradient))
painter.drawEllipse(int(1*scale), int(1*scale), int(38*scale), int(38*scale))
painter.setBrush(QBrush(QColor(255, 255, 255)))
painter.setPen(Qt.PenStyle.NoPen)
# Draw reversed "D"
d_path = QPainterPath()
d_path.moveTo(int(19.5*scale), int(12*scale))
d_path.lineTo(int(19.5*scale), int(28*scale))
d_path.lineTo(int(13.5*scale), int(28*scale))
d_path.cubicTo(int(8.5*scale), int(28*scale),
int(5.5*scale), int(24*scale),
int(5.5*scale), int(20*scale))
d_path.lineTo(int(5.5*scale), int(20*scale))
d_path.cubicTo(int(5.5*scale), int(16*scale),
int(8.5*scale), int(12*scale),
int(13.5*scale), int(12*scale))
d_path.closeSubpath()
inner_path = QPainterPath()
inner_path.moveTo(int(16.5*scale), int(15*scale))
inner_path.lineTo(int(16.5*scale), int(25*scale))
inner_path.lineTo(int(13.5*scale), int(25*scale))
inner_path.cubicTo(int(11*scale), int(25*scale),
int(8.5*scale), int(23*scale),
int(8.5*scale), int(20*scale))
inner_path.cubicTo(int(8.5*scale), int(17*scale),
int(11*scale), int(15*scale),
int(13.5*scale), int(15*scale))
inner_path.closeSubpath()
d_final = d_path.subtracted(inner_path)
painter.save()
painter.translate(int(0.5*scale), int(0.5*scale))
painter.fillPath(d_final, QColor(0, 0, 0, 60))
painter.restore()
painter.fillPath(d_final, QColor(255, 255, 255))
# Draw "F"
f_path = QPainterPath()
f_path.addRoundedRect(QRectF(int(21.5*scale), int(12*scale), int(3.5*scale), int(16*scale)),
int(0.5*scale), int(0.5*scale))
f_path.addRoundedRect(QRectF(int(25*scale), int(12*scale), int(7.5*scale), int(3.5*scale)),
int(0.5*scale), int(0.5*scale))
f_path.addRoundedRect(QRectF(int(25*scale), int(18.5*scale), int(5.5*scale), int(3*scale)),
int(0.5*scale), int(0.5*scale))
painter.save()
painter.translate(int(0.5*scale), int(0.5*scale))
painter.fillPath(f_path, QColor(0, 0, 0, 60))
painter.restore()
painter.fillPath(f_path, QColor(255, 255, 255))
painter.end()
scaled_pixmap = pixmap.scaled(size, size,
Qt.AspectRatioMode.KeepAspectRatio,
Qt.TransformationMode.SmoothTransformation)
logo_label.setPixmap(scaled_pixmap)
logo_label.setToolTip("Duplicate Finder")
return logo_label
def format_size(self, size_bytes):
"""Format size in bytes to human readable format"""
if size_bytes < 1024:
return f"{size_bytes} B"
elif size_bytes < 1024 * 1024:
return f"{size_bytes / 1024:.1f} KB"
elif size_bytes < 1024 * 1024 * 1024:
return f"{size_bytes / (1024 * 1024):.1f} MB"
else:
return f"{size_bytes / (1024 * 1024 * 1024):.2f} GB"
def load_and_process_image(self, file_path, target_size):
"""Enhanced image loading with better color preservation and error handling"""
try:
with Image.open(file_path) as img:
img = ImageOps.exif_transpose(img)
if img.mode == 'RGBA':
background = Image.new('RGB', img.size, (255, 255, 255))
background.paste(img, mask=img.split()[-1])
img = background
elif img.mode == 'P':
if 'transparency' in img.info:
img = img.convert('RGBA')
background = Image.new('RGB', img.size, (255, 255, 255))
background.paste(img, mask=img.split()[-1])
img = background
else:
img = img.convert('RGB')
elif img.mode == 'LA' or img.mode == 'L':
img = img.convert('RGB')
elif img.mode in ('CMYK', 'YCbCr', 'LAB', 'HSV'):
img = img.convert('RGB')
elif img.mode != 'RGB':
img = img.convert('RGB')
original_width, original_height = img.size
aspect_ratio = original_width / original_height
if aspect_ratio > 1:
new_width = target_size
new_height = int(target_size / aspect_ratio)
else:
new_height = target_size
new_width = int(target_size * aspect_ratio)
img = img.resize((new_width, new_height), Image.Resampling.LANCZOS)
byte_array = io.BytesIO()
img.save(byte_array, format='PNG', optimize=False, quality=95)
byte_array.seek(0)
qimg = QImage()
qimg.loadFromData(byte_array.getvalue())
if qimg.isNull():
raise Exception("Failed to create QImage")
pixmap = QPixmap.fromImage(qimg)
if pixmap.isNull():
raise Exception("Failed to create QPixmap")
return pixmap
except Exception as e:
logger.error(f"Error processing image {file_path}: {e}")
return None
def setup_ui(self):
central_widget = QWidget()
self.setCentralWidget(central_widget)
main_layout = QVBoxLayout(central_widget)
main_layout.setSpacing(4)
main_layout.setContentsMargins(8, 8, 8, 8)
self.create_menu_bar()
# Create main splitter for help panel - SAME AS BEFORE
self.main_splitter = QSplitter(Qt.Orientation.Horizontal)
main_layout.addWidget(self.main_splitter)
# Main content widget - SAME STYLING
content_widget = QWidget()
content_layout = QVBoxLayout(content_widget)
content_layout.setSpacing(6)
content_layout.setContentsMargins(4, 4, 4, 4)
# Folder selection row - EXACT SAME STYLING
folder_layout = QHBoxLayout()
folder_layout.setSpacing(8)
self.folder_btn = QPushButton("📁 Folder:")
self.folder_btn.clicked.connect(self.browse_folder)
self.folder_btn.setToolTip("Click to browse for folder")
self.folder_btn.setStyleSheet("""
QPushButton {
text-align: left;
padding: 6px 12px;
}
QPushButton:hover {
background-color: rgba(0, 120, 212, 0.1);
border-color: rgba(0, 120, 212, 0.5);
}
""")
folder_layout.addWidget(self.folder_btn)
self.folder_entry = QLineEdit()
self.folder_entry.setPlaceholderText("Select a folder to scan for duplicates...")
folder_layout.addWidget(self.folder_entry)
# SAME PROTECT ASSETS STYLING
self.protect_assets_btn = QPushButton("🛡️ Protect Assets")
self.protect_assets_btn.setCheckable(True)
self.protect_assets_btn.setChecked(True)
self.protect_assets_btn.clicked.connect(self.on_protect_assets_toggle)
self.protect_assets_btn.setToolTip("Toggle: Only find duplicates within the same folder")
self.protect_assets_btn.setStyleSheet("""
QPushButton {
background-color: #4CAF50;
color: white;
}
QPushButton:hover {
background-color: #45a049;
}
""")
folder_layout.addWidget(self.protect_assets_btn)
# SIMPLIFIED: ONE CLEAR SEARCH BUTTON
self.search_btn = QPushButton("🔍 Search Duplicates")
self.search_btn.clicked.connect(self.search_duplicates) # SINGLE PURPOSE
folder_layout.addWidget(self.search_btn)
folder_layout.addStretch()
logo = self.create_df_logo(80)
folder_layout.addWidget(logo)
folder_layout.addSpacing(16)
content_layout.addLayout(folder_layout)
# Options row - SAME STYLING
options_layout = QHBoxLayout()
options_layout.setSpacing(8)
type_icon = QLabel("📄")
type_icon.setFont(QFont("Segoe UI Symbol", 12))
options_layout.addWidget(type_icon)
options_layout.addWidget(QLabel("File Type:"))
self.ext_combo = QComboBox()
self.ext_combo.addItems(list(SUPPORTED_EXTENSIONS.keys()))
self.ext_combo.setToolTip("Select file type to scan. Choose 'All' to scan all file types.")
options_layout.addWidget(self.ext_combo)
# SIMPLIFIED: CLEAR VIEW MODE TOGGLE BUTTON
self.view_trash_btn = QPushButton("🗑️ View Trash")
self.view_trash_btn.setCheckable(True)
self.view_trash_btn.clicked.connect(self.on_view_mode_changed) # SINGLE PURPOSE
self.view_trash_btn.setToolTip("Toggle between normal view and trash view")
# Set initial styling for normal mode
self.view_trash_btn.setStyleSheet("""
QPushButton {
background-color: #f3f2f1;
color: #323130;
border: 1px solid #e5e5e5;
padding: 6px 12px;
border-radius: 4px;
font-weight: 500;
}
QPushButton:hover {
background-color: #e1dfdd;
border-color: #0078d4;
}
QPushButton:checked {
background-color: #FF6B35;
color: white;
border-color: #E85A2B;
font-weight: bold;
}
QPushButton:checked:hover {
background-color: #E85A2B;
}
""")
options_layout.addWidget(self.view_trash_btn)
# SEPARATE BUTTONS - NO MORE CONTEXT-DEPENDENT BEHAVIOR
self.empty_trash_btn = QPushButton("🗑️ Empty Trash")
self.empty_trash_btn.clicked.connect(self.empty_trash_folder) # ALWAYS SAME ACTION
self.empty_trash_btn.hide() # Hidden by default
options_layout.addWidget(self.empty_trash_btn)
self.delete_empty_cb = QCheckBox("📂 Delete Empty Folders")
self.delete_empty_cb.toggled.connect(self.on_delete_empty_toggle)
options_layout.addWidget(self.delete_empty_cb)
self.delete_empty_btn = QPushButton("📂 Delete Empty")
self.delete_empty_btn.clicked.connect(self.delete_empty_folders)
self.delete_empty_btn.hide()
options_layout.addWidget(self.delete_empty_btn)
# ALWAYS AVAILABLE UNDELETE BUTTON - SAME STYLING
self.undelete_btn = QPushButton("↩️ UnDelete")
self.undelete_btn.clicked.connect(self.show_undelete_dialog) # ALWAYS SAME ACTION
self.undelete_btn.setToolTip("View and restore recently deleted files")
self.undelete_btn.setStyleSheet("""
QPushButton {
background-color: #2196F3;
color: white;
}
QPushButton:hover {
background-color: #1976D2;
}
""")
options_layout.addWidget(self.undelete_btn)
options_layout.addStretch()
content_layout.addLayout(options_layout)
# Controls row - SAME STYLING, SIMPLIFIED LOGIC
controls_layout = QHBoxLayout()
controls_layout.setSpacing(6)
# SAME PURPLE TOGGLE BUTTON STYLING
self.toggle_select_btn = QPushButton("☐ Select All")
self.toggle_select_btn.clicked.connect(self.toggle_select_all) # SIMPLIFIED LOGIC
self.toggle_select_btn.setStyleSheet("""
QPushButton {
background-color: #9C27B0;
color: white;
font-weight: bold;
padding: 6px 12px;
border-radius: 4px;
border: 1px solid #7B1FA2;
}
QPushButton:hover {
background-color: #AD32CE;
}
QPushButton:pressed {
background-color: #6A1B9A;
}
""")
controls_layout.addWidget(self.toggle_select_btn)
# CLEAR DELETE BUTTON - ADAPTS BASED ON VIEW MODE
self.delete_selected_btn = QPushButton("🗑️ Delete Selected")
self.delete_selected_btn.clicked.connect(self.delete_selected_files) # ROUTES APPROPRIATELY
controls_layout.addWidget(self.delete_selected_btn)
# OTHER BUTTONS - SAME STYLING
self.stop_btn = QPushButton("⏹️ Stop")
self.stop_btn.clicked.connect(self.stop_search)
controls_layout.addWidget(self.stop_btn)
self.undo_btn = QPushButton("↶ Undo Last")
self.undo_btn.clicked.connect(self.undo_last_action)
controls_layout.addWidget(self.undo_btn)
self.exit_btn = QPushButton("❌ Exit")
self.exit_btn.clicked.connect(self.close)
controls_layout.addWidget(self.exit_btn)
controls_layout.addStretch()
# SAME SELECTED COUNT STYLING
self.selected_widget = QWidget()
self.selected_widget.setStyleSheet(f"""
QWidget {{
background-color: {THEMES[self.current_theme]['button']};
border: 1px solid {THEMES[self.current_theme]['border']};
border-radius: 4px;
padding: 6px 12px;
}}
""")
selected_layout = QHBoxLayout(self.selected_widget)
selected_layout.setContentsMargins(6, 2, 6, 2)
selected_layout.setSpacing(6)
self.selected_count_label = QLabel("Selected: 0 / 0")
self.selected_count_label.setFont(QFont("Segoe UI", 14))
selected_layout.addWidget(self.selected_count_label)
controls_layout.addWidget(self.selected_widget)
controls_layout.addStretch()
content_layout.addLayout(controls_layout)
# Note - normal styling
note_label = QLabel("ℹ️ Only surplus duplicate files are shown; the latest version of each file is automatically protected.")
note_label.setWordWrap(True)
note_label.setStyleSheet(f"""
background: {THEMES[self.current_theme]['info_bg']};
padding: 8px;
border: 1px solid {THEMES[self.current_theme]['border']};
border-radius: 4px;
color: {THEMES[self.current_theme]['text']};
""")
content_layout.addWidget(note_label)
# Enhanced status area with progress bar
status_widget = QWidget()
status_layout = QVBoxLayout(status_widget)
status_layout.setContentsMargins(0, 0, 0, 0)
status_layout.setSpacing(2)
# Status label
self.status_label = QLabel("Ready")
self.status_label.setStyleSheet(f"""
border: 1px solid {THEMES[self.current_theme]['border']};
padding: 4px 8px;
background-color: {THEMES[self.current_theme]['surface']};
color: {THEMES[self.current_theme]['text']};
""")
status_layout.addWidget(self.status_label)
# Progress bar (initially hidden)
self.progress_bar = QProgressBar()
self.progress_bar.setVisible(False)
self.progress_bar.setStyleSheet(f"""
QProgressBar {{
border: 1px solid {THEMES[self.current_theme]['border']};
border-radius: 4px;
background-color: {THEMES[self.current_theme]['progress_bg']};
color: white;
text-align: center;
font-weight: bold;
}}
QProgressBar::chunk {{
background-color: {THEMES[self.current_theme]['progress_fill']};
border-radius: 3px;
}}
""")
status_layout.addWidget(self.progress_bar)
content_layout.addWidget(status_widget)
# Results area with sortable table
self.results_table = QTableWidget()
self.results_table.setColumnCount(8)
self.results_table.setHorizontalHeaderLabels(["Select", "Group", "Type", "Filename", "Size", "Path", "Modified Date", "Thumbnail"])
self.results_table.setSortingEnabled(True)
self.results_table.setAlternatingRowColors(True)
self.results_table.setSelectionBehavior(QAbstractItemView.SelectionBehavior.SelectRows)
self.results_table.setSelectionMode(QAbstractItemView.SelectionMode.NoSelection)
self.results_table.horizontalHeader().setStretchLastSection(False)
self.results_table.verticalHeader().setVisible(False)
# Column widths
self.results_table.setColumnWidth(0, 55)
self.results_table.setColumnWidth(1, 55)
self.results_table.setColumnWidth(2, 70)
self.results_table.setColumnWidth(3, 200)
self.results_table.setColumnWidth(4, 85)
self.results_table.setColumnWidth(5, 300)
self.results_table.setColumnWidth(6, 130)
self.results_table.setColumnWidth(7, 85)
header = self.results_table.horizontalHeader()
header.setSectionResizeMode(3, QHeaderView.ResizeMode.Stretch)
header.setSectionResizeMode(5, QHeaderView.ResizeMode.Stretch)
self.apply_win11_table_style()
content_layout.addWidget(self.results_table)
# Add content to splitter
self.main_splitter.addWidget(content_widget)
# Help panel (initially hidden)
self.help_widget = self.create_help_panel()
self.main_splitter.addWidget(self.help_widget)
self.help_widget.hide()
# Set splitter proportions
self.main_splitter.setSizes([MAIN_WINDOW_INITIAL_WIDTH, HELP_PANEL_WIDTH])
def create_menu_bar(self):
menubar = self.menuBar()
file_menu = menubar.addMenu("File")
file_menu.addAction("Open", self.browse_folder)
self.recent_menu = file_menu.addMenu("Open Recent")
self.update_recent_menu()
file_menu.addSeparator()
file_menu.addAction("Print Search Results", self.print_results)
file_menu.addSeparator()
file_menu.addAction("Delete Log Files", self.delete_log_files)
file_menu.addSeparator()
file_menu.addAction("Exit", self.close)
help_menu = menubar.addMenu("Help")
help_menu.addAction("Show/Hide Help Panel", self.toggle_help_panel)
help_menu.addAction("About", self.show_about)
def create_help_panel(self):
help_widget = QWidget()
help_widget.setFixedWidth(HELP_PANEL_WIDTH)
help_layout = QVBoxLayout(help_widget)
help_layout.setSpacing(4)
help_layout.setContentsMargins(8, 8, 8, 8)
# Help header with close button
header_layout = QHBoxLayout()
header_layout.setSpacing(8)
help_icon = QLabel("❓")
help_icon.setFont(QFont("Segoe UI Symbol", 16))
header_layout.addWidget(help_icon)
title = QLabel("Help & Documentation")
title.setFont(QFont("Segoe UI", 14, QFont.Weight.Bold))
title.setWordWrap(True)
header_layout.addWidget(title)
header_layout.addStretch()
close_btn = QPushButton("✕")
close_btn.setFixedSize(25, 25)
close_btn.setStyleSheet("""
QPushButton {
background-color: transparent;
border: none;
color: #666;
font-size: 16px;
font-weight: bold;
padding: 0px;
margin: 0px;
}
QPushButton:hover {
background-color: #e81123;
color: white;
border-radius: 4px;
}
QPushButton:pressed {
background-color: #c50e1f;
color: white;
}
""")
close_btn.clicked.connect(self.toggle_help_panel)
close_btn.setToolTip("Close help panel")
header_layout.addWidget(close_btn)
help_layout.addLayout(header_layout)
# Create tabbed interface
tab_widget = QTabWidget()
tab_widget.setFixedWidth(HELP_PANEL_WIDTH - 16)
tab_widget.setStyleSheet("""
QTabWidget::pane {
border: 1px solid #d0d0d0;
background-color: white;
}
QTabWidget::tab-bar {
alignment: left;
}
QTabBar::tab {
background-color: #f0f0f0;
border: 1px solid #d0d0d0;
padding: 4px 8px;
margin: 0px 1px;
border-top-left-radius: 4px;
border-top-right-radius: 4px;
font-size: 13px;
min-width: 60px;
max-width: 85px;
}
QTabBar::tab:selected {
background-color: white;
border-bottom-color: white;
margin-bottom: -1px;
}
QTabBar::tab:hover {
background-color: #e0e0e0;
}
""")
self.apply_win11_scrollbar_style(tab_widget)
# About tab
about_tab = QWidget()
about_layout = QVBoxLayout(about_tab)
about_layout.setSpacing(4)
about_layout.setContentsMargins(8, 8, 8, 8)
about_text = QTextEdit()
about_text.setReadOnly(True)
about_text.setFixedWidth(HELP_PANEL_WIDTH - 32)
self.apply_win11_scrollbar_style(about_text)
about_text.setHtml(f"""
<h2>Duplicate Finder</h2>
<p>Advanced duplicate file finder with intelligent content detection.</p>
<h3>Features:</h3>
<ul>
<li>Smart Content Detection</li>
<li>Original File Protection</li>
<li>Windows 11 Design</li>
<li>60+ File Formats</li>
<li>Safe Deletion System</li>
<li>Enhanced UnDelete Functionality</li>
<li>Sortable Column Headers</li>
<li>Batch Processing for Large Scans</li>
<li>Print Search Results</li>
<li>Open Recent Folders</li>
<li>Crash Logging System</li>
<li>Asset Protection System</li>
<li>Size Statistics Display</li>
<li>Thread Safety & Performance</li>
<li>Visual Progress Tracking</li>
<li>Fixed Duplicate Counting</li>
<li>Simplified Button Logic</li>
</ul>
<h3>v{VERSION} Updates:</h3>
<ul>
<li>Simplified Button Logic - Each button has ONE clear purpose</li>
<li>Separated Search Functions - search_duplicates() vs show_trash() are now separate</li>
<li>Removed Context-Dependent Buttons - No more buttons that change meaning</li>
<li>Silent Toggle Behavior - No popup interruptions when toggling selection</li>
<li>Clear State Management - Simple view_mode instead of complex state checking</li>
<li>Instant View Toggle - "View Trash" button changes color immediately for instant feedback</li>
</ul>
<h3>Understanding Groups:</h3>
<p>The "Group" column shows which files are duplicates of each other.
All files with the same group number have identical content.
For example, if you see three files marked as "Group 1",
all three files have exactly the same content (byte-for-byte identical).</p>
""")
about_layout.addWidget(about_text)
tab_widget.addTab(about_tab, "About")
# How to use tab
howto_tab = QWidget()
howto_layout = QVBoxLayout(howto_tab)
howto_layout.setSpacing(4)
howto_layout.setContentsMargins(8, 8, 8, 8)
howto_text = QTextEdit()
howto_text.setReadOnly(True)
howto_text.setFixedWidth(HELP_PANEL_WIDTH - 32)
self.apply_win11_scrollbar_style(howto_text)
howto_text.setHtml("""
<h2>How To Use</h2>
<h3>🎯 Quick Start Guide</h3>
<ol>
<li><strong>📁 Select Folder:</strong> Click the folder icon or Browse button to choose a folder to scan</li>
<li><strong>📄 Choose File Type:</strong> Select the type of files you want to find (Images, Videos, Documents, etc.)</li>
<li><strong>🔍 Start Search:</strong> Click "Search Duplicates" to begin scanning</li>
<li><strong>📊 Watch Progress:</strong> Monitor the visual progress bar during scanning</li>
<li><strong>🛡️ Protect Assets:</strong> Toggle button (green when enabled) to only find duplicates within the same folder</li>
<li><strong>🗑️ View Trash:</strong> Click the "View Trash" button (turns orange when active) to switch between normal and trash view</li>
<li><strong>👀 Review Results:</strong> Hover over thumbnails to preview files</li>
<li><strong>☑️ Select Duplicates:</strong> Check the files you want to remove</li>
<li><strong>🗑️ Delete Safely:</strong> Click "Delete Selected" to move files to trash</li>
<li><strong>↩️ UnDelete Files:</strong> Use UnDelete button to restore recently deleted files</li>
</ol>
<h3>💡 Pro Tips</h3>
<ul>
<li><strong>Simplified Logic:</strong> v12.77 removes complex button behavior - each button now has one clear purpose</li>
<li><strong>Silent Toggle:</strong> Select All button works silently without popup interruptions</li>
<li><strong>View Trash Button:</strong> Orange button instantly toggles between normal and trash view with immediate color feedback</li>
<li><strong>Start Small:</strong> Begin with specific file types rather than "All" for faster scanning</li>
<li><strong>Asset Protection:</strong> Keep the Protect Assets button enabled (green) to only find duplicates within the same folder</li>
<li><strong>Progress Monitoring:</strong> Watch the progress bar for detailed scanning status</li>
<li><strong>Size Information:</strong> Check the status bar for total duplicate size found (MB/GB)</li>
<li><strong>Thread Safety:</strong> The app now handles interruptions and shutdowns gracefully</li>
<li><strong>Check Trash Regularly:</strong> Periodically empty trash after confirming deletions</li>
<li><strong>Preview Before Delete:</strong> Use thumbnail hover to verify files before deletion</li>
<li><strong>Sort Results:</strong> Click column headers to sort by name, size, date, etc.</li>
<li><strong>Understanding Groups:</strong> Files with the same group number are exact duplicates</li>
</ul>
""")
howto_layout.addWidget(howto_text)
tab_widget.addTab(howto_tab, "How To Use")
help_layout.addWidget(tab_widget)
return help_widget
# ===== SIMPLIFIED BUTTON FUNCTIONS - SINGLE PURPOSE EACH =====
def search_duplicates(self):
"""ONLY searches for duplicates - single purpose"""
if not self.folder_path:
QMessageBox.warning(self, "No Folder", "There are no previously deleted files to be seen or found..")
return
self.view_mode = "normal"
self.update_ui_for_view_mode()
# Stop any existing search
if self.search_thread and self.search_thread.isRunning():
self.search_thread.stop_safely()
self.stop_flag[0] = False
self.results_table.setSortingEnabled(False)
# Check for cached results
current_ext = self.ext_combo.currentText()
if (self.last_search_completed and
self.cached_duplicates and
hasattr(self, 'last_folder_path') and
hasattr(self, 'last_ext_type') and
self.last_folder_path == self.folder_path and
self.last_ext_type == current_ext):
self.clear_results()
self.show_cached_results()
self.status_label.setText(f"Cached results: {len(self.cached_duplicates)} duplicate files found.")
else:
self.clear_results()
self.start_new_search()
QTimer.singleShot(100, lambda: self.results_table.setSortingEnabled(True))
def show_trash_contents(self):
"""ONLY shows trash contents - single purpose"""
if not self.folder_path:
QMessageBox.warning(self, "No Folder", "There are no previously deleted files to be seen or found..")
return
self.view_mode = "trash"
self.update_ui_for_view_mode()
self.stop_flag[0] = False
self.results_table.setSortingEnabled(False)
self.clear_results()
self.list_trash()
QTimer.singleShot(100, lambda: self.results_table.setSortingEnabled(True))
def on_view_mode_changed(self):
"""Handle view mode toggle - routes to appropriate function"""
if self.view_trash_btn.isChecked():
self.show_trash_contents()
else:
self.search_duplicates()
def update_ui_for_view_mode(self):
"""Update UI elements based on current view mode - SAME STYLING"""
if self.view_mode == "trash":
self.search_btn.setText("🔍 Refresh Trash")
self.delete_selected_btn.setText("🗑️ Delete Permanently")
self.empty_trash_btn.show()
# Update button state if not already set
if not self.view_trash_btn.isChecked():
self.view_trash_btn.setChecked(True)
else:
self.search_btn.setText("🔍 Search Duplicates")
self.delete_selected_btn.setText("🗑️ Move to Trash")
self.empty_trash_btn.hide()
# Update button state if not already set
if self.view_trash_btn.isChecked():
self.view_trash_btn.setChecked(False)
def toggle_select_all(self):
"""SIMPLIFIED toggle - no popups, no complex state"""
if not self.duplicates:
return # Silent return - no annoying popups
# Simple check: are all files selected?
selected_count = sum(1 for cb in self.check_states.values() if cb.isChecked())
all_selected = (selected_count == len(self.duplicates))
if all_selected:
self.deselect_all()
self.toggle_select_btn.setText("☐ Select All")
else:
self.select_all()
self.toggle_select_btn.setText("☑️ Deselect All")
def delete_selected_files(self):
"""Routes to appropriate deletion method based on view mode"""
selected_files = [path for path, cb in self.check_states.items() if cb.isChecked()]
if not selected_files:
QMessageBox.information(self, "No Selection", "No files selected for deletion.")
return
if self.view_mode == "trash":
self.permanently_delete_files(selected_files)
else:
self.move_files_to_trash(selected_files)
def permanently_delete_files(self, files):
"""Separate function for permanent deletion"""
total_size = sum(self.file_sizes.get(path, 0) for path in files)
size_text = self.format_size(total_size)
reply = QMessageBox.question(
self, "Permanent Deletion",
f"Permanently delete {len(files)} files ({size_text})?\n\nThis cannot be undone!",
QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No
)
if reply == QMessageBox.StandardButton.Yes:
deleted_count = 0
for path in files:
try:
os.remove(path)
deleted_count += 1
self.deletion_history.append({
'path': path,
'timestamp': datetime.now(),
'permanent': True
})
except Exception as e:
pass
QMessageBox.information(self, "Deletion Complete",
f"Permanently deleted {deleted_count} of {len(files)} files.")
self.show_trash_contents() # Refresh trash view
def move_files_to_trash(self, files):
"""Separate function for moving to trash"""
trash_dir = os.path.join(self.folder_path, TRASH_FOLDER)
os.makedirs(trash_dir, exist_ok=True)
moved_files = []
total_size = sum(self.file_sizes.get(path, 0) for path in files)
size_text = self.format_size(total_size)
for path in files:
base_name = os.path.basename(path)
dest = os.path.join(trash_dir, base_name)
counter = 1
while os.path.exists(dest):
name, ext = os.path.splitext(base_name)
dest = os.path.join(trash_dir, f"{name}_{counter}{ext}")
counter += 1
try:
shutil.move(path, dest)
moved_files.append((path, dest))
self.deletion_history.append({
'path': path,
'trash_path': dest,
'timestamp': datetime.now(),
'permanent': False
})
except Exception as e:
pass
if moved_files:
self.undo_stack.append(("delete", moved_files))
QMessageBox.information(self, "Files Moved to Trash",
f"Moved {len(moved_files)} files ({size_text}) to trash.")
self.search_duplicates() # Refresh normal view
def empty_trash_folder(self):
"""ALWAYS empties trash - no context dependency"""
if not self.folder_path:
QMessageBox.warning(self, "No Folder", "There are no previously deleted files to be seen or found..")
return
trash_dir = os.path.join(self.folder_path, TRASH_FOLDER)
if not os.path.exists(trash_dir):
QMessageBox.information(self, "Empty Trash", "Trash folder is already empty.")
return
# Count files and calculate size
file_count = 0
total_size = 0
for root, dirs, files in os.walk(trash_dir):
for file in files:
file_count += 1
try:
total_size += os.path.getsize(os.path.join(root, file))
except OSError:
pass
if file_count == 0:
QMessageBox.information(self, "Empty Trash", "Trash folder is already empty.")
return
size_text = self.format_size(total_size)
reply = QMessageBox.question(
self, "Empty Trash",
f"Permanently delete {file_count} files ({size_text}) from trash?\n\nThis cannot be undone!",
QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No
)
if reply == QMessageBox.StandardButton.Yes:
try:
shutil.rmtree(trash_dir)
QMessageBox.information(self, "Trash Emptied",
f"Permanently deleted {file_count} files ({size_text}).")
if self.view_mode == "trash":
self.show_trash_contents() # Refresh if viewing trash
except Exception as e:
QMessageBox.critical(self, "Error", f"Failed to empty trash: {e}")
# ===== KEEP ALL OTHER METHODS EXACTLY THE SAME =====
def start_new_search(self):
"""Start a new duplicate search"""
self.cached_duplicates = []
self.last_search_completed = False
self.last_folder_path = self.folder_path
self.last_ext_type = self.ext_combo.currentText()
self.total_duplicate_size = 0
self.file_sizes = {}
# Show progress bar and update status
self.progress_bar.setVisible(True)
self.progress_bar.setValue(0)
self.status_label.setText("Initializing scan...")
extensions = SUPPORTED_EXTENSIONS.get(self.ext_combo.currentText(), [])
logger.info(f"Extensions to scan: {extensions if extensions else 'All file types'}")
# Create new thread-safe search thread
self.search_thread = SafeSearchThread(
self.folder_path, extensions, self.stop_flag, self.protect_assets, parent=self
)
self.search_thread.progress_update.connect(self.update_progress)
self.search_thread.file_found.connect(lambda path, hash, size: self.add_duplicate(path, hash, size))
self.search_thread.finished_signal.connect(self.search_finished)
self.search_thread.start()
def clear_results(self):
self.results_table.setRowCount(0)
self.duplicates = []
self.check_states = {}
self.update_selected_count()
self.group_counter = 0
self.current_group = {}
self.total_files_scanned = 0
self.total_groups_found = 0
self.total_duplicate_size = 0
self.file_sizes = {}
# Reset toggle button state
if hasattr(self, 'toggle_select_btn'):
self.toggle_select_btn.setText("☐ Select All")
def show_cached_results(self):
# Clean up non-existent files from cache first
self.cached_duplicates = [f for f in self.cached_duplicates if os.path.exists(f)]
for file_path in self.cached_duplicates:
# Get file size for cached files
try:
file_size = os.path.getsize(file_path)
self.add_duplicate(file_path, None, file_size)
except OSError:
continue
self.results_table.setSortingEnabled(True)
self.setWindowTitle(f"Duplicate Finder v{VERSION}")
def update_progress(self, current, total, status):
"""Enhanced progress updates with visual progress bar"""
if total > 0:
percent = int((current / total) * 100)
count = len(self.duplicates)
self.progress_bar.setValue(percent)
self.progress_bar.setFormat(f"{percent}% - {count} duplicates found")
self.status_label.setText(f"{status}")
self.setWindowTitle(f"Duplicate Finder v{VERSION} - {status}")
else:
self.status_label.setText(status)
def add_duplicate(self, file_path, file_hash=None, file_size=None):
"""Add duplicate file to the table with improved thumbnail handling"""
try:
if not os.path.exists(file_path):
logger.warning(f"Skipping non-existent file: {file_path}")
return
if file_size is None:
try:
file_size = os.path.getsize(file_path)
except OSError:
file_size = 0
self.file_sizes[file_path] = file_size
self.total_duplicate_size += file_size
self.duplicates.append(file_path)
if hasattr(self, 'search_thread') and self.search_thread.isRunning():
count = len(self.duplicates)
size_text = self.format_size(self.total_duplicate_size)
current_status = self.status_label.text()
if " - " in current_status:
progress_part = current_status.split(" - ")[0]
self.status_label.setText(f"{progress_part} - Found {count} duplicates out of total {self.total_files_scanned} files - Total duplicates {size_text}")
if self.view_mode == "normal" and file_path not in self.cached_duplicates:
self.cached_duplicates.append(file_path)
# Determine group number
if not file_hash:
file_hash = self.get_file_hash_for_grouping(file_path)
if file_hash not in self.current_group:
self.group_counter += 1
self.current_group[file_hash] = self.group_counter
group_num = self.current_group[file_hash]
# Add row to table
row = self.results_table.rowCount()
self.results_table.insertRow(row)
self.results_table.setRowHeight(row, 70)
# Checkbox in first column
checkbox = QCheckBox()
checkbox.toggled.connect(self.update_selected_count)
self.check_states[file_path] = checkbox
checkbox_widget = QWidget()
checkbox_layout = QHBoxLayout(checkbox_widget)
checkbox_layout.addWidget(checkbox)
checkbox_layout.setAlignment(Qt.AlignmentFlag.AlignCenter)
checkbox_layout.setContentsMargins(0, 0, 0, 0)
self.results_table.setCellWidget(row, 0, checkbox_widget)
# Group number
group_item = NumericTableWidgetItem(str(group_num))
group_item.setData(Qt.ItemDataRole.UserRole, group_num)
group_item.setTextAlignment(Qt.AlignmentFlag.AlignCenter)
self.results_table.setItem(row, 1, group_item)
# File Type
file_ext = os.path.splitext(file_path)[1].upper()
if file_ext:
type_item = QTableWidgetItem(file_ext[1:])
else:
type_item = QTableWidgetItem("FILE")
type_item.setTextAlignment(Qt.AlignmentFlag.AlignCenter)
self.results_table.setItem(row, 2, type_item)
# Filename
try:
filename = os.path.basename(file_path)
if not filename:
self.results_table.removeRow(row)
return
filename_item = QTableWidgetItem(filename)
self.results_table.setItem(row, 3, filename_item)
except Exception as e:
logger.error(f"Error getting filename for {file_path}: {e}")
self.results_table.removeRow(row)
return
try:
# Size with better formatting
if file_size < 1024:
size_text = f"{file_size} B"
elif file_size < 1024 * 1024:
size_text = f"{file_size // 1024} KB"
else:
size_text = f"{file_size // (1024 * 1024)} MB"
size_item = NumericTableWidgetItem(size_text)
size_item.setData(Qt.ItemDataRole.UserRole, file_size)
size_item.setTextAlignment(Qt.AlignmentFlag.AlignRight | Qt.AlignmentFlag.AlignVCenter)
self.results_table.setItem(row, 4, size_item)
# Path
path_item = QTableWidgetItem(os.path.dirname(file_path))
path_item.setToolTip(f"Full path: {file_path}")
self.results_table.setItem(row, 5, path_item)
# Modified date
try:
mtime = os.path.getmtime(file_path)
dt = datetime.fromtimestamp(mtime)
date_text = dt.strftime("%Y-%m-%d %H:%M")
date_item = NumericTableWidgetItem(date_text)
date_item.setData(Qt.ItemDataRole.UserRole, mtime)
except:
date_item = NumericTableWidgetItem("Unknown")
date_item.setData(Qt.ItemDataRole.UserRole, 0)
self.results_table.setItem(row, 6, date_item)
# Thumbnail
try:
file_ext = os.path.splitext(file_path)[1].lower()
if file_ext in ['.jpg', '.jpeg', '.png', '.gif', '.bmp', '.tiff', '.tif', '.webp']:
pixmap = self.load_and_process_image(file_path, THUMBNAIL_SIZE)
if pixmap and not pixmap.isNull():
thumb_label = HoverImageLabel(file_path, self)
thumb_label.setPixmap(pixmap)
thumb_label.setScaledContents(False)
self.results_table.setCellWidget(row, 7, thumb_label)
else:
raise Exception("Failed to create thumbnail pixmap")
else:
raise Exception("Not a supported image file")
except Exception:
# Fallback to icon for non-image files
file_ext = os.path.splitext(file_path)[1].lower()
icon_map = {
'.pdf': "📄",
'.docx': "📝", '.doc': "📝",
'.pptx': "📊", '.ppt': "📊",
'.xlsx': "📈", '.xls': "📈",
'.txt': "📃", '.log': "📃",
'.mp4': "🎬", '.mov': "🎬", '.avi': "🎬", '.mkv': "🎬",
'.mp3': "🎵", '.wav': "🎵", '.flac': "🎵",
'.zip': "🗜️", '.rar': "🗜️", '.7z': "🗜️",
'.exe': "⚙️", '.msi': "⚙️",
'.html': "🌐", '.htm': "🌐",
'.py': "🐍", '.js': "📜", '.java': "☕",
'.xml': "📋", '.json': "📋",
'.psd': "🎨", '.pst': "📧", '.ost': "📧",
'.mdb': "🗃️", '.accdb': "🗃️", '.sqlite': "🗃️", '.db': "🗃️"
}
icon = icon_map.get(file_ext, "📄")
thumb_label = QLabel(icon)
thumb_label.setFixedSize(THUMBNAIL_SIZE, THUMBNAIL_SIZE)
thumb_label.setAlignment(Qt.AlignmentFlag.AlignCenter)
thumb_label.setStyleSheet(f"""
border: 1px solid #999;
background-color: {THEMES[self.current_theme]['surface']};
font-size: 24px;
""")
thumb_label.setToolTip(f"{file_ext.upper()} file")
self.results_table.setCellWidget(row, 7, thumb_label)
except Exception as e:
logger.error(f"Error adding row for {file_path}: {e}", exc_info=True)
self.results_table.removeRow(row)
if file_path in self.duplicates:
self.duplicates.remove(file_path)
if file_path in self.check_states:
del self.check_states[file_path]
return
if row % 10 == 0:
QApplication.processEvents()
if row % 50 == 0:
self.auto_resize_window()
except Exception as e:
logger.error(f"Critical error in add_duplicate for {file_path}: {e}", exc_info=True)
def get_file_hash_for_grouping(self, file_path):
"""Get a hash for grouping files"""
try:
size = os.path.getsize(file_path)
h = hashlib.md5()
h.update(str(size).encode())
try:
with open(file_path, 'rb') as f:
h.update(f.read(1024))
except Exception as e:
logger.warning(f"Could not read file for hashing {file_path}: {e}")
return h.hexdigest()
except Exception as e:
logger.error(f"Error getting hash for {file_path}: {e}")
return hashlib.md5(file_path.encode()).hexdigest()
def search_finished(self, total_duplicates, total_scanned, total_size):
"""Enhanced search finished with progress bar cleanup"""
self.current_group = {}
self.total_files_scanned = total_scanned
self.total_groups_found = self.group_counter
self.total_duplicate_size = total_size
self.progress_bar.setVisible(False)
self.progress_bar.setValue(0)
self.results_table.setSortingEnabled(True)
self.setWindowTitle(f"Duplicate Finder v{VERSION}")
if total_duplicates == 0:
self.status_label.setText(f"✅ Scan complete. No duplicates found among {total_scanned} files.")
else:
size_text = self.format_size(total_size)
self.status_label.setText(f"✅ Found {total_duplicates} duplicates - Total of {size_text}")
if total_duplicates > 0:
self.auto_resize_window()
self.last_search_completed = True
self.update_selected_count()
def list_trash(self):
base = self.folder_path
trash_dir = os.path.join(base, TRASH_FOLDER)
files = []
if os.path.isdir(trash_dir):
for root, dirs, file_list in os.walk(trash_dir):
for file in file_list:
trash_path = os.path.join(root, file)
files.append(trash_path)
found_in_history = False
for hist_item in self.deletion_history:
if hist_item.get('trash_path') == trash_path:
found_in_history = True
break
if not found_in_history:
relative_path = os.path.relpath(trash_path, trash_dir)
original_path = os.path.join(base, relative_path)
self.deletion_history.append({
'path': original_path,
'trash_path': trash_path,
'timestamp': datetime.fromtimestamp(os.path.getmtime(trash_path)),
'permanent': False,
'synthetic': True
})
for file_path in files:
try:
file_size = os.path.getsize(file_path)
self.add_duplicate(file_path, None, file_size)
except OSError:
self.add_duplicate(file_path, None, 0)
self.results_table.setSortingEnabled(True)
total_size = sum(self.file_sizes.values())
size_text = self.format_size(total_size)
self.status_label.setText(f"🗑️ Trash contains {len(files)} items ({size_text}).")
self.setWindowTitle(f"Duplicate Finder v{VERSION}")
def update_selected_count(self):
"""SIMPLIFIED count update"""
if not hasattr(self, 'check_states'):
return
selected = sum(1 for cb in self.check_states.values() if cb.isChecked())
total = len(self.duplicates)
# Calculate size of selected files
selected_size = 0
for file_path, cb in self.check_states.items():
if cb.isChecked() and file_path in self.file_sizes:
selected_size += self.file_sizes[file_path]
if total > 0:
size_text = self.format_size(selected_size) if selected_size > 0 else ""
if size_text:
self.selected_count_label.setText(f"Selected: {selected} / {total} ({size_text})")
else:
self.selected_count_label.setText(f"Selected: {selected} / {total}")
else:
self.selected_count_label.setText(f"Selected: {selected}")
# Update toggle button text - SIMPLE LOGIC
if selected == total and total > 0:
self.toggle_select_btn.setText("☑️ Deselect All")
else:
self.toggle_select_btn.setText("☐ Select All")
def select_all(self):
for checkbox in self.check_states.values():
checkbox.setChecked(True)
self.update_selected_count()
def deselect_all(self):
for checkbox in self.check_states.values():
checkbox.setChecked(False)
self.update_selected_count()
def stop_search(self):
"""Enhanced stop with thread safety"""
self.stop_flag[0] = True
if self.search_thread and self.search_thread.isRunning():
self.search_thread.stop_safely()
self.progress_bar.setVisible(False)
self.progress_bar.setValue(0)
self.status_label.setText("⏹️ Scan stopped by user.")
self.setWindowTitle(f"Duplicate Finder v{VERSION}")
if hasattr(self, 'results_table'):
self.results_table.setSortingEnabled(True)
def undo_last_action(self):
if not self.undo_stack:
QMessageBox.information(self, "No Undo Available", "No actions to undo.")
return
action, data = self.undo_stack.pop()
if action == "delete":
restored_count = 0
for original_path, trash_path in data:
try:
os.makedirs(os.path.dirname(original_path), exist_ok=True)
shutil.move(trash_path, original_path)
restored_count += 1
except Exception as e:
logger.error(f"Failed to restore {trash_path}: {e}")
QMessageBox.information(self, "Undo Complete",
f"✅ Restored {restored_count} of {len(data)} files.")
elif action == "delete_empty":
restored_count = 0
for folder_path in data:
try:
os.makedirs(folder_path, exist_ok=True)
restored_count += 1
except Exception as e:
logger.error(f"Failed to restore folder {folder_path}: {e}")
QMessageBox.information(self, "Undo Complete",
f"✅ Restored {restored_count} empty folders.")
if self.view_mode == "normal":
self.search_duplicates()
else:
self.show_trash_contents()
def show_undelete_dialog(self):
"""Enhanced UnDelete dialog with better UI"""
if not self.deletion_history:
QMessageBox.information(self, "No Deleted Files",
"No recently deleted files to restore.")
return
dialog = QDialog(self)
dialog.setWindowTitle("UnDelete Files")
dialog.setModal(True)
dialog.resize(800, 500)
layout = QVBoxLayout()
header = QLabel("🔄 Recently Deleted Files")
header.setFont(QFont("Segoe UI", 14, QFont.Weight.Bold))
layout.addWidget(header)
info = QLabel("Select files to restore. Files moved to trash can be restored, "
"permanently deleted files are shown for reference only.")
info.setWordWrap(True)
layout.addWidget(info)
table = QTableWidget()
table.setColumnCount(4)
table.setHorizontalHeaderLabels(["Restore", "File Name", "Deleted On", "Status"])
table.horizontalHeader().setStretchLastSection(True)
restorable_indices = []
for i, item in enumerate(reversed(self.deletion_history[-50:])):
row = table.rowCount()
table.insertRow(row)
if not item.get('permanent', False) and 'trash_path' in item:
checkbox = QCheckBox()
checkbox_widget = QWidget()
checkbox_layout = QHBoxLayout(checkbox_widget)
checkbox_layout.addWidget(checkbox)
checkbox_layout.setAlignment(Qt.AlignmentFlag.AlignCenter)
checkbox_layout.setContentsMargins(0, 0, 0, 0)
table.setCellWidget(row, 0, checkbox_widget)
restorable_indices.append((row, len(self.deletion_history) - 1 - i))
else:
label = QLabel("❌")
label.setAlignment(Qt.AlignmentFlag.AlignCenter)
table.setCellWidget(row, 0, label)
table.setItem(row, 1, QTableWidgetItem(os.path.basename(item['path'])))
time_str = item['timestamp'].strftime("%Y-%m-%d %H:%M:%S")
table.setItem(row, 2, QTableWidgetItem(time_str))
if item.get('permanent', False):
status = "Permanently Deleted"
else:
status = "In Trash (Restorable)"
table.setItem(row, 3, QTableWidgetItem(status))
table.resizeColumnsToContents()
layout.addWidget(table)
button_layout = QHBoxLayout()
select_all_btn = QPushButton("Select All Restorable")
select_all_btn.clicked.connect(lambda: self.select_all_in_table(table, restorable_indices))
button_layout.addWidget(select_all_btn)
restore_btn = QPushButton("🔄 Restore Selected")
restore_btn.clicked.connect(lambda: self.restore_selected_files(table, restorable_indices, dialog))
restore_btn.setStyleSheet("""
QPushButton {
background-color: #4CAF50;
color: white;
font-weight: bold;
}
""")
button_layout.addWidget(restore_btn)
close_btn = QPushButton("Close")
close_btn.clicked.connect(dialog.close)
button_layout.addWidget(close_btn)
layout.addLayout(button_layout)
dialog.setLayout(layout)
dialog.exec()
def select_all_in_table(self, table, restorable_indices):
"""Select all restorable files in the undelete table"""
for row, _ in restorable_indices:
checkbox_widget = table.cellWidget(row, 0)
if checkbox_widget:
checkbox = checkbox_widget.findChild(QCheckBox)
if checkbox:
checkbox.setChecked(True)
def restore_selected_files(self, table, restorable_indices, dialog):
"""Restore selected files from the undelete dialog"""
restored_count = 0
failed_count = 0
failed_files = []
for row, history_index in restorable_indices:
checkbox_widget = table.cellWidget(row, 0)
if checkbox_widget:
checkbox = checkbox_widget.findChild(QCheckBox)
if checkbox and checkbox.isChecked():
item = self.deletion_history[history_index]
if 'trash_path' in item:
try:
original_path = item['path']
trash_path = item['trash_path']
if not os.path.exists(trash_path):
failed_files.append(f"{os.path.basename(original_path)} - File not found in trash")
failed_count += 1
continue
os.makedirs(os.path.dirname(original_path), exist_ok=True)
if os.path.exists(original_path):
base_dir = os.path.dirname(original_path)
base_name = os.path.basename(original_path)
name, ext = os.path.splitext(base_name)
counter = 1
while os.path.exists(original_path):
original_path = os.path.join(base_dir, f"{name}_restored_{counter}{ext}")
counter += 1
shutil.move(trash_path, original_path)
restored_count += 1
item['restored'] = True
item['restored_path'] = original_path
except Exception as e:
failed_files.append(f"{os.path.basename(item['path'])} - {str(e)}")
failed_count += 1
if restored_count > 0 or failed_count > 0:
message = f"✅ Successfully restored {restored_count} file(s)."
if failed_count > 0:
message += f"\n❌ Failed to restore {failed_count} file(s):"
for fail_msg in failed_files[:5]:
message += f"\n • {fail_msg}"
if len(failed_files) > 5:
message += f"\n ... and {len(failed_files) - 5} more"
QMessageBox.information(self, "Restoration Complete", message)
if restored_count > 0:
if self.view_mode == "normal":
self.search_duplicates()
else:
self.show_trash_contents()
else:
QMessageBox.information(self, "No Files Selected", "No files were selected for restoration.")
dialog.close()
def on_delete_empty_toggle(self):
if self.delete_empty_cb.isChecked():
self.delete_empty_btn.show()
else:
self.delete_empty_btn.hide()
def delete_empty_folders(self):
if not self.folder_path:
QMessageBox.warning(self, "No Folder", "There are no previously deleted files to be seen or found..")
return
empty_folders = []
for root, dirs, files in os.walk(self.folder_path, topdown=False):
if os.path.basename(root) == TRASH_FOLDER:
continue
if not dirs:
if not files or (len(files) == 1 and files[0].lower() == 'thumbs.db'):
empty_folders.append(root)
if not empty_folders:
QMessageBox.information(self, "No Empty Folders", "No empty folders found.")
return
folder_list = "\n".join(empty_folders[:10])
if len(empty_folders) > 10:
folder_list += f"\n... and {len(empty_folders) - 10} more"
reply = QMessageBox.question(
self, "Delete Empty Folders",
f"Found {len(empty_folders)} empty folders:\n\n{folder_list}\n\nDelete them all?",
QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No
)
if reply == QMessageBox.StandardButton.Yes:
deleted_folders = []
for folder in empty_folders:
try:
thumbs_path = os.path.join(folder, 'Thumbs.db')
if os.path.exists(thumbs_path):
os.remove(thumbs_path)
os.rmdir(folder)
deleted_folders.append(folder)
except Exception as e:
logger.error(f"Failed to delete folder {folder}: {e}")
if deleted_folders:
self.undo_stack.append(("delete_empty", deleted_folders))
QMessageBox.information(self, "Folders Deleted",
f"✅ Deleted {len(deleted_folders)} empty folders.")
if self.view_mode == "normal":
self.search_duplicates()
else:
self.show_trash_contents()
def on_protect_assets_toggle(self):
"""Handle protect assets toggle button"""
self.protect_assets = self.protect_assets_btn.isChecked()
if self.protect_assets:
self.protect_assets_btn.setStyleSheet("""
QPushButton {
background-color: #4CAF50;
color: white;
}
QPushButton:hover {
background-color: #45a049;
}
""")
else:
self.protect_assets_btn.setStyleSheet("")
def browse_folder(self):
try:
folder = QFileDialog.getExistingDirectory(self, "Select Folder")
if folder:
self.folder_entry.setText(folder)
self.folder_path = folder
self.add_to_recent_folders(folder)
logger.info(f"Selected folder: {folder}")
except Exception as e:
logger.error(f"Error in browse_folder: {e}", exc_info=True)
QMessageBox.critical(self, "Error", f"Failed to browse folder: {str(e)}")
def load_recent_folders(self):
"""Load recent folders from JSON file"""
try:
json_file = os.path.join(os.path.dirname(os.path.abspath(__file__)), RECENT_FOLDERS_FILE)
if os.path.exists(json_file):
with open(json_file, 'r', encoding='utf-8') as f:
folders = json.load(f)
return [f for f in folders if os.path.exists(f)][:MAX_RECENT_FOLDERS]
except Exception as e:
logger.error(f"Error loading recent folders: {e}")
return []
def save_recent_folders(self):
"""Save recent folders to JSON file"""
try:
json_file = os.path.join(os.path.dirname(os.path.abspath(__file__)), RECENT_FOLDERS_FILE)
with open(json_file, 'w', encoding='utf-8') as f:
json.dump(self.recent_folders, f, indent=2, ensure_ascii=False)
except Exception as e:
logger.error(f"Error saving recent folders: {e}")
def add_to_recent_folders(self, folder):
"""Add a folder to recent folders list"""
if folder in self.recent_folders:
self.recent_folders.remove(folder)
self.recent_folders.insert(0, folder)
self.recent_folders = self.recent_folders[:MAX_RECENT_FOLDERS]
self.save_recent_folders()
self.update_recent_menu()
def open_recent_folder(self, folder):
"""Open a folder from recent list"""
if os.path.exists(folder):
self.folder_entry.setText(folder)
self.folder_path = folder
logger.info(f"Opened recent folder: {folder}")
else:
QMessageBox.warning(self, "Folder Not Found", f"The folder no longer exists:\n{folder}")
self.recent_folders.remove(folder)
self.save_recent_folders()
self.update_recent_menu()
def clear_recent_folders(self):
"""Clear all recent folders"""
self.recent_folders.clear()
self.save_recent_folders()
self.update_recent_menu()
def update_recent_menu(self):
"""Update the recent folders menu with current list"""
try:
if not self.recent_menu:
return
self.recent_menu.clear()
if not self.recent_folders:
action = self.recent_menu.addAction("(No recent folders)")
action.setEnabled(False)
return
for folder in self.recent_folders:
action = self.recent_menu.addAction(folder)
action.triggered.connect(lambda checked, f=folder: self.open_recent_folder(f))
self.recent_menu.addSeparator()
clear_action = self.recent_menu.addAction("Clear Recent")
clear_action.triggered.connect(self.clear_recent_folders)
except Exception as e:
logger.error(f"Error updating recent menu: {e}")
def print_results(self):
"""Print the search results with a preview dialog"""
if self.results_table.rowCount() == 0:
QMessageBox.information(self, "No Results", "No search results to print.")
return
printer = QPrinter(QPrinter.PrinterMode.HighResolution)
preview_dialog = QPrintPreviewDialog(printer, self)
preview_dialog.setWindowTitle("Print Preview - Search Results")
preview_dialog.paintRequested.connect(lambda p: self.print_document(p))
preview_dialog.exec()
def print_document(self, printer):
"""Generate the document for printing"""
document = QTextDocument()
cursor = QTextCursor(document)
document.setDefaultFont(QFont("Arial", 10))
header_format = QTextCharFormat()
header_format.setFont(QFont("Arial", 16, QFont.Weight.Bold))
cursor.setCharFormat(header_format)
cursor.insertText("Duplicate File Finder - Search Results\n")
normal_format = QTextCharFormat()
normal_format.setFont(QFont("Arial", 10))
cursor.setCharFormat(normal_format)
cursor.insertText(f"Generated on: {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}\n")
cursor.insertText(f"Folder scanned: {self.folder_path}\n")
cursor.insertText(f"Total duplicates found: {self.results_table.rowCount()}\n")
cursor.insertText(f"Total duplicate size: {self.format_size(self.total_duplicate_size)}\n\n")
table_format = QTextTableFormat()
table_format.setAlignment(Qt.AlignmentFlag.AlignLeft)
table_format.setCellPadding(4)
table_format.setCellSpacing(0)
table_format.setBorder(1)
table_format.setBorderStyle(QTextFrameFormat.BorderStyle.BorderStyle_Solid)
table_format.setWidth(QTextLength(QTextLength.Type.PercentageLength, 100))
headers = ["Group", "Type", "Filename", "Size", "Path", "Modified Date"]
table = cursor.insertTable(self.results_table.rowCount() + 1, len(headers), table_format)
header_format = QTextCharFormat()
header_format.setFont(QFont("Arial", 10, QFont.Weight.Bold))
header_format.setBackground(QColor(240, 240, 240))
for i, header in enumerate(headers):
cell = table.cellAt(0, i)
cell_cursor = cell.firstCursorPosition()
cell_cursor.setCharFormat(header_format)
cell_cursor.insertText(header)
cell_format = QTextCharFormat()
cell_format.setFont(QFont("Arial", 9))
for row in range(self.results_table.rowCount()):
# Group
item = self.results_table.item(row, 1)
if item:
cell = table.cellAt(row + 1, 0)
cell_cursor = cell.firstCursorPosition()
cell_cursor.setCharFormat(cell_format)
cell_cursor.insertText(item.text())
# Type
item = self.results_table.item(row, 2)
if item:
cell = table.cellAt(row + 1, 1)
cell_cursor = cell.firstCursorPosition()
cell_cursor.setCharFormat(cell_format)
cell_cursor.insertText(item.text())
# Filename
item = self.results_table.item(row, 3)
if item:
cell = table.cellAt(row + 1, 2)
cell_cursor = cell.firstCursorPosition()
cell_cursor.setCharFormat(cell_format)
cell_cursor.insertText(item.text())
# Size
item = self.results_table.item(row, 4)
if item:
cell = table.cellAt(row + 1, 3)
cell_cursor = cell.firstCursorPosition()
cell_cursor.setCharFormat(cell_format)
cell_cursor.insertText(item.text())
# Path
item = self.results_table.item(row, 5)
if item:
cell = table.cellAt(row + 1, 4)
cell_cursor = cell.firstCursorPosition()
cell_cursor.setCharFormat(cell_format)
cell_cursor.insertText(item.text())
# Modified Date
item = self.results_table.item(row, 6)
if item:
cell = table.cellAt(row + 1, 5)
cell_cursor = cell.firstCursorPosition()
cell_cursor.setCharFormat(cell_format)
cell_cursor.insertText(item.text())
cursor.movePosition(QTextCursor.MoveOperation.End)
cursor.insertText(f"\n\nDuplicate Finder v{VERSION}")
document.print(printer)
def delete_log_files(self):
"""Delete all log files in the application directory"""
try:
app_dir = os.path.dirname(os.path.abspath(__file__))
log_files = glob.glob(os.path.join(app_dir, "*.log"))
if not log_files:
QMessageBox.information(self, "No Log Files", "No log files found to delete.")
return
reply = QMessageBox.question(
self, "Delete Log Files",
f"Found {len(log_files)} log file(s):\n\n" +
"\n".join([os.path.basename(f) for f in log_files]) +
"\n\nDelete all log files?",
QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No
)
if reply == QMessageBox.StandardButton.Yes:
deleted_count = 0
for log_file in log_files:
try:
os.remove(log_file)
deleted_count += 1
except Exception as e:
logger.error(f"Failed to delete {log_file}: {e}")
QMessageBox.information(self, "Log Files Deleted",
f"Successfully deleted {deleted_count} log file(s).")
except Exception as e:
logger.error(f"Error deleting log files: {e}", exc_info=True)
QMessageBox.critical(self, "Error", f"Failed to delete log files: {str(e)}")
def show_about(self):
about_dialog = QDialog(self)
about_dialog.setWindowTitle("About Duplicate Finder")
about_dialog.setFixedSize(500, 400)
layout = QVBoxLayout(about_dialog)
icon_label = QLabel("🔍")
icon_label.setFont(QFont("Segoe UI Symbol", 48))
icon_label.setAlignment(Qt.AlignmentFlag.AlignCenter)
layout.addWidget(icon_label)
title = QLabel("Duplicate Finder")
title.setFont(QFont("Segoe UI", 20, QFont.Weight.Bold))
title.setAlignment(Qt.AlignmentFlag.AlignCenter)
layout.addWidget(title)
version = QLabel(f"Version {VERSION}")
version.setFont(QFont("Segoe UI", 14))
version.setAlignment(Qt.AlignmentFlag.AlignCenter)
layout.addWidget(version)
credits = QLabel("Created by Faruk Ozturkmen\n© Silk Aurora Pty Ltd\n\nPyQt6 Enhanced Version")
credits.setAlignment(Qt.AlignmentFlag.AlignCenter)
credits.setFont(QFont("Segoe UI", 10))
layout.addWidget(credits)
description = QLabel(
"Advanced duplicate file finder with intelligent content detection,\n"
"thumbnail preview, safe deletion system, and comprehensive help.\n\n"
f"v{VERSION} Features:\n"
"• Simplified Button Logic - Each button has ONE clear purpose\n"
"• Separated Search Functions - search_duplicates() vs show_trash() are now separate\n"
"• Removed Context-Dependent Buttons - No more buttons that change meaning\n"
"• Silent Toggle Behavior - No popup interruptions when toggling selection\n"
"• Clear State Management - Simple view_mode instead of complex state checking\n"
"• Instant View Toggle - 'View Trash' button changes color immediately for instant feedback\n"
"• Real-time duplicate display\n"
"• Exact content comparison (byte-for-byte)\n"
"• 60+ supported file formats"
)
description.setWordWrap(True)
description.setAlignment(Qt.AlignmentFlag.AlignCenter)
layout.addWidget(description)
close_btn = QPushButton("Close")
close_btn.clicked.connect(about_dialog.accept)
layout.addWidget(close_btn)
about_dialog.exec()
def show_enlarged_image(self, path):
"""Display enlarged image with improved color and aspect ratio handling"""
try:
if self.enlarged_widget:
self.enlarged_widget.deleteLater()
self.enlarged_widget = EnlargedImageWidget(self.centralWidget())
self.enlarged_widget.clicked.connect(self.hide_enlarged_image)
pixmap = self.load_and_process_image(path, ENLARGED_SIZE)
if pixmap and not pixmap.isNull():
self.enlarged_widget.set_image(pixmap)
table_rect = self.results_table.rect()
global_pos = self.results_table.mapToGlobal(table_rect.topLeft())
local_pos = self.centralWidget().mapFromGlobal(global_pos)
x = local_pos.x() + (table_rect.width() - ENLARGED_SIZE) // 2
y = local_pos.y() + (table_rect.height() - ENLARGED_SIZE) // 2
x = max(10, min(x, self.centralWidget().width() - ENLARGED_SIZE - 10))
y = max(10, min(y, self.centralWidget().height() - ENLARGED_SIZE - 10))
self.enlarged_widget.move(x, y)
self.enlarged_widget.show()
self.enlarged_widget.raise_()
else:
logger.warning(f"Failed to load enlarged image: {path}")
except Exception as e:
logger.error(f"Error showing enlarged image: {e}")
def hide_enlarged_image(self):
if self.enlarged_widget:
self.enlarged_widget.hide()
self.enlarged_widget.deleteLater()
self.enlarged_widget = None
def toggle_help_panel(self):
"""Toggle help panel while maintaining window state memory"""
self.is_programmatic_resize = True
if self.help_widget.isVisible():
self.help_widget.hide()
target_width = self.user_stretched_width if self.user_stretched_width else self.original_main_width
self.resize(target_width, self.height())
else:
self.help_widget.show()
current_main_width = self.width()
total_width = current_main_width + HELP_PANEL_WIDTH
self.resize(total_width, self.height())
if hasattr(self, 'results_table') and self.results_table.rowCount() > 0:
QTimer.singleShot(100, self.auto_resize_window)
QTimer.singleShot(100, lambda: setattr(self, 'is_programmatic_resize', False))
def auto_resize_window(self):
"""Auto-resize window to show all table content without scrolling"""
table_width = 0
header = self.results_table.horizontalHeader()
for col in range(self.results_table.columnCount()):
if header.sectionResizeMode(col) != QHeaderView.ResizeMode.Stretch:
table_width += self.results_table.columnWidth(col)
else:
table_width += 150
table_width += self.results_table.verticalScrollBar().width() + 20
needed_main_width = table_width + 100
current_height = self.height()
min_main_width = MAIN_WINDOW_MIN_WIDTH
screen = QApplication.primaryScreen().geometry()
max_main_width = int(screen.width() * 0.7)
optimal_main_width = max(min_main_width, min(needed_main_width, max_main_width))
current_main_width = self.width()
if self.help_widget.isVisible():
current_main_width -= HELP_PANEL_WIDTH
if optimal_main_width > current_main_width and not self.user_stretched_width:
self.is_programmatic_resize = True
total_width = optimal_main_width
if self.help_widget.isVisible():
total_width += HELP_PANEL_WIDTH
self.resize(total_width, current_height)
QTimer.singleShot(100, lambda: setattr(self, 'is_programmatic_resize', False))
def resizeEvent(self, event):
"""Track manual window resizing"""
super().resizeEvent(event)
if not self.is_programmatic_resize and hasattr(self, 'original_main_width'):
new_width = event.size().width()
current_main_width = new_width
if self.help_widget.isVisible():
current_main_width -= HELP_PANEL_WIDTH
tolerance = 50
if current_main_width > self.original_main_width + tolerance:
self.user_stretched_width = current_main_width
elif current_main_width <= self.original_main_width + tolerance:
self.user_stretched_width = None
if self.enlarged_widget and self.enlarged_widget.isVisible():
table_rect = self.results_table.rect()
global_pos = self.results_table.mapToGlobal(table_rect.topLeft())
local_pos = self.centralWidget().mapFromGlobal(global_pos)
x = local_pos.x() + (table_rect.width() - ENLARGED_SIZE) // 2
y = local_pos.y() + (table_rect.height() - ENLARGED_SIZE) // 2
x = max(10, min(x, self.centralWidget().width() - ENLARGED_SIZE - 10))
y = max(10, min(y, self.centralWidget().height() - ENLARGED_SIZE - 10))
self.enlarged_widget.move(x, y)
def apply_theme(self, theme_name):
self.current_theme = theme_name
colors = THEMES[theme_name]
self.setStyleSheet(f"""
QMainWindow {{
background-color: {colors['background']};
color: {colors['text']};
font-family: 'Segoe UI', sans-serif;
}}
QWidget {{
background-color: {colors['background']};
color: {colors['text']};
font-family: 'Segoe UI', sans-serif;
}}
QPushButton {{
background-color: {colors['button']};
color: {colors['button_text']};
border: 1px solid {colors['border']};
padding: 6px 12px;
border-radius: 4px;
font-weight: 500;
font-size: 14px;
}}
QPushButton:hover {{
background-color: {colors['hover']};
border-color: {colors['primary']};
}}
QPushButton:pressed {{
background-color: {colors['primary']};
color: white;
}}
QLabel {{
color: {colors['text']};
background-color: transparent;
font-size: 14px;
}}
QLineEdit {{
background-color: {colors['surface']};
color: {colors['text']};
border: 1px solid {colors['border']};
padding: 6px 8px;
border-radius: 4px;
font-size: 14px;
}}
QLineEdit:focus {{
border-color: {colors['primary']};
outline: none;
}}
QComboBox {{
background-color: {colors['surface']};
color: {colors['text']};
border: 1px solid {colors['border']};
padding: 6px 8px;
border-radius: 4px;
font-size: 14px;
}}
QComboBox:hover {{
border-color: {colors['primary']};
}}
QComboBox QAbstractItemView {{
background-color: {colors['surface']};
color: {colors['text']};
selection-background-color: {colors['primary']};
selection-color: white;
border: 1px solid {colors['border']};
}}
QCheckBox {{
color: {colors['text']};
background-color: transparent;
font-size: 14px;
spacing: 8px;
}}
QCheckBox::indicator {{
width: 16px;
height: 16px;
border: 1px solid {colors['border']};
border-radius: 3px;
background-color: {colors['surface']};
}}
QCheckBox::indicator:checked {{
background-color: {colors['primary']};
border-color: {colors['primary']};
}}
QScrollArea {{
border: 1px solid {colors['border']};
background-color: {colors['surface']};
}}
QTextEdit {{
background-color: {colors['surface']};
color: {colors['text']};
border: 1px solid {colors['border']};
border-radius: 4px;
font-size: 14px;
}}
""")
if hasattr(self, 'results_table'):
self.apply_win11_table_style()
def apply_win11_table_style(self):
"""Apply Windows 11 style to the results table"""
colors = THEMES[self.current_theme]
self.results_table.setStyleSheet(f"""
QTableWidget {{
border: 1px solid {colors['border']};
background-color: {colors['surface']};
alternate-background-color: {colors['background']};
gridline-color: {colors['border']};
selection-background-color: transparent;
}}
QTableWidget::item {{
padding: 4px;
color: {colors['text']};
selection-background-color: transparent;
}}
QTableWidget::item:selected {{
background-color: transparent;
color: {colors['text']};
}}
QHeaderView::section {{
background-color: {colors['header']};
color: {colors['text']};
border: 1px solid {colors['border']};
padding: 6px;
font-weight: bold;
font-size: 13px;
}}
QHeaderView::section:hover {{
background-color: {colors['hover']};
}}
QScrollBar:vertical {{
background: {colors['background']};
width: 14px;
border: none;
border-radius: 0px;
}}
QScrollBar::handle:vertical {{
background: {colors['border']};
border-radius: 0px;
min-height: 30px;
margin: 0px;
}}
QScrollBar::handle:vertical:hover {{
background: {colors['text']};
}}
QScrollBar::handle:vertical:pressed {{
background: {colors['primary']};
}}
QScrollBar::add-line:vertical, QScrollBar::sub-line:vertical {{
height: 0px;
width: 0px;
}}
QScrollBar:horizontal {{
background: {colors['background']};
height: 14px;
border: none;
}}
QScrollBar::handle:horizontal {{
background: {colors['border']};
min-width: 30px;
}}
QScrollBar::handle:horizontal:hover {{
background: {colors['text']};
}}
""")
def apply_win11_scrollbar_style(self, widget):
colors = THEMES[self.current_theme]
widget.setStyleSheet(f"""
QScrollArea {{
border: 1px solid {colors['border']};
background-color: {colors['surface']};
}}
QScrollBar:vertical {{
background: {colors['background']};
width: 14px;
border: none;
border-radius: 0px;
}}
QScrollBar::handle:vertical {{
background: {colors['border']};
border-radius: 0px;
min-height: 30px;
margin: 0px;
}}
QScrollBar::handle:vertical:hover {{
background: {colors['text']};
}}
QScrollBar::handle:vertical:pressed {{
background: {colors['primary']};
}}
QScrollBar::add-line:vertical, QScrollBar::sub-line:vertical {{
height: 0px;
width: 0px;
}}
QTextEdit {{
border: 1px solid {colors['border']};
background-color: {colors['surface']};
color: {colors['text']};
}}
""")
def set_initial_size(self):
"""Set the initial window size - 900px main content width"""
min_height = 500
self.setMinimumSize(MAIN_WINDOW_MIN_WIDTH, min_height)
self.resize(MAIN_WINDOW_INITIAL_WIDTH, min_height)
self.original_main_width = MAIN_WINDOW_INITIAL_WIDTH
def closeEvent(self, event):
"""Enhanced close event with proper thread cleanup"""
logger.info("Application closing - starting cleanup...")
self.stop_flag[0] = True
if hasattr(self, 'search_thread') and self.search_thread and self.search_thread.isRunning():
logger.info("Stopping search thread...")
self.search_thread.stop_safely()
logger.info(f"=== Duplicate Finder v{VERSION} Closed ===\n")
event.accept()
def main():
try:
app = QApplication(sys.argv)
app.setApplicationName("Duplicate Finder")
app.setApplicationVersion(VERSION)
app.setOrganizationName("Silk Aurora Pty Ltd")
app.setOrganizationDomain("silkaurora.com.au")
app.setStyle('Fusion')
window = DuplicateFinderApp()
window.show()
sys.exit(app.exec())
except Exception as e:
logger.critical(f"Failed to start application: {e}", exc_info=True)
sys.exit(1)
if __name__ == "__main__":
main()
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment