devklean 1.0.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (49) hide show
  1. devklean/__init__.py +6 -0
  2. devklean/__main__.py +4 -0
  3. devklean/_version.py +8 -0
  4. devklean/cli/__init__.py +3 -0
  5. devklean/cli/commands/__init__.py +4 -0
  6. devklean/cli/commands/clean.py +106 -0
  7. devklean/cli/commands/common.py +38 -0
  8. devklean/cli/commands/doctor.py +43 -0
  9. devklean/cli/commands/history.py +14 -0
  10. devklean/cli/commands/restore.py +20 -0
  11. devklean/cli/commands/scan.py +16 -0
  12. devklean/cli/confirmation.py +51 -0
  13. devklean/cli/dispatcher.py +45 -0
  14. devklean/cli/main.py +61 -0
  15. devklean/cli/parser.py +164 -0
  16. devklean/config/__init__.py +15 -0
  17. devklean/config/defaults.py +14 -0
  18. devklean/config/manager.py +226 -0
  19. devklean/config/models.py +48 -0
  20. devklean/config/paths.py +24 -0
  21. devklean/config/targets.py +23 -0
  22. devklean/deletion/__init__.py +17 -0
  23. devklean/deletion/history.py +66 -0
  24. devklean/deletion/integrity.py +39 -0
  25. devklean/deletion/metadata.py +198 -0
  26. devklean/deletion/paths.py +24 -0
  27. devklean/deletion/safety.py +176 -0
  28. devklean/deletion/trash.py +89 -0
  29. devklean/formatting.py +31 -0
  30. devklean/logging_setup.py +74 -0
  31. devklean/models.py +34 -0
  32. devklean/output/__init__.py +5 -0
  33. devklean/output/base.py +38 -0
  34. devklean/output/console.py +94 -0
  35. devklean/output/history_payload.py +34 -0
  36. devklean/output/json.py +75 -0
  37. devklean/output/scan_payload.py +51 -0
  38. devklean/output/sorting.py +12 -0
  39. devklean/output/text.py +183 -0
  40. devklean/output/theme.py +46 -0
  41. devklean/scanner/__init__.py +17 -0
  42. devklean/scanner/filters.py +32 -0
  43. devklean/scanner/scanner.py +170 -0
  44. devklean/tui.py +126 -0
  45. devklean-1.0.0.dist-info/METADATA +257 -0
  46. devklean-1.0.0.dist-info/RECORD +49 -0
  47. devklean-1.0.0.dist-info/WHEEL +4 -0
  48. devklean-1.0.0.dist-info/entry_points.txt +2 -0
  49. devklean-1.0.0.dist-info/licenses/LICENSE +21 -0
