commandertui 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.
@@ -0,0 +1,137 @@
1
+ from __future__ import annotations
2
+
3
+ import time
4
+
5
+ from textual.app import ComposeResult
6
+ from textual.binding import Binding
7
+ from textual.containers import Vertical
8
+ from textual.screen import ModalScreen
9
+ from textual.widgets import Footer, ProgressBar, Static
10
+
11
+ from .executor import TransferStatus
12
+
13
+
14
+ def _human_bytes(n: int) -> str:
15
+ size = float(n)
16
+ for unit in ("B", "KB", "MB", "GB", "TB"):
17
+ if size < 1024 or unit == "TB":
18
+ return f"{size:.1f} {unit}"
19
+ size /= 1024
20
+ return f"{size:.1f} TB"
21
+
22
+
23
+ def _human_rate(bytes_per_sec: float) -> str:
24
+ # Bits, decimal units: comparable with network link speeds.
25
+ mbit = bytes_per_sec * 8 / 1_000_000
26
+ return f"{mbit / 1000:.2f} Gb/s" if mbit >= 1000 else f"{mbit:.1f} Mb/s"
27
+
28
+
29
+ def _human_duration(seconds: float) -> str:
30
+ s = int(seconds)
31
+ if s < 60:
32
+ return f"{s}s"
33
+ if s < 3600:
34
+ return f"{s // 60}m {s % 60:02d}s"
35
+ return f"{s // 3600}h {s % 3600 // 60:02d}m"
36
+
37
+
38
+ class ProgressScreen(ModalScreen[None]):
39
+ """Shown while an OperationQueue runs: one bar for the current file, one
40
+ for the whole job (by bytes). Escape asks before cancelling, so a stray
41
+ keypress can't stop a transfer by itself."""
42
+
43
+ DEFAULT_CSS = """
44
+ ProgressScreen {
45
+ align: center middle;
46
+ }
47
+ #progress-box {
48
+ width: 72;
49
+ height: auto;
50
+ border: solid $foreground;
51
+ padding: 1 2;
52
+ }
53
+ #progress-box ProgressBar {
54
+ margin-bottom: 1;
55
+ }
56
+ #progress-title, #progress-rate {
57
+ margin-bottom: 1;
58
+ }
59
+ """
60
+
61
+ BINDINGS = [
62
+ Binding("escape", "ask_cancel", "Cancel", priority=True),
63
+ Binding("y", "confirm_cancel", "Yes, cancel", priority=True),
64
+ Binding("n", "resume", "No, continue", priority=True),
65
+ ]
66
+
67
+ def __init__(self, title: str, total_ops: int) -> None:
68
+ super().__init__()
69
+ self.title_text = title
70
+ self.total_ops = total_ops
71
+ self.cancel_requested = False
72
+ self.asking = False
73
+ self.started = time.monotonic()
74
+
75
+ def compose(self) -> ComposeResult:
76
+ with Vertical(id="progress-box"):
77
+ yield Static(f"{self.title_text}\n\n Preparing...", id="progress-title")
78
+ yield Static("", id="file-label")
79
+ yield ProgressBar(total=100, id="file-bar", show_eta=False)
80
+ yield Static("", id="total-label")
81
+ yield ProgressBar(total=100, id="total-bar", show_eta=False)
82
+ yield Static("", id="progress-rate")
83
+ yield Footer()
84
+
85
+ def check_action(self, action: str, parameters: tuple[object, ...]) -> bool | None:
86
+ if action in ("confirm_cancel", "resume"):
87
+ return self.asking
88
+ if action == "ask_cancel":
89
+ return not self.asking and not self.cancel_requested
90
+ return True
91
+
92
+ def action_ask_cancel(self) -> None:
93
+ self.asking = True
94
+ self.refresh_bindings()
95
+ self.query_one("#progress-rate", Static).update(
96
+ "Cancel? The file being copied is discarded; finished files stay."
97
+ )
98
+
99
+ def action_confirm_cancel(self) -> None:
100
+ self.asking = False
101
+ self.cancel_requested = True
102
+ self.refresh_bindings()
103
+ self.query_one("#progress-rate", Static).update("Cancelling...")
104
+
105
+ def action_resume(self) -> None:
106
+ self.asking = False
107
+ self.refresh_bindings()
108
+
109
+ def report(self, s: TransferStatus) -> None:
110
+ """Called via app.call_from_thread from the worker -- safe to touch widgets."""
111
+ self.query_one("#progress-title", Static).update(self.title_text)
112
+
113
+ if s.bytes_total:
114
+ self.query_one("#file-label", Static).update(
115
+ f"File: {s.file_name} {_human_bytes(s.file_done)} of {_human_bytes(s.file_total)}"
116
+ )
117
+ file_pct = 100 * s.file_done / s.file_total if s.file_total else 100
118
+ self.query_one("#file-bar", ProgressBar).update(progress=file_pct)
119
+ self.query_one("#total-label", Static).update(
120
+ f"Total: file {s.files_done:,} of {s.files_total:,} "
121
+ f"{_human_bytes(s.bytes_done)} of {_human_bytes(s.bytes_total)}"
122
+ )
123
+ total_pct = 100 * s.bytes_done / s.bytes_total
124
+ else: # nothing byte-sized (deletes, empty files): count items instead
125
+ self.query_one("#total-label", Static).update(f"Total: {s.ops_done} of {s.ops_total} items")
126
+ total_pct = 100 * s.ops_done / s.ops_total if s.ops_total else 100
127
+ self.query_one("#total-bar", ProgressBar).update(progress=total_pct)
128
+
129
+ if self.asking or self.cancel_requested:
130
+ return
131
+ elapsed = time.monotonic() - self.started
132
+ if s.bytes_total and s.bytes_done and elapsed > 1:
133
+ rate = s.bytes_done / elapsed
134
+ left = (s.bytes_total - s.bytes_done) / rate
135
+ self.query_one("#progress-rate", Static).update(
136
+ f"{_human_rate(rate)} - about {_human_duration(left)} left"
137
+ )
@@ -0,0 +1,83 @@
1
+ from __future__ import annotations
2
+
3
+ import time
4
+ from dataclasses import replace
5
+ from typing import Callable
6
+
7
+ from .executor import ExecutionReport, TransferStatus, execute
8
+ from .modals import ConfirmScreen, MessageScreen
9
+ from .models import OperationQueue
10
+ from .progress_screen import ProgressScreen
11
+
12
+
13
+ def run_queue_with_progress(
14
+ owner, # Screen: MainScreen or CompareScreen
15
+ title: str,
16
+ queue: OperationQueue,
17
+ left_root: str,
18
+ right_root: str,
19
+ after: Callable[[ExecutionReport], None] | None = None,
20
+ ) -> None:
21
+ """Confirm, then run an OperationQueue in a background thread with a
22
+ live progress screen.
23
+
24
+ Running `execute()` straight on the UI thread (the original approach)
25
+ blocked Textual's event loop for the whole transfer: no repaint, no
26
+ input, the screen just sat frozen until it finished. Everything that
27
+ touches a widget from inside `work()` goes through `call_from_thread`,
28
+ which is the only safe way back onto the UI thread from a worker.
29
+ """
30
+ if len(queue) == 0:
31
+ owner.app.push_screen(MessageScreen("Nothing to do."))
32
+ return
33
+
34
+ def on_confirmed(confirmed: bool | None) -> None:
35
+ if not confirmed:
36
+ return
37
+
38
+ progress = ProgressScreen(title, len(queue))
39
+ owner.app.push_screen(progress)
40
+
41
+ def work() -> None:
42
+ last = 0.0
43
+
44
+ def on_transfer(status: TransferStatus) -> None:
45
+ # Throttled: a per-chunk UI update would flood the event loop.
46
+ nonlocal last
47
+ now = time.monotonic()
48
+ if now - last >= 0.1 or status.file_done == status.file_total:
49
+ last = now
50
+ owner.app.call_from_thread(progress.report, replace(status))
51
+
52
+ report = execute(
53
+ queue,
54
+ left_root,
55
+ right_root,
56
+ cancel_check=lambda: progress.cancel_requested,
57
+ on_transfer=on_transfer,
58
+ )
59
+
60
+ def finish() -> None:
61
+ progress.dismiss()
62
+ if after:
63
+ after(report)
64
+ if report.cancelled:
65
+ owner.app.push_screen(
66
+ MessageScreen(
67
+ "Cancelled.\n\n"
68
+ "Files already copied stay at the destination.\n"
69
+ "The file in progress was discarded - no half-copied files were left.\n"
70
+ "For a move, the source of anything unfinished is still in place."
71
+ )
72
+ )
73
+ elif report.failed:
74
+ lines = "\n".join(f" {p}: {e}" for p, e in report.failed[:10])
75
+ owner.app.push_screen(
76
+ MessageScreen(f"{report.succeeded} ok, {len(report.failed)} failed:\n{lines}")
77
+ )
78
+
79
+ owner.app.call_from_thread(finish)
80
+
81
+ owner.run_worker(work, thread=True)
82
+
83
+ owner.app.push_screen(ConfirmScreen(title, queue), on_confirmed)
@@ -0,0 +1,115 @@
1
+ from __future__ import annotations
2
+
3
+ import os
4
+ import stat
5
+
6
+ from .models import EntryKind, FileEntry
7
+
8
+ DEFAULT_EXCLUSIONS = {
9
+ ".git",
10
+ "__pycache__",
11
+ ".venv",
12
+ "venv",
13
+ "node_modules",
14
+ ".pytest_cache",
15
+ ".mypy_cache",
16
+ ".ruff_cache",
17
+ }
18
+
19
+
20
+ def _kind_of(entry: os.DirEntry) -> EntryKind:
21
+ if entry.is_symlink():
22
+ return EntryKind.SYMLINK
23
+ if entry.is_dir(follow_symlinks=False):
24
+ return EntryKind.DIR
25
+ return EntryKind.FILE
26
+
27
+
28
+ def scan_directory(
29
+ root: str,
30
+ exclusions: set[str] = frozenset(DEFAULT_EXCLUSIONS),
31
+ cancel_check=None,
32
+ ) -> dict[str, FileEntry]:
33
+ """Recursively scan a directory, returning a map of rel_path -> FileEntry.
34
+
35
+ Directories are included as entries too (needed to diff empty folders),
36
+ but only files/symlinks contribute to size comparisons.
37
+ """
38
+ results: dict[str, FileEntry] = {}
39
+
40
+ def walk(current_abs: str, current_rel: str) -> None:
41
+ if cancel_check and cancel_check():
42
+ return
43
+ try:
44
+ with os.scandir(current_abs) as it:
45
+ entries = sorted(it, key=lambda e: e.name)
46
+ except (PermissionError, FileNotFoundError):
47
+ return
48
+
49
+ for entry in entries:
50
+ if entry.name in exclusions:
51
+ continue
52
+ rel_path = f"{current_rel}/{entry.name}" if current_rel else entry.name
53
+ kind = _kind_of(entry)
54
+ try:
55
+ stat = entry.stat(follow_symlinks=False)
56
+ except (PermissionError, FileNotFoundError):
57
+ continue
58
+
59
+ results[rel_path] = FileEntry(
60
+ rel_path=rel_path,
61
+ name=entry.name,
62
+ kind=kind,
63
+ size=stat.st_size if kind != EntryKind.DIR else 0,
64
+ mtime=stat.st_mtime,
65
+ )
66
+
67
+ if kind == EntryKind.DIR:
68
+ walk(entry.path, rel_path)
69
+
70
+ walk(root, "")
71
+ return results
72
+
73
+
74
+ def tree_stats(paths, cancel_check=None, on_progress=None) -> tuple[int, int, int]:
75
+ """(files, subfolders, bytes) under `paths`. Read-only, never follows
76
+ symlinks; the given top-level folders themselves aren't counted."""
77
+ top = set(paths)
78
+ stack = list(paths)
79
+ files = dirs = total = 0
80
+ while stack:
81
+ if cancel_check and cancel_check():
82
+ break
83
+ p = stack.pop()
84
+ try:
85
+ st = os.lstat(p)
86
+ except OSError:
87
+ continue
88
+ if stat.S_ISDIR(st.st_mode):
89
+ if p not in top:
90
+ dirs += 1
91
+ try:
92
+ with os.scandir(p) as it:
93
+ stack.extend(e.path for e in it)
94
+ except OSError:
95
+ pass
96
+ else:
97
+ files += 1
98
+ total += st.st_size
99
+ if on_progress:
100
+ on_progress(files, dirs, total)
101
+ return files, dirs, total
102
+
103
+
104
+ def path_size(path: str) -> int:
105
+ """Total bytes under `path` -- a single file's size, or a directory
106
+ tree's total. Used for the byte counts shown in confirmation dialogs;
107
+ without this, manually-marked items always reported "0 bytes" since
108
+ only the bulk compare/sync path (plan.py) computed real sizes.
109
+ """
110
+ try:
111
+ if os.path.isdir(path) and not os.path.islink(path):
112
+ return sum(path_size(entry.path) for entry in os.scandir(path))
113
+ return os.path.getsize(path)
114
+ except OSError:
115
+ return 0
commandertui/sync.py ADDED
@@ -0,0 +1,149 @@
1
+ from __future__ import annotations
2
+
3
+ import hashlib
4
+ import os
5
+ import shutil
6
+ from dataclasses import dataclass
7
+ from typing import Callable
8
+
9
+ from .trash import send_to_trash
10
+
11
+ ProgressCallback = Callable[[int, int, str], None] # (bytes_done, bytes_total, current_name)
12
+ CancelCheck = Callable[[], bool]
13
+ CHUNK_SIZE = 1024 * 1024 # 1 MiB
14
+
15
+
16
+ class OperationCancelled(Exception):
17
+ pass
18
+
19
+
20
+ @dataclass
21
+ class TransferResult:
22
+ ok: bool
23
+ error: str | None = None
24
+ cancelled: bool = False
25
+
26
+
27
+ def _atomic_copy_file(
28
+ src: str, dst: str, progress: ProgressCallback | None = None, cancel_check: CancelCheck | None = None
29
+ ) -> None:
30
+ """Copy one file into dst via a hidden temp file + atomic rename.
31
+
32
+ The destination is never seen half-written: a reader either sees the old
33
+ file or the fully-copied new one, never something in between. Metadata
34
+ (mode, mtime) is preserved with copystat.
35
+ """
36
+ dst_dir = os.path.dirname(dst)
37
+ os.makedirs(dst_dir, exist_ok=True)
38
+ tmp_path = os.path.join(dst_dir, f".{os.path.basename(dst)}.{os.getpid()}.tmp")
39
+
40
+ total = os.path.getsize(src)
41
+ done = 0
42
+ name = os.path.basename(src)
43
+ if progress:
44
+ progress(0, total, name)
45
+ try:
46
+ with open(src, "rb") as fsrc, open(tmp_path, "wb") as fdst:
47
+ while True:
48
+ # Checked per chunk so a cancel stops mid-file; the except
49
+ # below removes the temp file, dst is never touched.
50
+ if cancel_check and cancel_check():
51
+ raise OperationCancelled()
52
+ chunk = fsrc.read(CHUNK_SIZE)
53
+ if not chunk:
54
+ break
55
+ fdst.write(chunk)
56
+ done += len(chunk)
57
+ if progress:
58
+ progress(done, total, name)
59
+ shutil.copystat(src, tmp_path, follow_symlinks=False)
60
+ os.replace(tmp_path, dst)
61
+ except BaseException:
62
+ if os.path.exists(tmp_path):
63
+ os.remove(tmp_path)
64
+ raise
65
+
66
+
67
+ def copy_item(
68
+ src: str, dst: str, progress: ProgressCallback | None = None, cancel_check: CancelCheck | None = None
69
+ ) -> TransferResult:
70
+ """Copy a file or directory tree atomically, file by file."""
71
+ try:
72
+ if cancel_check and cancel_check():
73
+ raise OperationCancelled()
74
+ if os.path.islink(src):
75
+ link_target = os.readlink(src)
76
+ dst_dir = os.path.dirname(dst)
77
+ os.makedirs(dst_dir, exist_ok=True)
78
+ if os.path.lexists(dst):
79
+ os.remove(dst)
80
+ os.symlink(link_target, dst)
81
+ elif os.path.isdir(src):
82
+ os.makedirs(dst, exist_ok=True)
83
+ for entry in os.scandir(src):
84
+ sub_result = copy_item(entry.path, os.path.join(dst, entry.name), progress, cancel_check)
85
+ if not sub_result.ok:
86
+ return sub_result
87
+ shutil.copystat(src, dst, follow_symlinks=False)
88
+ else:
89
+ _atomic_copy_file(src, dst, progress, cancel_check)
90
+ return TransferResult(ok=True)
91
+ except OperationCancelled:
92
+ return TransferResult(ok=False, error="cancelled", cancelled=True)
93
+ except Exception as e:
94
+ return TransferResult(ok=False, error=str(e))
95
+
96
+
97
+ def move_item(
98
+ src: str, dst: str, progress: ProgressCallback | None = None, cancel_check: CancelCheck | None = None
99
+ ) -> TransferResult:
100
+ """Move a file or directory. Same-filesystem moves are atomic (os.replace);
101
+ cross-filesystem moves fall back to copy + trash-the-source, so a failed
102
+ or interrupted move never leaves the source half-deleted.
103
+ """
104
+ try:
105
+ dst_dir = os.path.dirname(dst)
106
+ os.makedirs(dst_dir, exist_ok=True)
107
+ try:
108
+ os.rename(src, dst)
109
+ return TransferResult(ok=True)
110
+ except OSError:
111
+ pass # cross-device or dst exists as a different type; fall through
112
+
113
+ # A cancelled or failed copy returns here, before the source is
114
+ # trashed -- the source is only removed after a complete copy.
115
+ result = copy_item(src, dst, progress, cancel_check)
116
+ if not result.ok:
117
+ return result
118
+ if not send_to_trash(src):
119
+ return TransferResult(ok=False, error="Copied to destination but could not remove source")
120
+ return TransferResult(ok=True)
121
+ except Exception as e:
122
+ return TransferResult(ok=False, error=str(e))
123
+
124
+
125
+ def delete_item(path: str) -> TransferResult:
126
+ if send_to_trash(path):
127
+ return TransferResult(ok=True)
128
+ return TransferResult(ok=False, error="All trash methods failed")
129
+
130
+
131
+ def sha256_of(path: str) -> str:
132
+ h = hashlib.sha256()
133
+ with open(path, "rb") as f:
134
+ while True:
135
+ chunk = f.read(CHUNK_SIZE)
136
+ if not chunk:
137
+ break
138
+ h.update(chunk)
139
+ return h.hexdigest()
140
+
141
+
142
+ def verify_copy(src: str, dst: str) -> bool:
143
+ """Checksum verification for critical transfers (USB/network)."""
144
+ if os.path.isdir(src):
145
+ for entry in os.scandir(src):
146
+ if not verify_copy(entry.path, os.path.join(dst, entry.name)):
147
+ return False
148
+ return True
149
+ return os.path.exists(dst) and sha256_of(src) == sha256_of(dst)
commandertui/theme.py ADDED
@@ -0,0 +1,94 @@
1
+ from __future__ import annotations
2
+
3
+ # Phosphor palettes, consistent with the rest of the green-CRT TUI family
4
+ # (btrfs-restore-tui). Amber is the classic VT220 alternative for long
5
+ # sessions.
6
+ PALETTES = {
7
+ "green": {
8
+ "foreground": "#00FF66",
9
+ "dim": "#0A5C2C",
10
+ "background": "#001A0D",
11
+ "accent": "#66FFB2",
12
+ "danger": "#FF4444",
13
+ "warning": "#FFD24C",
14
+ },
15
+ "amber": {
16
+ "foreground": "#FFB000",
17
+ "dim": "#5C3F00",
18
+ "background": "#1A1000",
19
+ "accent": "#FFD24C",
20
+ "danger": "#FF4444",
21
+ "warning": "#FFEE66",
22
+ },
23
+ }
24
+
25
+
26
+ def css_for(palette_name: str = "green") -> str:
27
+ p = PALETTES.get(palette_name, PALETTES["green"])
28
+ return f"""
29
+ Screen {{
30
+ background: {p['background']};
31
+ color: {p['foreground']};
32
+ }}
33
+
34
+ .dim {{
35
+ color: {p['dim']};
36
+ }}
37
+
38
+ .accent {{
39
+ color: {p['accent']};
40
+ }}
41
+
42
+ .danger {{
43
+ color: {p['danger']};
44
+ }}
45
+
46
+ .warning {{
47
+ color: {p['warning']};
48
+ }}
49
+
50
+ Header, Footer {{
51
+ background: {p['background']};
52
+ color: {p['foreground']};
53
+ }}
54
+
55
+ /* DataTable draws its own cell colors independently of Screen, so every
56
+ part of it needs to be told about the palette explicitly -- otherwise it
57
+ falls back to Textual's default theme (white text) regardless of what the
58
+ rest of the app looks like. */
59
+ DataTable {{
60
+ background: {p['background']};
61
+ color: {p['foreground']};
62
+ scrollbar-color: {p['dim']};
63
+ scrollbar-background: {p['background']};
64
+ }}
65
+
66
+ DataTable > .datatable--header {{
67
+ background: {p['background']};
68
+ color: {p['accent']};
69
+ text-style: bold;
70
+ }}
71
+
72
+ DataTable > .datatable--cursor {{
73
+ background: {p['foreground']};
74
+ color: {p['background']};
75
+ }}
76
+
77
+ DataTable > .datatable--odd-row {{
78
+ background: {p['background']};
79
+ }}
80
+
81
+ DataTable > .datatable--even-row {{
82
+ background: {p['background']};
83
+ }}
84
+
85
+ .pathbar {{
86
+ color: {p['dim']};
87
+ padding: 0 1;
88
+ }}
89
+
90
+ .pathbar.active {{
91
+ color: {p['accent']};
92
+ text-style: bold;
93
+ }}
94
+ """
commandertui/trash.py ADDED
@@ -0,0 +1,49 @@
1
+ from __future__ import annotations
2
+
3
+ import os
4
+ import shutil
5
+ import subprocess
6
+
7
+
8
+ def send_to_trash(path: str) -> bool:
9
+ """Move a file or directory to the system trash. Never a raw rm/rmtree.
10
+
11
+ Three-layer fallback, in order of preference:
12
+ 1. send2trash (FreeDesktop-native, cross-desktop)
13
+ 2. `gio trash` (GLib/GIO, present on GNOME/KDE)
14
+ 3. Direct move into ~/.local/share/Trash/files (last resort, e.g. for
15
+ external disks or network mounts without native trash support)
16
+ """
17
+ abs_path = os.path.abspath(path)
18
+ if not os.path.exists(abs_path):
19
+ return False
20
+
21
+ try:
22
+ import send2trash
23
+
24
+ send2trash.send2trash(abs_path)
25
+ return True
26
+ except Exception:
27
+ pass
28
+
29
+ try:
30
+ result = subprocess.run(["gio", "trash", abs_path], capture_output=True, text=True, timeout=30)
31
+ if result.returncode == 0:
32
+ return True
33
+ except Exception:
34
+ pass
35
+
36
+ try:
37
+ trash_dir = os.path.expanduser("~/.local/share/Trash/files")
38
+ os.makedirs(trash_dir, exist_ok=True)
39
+ base = os.path.basename(abs_path)
40
+ name, ext = os.path.splitext(base)
41
+ target = os.path.join(trash_dir, base)
42
+ counter = 1
43
+ while os.path.exists(target):
44
+ target = os.path.join(trash_dir, f"{name}_{counter}{ext}")
45
+ counter += 1
46
+ shutil.move(abs_path, target)
47
+ return True
48
+ except Exception:
49
+ return False