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.
Files changed (54) hide show
  1. {commandertui-1.4.2 → commandertui-1.5.0}/PKG-INFO +18 -1
  2. {commandertui-1.4.2 → commandertui-1.5.0}/README.md +17 -0
  3. commandertui-1.5.0/commandertui/__init__.py +1 -0
  4. commandertui-1.5.0/commandertui/conflicts.py +183 -0
  5. {commandertui-1.4.2 → commandertui-1.5.0}/commandertui/main_screen.py +5 -20
  6. {commandertui-1.4.2 → commandertui-1.5.0}/commandertui/modals.py +202 -1
  7. commandertui-1.5.0/commandertui/run_transfer.py +191 -0
  8. {commandertui-1.4.2 → commandertui-1.5.0}/commandertui/theme.py +38 -6
  9. commandertui-1.5.0/commandertui/transfer.py +294 -0
  10. {commandertui-1.4.2 → commandertui-1.5.0}/commandertui/trash.py +2 -2
  11. {commandertui-1.4.2 → commandertui-1.5.0}/commandertui/updater.py +22 -4
  12. {commandertui-1.4.2 → commandertui-1.5.0}/commandertui.egg-info/PKG-INFO +18 -1
  13. {commandertui-1.4.2 → commandertui-1.5.0}/commandertui.egg-info/SOURCES.txt +6 -0
  14. commandertui-1.5.0/tests/test_conflict_ui.py +488 -0
  15. commandertui-1.5.0/tests/test_conflicts.py +215 -0
  16. commandertui-1.5.0/tests/test_transfer.py +547 -0
  17. {commandertui-1.4.2 → commandertui-1.5.0}/tests/test_updater.py +34 -2
  18. commandertui-1.4.2/commandertui/__init__.py +0 -1
  19. {commandertui-1.4.2 → commandertui-1.5.0}/LICENSE +0 -0
  20. {commandertui-1.4.2 → commandertui-1.5.0}/commandertui/app.py +0 -0
  21. {commandertui-1.4.2 → commandertui-1.5.0}/commandertui/ascii_font.py +0 -0
  22. {commandertui-1.4.2 → commandertui-1.5.0}/commandertui/bookmarks.py +0 -0
  23. {commandertui-1.4.2 → commandertui-1.5.0}/commandertui/boot.py +0 -0
  24. {commandertui-1.4.2 → commandertui-1.5.0}/commandertui/cli.py +0 -0
  25. {commandertui-1.4.2 → commandertui-1.5.0}/commandertui/compare_screen.py +0 -0
  26. {commandertui-1.4.2 → commandertui-1.5.0}/commandertui/diff.py +0 -0
  27. {commandertui-1.4.2 → commandertui-1.5.0}/commandertui/editor.py +0 -0
  28. {commandertui-1.4.2 → commandertui-1.5.0}/commandertui/executor.py +0 -0
  29. {commandertui-1.4.2 → commandertui-1.5.0}/commandertui/models.py +0 -0
  30. {commandertui-1.4.2 → commandertui-1.5.0}/commandertui/netlocations.py +0 -0
  31. {commandertui-1.4.2 → commandertui-1.5.0}/commandertui/plan.py +0 -0
  32. {commandertui-1.4.2 → commandertui-1.5.0}/commandertui/preview.py +0 -0
  33. {commandertui-1.4.2 → commandertui-1.5.0}/commandertui/progress_screen.py +0 -0
  34. {commandertui-1.4.2 → commandertui-1.5.0}/commandertui/run_queue.py +0 -0
  35. {commandertui-1.4.2 → commandertui-1.5.0}/commandertui/scanner.py +0 -0
  36. {commandertui-1.4.2 → commandertui-1.5.0}/commandertui/settings.py +0 -0
  37. {commandertui-1.4.2 → commandertui-1.5.0}/commandertui/sync.py +0 -0
  38. {commandertui-1.4.2 → commandertui-1.5.0}/commandertui/widgets.py +0 -0
  39. {commandertui-1.4.2 → commandertui-1.5.0}/commandertui.egg-info/dependency_links.txt +0 -0
  40. {commandertui-1.4.2 → commandertui-1.5.0}/commandertui.egg-info/entry_points.txt +0 -0
  41. {commandertui-1.4.2 → commandertui-1.5.0}/commandertui.egg-info/requires.txt +0 -0
  42. {commandertui-1.4.2 → commandertui-1.5.0}/commandertui.egg-info/top_level.txt +0 -0
  43. {commandertui-1.4.2 → commandertui-1.5.0}/pyproject.toml +0 -0
  44. {commandertui-1.4.2 → commandertui-1.5.0}/setup.cfg +0 -0
  45. {commandertui-1.4.2 → commandertui-1.5.0}/tests/test_app_smoke.py +0 -0
  46. {commandertui-1.4.2 → commandertui-1.5.0}/tests/test_bookmarks.py +0 -0
  47. {commandertui-1.4.2 → commandertui-1.5.0}/tests/test_diff.py +0 -0
  48. {commandertui-1.4.2 → commandertui-1.5.0}/tests/test_executor.py +0 -0
  49. {commandertui-1.4.2 → commandertui-1.5.0}/tests/test_netlocations.py +0 -0
  50. {commandertui-1.4.2 → commandertui-1.5.0}/tests/test_plan.py +0 -0
  51. {commandertui-1.4.2 → commandertui-1.5.0}/tests/test_preview.py +0 -0
  52. {commandertui-1.4.2 → commandertui-1.5.0}/tests/test_scanner.py +0 -0
  53. {commandertui-1.4.2 → commandertui-1.5.0}/tests/test_sync.py +0 -0
  54. {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.4.2
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("The update did not finish. Check the terminal output, then try again.")
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
- side = OpSide.LEFT_TO_RIGHT if src_panel.side == "left" else OpSide.RIGHT_TO_LEFT
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))