"""SysDevCleaner — графический интерфейс.""" from __future__ import annotations import os import queue import subprocess import sys import threading import time import tkinter as tk from tkinter import filedialog, messagebox, ttk from ..core import cleaner as cleanmod from ..core import programs as progmod from ..core.advisor import default_selection, plan_summary, report_text from ..core.model import Category, Finding, Risk, ScanResult from ..core.scanner import ScanOptions, default_project_roots, full_scan from ..core.util import (Cancel, disk_usage, drives, human, is_admin, open_in_explorer) from .theme_tk import Theme, tag_colors CHECKED = "☑" UNCHECKED = "☐" PARTIAL = "◪" RISK_TAG = {Risk.SAFE: "safe", Risk.LOW: "low", Risk.MEDIUM: "medium", Risk.HIGH: "high"} ALL_CATEGORIES = [ Category.WINDOWS, Category.BROWSERS, Category.DEV_CACHE, Category.DEV_PROJECT, Category.APPS, Category.LOGS, Category.PACKAGE_MANAGERS, ] class App(tk.Tk): def __init__(self) -> None: super().__init__() self.title("SysDevCleaner — анализ и очистка системы") self.geometry("1280x820") self.minsize(1040, 660) self.theme = Theme(self, dark=True) self.tags = tag_colors(self.theme) self.result: ScanResult | None = None self.cancel = Cancel() self.q: queue.Queue = queue.Queue() self.worker: threading.Thread | None = None self.busy = False self.items: dict[str, Finding] = {} # iid дерева -> находка self.cat_nodes: dict[Category, str] = {} self.prog_rows: dict[str, object] = {} # настройки self.var_cat = {c: tk.BooleanVar(value=True) for c in ALL_CATEGORIES} self.var_big = tk.BooleanVar(value=False) self.var_big_mb = tk.StringVar(value="200") self.var_dup = tk.BooleanVar(value=False) self.var_dup_mb = tk.StringVar(value="10") self.var_progs = tk.BooleanVar(value=True) self.var_prog_size = tk.BooleanVar(value=True) self.var_recycle = tk.BooleanVar(value=True) self.var_dry = tk.BooleanVar(value=False) self.var_status = tk.StringVar(value="Готов к сканированию") self.var_sel = tk.StringVar(value="Ничего не выбрано") self.roots: list[str] = default_project_roots() self._build() self.after(120, self._poll) self.protocol("WM_DELETE_WINDOW", self._on_close) self._refresh_disks() self._load_journals() # ------------------------------------------------------------ разметка -- def _build(self) -> None: self._build_header() self.nb = ttk.Notebook(self) self.nb.pack(fill="both", expand=True, padx=14, pady=(4, 0)) self._build_overview() self._build_clean() self._build_programs() self._build_settings() self._build_journal() self._build_status() def _build_header(self) -> None: c = self.theme.c head = ttk.Frame(self) head.pack(fill="x", padx=16, pady=(14, 6)) left = ttk.Frame(head) left.pack(side="left", fill="x", expand=True) ttk.Label(left, text="SysDevCleaner", style="H1.TLabel").pack(anchor="w") sub = "Полный анализ кешей, мусора и остатков разработки" if is_admin(): sub += " · права администратора получены" else: sub += " · запущено без прав администратора" ttk.Label(left, text=sub, style="Dim.TLabel").pack(anchor="w", pady=(2, 0)) right = ttk.Frame(head) right.pack(side="right") self.btn_scan = ttk.Button(right, text="Сканировать", style="Accent.TButton", command=self.start_scan) self.btn_scan.pack(side="left", padx=(0, 8)) self.btn_stop = ttk.Button(right, text="Стоп", command=self.stop, state="disabled") self.btn_stop.pack(side="left", padx=(0, 8)) ttk.Button(right, text="Тема", command=self._toggle_theme).pack(side="left") def _build_status(self) -> None: bar = ttk.Frame(self) bar.pack(fill="x", side="bottom", padx=16, pady=(6, 12)) self.pb = ttk.Progressbar(bar, style="Thin.Horizontal.TProgressbar", mode="determinate", maximum=1000) self.pb.pack(fill="x") row = ttk.Frame(bar) row.pack(fill="x", pady=(6, 0)) ttk.Label(row, textvariable=self.var_status, style="Dim.TLabel").pack(side="left") ttk.Label(row, textvariable=self.var_sel, style="Dim.TLabel").pack(side="right") # ------------------------------------------------------------- Обзор ---- def _build_overview(self) -> None: tab = ttk.Frame(self.nb) self.nb.add(tab, text=" Обзор ") outer = ttk.Frame(tab) outer.pack(fill="both", expand=True, padx=14, pady=14) # карточки-итоги self.cards = ttk.Frame(outer) self.cards.pack(fill="x") self.card_vals: dict[str, tk.StringVar] = {} for key, title in (("total", "Найдено мусора"), ("safe", "Безопасно удалить"), ("dev", "Кеши разработки"), ("free", "Свободно на C:")): card = ttk.Frame(self.cards, style="Card.TFrame", padding=16) card.pack(side="left", fill="both", expand=True, padx=(0, 10)) v = tk.StringVar(value="—") self.card_vals[key] = v ttk.Label(card, textvariable=v, style="CardBig.TLabel").pack(anchor="w") ttk.Label(card, text=title, style="CardDim.TLabel").pack(anchor="w", pady=(4, 0)) body = ttk.Frame(outer) body.pack(fill="both", expand=True, pady=(14, 0)) # левая колонка: диски + категории left = ttk.Frame(body) left.pack(side="left", fill="both", expand=True, padx=(0, 12)) ttk.Label(left, text="Диски", style="H2.TLabel").pack(anchor="w") self.disk_box = ttk.Frame(left) self.disk_box.pack(fill="x", pady=(8, 16)) ttk.Label(left, text="По категориям", style="H2.TLabel").pack(anchor="w") self.cat_box = ttk.Frame(left) self.cat_box.pack(fill="both", expand=True, pady=(8, 0)) # правая колонка: рекомендации right = ttk.Frame(body) right.pack(side="left", fill="both", expand=True) head = ttk.Frame(right) head.pack(fill="x") ttk.Label(head, text="Рекомендации", style="H2.TLabel").pack(side="left") ttk.Button(head, text="Сохранить отчёт…", command=self.save_report).pack(side="right") wrap = ttk.Frame(right) wrap.pack(fill="both", expand=True, pady=(8, 0)) c = self.theme.c self.rec_text = tk.Text(wrap, wrap="word", bg=c["bg2"], fg=c["fg"], insertbackground=c["fg"], relief="flat", padx=14, pady=12, font=("Segoe UI", 10), spacing1=2, spacing3=4, highlightthickness=0) sb = ttk.Scrollbar(wrap, orient="vertical", command=self.rec_text.yview) self.rec_text.configure(yscrollcommand=sb.set) sb.pack(side="right", fill="y") self.rec_text.pack(side="left", fill="both", expand=True) self.rec_text.tag_configure("h", font=("Segoe UI Semibold", 11), foreground=c["fg"], spacing1=10, spacing3=4) self.rec_text.tag_configure("imp", foreground=c["warn"], font=("Segoe UI Semibold", 11), spacing1=10, spacing3=4) self.rec_text.tag_configure("gain", foreground=c["ok"], font=("Segoe UI Semibold", 10)) self.rec_text.tag_configure("body", foreground=c["fg_dim"], lmargin1=14, lmargin2=14) self.rec_text.insert("1.0", "Нажмите «Сканировать», чтобы проанализировать систему.\n\n" "Что будет проверено:\n" " • временные файлы, дампы, отчёты об ошибках Windows;\n" " • кеши всех установленных браузеров;\n" " • кеши npm, pip, Gradle, Maven, Cargo, NuGet, Go и др.;\n" " • node_modules, target, build, __pycache__ в проектах;\n" " • кеши IDE, игровых лаунчеров и мессенджеров;\n" " • список установленных программ с реальным размером.\n", "body") self.rec_text.configure(state="disabled") # ------------------------------------------------------------ Очистка --- def _build_clean(self) -> None: tab = ttk.Frame(self.nb) self.nb.add(tab, text=" Очистка ") outer = ttk.Frame(tab) outer.pack(fill="both", expand=True, padx=14, pady=14) bar = ttk.Frame(outer) bar.pack(fill="x", pady=(0, 10)) ttk.Button(bar, text="Отметить безопасное", command=lambda: self._bulk("safe")).pack(side="left", padx=(0, 6)) ttk.Button(bar, text="Отметить безопасное и низкий риск", command=lambda: self._bulk("low")).pack(side="left", padx=(0, 6)) ttk.Button(bar, text="Снять всё", command=lambda: self._bulk("none")).pack(side="left", padx=(0, 6)) ttk.Button(bar, text="Развернуть", command=lambda: self._expand(True)).pack(side="left") ttk.Button(bar, text="Свернуть", command=lambda: self._expand(False)).pack(side="left", padx=(6, 0)) ttk.Label(bar, text="Фильтр:", style="Dim.TLabel").pack(side="left", padx=(18, 6)) self.var_filter = tk.StringVar() ent = ttk.Entry(bar, textvariable=self.var_filter, width=26) ent.pack(side="left") self.var_filter.trace_add("write", lambda *_: self._refill_tree()) self.var_hide_small = tk.BooleanVar(value=False) ttk.Checkbutton(bar, text="Скрыть мельче 10 МБ", variable=self.var_hide_small, command=self._refill_tree).pack(side="left", padx=(14, 0)) pane = ttk.PanedWindow(outer, orient="vertical") pane.pack(fill="both", expand=True) top = ttk.Frame(pane) pane.add(top, weight=4) cols = ("chk", "size", "risk", "age", "path") self.tree = ttk.Treeview(top, columns=cols, show="tree headings", selectmode="extended") self.tree.heading("#0", text="Объект") self.tree.heading("chk", text="") self.tree.heading("size", text="Размер") self.tree.heading("risk", text="Риск") self.tree.heading("age", text="Изменён") self.tree.heading("path", text="Путь") self.tree.column("#0", width=380, minwidth=220, stretch=False) self.tree.column("chk", width=40, minwidth=40, anchor="center", stretch=False) self.tree.column("size", width=100, anchor="e", stretch=False) self.tree.column("risk", width=120, anchor="w", stretch=False) self.tree.column("age", width=110, anchor="e", stretch=False) self.tree.column("path", width=520, anchor="w") vsb = ttk.Scrollbar(top, orient="vertical", command=self.tree.yview) hsb = ttk.Scrollbar(top, orient="horizontal", command=self.tree.xview) self.tree.configure(yscrollcommand=vsb.set, xscrollcommand=hsb.set) vsb.pack(side="right", fill="y") hsb.pack(side="bottom", fill="x") self.tree.pack(side="left", fill="both", expand=True) for name, cfg in self.tags.items(): self.tree.tag_configure(name, **cfg) self.tree.bind("", self._on_tree_click) self.tree.bind("", self._on_space) self.tree.bind("<>", self._on_tree_select) self.tree.bind("", self._on_tree_double) self.tree.bind("", self._on_tree_menu) self.menu = tk.Menu(self, tearoff=0) self.menu.add_command(label="Открыть в проводнике", command=self._open_selected) self.menu.add_command(label="Скопировать путь", command=self._copy_path) self.menu.add_separator() self.menu.add_command(label="Отметить", command=lambda: self._set_selected(True)) self.menu.add_command(label="Снять отметку", command=lambda: self._set_selected(False)) bottom = ttk.Frame(pane) pane.add(bottom, weight=1) c = self.theme.c self.detail = tk.Text(bottom, height=7, wrap="word", bg=c["bg2"], fg=c["fg_dim"], relief="flat", padx=14, pady=10, font=("Segoe UI", 10), highlightthickness=0) self.detail.pack(fill="both", expand=True) self.detail.insert("1.0", "Выделите строку, чтобы увидеть подробности.") self.detail.configure(state="disabled") act = ttk.Frame(outer) act.pack(fill="x", pady=(10, 0)) ttk.Checkbutton(act, text="Удалять в Корзину (можно восстановить)", variable=self.var_recycle).pack(side="left") ttk.Checkbutton(act, text="Пробный запуск (ничего не удалять)", variable=self.var_dry).pack(side="left", padx=(18, 0)) self.btn_clean = ttk.Button(act, text="Очистить выбранное", style="Danger.TButton", command=self.do_clean, state="disabled") self.btn_clean.pack(side="right") # ----------------------------------------------------------- Программы -- def _build_programs(self) -> None: tab = ttk.Frame(self.nb) self.nb.add(tab, text=" Программы ") outer = ttk.Frame(tab) outer.pack(fill="both", expand=True, padx=14, pady=14) bar = ttk.Frame(outer) bar.pack(fill="x", pady=(0, 10)) ttk.Label(bar, text="Установленные программы, отсортированные по занимаемому месту. " "Двойной клик открывает папку установки.", style="Dim.TLabel").pack(side="left") ttk.Button(bar, text="Удалить программу…", command=self._uninstall).pack(side="right") ttk.Button(bar, text="Открыть «Приложения и возможности»", command=self._open_appwiz).pack(side="right", padx=(0, 8)) cols = ("size", "idle", "version", "publisher", "location") self.ptree = ttk.Treeview(outer, columns=cols, show="tree headings") self.ptree.heading("#0", text="Программа") self.ptree.heading("size", text="Размер") self.ptree.heading("idle", text="Простой") self.ptree.heading("version", text="Версия") self.ptree.heading("publisher", text="Издатель") self.ptree.heading("location", text="Расположение") self.ptree.column("#0", width=340, stretch=False) self.ptree.column("size", width=100, anchor="e", stretch=False) self.ptree.column("idle", width=100, anchor="e", stretch=False) self.ptree.column("version", width=120, stretch=False) self.ptree.column("publisher", width=200, stretch=False) self.ptree.column("location", width=420) vsb = ttk.Scrollbar(outer, orient="vertical", command=self.ptree.yview) self.ptree.configure(yscrollcommand=vsb.set) vsb.pack(side="right", fill="y") self.ptree.pack(fill="both", expand=True) for name, cfg in self.tags.items(): self.ptree.tag_configure(name, **cfg) self.ptree.bind("", self._open_program_folder) # ----------------------------------------------------------- Настройки -- def _build_settings(self) -> None: tab = ttk.Frame(self.nb) self.nb.add(tab, text=" Настройки ") outer = ttk.Frame(tab) outer.pack(fill="both", expand=True, padx=14, pady=14) left = ttk.Frame(outer) left.pack(side="left", fill="both", expand=True, padx=(0, 16)) ttk.Label(left, text="Что искать", style="H2.TLabel").pack(anchor="w") box = ttk.Frame(left, style="Card.TFrame", padding=14) box.pack(fill="x", pady=(8, 16)) for cat in ALL_CATEGORIES: ttk.Checkbutton(box, text=f"{cat.icon} {cat.label}", variable=self.var_cat[cat], style="Card.TCheckbutton").pack(anchor="w", pady=2) ttk.Label(left, text="Дополнительный анализ", style="H2.TLabel").pack(anchor="w") box2 = ttk.Frame(left, style="Card.TFrame", padding=14) box2.pack(fill="x", pady=(8, 16)) r1 = ttk.Frame(box2, style="Card.TFrame") r1.pack(fill="x", pady=2) ttk.Checkbutton(r1, text="Искать крупные файлы, больше", variable=self.var_big, style="Card.TCheckbutton").pack(side="left") ttk.Entry(r1, textvariable=self.var_big_mb, width=6).pack(side="left", padx=6) ttk.Label(r1, text="МБ", style="CardDim.TLabel").pack(side="left") r2 = ttk.Frame(box2, style="Card.TFrame") r2.pack(fill="x", pady=2) ttk.Checkbutton(r2, text="Искать дубликаты, больше", variable=self.var_dup, style="Card.TCheckbutton").pack(side="left") ttk.Entry(r2, textvariable=self.var_dup_mb, width=6).pack(side="left", padx=6) ttk.Label(r2, text="МБ (медленно)", style="CardDim.TLabel").pack(side="left") ttk.Checkbutton(box2, text="Собирать список установленных программ", variable=self.var_progs, style="Card.TCheckbutton").pack(anchor="w", pady=2) ttk.Checkbutton(box2, text="Измерять реальный размер программ на диске (дольше)", variable=self.var_prog_size, style="Card.TCheckbutton").pack(anchor="w", pady=2) right = ttk.Frame(outer) right.pack(side="left", fill="both", expand=True) ttk.Label(right, text="Где искать проекты и крупные файлы", style="H2.TLabel").pack(anchor="w") ttk.Label(right, text="По умолчанию — папки разработки в профиле и несистемные диски.", style="Dim.TLabel").pack(anchor="w", pady=(2, 8)) self.roots_list = tk.Listbox(right, height=14, bg=self.theme.c["bg2"], fg=self.theme.c["fg"], relief="flat", selectbackground=self.theme.c["sel"], highlightthickness=0, font=("Segoe UI", 10)) self.roots_list.pack(fill="both", expand=True) for r in self.roots: self.roots_list.insert("end", r) rb = ttk.Frame(right) rb.pack(fill="x", pady=(8, 0)) ttk.Button(rb, text="Добавить папку…", command=self._add_root).pack(side="left") ttk.Button(rb, text="Убрать", command=self._del_root).pack(side="left", padx=6) ttk.Button(rb, text="По умолчанию", command=self._reset_roots).pack(side="left") # ------------------------------------------------------------- Журнал --- def _build_journal(self) -> None: tab = ttk.Frame(self.nb) self.nb.add(tab, text=" Журнал ") outer = ttk.Frame(tab) outer.pack(fill="both", expand=True, padx=14, pady=14) ttk.Label(outer, text="История очисток", style="H2.TLabel").pack(anchor="w") ttk.Label(outer, text="Каждая очистка записывается в файл — видно, что и когда " "было удалено.", style="Dim.TLabel").pack(anchor="w", pady=(2, 8)) self.jtree = ttk.Treeview(outer, columns=("when", "freed", "file"), show="headings", height=10) self.jtree.heading("when", text="Дата") self.jtree.heading("freed", text="Освобождено") self.jtree.heading("file", text="Файл журнала") self.jtree.column("when", width=160, stretch=False) self.jtree.column("freed", width=130, anchor="e", stretch=False) self.jtree.column("file", width=700) self.jtree.pack(fill="x") self.jtree.bind("", lambda e: self._open_journal()) ttk.Label(outer, text="Ошибки доступа при последнем сканировании", style="H2.TLabel").pack(anchor="w", pady=(18, 0)) c = self.theme.c self.err_text = tk.Text(outer, height=10, wrap="none", bg=c["bg2"], fg=c["fg_dim"], relief="flat", padx=12, pady=10, font=("Consolas", 9), highlightthickness=0) self.err_text.pack(fill="both", expand=True, pady=(8, 0)) self.err_text.insert("1.0", "—") self.err_text.configure(state="disabled") # =========================================================== сканирование def start_scan(self) -> None: if self.busy: return cats = {c for c in ALL_CATEGORIES if self.var_cat[c].get()} if not cats: messagebox.showwarning("SysDevCleaner", "Выберите хотя бы одну категорию в настройках.") return opts = ScanOptions( categories=cats, project_roots=list(self.roots), scan_big_files=self.var_big.get(), big_file_min=_mb(self.var_big_mb.get(), 200), scan_duplicates=self.var_dup.get(), duplicate_min=_mb(self.var_dup_mb.get(), 10), scan_programs=self.var_progs.get(), measure_program_size=self.var_prog_size.get(), ) self.busy = True self.cancel.reset() self.btn_scan.configure(state="disabled") self.btn_stop.configure(state="normal") self.btn_clean.configure(state="disabled") self.tree.delete(*self.tree.get_children()) self.items.clear() self.cat_nodes.clear() self.var_status.set("Сканирование…") self.pb.configure(value=0) def work() -> None: try: res = full_scan(opts, self.cancel, lambda m, f: self.q.put(("progress", (m, f)))) self.q.put(("scan_done", res)) except Exception as e: # noqa: BLE001 self.q.put(("error", f"Сбой сканирования: {e}")) self.worker = threading.Thread(target=work, daemon=True) self.worker.start() def stop(self) -> None: self.cancel.cancel() self.var_status.set("Останавливаю…") def _poll(self) -> None: try: while True: kind, payload = self.q.get_nowait() if kind == "progress": msg, frac = payload self.var_status.set(msg) self.pb.configure(value=int(frac * 1000)) elif kind == "scan_done": self._scan_done(payload) elif kind == "clean_progress": msg, frac = payload self.var_status.set(f"Очистка: {msg}") self.pb.configure(value=int(frac * 1000)) elif kind == "clean_done": self._clean_done(payload) elif kind == "error": self.busy = False self.btn_scan.configure(state="normal") self.btn_stop.configure(state="disabled") self.var_status.set("Ошибка") messagebox.showerror("SysDevCleaner", str(payload)) except queue.Empty: pass self.after(120, self._poll) def _scan_done(self, res: ScanResult) -> None: self.busy = False self.result = res self.btn_scan.configure(state="normal") self.btn_stop.configure(state="disabled") self.btn_clean.configure(state="normal") self.pb.configure(value=1000) default_selection(res.findings) self._refill_tree() self._fill_overview(res) self._fill_programs(res) self._fill_errors(res) self._refresh_disks() self._update_sel() self.var_status.set( f"Найдено {human(res.total())} в {len(res.findings)} местах · " f"{res.duration:.0f} с" + (" · сканирование прервано" if self.cancel.cancelled else "") ) # =============================================================== дерево == def _refill_tree(self) -> None: if self.result is None: return self.tree.delete(*self.tree.get_children()) self.items.clear() self.cat_nodes.clear() needle = self.var_filter.get().strip().lower() floor = 10 * 1024 * 1024 if self.var_hide_small.get() else 0 for cat, findings in sorted(self.result.by_category().items(), key=lambda kv: -sum(f.size for f in kv[1])): shown = [f for f in findings if f.size >= floor and (not needle or needle in f.title.lower() or needle in f.path.lower())] if not shown: continue total = sum(f.size for f in shown) node = self.tree.insert( "", "end", text=f"{cat.icon} {cat.label}", values=(UNCHECKED, human(total), f"{len(shown)} шт.", "", ""), open=cat in (Category.DEV_PROJECT, Category.DEV_CACHE), tags=("cat",)) self.cat_nodes[cat] = node for f in shown: iid = self.tree.insert( node, "end", text=" " + f.title, values=(CHECKED if f.selected else UNCHECKED, human(f.size), f.risk.label, _age(f), f.path), tags=(RISK_TAG[f.risk],)) self.items[iid] = f self._sync_cat(node) self._update_sel() def _on_tree_click(self, event: tk.Event) -> None: region = self.tree.identify_region(event.x, event.y) if region not in ("cell", "tree"): return col = self.tree.identify_column(event.x) iid = self.tree.identify_row(event.y) if not iid or col != "#1": return if iid in self.items: self._toggle(iid) else: self._toggle_cat(iid) return "break" def _on_space(self, event: tk.Event) -> str: for iid in self.tree.selection(): if iid in self.items: self._toggle(iid) else: self._toggle_cat(iid) return "break" def _toggle(self, iid: str, value: bool | None = None) -> None: f = self.items[iid] f.selected = (not f.selected) if value is None else value self.tree.set(iid, "chk", CHECKED if f.selected else UNCHECKED) parent = self.tree.parent(iid) if parent: self._sync_cat(parent) self._update_sel() def _toggle_cat(self, node: str) -> None: kids = self.tree.get_children(node) target = not all(self.items[k].selected for k in kids if k in self.items) for k in kids: if k in self.items: self.items[k].selected = target self.tree.set(k, "chk", CHECKED if target else UNCHECKED) self._sync_cat(node) self._update_sel() def _sync_cat(self, node: str) -> None: kids = [k for k in self.tree.get_children(node) if k in self.items] if not kids: return n = sum(1 for k in kids if self.items[k].selected) glyph = CHECKED if n == len(kids) else (UNCHECKED if n == 0 else PARTIAL) self.tree.set(node, "chk", glyph) def _set_selected(self, value: bool) -> None: for iid in self.tree.selection(): if iid in self.items: self._toggle(iid, value) def _bulk(self, mode: str) -> None: for iid, f in self.items.items(): if mode == "none": f.selected = False elif mode == "safe": f.selected = f.risk == Risk.SAFE and not f.extra.get("busy") elif mode == "low": f.selected = f.risk <= Risk.LOW and f.category not in ( Category.BIG_FILES, Category.DUPLICATES) self.tree.set(iid, "chk", CHECKED if f.selected else UNCHECKED) for node in self.cat_nodes.values(): self._sync_cat(node) self._update_sel() def _expand(self, opened: bool) -> None: for node in self.cat_nodes.values(): self.tree.item(node, open=opened) def _update_sel(self) -> None: sel = [f for f in self.items.values() if f.selected] if not sel: self.var_sel.set("Ничего не выбрано") else: risky = sum(1 for f in sel if f.risk >= Risk.MEDIUM) txt = f"Выбрано {len(sel)} · {human(sum(f.size for f in sel))}" if risky: txt += f" · из них {risky} с повышенным риском" self.var_sel.set(txt) def _on_tree_select(self, event: tk.Event) -> None: sel = self.tree.selection() if not sel: return iid = sel[0] self.detail.configure(state="normal") self.detail.delete("1.0", "end") if iid in self.items: f = self.items[iid] mode = "Удаляется содержимое каталога" if f.delete_contents_only else "Удаляется целиком" if f.members: mode = f"Удаляются лишние копии ({len(f.members)} шт.), оригинал остаётся" self.detail.insert("1.0", f"{f.title}\n{f.path}\n\n" f"{human(f.size)} · {f.files} файлов · {f.risk.label} · {mode}\n\n" f"{f.detail}") else: self.detail.insert("1.0", "Категория. Клик по квадратику отмечает всё внутри.") self.detail.configure(state="disabled") def _on_tree_double(self, event: tk.Event) -> str: iid = self.tree.identify_row(event.y) if iid in self.items: open_in_explorer(self.items[iid].path) return "break" return "" def _on_tree_menu(self, event: tk.Event) -> None: iid = self.tree.identify_row(event.y) if iid: if iid not in self.tree.selection(): self.tree.selection_set(iid) self.menu.tk_popup(event.x_root, event.y_root) def _open_selected(self) -> None: for iid in self.tree.selection(): if iid in self.items: open_in_explorer(self.items[iid].path) break def _copy_path(self) -> None: paths = [self.items[i].path for i in self.tree.selection() if i in self.items] if paths: self.clipboard_clear() self.clipboard_append("\n".join(paths)) # ================================================================ обзор == def _refresh_disks(self) -> None: for w in self.disk_box.winfo_children(): w.destroy() c = self.theme.c for d in (self.result.disks if self.result else [disk_usage(x) for x in drives()]): if not d["total"]: continue row = ttk.Frame(self.disk_box) row.pack(fill="x", pady=4) ttk.Label(row, text=d["root"], width=5, style="Dim.TLabel").pack(side="left") canvas = tk.Canvas(row, height=14, bg=c["bg3"], highlightthickness=0) canvas.pack(side="left", fill="x", expand=True, padx=(4, 10)) pct = d["percent"] col = c["danger"] if pct > 90 else (c["warn"] if pct > 80 else c["accent"]) def draw(ev=None, cv=canvas, p=pct, color=col): cv.delete("all") w = cv.winfo_width() cv.create_rectangle(0, 0, w * p / 100, 14, fill=color, outline="") canvas.bind("", draw) ttk.Label(row, text=f"{human(d['free'])} свободно", style="Dim.TLabel", width=18).pack(side="right") if self.result is None: self.card_vals["free"].set(human(disk_usage(drives()[0])["free"])) def _fill_overview(self, res: ScanResult) -> None: safe = sum(f.size for f in res.findings if f.risk == Risk.SAFE) dev = res.total({Category.DEV_CACHE, Category.DEV_PROJECT}) self.card_vals["total"].set(human(res.total())) self.card_vals["safe"].set(human(safe)) self.card_vals["dev"].set(human(dev)) sysroot = next((d for d in res.disks if d["root"].upper().startswith("C")), None) self.card_vals["free"].set(human(sysroot["free"]) if sysroot else "—") for w in self.cat_box.winfo_children(): w.destroy() c = self.theme.c cats = sorted(res.by_category().items(), key=lambda kv: -sum(f.size for f in kv[1])) top = max((sum(f.size for f in v) for _k, v in cats), default=1) or 1 for cat, items in cats: total = sum(f.size for f in items) row = ttk.Frame(self.cat_box) row.pack(fill="x", pady=3) ttk.Label(row, text=f"{cat.icon} {cat.label}", width=24, style="Dim.TLabel").pack(side="left") cv = tk.Canvas(row, height=12, bg=c["bg3"], highlightthickness=0) cv.pack(side="left", fill="x", expand=True, padx=(4, 10)) def draw(ev=None, cv=cv, frac=total / top): cv.delete("all") w = cv.winfo_width() cv.create_rectangle(0, 0, max(2, w * frac), 12, fill=c["accent"], outline="") cv.bind("", draw) ttk.Label(row, text=f"{human(total)} ({len(items)})", width=18, style="Dim.TLabel").pack(side="right") self.rec_text.configure(state="normal") self.rec_text.delete("1.0", "end") if not res.recommendations: self.rec_text.insert("end", "Система выглядит чистой — существенного мусора " "не найдено.\n", "body") for i, r in enumerate(res.recommendations, 1): tag = "imp" if r.severity == 2 else "h" self.rec_text.insert("end", f"{i}. {r.title}\n", tag) if r.gain: self.rec_text.insert("end", f" освободится {human(r.gain)}\n", "gain") self.rec_text.insert("end", r.body + "\n", "body") self.rec_text.configure(state="disabled") def _fill_programs(self, res: ScanResult) -> None: self.ptree.delete(*self.ptree.get_children()) self.prog_rows.clear() for p in res.programs: idle = f"{int(p.idle_days)} дн." if p.idle_days >= 0 else "—" tag = "" if p.best_size > 2 * 1024 ** 3: tag = "medium" if p.idle_days > 365 and p.best_size > 500 * 1024 ** 2: tag = "high" iid = self.ptree.insert("", "end", text=p.name, values=(human(p.best_size), idle, p.version, p.publisher, p.install_location), tags=(tag,) if tag else ()) self.prog_rows[iid] = p def _fill_errors(self, res: ScanResult) -> None: self.err_text.configure(state="normal") self.err_text.delete("1.0", "end") if res.errors: self.err_text.insert("1.0", "\n".join(res.errors[:400])) else: self.err_text.insert("1.0", "Ошибок не было.") self.err_text.configure(state="disabled") # =============================================================== очистка = def do_clean(self) -> None: if self.busy: return sel = [f for f in self.items.values() if f.selected] if not sel: messagebox.showinfo("SysDevCleaner", "Сначала отметьте, что нужно удалить.") return mode = ("ПРОБНЫЙ ЗАПУСК — ничего не будет удалено." if self.var_dry.get() else ("Файлы отправятся в Корзину." if self.var_recycle.get() else "ВНИМАНИЕ: удаление БЕЗВОЗВРАТНОЕ, мимо Корзины.")) if not messagebox.askyesno("Подтверждение очистки", plan_summary(sel) + "\n\n" + mode + "\n\nПродолжить?"): return opts = cleanmod.CleanOptions(to_recycle_bin=self.var_recycle.get(), dry_run=self.var_dry.get()) self.busy = True self.cancel.reset() self.btn_clean.configure(state="disabled") self.btn_scan.configure(state="disabled") self.btn_stop.configure(state="normal") def work() -> None: try: res = cleanmod.clean(sel, opts, self.cancel, lambda m, f: self.q.put(("clean_progress", (m, f)))) self.q.put(("clean_done", res)) except Exception as e: # noqa: BLE001 self.q.put(("error", f"Сбой очистки: {e}")) threading.Thread(target=work, daemon=True).start() def _clean_done(self, res) -> None: self.busy = False self.btn_scan.configure(state="normal") self.btn_stop.configure(state="disabled") self.btn_clean.configure(state="normal") self.pb.configure(value=1000) self.var_status.set(res.summary()) self._load_journals() self._refresh_disks() lines = [res.summary(), ""] fails = [i for i in res.items if not i.ok] if fails: lines.append(f"Не удалось полностью очистить {len(fails)}:") for i in fails[:12]: lines.append(f" • {i.title} — {i.message or 'файлы заняты другой программой'}") if len(fails) > 12: lines.append(f" … и ещё {len(fails) - 12}") lines.append("") lines.append("Обычная причина — файлы используются запущенной программой " "или нужны права администратора.") if res.journal_path: lines.append("") lines.append(f"Журнал: {res.journal_path}") messagebox.showinfo("Очистка завершена", "\n".join(lines)) # убираем из дерева то, что удалось удалить целиком for item in res.items: if item.ok and not item.finding.delete_contents_only: for iid, f in list(self.items.items()): if f is item.finding: try: self.tree.delete(iid) except tk.TclError: pass self.items.pop(iid, None) for node in list(self.cat_nodes.values()): try: self._sync_cat(node) except tk.TclError: pass self._update_sel() # ============================================================== прочее === def save_report(self) -> None: if self.result is None: messagebox.showinfo("SysDevCleaner", "Сначала выполните сканирование.") return path = filedialog.asksaveasfilename( title="Сохранить отчёт", defaultextension=".txt", initialfile=time.strftime("sysdevcleaner-report-%Y%m%d-%H%M.txt"), filetypes=[("Текстовый файл", "*.txt"), ("Все файлы", "*.*")]) if not path: return try: with open(path, "w", encoding="utf-8") as fh: fh.write(report_text(self.result)) if messagebox.askyesno("SysDevCleaner", "Отчёт сохранён. Открыть его?"): open_in_explorer(path) except OSError as e: messagebox.showerror("SysDevCleaner", f"Не удалось сохранить: {e}") def _add_root(self) -> None: d = filedialog.askdirectory(title="Папка для поиска проектов") if d: d = os.path.normpath(d) if d not in self.roots: self.roots.append(d) self.roots_list.insert("end", d) def _del_root(self) -> None: for i in reversed(self.roots_list.curselection()): self.roots_list.delete(i) del self.roots[i] def _reset_roots(self) -> None: self.roots = default_project_roots() self.roots_list.delete(0, "end") for r in self.roots: self.roots_list.insert("end", r) def _uninstall(self) -> None: sel = self.ptree.selection() if not sel: messagebox.showinfo("SysDevCleaner", "Выберите программу в списке.") return p = self.prog_rows.get(sel[0]) if p is None: return if not messagebox.askyesno( "Удаление программы", f"Запустить штатный деинсталлятор?\n\n{p.name}\n{human(p.best_size)}\n\n" "Откроется окно самой программы — дальнейшие шаги за вами."): return ok, err = progmod.uninstall(p) if not ok: messagebox.showerror("SysDevCleaner", f"Не удалось запустить удаление: {err}") def _open_program_folder(self, event: tk.Event) -> None: iid = self.ptree.identify_row(event.y) p = self.prog_rows.get(iid) if p is not None and p.install_location: open_in_explorer(p.install_location) def _open_appwiz(self) -> None: try: if sys.platform == "win32": subprocess.Popen(["cmd", "/c", "start", "ms-settings:appsfeatures"], shell=False) except Exception: pass def _load_journals(self) -> None: self.jtree.delete(*self.jtree.get_children()) for path, when, freed in cleanmod.list_journals(): self.jtree.insert("", "end", values=(when, human(freed), path)) def _open_journal(self) -> None: sel = self.jtree.selection() if sel: open_in_explorer(self.jtree.item(sel[0], "values")[2]) def _toggle_theme(self) -> None: self.theme.toggle() self.tags = tag_colors(self.theme) messagebox.showinfo("SysDevCleaner", "Тема изменена. Некоторые элементы обновятся после " "перезапуска приложения.") for t in (self.tree, self.ptree): for name, cfg in self.tags.items(): t.tag_configure(name, **cfg) c = self.theme.c for txt in (self.rec_text, self.detail, self.err_text): txt.configure(bg=c["bg2"], fg=c["fg_dim"]) self._refresh_disks() if self.result: self._fill_overview(self.result) def _on_close(self) -> None: if self.busy: if not messagebox.askyesno("SysDevCleaner", "Идёт операция. Прервать и выйти?"): return self.cancel.cancel() self.destroy() def _mb(text: str, default: int) -> int: try: return max(1, int(float(text))) * 1024 * 1024 except (TypeError, ValueError): return default * 1024 * 1024 def _age(f: Finding) -> str: if not f.mtime: return "—" d = int(f.age_days) if d < 1: return "сегодня" if d < 30: return f"{d} дн." if d < 365: return f"{d // 30} мес." return f"{d // 365} г." def main() -> None: app = App() app.mainloop() if __name__ == "__main__": main()