@@ -0,0 +1,51 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import Sequence
4
+
5
+ from devklean.formatting import format_size
6
+ from devklean.models import CleanableItem
7
+ from devklean.output.sorting import items_by_size_desc
8
+
9
+ SCAN_OUTPUT_VERSION = "1.0"
10
+
11
+
12
+ def build_scan_payload(
13
+ root: str,
14
+ items: list[CleanableItem],
15
+ permission_errors: Sequence[str] = (),
16
+ ) -> dict:
17
+ """Build the canonical scan result structure for renderers and tooling."""
18
+ sorted_items = items_by_size_desc(items)
19
+ total_size = sum(item.size for item in items)
20
+
21
+ return {
22
+ "version": SCAN_OUTPUT_VERSION,
23
+ "root": root,
24
+ "items": [serialize_cleanable_item(item) for item in sorted_items],
25
+ "permission_errors": list(permission_errors),
26
+ "summary": {
27
+ "count": len(sorted_items),
28
+ "total_size": total_size,
29
+ "formatted_total_size": format_size(total_size),
30
+ },
31
+ }
32
+
33
+
34
+ def serialize_cleanable_item(item: CleanableItem) -> dict:
35
+ return {
36
+ "path": item.path,
37
+ "display_name": item.display_label,
38
+ "size": item.size,
39
+ "formatted_size": format_size(item.size),
40
+ "type": item.name,
41
+ }
42
+
43
+
44
+ def build_error_payload(code: str, message: str) -> dict:
45
+ return {
46
+ "version": SCAN_OUTPUT_VERSION,
47
+ "error": {
48
+ "code": code,
49
+ "message": message,
50
+ },
51
+ }
@@ -0,0 +1,12 @@
1
+ from __future__ import annotations
2
+
3
+ from devklean.models import CleanableItem
4
+
5
+
6
+ def items_by_size_desc(items: list[CleanableItem]) -> list[CleanableItem]:
7
+ """Return items sorted largest-first by size.
8
+
9
+ Safe: sort is stable; equal sizes retain relative order. Used by all
10
+ output paths so sort logic lives in one place without affecting scan order.
11
+ """
12
+ return sorted(items, key=lambda item: item.size, reverse=True)
@@ -0,0 +1,183 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import Sequence
4
+
5
+ from devklean.deletion.history import HistoryOperation
6
+ from devklean.deletion.integrity import IntegrityReport
7
+ from devklean.formatting import format_size, format_timestamp
8
+ from devklean.models import CleanableItem, DeleteResult
9
+ from devklean.output.console import SYM_ERROR, SYM_SUCCESS, Console
10
+ from devklean.output.sorting import items_by_size_desc
11
+
12
+
13
+ class TextRenderer:
14
+ """Color-gated terminal output with a standardized symbol scheme."""
15
+
16
+ def __init__(self, console: Console | None = None) -> None:
17
+ self._console = console if console is not None else Console()
18
+
19
+ def _println(self, text: str = "") -> None:
20
+ self._console.plain(text)
21
+
22
+ def scan_start(self, root: str) -> None:
23
+ c = self._console
24
+ banner = f"{c.paint('devklean', 'bold')} {c.paint(f'scanning {root}...', 'detail')}"
25
+ self._println(f"\n{banner}\n")
26
+
27
+ def scan_summary(self, items: list[CleanableItem]) -> int:
28
+ c = self._console
29
+ total_size = sum(item.size for item in items)
30
+
31
+ self._println(f"{'TYPE':<18} {'SIZE':>8} {'PATH'}")
32
+ self._println(c.paint("─" * 70, "detail"))
33
+
34
+ for item in items_by_size_desc(items):
35
+ role = "error" if item.size > 50 * 1024 * 1024 else "warning"
36
+ self._println(
37
+ f"{c.paint(f'{item.display_label:<18}', 'detail')} "
38
+ f"{c.paint(f'{format_size(item.size):>8}', role)} "
39
+ f"{c.paint(item.path, 'detail')}"
40
+ )
41
+
42
+ self._println(c.paint("─" * 70, "detail"))
43
+ total = c.paint(c.paint(f"{format_size(total_size):>8}", "bold"), "error")
44
+ self._println(f"{'TOTAL':<18} {total}\n")
45
+
46
+ return total_size
47
+
48
+ def nothing_to_clean(self) -> None:
49
+ self._console.success("Nothing to clean.")
50
+ self._println()
51
+
52
+ def dry_run_nothing_deleted(self) -> None:
53
+ self._console.warning("[dry-run] would delete nothing.")
54
+ self._println()
55
+
56
+ def dry_run_selected(self, count: int) -> None:
57
+ word = "directory" if count == 1 else "directories"
58
+ self._console.warning(f"[dry-run] would delete {count} {word}; nothing deleted.")
59
+ self._println()
60
+
61
+ def aborted(self) -> None:
62
+ self._println()
63
+ self._console.detail("Aborted. Nothing deleted.")
64
+ self._println()
65
+
66
+ def no_items_selected(self) -> None:
67
+ self._println()
68
+ self._console.detail("No items selected. Nothing deleted.")
69
+ self._println()
70
+
71
+ def invalid_directory(self, path: str) -> None:
72
+ self._console.error(f"'{path}' is not a directory.")
73
+
74
+ def confirm_prompt(self, count: int, total_size: int = 0) -> str:
75
+ word = "directory" if count == 1 else "directories"
76
+ return (
77
+ f"{self._console.paint('Delete', 'bold')} {count} {word} "
78
+ f"(~{format_size(total_size)})? "
79
+ f"{self._console.paint('(y/N)', 'detail')} "
80
+ )
81
+
82
+ def deletion_result(self, result: DeleteResult) -> None:
83
+ c = self._console
84
+ self._println()
85
+ for path in result.deleted:
86
+ self._println(f" {c.paint(SYM_SUCCESS, 'success')} {c.paint(path, 'detail')}")
87
+ for failure in result.failed:
88
+ self._println(f" {c.paint(SYM_ERROR, 'error')} {failure.path} — {failure.error}")
89
+
90
+ deleted = result.deleted_count
91
+ word = "directory" if deleted == 1 else "directories"
92
+ self._println()
93
+ self._console.success(
94
+ c.paint(f"Cleaned {deleted} {word}, freed ~{format_size(result.total_size)}.", "bold")
95
+ )
96
+ if result.failed_count:
97
+ self._console.error(f"{result.failed_count} failed.")
98
+ self._println()
99
+
100
+ def permission_warnings(self, paths: Sequence[str]) -> None:
101
+ if not paths:
102
+ return
103
+ count = len(paths)
104
+ plural = "s" if count != 1 else ""
105
+ self._console.warning(f"Skipped {count} path{plural} (permission denied):")
106
+ for path in paths:
107
+ self._console.detail(f" {path}")
108
+
109
+ def history(
110
+ self,
111
+ operations: Sequence[HistoryOperation],
112
+ invalid_count: int,
113
+ ) -> None:
114
+ c = self._console
115
+ if not operations:
116
+ c.detail("No cleanup history.")
117
+ self._print_invalid_note(invalid_count)
118
+ return
119
+
120
+ self._println(f"\n{c.paint('Cleanup history', 'bold')}\n")
121
+ self._println(f"{'WHEN':<18} {'SIZE':>9} {'STRATEGY':<10} {'ITEMS':>5}")
122
+ self._println(c.paint("─" * 48, "detail"))
123
+
124
+ for op in operations:
125
+ self._println(
126
+ f"{c.paint(f'{format_timestamp(op.timestamp):<18}', 'detail')} "
127
+ f"{c.paint(f'{format_size(op.reclaimed_size):>9}', 'success')} "
128
+ f"{op.strategy:<10} "
129
+ f"{op.item_count:>5}"
130
+ )
131
+ self._println()
132
+ self._print_invalid_note(invalid_count)
133
+
134
+ def _print_invalid_note(self, invalid_count: int) -> None:
135
+ if invalid_count:
136
+ plural = "s" if invalid_count != 1 else ""
137
+ self._console.warning(
138
+ f"Skipped {invalid_count} corrupt metadata record{plural} "
139
+ f"— run `devklean doctor` to inspect."
140
+ )
141
+
142
+ # --- doctor ---
143
+
144
+ def doctor_healthy(self) -> None:
145
+ self._console.success("Metadata store is healthy.")
146
+
147
+ def doctor_corruption_report(self, report: IntegrityReport) -> None:
148
+ c = self._console
149
+ self._println()
150
+ c.warning(
151
+ c.paint(f"CORRUPT ({len(report.corrupt)})", "error")
152
+ + " — unparseable metadata, will be removed on confirmation"
153
+ )
154
+ for entry in report.corrupt:
155
+ c.detail(f" {SYM_ERROR} {entry.path.name} — {entry.reason}")
156
+
157
+ def doctor_confirm_prompt(self, count: int) -> str:
158
+ plural = "s" if count != 1 else ""
159
+ return f"\nRemove {count} corrupt metadata record{plural}? (y/N) "
160
+
161
+ def doctor_kept(self) -> None:
162
+ self._console.detail("Kept all records. Nothing removed.")
163
+
164
+ def doctor_removed(self, removed: int) -> None:
165
+ plural = "s" if removed != 1 else ""
166
+ self._println()
167
+ self._console.success(f"Removed {removed} corrupt metadata record{plural}.")
168
+
169
+ def doctor_remove_error(self, name: str, error: str) -> None:
170
+ self._console.error(f"could not remove {name}: {error}")
171
+
172
+ # --- restore ---
173
+
174
+ def restore_help(self) -> None:
175
+ c = self._console
176
+ c.info("devklean moves deleted items to your system trash, not to a devklean-owned store.")
177
+ self._println()
178
+ c.detail("To recover something you deleted:")
179
+ c.detail(" • Windows — open the Recycle Bin and restore the item.")
180
+ c.detail(" • macOS — open Trash in Finder and 'Put Back'.")
181
+ c.detail(" • Linux — open Trash in your file manager and restore.")
182
+ self._println()
183
+ c.detail("Run `devklean history` to see what was removed and when.")
@@ -0,0 +1,46 @@
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import dataclass
4
+
5
+
6
+ @dataclass(frozen=True)
7
+ class Palette:
8
+ """ANSI codes for semantic roles. Empty strings mean 'no color'."""
9
+
10
+ green: str
11
+ red: str
12
+ yellow: str
13
+ cyan: str
14
+ dim: str
15
+ bold: str
16
+ reset: str
17
+
18
+
19
+ _COLOR_PALETTE = Palette(
20
+ green="\033[32m",
21
+ red="\033[31m",
22
+ yellow="\033[33m",
23
+ cyan="\033[36m",
24
+ dim="\033[2m",
25
+ bold="\033[1m",
26
+ reset="\033[0m",
27
+ )
28
+
29
+ _MONO_PALETTE = Palette(green="", red="", yellow="", cyan="", dim="", bold="", reset="")
30
+
31
+
32
+ @dataclass(frozen=True)
33
+ class Theme:
34
+ name: str
35
+ palette: Palette
36
+
37
+
38
+ _THEMES = {
39
+ "default": Theme(name="default", palette=_COLOR_PALETTE),
40
+ "mono": Theme(name="mono", palette=_MONO_PALETTE),
41
+ }
42
+
43
+
44
+ def get_theme(name: str) -> Theme:
45
+ """Return the named theme, falling back to 'default' for unknown names."""
46
+ return _THEMES.get(name, _THEMES["default"])
@@ -0,0 +1,17 @@
1
+ from devklean.config.defaults import DEFAULT_TARGETS
2
+ from devklean.scanner.filters import (
3
+ dir_is_under_ignored_path,
4
+ normalize_paths,
5
+ path_is_excluded,
6
+ )
7
+ from devklean.scanner.scanner import ScanResult, get_dir_size, scan_tree
8
+
9
+ __all__ = [
10
+ "DEFAULT_TARGETS",
11
+ "ScanResult",
12
+ "dir_is_under_ignored_path",
13
+ "get_dir_size",
14
+ "normalize_paths",
15
+ "path_is_excluded",
16
+ "scan_tree",
17
+ ]
@@ -0,0 +1,32 @@
1
+ from __future__ import annotations
2
+
3
+ import os
4
+
5
+
6
+ def normalize_paths(paths: frozenset[str] | set[str] | list[str] | tuple[str, ...]) -> set[str]:
7
+ if not paths:
8
+ return set()
9
+ return {os.path.abspath(os.path.expanduser(path)) for path in paths}
10
+
11
+
12
+ def dir_is_under_ignored_path(dirpath: str, ignored_paths: set[str]) -> bool:
13
+ """Return True when ``dirpath`` is inside an ignored path.
14
+
15
+ Safe: callers pass absolute ``dirpath`` values from ``os.walk(abspath(root))``
16
+ and absolute entries in ``ignored_paths`` from ``normalize_paths``.
17
+ """
18
+ for ignored_path in ignored_paths:
19
+ if dirpath == ignored_path or dirpath.startswith(ignored_path + os.sep):
20
+ return True
21
+ return False
22
+
23
+
24
+ def path_is_excluded(full_path: str, ignored_paths: set[str]) -> bool:
25
+ """Return True when ``full_path`` should be excluded from scan results.
26
+
27
+ Safe: same absolute-path precondition as ``dir_is_under_ignored_path``.
28
+ """
29
+ for ignored_path in ignored_paths:
30
+ if full_path == ignored_path or full_path.startswith(ignored_path + os.sep):
31
+ return True
32
+ return False
@@ -0,0 +1,170 @@
1
+ """Scanner performance notes.
2
+
3
+ Before (per directory visited during ``os.walk``):
4
+ - Rebuilt ``dirnames`` via list comprehension (new list allocation).
5
+ - Copied ``dirnames`` with ``list()`` for safe removal during iteration.
6
+ - Called ``dirnames.remove`` multiple times (O(n) each).
7
+ - Called ``os.path.abspath`` on every ``dirpath`` and matched path.
8
+ - Ran ignore-path checks even when the ignore list was empty.
9
+
10
+ After:
11
+ - Single-pass ``dirnames`` filtering via slice assignment (one list built per node).
12
+ - ``root`` normalized once so walk paths stay absolute; filters skip ``abspath``.
13
+ - Ignore-path checks gated on ``has_ignored_paths`` (empty config = no work).
14
+ - Directory-name ignore checks gated on ``has_ignored_directories``.
15
+ - ``active_targets[dirname]`` used directly after membership test (no redundant ``.get``).
16
+
17
+ Complexity (unchanged asymptotics, improved constants):
18
+ - Walk: still O(N) over filesystem nodes under ``root``.
19
+ - Per node: O(d) directory entries; target/ignore lookups O(1) average.
20
+ - Found-path pruning: O(1) per child via ``found_paths`` set (unchanged).
21
+
22
+ Sizing and concurrency (qualitative before/after):
23
+ - Sizing: ``get_dir_size`` moved from ``os.walk`` + ``os.path.getsize`` (which
24
+ re-``stat``s every file) to ``os.scandir`` + ``entry.stat`` (cached stat, one
25
+ syscall per entry). Symlinked dirs/files are skipped, so no double-counting.
26
+ - Concurrency: target sizing was sequential (each target's subtree walked one
27
+ after another). It now runs on a bounded ``ThreadPoolExecutor`` — sizing is
28
+ I/O-bound, so wide trees with many targets see real wall-clock wins. Discovery
29
+ stays a single deterministic walk; result order is preserved (map keeps input
30
+ order), so output is identical to the sequential version.
31
+ - Memory: only per-directory totals are retained (never a list of every file),
32
+ unchanged — large scans stay bounded by the number of matched targets, not
33
+ the number of files.
34
+
35
+ All changes preserve behavior: same targets matched, same paths ignored, same
36
+ sizes reported, same traversal boundaries (no recursion into matched targets).
37
+ """
38
+
39
+ from __future__ import annotations
40
+
41
+ import os
42
+ from concurrent.futures import ThreadPoolExecutor
43
+ from dataclasses import dataclass, field
44
+
45
+ from devklean.config.models import ScanSettings
46
+ from devklean.models import CleanableItem
47
+ from devklean.scanner.filters import (
48
+ dir_is_under_ignored_path,
49
+ normalize_paths,
50
+ path_is_excluded,
51
+ )
52
+
53
+
54
+ @dataclass(frozen=True)
55
+ class ScanResult:
56
+ """Discovered cleanable items plus paths skipped due to permission errors."""
57
+
58
+ items: list[CleanableItem]
59
+ permission_errors: list[str] = field(default_factory=list)
60
+
61
+
62
+ def get_dir_size(path: str) -> int:
63
+ """Return the combined byte size of all regular files under ``path``.
64
+
65
+ Uses ``os.scandir`` with ``entry.stat(follow_symlinks=False)``, which:
66
+ - avoids the redundant ``stat`` that ``os.path.getsize`` performs after
67
+ ``os.walk`` (scandir caches the entry's stat where the OS provides it),
68
+ - never traverses symlinked directories and never counts symlinked files,
69
+ so symlinks/hardlink aliases are not double-counted,
70
+ - counts regular files only.
71
+ """
72
+ total = 0
73
+ stack = [path]
74
+ while stack:
75
+ current = stack.pop()
76
+ try:
77
+ with os.scandir(current) as entries:
78
+ for entry in entries:
79
+ try:
80
+ if entry.is_dir(follow_symlinks=False):
81
+ stack.append(entry.path)
82
+ elif entry.is_file(follow_symlinks=False):
83
+ total += entry.stat(follow_symlinks=False).st_size
84
+ except OSError:
85
+ pass
86
+ except OSError:
87
+ pass
88
+ return total
89
+
90
+
91
+ def scan_tree(root: str, settings: ScanSettings | None = None) -> ScanResult:
92
+ """Discover cleanable directories, reporting permission-denied paths.
93
+
94
+ Permission errors during traversal are collected (via ``os.walk``'s
95
+ ``onerror`` hook) rather than silently swallowed, so the caller can tell the
96
+ user which paths were skipped. Scanning continues past them.
97
+ """
98
+ active = settings or ScanSettings.defaults()
99
+
100
+ # Safe: absolute root => os.walk yields absolute dirpaths; ignored paths
101
+ # are normalized the same way in ConfigManager / normalize_paths.
102
+ root = os.path.abspath(root)
103
+ ignored_paths = normalize_paths(active.ignored_paths)
104
+ has_ignored_paths = bool(ignored_paths)
105
+ ignored_directories = active.ignored_directories
106
+ has_ignored_directories = bool(ignored_directories)
107
+ active_targets = active.targets
108
+
109
+ # Discovery is a single deterministic pass; sizing (the real cost) is
110
+ # deferred so it can run concurrently afterwards.
111
+ matched: list[tuple[str, str]] = [] # (child_path, dirname)
112
+ found_paths: set[str] = set()
113
+ permission_errors: list[str] = []
114
+
115
+ def _on_walk_error(error: OSError) -> None:
116
+ if isinstance(error, PermissionError):
117
+ permission_errors.append(error.filename or str(error))
118
+
119
+ for dirpath, dirnames, _ in os.walk(
120
+ root, topdown=True, followlinks=False, onerror=_on_walk_error
121
+ ):
122
+ if has_ignored_paths and dir_is_under_ignored_path(dirpath, ignored_paths):
123
+ dirnames.clear()
124
+ continue
125
+
126
+ kept_dirnames: list[str] = []
127
+ for dirname in dirnames:
128
+ if has_ignored_directories and dirname in ignored_directories:
129
+ continue
130
+
131
+ child_path = os.path.join(dirpath, dirname)
132
+
133
+ if child_path in found_paths:
134
+ continue
135
+
136
+ if dirname in active_targets:
137
+ if has_ignored_paths and path_is_excluded(child_path, ignored_paths):
138
+ continue
139
+ matched.append((child_path, dirname))
140
+ found_paths.add(child_path)
141
+ continue
142
+
143
+ kept_dirnames.append(dirname)
144
+
145
+ dirnames[:] = kept_dirnames
146
+
147
+ sizes = _compute_sizes_concurrently([path for path, _ in matched])
148
+ found = [
149
+ CleanableItem(
150
+ path=child_path,
151
+ name=dirname,
152
+ size=size,
153
+ display_label=active_targets[dirname],
154
+ )
155
+ for (child_path, dirname), size in zip(matched, sizes)
156
+ ]
157
+
158
+ return ScanResult(items=found, permission_errors=permission_errors)
159
+
160
+
161
+ def _compute_sizes_concurrently(paths: list[str]) -> list[int]:
162
+ """Compute directory sizes in parallel (I/O-bound). Order is preserved."""
163
+ if not paths:
164
+ return []
165
+ if len(paths) == 1:
166
+ return [get_dir_size(paths[0])]
167
+
168
+ workers = min(32, (os.cpu_count() or 1) * 5, len(paths))
169
+ with ThreadPoolExecutor(max_workers=workers) as executor:
170
+ return list(executor.map(get_dir_size, paths))
devklean/tui.py ADDED
@@ -0,0 +1,126 @@
1
+ from __future__ import annotations
2
+
3
+ from devklean.cli.confirmation import (
4
+ DEFAULT_LARGE_THRESHOLD,
5
+ confirm_large_deletion,
6
+ exceeds_threshold,
7
+ )
8
+ from devklean.deletion import SafetyValidator, delete_items
9
+ from devklean.formatting import format_size, truncate
10
+ from devklean.models import CleanableItem
11
+ from devklean.output.base import Renderer
12
+ from devklean.output.sorting import items_by_size_desc
13
+
14
+
15
+ def interactive_ui(stdscr, items: list[CleanableItem], dry_run: bool) -> list[int] | None:
16
+ import curses # Unix-only; imported lazily so this module loads on Windows.
17
+
18
+ curses.curs_set(0)
19
+ stdscr.keypad(True)
20
+
21
+ selected = [False] * len(items)
22
+ idx = 0
23
+ top = 0
24
+
25
+ while True:
26
+ stdscr.erase()
27
+ height, width = stdscr.getmaxyx()
28
+
29
+ header = "devklean interactive - SPACE select | A all | D none | ENTER confirm | Q/ESC quit"
30
+ stdscr.addnstr(0, 0, truncate(header, width), width)
31
+
32
+ list_top = 1
33
+ list_height = max(0, height - 3)
34
+ footer_y = height - 1
35
+
36
+ if len(items) == 0:
37
+ stdscr.addnstr(
38
+ list_top,
39
+ 0,
40
+ "No matching directories found — press Q to exit.",
41
+ width,
42
+ )
43
+ else:
44
+ if idx < top:
45
+ top = idx
46
+ if idx >= top + list_height:
47
+ top = idx - list_height + 1
48
+
49
+ for row in range(list_height):
50
+ i = top + row
51
+ if i >= len(items):
52
+ break
53
+ item = items[i]
54
+ mark = "x" if selected[i] else " "
55
+ line = f"[{mark}] {item.display_label:<18} {format_size(item.size):>8} {item.path}"
56
+ line = truncate(line, width)
57
+ if i == idx:
58
+ stdscr.addnstr(list_top + row, 0, line, width, curses.A_REVERSE)
59
+ else:
60
+ stdscr.addnstr(list_top + row, 0, line, width)
61
+
62
+ selected_count = sum(1 for s in selected if s)
63
+ selected_size = sum(items[i].size for i, s in enumerate(selected) if s)
64
+ footer = f"Selected: {selected_count}/{len(items)} Total: {format_size(selected_size)}"
65
+ if dry_run:
66
+ footer += " [dry-run: would delete, nothing removed]"
67
+ stdscr.addnstr(footer_y, 0, truncate(footer, width), width)
68
+
69
+ stdscr.refresh()
70
+
71
+ key = stdscr.getch()
72
+ if key in (ord("q"), ord("Q"), 27):
73
+ return None
74
+ if key in (curses.KEY_UP,):
75
+ if idx > 0:
76
+ idx -= 1
77
+ elif key in (curses.KEY_DOWN,):
78
+ if idx < len(items) - 1:
79
+ idx += 1
80
+ elif key == ord(" "):
81
+ if len(items) > 0:
82
+ selected[idx] = not selected[idx]
83
+ elif key in (ord("a"), ord("A")):
84
+ selected = [True] * len(items)
85
+ elif key in (ord("d"), ord("D")):
86
+ selected = [False] * len(items)
87
+ elif key in (curses.KEY_ENTER, 10, 13):
88
+ return [i for i, s in enumerate(selected) if s]
89
+
90
+
91
+ def run_interactive(
92
+ renderer: Renderer,
93
+ found: list[CleanableItem],
94
+ dry_run: bool,
95
+ validator: SafetyValidator | None = None,
96
+ *,
97
+ confirm_threshold: int = DEFAULT_LARGE_THRESHOLD,
98
+ ) -> None:
99
+ import curses # Unix-only; imported lazily so this module loads on Windows.
100
+
101
+ items = items_by_size_desc(found)
102
+
103
+ selected_indices = curses.wrapper(interactive_ui, items, dry_run)
104
+ if selected_indices is None:
105
+ renderer.aborted()
106
+ return
107
+
108
+ selected = [items[i] for i in selected_indices]
109
+ if not selected:
110
+ renderer.no_items_selected()
111
+ return
112
+
113
+ total_size = sum(item.size for item in selected)
114
+
115
+ if dry_run:
116
+ renderer.dry_run_selected(len(selected))
117
+ return
118
+
119
+ # Large selections require an explicit typed confirmation even here.
120
+ if exceeds_threshold(total_size, confirm_threshold):
121
+ if not confirm_large_deletion(len(selected), total_size, confirm_threshold):
122
+ renderer.aborted()
123
+ return
124
+
125
+ result = delete_items(selected, total_size, validator=validator)
126
+ renderer.deletion_result(result)