ptdu 0.1.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.
- ptdu/__init__.py +38 -0
- ptdu/cache.py +374 -0
- ptdu/errors.py +627 -0
- ptdu/fonts.py +237 -0
- ptdu/main.py +118 -0
- ptdu/models.py +130 -0
- ptdu/performance.py +348 -0
- ptdu/scanner.py +490 -0
- ptdu/threads.py +250 -0
- ptdu/treeview.py +426 -0
- ptdu/ui.py +1247 -0
- ptdu/utils.py +80 -0
- ptdu-0.1.0.dist-info/METADATA +341 -0
- ptdu-0.1.0.dist-info/RECORD +17 -0
- ptdu-0.1.0.dist-info/WHEEL +4 -0
- ptdu-0.1.0.dist-info/entry_points.txt +2 -0
- ptdu-0.1.0.dist-info/licenses/LICENSE +21 -0
ptdu/ui.py
ADDED
|
@@ -0,0 +1,1247 @@
|
|
|
1
|
+
"""UI components for PTDU."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import csv
|
|
6
|
+
import json
|
|
7
|
+
import tkinter as tk
|
|
8
|
+
from enum import Enum, auto
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
from tkinter import filedialog, messagebox, ttk
|
|
11
|
+
from typing import Optional, TypedDict
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class ExportDataItem(TypedDict):
|
|
15
|
+
"""Type for export data items."""
|
|
16
|
+
|
|
17
|
+
path: str
|
|
18
|
+
name: str
|
|
19
|
+
size: int
|
|
20
|
+
size_human: int
|
|
21
|
+
is_dir: bool
|
|
22
|
+
item_count: int
|
|
23
|
+
|
|
24
|
+
from ptdu.cache import ScanCache
|
|
25
|
+
from ptdu.errors import ErrorHandler, PathValidator, get_error_handler
|
|
26
|
+
from ptdu.fonts import FontManager
|
|
27
|
+
from ptdu.models import DirNode
|
|
28
|
+
from ptdu.performance import LargeDirectoryHandler, VirtualScroller
|
|
29
|
+
from ptdu.scanner import Scanner
|
|
30
|
+
from ptdu.threads import (
|
|
31
|
+
ScanComplete,
|
|
32
|
+
ScanError,
|
|
33
|
+
ScanMessage,
|
|
34
|
+
ScanProgress,
|
|
35
|
+
ScanThreadManager,
|
|
36
|
+
)
|
|
37
|
+
from ptdu.treeview import DirectoryTreeview
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
class SortMode(Enum):
|
|
41
|
+
"""Sorting modes for directory contents."""
|
|
42
|
+
|
|
43
|
+
SIZE = auto()
|
|
44
|
+
NAME = auto()
|
|
45
|
+
COUNT = auto()
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
class MainWindow:
|
|
49
|
+
"""Main application window for PTDU."""
|
|
50
|
+
|
|
51
|
+
DEFAULT_WIDTH: int = 900
|
|
52
|
+
DEFAULT_HEIGHT: int = 600
|
|
53
|
+
MESSAGE_CHECK_INTERVAL: int = 50 # milliseconds
|
|
54
|
+
|
|
55
|
+
# Sort mode display names
|
|
56
|
+
SORT_MODE_NAMES: dict[SortMode, str] = {
|
|
57
|
+
SortMode.SIZE: "Size",
|
|
58
|
+
SortMode.NAME: "Name",
|
|
59
|
+
SortMode.COUNT: "Items",
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
def __init__(self, root: Optional[tk.Tk] = None) -> None:
|
|
63
|
+
"""Initialize the main window.
|
|
64
|
+
|
|
65
|
+
Args:
|
|
66
|
+
root: Optional Tk root window. Creates one if not provided.
|
|
67
|
+
"""
|
|
68
|
+
self.root: tk.Tk = root if root is not None else tk.Tk()
|
|
69
|
+
self._setup_window()
|
|
70
|
+
|
|
71
|
+
# Font manager for OS-aware fonts (must be before _create_layout)
|
|
72
|
+
self._font_manager: FontManager = FontManager(self.root)
|
|
73
|
+
|
|
74
|
+
self._create_layout()
|
|
75
|
+
|
|
76
|
+
# Thread manager for background scanning
|
|
77
|
+
self._scan_manager: ScanThreadManager = ScanThreadManager()
|
|
78
|
+
self._scanner: Optional[Scanner] = None
|
|
79
|
+
self._after_id: Optional[str] = None
|
|
80
|
+
self._root_path: Path = Path(".")
|
|
81
|
+
|
|
82
|
+
# Breadcrumb UI components (initialized on demand)
|
|
83
|
+
self._breadcrumb_container: ttk.Frame | None = None
|
|
84
|
+
|
|
85
|
+
# Sorting mode
|
|
86
|
+
self._sort_mode: SortMode = SortMode.SIZE
|
|
87
|
+
|
|
88
|
+
# Filter state
|
|
89
|
+
self._show_hidden: bool = True
|
|
90
|
+
|
|
91
|
+
# Error handler
|
|
92
|
+
self._error_handler: ErrorHandler = get_error_handler()
|
|
93
|
+
self._error_handler.set_parent(self.root)
|
|
94
|
+
|
|
95
|
+
# Performance optimizations
|
|
96
|
+
self._virtual_scroller: VirtualScroller = VirtualScroller()
|
|
97
|
+
self._large_dir_handler: LargeDirectoryHandler = LargeDirectoryHandler()
|
|
98
|
+
self._max_depth: Optional[int] = None
|
|
99
|
+
|
|
100
|
+
# Cache reference
|
|
101
|
+
self._cache: Optional[ScanCache] = None
|
|
102
|
+
|
|
103
|
+
# Start checking for messages
|
|
104
|
+
self._schedule_message_check()
|
|
105
|
+
|
|
106
|
+
# Bind events
|
|
107
|
+
self._bind_events()
|
|
108
|
+
|
|
109
|
+
def _bind_events(self) -> None:
|
|
110
|
+
"""Bind keyboard and mouse events."""
|
|
111
|
+
# Treeview selection and expansion events
|
|
112
|
+
self.treeview.bind("<Double-1>", self._on_double_click)
|
|
113
|
+
self.treeview.bind("<Return>", self._on_enter_key)
|
|
114
|
+
|
|
115
|
+
# Keyboard navigation (vim-style)
|
|
116
|
+
self.root.bind("j", self._on_down)
|
|
117
|
+
self.root.bind("k", self._on_up)
|
|
118
|
+
self.root.bind("<Down>", self._on_down)
|
|
119
|
+
self.root.bind("<Up>", self._on_up)
|
|
120
|
+
|
|
121
|
+
# Expand/collapse
|
|
122
|
+
self.root.bind("h", self._on_collapse)
|
|
123
|
+
self.root.bind("l", self._on_expand)
|
|
124
|
+
self.root.bind("<Left>", self._on_collapse)
|
|
125
|
+
self.root.bind("<Right>", self._on_expand)
|
|
126
|
+
|
|
127
|
+
# Jump to top/bottom
|
|
128
|
+
self.root.bind("g", self._on_jump_top)
|
|
129
|
+
self.root.bind("G", self._on_jump_bottom)
|
|
130
|
+
|
|
131
|
+
# Action keys
|
|
132
|
+
self.root.bind("q", self._on_quit)
|
|
133
|
+
self.root.bind("r", self._on_rescan)
|
|
134
|
+
self.root.bind("d", self._on_delete)
|
|
135
|
+
self.root.bind("b", self._on_toggle_breadcrumb)
|
|
136
|
+
self.root.bind("u", self._on_parent_directory)
|
|
137
|
+
|
|
138
|
+
# Font size controls (Ctrl+Plus/Ctrl+Minus)
|
|
139
|
+
self.root.bind("<Control-plus>", self._on_increase_font)
|
|
140
|
+
self.root.bind(
|
|
141
|
+
"<Control-equal>", self._on_increase_font
|
|
142
|
+
) # For keyboards without numpad
|
|
143
|
+
self.root.bind("<Control-minus>", self._on_decrease_font)
|
|
144
|
+
self.root.bind("<Control-0>", self._on_reset_font)
|
|
145
|
+
|
|
146
|
+
# Sorting (s key to cycle)
|
|
147
|
+
self.root.bind("s", self._on_cycle_sort)
|
|
148
|
+
|
|
149
|
+
# Toggle hidden files (. key)
|
|
150
|
+
self.root.bind(".", self._on_toggle_hidden)
|
|
151
|
+
|
|
152
|
+
# Export (e key)
|
|
153
|
+
self.root.bind("e", self._on_export)
|
|
154
|
+
|
|
155
|
+
# Open folder (Ctrl+O)
|
|
156
|
+
self.root.bind("<Control-o>", self._on_open_folder_event)
|
|
157
|
+
|
|
158
|
+
# Breadcrumb frame (initially hidden)
|
|
159
|
+
self._breadcrumb_visible: bool = False
|
|
160
|
+
self._breadcrumb_frame: ttk.Frame | None = None
|
|
161
|
+
self._breadcrumb_labels: list[ttk.Label] = []
|
|
162
|
+
|
|
163
|
+
def _on_double_click(self, event: tk.Event) -> str | None:
|
|
164
|
+
"""Handle double-click on treeview item."""
|
|
165
|
+
item = self.treeview.identify_row(event.y)
|
|
166
|
+
if item:
|
|
167
|
+
self._toggle_expand_collapse(item)
|
|
168
|
+
return None
|
|
169
|
+
|
|
170
|
+
def _on_enter_key(self, event: tk.Event) -> str | None:
|
|
171
|
+
"""Handle Enter key press."""
|
|
172
|
+
selection = self.treeview.selection()
|
|
173
|
+
if selection:
|
|
174
|
+
self._toggle_expand_collapse(selection[0])
|
|
175
|
+
return None
|
|
176
|
+
|
|
177
|
+
def _toggle_expand_collapse(self, item: str) -> None:
|
|
178
|
+
"""Toggle expand/collapse state of an item."""
|
|
179
|
+
node = self.treeview.get_node(item)
|
|
180
|
+
if node is None or not node.is_dir:
|
|
181
|
+
return
|
|
182
|
+
|
|
183
|
+
if self.treeview.item(item, "open"):
|
|
184
|
+
self.treeview.collapse_node(item)
|
|
185
|
+
else:
|
|
186
|
+
self.treeview.expand_node(item)
|
|
187
|
+
|
|
188
|
+
def _on_down(self, event: tk.Event) -> str | None:
|
|
189
|
+
"""Move selection down."""
|
|
190
|
+
selection = self.treeview.selection()
|
|
191
|
+
if not selection:
|
|
192
|
+
# Select first item
|
|
193
|
+
children = self.treeview.get_children()
|
|
194
|
+
if children:
|
|
195
|
+
self.treeview.select_node(children[0])
|
|
196
|
+
else:
|
|
197
|
+
# Get next sibling or parent's next sibling
|
|
198
|
+
current = selection[0]
|
|
199
|
+
next_item = self.treeview.next(current)
|
|
200
|
+
if next_item:
|
|
201
|
+
self.treeview.select_node(next_item)
|
|
202
|
+
else:
|
|
203
|
+
# Try to find next of parent
|
|
204
|
+
parent = self.treeview.parent(current)
|
|
205
|
+
if parent:
|
|
206
|
+
next_of_parent = self.treeview.next(parent)
|
|
207
|
+
if next_of_parent:
|
|
208
|
+
self.treeview.select_node(next_of_parent)
|
|
209
|
+
return "break"
|
|
210
|
+
|
|
211
|
+
def _on_up(self, event: tk.Event) -> str | None:
|
|
212
|
+
"""Move selection up."""
|
|
213
|
+
selection = self.treeview.selection()
|
|
214
|
+
if not selection:
|
|
215
|
+
return "break"
|
|
216
|
+
|
|
217
|
+
current = selection[0]
|
|
218
|
+
prev_item = self.treeview.prev(current)
|
|
219
|
+
if prev_item:
|
|
220
|
+
self.treeview.select_node(prev_item)
|
|
221
|
+
else:
|
|
222
|
+
# Select parent
|
|
223
|
+
parent = self.treeview.parent(current)
|
|
224
|
+
if parent:
|
|
225
|
+
self.treeview.select_node(parent)
|
|
226
|
+
return "break"
|
|
227
|
+
|
|
228
|
+
def _on_expand(self, event: tk.Event) -> str | None:
|
|
229
|
+
"""Expand selected directory."""
|
|
230
|
+
selection = self.treeview.selection()
|
|
231
|
+
if selection:
|
|
232
|
+
self.treeview.expand_node(selection[0])
|
|
233
|
+
return "break"
|
|
234
|
+
|
|
235
|
+
def _on_collapse(self, event: tk.Event) -> str | None:
|
|
236
|
+
"""Collapse selected directory."""
|
|
237
|
+
selection = self.treeview.selection()
|
|
238
|
+
if selection:
|
|
239
|
+
self.treeview.collapse_node(selection[0])
|
|
240
|
+
return "break"
|
|
241
|
+
|
|
242
|
+
def _on_jump_top(self, event: tk.Event) -> str | None:
|
|
243
|
+
"""Jump to first item."""
|
|
244
|
+
children = self.treeview.get_children()
|
|
245
|
+
if children:
|
|
246
|
+
self.treeview.select_node(children[0])
|
|
247
|
+
return "break"
|
|
248
|
+
|
|
249
|
+
def _on_jump_bottom(self, event: tk.Event) -> str | None:
|
|
250
|
+
"""Jump to last visible item."""
|
|
251
|
+
|
|
252
|
+
# Get all visible items
|
|
253
|
+
def get_last_visible(item: str) -> str:
|
|
254
|
+
if self.treeview.item(item, "open"):
|
|
255
|
+
children = self.treeview.get_children(item)
|
|
256
|
+
if children:
|
|
257
|
+
return get_last_visible(children[-1])
|
|
258
|
+
return item
|
|
259
|
+
|
|
260
|
+
children = self.treeview.get_children()
|
|
261
|
+
if children:
|
|
262
|
+
last = get_last_visible(children[-1])
|
|
263
|
+
self.treeview.select_node(last)
|
|
264
|
+
return "break"
|
|
265
|
+
|
|
266
|
+
def _on_quit(self, event: tk.Event) -> str | None:
|
|
267
|
+
"""Quit application."""
|
|
268
|
+
self.quit()
|
|
269
|
+
return "break"
|
|
270
|
+
|
|
271
|
+
def _on_rescan(self, event: tk.Event) -> str | None:
|
|
272
|
+
"""Rescan current directory."""
|
|
273
|
+
selection = self.treeview.selection()
|
|
274
|
+
if selection:
|
|
275
|
+
node = self.treeview.get_node(selection[0])
|
|
276
|
+
if node is not None and node.is_dir:
|
|
277
|
+
self.treeview.delete(*self.treeview.get_children(selection[0]))
|
|
278
|
+
node.children.clear()
|
|
279
|
+
self.start_background_scan(node.path, node, selection[0])
|
|
280
|
+
else:
|
|
281
|
+
# Rescan root
|
|
282
|
+
self.treeview.clear()
|
|
283
|
+
if self._scanner is not None:
|
|
284
|
+
self.start_background_scan(self._root_path)
|
|
285
|
+
return "break"
|
|
286
|
+
|
|
287
|
+
def _on_delete(self, event: tk.Event) -> str | None:
|
|
288
|
+
"""Delete selected item with confirmation."""
|
|
289
|
+
selection = self.treeview.selection()
|
|
290
|
+
if not selection:
|
|
291
|
+
return "break"
|
|
292
|
+
|
|
293
|
+
node = self.treeview.get_node(selection[0])
|
|
294
|
+
if node is None:
|
|
295
|
+
return "break"
|
|
296
|
+
|
|
297
|
+
# Show confirmation dialog
|
|
298
|
+
item_type = "directory" if node.is_dir else "file"
|
|
299
|
+
confirm = messagebox.askyesno(
|
|
300
|
+
"Confirm Delete",
|
|
301
|
+
f"Are you sure you want to delete this {item_type}?\n\n{node.path}",
|
|
302
|
+
icon="warning",
|
|
303
|
+
)
|
|
304
|
+
|
|
305
|
+
if not confirm:
|
|
306
|
+
return "break"
|
|
307
|
+
|
|
308
|
+
try:
|
|
309
|
+
# Try to use send2trash first (safer - moves to trash)
|
|
310
|
+
try:
|
|
311
|
+
from send2trash import send2trash
|
|
312
|
+
|
|
313
|
+
send2trash(str(node.path))
|
|
314
|
+
method = "moved to trash"
|
|
315
|
+
except ImportError:
|
|
316
|
+
# Fallback to permanent deletion
|
|
317
|
+
import shutil
|
|
318
|
+
|
|
319
|
+
if node.is_dir:
|
|
320
|
+
shutil.rmtree(node.path)
|
|
321
|
+
else:
|
|
322
|
+
node.path.unlink()
|
|
323
|
+
method = "deleted permanently"
|
|
324
|
+
|
|
325
|
+
# Remove from treeview
|
|
326
|
+
self.treeview.delete_node(selection[0])
|
|
327
|
+
|
|
328
|
+
# Update status
|
|
329
|
+
self.set_status(f"Deleted: {node.name} ({method})")
|
|
330
|
+
|
|
331
|
+
except Exception as e:
|
|
332
|
+
messagebox.showerror(
|
|
333
|
+
"Delete Failed",
|
|
334
|
+
f"Failed to delete {node.name}:\n{str(e)}",
|
|
335
|
+
)
|
|
336
|
+
|
|
337
|
+
return "break"
|
|
338
|
+
|
|
339
|
+
def _on_toggle_breadcrumb(self, event: tk.Event) -> str | None:
|
|
340
|
+
"""Toggle breadcrumb path bar visibility."""
|
|
341
|
+
if self._breadcrumb_frame is None:
|
|
342
|
+
self._create_breadcrumb()
|
|
343
|
+
|
|
344
|
+
if self._breadcrumb_frame is not None:
|
|
345
|
+
if self._breadcrumb_visible:
|
|
346
|
+
self._breadcrumb_frame.grid_remove()
|
|
347
|
+
self._breadcrumb_visible = False
|
|
348
|
+
else:
|
|
349
|
+
self._breadcrumb_frame.grid()
|
|
350
|
+
self._update_breadcrumb()
|
|
351
|
+
self._breadcrumb_visible = True
|
|
352
|
+
|
|
353
|
+
return "break"
|
|
354
|
+
|
|
355
|
+
def _create_breadcrumb(self) -> None:
|
|
356
|
+
"""Create the breadcrumb path bar."""
|
|
357
|
+
self._breadcrumb_frame = ttk.Frame(
|
|
358
|
+
self.main_frame, relief="groove", padding="2"
|
|
359
|
+
)
|
|
360
|
+
self._breadcrumb_frame.grid(row=0, column=0, sticky="ew", pady=(0, 5))
|
|
361
|
+
self._breadcrumb_frame.grid_remove() # Initially hidden
|
|
362
|
+
|
|
363
|
+
# Container for path segments
|
|
364
|
+
self._breadcrumb_container = ttk.Frame(self._breadcrumb_frame)
|
|
365
|
+
self._breadcrumb_container.pack(fill="x", expand=True)
|
|
366
|
+
|
|
367
|
+
def _update_breadcrumb(self) -> None:
|
|
368
|
+
"""Update breadcrumb display with current path."""
|
|
369
|
+
if self._breadcrumb_frame is None:
|
|
370
|
+
return
|
|
371
|
+
|
|
372
|
+
# Clear existing labels
|
|
373
|
+
for label in self._breadcrumb_labels:
|
|
374
|
+
label.destroy()
|
|
375
|
+
self._breadcrumb_labels.clear()
|
|
376
|
+
|
|
377
|
+
# Get current path from selection or root
|
|
378
|
+
selection = self.treeview.selection()
|
|
379
|
+
if selection:
|
|
380
|
+
node = self.treeview.get_node(selection[0])
|
|
381
|
+
current_path = node.path if node else self._root_path
|
|
382
|
+
else:
|
|
383
|
+
current_path = getattr(self, "_root_path", Path("."))
|
|
384
|
+
|
|
385
|
+
# Create path segments
|
|
386
|
+
parts: list[Path] = []
|
|
387
|
+
current = current_path
|
|
388
|
+
while current != current.parent:
|
|
389
|
+
parts.insert(0, current)
|
|
390
|
+
current = current.parent
|
|
391
|
+
parts.insert(0, current) # Add root
|
|
392
|
+
|
|
393
|
+
# Create labels for each segment
|
|
394
|
+
for i, part in enumerate(parts):
|
|
395
|
+
# Separator
|
|
396
|
+
if i > 0:
|
|
397
|
+
sep = ttk.Label(self._breadcrumb_container, text=" > ")
|
|
398
|
+
sep.pack(side="left")
|
|
399
|
+
self._breadcrumb_labels.append(sep)
|
|
400
|
+
|
|
401
|
+
# Clickable path segment
|
|
402
|
+
label = ttk.Label(
|
|
403
|
+
self._breadcrumb_container,
|
|
404
|
+
text=part.name if part.name else str(part),
|
|
405
|
+
foreground="blue",
|
|
406
|
+
cursor="hand2",
|
|
407
|
+
)
|
|
408
|
+
label.pack(side="left")
|
|
409
|
+
label.bind(
|
|
410
|
+
"<Button-1>",
|
|
411
|
+
lambda e, p=part: self._navigate_to_path(p), # type: ignore[misc]
|
|
412
|
+
)
|
|
413
|
+
self._breadcrumb_labels.append(label)
|
|
414
|
+
|
|
415
|
+
def _navigate_to_path(self, path: Path) -> None:
|
|
416
|
+
"""Navigate to a specific path."""
|
|
417
|
+
# Find the item in treeview
|
|
418
|
+
iid = str(path)
|
|
419
|
+
node = self.treeview.get_node(iid)
|
|
420
|
+
|
|
421
|
+
if node is not None:
|
|
422
|
+
# Select and show the node
|
|
423
|
+
self.treeview.select_node(iid)
|
|
424
|
+
# Expand all parents
|
|
425
|
+
current = iid
|
|
426
|
+
while True:
|
|
427
|
+
parent = self.treeview.parent(current)
|
|
428
|
+
if not parent:
|
|
429
|
+
break
|
|
430
|
+
self.treeview.expand_node(parent)
|
|
431
|
+
current = parent
|
|
432
|
+
|
|
433
|
+
def _on_parent_directory(self, event: tk.Event) -> str | None:
|
|
434
|
+
"""Navigate to parent directory."""
|
|
435
|
+
selection = self.treeview.selection()
|
|
436
|
+
if not selection:
|
|
437
|
+
# If nothing selected, select first item
|
|
438
|
+
children = self.treeview.get_children()
|
|
439
|
+
if children:
|
|
440
|
+
self.treeview.select_node(children[0])
|
|
441
|
+
return "break"
|
|
442
|
+
|
|
443
|
+
current = selection[0]
|
|
444
|
+
parent = self.treeview.parent(current)
|
|
445
|
+
|
|
446
|
+
if parent:
|
|
447
|
+
self.treeview.select_node(parent)
|
|
448
|
+
else:
|
|
449
|
+
# Already at root, try to find parent in filesystem
|
|
450
|
+
node = self.treeview.get_node(current)
|
|
451
|
+
if node and node.path != node.path.parent:
|
|
452
|
+
# Create parent node if needed
|
|
453
|
+
parent_path = node.path.parent
|
|
454
|
+
parent_iid = str(parent_path)
|
|
455
|
+
|
|
456
|
+
# Check if parent already exists
|
|
457
|
+
if self.treeview.get_node(parent_iid) is None:
|
|
458
|
+
parent_node = DirNode(
|
|
459
|
+
path=parent_path,
|
|
460
|
+
name=parent_path.name if parent_path.name else str(parent_path),
|
|
461
|
+
is_dir=True,
|
|
462
|
+
)
|
|
463
|
+
parent_node.is_expanded = True
|
|
464
|
+
# Insert at top level (we'll need to reorganize tree)
|
|
465
|
+
self.treeview.insert_node("", parent_node)
|
|
466
|
+
|
|
467
|
+
self.treeview.select_node(parent_iid)
|
|
468
|
+
|
|
469
|
+
return "break"
|
|
470
|
+
|
|
471
|
+
def _on_cycle_sort(self, event: tk.Event) -> str | None:
|
|
472
|
+
"""Cycle through sorting modes."""
|
|
473
|
+
# Get next sort mode
|
|
474
|
+
modes = list(SortMode)
|
|
475
|
+
current_index = modes.index(self._sort_mode)
|
|
476
|
+
next_index = (current_index + 1) % len(modes)
|
|
477
|
+
self._sort_mode = modes[next_index]
|
|
478
|
+
|
|
479
|
+
# Show status
|
|
480
|
+
self.set_status(f"Sort: {self.SORT_MODE_NAMES[self._sort_mode]}")
|
|
481
|
+
|
|
482
|
+
# Re-sort the current view if we have items
|
|
483
|
+
self._resort_treeview()
|
|
484
|
+
|
|
485
|
+
return "break"
|
|
486
|
+
|
|
487
|
+
def _on_toggle_hidden(self, event: tk.Event) -> str | None:
|
|
488
|
+
"""Toggle visibility of hidden files (dotfiles)."""
|
|
489
|
+
self._show_hidden = not self._show_hidden
|
|
490
|
+
|
|
491
|
+
status = "shown" if self._show_hidden else "hidden"
|
|
492
|
+
self.set_status(f"Hidden files: {status}")
|
|
493
|
+
|
|
494
|
+
# Trigger a rescan to apply the filter
|
|
495
|
+
self._on_rescan(event)
|
|
496
|
+
|
|
497
|
+
return "break"
|
|
498
|
+
|
|
499
|
+
def _on_open_folder_event(self, event: tk.Event) -> str | None:
|
|
500
|
+
"""Event handler for open folder."""
|
|
501
|
+
self._on_open_folder()
|
|
502
|
+
return "break"
|
|
503
|
+
|
|
504
|
+
def _on_export(self, event: tk.Event) -> str | None:
|
|
505
|
+
"""Export current scan results to JSON or CSV."""
|
|
506
|
+
# Ask user for export format
|
|
507
|
+
formats = [
|
|
508
|
+
("JSON files", "*.json"),
|
|
509
|
+
("CSV files", "*.csv"),
|
|
510
|
+
("All files", "*.*"),
|
|
511
|
+
]
|
|
512
|
+
filename = filedialog.asksaveasfilename(
|
|
513
|
+
defaultextension=".json",
|
|
514
|
+
filetypes=formats,
|
|
515
|
+
title="Export Scan Results",
|
|
516
|
+
)
|
|
517
|
+
|
|
518
|
+
if not filename:
|
|
519
|
+
return "break"
|
|
520
|
+
|
|
521
|
+
try:
|
|
522
|
+
filepath = Path(filename)
|
|
523
|
+
if filepath.suffix.lower() == ".json":
|
|
524
|
+
self._export_json(filepath)
|
|
525
|
+
elif filepath.suffix.lower() == ".csv":
|
|
526
|
+
self._export_csv(filepath)
|
|
527
|
+
else:
|
|
528
|
+
# Default to JSON
|
|
529
|
+
self._export_json(filepath.with_suffix(".json"))
|
|
530
|
+
|
|
531
|
+
self.set_status(f"Exported to {filepath.name}")
|
|
532
|
+
|
|
533
|
+
except Exception as e:
|
|
534
|
+
messagebox.showerror(
|
|
535
|
+
"Export Failed",
|
|
536
|
+
f"Failed to export results:\n{str(e)}",
|
|
537
|
+
)
|
|
538
|
+
|
|
539
|
+
return "break"
|
|
540
|
+
|
|
541
|
+
def _collect_export_data(self) -> list[ExportDataItem]:
|
|
542
|
+
"""Collect current treeview data for export."""
|
|
543
|
+
data: list[ExportDataItem] = []
|
|
544
|
+
|
|
545
|
+
def collect_items(parent_iid: str = "") -> None:
|
|
546
|
+
for iid in self.treeview.get_children(parent_iid):
|
|
547
|
+
node = self.treeview.get_node(iid)
|
|
548
|
+
if node is not None:
|
|
549
|
+
data.append(
|
|
550
|
+
{
|
|
551
|
+
"path": str(node.path),
|
|
552
|
+
"name": node.name,
|
|
553
|
+
"size": node.size,
|
|
554
|
+
"size_human": self.treeview._font_manager.measure_text_width(
|
|
555
|
+
node.name, "treeview"
|
|
556
|
+
),
|
|
557
|
+
"is_dir": node.is_dir,
|
|
558
|
+
"item_count": node.get_item_count() if node.is_dir else 0,
|
|
559
|
+
}
|
|
560
|
+
)
|
|
561
|
+
# Recursively collect children
|
|
562
|
+
if node.is_dir:
|
|
563
|
+
collect_items(iid)
|
|
564
|
+
|
|
565
|
+
collect_items()
|
|
566
|
+
return data
|
|
567
|
+
|
|
568
|
+
def _export_json(self, filepath: Path) -> None:
|
|
569
|
+
"""Export data to JSON format."""
|
|
570
|
+
data = self._collect_export_data()
|
|
571
|
+
|
|
572
|
+
# Add metadata
|
|
573
|
+
export_data = {
|
|
574
|
+
"exported_from": str(self._root_path),
|
|
575
|
+
"total_items": len(data),
|
|
576
|
+
"items": data,
|
|
577
|
+
}
|
|
578
|
+
|
|
579
|
+
with open(filepath, "w", encoding="utf-8") as f:
|
|
580
|
+
json.dump(export_data, f, indent=2, ensure_ascii=False)
|
|
581
|
+
|
|
582
|
+
def _export_csv(self, filepath: Path) -> None:
|
|
583
|
+
"""Export data to CSV format."""
|
|
584
|
+
data = self._collect_export_data()
|
|
585
|
+
|
|
586
|
+
with open(filepath, "w", newline="", encoding="utf-8") as f:
|
|
587
|
+
writer = csv.DictWriter(
|
|
588
|
+
f,
|
|
589
|
+
fieldnames=["path", "name", "size", "is_dir", "item_count"],
|
|
590
|
+
)
|
|
591
|
+
writer.writeheader()
|
|
592
|
+
writer.writerows(data)
|
|
593
|
+
|
|
594
|
+
def _get_sort_key(self, node: DirNode) -> tuple[object, ...]:
|
|
595
|
+
"""Get sort key for a node based on current sort mode.
|
|
596
|
+
|
|
597
|
+
Returns:
|
|
598
|
+
Tuple for sorting (directories first, then by sort criteria)
|
|
599
|
+
"""
|
|
600
|
+
# Always put directories first, then sort by criteria
|
|
601
|
+
if self._sort_mode == SortMode.SIZE:
|
|
602
|
+
# Sort by size descending, then by name ascending
|
|
603
|
+
return (0 if node.is_dir else 1, -node.size, node.name.lower())
|
|
604
|
+
elif self._sort_mode == SortMode.NAME:
|
|
605
|
+
# Sort by name ascending
|
|
606
|
+
return (0 if node.is_dir else 1, node.name.lower())
|
|
607
|
+
else: # SortMode.COUNT
|
|
608
|
+
# Sort by item count descending, then by name
|
|
609
|
+
count = node.get_item_count() if node.is_dir else 0
|
|
610
|
+
return (0 if node.is_dir else 1, -count, node.name.lower())
|
|
611
|
+
|
|
612
|
+
def _resort_treeview(self) -> None:
|
|
613
|
+
"""Re-sort all items in the treeview."""
|
|
614
|
+
# Get all items and their nodes
|
|
615
|
+
items_to_move: list[tuple[str, str, DirNode]] = [] # (parent_iid, iid, node)
|
|
616
|
+
|
|
617
|
+
def collect_items(parent_iid: str = "") -> None:
|
|
618
|
+
for iid in self.treeview.get_children(parent_iid):
|
|
619
|
+
node = self.treeview.get_node(iid)
|
|
620
|
+
if node is not None:
|
|
621
|
+
items_to_move.append((parent_iid, iid, node))
|
|
622
|
+
# Recursively collect children
|
|
623
|
+
if node.is_dir:
|
|
624
|
+
collect_items(iid)
|
|
625
|
+
|
|
626
|
+
collect_items()
|
|
627
|
+
|
|
628
|
+
# Sort by current sort mode
|
|
629
|
+
items_to_move.sort(key=lambda x: self._get_sort_key(x[2]))
|
|
630
|
+
|
|
631
|
+
# Move items to their new positions within each parent
|
|
632
|
+
# Group by parent
|
|
633
|
+
from itertools import groupby
|
|
634
|
+
|
|
635
|
+
items_to_move.sort(key=lambda x: x[0]) # Sort by parent first
|
|
636
|
+
for parent_iid, group in groupby(items_to_move, key=lambda x: x[0]):
|
|
637
|
+
group_list = list(group)
|
|
638
|
+
# Sort this parent's children
|
|
639
|
+
group_list.sort(key=lambda x: self._get_sort_key(x[2]))
|
|
640
|
+
# Move each item to the end in sorted order
|
|
641
|
+
for _, iid, _ in group_list:
|
|
642
|
+
self.treeview.move(iid, parent_iid, "end")
|
|
643
|
+
|
|
644
|
+
def _on_increase_font(self, event: tk.Event) -> str | None:
|
|
645
|
+
"""Increase font size."""
|
|
646
|
+
new_size = self._font_manager.increase_font_size()
|
|
647
|
+
self.treeview.refresh_fonts()
|
|
648
|
+
self._refresh_status_fonts()
|
|
649
|
+
self.set_status(f"Font size: {new_size}pt")
|
|
650
|
+
return "break"
|
|
651
|
+
|
|
652
|
+
def _on_decrease_font(self, event: tk.Event) -> str | None:
|
|
653
|
+
"""Decrease font size."""
|
|
654
|
+
new_size = self._font_manager.decrease_font_size()
|
|
655
|
+
self.treeview.refresh_fonts()
|
|
656
|
+
self._refresh_status_fonts()
|
|
657
|
+
self.set_status(f"Font size: {new_size}pt")
|
|
658
|
+
return "break"
|
|
659
|
+
|
|
660
|
+
def _on_reset_font(self, event: tk.Event) -> str | None:
|
|
661
|
+
"""Reset font size to default."""
|
|
662
|
+
new_size = self._font_manager.reset_font_size()
|
|
663
|
+
self.treeview.refresh_fonts()
|
|
664
|
+
self._refresh_status_fonts()
|
|
665
|
+
self.set_status(f"Font size: {new_size}pt (default)")
|
|
666
|
+
return "break"
|
|
667
|
+
|
|
668
|
+
def _refresh_status_fonts(self) -> None:
|
|
669
|
+
"""Refresh status bar fonts after size change."""
|
|
670
|
+
status_font = self._font_manager.get_status_font()
|
|
671
|
+
self.path_label.configure(font=status_font)
|
|
672
|
+
self.items_label.configure(font=status_font)
|
|
673
|
+
self.status_label.configure(font=status_font)
|
|
674
|
+
|
|
675
|
+
def _setup_window(self) -> None:
|
|
676
|
+
"""Configure the main window properties."""
|
|
677
|
+
self.root.title("PTDU - Python Tkinter Disk Usage")
|
|
678
|
+
self.root.geometry(self._get_centered_geometry())
|
|
679
|
+
self.root.minsize(600, 400)
|
|
680
|
+
|
|
681
|
+
# Set up grid weights for resizing
|
|
682
|
+
self.root.columnconfigure(0, weight=1)
|
|
683
|
+
self.root.rowconfigure(0, weight=1)
|
|
684
|
+
|
|
685
|
+
def _get_centered_geometry(self) -> str:
|
|
686
|
+
"""Calculate centered window geometry string.
|
|
687
|
+
|
|
688
|
+
Returns:
|
|
689
|
+
Geometry string in format 'WIDTHxHEIGHT+X+Y'
|
|
690
|
+
"""
|
|
691
|
+
screen_width = self.root.winfo_screenwidth()
|
|
692
|
+
screen_height = self.root.winfo_screenheight()
|
|
693
|
+
|
|
694
|
+
x = (screen_width - self.DEFAULT_WIDTH) // 2
|
|
695
|
+
y = (screen_height - self.DEFAULT_HEIGHT) // 2
|
|
696
|
+
|
|
697
|
+
return f"{self.DEFAULT_WIDTH}x{self.DEFAULT_HEIGHT}+{x}+{y}"
|
|
698
|
+
|
|
699
|
+
def _create_menubar(self) -> None:
|
|
700
|
+
"""Create the application menubar with keyboard shortcuts."""
|
|
701
|
+
menubar = tk.Menu(self.root)
|
|
702
|
+
self.root.config(menu=menubar)
|
|
703
|
+
|
|
704
|
+
# File Menu
|
|
705
|
+
file_menu = tk.Menu(menubar, tearoff=0)
|
|
706
|
+
menubar.add_cascade(label="File", menu=file_menu)
|
|
707
|
+
file_menu.add_command(
|
|
708
|
+
label="Open Folder...",
|
|
709
|
+
command=self._on_open_folder,
|
|
710
|
+
accelerator="Ctrl+O",
|
|
711
|
+
)
|
|
712
|
+
file_menu.add_separator()
|
|
713
|
+
file_menu.add_command(
|
|
714
|
+
label="Export...",
|
|
715
|
+
command=self._on_export_menu,
|
|
716
|
+
accelerator="E",
|
|
717
|
+
)
|
|
718
|
+
file_menu.add_separator()
|
|
719
|
+
file_menu.add_command(
|
|
720
|
+
label="Quit",
|
|
721
|
+
command=self.quit,
|
|
722
|
+
accelerator="Q",
|
|
723
|
+
)
|
|
724
|
+
|
|
725
|
+
# View Menu
|
|
726
|
+
view_menu = tk.Menu(menubar, tearoff=0)
|
|
727
|
+
menubar.add_cascade(label="View", menu=view_menu)
|
|
728
|
+
view_menu.add_command(
|
|
729
|
+
label="Toggle Hidden Files",
|
|
730
|
+
command=self._on_toggle_hidden_menu,
|
|
731
|
+
accelerator=".",
|
|
732
|
+
)
|
|
733
|
+
view_menu.add_command(
|
|
734
|
+
label="Toggle Breadcrumb",
|
|
735
|
+
command=self._on_toggle_breadcrumb_menu,
|
|
736
|
+
accelerator="B",
|
|
737
|
+
)
|
|
738
|
+
view_menu.add_separator()
|
|
739
|
+
view_menu.add_command(
|
|
740
|
+
label="Increase Font Size",
|
|
741
|
+
command=self._on_increase_font_menu,
|
|
742
|
+
accelerator="Ctrl++",
|
|
743
|
+
)
|
|
744
|
+
view_menu.add_command(
|
|
745
|
+
label="Decrease Font Size",
|
|
746
|
+
command=self._on_decrease_font_menu,
|
|
747
|
+
accelerator="Ctrl+-",
|
|
748
|
+
)
|
|
749
|
+
view_menu.add_command(
|
|
750
|
+
label="Reset Font Size",
|
|
751
|
+
command=self._on_reset_font_menu,
|
|
752
|
+
accelerator="Ctrl+0",
|
|
753
|
+
)
|
|
754
|
+
|
|
755
|
+
# Sort Menu
|
|
756
|
+
sort_menu = tk.Menu(menubar, tearoff=0)
|
|
757
|
+
menubar.add_cascade(label="Sort", menu=sort_menu)
|
|
758
|
+
sort_menu.add_command(
|
|
759
|
+
label="Cycle Sort Mode",
|
|
760
|
+
command=self._on_cycle_sort_menu,
|
|
761
|
+
accelerator="S",
|
|
762
|
+
)
|
|
763
|
+
|
|
764
|
+
# Navigation Menu
|
|
765
|
+
nav_menu = tk.Menu(menubar, tearoff=0)
|
|
766
|
+
menubar.add_cascade(label="Navigation", menu=nav_menu)
|
|
767
|
+
nav_menu.add_command(
|
|
768
|
+
label="Up",
|
|
769
|
+
command=self._on_up_menu,
|
|
770
|
+
accelerator="K or ↑",
|
|
771
|
+
)
|
|
772
|
+
nav_menu.add_command(
|
|
773
|
+
label="Down",
|
|
774
|
+
command=self._on_down_menu,
|
|
775
|
+
accelerator="J or ↓",
|
|
776
|
+
)
|
|
777
|
+
nav_menu.add_separator()
|
|
778
|
+
nav_menu.add_command(
|
|
779
|
+
label="Expand",
|
|
780
|
+
command=self._on_expand_menu,
|
|
781
|
+
accelerator="L or →",
|
|
782
|
+
)
|
|
783
|
+
nav_menu.add_command(
|
|
784
|
+
label="Collapse",
|
|
785
|
+
command=self._on_collapse_menu,
|
|
786
|
+
accelerator="H or ←",
|
|
787
|
+
)
|
|
788
|
+
nav_menu.add_separator()
|
|
789
|
+
nav_menu.add_command(
|
|
790
|
+
label="Jump to Top",
|
|
791
|
+
command=self._on_jump_top_menu,
|
|
792
|
+
accelerator="G",
|
|
793
|
+
)
|
|
794
|
+
nav_menu.add_command(
|
|
795
|
+
label="Jump to Bottom",
|
|
796
|
+
command=self._on_jump_bottom_menu,
|
|
797
|
+
accelerator="Shift+G",
|
|
798
|
+
)
|
|
799
|
+
nav_menu.add_separator()
|
|
800
|
+
nav_menu.add_command(
|
|
801
|
+
label="Go to Parent",
|
|
802
|
+
command=self._on_parent_directory_menu,
|
|
803
|
+
accelerator="U",
|
|
804
|
+
)
|
|
805
|
+
|
|
806
|
+
# Actions Menu
|
|
807
|
+
actions_menu = tk.Menu(menubar, tearoff=0)
|
|
808
|
+
menubar.add_cascade(label="Actions", menu=actions_menu)
|
|
809
|
+
actions_menu.add_command(
|
|
810
|
+
label="Rescan",
|
|
811
|
+
command=self._on_rescan_menu,
|
|
812
|
+
accelerator="R",
|
|
813
|
+
)
|
|
814
|
+
actions_menu.add_command(
|
|
815
|
+
label="Delete Selected",
|
|
816
|
+
command=self._on_delete_menu,
|
|
817
|
+
accelerator="D",
|
|
818
|
+
)
|
|
819
|
+
|
|
820
|
+
def _on_open_folder(self) -> None:
|
|
821
|
+
"""Open a different folder."""
|
|
822
|
+
folder = filedialog.askdirectory(title="Select Folder to Analyze")
|
|
823
|
+
if folder:
|
|
824
|
+
path = Path(folder)
|
|
825
|
+
if path.exists() and path.is_dir():
|
|
826
|
+
self._root_path = path
|
|
827
|
+
self.treeview.clear()
|
|
828
|
+
self.start_background_scan(path)
|
|
829
|
+
|
|
830
|
+
def _on_export_menu(self) -> None:
|
|
831
|
+
"""Menu callback for export."""
|
|
832
|
+
self._on_export(tk.Event())
|
|
833
|
+
|
|
834
|
+
def _on_toggle_hidden_menu(self) -> None:
|
|
835
|
+
"""Menu callback for toggle hidden."""
|
|
836
|
+
self._on_toggle_hidden(tk.Event())
|
|
837
|
+
|
|
838
|
+
def _on_toggle_breadcrumb_menu(self) -> None:
|
|
839
|
+
"""Menu callback for toggle breadcrumb."""
|
|
840
|
+
self._on_toggle_breadcrumb(tk.Event())
|
|
841
|
+
|
|
842
|
+
def _on_increase_font_menu(self) -> None:
|
|
843
|
+
"""Menu callback for increase font."""
|
|
844
|
+
self._on_increase_font(tk.Event())
|
|
845
|
+
|
|
846
|
+
def _on_decrease_font_menu(self) -> None:
|
|
847
|
+
"""Menu callback for decrease font."""
|
|
848
|
+
self._on_decrease_font(tk.Event())
|
|
849
|
+
|
|
850
|
+
def _on_reset_font_menu(self) -> None:
|
|
851
|
+
"""Menu callback for reset font."""
|
|
852
|
+
self._on_reset_font(tk.Event())
|
|
853
|
+
|
|
854
|
+
def _on_cycle_sort_menu(self) -> None:
|
|
855
|
+
"""Menu callback for cycle sort."""
|
|
856
|
+
self._on_cycle_sort(tk.Event())
|
|
857
|
+
|
|
858
|
+
def _on_up_menu(self) -> None:
|
|
859
|
+
"""Menu callback for up navigation."""
|
|
860
|
+
self._on_up(tk.Event())
|
|
861
|
+
|
|
862
|
+
def _on_down_menu(self) -> None:
|
|
863
|
+
"""Menu callback for down navigation."""
|
|
864
|
+
self._on_down(tk.Event())
|
|
865
|
+
|
|
866
|
+
def _on_expand_menu(self) -> None:
|
|
867
|
+
"""Menu callback for expand."""
|
|
868
|
+
self._on_expand(tk.Event())
|
|
869
|
+
|
|
870
|
+
def _on_collapse_menu(self) -> None:
|
|
871
|
+
"""Menu callback for collapse."""
|
|
872
|
+
self._on_collapse(tk.Event())
|
|
873
|
+
|
|
874
|
+
def _on_jump_top_menu(self) -> None:
|
|
875
|
+
"""Menu callback for jump top."""
|
|
876
|
+
self._on_jump_top(tk.Event())
|
|
877
|
+
|
|
878
|
+
def _on_jump_bottom_menu(self) -> None:
|
|
879
|
+
"""Menu callback for jump bottom."""
|
|
880
|
+
self._on_jump_bottom(tk.Event())
|
|
881
|
+
|
|
882
|
+
def _on_parent_directory_menu(self) -> None:
|
|
883
|
+
"""Menu callback for parent directory."""
|
|
884
|
+
self._on_parent_directory(tk.Event())
|
|
885
|
+
|
|
886
|
+
def _on_rescan_menu(self) -> None:
|
|
887
|
+
"""Menu callback for rescan."""
|
|
888
|
+
self._on_rescan(tk.Event())
|
|
889
|
+
|
|
890
|
+
def _on_delete_menu(self) -> None:
|
|
891
|
+
"""Menu callback for delete."""
|
|
892
|
+
self._on_delete(tk.Event())
|
|
893
|
+
|
|
894
|
+
def _create_layout(self) -> None:
|
|
895
|
+
"""Create the main window layout."""
|
|
896
|
+
# Create menubar first
|
|
897
|
+
self._create_menubar()
|
|
898
|
+
|
|
899
|
+
# Main frame
|
|
900
|
+
self.main_frame = ttk.Frame(self.root, padding="5")
|
|
901
|
+
self.main_frame.grid(row=0, column=0, sticky="nsew")
|
|
902
|
+
self.main_frame.columnconfigure(0, weight=1)
|
|
903
|
+
self.main_frame.rowconfigure(0, weight=1)
|
|
904
|
+
|
|
905
|
+
# Treeview frame with scrollbar
|
|
906
|
+
self.tree_frame = ttk.Frame(self.main_frame)
|
|
907
|
+
self.tree_frame.grid(row=0, column=0, sticky="nsew", pady=(0, 5))
|
|
908
|
+
self.tree_frame.columnconfigure(0, weight=1)
|
|
909
|
+
self.tree_frame.rowconfigure(0, weight=1)
|
|
910
|
+
|
|
911
|
+
# Directory treeview with font manager
|
|
912
|
+
self.treeview = DirectoryTreeview(self.tree_frame, self._font_manager)
|
|
913
|
+
self.treeview.grid(row=0, column=0, sticky="nsew")
|
|
914
|
+
|
|
915
|
+
# Vertical scrollbar
|
|
916
|
+
self.vscrollbar = ttk.Scrollbar(
|
|
917
|
+
self.tree_frame,
|
|
918
|
+
orient="vertical",
|
|
919
|
+
command=self.treeview.yview,
|
|
920
|
+
)
|
|
921
|
+
self.vscrollbar.grid(row=0, column=1, sticky="ns")
|
|
922
|
+
self.treeview.configure(yscrollcommand=self.vscrollbar.set)
|
|
923
|
+
|
|
924
|
+
# Horizontal scrollbar
|
|
925
|
+
self.hscrollbar = ttk.Scrollbar(
|
|
926
|
+
self.tree_frame,
|
|
927
|
+
orient="horizontal",
|
|
928
|
+
command=self.treeview.xview,
|
|
929
|
+
)
|
|
930
|
+
self.hscrollbar.grid(row=1, column=0, sticky="ew")
|
|
931
|
+
self.treeview.configure(xscrollcommand=self.hscrollbar.set)
|
|
932
|
+
|
|
933
|
+
# Status bar
|
|
934
|
+
self._create_status_bar()
|
|
935
|
+
|
|
936
|
+
def _create_status_bar(self) -> None:
|
|
937
|
+
"""Create the status bar at the bottom."""
|
|
938
|
+
status_font = self._font_manager.get_status_font()
|
|
939
|
+
|
|
940
|
+
# Use a frame with custom background for better styling
|
|
941
|
+
self.status_frame = tk.Frame(
|
|
942
|
+
self.main_frame,
|
|
943
|
+
relief="sunken",
|
|
944
|
+
borderwidth=1,
|
|
945
|
+
background="#f0f0f0",
|
|
946
|
+
)
|
|
947
|
+
self.status_frame.grid(row=1, column=0, sticky="ew", pady=(5, 0))
|
|
948
|
+
self.status_frame.columnconfigure(0, weight=1)
|
|
949
|
+
|
|
950
|
+
# Path label
|
|
951
|
+
self.path_var = tk.StringVar(value="Ready")
|
|
952
|
+
self.path_label = tk.Label(
|
|
953
|
+
self.status_frame,
|
|
954
|
+
textvariable=self.path_var,
|
|
955
|
+
font=status_font,
|
|
956
|
+
background="#f0f0f0",
|
|
957
|
+
foreground="#333333",
|
|
958
|
+
padx=10,
|
|
959
|
+
pady=4,
|
|
960
|
+
)
|
|
961
|
+
self.path_label.grid(row=0, column=0, sticky="w")
|
|
962
|
+
|
|
963
|
+
# Separator
|
|
964
|
+
separator = tk.Frame(
|
|
965
|
+
self.status_frame,
|
|
966
|
+
width=1,
|
|
967
|
+
background="#cccccc",
|
|
968
|
+
)
|
|
969
|
+
separator.grid(row=0, column=1, sticky="ns", padx=5, pady=4)
|
|
970
|
+
|
|
971
|
+
# Items count label
|
|
972
|
+
self.items_var = tk.StringVar(value="Items: 0")
|
|
973
|
+
self.items_label = tk.Label(
|
|
974
|
+
self.status_frame,
|
|
975
|
+
textvariable=self.items_var,
|
|
976
|
+
font=status_font,
|
|
977
|
+
background="#f0f0f0",
|
|
978
|
+
foreground="#666666",
|
|
979
|
+
padx=10,
|
|
980
|
+
pady=4,
|
|
981
|
+
)
|
|
982
|
+
self.items_label.grid(row=0, column=2, sticky="e")
|
|
983
|
+
|
|
984
|
+
# Separator
|
|
985
|
+
separator2 = tk.Frame(
|
|
986
|
+
self.status_frame,
|
|
987
|
+
width=1,
|
|
988
|
+
background="#cccccc",
|
|
989
|
+
)
|
|
990
|
+
separator2.grid(row=0, column=3, sticky="ns", padx=5, pady=4)
|
|
991
|
+
|
|
992
|
+
# Status label
|
|
993
|
+
self.status_var = tk.StringVar(value="")
|
|
994
|
+
self.status_label = tk.Label(
|
|
995
|
+
self.status_frame,
|
|
996
|
+
textvariable=self.status_var,
|
|
997
|
+
font=status_font,
|
|
998
|
+
background="#f0f0f0",
|
|
999
|
+
foreground="#0066cc",
|
|
1000
|
+
padx=10,
|
|
1001
|
+
pady=4,
|
|
1002
|
+
)
|
|
1003
|
+
self.status_label.grid(row=0, column=4, sticky="e")
|
|
1004
|
+
|
|
1005
|
+
def set_scanner(self, scanner: Scanner) -> None:
|
|
1006
|
+
"""Set the scanner instance to use for background scans.
|
|
1007
|
+
|
|
1008
|
+
Args:
|
|
1009
|
+
scanner: Scanner instance
|
|
1010
|
+
"""
|
|
1011
|
+
self._scanner = scanner
|
|
1012
|
+
# Extract cache from scanner if available
|
|
1013
|
+
if hasattr(scanner, "_cache"):
|
|
1014
|
+
self._cache = scanner._cache
|
|
1015
|
+
|
|
1016
|
+
def set_max_depth(self, depth: int) -> None:
|
|
1017
|
+
"""Set maximum scan depth.
|
|
1018
|
+
|
|
1019
|
+
Args:
|
|
1020
|
+
depth: Maximum recursion depth
|
|
1021
|
+
"""
|
|
1022
|
+
self._max_depth = depth
|
|
1023
|
+
|
|
1024
|
+
def start_background_scan(
|
|
1025
|
+
self,
|
|
1026
|
+
path: Path,
|
|
1027
|
+
parent_node: Optional[DirNode] = None,
|
|
1028
|
+
parent_iid: str = "",
|
|
1029
|
+
) -> None:
|
|
1030
|
+
"""Start a background scan of a directory.
|
|
1031
|
+
|
|
1032
|
+
Args:
|
|
1033
|
+
path: Directory path to scan
|
|
1034
|
+
parent_node: Optional parent node in the tree
|
|
1035
|
+
parent_iid: Parent item ID in the treeview
|
|
1036
|
+
"""
|
|
1037
|
+
if self._scanner is None:
|
|
1038
|
+
raise RuntimeError("Scanner not set. Call set_scanner() first.")
|
|
1039
|
+
|
|
1040
|
+
# Validate path
|
|
1041
|
+
is_valid, error_msg = PathValidator.validate_path(path)
|
|
1042
|
+
if not is_valid:
|
|
1043
|
+
self._error_handler.show_error("Invalid Path", error_msg or "Unknown error")
|
|
1044
|
+
return
|
|
1045
|
+
|
|
1046
|
+
# Check for long path
|
|
1047
|
+
if len(str(path)) > PathValidator.MAX_SAFE_PATH_LENGTH:
|
|
1048
|
+
self._error_handler.handle_path_too_long(path)
|
|
1049
|
+
|
|
1050
|
+
# Configure scanner with current filter settings
|
|
1051
|
+
self._scanner.set_show_hidden(self._show_hidden)
|
|
1052
|
+
|
|
1053
|
+
# Update status
|
|
1054
|
+
self.set_path(str(path))
|
|
1055
|
+
self.set_status("Scanning...")
|
|
1056
|
+
|
|
1057
|
+
# Store root path for rescan
|
|
1058
|
+
self._root_path = path
|
|
1059
|
+
|
|
1060
|
+
# Start the scan in background
|
|
1061
|
+
try:
|
|
1062
|
+
self._scan_manager.start_scan(path, self._scanner, parent_node)
|
|
1063
|
+
except Exception as e:
|
|
1064
|
+
self._error_handler.handle_scan_error(path, e, continue_scan=False)
|
|
1065
|
+
|
|
1066
|
+
def _schedule_message_check(self) -> None:
|
|
1067
|
+
"""Schedule periodic checking for scan messages."""
|
|
1068
|
+
self._check_messages()
|
|
1069
|
+
self._after_id = self.root.after(
|
|
1070
|
+
self.MESSAGE_CHECK_INTERVAL, self._schedule_message_check
|
|
1071
|
+
)
|
|
1072
|
+
|
|
1073
|
+
def _check_messages(self) -> None:
|
|
1074
|
+
"""Check for and process messages from scan threads."""
|
|
1075
|
+
messages = self._scan_manager.get_messages()
|
|
1076
|
+
|
|
1077
|
+
for msg in messages:
|
|
1078
|
+
self._process_message(msg)
|
|
1079
|
+
|
|
1080
|
+
# Update status if scans are active
|
|
1081
|
+
if self._scan_manager.has_active_scans():
|
|
1082
|
+
self.set_status("Scanning...")
|
|
1083
|
+
else:
|
|
1084
|
+
self.set_status("Ready")
|
|
1085
|
+
|
|
1086
|
+
def _process_message(self, msg: ScanMessage) -> None:
|
|
1087
|
+
"""Process a single scan message.
|
|
1088
|
+
|
|
1089
|
+
Args:
|
|
1090
|
+
msg: Message from scan thread
|
|
1091
|
+
"""
|
|
1092
|
+
if isinstance(msg, ScanProgress):
|
|
1093
|
+
# Update progress display
|
|
1094
|
+
self.items_var.set(f"Items: {msg.items_scanned}")
|
|
1095
|
+
|
|
1096
|
+
elif isinstance(msg, ScanComplete):
|
|
1097
|
+
# Scan completed - populate treeview
|
|
1098
|
+
self._handle_scan_complete(msg)
|
|
1099
|
+
|
|
1100
|
+
elif isinstance(msg, ScanError):
|
|
1101
|
+
# Scan error - show in status and handle properly
|
|
1102
|
+
self.set_status(f"Error: {msg.error_message}")
|
|
1103
|
+
self._error_handler.handle_scan_error(
|
|
1104
|
+
msg.path,
|
|
1105
|
+
Exception(msg.error_message),
|
|
1106
|
+
continue_scan=True,
|
|
1107
|
+
)
|
|
1108
|
+
|
|
1109
|
+
def _handle_scan_complete(self, msg: ScanComplete) -> None:
|
|
1110
|
+
"""Handle scan completion message.
|
|
1111
|
+
|
|
1112
|
+
Builds a proper tree structure from recursive scan results.
|
|
1113
|
+
|
|
1114
|
+
Args:
|
|
1115
|
+
msg: Completion message with results
|
|
1116
|
+
"""
|
|
1117
|
+
# Calculate max size for percentages (use total size for proper percentages)
|
|
1118
|
+
max_size = msg.total_size if msg.total_size > 0 else 1
|
|
1119
|
+
self.treeview.set_max_size(max_size)
|
|
1120
|
+
|
|
1121
|
+
# Build node map: path -> DirNode
|
|
1122
|
+
node_map: dict[Path, DirNode] = {}
|
|
1123
|
+
|
|
1124
|
+
# First pass: create all nodes
|
|
1125
|
+
for result in msg.results:
|
|
1126
|
+
if result.error:
|
|
1127
|
+
continue
|
|
1128
|
+
|
|
1129
|
+
node = DirNode(
|
|
1130
|
+
path=result.path,
|
|
1131
|
+
name=result.name,
|
|
1132
|
+
is_dir=result.is_dir,
|
|
1133
|
+
)
|
|
1134
|
+
node.size = result.size if not result.is_dir else 0
|
|
1135
|
+
node_map[result.path] = node
|
|
1136
|
+
|
|
1137
|
+
# Create root node if not in results
|
|
1138
|
+
root_iid = str(msg.path)
|
|
1139
|
+
if msg.path not in node_map:
|
|
1140
|
+
root_node = DirNode(
|
|
1141
|
+
path=msg.path,
|
|
1142
|
+
name=msg.path.name if msg.path.name else str(msg.path),
|
|
1143
|
+
is_dir=True,
|
|
1144
|
+
)
|
|
1145
|
+
root_node.is_expanded = True
|
|
1146
|
+
node_map[msg.path] = root_node
|
|
1147
|
+
self.treeview.insert_node("", root_node)
|
|
1148
|
+
|
|
1149
|
+
# Second pass: establish parent-child relationships and calculate dir sizes
|
|
1150
|
+
for path, node in node_map.items():
|
|
1151
|
+
if path == msg.path:
|
|
1152
|
+
continue # Skip root
|
|
1153
|
+
|
|
1154
|
+
# Find parent
|
|
1155
|
+
parent_path = path.parent
|
|
1156
|
+
parent_node = node_map.get(parent_path)
|
|
1157
|
+
|
|
1158
|
+
if parent_node is not None:
|
|
1159
|
+
node.parent = parent_node
|
|
1160
|
+
parent_node.add_child(node)
|
|
1161
|
+
|
|
1162
|
+
# Add size to parent directory
|
|
1163
|
+
if not node.is_dir:
|
|
1164
|
+
parent_node.size += node.size
|
|
1165
|
+
|
|
1166
|
+
# Propagate sizes up the tree (bottom-up)
|
|
1167
|
+
# Sort by depth (deepest first) to propagate sizes correctly
|
|
1168
|
+
sorted_paths = sorted(node_map.keys(), key=lambda p: len(p.parts), reverse=True)
|
|
1169
|
+
for path in sorted_paths:
|
|
1170
|
+
node = node_map[path]
|
|
1171
|
+
if node.parent is not None and node.is_dir:
|
|
1172
|
+
node.parent.size += node.size
|
|
1173
|
+
|
|
1174
|
+
# Insert nodes into treeview, sorted by current sort mode
|
|
1175
|
+
# Collect all nodes to insert
|
|
1176
|
+
nodes_to_insert: list[tuple[Path, DirNode]] = []
|
|
1177
|
+
for path, node in node_map.items():
|
|
1178
|
+
if path == msg.path:
|
|
1179
|
+
continue # Root already inserted
|
|
1180
|
+
nodes_to_insert.append((path, node))
|
|
1181
|
+
|
|
1182
|
+
# Sort by depth first (parents before children), then by sort mode
|
|
1183
|
+
nodes_to_insert.sort(
|
|
1184
|
+
key=lambda item: (len(item[0].parts), self._get_sort_key(item[1]))
|
|
1185
|
+
)
|
|
1186
|
+
|
|
1187
|
+
# Insert in sorted order
|
|
1188
|
+
for path, node in nodes_to_insert:
|
|
1189
|
+
parent_path = path.parent
|
|
1190
|
+
parent_iid = str(parent_path)
|
|
1191
|
+
self.treeview.insert_node(parent_iid, node)
|
|
1192
|
+
|
|
1193
|
+
# Update root node size
|
|
1194
|
+
existing_root = node_map.get(msg.path)
|
|
1195
|
+
if isinstance(existing_root, DirNode):
|
|
1196
|
+
existing_root.size = msg.total_size
|
|
1197
|
+
root_iid = str(msg.path)
|
|
1198
|
+
if self.treeview.get_node(root_iid) is not None:
|
|
1199
|
+
self.treeview.update_node(root_iid)
|
|
1200
|
+
|
|
1201
|
+
# Update counts
|
|
1202
|
+
self.set_item_count(len(msg.results))
|
|
1203
|
+
|
|
1204
|
+
def set_path(self, path: str) -> None:
|
|
1205
|
+
"""Update the path display in the status bar.
|
|
1206
|
+
|
|
1207
|
+
Args:
|
|
1208
|
+
path: Current path to display
|
|
1209
|
+
"""
|
|
1210
|
+
self.path_var.set(path)
|
|
1211
|
+
|
|
1212
|
+
def set_item_count(self, count: int) -> None:
|
|
1213
|
+
"""Update the item count display.
|
|
1214
|
+
|
|
1215
|
+
Args:
|
|
1216
|
+
count: Number of items
|
|
1217
|
+
"""
|
|
1218
|
+
self.items_var.set(f"Items: {count}")
|
|
1219
|
+
|
|
1220
|
+
def set_status(self, status: str) -> None:
|
|
1221
|
+
"""Update the status message.
|
|
1222
|
+
|
|
1223
|
+
Args:
|
|
1224
|
+
status: Status message to display
|
|
1225
|
+
"""
|
|
1226
|
+
self.status_var.set(status)
|
|
1227
|
+
|
|
1228
|
+
def run(self) -> None:
|
|
1229
|
+
"""Start the main event loop."""
|
|
1230
|
+
self.root.mainloop()
|
|
1231
|
+
|
|
1232
|
+
def cleanup(self) -> None:
|
|
1233
|
+
"""Clean up resources before destruction."""
|
|
1234
|
+
# Stop all background scans
|
|
1235
|
+
self._scan_manager.stop_all()
|
|
1236
|
+
# Cancel scheduled after call
|
|
1237
|
+
if self._after_id is not None:
|
|
1238
|
+
self.root.after_cancel(self._after_id)
|
|
1239
|
+
self._after_id = None
|
|
1240
|
+
# Close cache connection
|
|
1241
|
+
if self._cache is not None:
|
|
1242
|
+
self._cache.close()
|
|
1243
|
+
|
|
1244
|
+
def quit(self) -> None:
|
|
1245
|
+
"""Quit the application."""
|
|
1246
|
+
self.cleanup()
|
|
1247
|
+
self.root.destroy()
|