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.
- commandertui/__init__.py +1 -0
- commandertui/app.py +40 -0
- commandertui/ascii_font.py +27 -0
- commandertui/bookmarks.py +133 -0
- commandertui/boot.py +114 -0
- commandertui/cli.py +22 -0
- commandertui/compare_screen.py +146 -0
- commandertui/diff.py +61 -0
- commandertui/executor.py +134 -0
- commandertui/main_screen.py +211 -0
- commandertui/modals.py +250 -0
- commandertui/models.py +79 -0
- commandertui/plan.py +50 -0
- commandertui/progress_screen.py +137 -0
- commandertui/run_queue.py +83 -0
- commandertui/scanner.py +115 -0
- commandertui/sync.py +149 -0
- commandertui/theme.py +94 -0
- commandertui/trash.py +49 -0
- commandertui/widgets.py +134 -0
- commandertui-1.0.0.dist-info/METADATA +191 -0
- commandertui-1.0.0.dist-info/RECORD +26 -0
- commandertui-1.0.0.dist-info/WHEEL +5 -0
- commandertui-1.0.0.dist-info/entry_points.txt +2 -0
- commandertui-1.0.0.dist-info/licenses/LICENSE +21 -0
- commandertui-1.0.0.dist-info/top_level.txt +1 -0
|
@@ -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)
|
commandertui/scanner.py
ADDED
|
@@ -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
|