Created
June 6, 2025 12:28
-
-
Save oavs/55d0f3ca78290e4d7e91c8fb0d9b44f9 to your computer and use it in GitHub Desktop.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
""" | |
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