commandertui 1.4.2__tar.gz → 1.5.0__tar.gz
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-1.4.2 → commandertui-1.5.0}/PKG-INFO +18 -1
- {commandertui-1.4.2 → commandertui-1.5.0}/README.md +17 -0
- commandertui-1.5.0/commandertui/__init__.py +1 -0
- commandertui-1.5.0/commandertui/conflicts.py +183 -0
- {commandertui-1.4.2 → commandertui-1.5.0}/commandertui/main_screen.py +5 -20
- {commandertui-1.4.2 → commandertui-1.5.0}/commandertui/modals.py +202 -1
- commandertui-1.5.0/commandertui/run_transfer.py +191 -0
- {commandertui-1.4.2 → commandertui-1.5.0}/commandertui/theme.py +38 -6
- commandertui-1.5.0/commandertui/transfer.py +294 -0
- {commandertui-1.4.2 → commandertui-1.5.0}/commandertui/trash.py +2 -2
- {commandertui-1.4.2 → commandertui-1.5.0}/commandertui/updater.py +22 -4
- {commandertui-1.4.2 → commandertui-1.5.0}/commandertui.egg-info/PKG-INFO +18 -1
- {commandertui-1.4.2 → commandertui-1.5.0}/commandertui.egg-info/SOURCES.txt +6 -0
- commandertui-1.5.0/tests/test_conflict_ui.py +488 -0
- commandertui-1.5.0/tests/test_conflicts.py +215 -0
- commandertui-1.5.0/tests/test_transfer.py +547 -0
- {commandertui-1.4.2 → commandertui-1.5.0}/tests/test_updater.py +34 -2
- commandertui-1.4.2/commandertui/__init__.py +0 -1
- {commandertui-1.4.2 → commandertui-1.5.0}/LICENSE +0 -0
- {commandertui-1.4.2 → commandertui-1.5.0}/commandertui/app.py +0 -0
- {commandertui-1.4.2 → commandertui-1.5.0}/commandertui/ascii_font.py +0 -0
- {commandertui-1.4.2 → commandertui-1.5.0}/commandertui/bookmarks.py +0 -0
- {commandertui-1.4.2 → commandertui-1.5.0}/commandertui/boot.py +0 -0
- {commandertui-1.4.2 → commandertui-1.5.0}/commandertui/cli.py +0 -0
- {commandertui-1.4.2 → commandertui-1.5.0}/commandertui/compare_screen.py +0 -0
- {commandertui-1.4.2 → commandertui-1.5.0}/commandertui/diff.py +0 -0
- {commandertui-1.4.2 → commandertui-1.5.0}/commandertui/editor.py +0 -0
- {commandertui-1.4.2 → commandertui-1.5.0}/commandertui/executor.py +0 -0
- {commandertui-1.4.2 → commandertui-1.5.0}/commandertui/models.py +0 -0
- {commandertui-1.4.2 → commandertui-1.5.0}/commandertui/netlocations.py +0 -0
- {commandertui-1.4.2 → commandertui-1.5.0}/commandertui/plan.py +0 -0
- {commandertui-1.4.2 → commandertui-1.5.0}/commandertui/preview.py +0 -0
- {commandertui-1.4.2 → commandertui-1.5.0}/commandertui/progress_screen.py +0 -0
- {commandertui-1.4.2 → commandertui-1.5.0}/commandertui/run_queue.py +0 -0
- {commandertui-1.4.2 → commandertui-1.5.0}/commandertui/scanner.py +0 -0
- {commandertui-1.4.2 → commandertui-1.5.0}/commandertui/settings.py +0 -0
- {commandertui-1.4.2 → commandertui-1.5.0}/commandertui/sync.py +0 -0
- {commandertui-1.4.2 → commandertui-1.5.0}/commandertui/widgets.py +0 -0
- {commandertui-1.4.2 → commandertui-1.5.0}/commandertui.egg-info/dependency_links.txt +0 -0
- {commandertui-1.4.2 → commandertui-1.5.0}/commandertui.egg-info/entry_points.txt +0 -0
- {commandertui-1.4.2 → commandertui-1.5.0}/commandertui.egg-info/requires.txt +0 -0
- {commandertui-1.4.2 → commandertui-1.5.0}/commandertui.egg-info/top_level.txt +0 -0
- {commandertui-1.4.2 → commandertui-1.5.0}/pyproject.toml +0 -0
- {commandertui-1.4.2 → commandertui-1.5.0}/setup.cfg +0 -0
- {commandertui-1.4.2 → commandertui-1.5.0}/tests/test_app_smoke.py +0 -0
- {commandertui-1.4.2 → commandertui-1.5.0}/tests/test_bookmarks.py +0 -0
- {commandertui-1.4.2 → commandertui-1.5.0}/tests/test_diff.py +0 -0
- {commandertui-1.4.2 → commandertui-1.5.0}/tests/test_executor.py +0 -0
- {commandertui-1.4.2 → commandertui-1.5.0}/tests/test_netlocations.py +0 -0
- {commandertui-1.4.2 → commandertui-1.5.0}/tests/test_plan.py +0 -0
- {commandertui-1.4.2 → commandertui-1.5.0}/tests/test_preview.py +0 -0
- {commandertui-1.4.2 → commandertui-1.5.0}/tests/test_scanner.py +0 -0
- {commandertui-1.4.2 → commandertui-1.5.0}/tests/test_sync.py +0 -0
- {commandertui-1.4.2 → commandertui-1.5.0}/tests/test_widgets.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: commandertui
|
|
3
|
-
Version: 1.
|
|
3
|
+
Version: 1.5.0
|
|
4
4
|
Summary: Dual-pane retro terminal file manager: compare, sync, copy and move folders
|
|
5
5
|
Author: Humberto Barchini
|
|
6
6
|
License-Expression: MIT
|
|
@@ -260,3 +260,20 @@ first version, **Claude Opus 5.5** did most of v0.2 and the v1.0 packaging.
|
|
|
260
260
|
## License
|
|
261
261
|
|
|
262
262
|
MIT — see [LICENSE](LICENSE).
|
|
263
|
+
|
|
264
|
+
|
|
265
|
+
## Copy/move conflicts
|
|
266
|
+
|
|
267
|
+
F5 (Copy) and F6 (Move) check the destination before transferring. Existing
|
|
268
|
+
items open a dialog focused on **Skip**: overwrite, skip, rename, or ask for
|
|
269
|
+
each conflict (with **Apply to all remaining**). Copy also offers **Only newer**
|
|
270
|
+
and **Merge** for existing folders; Merge asks how to handle conflicting files.
|
|
271
|
+
Overwrite on folders keeps destination-only files. Repeated source names use
|
|
272
|
+
the same policy; rename selects free `_1`, `_2`, … names.
|
|
273
|
+
|
|
274
|
+
Transfers run with cancellable progress. Skipped and unfinished sources stay
|
|
275
|
+
marked. Moves verify each copied file before trashing its source; skipped
|
|
276
|
+
and failed files stay in the source. Existing files always require a decision,
|
|
277
|
+
even when size and date match: Overwrite copies/moves them, while Skip (or
|
|
278
|
+
Only newer for identical files) retains them. The completion message reports
|
|
279
|
+
copied, skipped, failed, and identical files retained by a move.
|
|
@@ -241,3 +241,20 @@ first version, **Claude Opus 5.5** did most of v0.2 and the v1.0 packaging.
|
|
|
241
241
|
## License
|
|
242
242
|
|
|
243
243
|
MIT — see [LICENSE](LICENSE).
|
|
244
|
+
|
|
245
|
+
|
|
246
|
+
## Copy/move conflicts
|
|
247
|
+
|
|
248
|
+
F5 (Copy) and F6 (Move) check the destination before transferring. Existing
|
|
249
|
+
items open a dialog focused on **Skip**: overwrite, skip, rename, or ask for
|
|
250
|
+
each conflict (with **Apply to all remaining**). Copy also offers **Only newer**
|
|
251
|
+
and **Merge** for existing folders; Merge asks how to handle conflicting files.
|
|
252
|
+
Overwrite on folders keeps destination-only files. Repeated source names use
|
|
253
|
+
the same policy; rename selects free `_1`, `_2`, … names.
|
|
254
|
+
|
|
255
|
+
Transfers run with cancellable progress. Skipped and unfinished sources stay
|
|
256
|
+
marked. Moves verify each copied file before trashing its source; skipped
|
|
257
|
+
and failed files stay in the source. Existing files always require a decision,
|
|
258
|
+
even when size and date match: Overwrite copies/moves them, while Skip (or
|
|
259
|
+
Only newer for identical files) retains them. The completion message reports
|
|
260
|
+
copied, skipped, failed, and identical files retained by a move.
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
__version__ = "1.5.0"
|
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
"""Read-only copy/move collision detection and pure per-entry decisions.
|
|
2
|
+
|
|
3
|
+
Identity is metadata-based, not a content comparison: SMB/GVFS can report
|
|
4
|
+
misleading sizes/mtimes. As in diff.py, equal sizes and mtimes within two
|
|
5
|
+
seconds count as identical. Every existing file is still a conflict requiring
|
|
6
|
+
an explicit resolution, including metadata-identical files.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import os
|
|
12
|
+
import stat
|
|
13
|
+
from collections import Counter
|
|
14
|
+
from dataclasses import dataclass, field, replace
|
|
15
|
+
from enum import Enum
|
|
16
|
+
from typing import Literal
|
|
17
|
+
|
|
18
|
+
from .diff import MTIME_TOLERANCE_SECONDS
|
|
19
|
+
from .models import EntryKind, FileEntry, OpKind
|
|
20
|
+
from .scanner import scan_directory
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
@dataclass(frozen=True)
|
|
24
|
+
class Conflict:
|
|
25
|
+
rel_path: str
|
|
26
|
+
name: str
|
|
27
|
+
src: FileEntry
|
|
28
|
+
dst: FileEntry
|
|
29
|
+
kind_mismatch: bool
|
|
30
|
+
newer: Literal["src", "dst", "same"]
|
|
31
|
+
identical: bool = False
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
@dataclass
|
|
35
|
+
class ConflictReport:
|
|
36
|
+
conflicts: list[Conflict] = field(default_factory=list)
|
|
37
|
+
identical: int = 0
|
|
38
|
+
dir_collisions: list[str] = field(default_factory=list)
|
|
39
|
+
total_src_files_new: int = 0
|
|
40
|
+
duplicate_sources: list[str] = field(default_factory=list)
|
|
41
|
+
identical_paths: list[str] = field(default_factory=list)
|
|
42
|
+
conflicts_by_source: dict[str, Conflict] = field(default_factory=dict)
|
|
43
|
+
|
|
44
|
+
@property
|
|
45
|
+
def has_conflicts(self) -> bool:
|
|
46
|
+
return bool(self.conflicts or self.duplicate_sources)
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
class Resolution(str, Enum):
|
|
50
|
+
OVERWRITE = "overwrite"
|
|
51
|
+
SKIP = "skip"
|
|
52
|
+
ONLY_NEWER = "only_newer"
|
|
53
|
+
RENAME = "rename"
|
|
54
|
+
MERGE = "merge"
|
|
55
|
+
CANCEL = "cancel"
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def allowed_resolutions(kind: OpKind) -> list[Resolution]:
|
|
59
|
+
if kind == OpKind.COPY:
|
|
60
|
+
return list(Resolution)
|
|
61
|
+
if kind == OpKind.MOVE:
|
|
62
|
+
return [Resolution.OVERWRITE, Resolution.SKIP, Resolution.RENAME, Resolution.CANCEL]
|
|
63
|
+
raise ValueError(f"Unsupported operation: {kind}")
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def rename_target(path: str) -> str:
|
|
67
|
+
"""Suggest a free sibling (not reserved); even dangling symlinks occupy names."""
|
|
68
|
+
stem, extension = os.path.splitext(path)
|
|
69
|
+
index = 1
|
|
70
|
+
while True:
|
|
71
|
+
candidate = f"{stem}_{index}{extension}"
|
|
72
|
+
if not os.path.lexists(candidate):
|
|
73
|
+
return candidate
|
|
74
|
+
index += 1
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def resolve(conflict: Conflict, resolution: Resolution) -> Literal["copy", "skip", "rename"]:
|
|
78
|
+
"""Choose an action without touching either side, including for MOVE skips.
|
|
79
|
+
|
|
80
|
+
MERGE is a tree-level policy: missing files copy directly, while collisions
|
|
81
|
+
need another per-file resolution. CANCEL must be handled by the caller.
|
|
82
|
+
Neither is a valid per-conflict decision here.
|
|
83
|
+
"""
|
|
84
|
+
if resolution == Resolution.OVERWRITE:
|
|
85
|
+
return "copy"
|
|
86
|
+
if resolution == Resolution.SKIP:
|
|
87
|
+
return "skip"
|
|
88
|
+
if resolution == Resolution.RENAME:
|
|
89
|
+
return "rename"
|
|
90
|
+
if resolution == Resolution.ONLY_NEWER:
|
|
91
|
+
return "copy" if conflict.newer == "src" else "skip"
|
|
92
|
+
raise ValueError(f"{resolution} requires caller-level handling")
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def _entry(path: str, rel_path: str) -> FileEntry:
|
|
96
|
+
metadata = os.lstat(path)
|
|
97
|
+
kind = (
|
|
98
|
+
EntryKind.SYMLINK
|
|
99
|
+
if stat.S_ISLNK(metadata.st_mode)
|
|
100
|
+
else EntryKind.DIR if stat.S_ISDIR(metadata.st_mode) else EntryKind.FILE
|
|
101
|
+
)
|
|
102
|
+
return FileEntry(
|
|
103
|
+
rel_path,
|
|
104
|
+
os.path.basename(rel_path),
|
|
105
|
+
kind,
|
|
106
|
+
0 if kind == EntryKind.DIR else metadata.st_size,
|
|
107
|
+
metadata.st_mtime,
|
|
108
|
+
)
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def detect_conflicts(sources: list[str], dst_dir: str, cancel_check=None) -> ConflictReport:
|
|
112
|
+
"""Return collisions with paths relative to dst_dir, including source basename.
|
|
113
|
+
|
|
114
|
+
Uses scanner exclusions and never follows symlinks. Directory/type collisions
|
|
115
|
+
block their subtree, so descendants are not reported as independently new.
|
|
116
|
+
Cancellation returns an empty report rather than a misleading partial result.
|
|
117
|
+
"""
|
|
118
|
+
names = Counter(os.path.basename(os.path.normpath(source)) for source in sources)
|
|
119
|
+
report = ConflictReport(duplicate_sources=[name for name, count in names.items() if count > 1])
|
|
120
|
+
|
|
121
|
+
def cancelled() -> bool:
|
|
122
|
+
return bool(cancel_check and cancel_check())
|
|
123
|
+
|
|
124
|
+
def compare(src: FileEntry, dst: FileEntry) -> None:
|
|
125
|
+
src_abs = os.path.join(os.path.dirname(os.path.abspath(source)), src.rel_path)
|
|
126
|
+
mismatch = src.kind != dst.kind
|
|
127
|
+
delta = src.mtime - dst.mtime
|
|
128
|
+
newer = "same" if abs(delta) <= MTIME_TOLERANCE_SECONDS else "src" if delta > 0 else "dst"
|
|
129
|
+
identical = not mismatch and src.size == dst.size and newer == "same"
|
|
130
|
+
if identical:
|
|
131
|
+
report.identical += 1
|
|
132
|
+
report.identical_paths.append(src_abs)
|
|
133
|
+
conflict = Conflict(src.rel_path, src.name, src, dst, mismatch, newer, identical)
|
|
134
|
+
report.conflicts.append(conflict)
|
|
135
|
+
report.conflicts_by_source[src_abs] = conflict
|
|
136
|
+
|
|
137
|
+
for source in sources:
|
|
138
|
+
if cancelled():
|
|
139
|
+
return ConflictReport()
|
|
140
|
+
name = os.path.basename(os.path.normpath(source))
|
|
141
|
+
src = _entry(source, name)
|
|
142
|
+
target = os.path.join(dst_dir, name)
|
|
143
|
+
dst = _entry(target, name) if os.path.lexists(target) else None
|
|
144
|
+
if src.kind != EntryKind.DIR:
|
|
145
|
+
if dst is None:
|
|
146
|
+
report.total_src_files_new += 1
|
|
147
|
+
else:
|
|
148
|
+
compare(src, dst)
|
|
149
|
+
continue
|
|
150
|
+
if dst is not None and dst.kind != EntryKind.DIR:
|
|
151
|
+
compare(src, dst)
|
|
152
|
+
continue
|
|
153
|
+
left = scan_directory(source, cancel_check=cancel_check)
|
|
154
|
+
if cancelled():
|
|
155
|
+
return ConflictReport()
|
|
156
|
+
if dst is None:
|
|
157
|
+
report.total_src_files_new += sum(e.kind != EntryKind.DIR for e in left.values())
|
|
158
|
+
continue
|
|
159
|
+
report.dir_collisions.append(name)
|
|
160
|
+
right = scan_directory(target, cancel_check=cancel_check)
|
|
161
|
+
if cancelled():
|
|
162
|
+
return ConflictReport()
|
|
163
|
+
blocked: set[str] = set()
|
|
164
|
+
for path, entry in left.items():
|
|
165
|
+
if cancelled():
|
|
166
|
+
return ConflictReport()
|
|
167
|
+
if any(parent in blocked for parent in _parents(path)):
|
|
168
|
+
continue
|
|
169
|
+
other = right.get(path)
|
|
170
|
+
if other is None:
|
|
171
|
+
report.total_src_files_new += entry.kind != EntryKind.DIR
|
|
172
|
+
elif entry.kind != EntryKind.DIR or other.kind != EntryKind.DIR:
|
|
173
|
+
compare(replace(entry, rel_path=f"{name}/{path}"), replace(other, rel_path=f"{name}/{path}"))
|
|
174
|
+
if entry.kind == EntryKind.DIR:
|
|
175
|
+
blocked.add(path)
|
|
176
|
+
return report
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
def _parents(path: str):
|
|
180
|
+
parent = os.path.dirname(path)
|
|
181
|
+
while parent:
|
|
182
|
+
yield parent
|
|
183
|
+
parent = os.path.dirname(parent)
|
|
@@ -24,6 +24,7 @@ from .modals import (
|
|
|
24
24
|
)
|
|
25
25
|
from .models import OpKind, OpSide, OperationQueue, QueuedOp
|
|
26
26
|
from .run_queue import run_queue_with_progress
|
|
27
|
+
from .run_transfer import run_transfer
|
|
27
28
|
from .scanner import path_size
|
|
28
29
|
from .widgets import PARENT_MARK, FilePanel, FunctionBar, MainTopBar
|
|
29
30
|
|
|
@@ -350,7 +351,9 @@ class MainScreen(Screen):
|
|
|
350
351
|
)
|
|
351
352
|
else:
|
|
352
353
|
self.app.push_screen(
|
|
353
|
-
MessageScreen(
|
|
354
|
+
MessageScreen(
|
|
355
|
+
"The update did not finish. The package index may not have the new version yet:\nwait a few minutes and try again, or check the terminal output."
|
|
356
|
+
)
|
|
354
357
|
)
|
|
355
358
|
|
|
356
359
|
self.app.push_screen(UpdateScreen(info), on_choice)
|
|
@@ -491,25 +494,7 @@ class MainScreen(Screen):
|
|
|
491
494
|
self._nothing_selected()
|
|
492
495
|
return
|
|
493
496
|
|
|
494
|
-
|
|
495
|
-
queue = OperationQueue()
|
|
496
|
-
for item in items:
|
|
497
|
-
rel_path = os.path.relpath(item, src_panel.path)
|
|
498
|
-
queue.add(QueuedOp(kind, side, rel_path, os.path.isdir(item), size=path_size(item)))
|
|
499
|
-
|
|
500
|
-
def after(report) -> None:
|
|
501
|
-
src_panel.marked.clear()
|
|
502
|
-
src_panel.refresh_listing()
|
|
503
|
-
dst_panel.refresh_listing()
|
|
504
|
-
|
|
505
|
-
run_queue_with_progress(
|
|
506
|
-
self,
|
|
507
|
-
f"{label} to {dst_panel.path}",
|
|
508
|
-
queue,
|
|
509
|
-
self._panel("left").path,
|
|
510
|
-
self._panel("right").path,
|
|
511
|
-
after,
|
|
512
|
-
)
|
|
497
|
+
run_transfer(self, kind, label, src_panel, dst_panel, items)
|
|
513
498
|
|
|
514
499
|
# ------------------------------------------------------------- misc
|
|
515
500
|
def action_show_compare(self) -> None:
|
|
@@ -7,7 +7,7 @@ import time
|
|
|
7
7
|
|
|
8
8
|
from textual.app import ComposeResult
|
|
9
9
|
from textual.binding import Binding
|
|
10
|
-
from textual.containers import Horizontal, Vertical
|
|
10
|
+
from textual.containers import Horizontal, Vertical, VerticalScroll
|
|
11
11
|
from textual.screen import ModalScreen
|
|
12
12
|
from textual.widgets import Button, DataTable, Input, OptionList, Static
|
|
13
13
|
|
|
@@ -698,3 +698,204 @@ class UpdateScreen(ModalScreen[str]):
|
|
|
698
698
|
|
|
699
699
|
def action_skip(self) -> None:
|
|
700
700
|
self.dismiss("skip")
|
|
701
|
+
|
|
702
|
+
|
|
703
|
+
class ConflictScreen(ModalScreen):
|
|
704
|
+
"""One safe decision for a batch or one collision; no filesystem changes."""
|
|
705
|
+
|
|
706
|
+
DEFAULT_CSS = """
|
|
707
|
+
ConflictScreen { align: center middle; }
|
|
708
|
+
#conflict-box { width: 80%; min-width: 60; max-width: 100; height: auto; max-height: 95%;
|
|
709
|
+
border: heavy $foreground; padding: 1 3; overflow: hidden hidden; }
|
|
710
|
+
ConflictScreen.compact #conflict-box { padding: 0 2; }
|
|
711
|
+
#conflict-title { text-style: bold; margin-bottom: 1; max-height: 2; }
|
|
712
|
+
#conflict-summary { height: auto; margin-bottom: 1; overflow: hidden auto; }
|
|
713
|
+
#conflict-buttons { height: auto; overflow: hidden hidden; }
|
|
714
|
+
#conflict-buttons Button { position: absolute; min-width: 0; height: 3; margin: 0; }
|
|
715
|
+
#apply-remaining { margin-top: 1; }
|
|
716
|
+
ConflictScreen.compact #apply-remaining { margin-top: 0; }
|
|
717
|
+
"""
|
|
718
|
+
|
|
719
|
+
BINDINGS = [
|
|
720
|
+
Binding(key, f"choose('{value}')", label, priority=True)
|
|
721
|
+
for key, value, label in [
|
|
722
|
+
("o", "overwrite", "Overwrite"),
|
|
723
|
+
("s", "skip", "Skip"),
|
|
724
|
+
("n", "only_newer", "Only newer"),
|
|
725
|
+
("r", "rename", "Rename"),
|
|
726
|
+
("m", "merge", "Merge"),
|
|
727
|
+
("a", "ask", "Ask each"),
|
|
728
|
+
("c", "cancel", "Cancel"),
|
|
729
|
+
("escape", "cancel", "Cancel"),
|
|
730
|
+
]
|
|
731
|
+
]
|
|
732
|
+
|
|
733
|
+
def __init__(self, report, dst_dir, kind, *, rel_path=None, file_policy=False, summary=None):
|
|
734
|
+
super().__init__()
|
|
735
|
+
from .conflicts import Resolution, allowed_resolutions
|
|
736
|
+
|
|
737
|
+
self.report_data = report
|
|
738
|
+
self.dst_dir = dst_dir
|
|
739
|
+
self.kind = kind
|
|
740
|
+
self.rel_path = rel_path
|
|
741
|
+
self.file_policy = file_policy
|
|
742
|
+
self.summary_text = summary
|
|
743
|
+
self.options = [
|
|
744
|
+
r.value
|
|
745
|
+
for r in allowed_resolutions(kind)
|
|
746
|
+
if r != Resolution.MERGE or (report.dir_collisions and not file_policy and rel_path is None)
|
|
747
|
+
]
|
|
748
|
+
if not file_policy and rel_path is None:
|
|
749
|
+
self.options.insert(-1, "ask")
|
|
750
|
+
|
|
751
|
+
def compose(self):
|
|
752
|
+
from textual.widgets import Checkbox
|
|
753
|
+
|
|
754
|
+
report = self.report_data
|
|
755
|
+
count = len(
|
|
756
|
+
{c.rel_path for c in report.conflicts}
|
|
757
|
+
| set(report.dir_collisions)
|
|
758
|
+
| set(report.duplicate_sources)
|
|
759
|
+
)
|
|
760
|
+
title = (
|
|
761
|
+
f"Resolve {self.rel_path}"
|
|
762
|
+
if self.rel_path
|
|
763
|
+
else (
|
|
764
|
+
"Choose a policy for conflicting files"
|
|
765
|
+
if self.file_policy
|
|
766
|
+
else f"{count} items already exist"
|
|
767
|
+
)
|
|
768
|
+
)
|
|
769
|
+
entries = report.conflicts
|
|
770
|
+
if self.rel_path:
|
|
771
|
+
entries = [c for c in entries if c.rel_path == self.rel_path]
|
|
772
|
+
lines = [f"in {self.dst_dir}"]
|
|
773
|
+
if report.identical:
|
|
774
|
+
lines.append(f"({report.identical} identical: same size and date)")
|
|
775
|
+
for conflict in entries[:6]:
|
|
776
|
+
|
|
777
|
+
def side(entry, label):
|
|
778
|
+
date = time.strftime("%Y-%m-%d %H:%M:%S", time.localtime(entry.mtime))
|
|
779
|
+
return f"{label}: {_human_bytes(entry.size)}, {date}"
|
|
780
|
+
|
|
781
|
+
label = "identical" if conflict.identical else f"newer: {conflict.newer}"
|
|
782
|
+
lines.append(
|
|
783
|
+
f"{conflict.rel_path}: {side(conflict.src, 'Source')} | "
|
|
784
|
+
f"{side(conflict.dst, 'Destination')} — {label}"
|
|
785
|
+
)
|
|
786
|
+
if len(entries) > 6:
|
|
787
|
+
lines.append(f"... and {len(entries) - 6} more")
|
|
788
|
+
if report.dir_collisions:
|
|
789
|
+
lines.append("Existing folders: " + ", ".join(report.dir_collisions[:6]))
|
|
790
|
+
if report.duplicate_sources:
|
|
791
|
+
lines.append("Repeated source names: " + ", ".join(report.duplicate_sources))
|
|
792
|
+
labels = {
|
|
793
|
+
"overwrite": "Overwrite (o)",
|
|
794
|
+
"skip": "Skip (s)",
|
|
795
|
+
"only_newer": "Only newer (n)",
|
|
796
|
+
"rename": "Rename (r)",
|
|
797
|
+
"merge": "Merge (m)",
|
|
798
|
+
"ask": "Ask each (a)",
|
|
799
|
+
"cancel": "Cancel (Esc)",
|
|
800
|
+
}
|
|
801
|
+
if report.dir_collisions and not self.file_policy:
|
|
802
|
+
labels["overwrite"] = "Overwrite (keeps extra files) (o)"
|
|
803
|
+
with Vertical(id="conflict-box"):
|
|
804
|
+
yield Static(title, id="conflict-title", markup=False)
|
|
805
|
+
with VerticalScroll(id="conflict-summary"):
|
|
806
|
+
yield Static(self.summary_text or "\n".join(lines), id="conflict-list", markup=False)
|
|
807
|
+
with Vertical(id="conflict-buttons"):
|
|
808
|
+
for option in self.options:
|
|
809
|
+
yield Button(labels[option], id=f"conflict-{option}")
|
|
810
|
+
if self.rel_path:
|
|
811
|
+
yield Checkbox("Apply to all remaining (l)", id="apply-remaining")
|
|
812
|
+
|
|
813
|
+
def on_mount(self):
|
|
814
|
+
self._resize_layout()
|
|
815
|
+
self.query_one("#conflict-skip", Button).focus()
|
|
816
|
+
|
|
817
|
+
def on_resize(self):
|
|
818
|
+
self._resize_layout()
|
|
819
|
+
|
|
820
|
+
def _resize_layout(self):
|
|
821
|
+
self.set_class(self.size.width < 125 or self.size.height < 30, "compact")
|
|
822
|
+
self.call_after_refresh(self._layout_controls)
|
|
823
|
+
|
|
824
|
+
def _layout_controls(self):
|
|
825
|
+
"""Wrap measured button widths; reserve fixed space outside the list.
|
|
826
|
+
|
|
827
|
+
Reposition existing buttons instead of remounting them, preserving
|
|
828
|
+
focus and the Ask each checkbox when the terminal is resized.
|
|
829
|
+
"""
|
|
830
|
+
from rich.cells import cell_len
|
|
831
|
+
|
|
832
|
+
box = self.query_one("#conflict-box")
|
|
833
|
+
controls = self.query_one("#conflict-buttons")
|
|
834
|
+
buttons = list(controls.query(Button))
|
|
835
|
+
available = controls.content_size.width
|
|
836
|
+
if not available:
|
|
837
|
+
return
|
|
838
|
+
compact = self.has_class("compact")
|
|
839
|
+
margin = 1 if compact else 2
|
|
840
|
+
widths = [
|
|
841
|
+
max(14 if compact else 18, cell_len(str(button.label)) + max(4, button.gutter.width))
|
|
842
|
+
for button in buttons
|
|
843
|
+
]
|
|
844
|
+
rows = [list(range(min(3, len(buttons)))), list(range(3, len(buttons)))]
|
|
845
|
+
if compact or any(sum(widths[i] + 2 * margin for i in row) > available for row in rows):
|
|
846
|
+
rows = []
|
|
847
|
+
row = []
|
|
848
|
+
used = 0
|
|
849
|
+
for index, width in enumerate(widths):
|
|
850
|
+
if row and used + width + 2 * margin > available:
|
|
851
|
+
rows.append(row)
|
|
852
|
+
row, used = [], 0
|
|
853
|
+
row.append(index)
|
|
854
|
+
used += width + 2 * margin
|
|
855
|
+
if row:
|
|
856
|
+
rows.append(row)
|
|
857
|
+
y = 1
|
|
858
|
+
for row in filter(None, rows):
|
|
859
|
+
used = sum(widths[i] + 2 * margin for i in row)
|
|
860
|
+
x = max(0, (available - used) // 2)
|
|
861
|
+
for index in row:
|
|
862
|
+
button = buttons[index]
|
|
863
|
+
button.styles.width = min(widths[index], available - 2 * margin)
|
|
864
|
+
button.styles.offset = (x + margin, y)
|
|
865
|
+
x += widths[index] + 2 * margin
|
|
866
|
+
y += 3 if compact else 4
|
|
867
|
+
controls.styles.height = y if compact else y - 1
|
|
868
|
+
title = self.query_one("#conflict-title")
|
|
869
|
+
summary = self.query_one("#conflict-summary")
|
|
870
|
+
checkbox = self.query("#apply-remaining")
|
|
871
|
+
checkbox_height = (3 + (0 if compact else 1)) if checkbox else 0
|
|
872
|
+
# Auto height keeps the established large-dialog proportions. Only the
|
|
873
|
+
# summary is capped and scrollable; focus can never scroll the box.
|
|
874
|
+
reserved = (
|
|
875
|
+
box.gutter.height
|
|
876
|
+
+ min(2, title.get_content_height(box.content_size, self.size, available))
|
|
877
|
+
+ title.styles.margin.height
|
|
878
|
+
+ int(controls.styles.height.value)
|
|
879
|
+
)
|
|
880
|
+
reserved += checkbox_height + summary.styles.margin.height
|
|
881
|
+
summary.styles.max_height = max(1, int(self.size.height * 0.95) - reserved)
|
|
882
|
+
|
|
883
|
+
def action_choose(self, value):
|
|
884
|
+
from textual.widgets import Checkbox
|
|
885
|
+
|
|
886
|
+
if value not in self.options:
|
|
887
|
+
return
|
|
888
|
+
apply = self.query_one("#apply-remaining", Checkbox).value if self.rel_path else False
|
|
889
|
+
self.dismiss((value, apply))
|
|
890
|
+
|
|
891
|
+
def action_cancel(self):
|
|
892
|
+
self.dismiss(("cancel", False))
|
|
893
|
+
|
|
894
|
+
def key_l(self):
|
|
895
|
+
if self.rel_path:
|
|
896
|
+
checkbox = self.query_one("#apply-remaining")
|
|
897
|
+
checkbox.value = not checkbox.value
|
|
898
|
+
|
|
899
|
+
def on_button_pressed(self, event: Button.Pressed):
|
|
900
|
+
event.stop()
|
|
901
|
+
self.action_choose(event.button.id.removeprefix("conflict-"))
|
|
@@ -0,0 +1,191 @@
|
|
|
1
|
+
"""Textual orchestration for the shared copy/move transfer engine."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import os
|
|
6
|
+
import time
|
|
7
|
+
from dataclasses import replace
|
|
8
|
+
|
|
9
|
+
from .conflicts import Resolution, detect_conflicts
|
|
10
|
+
from .executor import TransferStatus
|
|
11
|
+
from .modals import ConfirmScreen, ConflictScreen, MessageScreen
|
|
12
|
+
from .models import OpKind, OpSide, OperationQueue, QueuedOp
|
|
13
|
+
from .progress_screen import ProgressScreen
|
|
14
|
+
from .scanner import tree_stats
|
|
15
|
+
from .transfer import ConflictPolicy, build_transfer_plan, execute_transfer_plan
|
|
16
|
+
|
|
17
|
+
CANCELLED_TEXT = (
|
|
18
|
+
"Cancelled.\n\nFiles already copied stay at the destination.\n"
|
|
19
|
+
"The file in progress was discarded - no half-copied files were left.\n"
|
|
20
|
+
"For a move, the source of anything unfinished is still in place."
|
|
21
|
+
)
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def run_transfer(owner, kind, label, src_panel, dst_panel, sources):
|
|
25
|
+
destination = dst_panel.path
|
|
26
|
+
scanning = ProgressScreen("Checking copy/move conflicts…", len(sources))
|
|
27
|
+
owner.app.push_screen(scanning)
|
|
28
|
+
|
|
29
|
+
def scanned(report, queue, error=None):
|
|
30
|
+
scanning.dismiss()
|
|
31
|
+
if error:
|
|
32
|
+
owner.app.push_screen(MessageScreen(f"Could not scan sources: {error}"))
|
|
33
|
+
elif scanning.cancel_requested:
|
|
34
|
+
owner.app.push_screen(MessageScreen(CANCELLED_TEXT))
|
|
35
|
+
elif report.has_conflicts or report.dir_collisions:
|
|
36
|
+
choose_policy(report)
|
|
37
|
+
else:
|
|
38
|
+
owner.app.push_screen(
|
|
39
|
+
ConfirmScreen(f"{label} to {destination}", queue),
|
|
40
|
+
lambda confirmed: (
|
|
41
|
+
execute(report, ConflictPolicy(Resolution.OVERWRITE)) if confirmed else None
|
|
42
|
+
),
|
|
43
|
+
)
|
|
44
|
+
|
|
45
|
+
def scan():
|
|
46
|
+
try:
|
|
47
|
+
report = detect_conflicts(sources, destination, lambda: scanning.cancel_requested)
|
|
48
|
+
queue = OperationQueue()
|
|
49
|
+
side = OpSide.LEFT_TO_RIGHT if src_panel.side == "left" else OpSide.RIGHT_TO_LEFT
|
|
50
|
+
for source in sources:
|
|
51
|
+
_, _, size = tree_stats([source], lambda: scanning.cancel_requested)
|
|
52
|
+
queue.add(
|
|
53
|
+
QueuedOp(
|
|
54
|
+
kind, side, os.path.relpath(source, src_panel.path), os.path.isdir(source), size=size
|
|
55
|
+
)
|
|
56
|
+
)
|
|
57
|
+
owner.app.call_from_thread(scanned, report, queue)
|
|
58
|
+
except Exception as error:
|
|
59
|
+
owner.app.call_from_thread(scanned, None, None, str(error))
|
|
60
|
+
|
|
61
|
+
def choose_policy(report):
|
|
62
|
+
def selected(answer):
|
|
63
|
+
value, _ = answer
|
|
64
|
+
if value == "cancel":
|
|
65
|
+
return
|
|
66
|
+
if value == "ask":
|
|
67
|
+
ask_each(report)
|
|
68
|
+
elif value == "merge":
|
|
69
|
+
|
|
70
|
+
def merged(answer):
|
|
71
|
+
value, _ = answer
|
|
72
|
+
if value != "cancel":
|
|
73
|
+
execute(
|
|
74
|
+
report,
|
|
75
|
+
ConflictPolicy(
|
|
76
|
+
Resolution(value), {path: Resolution.MERGE for path in report.dir_collisions}
|
|
77
|
+
),
|
|
78
|
+
)
|
|
79
|
+
|
|
80
|
+
owner.app.push_screen(ConflictScreen(report, destination, kind, file_policy=True), merged)
|
|
81
|
+
else:
|
|
82
|
+
execute(report, ConflictPolicy(Resolution(value)))
|
|
83
|
+
|
|
84
|
+
owner.app.push_screen(ConflictScreen(report, destination, kind), selected)
|
|
85
|
+
|
|
86
|
+
def ask_each(report):
|
|
87
|
+
# A rel_path is the policy key, so homonymous source names share a
|
|
88
|
+
# decision, displayed explicitly in the summary.
|
|
89
|
+
paths = list(dict.fromkeys([c.rel_path for c in report.conflicts] + report.duplicate_sources))
|
|
90
|
+
paths += [p for p in report.dir_collisions if not any(x == p or x.startswith(p + "/") for x in paths)]
|
|
91
|
+
policy = ConflictPolicy(Resolution.SKIP)
|
|
92
|
+
|
|
93
|
+
def next_item(index):
|
|
94
|
+
if index == len(paths):
|
|
95
|
+
execute(report, policy)
|
|
96
|
+
return
|
|
97
|
+
|
|
98
|
+
def selected(answer):
|
|
99
|
+
value, apply = answer
|
|
100
|
+
if value == "cancel":
|
|
101
|
+
return # Nothing has executed yet, including prior decisions.
|
|
102
|
+
remaining = paths[index:] if apply else [paths[index]]
|
|
103
|
+
for path in remaining:
|
|
104
|
+
policy.overrides[path] = Resolution(value)
|
|
105
|
+
next_item(len(paths) if apply else index + 1)
|
|
106
|
+
|
|
107
|
+
owner.app.push_screen(ConflictScreen(report, destination, kind, rel_path=paths[index]), selected)
|
|
108
|
+
|
|
109
|
+
next_item(0)
|
|
110
|
+
|
|
111
|
+
def execute(report, policy):
|
|
112
|
+
progress = ProgressScreen(f"{label} to {destination}", len(sources))
|
|
113
|
+
owner.app.push_screen(progress)
|
|
114
|
+
|
|
115
|
+
def work():
|
|
116
|
+
try:
|
|
117
|
+
plan = build_transfer_plan(sources, destination, kind, report, policy)
|
|
118
|
+
status = TransferStatus(ops_total=len(plan.items))
|
|
119
|
+
for item in plan.items:
|
|
120
|
+
if item.action != "skip":
|
|
121
|
+
files, _, size = tree_stats([item.src_abs], lambda: progress.cancel_requested)
|
|
122
|
+
status.files_total += files
|
|
123
|
+
status.bytes_total += size
|
|
124
|
+
last = 0.0
|
|
125
|
+
current_done = 0
|
|
126
|
+
completed = set()
|
|
127
|
+
previous_failures = 0
|
|
128
|
+
|
|
129
|
+
def update(done, total, name):
|
|
130
|
+
nonlocal last, current_done
|
|
131
|
+
if done == 0:
|
|
132
|
+
current_done = 0
|
|
133
|
+
status.files_done += 1
|
|
134
|
+
status.bytes_done += done - current_done
|
|
135
|
+
current_done = done
|
|
136
|
+
status.file_name, status.file_done, status.file_total = name, done, total
|
|
137
|
+
now = time.monotonic()
|
|
138
|
+
if now - last >= 0.1 or done == total:
|
|
139
|
+
last = now
|
|
140
|
+
owner.app.call_from_thread(progress.report, replace(status))
|
|
141
|
+
|
|
142
|
+
def on_item(item, result):
|
|
143
|
+
nonlocal previous_failures
|
|
144
|
+
status.ops_done += 1
|
|
145
|
+
if (
|
|
146
|
+
item.action != "skip"
|
|
147
|
+
and not result.cancelled
|
|
148
|
+
and len(result.failed) == previous_failures
|
|
149
|
+
):
|
|
150
|
+
completed.add(item.src_abs)
|
|
151
|
+
previous_failures = len(result.failed)
|
|
152
|
+
owner.app.call_from_thread(progress.report, replace(status))
|
|
153
|
+
|
|
154
|
+
result = execute_transfer_plan(plan, kind, update, lambda: progress.cancel_requested, on_item)
|
|
155
|
+
owner.app.call_from_thread(finish, plan, result, completed)
|
|
156
|
+
except Exception as error:
|
|
157
|
+
owner.app.call_from_thread(failed, str(error))
|
|
158
|
+
|
|
159
|
+
def failed(error):
|
|
160
|
+
progress.dismiss()
|
|
161
|
+
owner.app.push_screen(MessageScreen(f"Could not transfer: {error}"))
|
|
162
|
+
|
|
163
|
+
def finish(plan, result, completed):
|
|
164
|
+
progress.dismiss()
|
|
165
|
+
for source in sources:
|
|
166
|
+
related = [
|
|
167
|
+
item
|
|
168
|
+
for item in plan.items
|
|
169
|
+
if item.src_abs == source or item.src_abs.startswith(source + os.sep)
|
|
170
|
+
]
|
|
171
|
+
if (related and all(item.src_abs in completed for item in related)) or (
|
|
172
|
+
not related and not result.cancelled and not result.failed and source in plan.source_dirs
|
|
173
|
+
):
|
|
174
|
+
src_panel.marked.discard(source)
|
|
175
|
+
src_panel.refresh_listing()
|
|
176
|
+
dst_panel.refresh_listing()
|
|
177
|
+
summary = f"{result.copied} copied, {len(result.skipped)} skipped, {len(result.failed)} failed"
|
|
178
|
+
identical_kept = result.identical_kept or plan.identical_kept
|
|
179
|
+
if kind == OpKind.MOVE and identical_kept:
|
|
180
|
+
summary += f"\n{identical_kept} identical files stayed in the source"
|
|
181
|
+
if result.cancelled:
|
|
182
|
+
owner.app.push_screen(MessageScreen(CANCELLED_TEXT + "\n\n" + summary))
|
|
183
|
+
elif result.failed:
|
|
184
|
+
errors = "\n".join(f"{path}: {error}" for path, error in result.failed[:10])
|
|
185
|
+
owner.app.push_screen(MessageScreen(summary + "\n" + errors))
|
|
186
|
+
else:
|
|
187
|
+
owner.notify(summary)
|
|
188
|
+
|
|
189
|
+
owner.call_after_refresh(lambda: owner.run_worker(work, thread=True))
|
|
190
|
+
|
|
191
|
+
owner.call_after_refresh(lambda: owner.run_worker(scan, thread=True))
|