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