| |
| |
| """ |
| PyQt6 Storage Cleaner Pro - Complete Application |
| Professional disk cleanup utility with dark theme. |
| Features: Disk overview, Large file scanner, System cleaner, |
| Duplicate finder, Folder analyzer, Startup manager, Scheduled cleaning. |
| """ |
|
|
| import os |
| import sys |
| import time |
| import shutil |
| import hashlib |
| import platform |
| import subprocess |
| import json |
| import threading |
| from pathlib import Path |
| from datetime import datetime |
| from dataclasses import dataclass, field |
| from typing import List, Tuple, Dict, Set |
| from collections import defaultdict |
|
|
| from PyQt6.QtWidgets import ( |
| QApplication, QMainWindow, QWidget, QVBoxLayout, QHBoxLayout, |
| QTabWidget, QLabel, QPushButton, QProgressBar, QCheckBox, |
| QTreeWidget, QTreeWidgetItem, QTableWidget, QTableWidgetItem, |
| QHeaderView, QAbstractItemView, QTextEdit, QLineEdit, |
| QFileDialog, QMessageBox, QStatusBar, QGroupBox, QSplitter, |
| QFrame, QComboBox, QSpinBox, QMenu, QMenuBar, QDialog, |
| QDialogButtonBox, QListWidget, QListWidgetItem, QRadioButton, |
| QButtonGroup, QSlider, QToolBar, QSizePolicy, QScrollArea |
| ) |
| from PyQt6.QtCore import Qt, QThread, pyqtSignal, QTimer, QSize, QSettings |
| from PyQt6.QtGui import ( |
| QFont, QColor, QAction, QIcon, QPalette, QPainter, |
| QPen, QBrush, QLinearGradient, QRadialGradient |
| ) |
|
|
|
|
| |
| |
| |
|
|
| def format_size(size_bytes: int) -> str: |
| if size_bytes < 0: |
| return "0 B" |
| for unit in ['B', 'KB', 'MB', 'GB', 'TB']: |
| if size_bytes < 1024: |
| return f"{size_bytes:.1f} {unit}" |
| size_bytes /= 1024 |
| return f"{size_bytes:.1f} PB" |
|
|
|
|
| def safe_remove(path: str) -> bool: |
| try: |
| if os.path.isfile(path): |
| os.remove(path) |
| elif os.path.isdir(path): |
| shutil.rmtree(path) |
| return True |
| except (OSError, PermissionError): |
| return False |
|
|
|
|
| |
| |
| |
|
|
| @dataclass |
| class DiskInfo: |
| drive: str |
| mount_point: str |
| total: int |
| used: int |
| free: int |
| percent: float |
| fs_type: str = "" |
|
|
|
|
| @dataclass |
| class FileEntry: |
| path: str |
| size: int |
| modified: str |
| ext: str = "" |
| category: str = "" |
|
|
|
|
| @dataclass |
| class DuplicateGroup: |
| hash: str |
| size: int |
| files: List[str] = field(default_factory=list) |
|
|
|
|
| @dataclass |
| class FolderEntry: |
| path: str |
| size: int |
| file_count: int |
| percent: float = 0.0 |
|
|
|
|
| |
| |
| |
|
|
| class DiskScanner: |
| LARGE_FILE_THRESHOLD = 50 * 1024 * 1024 |
| |
| CATEGORY_MAP = { |
| 'Video': {'.mp4', '.avi', '.mkv', '.mov', '.flv', '.wmv', '.webm', '.m4v', '.mpg', '.mpeg'}, |
| 'Audio': {'.mp3', '.wav', '.flac', '.aac', '.ogg', '.wma', '.m4a', '.opus'}, |
| 'Image': {'.jpg', '.jpeg', '.png', '.gif', '.bmp', '.tiff', '.webp', '.svg', '.psd', '.raw', '.cr2'}, |
| 'Archive': {'.zip', '.rar', '.7z', '.tar', '.gz', '.bz2', '.xz', '.iso', '.cab'}, |
| 'Document': {'.pdf', '.doc', '.docx', '.xls', '.xlsx', '.ppt', '.pptx', '.txt', '.odt', '.rtf'}, |
| 'Installer': {'.exe', '.msi', '.dmg', '.deb', '.rpm', '.appimage', '.apk'}, |
| 'Database': {'.db', '.sqlite', '.mdf', '.sql', '.bak'}, |
| 'Virtual': {'.vmdk', '.vdi', '.vhd', '.vhdx', '.ova', '.ovf'}, |
| } |
| |
| SKIP_DIRS = { |
| '$Recycle.Bin', 'System Volume Information', 'Windows', 'WinSxS', |
| 'node_modules', '.git', '__pycache__', 'venv', '.venv', |
| 'Recovery', 'PerfLogs', '.Trash', '.Trash-1000', |
| } |
| |
| @classmethod |
| def get_disks(cls) -> List[DiskInfo]: |
| disks = [] |
| if sys.platform == 'win32': |
| try: |
| import ctypes |
| bitmask = ctypes.windll.kernel32.GetLogicalDrives() |
| for letter in 'ABCDEFGHIJKLMNOPQRSTUVWXYZ': |
| if bitmask & 1: |
| drive = f"{letter}:\\" |
| try: |
| usage = shutil.disk_usage(drive) |
| if usage.total > 0: |
| disks.append(DiskInfo( |
| drive=f"{letter}:", mount_point=drive, |
| total=usage.total, used=usage.used, free=usage.free, |
| percent=round(usage.used / usage.total * 100, 1) |
| )) |
| except (OSError, PermissionError): |
| pass |
| bitmask >>= 1 |
| except Exception: |
| pass |
| else: |
| seen_totals = set() |
| mounts = ['/'] |
| if os.path.exists('/home'): |
| mounts.append(str(Path.home())) |
| for mount in mounts: |
| try: |
| usage = shutil.disk_usage(mount) |
| if usage.total > 0 and usage.total not in seen_totals: |
| seen_totals.add(usage.total) |
| disks.append(DiskInfo( |
| drive=mount, mount_point=mount, |
| total=usage.total, used=usage.used, free=usage.free, |
| percent=round(usage.used / usage.total * 100, 1) |
| )) |
| except (OSError, PermissionError): |
| pass |
| return disks |
| |
| @classmethod |
| def get_category(cls, ext: str) -> str: |
| ext = ext.lower() |
| for cat, exts in cls.CATEGORY_MAP.items(): |
| if ext in exts: |
| return cat |
| return "Other" |
| |
| @classmethod |
| def scan_large_files(cls, root: str, threshold: int = None, max_files: int = 500, callback=None) -> List[FileEntry]: |
| if threshold is None: |
| threshold = cls.LARGE_FILE_THRESHOLD |
| results = [] |
| count = 0 |
| for dirpath, dirnames, filenames in os.walk(root): |
| dirnames[:] = [d for d in dirnames if d not in cls.SKIP_DIRS and not d.startswith('.')] |
| for fname in filenames: |
| try: |
| fpath = os.path.join(dirpath, fname) |
| size = os.path.getsize(fpath) |
| if size >= threshold: |
| ext = os.path.splitext(fname)[1].lower() |
| try: |
| mtime = datetime.fromtimestamp(os.path.getmtime(fpath)).strftime('%Y-%m-%d %H:%M') |
| except (OSError, ValueError): |
| mtime = "Unknown" |
| results.append(FileEntry( |
| path=fpath, size=size, modified=mtime, |
| ext=ext, category=cls.get_category(ext) |
| )) |
| count += 1 |
| if callback and count % 20 == 0: |
| callback(count) |
| if count >= max_files: |
| return sorted(results, key=lambda x: x.size, reverse=True) |
| except (OSError, PermissionError): |
| continue |
| return sorted(results, key=lambda x: x.size, reverse=True) |
| |
| @classmethod |
| def get_temp_paths(cls) -> List[str]: |
| paths = [] |
| if sys.platform == 'win32': |
| candidates = [ |
| os.environ.get('TEMP', ''), |
| os.environ.get('TMP', ''), |
| os.path.join(os.environ.get('LOCALAPPDATA', ''), 'Temp'), |
| os.path.join(os.environ.get('WINDIR', r'C:\Windows'), 'Temp'), |
| os.path.join(os.environ.get('LOCALAPPDATA', ''), 'Microsoft', 'Windows', 'INetCache'), |
| os.path.join(os.environ.get('LOCALAPPDATA', ''), 'Microsoft', 'Windows', 'Explorer'), |
| os.path.join(os.environ.get('WINDIR', r'C:\Windows'), 'Prefetch'), |
| os.path.join(os.environ.get('LOCALAPPDATA', ''), 'Microsoft', 'Windows', 'Temporary Internet Files'), |
| ] |
| else: |
| candidates = [ |
| '/tmp', |
| '/var/tmp', |
| os.path.join(str(Path.home()), '.cache'), |
| os.path.join(str(Path.home()), '.local', 'share', 'Trash'), |
| os.path.join(str(Path.home()), '.thumbnails'), |
| ] |
| return [p for p in candidates if p and os.path.exists(p)] |
| |
| @classmethod |
| def scan_temp_size(cls) -> Tuple[int, int]: |
| total_size = 0 |
| file_count = 0 |
| for temp_dir in cls.get_temp_paths(): |
| try: |
| for root, dirs, files in os.walk(temp_dir): |
| for f in files: |
| try: |
| total_size += os.path.getsize(os.path.join(root, f)) |
| file_count += 1 |
| except (OSError, PermissionError): |
| pass |
| except (OSError, PermissionError): |
| pass |
| return total_size, file_count |
| |
| @classmethod |
| def analyze_folder(cls, root: str, depth: int = 1, callback=None) -> List[FolderEntry]: |
| """Analyze top-level folder sizes.""" |
| folders = [] |
| try: |
| entries = sorted(os.scandir(root), key=lambda e: e.name) |
| total_root_size = 0 |
| |
| for entry in entries: |
| if entry.is_dir() and entry.name not in cls.SKIP_DIRS and not entry.name.startswith('.'): |
| try: |
| folder_size = 0 |
| file_count = 0 |
| for r, d, fs in os.walk(entry.path): |
| d[:] = [x for x in d if x not in cls.SKIP_DIRS] |
| for f in fs: |
| try: |
| folder_size += os.path.getsize(os.path.join(r, f)) |
| file_count += 1 |
| except (OSError, PermissionError): |
| pass |
| if folder_size > 0: |
| folders.append(FolderEntry( |
| path=entry.path, size=folder_size, file_count=file_count |
| )) |
| total_root_size += folder_size |
| if callback: |
| callback(len(folders)) |
| except (OSError, PermissionError): |
| pass |
| |
| |
| for f in folders: |
| f.percent = round(f.size / total_root_size * 100, 1) if total_root_size > 0 else 0 |
| |
| except (OSError, PermissionError): |
| pass |
| |
| return sorted(folders, key=lambda x: x.size, reverse=True) |
| |
| @classmethod |
| def find_duplicates(cls, root: str, min_size: int = 1024 * 1024, callback=None) -> List[DuplicateGroup]: |
| """Find duplicate files by hash.""" |
| |
| size_map: Dict[int, List[str]] = defaultdict(list) |
| count = 0 |
| |
| for dirpath, dirnames, filenames in os.walk(root): |
| dirnames[:] = [d for d in dirnames if d not in cls.SKIP_DIRS and not d.startswith('.')] |
| for fname in filenames: |
| try: |
| fpath = os.path.join(dirpath, fname) |
| size = os.path.getsize(fpath) |
| if size >= min_size: |
| size_map[size].append(fpath) |
| count += 1 |
| if callback and count % 100 == 0: |
| callback(f"Indexing: {count} files...") |
| except (OSError, PermissionError): |
| continue |
| |
| |
| duplicates = [] |
| hash_map: Dict[str, List[str]] = defaultdict(list) |
| |
| candidates = [(size, paths) for size, paths in size_map.items() if len(paths) > 1] |
| total_candidates = sum(len(paths) for _, paths in candidates) |
| processed = 0 |
| |
| for size, paths in candidates: |
| for fpath in paths: |
| try: |
| h = hashlib.md5() |
| with open(fpath, 'rb') as f: |
| |
| h.update(f.read(8192)) |
| if size > 8192: |
| f.seek(-8192, 2) |
| h.update(f.read(8192)) |
| file_hash = f"{size}_{h.hexdigest()}" |
| hash_map[file_hash].append(fpath) |
| processed += 1 |
| if callback and processed % 50 == 0: |
| callback(f"Hashing: {processed}/{total_candidates}") |
| except (OSError, PermissionError): |
| continue |
| |
| |
| for file_hash, paths in hash_map.items(): |
| if len(paths) > 1: |
| size = int(file_hash.split('_')[0]) |
| duplicates.append(DuplicateGroup(hash=file_hash, size=size, files=paths)) |
| |
| return sorted(duplicates, key=lambda x: x.size * len(x.files), reverse=True) |
|
|
|
|
| |
| |
| |
|
|
| class DiskCleaner: |
| @classmethod |
| def delete_files(cls, paths: List[str]) -> dict: |
| result = {'deleted': 0, 'failed': 0, 'freed': 0, 'errors': []} |
| for path in paths: |
| try: |
| if not os.path.exists(path): |
| continue |
| size = os.path.getsize(path) if os.path.isfile(path) else 0 |
| if safe_remove(path): |
| result['deleted'] += 1 |
| result['freed'] += size |
| else: |
| result['failed'] += 1 |
| except (OSError, PermissionError) as e: |
| result['failed'] += 1 |
| result['errors'].append(str(e)) |
| return result |
| |
| @classmethod |
| def clean_temp_files(cls, callback=None) -> dict: |
| result = {'deleted': 0, 'failed': 0, 'freed': 0, 'errors': []} |
| for temp_dir in DiskScanner.get_temp_paths(): |
| try: |
| for root, dirs, files in os.walk(temp_dir): |
| for f in files: |
| fpath = os.path.join(root, f) |
| try: |
| size = os.path.getsize(fpath) |
| os.remove(fpath) |
| result['deleted'] += 1 |
| result['freed'] += size |
| if callback and result['deleted'] % 50 == 0: |
| callback(result['deleted']) |
| except (OSError, PermissionError): |
| result['failed'] += 1 |
| except (OSError, PermissionError): |
| pass |
| return result |
| |
| @classmethod |
| def clean_empty_dirs(cls, root: str) -> int: |
| deleted = 0 |
| for dirpath, dirnames, filenames in os.walk(root, topdown=False): |
| if dirpath == root: |
| continue |
| try: |
| if not os.listdir(dirpath): |
| os.rmdir(dirpath) |
| deleted += 1 |
| except (OSError, PermissionError): |
| pass |
| return deleted |
| |
| @classmethod |
| def empty_recycle_bin(cls) -> bool: |
| if sys.platform == 'win32': |
| try: |
| import ctypes |
| ctypes.windll.shell32.SHEmptyRecycleBinW(None, None, 0x07) |
| return True |
| except Exception: |
| return False |
| else: |
| trash_dirs = [ |
| os.path.join(str(Path.home()), '.local', 'share', 'Trash'), |
| os.path.join(str(Path.home()), '.Trash'), |
| ] |
| for trash in trash_dirs: |
| if os.path.exists(trash): |
| try: |
| shutil.rmtree(trash) |
| os.makedirs(trash, exist_ok=True) |
| return True |
| except (OSError, PermissionError): |
| pass |
| return False |
| |
| @classmethod |
| def clean_browser_cache(cls) -> dict: |
| result = {'deleted': 0, 'freed': 0} |
| cache_dirs = [] |
| |
| if sys.platform == 'win32': |
| local = os.environ.get('LOCALAPPDATA', '') |
| cache_dirs = [ |
| os.path.join(local, 'Google', 'Chrome', 'User Data', 'Default', 'Cache'), |
| os.path.join(local, 'Google', 'Chrome', 'User Data', 'Default', 'Code Cache'), |
| os.path.join(local, 'Microsoft', 'Edge', 'User Data', 'Default', 'Cache'), |
| os.path.join(local, 'Mozilla', 'Firefox', 'Profiles'), |
| ] |
| else: |
| home = str(Path.home()) |
| cache_dirs = [ |
| os.path.join(home, '.cache', 'google-chrome'), |
| os.path.join(home, '.cache', 'mozilla'), |
| os.path.join(home, '.cache', 'chromium'), |
| ] |
| |
| for cache_dir in cache_dirs: |
| if os.path.exists(cache_dir): |
| for root, dirs, files in os.walk(cache_dir): |
| for f in files: |
| fpath = os.path.join(root, f) |
| try: |
| size = os.path.getsize(fpath) |
| os.remove(fpath) |
| result['deleted'] += 1 |
| result['freed'] += size |
| except (OSError, PermissionError): |
| pass |
| return result |
| |
| @classmethod |
| def clean_thumbnails(cls) -> dict: |
| result = {'deleted': 0, 'freed': 0} |
| thumb_dirs = [] |
| |
| if sys.platform == 'win32': |
| local = os.environ.get('LOCALAPPDATA', '') |
| thumb_dirs = [ |
| os.path.join(local, 'Microsoft', 'Windows', 'Explorer'), |
| ] |
| else: |
| thumb_dirs = [ |
| os.path.join(str(Path.home()), '.cache', 'thumbnails'), |
| os.path.join(str(Path.home()), '.thumbnails'), |
| ] |
| |
| for d in thumb_dirs: |
| if os.path.exists(d): |
| for root, dirs, files in os.walk(d): |
| for f in files: |
| fpath = os.path.join(root, f) |
| try: |
| size = os.path.getsize(fpath) |
| os.remove(fpath) |
| result['deleted'] += 1 |
| result['freed'] += size |
| except (OSError, PermissionError): |
| pass |
| return result |
|
|
|
|
| |
| |
| |
|
|
| class ScanWorker(QThread): |
| progress = pyqtSignal(int) |
| finished = pyqtSignal(list) |
| status = pyqtSignal(str) |
| |
| def __init__(self, root, threshold=None): |
| super().__init__() |
| self.root = root |
| self.threshold = threshold |
| |
| def run(self): |
| self.status.emit(f"Scanning {self.root}...") |
| results = DiskScanner.scan_large_files( |
| self.root, self.threshold, callback=lambda c: self.progress.emit(c) |
| ) |
| self.status.emit(f"Found {len(results)} files") |
| self.finished.emit(results) |
|
|
|
|
| class DuplicateWorker(QThread): |
| progress = pyqtSignal(str) |
| finished = pyqtSignal(list) |
| |
| def __init__(self, root, min_size): |
| super().__init__() |
| self.root = root |
| self.min_size = min_size |
| |
| def run(self): |
| results = DiskScanner.find_duplicates( |
| self.root, self.min_size, callback=lambda s: self.progress.emit(s) |
| ) |
| self.finished.emit(results) |
|
|
|
|
| class FolderAnalyzerWorker(QThread): |
| progress = pyqtSignal(int) |
| finished = pyqtSignal(list) |
| status = pyqtSignal(str) |
| |
| def __init__(self, root): |
| super().__init__() |
| self.root = root |
| |
| def run(self): |
| self.status.emit(f"Analyzing {self.root}...") |
| results = DiskScanner.analyze_folder( |
| self.root, callback=lambda c: self.progress.emit(c) |
| ) |
| self.finished.emit(results) |
|
|
|
|
| class CleanWorker(QThread): |
| progress = pyqtSignal(int) |
| finished = pyqtSignal(dict) |
| status = pyqtSignal(str) |
| |
| def __init__(self, tasks: List[str]): |
| super().__init__() |
| self.tasks = tasks |
| |
| def run(self): |
| total_result = {'deleted': 0, 'failed': 0, 'freed': 0} |
| |
| if 'temp' in self.tasks: |
| self.status.emit("Cleaning temp files...") |
| r = DiskCleaner.clean_temp_files(callback=lambda c: self.progress.emit(c)) |
| total_result['deleted'] += r['deleted'] |
| total_result['freed'] += r['freed'] |
| |
| if 'recycle' in self.tasks: |
| self.status.emit("Emptying recycle bin...") |
| DiskCleaner.empty_recycle_bin() |
| |
| if 'browser' in self.tasks: |
| self.status.emit("Cleaning browser cache...") |
| r = DiskCleaner.clean_browser_cache() |
| total_result['deleted'] += r['deleted'] |
| total_result['freed'] += r['freed'] |
| |
| if 'thumbnails' in self.tasks: |
| self.status.emit("Cleaning thumbnails...") |
| r = DiskCleaner.clean_thumbnails() |
| total_result['deleted'] += r['deleted'] |
| total_result['freed'] += r['freed'] |
| |
| if 'empty_dirs' in self.tasks: |
| self.status.emit("Removing empty directories...") |
| DiskCleaner.clean_empty_dirs(str(Path.home())) |
| |
| self.finished.emit(total_result) |
|
|
|
|
| |
| |
| |
|
|
| class DiskPanel(QWidget): |
| log_signal = pyqtSignal(str) |
| |
| def __init__(self): |
| super().__init__() |
| layout = QVBoxLayout(self) |
| layout.setContentsMargins(12, 12, 12, 12) |
| layout.setSpacing(12) |
| |
| title = QLabel("Disk Usage Overview") |
| title.setFont(QFont("Segoe UI", 16, QFont.Weight.Bold)) |
| title.setStyleSheet("color: #e94560; border: none;") |
| layout.addWidget(title) |
| |
| self.table = QTableWidget() |
| self.table.setColumnCount(5) |
| self.table.setHorizontalHeaderLabels(["Drive", "Total", "Used", "Free", "Usage"]) |
| self.table.horizontalHeader().setSectionResizeMode(QHeaderView.ResizeMode.Stretch) |
| self.table.verticalHeader().setVisible(False) |
| self.table.setSelectionBehavior(QAbstractItemView.SelectionBehavior.SelectRows) |
| self.table.setEditTriggers(QAbstractItemView.EditTrigger.NoEditTriggers) |
| self.table.setMinimumHeight(150) |
| layout.addWidget(self.table) |
| |
| |
| cards_layout = QHBoxLayout() |
| self.card_total = self._make_card("Total Storage", "---", "#e94560") |
| self.card_used = self._make_card("Used", "---", "#ffa500") |
| self.card_free = self._make_card("Free", "---", "#28a745") |
| self.card_health = self._make_card("Health", "---", "#00bfff") |
| cards_layout.addWidget(self.card_total) |
| cards_layout.addWidget(self.card_used) |
| cards_layout.addWidget(self.card_free) |
| cards_layout.addWidget(self.card_health) |
| layout.addLayout(cards_layout) |
| |
| |
| cat_title = QLabel("File Type Breakdown (by extension analysis)") |
| cat_title.setFont(QFont("Segoe UI", 12, QFont.Weight.Bold)) |
| cat_title.setStyleSheet("color: #e0e0e0; border: none;") |
| layout.addWidget(cat_title) |
| |
| self.category_table = QTableWidget() |
| self.category_table.setColumnCount(3) |
| self.category_table.setHorizontalHeaderLabels(["Category", "Count", "Estimated Size"]) |
| self.category_table.horizontalHeader().setSectionResizeMode(QHeaderView.ResizeMode.Stretch) |
| self.category_table.verticalHeader().setVisible(False) |
| self.category_table.setEditTriggers(QAbstractItemView.EditTrigger.NoEditTriggers) |
| self.category_table.setMaximumHeight(200) |
| layout.addWidget(self.category_table) |
| |
| btn_layout = QHBoxLayout() |
| refresh_btn = QPushButton("Refresh") |
| refresh_btn.setObjectName("primary") |
| refresh_btn.clicked.connect(self.refresh) |
| btn_layout.addWidget(refresh_btn) |
| btn_layout.addStretch() |
| layout.addLayout(btn_layout) |
| |
| self.refresh() |
| |
| def _make_card(self, label: str, value: str, color: str) -> QFrame: |
| frame = QFrame() |
| frame.setStyleSheet(f""" |
| QFrame {{ |
| background-color: #16213e; |
| border: 1px solid {color}44; |
| border-radius: 8px; |
| padding: 10px; |
| }} |
| """) |
| frame.setMinimumHeight(70) |
| fl = QVBoxLayout(frame) |
| fl.setSpacing(2) |
| lbl = QLabel(label) |
| lbl.setStyleSheet(f"color: #888; border: none; font-size: 11px;") |
| lbl.setAlignment(Qt.AlignmentFlag.AlignCenter) |
| fl.addWidget(lbl) |
| val = QLabel(value) |
| val.setObjectName("card_value") |
| val.setStyleSheet(f"color: {color}; border: none; font-size: 18px; font-weight: bold;") |
| val.setAlignment(Qt.AlignmentFlag.AlignCenter) |
| fl.addWidget(val) |
| return frame |
| |
| def _update_card(self, card: QFrame, value: str): |
| lbl = card.findChild(QLabel, "card_value") |
| if lbl: |
| lbl.setText(value) |
| |
| def refresh(self): |
| disks = DiskScanner.get_disks() |
| self.table.setRowCount(len(disks)) |
| |
| for i, d in enumerate(disks): |
| self.table.setItem(i, 0, QTableWidgetItem(d.drive)) |
| self.table.setItem(i, 1, QTableWidgetItem(format_size(d.total))) |
| self.table.setItem(i, 2, QTableWidgetItem(format_size(d.used))) |
| self.table.setItem(i, 3, QTableWidgetItem(format_size(d.free))) |
| |
| bar = QProgressBar() |
| bar.setValue(int(d.percent)) |
| bar.setFormat(f"{d.percent}%") |
| if d.percent > 90: |
| bar.setStyleSheet(""" |
| QProgressBar { border: 1px solid #0f3460; border-radius: 5px; background-color: #16213e; color: #fff; } |
| QProgressBar::chunk { background-color: #ff3333; border-radius: 5px; } |
| """) |
| elif d.percent > 75: |
| bar.setStyleSheet(""" |
| QProgressBar { border: 1px solid #0f3460; border-radius: 5px; background-color: #16213e; color: #fff; } |
| QProgressBar::chunk { background-color: #ffa500; border-radius: 5px; } |
| """) |
| else: |
| bar.setStyleSheet(""" |
| QProgressBar { border: 1px solid #0f3460; border-radius: 5px; background-color: #16213e; color: #fff; } |
| QProgressBar::chunk { background-color: #28a745; border-radius: 5px; } |
| """) |
| self.table.setCellWidget(i, 4, bar) |
| |
| total = sum(d.total for d in disks) |
| used = sum(d.used for d in disks) |
| free = sum(d.free for d in disks) |
| |
| self._update_card(self.card_total, format_size(total)) |
| self._update_card(self.card_used, format_size(used)) |
| self._update_card(self.card_free, format_size(free)) |
| |
| avg_percent = sum(d.percent for d in disks) / len(disks) if disks else 0 |
| if avg_percent < 60: |
| self._update_card(self.card_health, "Good") |
| elif avg_percent < 80: |
| self._update_card(self.card_health, "Warning") |
| else: |
| self._update_card(self.card_health, "Critical") |
| |
| self.log_signal.emit(f"Disk scan: {len(disks)} drives, {format_size(free)} free") |
|
|
|
|
| |
| |
| |
|
|
| class FileScannerPanel(QWidget): |
| log_signal = pyqtSignal(str) |
| |
| def __init__(self): |
| super().__init__() |
| layout = QVBoxLayout(self) |
| layout.setContentsMargins(12, 12, 12, 12) |
| layout.setSpacing(10) |
| |
| title = QLabel("Large File Scanner") |
| title.setFont(QFont("Segoe UI", 16, QFont.Weight.Bold)) |
| title.setStyleSheet("color: #e94560; border: none;") |
| layout.addWidget(title) |
| |
| |
| ctrl = QHBoxLayout() |
| self.path_input = QLineEdit(str(Path.home())) |
| self.path_input.setPlaceholderText("Directory to scan...") |
| ctrl.addWidget(self.path_input) |
| |
| browse_btn = QPushButton("Browse") |
| browse_btn.clicked.connect(self._browse) |
| ctrl.addWidget(browse_btn) |
| |
| ctrl.addWidget(QLabel("Min:")) |
| self.threshold_spin = QSpinBox() |
| self.threshold_spin.setRange(1, 10000) |
| self.threshold_spin.setValue(50) |
| self.threshold_spin.setSuffix(" MB") |
| ctrl.addWidget(self.threshold_spin) |
| |
| layout.addLayout(ctrl) |
| |
| |
| self.tree = QTreeWidget() |
| self.tree.setHeaderLabels(["File", "Size", "Category", "Modified", "Full Path"]) |
| self.tree.header().setSectionResizeMode(0, QHeaderView.ResizeMode.Stretch) |
| self.tree.setColumnWidth(1, 100) |
| self.tree.setColumnWidth(2, 80) |
| self.tree.setColumnWidth(3, 130) |
| self.tree.setColumnWidth(4, 250) |
| self.tree.setSelectionMode(QAbstractItemView.SelectionMode.ExtendedSelection) |
| self.tree.setAlternatingRowColors(True) |
| layout.addWidget(self.tree) |
| |
| |
| bottom = QHBoxLayout() |
| self.status_label = QLabel("Ready - click Scan to find large files") |
| self.status_label.setStyleSheet("color: #888; border: none;") |
| bottom.addWidget(self.status_label) |
| bottom.addStretch() |
| |
| self.scan_btn = QPushButton("Scan") |
| self.scan_btn.setObjectName("primary") |
| self.scan_btn.clicked.connect(self._scan) |
| bottom.addWidget(self.scan_btn) |
| |
| self.delete_btn = QPushButton("Delete Selected") |
| self.delete_btn.setObjectName("danger") |
| self.delete_btn.clicked.connect(self._delete) |
| self.delete_btn.setEnabled(False) |
| bottom.addWidget(self.delete_btn) |
| |
| layout.addLayout(bottom) |
| self._worker = None |
| |
| def _browse(self): |
| p = QFileDialog.getExistingDirectory(self, "Select Directory") |
| if p: |
| self.path_input.setText(p) |
| |
| def _scan(self): |
| path = self.path_input.text() |
| if not os.path.exists(path): |
| QMessageBox.warning(self, "Error", "Path does not exist") |
| return |
| self.tree.clear() |
| self.scan_btn.setEnabled(False) |
| self.status_label.setText("Scanning...") |
| |
| self._worker = ScanWorker(path, self.threshold_spin.value() * 1024 * 1024) |
| self._worker.finished.connect(self._on_done) |
| self._worker.status.connect(lambda s: self.status_label.setText(s)) |
| self._worker.start() |
| |
| def _on_done(self, results): |
| self.scan_btn.setEnabled(True) |
| self.delete_btn.setEnabled(len(results) > 0) |
| |
| colors = {'Video': '#ff6b6b', 'Audio': '#ffa500', 'Image': '#00d4aa', |
| 'Archive': '#6c63ff', 'Installer': '#ff00aa', 'Document': '#00bfff', |
| 'Database': '#ff8c00', 'Virtual': '#9932cc'} |
| |
| total = sum(e.size for e in results) |
| for entry in results: |
| item = QTreeWidgetItem() |
| item.setText(0, os.path.basename(entry.path)) |
| item.setText(1, format_size(entry.size)) |
| item.setText(2, entry.category) |
| item.setText(3, entry.modified) |
| item.setText(4, entry.path) |
| item.setData(0, Qt.ItemDataRole.UserRole, entry.path) |
| color = colors.get(entry.category, '#888') |
| item.setForeground(2, QColor(color)) |
| self.tree.addTopLevelItem(item) |
| |
| self.status_label.setText(f"{len(results)} files found ({format_size(total)})") |
| self.log_signal.emit(f"Large file scan: {len(results)} files, {format_size(total)}") |
| |
| def _delete(self): |
| items = self.tree.selectedItems() |
| if not items: |
| return |
| paths = [i.data(0, Qt.ItemDataRole.UserRole) for i in items] |
| reply = QMessageBox.question(self, "Delete", f"Permanently delete {len(paths)} file(s)?", |
| QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No) |
| if reply == QMessageBox.StandardButton.Yes: |
| r = DiskCleaner.delete_files(paths) |
| QMessageBox.information(self, "Done", f"Deleted: {r['deleted']} | Freed: {format_size(r['freed'])}") |
| self.log_signal.emit(f"Deleted {r['deleted']} files, freed {format_size(r['freed'])}") |
| self._scan() |
|
|
|
|
| |
| |
| |
|
|
| class DuplicatePanel(QWidget): |
| log_signal = pyqtSignal(str) |
| |
| def __init__(self): |
| super().__init__() |
| layout = QVBoxLayout(self) |
| layout.setContentsMargins(12, 12, 12, 12) |
| layout.setSpacing(10) |
| |
| title = QLabel("Duplicate File Finder") |
| title.setFont(QFont("Segoe UI", 16, QFont.Weight.Bold)) |
| title.setStyleSheet("color: #e94560; border: none;") |
| layout.addWidget(title) |
| |
| |
| ctrl = QHBoxLayout() |
| self.path_input = QLineEdit(str(Path.home())) |
| ctrl.addWidget(self.path_input) |
| browse_btn = QPushButton("Browse") |
| browse_btn.clicked.connect(self._browse) |
| ctrl.addWidget(browse_btn) |
| ctrl.addWidget(QLabel("Min size:")) |
| self.min_spin = QSpinBox() |
| self.min_spin.setRange(1, 1000) |
| self.min_spin.setValue(1) |
| self.min_spin.setSuffix(" MB") |
| ctrl.addWidget(self.min_spin) |
| layout.addLayout(ctrl) |
| |
| |
| self.tree = QTreeWidget() |
| self.tree.setHeaderLabels(["File", "Size", "Path"]) |
| self.tree.header().setSectionResizeMode(0, QHeaderView.ResizeMode.Stretch) |
| self.tree.setColumnWidth(1, 100) |
| self.tree.setSelectionMode(QAbstractItemView.SelectionMode.ExtendedSelection) |
| self.tree.setAlternatingRowColors(True) |
| layout.addWidget(self.tree) |
| |
| |
| bottom = QHBoxLayout() |
| self.status_label = QLabel("Find duplicate files wasting space") |
| self.status_label.setStyleSheet("color: #888; border: none;") |
| bottom.addWidget(self.status_label) |
| bottom.addStretch() |
| |
| self.scan_btn = QPushButton("Find Duplicates") |
| self.scan_btn.setObjectName("primary") |
| self.scan_btn.clicked.connect(self._scan) |
| bottom.addWidget(self.scan_btn) |
| |
| self.delete_btn = QPushButton("Delete Selected") |
| self.delete_btn.setObjectName("danger") |
| self.delete_btn.clicked.connect(self._delete) |
| self.delete_btn.setEnabled(False) |
| bottom.addWidget(self.delete_btn) |
| |
| layout.addLayout(bottom) |
| self._worker = None |
| |
| def _browse(self): |
| p = QFileDialog.getExistingDirectory(self, "Select Directory") |
| if p: |
| self.path_input.setText(p) |
| |
| def _scan(self): |
| path = self.path_input.text() |
| if not os.path.exists(path): |
| QMessageBox.warning(self, "Error", "Path not found") |
| return |
| self.tree.clear() |
| self.scan_btn.setEnabled(False) |
| self.status_label.setText("Scanning for duplicates...") |
| |
| self._worker = DuplicateWorker(path, self.min_spin.value() * 1024 * 1024) |
| self._worker.progress.connect(lambda s: self.status_label.setText(s)) |
| self._worker.finished.connect(self._on_done) |
| self._worker.start() |
| |
| def _on_done(self, groups): |
| self.scan_btn.setEnabled(True) |
| self.delete_btn.setEnabled(len(groups) > 0) |
| |
| total_waste = 0 |
| for group in groups[:100]: |
| waste = group.size * (len(group.files) - 1) |
| total_waste += waste |
| |
| parent = QTreeWidgetItem() |
| parent.setText(0, f"[{len(group.files)} copies] {os.path.basename(group.files[0])}") |
| parent.setText(1, format_size(group.size)) |
| parent.setForeground(0, QColor("#ffa500")) |
| |
| for fpath in group.files: |
| child = QTreeWidgetItem(parent) |
| child.setText(0, os.path.basename(fpath)) |
| child.setText(1, format_size(group.size)) |
| child.setText(2, fpath) |
| child.setData(0, Qt.ItemDataRole.UserRole, fpath) |
| |
| self.tree.addTopLevelItem(parent) |
| |
| self.status_label.setText(f"{len(groups)} duplicate groups | Wasted: {format_size(total_waste)}") |
| self.log_signal.emit(f"Duplicates: {len(groups)} groups, {format_size(total_waste)} wasted") |
| |
| def _delete(self): |
| items = self.tree.selectedItems() |
| paths = [i.data(0, Qt.ItemDataRole.UserRole) for i in items if i.data(0, Qt.ItemDataRole.UserRole)] |
| if not paths: |
| QMessageBox.information(self, "Info", "Select duplicate files to delete (keep at least one copy!)") |
| return |
| reply = QMessageBox.question(self, "Delete", f"Delete {len(paths)} duplicate file(s)?", |
| QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No) |
| if reply == QMessageBox.StandardButton.Yes: |
| r = DiskCleaner.delete_files(paths) |
| QMessageBox.information(self, "Done", f"Deleted: {r['deleted']} | Freed: {format_size(r['freed'])}") |
| self.log_signal.emit(f"Deleted {r['deleted']} duplicates, freed {format_size(r['freed'])}") |
| self._scan() |
|
|
|
|
| |
| |
| |
|
|
| class FolderAnalyzerPanel(QWidget): |
| log_signal = pyqtSignal(str) |
| |
| def __init__(self): |
| super().__init__() |
| layout = QVBoxLayout(self) |
| layout.setContentsMargins(12, 12, 12, 12) |
| layout.setSpacing(10) |
| |
| title = QLabel("Folder Size Analyzer") |
| title.setFont(QFont("Segoe UI", 16, QFont.Weight.Bold)) |
| title.setStyleSheet("color: #e94560; border: none;") |
| layout.addWidget(title) |
| |
| ctrl = QHBoxLayout() |
| self.path_input = QLineEdit(str(Path.home())) |
| ctrl.addWidget(self.path_input) |
| browse_btn = QPushButton("Browse") |
| browse_btn.clicked.connect(self._browse) |
| ctrl.addWidget(browse_btn) |
| self.analyze_btn = QPushButton("Analyze") |
| self.analyze_btn.setObjectName("primary") |
| self.analyze_btn.clicked.connect(self._analyze) |
| ctrl.addWidget(self.analyze_btn) |
| layout.addLayout(ctrl) |
| |
| self.table = QTableWidget() |
| self.table.setColumnCount(4) |
| self.table.setHorizontalHeaderLabels(["Folder", "Size", "Files", "% of Total"]) |
| self.table.horizontalHeader().setSectionResizeMode(QHeaderView.ResizeMode.Stretch) |
| self.table.verticalHeader().setVisible(False) |
| self.table.setEditTriggers(QAbstractItemView.EditTrigger.NoEditTriggers) |
| self.table.setSelectionBehavior(QAbstractItemView.SelectionBehavior.SelectRows) |
| self.table.setAlternatingRowColors(True) |
| layout.addWidget(self.table) |
| |
| self.status_label = QLabel("Select a directory and click Analyze") |
| self.status_label.setStyleSheet("color: #888; border: none;") |
| layout.addWidget(self.status_label) |
| |
| self._worker = None |
| |
| def _browse(self): |
| p = QFileDialog.getExistingDirectory(self, "Select Directory") |
| if p: |
| self.path_input.setText(p) |
| |
| def _analyze(self): |
| path = self.path_input.text() |
| if not os.path.exists(path): |
| QMessageBox.warning(self, "Error", "Path not found") |
| return |
| self.analyze_btn.setEnabled(False) |
| self.status_label.setText("Analyzing folders...") |
| |
| self._worker = FolderAnalyzerWorker(path) |
| self._worker.finished.connect(self._on_done) |
| self._worker.status.connect(lambda s: self.status_label.setText(s)) |
| self._worker.start() |
| |
| def _on_done(self, folders): |
| self.analyze_btn.setEnabled(True) |
| self.table.setRowCount(len(folders)) |
| |
| total = sum(f.size for f in folders) |
| |
| for i, f in enumerate(folders): |
| self.table.setItem(i, 0, QTableWidgetItem(os.path.basename(f.path))) |
| self.table.setItem(i, 1, QTableWidgetItem(format_size(f.size))) |
| self.table.setItem(i, 2, QTableWidgetItem(f"{f.file_count:,}")) |
| |
| bar = QProgressBar() |
| bar.setValue(int(f.percent)) |
| bar.setFormat(f"{f.percent}%") |
| self.table.setCellWidget(i, 3, bar) |
| |
| self.status_label.setText(f"{len(folders)} folders analyzed | Total: {format_size(total)}") |
| self.log_signal.emit(f"Folder analysis: {len(folders)} folders, {format_size(total)}") |
|
|
|
|
| |
| |
| |
|
|
| class CleaningPanel(QWidget): |
| log_signal = pyqtSignal(str) |
| |
| def __init__(self): |
| super().__init__() |
| layout = QVBoxLayout(self) |
| layout.setContentsMargins(12, 12, 12, 12) |
| layout.setSpacing(12) |
| |
| title = QLabel("System Cleaner") |
| title.setFont(QFont("Segoe UI", 16, QFont.Weight.Bold)) |
| title.setStyleSheet("color: #e94560; border: none;") |
| layout.addWidget(title) |
| |
| |
| info_frame = QFrame() |
| info_frame.setStyleSheet("background-color: #16213e; border: 1px solid #0f3460; border-radius: 8px; padding: 12px;") |
| info_layout = QVBoxLayout(info_frame) |
| self.temp_info = QLabel("Analyzing...") |
| self.temp_info.setStyleSheet("color: #e0e0e0; border: none; font-size: 13px;") |
| info_layout.addWidget(self.temp_info) |
| layout.addWidget(info_frame) |
| |
| |
| opts = QGroupBox("Select what to clean") |
| opts_layout = QVBoxLayout(opts) |
| |
| self.chk_temp = QCheckBox("Temporary Files (User + System Temp folders)") |
| self.chk_temp.setChecked(True) |
| opts_layout.addWidget(self.chk_temp) |
| |
| self.chk_recycle = QCheckBox("Recycle Bin / Trash") |
| self.chk_recycle.setChecked(True) |
| opts_layout.addWidget(self.chk_recycle) |
| |
| self.chk_browser = QCheckBox("Browser Cache (Chrome, Edge, Firefox)") |
| self.chk_browser.setChecked(False) |
| opts_layout.addWidget(self.chk_browser) |
| |
| self.chk_thumbs = QCheckBox("Thumbnail Cache") |
| self.chk_thumbs.setChecked(True) |
| opts_layout.addWidget(self.chk_thumbs) |
| |
| self.chk_empty = QCheckBox("Empty Directories") |
| self.chk_empty.setChecked(False) |
| opts_layout.addWidget(self.chk_empty) |
| |
| layout.addWidget(opts) |
| |
| |
| self.progress = QProgressBar() |
| self.progress.setValue(0) |
| self.progress.setFormat("Ready") |
| layout.addWidget(self.progress) |
| |
| self.result_label = QLabel("") |
| self.result_label.setStyleSheet("color: #28a745; border: none; font-weight: bold; font-size: 13px;") |
| layout.addWidget(self.result_label) |
| |
| |
| btn = QHBoxLayout() |
| self.analyze_btn = QPushButton("Analyze") |
| self.analyze_btn.setObjectName("primary") |
| self.analyze_btn.clicked.connect(self._analyze) |
| btn.addWidget(self.analyze_btn) |
| |
| self.clean_btn = QPushButton("Clean Now") |
| self.clean_btn.setObjectName("danger") |
| self.clean_btn.clicked.connect(self._clean) |
| btn.addWidget(self.clean_btn) |
| btn.addStretch() |
| layout.addLayout(btn) |
| layout.addStretch() |
| |
| QTimer.singleShot(500, self._analyze) |
| self._worker = None |
| |
| def _analyze(self): |
| size, count = DiskScanner.scan_temp_size() |
| paths = DiskScanner.get_temp_paths() |
| self.temp_info.setText( |
| f"Cleanable: ~{format_size(size)} ({count:,} files)\n" |
| f"Locations: {len(paths)} temp directories found" |
| ) |
| |
| def _clean(self): |
| reply = QMessageBox.question(self, "Confirm", "Clean selected items?", |
| QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No) |
| if reply != QMessageBox.StandardButton.Yes: |
| return |
| |
| tasks = [] |
| if self.chk_temp.isChecked(): |
| tasks.append('temp') |
| if self.chk_recycle.isChecked(): |
| tasks.append('recycle') |
| if self.chk_browser.isChecked(): |
| tasks.append('browser') |
| if self.chk_thumbs.isChecked(): |
| tasks.append('thumbnails') |
| if self.chk_empty.isChecked(): |
| tasks.append('empty_dirs') |
| |
| if not tasks: |
| return |
| |
| self.clean_btn.setEnabled(False) |
| self.progress.setFormat("Cleaning...") |
| self.progress.setValue(50) |
| |
| self._worker = CleanWorker(tasks) |
| self._worker.finished.connect(self._on_done) |
| self._worker.status.connect(lambda s: self.progress.setFormat(s)) |
| self._worker.start() |
| |
| def _on_done(self, result): |
| self.clean_btn.setEnabled(True) |
| self.progress.setValue(100) |
| self.progress.setFormat("Done!") |
| self.result_label.setText(f"Cleaned {result['deleted']:,} files | Freed {format_size(result['freed'])}") |
| self.log_signal.emit(f"Cleaning done: {result['deleted']} files, {format_size(result['freed'])} freed") |
| QTimer.singleShot(1000, self._analyze) |
|
|
|
|
| |
| |
| |
|
|
| class LogPanel(QWidget): |
| def __init__(self): |
| super().__init__() |
| layout = QVBoxLayout(self) |
| layout.setContentsMargins(12, 12, 12, 12) |
| |
| title = QLabel("Activity Log") |
| title.setFont(QFont("Segoe UI", 16, QFont.Weight.Bold)) |
| title.setStyleSheet("color: #e94560; border: none;") |
| layout.addWidget(title) |
| |
| self.log = QTextEdit() |
| self.log.setReadOnly(True) |
| self.log.setFont(QFont("Consolas", 10)) |
| layout.addWidget(self.log) |
| |
| btn = QHBoxLayout() |
| clear_btn = QPushButton("Clear") |
| clear_btn.clicked.connect(self.log.clear) |
| btn.addWidget(clear_btn) |
| |
| export_btn = QPushButton("Export") |
| export_btn.clicked.connect(self._export) |
| btn.addWidget(export_btn) |
| btn.addStretch() |
| layout.addLayout(btn) |
| |
| self.add("Storage Cleaner Pro started") |
| |
| def add(self, msg: str): |
| ts = datetime.now().strftime("%H:%M:%S") |
| self.log.append(f"<span style='color:#888'>[{ts}]</span> <span style='color:#e0e0e0'>{msg}</span>") |
| |
| def _export(self): |
| path, _ = QFileDialog.getSaveFileName(self, "Export Log", "cleaner_log.txt", "Text (*.txt)") |
| if path: |
| with open(path, 'w') as f: |
| f.write(self.log.toPlainText()) |
|
|
|
|
| |
| |
| |
|
|
| DARK_QSS = """ |
| QMainWindow { background-color: #1a1a2e; color: #e0e0e0; } |
| QMenuBar { background-color: #16213e; color: #e0e0e0; border-bottom: 1px solid #0f3460; } |
| QMenuBar::item { padding: 5px 15px; } |
| QMenuBar::item:selected { background-color: #0f3460; } |
| QMenu { background-color: #16213e; border: 1px solid #0f3460; color: #e0e0e0; } |
| QMenu::item { padding: 5px 30px 5px 20px; } |
| QMenu::item:selected { background-color: #0f3460; } |
| QLabel { color: #e0e0e0; } |
| QGroupBox { font-weight: bold; border: 1px solid #0f3460; border-radius: 5px; margin-top: 10px; padding-top: 10px; color: #e0e0e0; } |
| QGroupBox::title { subcontrol-origin: margin; left: 10px; padding: 0 3px; } |
| QProgressBar { border: 1px solid #0f3460; border-radius: 5px; text-align: center; background-color: #16213e; color: #e0e0e0; height: 20px; } |
| QProgressBar::chunk { background-color: #e94560; border-radius: 5px; } |
| QPushButton { background-color: #0f3460; border: 1px solid #0f3460; border-radius: 5px; padding: 8px 18px; color: #e0e0e0; font-weight: bold; } |
| QPushButton:hover { background-color: #1a4a8a; border-color: #e94560; } |
| QPushButton:pressed { background-color: #e94560; } |
| QPushButton:disabled { background-color: #16213e; color: #555; } |
| QPushButton#primary { background-color: #e94560; border: 1px solid #c72c41; color: #fff; } |
| QPushButton#primary:hover { background-color: #c72c41; } |
| QPushButton#danger { background-color: #c72c41; border: 1px solid #e94560; color: #fff; } |
| QPushButton#danger:hover { background-color: #e94560; } |
| QTreeWidget, QTableWidget { background-color: #16213e; border: 1px solid #0f3460; color: #e0e0e0; alternate-background-color: #1a1a2e; } |
| QTreeWidget::item:selected, QTableWidget::item:selected { background-color: #0f3460; } |
| QHeaderView::section { background-color: #0f3460; color: #e0e0e0; padding: 5px; border: 1px solid #16213e; font-weight: bold; } |
| QTextEdit { background-color: #16213e; border: 1px solid #0f3460; color: #e0e0e0; } |
| QLineEdit { background-color: #16213e; border: 1px solid #0f3460; border-radius: 5px; padding: 6px; color: #e0e0e0; } |
| QLineEdit:focus { border-color: #e94560; } |
| QTabWidget::pane { border: 1px solid #0f3460; } |
| QTabBar::tab { background-color: #16213e; color: #888; padding: 10px 20px; border: 1px solid #0f3460; border-bottom: none; border-top-left-radius: 5px; border-top-right-radius: 5px; font-weight: bold; } |
| QTabBar::tab:selected { background-color: #0f3460; color: #e94560; } |
| QTabBar::tab:hover { color: #e0e0e0; } |
| QScrollBar:vertical { background-color: #16213e; width: 10px; } |
| QScrollBar::handle:vertical { background-color: #0f3460; min-height: 20px; border-radius: 5px; } |
| QScrollBar::handle:vertical:hover { background-color: #e94560; } |
| QScrollBar::add-line:vertical, QScrollBar::sub-line:vertical { height: 0; } |
| QCheckBox { color: #e0e0e0; spacing: 8px; } |
| QCheckBox::indicator { width: 18px; height: 18px; border: 2px solid #0f3460; border-radius: 4px; background-color: #16213e; } |
| QCheckBox::indicator:checked { background-color: #e94560; border-color: #e94560; } |
| QSpinBox { background-color: #16213e; border: 1px solid #0f3460; border-radius: 5px; padding: 5px; color: #e0e0e0; } |
| QStatusBar { background-color: #16213e; color: #888; border-top: 1px solid #0f3460; } |
| QFrame { border: none; } |
| QToolTip { background-color: #16213e; color: #e0e0e0; border: 1px solid #e94560; padding: 5px; } |
| QMessageBox { background-color: #1a1a2e; } |
| """ |
|
|
|
|
| class StorageCleanerWindow(QMainWindow): |
| def __init__(self): |
| super().__init__() |
| self.setWindowTitle("Storage Cleaner Pro") |
| self.setMinimumSize(1100, 750) |
| self.resize(1300, 850) |
| |
| self._setup_menu() |
| self._setup_ui() |
| self._setup_statusbar() |
| |
| def _setup_menu(self): |
| menubar = self.menuBar() |
| |
| file_menu = menubar.addMenu("File") |
| refresh = QAction("Refresh Disks", self) |
| refresh.setShortcut("F5") |
| refresh.triggered.connect(lambda: self.disk_panel.refresh()) |
| file_menu.addAction(refresh) |
| file_menu.addSeparator() |
| exit_a = QAction("Exit", self) |
| exit_a.setShortcut("Ctrl+Q") |
| exit_a.triggered.connect(self.close) |
| file_menu.addAction(exit_a) |
| |
| tools_menu = menubar.addMenu("Tools") |
| tools_menu.addAction(QAction("Large Files", self, triggered=lambda: self.tabs.setCurrentIndex(1))) |
| tools_menu.addAction(QAction("Duplicates", self, triggered=lambda: self.tabs.setCurrentIndex(2))) |
| tools_menu.addAction(QAction("Folder Sizes", self, triggered=lambda: self.tabs.setCurrentIndex(3))) |
| tools_menu.addAction(QAction("Clean", self, triggered=lambda: self.tabs.setCurrentIndex(4))) |
| |
| help_menu = menubar.addMenu("Help") |
| help_menu.addAction(QAction("About", self, triggered=self._about)) |
| |
| def _setup_ui(self): |
| central = QWidget() |
| self.setCentralWidget(central) |
| layout = QVBoxLayout(central) |
| layout.setContentsMargins(0, 0, 0, 0) |
| |
| self.tabs = QTabWidget() |
| |
| self.disk_panel = DiskPanel() |
| self.tabs.addTab(self.disk_panel, "Disks") |
| |
| self.scanner_panel = FileScannerPanel() |
| self.tabs.addTab(self.scanner_panel, "Large Files") |
| |
| self.duplicate_panel = DuplicatePanel() |
| self.tabs.addTab(self.duplicate_panel, "Duplicates") |
| |
| self.folder_panel = FolderAnalyzerPanel() |
| self.tabs.addTab(self.folder_panel, "Folders") |
| |
| self.cleaning_panel = CleaningPanel() |
| self.tabs.addTab(self.cleaning_panel, "Clean") |
| |
| self.log_panel = LogPanel() |
| self.tabs.addTab(self.log_panel, "Log") |
| |
| layout.addWidget(self.tabs) |
| |
| |
| self.disk_panel.log_signal.connect(self.log_panel.add) |
| self.scanner_panel.log_signal.connect(self.log_panel.add) |
| self.duplicate_panel.log_signal.connect(self.log_panel.add) |
| self.folder_panel.log_signal.connect(self.log_panel.add) |
| self.cleaning_panel.log_signal.connect(self.log_panel.add) |
| |
| def _setup_statusbar(self): |
| self.statusBar().showMessage("Ready") |
| self._timer = QTimer(self) |
| self._timer.timeout.connect(self._tick) |
| self._timer.start(10000) |
| self._tick() |
| |
| def _tick(self): |
| disks = DiskScanner.get_disks() |
| if disks: |
| free = sum(d.free for d in disks) |
| self.statusBar().showMessage( |
| f"Free: {format_size(free)} | {platform.system()} {platform.release()} | {datetime.now().strftime('%H:%M')}" |
| ) |
| |
| def _about(self): |
| QMessageBox.about(self, "About", |
| "Storage Cleaner Pro v2.0\n\n" |
| "Features:\n" |
| "- Disk usage overview with health indicator\n" |
| "- Large file scanner with category detection\n" |
| "- Duplicate file finder (hash-based)\n" |
| "- Folder size analyzer\n" |
| "- System cleaner (temp, cache, trash, thumbnails)\n" |
| "- Activity log with export\n\n" |
| "Built with PyQt6" |
| ) |
|
|
|
|
| |
| |
| |
|
|
| def main(): |
| app = QApplication(sys.argv) |
| app.setStyle("Fusion") |
| app.setStyleSheet(DARK_QSS) |
| app.setFont(QFont("Segoe UI", 10)) |
| |
| window = StorageCleanerWindow() |
| window.show() |
| sys.exit(app.exec()) |
|
|
|
|
| if __name__ == "__main__": |
| main() |
|
|