commandertui 1.0.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1 @@
1
+ __version__ = "1.0.0"
commandertui/app.py ADDED
@@ -0,0 +1,40 @@
1
+ from __future__ import annotations
2
+
3
+ import os
4
+
5
+ from textual.app import App
6
+
7
+ from .boot import BootScreen
8
+ from .main_screen import MainScreen
9
+ from .theme import css_for
10
+
11
+
12
+ class CommanderApp(App):
13
+ """Owns only the screen stack. All pane behavior lives in MainScreen --
14
+ see its docstring for why that split matters for key bindings.
15
+ """
16
+
17
+ CSS = css_for("green")
18
+
19
+ def __init__(self, left_path: str | None = None, right_path: str | None = None) -> None:
20
+ super().__init__()
21
+ self.left_path = os.path.abspath(left_path or os.path.expanduser("~"))
22
+ self.right_path = os.path.abspath(right_path or os.path.expanduser("~"))
23
+
24
+ def on_mount(self) -> None:
25
+ self.push_screen(MainScreen(self.left_path, self.right_path))
26
+ self.push_screen(BootScreen())
27
+
28
+ # Convenience passthroughs so existing tests/tools that poke at the app
29
+ # directly (e.g. app._panel("left")) keep working without knowing about
30
+ # the MainScreen split.
31
+ def _panel(self, side: str):
32
+ return self.main_screen._panel(side)
33
+
34
+ @property
35
+ def main_screen(self) -> MainScreen:
36
+ return next(s for s in self.screen_stack if isinstance(s, MainScreen))
37
+
38
+ @property
39
+ def active_side(self) -> str:
40
+ return self.main_screen.active_side
@@ -0,0 +1,27 @@
1
+ from __future__ import annotations
2
+
3
+ # A minimal 5x5 block-letter font, just enough for "COMMANDER". Handwritten
4
+ # once here rather than pulled from a figlet dependency -- it's nine glyphs,
5
+ # not worth a library.
6
+ _FONT: dict[str, list[str]] = {
7
+ "A": [" ### ", "# #", "#####", "# #", "# #"],
8
+ "C": [" ####", "# ", "# ", "# ", " ####"],
9
+ "D": ["#### ", "# #", "# #", "# #", "#### "],
10
+ "E": ["#####", "# ", "#### ", "# ", "#####"],
11
+ "M": ["# #", "## ##", "# # #", "# #", "# #"],
12
+ "N": ["# #", "## #", "# # #", "# ##", "# #"],
13
+ "O": [" ### ", "# #", "# #", "# #", " ### "],
14
+ "R": ["#### ", "# #", "#### ", "# # ", "# #"],
15
+ }
16
+
17
+ BLOCK = "█" # solid block, swapped in for the '#' placeholders above
18
+
19
+
20
+ def banner(word: str, gap: int = 1) -> str:
21
+ """Render `word` as 5 lines of block letters."""
22
+ letters = [_FONT[ch] for ch in word.upper()]
23
+ sep = " " * gap
24
+ rows = []
25
+ for row_index in range(5):
26
+ rows.append(sep.join(letter[row_index] for letter in letters).replace("#", BLOCK))
27
+ return "\n".join(rows)
@@ -0,0 +1,133 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ import os
5
+ from dataclasses import dataclass
6
+
7
+ CONFIG_DIR = os.path.expanduser("~/.config/commandertui")
8
+ BOOKMARKS_FILE = os.path.join(CONFIG_DIR, "bookmarks.json")
9
+
10
+ # Where Linux desktops mount removable/network drives.
11
+ MOUNT_ROOTS = ("/run/media", "/media", "/mnt")
12
+
13
+ # Nautilus (and GNOME apps generally) mount network shares -- SMB, FTP,
14
+ # SFTP -- through GVFS rather than udisks2, so they never show up under
15
+ # MOUNT_ROOTS. They land here instead, one FUSE directory per connection.
16
+ GVFS_ROOT = f"/run/user/{os.getuid()}/gvfs"
17
+
18
+
19
+ @dataclass(frozen=True)
20
+ class Place:
21
+ label: str
22
+ path: str
23
+ kind: str # "mounted" | "saved" | "home"
24
+
25
+
26
+ def detect_mounted() -> list[Place]:
27
+ places: list[Place] = []
28
+ for root in MOUNT_ROOTS:
29
+ if not os.path.isdir(root):
30
+ continue
31
+ try:
32
+ for user_dir in os.scandir(root):
33
+ if not user_dir.is_dir():
34
+ continue
35
+ # /run/media/<user>/<Label> vs /mnt/<Label> (no user subdir)
36
+ candidates = list(os.scandir(user_dir.path)) if root == "/run/media" else [user_dir]
37
+ for c in candidates:
38
+ # An on-demand mount point (e.g. /mnt/i7server) exists as
39
+ # an empty directory even when nothing is mounted there --
40
+ # os.path.ismount() is what actually tells them apart.
41
+ if c.is_dir() and os.path.ismount(c.path):
42
+ places.append(Place(label=c.name, path=c.path, kind="mounted"))
43
+ except PermissionError:
44
+ continue
45
+ return places
46
+
47
+
48
+ def _label_for_gvfs_entry(name: str) -> str:
49
+ """GVFS names a mount after its full connection URI, e.g.
50
+ 'smb-share:server=192.168.1.10,share=Media' -- turn that into something
51
+ a human would recognize, falling back to the raw name if it's a shape
52
+ we don't specifically parse (sftp, ftp, google-drive, ...).
53
+ """
54
+ _scheme, _, rest = name.partition(":")
55
+ params = dict(pair.split("=", 1) for pair in rest.split(",") if "=" in pair)
56
+ share = params.get("share")
57
+ server = params.get("server") or params.get("host")
58
+ if share and server:
59
+ return f"{share} @ {server}"
60
+ return server or name
61
+
62
+
63
+ def detect_network_mounts() -> list[Place]:
64
+ if not os.path.isdir(GVFS_ROOT):
65
+ return []
66
+ try:
67
+ return [
68
+ Place(label=_label_for_gvfs_entry(entry.name), path=entry.path, kind="network")
69
+ for entry in os.scandir(GVFS_ROOT)
70
+ if entry.is_dir()
71
+ ]
72
+ except PermissionError:
73
+ return []
74
+
75
+
76
+ PROC_MOUNTS = "/proc/mounts"
77
+
78
+
79
+ def _unescape_mount_path(path: str) -> str:
80
+ # /proc/mounts octal-escapes whitespace and backslashes in paths.
81
+ return path.replace("\\040", " ").replace("\\011", "\t").replace("\\012", "\n").replace("\\134", "\\")
82
+
83
+
84
+ def detect_home_mounts() -> list[Place]:
85
+ """FUSE mounts inside $HOME, e.g. rclone's ~/365 and ~/GoogleDrive.
86
+ Read from /proc/mounts so only what is live right now shows up."""
87
+ home = os.path.expanduser("~")
88
+ try:
89
+ with open(PROC_MOUNTS) as f:
90
+ lines = f.readlines()
91
+ except OSError:
92
+ return []
93
+ places: list[Place] = []
94
+ for line in lines:
95
+ parts = line.split()
96
+ if len(parts) < 3 or not parts[2].startswith("fuse."):
97
+ continue
98
+ path = _unescape_mount_path(parts[1])
99
+ if path.startswith(home + os.sep):
100
+ places.append(Place(label=os.path.basename(path), path=path, kind=parts[2][5:]))
101
+ return places
102
+
103
+
104
+ def load_saved() -> list[Place]:
105
+ if not os.path.isfile(BOOKMARKS_FILE):
106
+ return []
107
+ try:
108
+ with open(BOOKMARKS_FILE) as f:
109
+ data = json.load(f)
110
+ return [Place(label=b["label"], path=b["path"], kind="saved") for b in data]
111
+ except Exception:
112
+ return []
113
+
114
+
115
+ def save_bookmark(label: str, path: str) -> None:
116
+ os.makedirs(CONFIG_DIR, exist_ok=True)
117
+ saved = load_saved()
118
+ saved = [b for b in saved if b.path != path]
119
+ saved.append(Place(label=label, path=path, kind="saved"))
120
+ with open(BOOKMARKS_FILE, "w") as f:
121
+ json.dump([{"label": b.label, "path": b.path} for b in saved], f, indent=2)
122
+
123
+
124
+ def remove_bookmark(path: str) -> None:
125
+ saved = [b for b in load_saved() if b.path != path]
126
+ os.makedirs(CONFIG_DIR, exist_ok=True)
127
+ with open(BOOKMARKS_FILE, "w") as f:
128
+ json.dump([{"label": b.label, "path": b.path} for b in saved], f, indent=2)
129
+
130
+
131
+ def all_places() -> list[Place]:
132
+ home = Place(label="Home", path=os.path.expanduser("~"), kind="home")
133
+ return [home] + detect_mounted() + detect_network_mounts() + detect_home_mounts() + load_saved()
commandertui/boot.py ADDED
@@ -0,0 +1,114 @@
1
+ from __future__ import annotations
2
+
3
+ from textual.app import ComposeResult
4
+ from textual.binding import Binding
5
+ from textual.screen import Screen
6
+ from textual.widgets import Static
7
+
8
+ from .ascii_font import banner
9
+
10
+ CURSOR = "█" # solid block, like a CRT text cursor
11
+
12
+
13
+ def _build_art() -> str:
14
+ title_lines = banner("COMMANDER").split("\n")
15
+ body_lines = [
16
+ "",
17
+ "T U I -- dual-pane file manager",
18
+ "",
19
+ "CONNECT 2400 ...................... OK",
20
+ "LOADING PANELS ..................... OK",
21
+ "READY.",
22
+ ]
23
+ content = title_lines + body_lines
24
+ width = max(len(line) for line in content) + 2 # 1 space padding each side
25
+
26
+ def pad(line: str) -> str:
27
+ return f"|{line.center(width)}|"
28
+
29
+ border = "+" + "-" * width + "+"
30
+ boxed = [border] + [pad(line) for line in content] + [border]
31
+ return "\n".join(boxed)
32
+
33
+
34
+ ART = _build_art()
35
+
36
+
37
+ class BootScreen(Screen):
38
+ """A brief modem-style 'printing' reveal shown once at startup.
39
+
40
+ Purely cosmetic: it reveals ART character by character with a visible
41
+ cursor, like text arriving over a slow serial line. Once fully drawn it
42
+ waits on screen for an explicit [Enter] rather than auto-dismissing --
43
+ the point is to actually see the drawing happen, not blink and miss it.
44
+ """
45
+
46
+ DEFAULT_CSS = """
47
+ BootScreen {
48
+ align: center middle;
49
+ }
50
+ #boot-text {
51
+ width: auto;
52
+ height: auto;
53
+ }
54
+ #boot-hint {
55
+ width: auto;
56
+ height: auto;
57
+ text-align: center;
58
+ margin-top: 1;
59
+ }
60
+ """
61
+
62
+ BINDINGS = [Binding("enter", "continue_", "Continue", priority=True)]
63
+
64
+ def __init__(
65
+ self,
66
+ art: str = ART,
67
+ chars_per_tick: int = 3,
68
+ tick_seconds: float = 0.035,
69
+ bell: bool = True,
70
+ ) -> None:
71
+ super().__init__()
72
+ self.art = art
73
+ self.chars_per_tick = chars_per_tick
74
+ self.tick_seconds = tick_seconds
75
+ self.bell = bell
76
+ self._pos = 0
77
+ self._timer = None
78
+ self._done = False
79
+
80
+ def compose(self) -> ComposeResult:
81
+ # No Center/Middle wrapper containers: those default to width 100%,
82
+ # which meant "align: center" had nothing to actually center -- the
83
+ # boxed art rendered flush against the left edge instead. `width:
84
+ # auto` on the Static itself (set in DEFAULT_CSS) is what lets the
85
+ # screen's own `align: center middle` actually center it as a block.
86
+ yield Static("", id="boot-text")
87
+ yield Static("", id="boot-hint")
88
+
89
+ def on_mount(self) -> None:
90
+ self._timer = self.set_interval(self.tick_seconds, self._tick)
91
+
92
+ def _tick(self) -> None:
93
+ self._pos = min(len(self.art), self._pos + self.chars_per_tick)
94
+ widget = self.query_one("#boot-text", Static)
95
+ visible = self.art[: self._pos]
96
+ cursor = CURSOR if self._pos < len(self.art) else ""
97
+ widget.update(visible + cursor)
98
+
99
+ if self._pos >= len(self.art):
100
+ self._done = True
101
+ if self._timer is not None:
102
+ self._timer.stop()
103
+ if self.bell:
104
+ self.app.bell()
105
+ self.query_one("#boot-hint", Static).update("[ENTER] to continue")
106
+
107
+ def action_continue_(self) -> None:
108
+ if not self._done:
109
+ # Not drawn yet: Enter fast-forwards straight to the final frame
110
+ # instead of doing nothing, in case someone doesn't want to wait.
111
+ self._pos = len(self.art)
112
+ self._tick()
113
+ return
114
+ self.dismiss()
commandertui/cli.py ADDED
@@ -0,0 +1,22 @@
1
+ from __future__ import annotations
2
+
3
+ import argparse
4
+ import os
5
+
6
+ from . import __version__
7
+ from .app import CommanderApp
8
+
9
+
10
+ def main() -> None:
11
+ parser = argparse.ArgumentParser(prog="commandertui", description="Dual-pane TUI file manager")
12
+ parser.add_argument("left", nargs="?", default=os.path.expanduser("~"))
13
+ parser.add_argument("right", nargs="?", default=os.path.expanduser("~"))
14
+ parser.add_argument("--version", action="version", version=f"commandertui {__version__}")
15
+ args = parser.parse_args()
16
+
17
+ app = CommanderApp(left_path=args.left, right_path=args.right)
18
+ app.run()
19
+
20
+
21
+ if __name__ == "__main__":
22
+ main()
@@ -0,0 +1,146 @@
1
+ from __future__ import annotations
2
+
3
+ from textual.app import ComposeResult
4
+ from textual.binding import Binding
5
+ from textual.containers import Vertical
6
+ from textual.screen import Screen
7
+ from textual.widgets import DataTable, Footer, Static
8
+
9
+ from .diff import compare
10
+ from .modals import MessageScreen
11
+ from .models import DiffStatus, OpKind, OpSide, OperationQueue, QueuedOp
12
+ from .plan import plan_mirror, plan_two_way, plan_update
13
+ from .run_queue import run_queue_with_progress
14
+
15
+ STATUS_LABEL = {
16
+ DiffStatus.LEFT_ONLY: "< only left",
17
+ DiffStatus.RIGHT_ONLY: "> only right",
18
+ DiffStatus.MODIFIED: "! modified",
19
+ DiffStatus.IDENTICAL: "= identical",
20
+ }
21
+
22
+
23
+ class CompareScreen(Screen):
24
+ """Full recursive diff between the two panes' current directories, with
25
+ bulk actions (mirror/update/two-way) or per-item manual handling.
26
+ """
27
+
28
+ # priority=True: same reason as CommanderApp -- the focused DataTable
29
+ # would otherwise intercept space/enter-like keys before we see them.
30
+ BINDINGS = [
31
+ Binding("escape", "back", "Back", priority=True),
32
+ Binding("m", "mirror", "Mirror ->", priority=True),
33
+ Binding("u", "update", "Update ->", priority=True),
34
+ Binding("b", "two_way", "Two-way", priority=True),
35
+ Binding("space", "toggle_manual", "Mark", priority=True),
36
+ Binding("c", "copy_manual", "Copy marked ->", priority=True),
37
+ ]
38
+
39
+ def __init__(self, left_root: str, right_root: str) -> None:
40
+ super().__init__()
41
+ self.left_root = left_root
42
+ self.right_root = right_root
43
+ self.manual_marks: set[str] = set()
44
+
45
+ def compose(self) -> ComposeResult:
46
+ yield Static("", id="compare-summary")
47
+ yield DataTable(id="compare-table", cursor_type="row")
48
+ yield Footer()
49
+
50
+ def on_mount(self) -> None:
51
+ self.reload()
52
+
53
+ def reload(self) -> None:
54
+ self.diffs = compare(self.left_root, self.right_root)
55
+ table = self.query_one("#compare-table", DataTable)
56
+ table.clear(columns=True)
57
+ table.add_columns(" ", "Path", "Status")
58
+ for d in self.diffs:
59
+ if d.status == DiffStatus.IDENTICAL:
60
+ continue
61
+ mark = "*" if d.rel_path in self.manual_marks else ""
62
+ table.add_row(mark, d.rel_path, STATUS_LABEL[d.status], key=d.rel_path)
63
+
64
+ non_identical = sum(1 for d in self.diffs if d.status != DiffStatus.IDENTICAL)
65
+ summary = self.query_one("#compare-summary", Static)
66
+ summary.update(
67
+ f"{self.left_root} <-> {self.right_root}\n"
68
+ f"{non_identical} differences "
69
+ "[m]irror [u]pdate [b]idirectional [space] mark [c]opy marked [esc] back"
70
+ )
71
+
72
+ def action_back(self) -> None:
73
+ self.dismiss()
74
+
75
+ def _run_queue(self, title: str, queue: OperationQueue) -> None:
76
+ run_queue_with_progress(
77
+ self, title, queue, self.left_root, self.right_root, after=lambda report: self.reload()
78
+ )
79
+
80
+ def action_mirror(self) -> None:
81
+ active = [d for d in self.diffs if d.status != DiffStatus.IDENTICAL]
82
+ self._run_queue("Mirror: make RIGHT identical to LEFT", plan_mirror(active))
83
+
84
+ def action_update(self) -> None:
85
+ active = [d for d in self.diffs if d.status != DiffStatus.IDENTICAL]
86
+ self._run_queue("Update: copy new/changed LEFT -> RIGHT (no deletes)", plan_update(active))
87
+
88
+ def action_two_way(self) -> None:
89
+ active = [d for d in self.diffs if d.status != DiffStatus.IDENTICAL]
90
+ queue, conflicts = plan_two_way(active)
91
+ if conflicts:
92
+ names = "\n".join(f" {c.rel_path}" for c in conflicts[:10])
93
+ self.app.push_screen(
94
+ MessageScreen(
95
+ f"{len(conflicts)} conflict(s) changed on both sides -- "
96
+ f"resolve manually with [space]+[c]:\n{names}"
97
+ )
98
+ )
99
+ self._run_queue("Two-way: propagate each side's exclusive changes", queue)
100
+
101
+ def action_toggle_manual(self) -> None:
102
+ table = self.query_one("#compare-table", DataTable)
103
+ if table.row_count == 0:
104
+ return
105
+ row_key = table.coordinate_to_cell_key(table.cursor_coordinate).row_key
106
+ if row_key is None or row_key.value is None:
107
+ return
108
+ rel_path = str(row_key.value)
109
+ if rel_path in self.manual_marks:
110
+ self.manual_marks.discard(rel_path)
111
+ else:
112
+ self.manual_marks.add(rel_path)
113
+ self.reload()
114
+
115
+ def action_copy_manual(self) -> None:
116
+ if not self.manual_marks:
117
+ self.app.push_screen(MessageScreen("No items marked. Use [space] first."))
118
+ return
119
+ by_path = {d.rel_path: d for d in self.diffs}
120
+ queue = OperationQueue()
121
+ for rel_path in self.manual_marks:
122
+ d = by_path.get(rel_path)
123
+ if d is None:
124
+ continue
125
+ if d.status == DiffStatus.LEFT_ONLY or (d.status == DiffStatus.MODIFIED and d.left):
126
+ queue.add(
127
+ QueuedOp(
128
+ OpKind.COPY,
129
+ OpSide.LEFT_TO_RIGHT,
130
+ rel_path,
131
+ bool(d.left and d.left.kind.value == "dir"),
132
+ size=d.left.size if d.left else 0,
133
+ )
134
+ )
135
+ elif d.status == DiffStatus.RIGHT_ONLY:
136
+ queue.add(
137
+ QueuedOp(
138
+ OpKind.COPY,
139
+ OpSide.RIGHT_TO_LEFT,
140
+ rel_path,
141
+ bool(d.right and d.right.kind.value == "dir"),
142
+ size=d.right.size if d.right else 0,
143
+ )
144
+ )
145
+ self.manual_marks.clear()
146
+ self._run_queue("Copy marked items", queue)
commandertui/diff.py ADDED
@@ -0,0 +1,61 @@
1
+ from __future__ import annotations
2
+
3
+ import os
4
+
5
+ from .models import DiffEntry, DiffStatus, EntryKind
6
+ from .scanner import DEFAULT_EXCLUSIONS, scan_directory
7
+
8
+ # Files that differ by mtime alone (common after a plain copy that touched
9
+ # metadata) but are otherwise identical should not be flagged as modified.
10
+ MTIME_TOLERANCE_SECONDS = 2.0
11
+
12
+
13
+ def compare(
14
+ left_root: str,
15
+ right_root: str,
16
+ exclusions: set[str] = frozenset(DEFAULT_EXCLUSIONS),
17
+ cancel_check=None,
18
+ ) -> list[DiffEntry]:
19
+ if not os.path.isdir(left_root) or not os.path.isdir(right_root):
20
+ raise FileNotFoundError("One or both paths do not exist")
21
+
22
+ left = scan_directory(left_root, exclusions, cancel_check)
23
+ if cancel_check and cancel_check():
24
+ return []
25
+ right = scan_directory(right_root, exclusions, cancel_check)
26
+ if cancel_check and cancel_check():
27
+ return []
28
+
29
+ all_paths = sorted(set(left) | set(right))
30
+ diffs: list[DiffEntry] = []
31
+
32
+ for rel_path in all_paths:
33
+ l = left.get(rel_path)
34
+ r = right.get(rel_path)
35
+ name = (l or r).name
36
+
37
+ if l and not r:
38
+ status = DiffStatus.LEFT_ONLY
39
+ elif r and not l:
40
+ status = DiffStatus.RIGHT_ONLY
41
+ elif l.kind == EntryKind.DIR and r.kind == EntryKind.DIR:
42
+ status = DiffStatus.IDENTICAL
43
+ elif l.kind != r.kind:
44
+ status = DiffStatus.MODIFIED
45
+ elif l.size != r.size:
46
+ status = DiffStatus.MODIFIED
47
+ elif abs(l.mtime - r.mtime) > MTIME_TOLERANCE_SECONDS:
48
+ status = DiffStatus.MODIFIED
49
+ else:
50
+ status = DiffStatus.IDENTICAL
51
+
52
+ diffs.append(DiffEntry(rel_path=rel_path, name=name, status=status, left=l, right=r))
53
+
54
+ return diffs
55
+
56
+
57
+ def summarize(diffs: list[DiffEntry]) -> dict:
58
+ counts = {status: 0 for status in DiffStatus}
59
+ for d in diffs:
60
+ counts[d.status] += 1
61
+ return {status.value: n for status, n in counts.items()}
@@ -0,0 +1,134 @@
1
+ from __future__ import annotations
2
+
3
+ import os
4
+ from dataclasses import dataclass, field
5
+ from typing import Callable
6
+
7
+ from .models import OpKind, OpSide, OperationQueue
8
+ from .scanner import tree_stats
9
+ from .sync import copy_item, delete_item, move_item
10
+
11
+ OpProgressCallback = Callable[[int, int, str], None] # (op_index, op_total, description)
12
+
13
+
14
+ @dataclass
15
+ class TransferStatus:
16
+ file_name: str = ""
17
+ file_done: int = 0
18
+ file_total: int = 0
19
+ files_done: int = 0
20
+ files_total: int = 0
21
+ bytes_done: int = 0
22
+ bytes_total: int = 0
23
+ ops_done: int = 0
24
+ ops_total: int = 0
25
+
26
+
27
+ TransferCallback = Callable[[TransferStatus], None]
28
+
29
+
30
+ @dataclass
31
+ class ExecutionReport:
32
+ succeeded: int = 0
33
+ failed: list[tuple[str, str]] = field(default_factory=list) # (rel_path, error)
34
+ cancelled: bool = False
35
+
36
+
37
+ def execute(
38
+ queue: OperationQueue,
39
+ left_root: str,
40
+ right_root: str,
41
+ on_progress: OpProgressCallback | None = None,
42
+ cancel_check: Callable[[], bool] | None = None,
43
+ on_transfer: TransferCallback | None = None,
44
+ ) -> ExecutionReport:
45
+ report = ExecutionReport()
46
+ total = len(queue)
47
+ status = TransferStatus(ops_total=total)
48
+
49
+ # Per-op (files, bytes), so the overall bar can snap to the exact
50
+ # position after each op -- renames and deletes move no bytes but
51
+ # still have to advance it.
52
+ op_stats: list[tuple[int, int]] = []
53
+ if on_transfer:
54
+ for op in queue.ops:
55
+ if op.kind == OpKind.DELETE:
56
+ op_stats.append((0, 0))
57
+ continue
58
+ src_root, _ = _roots_for(op.side, left_root, right_root)
59
+ files, _dirs, size = tree_stats([os.path.join(src_root, op.rel_path)], cancel_check)
60
+ op_stats.append((files, size))
61
+ status.files_total = sum(f for f, _ in op_stats)
62
+ status.bytes_total = sum(b for _, b in op_stats)
63
+ on_transfer(status)
64
+
65
+ base_files = base_bytes = 0
66
+ current = {"done": 0}
67
+
68
+ def file_progress(done: int, file_total: int, name: str) -> None:
69
+ if done == 0: # a new file starts
70
+ status.files_done += 1
71
+ current["done"] = 0
72
+ status.bytes_done += done - current["done"]
73
+ current["done"] = done
74
+ status.file_name, status.file_done, status.file_total = name, done, file_total
75
+ on_transfer(status)
76
+
77
+ byte_progress = file_progress if on_transfer else None
78
+
79
+ for i, op in enumerate(queue.ops):
80
+ if cancel_check and cancel_check():
81
+ report.cancelled = True
82
+ break
83
+ if on_progress:
84
+ on_progress(i, total, op.rel_path)
85
+
86
+ try:
87
+ if op.kind == OpKind.COPY:
88
+ src_root, dst_root = _roots_for(op.side, left_root, right_root)
89
+ result = copy_item(
90
+ os.path.join(src_root, op.rel_path),
91
+ os.path.join(dst_root, op.rel_path),
92
+ byte_progress,
93
+ cancel_check,
94
+ )
95
+ elif op.kind == OpKind.MOVE:
96
+ src_root, dst_root = _roots_for(op.side, left_root, right_root)
97
+ result = move_item(
98
+ os.path.join(src_root, op.rel_path),
99
+ os.path.join(dst_root, op.rel_path),
100
+ byte_progress,
101
+ cancel_check,
102
+ )
103
+ else: # DELETE
104
+ root = left_root if op.side == OpSide.LEFT else right_root
105
+ result = delete_item(os.path.join(root, op.rel_path))
106
+
107
+ if result.cancelled:
108
+ report.cancelled = True
109
+ break
110
+ if result.ok:
111
+ report.succeeded += 1
112
+ else:
113
+ report.failed.append((op.rel_path, result.error or "unknown error"))
114
+ except Exception as e:
115
+ report.failed.append((op.rel_path, str(e)))
116
+
117
+ if on_transfer:
118
+ base_files += op_stats[i][0]
119
+ base_bytes += op_stats[i][1]
120
+ status.files_done, status.bytes_done = base_files, base_bytes
121
+ status.ops_done = i + 1
122
+ on_transfer(status)
123
+
124
+ if on_progress:
125
+ on_progress(total, total, "done")
126
+ return report
127
+
128
+
129
+ def _roots_for(side: OpSide, left_root: str, right_root: str) -> tuple[str, str]:
130
+ if side == OpSide.LEFT_TO_RIGHT:
131
+ return left_root, right_root
132
+ if side == OpSide.RIGHT_TO_LEFT:
133
+ return right_root, left_root
134
+ raise ValueError(f"_roots_for is only valid for cross-pane ops, got {side}")