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.
- commandertui/__init__.py +1 -0
- commandertui/app.py +40 -0
- commandertui/ascii_font.py +27 -0
- commandertui/bookmarks.py +133 -0
- commandertui/boot.py +114 -0
- commandertui/cli.py +22 -0
- commandertui/compare_screen.py +146 -0
- commandertui/diff.py +61 -0
- commandertui/executor.py +134 -0
- commandertui/main_screen.py +211 -0
- commandertui/modals.py +250 -0
- commandertui/models.py +79 -0
- commandertui/plan.py +50 -0
- commandertui/progress_screen.py +137 -0
- commandertui/run_queue.py +83 -0
- commandertui/scanner.py +115 -0
- commandertui/sync.py +149 -0
- commandertui/theme.py +94 -0
- commandertui/trash.py +49 -0
- commandertui/widgets.py +134 -0
- commandertui-1.0.0.dist-info/METADATA +191 -0
- commandertui-1.0.0.dist-info/RECORD +26 -0
- commandertui-1.0.0.dist-info/WHEEL +5 -0
- commandertui-1.0.0.dist-info/entry_points.txt +2 -0
- commandertui-1.0.0.dist-info/licenses/LICENSE +21 -0
- commandertui-1.0.0.dist-info/top_level.txt +1 -0
commandertui/__init__.py
ADDED
|
@@ -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()}
|
commandertui/executor.py
ADDED
|
@@ -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}")
|