commandertui 1.4.3__tar.gz → 1.5.1__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.3 → commandertui-1.5.1}/PKG-INFO +19 -2
- {commandertui-1.4.3 → commandertui-1.5.1}/README.md +18 -1
- commandertui-1.5.1/commandertui/__init__.py +1 -0
- {commandertui-1.4.3 → commandertui-1.5.1}/commandertui/boot.py +12 -3
- commandertui-1.5.1/commandertui/conflicts.py +183 -0
- {commandertui-1.4.3 → commandertui-1.5.1}/commandertui/main_screen.py +2 -19
- {commandertui-1.4.3 → commandertui-1.5.1}/commandertui/modals.py +202 -1
- commandertui-1.5.1/commandertui/run_transfer.py +191 -0
- {commandertui-1.4.3 → commandertui-1.5.1}/commandertui/theme.py +38 -6
- commandertui-1.5.1/commandertui/transfer.py +294 -0
- {commandertui-1.4.3 → commandertui-1.5.1}/commandertui/trash.py +2 -2
- {commandertui-1.4.3 → commandertui-1.5.1}/commandertui/updater.py +4 -2
- {commandertui-1.4.3 → commandertui-1.5.1}/commandertui.egg-info/PKG-INFO +19 -2
- {commandertui-1.4.3 → commandertui-1.5.1}/commandertui.egg-info/SOURCES.txt +6 -0
- {commandertui-1.4.3 → commandertui-1.5.1}/tests/test_app_smoke.py +21 -0
- commandertui-1.5.1/tests/test_conflict_ui.py +488 -0
- commandertui-1.5.1/tests/test_conflicts.py +215 -0
- commandertui-1.5.1/tests/test_transfer.py +547 -0
- {commandertui-1.4.3 → commandertui-1.5.1}/tests/test_updater.py +1 -1
- commandertui-1.4.3/commandertui/__init__.py +0 -1
- {commandertui-1.4.3 → commandertui-1.5.1}/LICENSE +0 -0
- {commandertui-1.4.3 → commandertui-1.5.1}/commandertui/app.py +0 -0
- {commandertui-1.4.3 → commandertui-1.5.1}/commandertui/ascii_font.py +0 -0
- {commandertui-1.4.3 → commandertui-1.5.1}/commandertui/bookmarks.py +0 -0
- {commandertui-1.4.3 → commandertui-1.5.1}/commandertui/cli.py +0 -0
- {commandertui-1.4.3 → commandertui-1.5.1}/commandertui/compare_screen.py +0 -0
- {commandertui-1.4.3 → commandertui-1.5.1}/commandertui/diff.py +0 -0
- {commandertui-1.4.3 → commandertui-1.5.1}/commandertui/editor.py +0 -0
- {commandertui-1.4.3 → commandertui-1.5.1}/commandertui/executor.py +0 -0
- {commandertui-1.4.3 → commandertui-1.5.1}/commandertui/models.py +0 -0
- {commandertui-1.4.3 → commandertui-1.5.1}/commandertui/netlocations.py +0 -0
- {commandertui-1.4.3 → commandertui-1.5.1}/commandertui/plan.py +0 -0
- {commandertui-1.4.3 → commandertui-1.5.1}/commandertui/preview.py +0 -0
- {commandertui-1.4.3 → commandertui-1.5.1}/commandertui/progress_screen.py +0 -0
- {commandertui-1.4.3 → commandertui-1.5.1}/commandertui/run_queue.py +0 -0
- {commandertui-1.4.3 → commandertui-1.5.1}/commandertui/scanner.py +0 -0
- {commandertui-1.4.3 → commandertui-1.5.1}/commandertui/settings.py +0 -0
- {commandertui-1.4.3 → commandertui-1.5.1}/commandertui/sync.py +0 -0
- {commandertui-1.4.3 → commandertui-1.5.1}/commandertui/widgets.py +0 -0
- {commandertui-1.4.3 → commandertui-1.5.1}/commandertui.egg-info/dependency_links.txt +0 -0
- {commandertui-1.4.3 → commandertui-1.5.1}/commandertui.egg-info/entry_points.txt +0 -0
- {commandertui-1.4.3 → commandertui-1.5.1}/commandertui.egg-info/requires.txt +0 -0
- {commandertui-1.4.3 → commandertui-1.5.1}/commandertui.egg-info/top_level.txt +0 -0
- {commandertui-1.4.3 → commandertui-1.5.1}/pyproject.toml +0 -0
- {commandertui-1.4.3 → commandertui-1.5.1}/setup.cfg +0 -0
- {commandertui-1.4.3 → commandertui-1.5.1}/tests/test_bookmarks.py +0 -0
- {commandertui-1.4.3 → commandertui-1.5.1}/tests/test_diff.py +0 -0
- {commandertui-1.4.3 → commandertui-1.5.1}/tests/test_executor.py +0 -0
- {commandertui-1.4.3 → commandertui-1.5.1}/tests/test_netlocations.py +0 -0
- {commandertui-1.4.3 → commandertui-1.5.1}/tests/test_plan.py +0 -0
- {commandertui-1.4.3 → commandertui-1.5.1}/tests/test_preview.py +0 -0
- {commandertui-1.4.3 → commandertui-1.5.1}/tests/test_scanner.py +0 -0
- {commandertui-1.4.3 → commandertui-1.5.1}/tests/test_sync.py +0 -0
- {commandertui-1.4.3 → commandertui-1.5.1}/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.1
|
|
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
|
|
@@ -183,7 +183,7 @@ the command that matches how it was installed:
|
|
|
183
183
|
|
|
184
184
|
| Installed as | Update runs |
|
|
185
185
|
|---|---|
|
|
186
|
-
| Arch package (`makepkg`) | rebuilds from the release's PKGBUILD with `makepkg -si` (your sudo password is typed in the terminal, never read by the app) |
|
|
186
|
+
| Arch package (`makepkg`) | rebuilds from the release's PKGBUILD with `makepkg -si --nocheck` (tests already ran in the release workflow) (your sudo password is typed in the terminal, never read by the app) |
|
|
187
187
|
| pipx | `pipx upgrade commandertui` |
|
|
188
188
|
| pip | `pip install --upgrade commandertui` |
|
|
189
189
|
| git checkout | no button: the dialog tells you to `git pull` |
|
|
@@ -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.
|
|
@@ -164,7 +164,7 @@ the command that matches how it was installed:
|
|
|
164
164
|
|
|
165
165
|
| Installed as | Update runs |
|
|
166
166
|
|---|---|
|
|
167
|
-
| Arch package (`makepkg`) | rebuilds from the release's PKGBUILD with `makepkg -si` (your sudo password is typed in the terminal, never read by the app) |
|
|
167
|
+
| Arch package (`makepkg`) | rebuilds from the release's PKGBUILD with `makepkg -si --nocheck` (tests already ran in the release workflow) (your sudo password is typed in the terminal, never read by the app) |
|
|
168
168
|
| pipx | `pipx upgrade commandertui` |
|
|
169
169
|
| pip | `pip install --upgrade commandertui` |
|
|
170
170
|
| git checkout | no button: the dialog tells you to `git pull` |
|
|
@@ -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.1"
|
|
@@ -3,6 +3,7 @@ from __future__ import annotations
|
|
|
3
3
|
from textual.app import ComposeResult
|
|
4
4
|
from textual.binding import Binding
|
|
5
5
|
from textual.screen import Screen
|
|
6
|
+
from textual.containers import Vertical
|
|
6
7
|
from textual.widgets import Static
|
|
7
8
|
|
|
8
9
|
from .ascii_font import banner
|
|
@@ -65,9 +66,16 @@ class BootScreen(Screen):
|
|
|
65
66
|
width: auto;
|
|
66
67
|
height: auto;
|
|
67
68
|
}
|
|
68
|
-
#boot-
|
|
69
|
+
#boot-box {
|
|
69
70
|
width: auto;
|
|
70
71
|
height: auto;
|
|
72
|
+
}
|
|
73
|
+
#boot-hint {
|
|
74
|
+
/* 100% of the box, which is as wide as the art: text-align can then
|
|
75
|
+
really center the hint under it (width:auto hugged the text and
|
|
76
|
+
left it at the art's left edge). */
|
|
77
|
+
width: 100%;
|
|
78
|
+
height: auto;
|
|
71
79
|
text-align: center;
|
|
72
80
|
margin-top: 1;
|
|
73
81
|
}
|
|
@@ -100,8 +108,9 @@ class BootScreen(Screen):
|
|
|
100
108
|
# boxed art rendered flush against the left edge instead. `width:
|
|
101
109
|
# auto` on the Static itself (set in DEFAULT_CSS) is what lets the
|
|
102
110
|
# screen's own `align: center middle` actually center it as a block.
|
|
103
|
-
|
|
104
|
-
|
|
111
|
+
with Vertical(id="boot-box"):
|
|
112
|
+
yield Static("", id="boot-text")
|
|
113
|
+
yield Static("", id="boot-hint")
|
|
105
114
|
|
|
106
115
|
def on_mount(self) -> None:
|
|
107
116
|
# Double-size title when the window is wide enough for it.
|
|
@@ -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
|
|
|
@@ -493,25 +494,7 @@ class MainScreen(Screen):
|
|
|
493
494
|
self._nothing_selected()
|
|
494
495
|
return
|
|
495
496
|
|
|
496
|
-
|
|
497
|
-
queue = OperationQueue()
|
|
498
|
-
for item in items:
|
|
499
|
-
rel_path = os.path.relpath(item, src_panel.path)
|
|
500
|
-
queue.add(QueuedOp(kind, side, rel_path, os.path.isdir(item), size=path_size(item)))
|
|
501
|
-
|
|
502
|
-
def after(report) -> None:
|
|
503
|
-
src_panel.marked.clear()
|
|
504
|
-
src_panel.refresh_listing()
|
|
505
|
-
dst_panel.refresh_listing()
|
|
506
|
-
|
|
507
|
-
run_queue_with_progress(
|
|
508
|
-
self,
|
|
509
|
-
f"{label} to {dst_panel.path}",
|
|
510
|
-
queue,
|
|
511
|
-
self._panel("left").path,
|
|
512
|
-
self._panel("right").path,
|
|
513
|
-
after,
|
|
514
|
-
)
|
|
497
|
+
run_transfer(self, kind, label, src_panel, dst_panel, items)
|
|
515
498
|
|
|
516
499
|
# ------------------------------------------------------------- misc
|
|
517
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-"))
|