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
|
@@ -0,0 +1,211 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
|
|
5
|
+
from textual.app import ComposeResult
|
|
6
|
+
from textual.binding import Binding
|
|
7
|
+
from textual.containers import Horizontal
|
|
8
|
+
from textual.screen import Screen
|
|
9
|
+
from textual.widgets import DataTable, Footer, Header
|
|
10
|
+
|
|
11
|
+
from .compare_screen import CompareScreen
|
|
12
|
+
from .modals import InputScreen, MessageScreen, PlacesScreen, SizeScreen
|
|
13
|
+
from .models import OpKind, OpSide, OperationQueue, QueuedOp
|
|
14
|
+
from .run_queue import run_queue_with_progress
|
|
15
|
+
from .scanner import path_size
|
|
16
|
+
from .widgets import FilePanel
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class MainScreen(Screen):
|
|
20
|
+
"""The dual-pane view and its keybindings.
|
|
21
|
+
|
|
22
|
+
Kept as its own Screen (rather than living directly on the App) so its
|
|
23
|
+
priority bindings only take effect while it is the active screen --
|
|
24
|
+
Textual checks an App's own priority bindings even when a different
|
|
25
|
+
screen (BootScreen, CompareScreen, a modal) is on top of the stack, so
|
|
26
|
+
anything pane-specific has to live here, not on CommanderApp.
|
|
27
|
+
"""
|
|
28
|
+
|
|
29
|
+
# priority=True: the focused DataTable has built-in bindings for several
|
|
30
|
+
# of these same keys (notably Enter, which it uses internally to fire
|
|
31
|
+
# RowSelected) that would otherwise intercept the key before our action
|
|
32
|
+
# ever runs.
|
|
33
|
+
BINDINGS = [
|
|
34
|
+
Binding("tab", "switch_pane", "Switch pane", priority=True),
|
|
35
|
+
Binding("enter", "enter_dir", "Open", priority=True),
|
|
36
|
+
Binding("backspace", "up_dir", "Up", priority=True),
|
|
37
|
+
Binding("space", "toggle_mark", "Mark", priority=True),
|
|
38
|
+
Binding("f5", "copy", "Copy", priority=True),
|
|
39
|
+
Binding("f6", "move", "Move", priority=True),
|
|
40
|
+
Binding("f7", "make_dir", "MkDir", priority=True),
|
|
41
|
+
Binding("f8", "delete", "Delete", priority=True),
|
|
42
|
+
Binding("delete", "delete", "Delete", priority=True),
|
|
43
|
+
Binding("c", "show_compare", "Compare", priority=True),
|
|
44
|
+
Binding("p", "show_places", "Places", priority=True),
|
|
45
|
+
Binding("r", "refresh_both", "Refresh", priority=True),
|
|
46
|
+
Binding("h", "toggle_hidden", "Hidden", priority=True),
|
|
47
|
+
Binding("i", "show_size", "Size", priority=True),
|
|
48
|
+
# "app.quit", not "quit": this binding lives on the Screen, and a bare
|
|
49
|
+
# "quit" resolves against the Screen's own (nonexistent) action_quit
|
|
50
|
+
# rather than bubbling to the App, so it silently did nothing.
|
|
51
|
+
Binding("q", "app.quit", "Quit", priority=True),
|
|
52
|
+
]
|
|
53
|
+
|
|
54
|
+
def __init__(self, left_path: str, right_path: str) -> None:
|
|
55
|
+
super().__init__()
|
|
56
|
+
self.left_path = left_path
|
|
57
|
+
self.right_path = right_path
|
|
58
|
+
self.active_side = "left"
|
|
59
|
+
|
|
60
|
+
def compose(self) -> ComposeResult:
|
|
61
|
+
yield Header(show_clock=True)
|
|
62
|
+
with Horizontal():
|
|
63
|
+
yield FilePanel(self.left_path, side="left", id="panel-left")
|
|
64
|
+
yield FilePanel(self.right_path, side="right", id="panel-right")
|
|
65
|
+
yield Footer()
|
|
66
|
+
|
|
67
|
+
def on_mount(self) -> None:
|
|
68
|
+
self._set_active("left")
|
|
69
|
+
|
|
70
|
+
# ------------------------------------------------------------ helpers
|
|
71
|
+
def _panel(self, side: str) -> FilePanel:
|
|
72
|
+
return self.query_one(f"#panel-{side}", FilePanel)
|
|
73
|
+
|
|
74
|
+
def _active_panel(self) -> FilePanel:
|
|
75
|
+
return self._panel(self.active_side)
|
|
76
|
+
|
|
77
|
+
def _other_panel(self) -> FilePanel:
|
|
78
|
+
return self._panel("right" if self.active_side == "left" else "left")
|
|
79
|
+
|
|
80
|
+
def _set_active(self, side: str) -> None:
|
|
81
|
+
self.active_side = side
|
|
82
|
+
self._panel("left").active = side == "left"
|
|
83
|
+
self._panel("right").active = side == "right"
|
|
84
|
+
# Marking a panel "active" only recolors its path bar; arrow-key
|
|
85
|
+
# navigation is driven by Textual's real focus, on the DataTable
|
|
86
|
+
# itself, so it has to move too or the cursor keeps responding to
|
|
87
|
+
# whichever pane was focused first regardless of which one looks active.
|
|
88
|
+
self._panel(side).query_one(DataTable).focus()
|
|
89
|
+
|
|
90
|
+
# -------------------------------------------------------------- nav
|
|
91
|
+
def action_switch_pane(self) -> None:
|
|
92
|
+
self._set_active("right" if self.active_side == "left" else "left")
|
|
93
|
+
|
|
94
|
+
def action_enter_dir(self) -> None:
|
|
95
|
+
self._active_panel().enter_selected()
|
|
96
|
+
|
|
97
|
+
def action_up_dir(self) -> None:
|
|
98
|
+
panel = self._active_panel()
|
|
99
|
+
panel.path = os.path.dirname(panel.path)
|
|
100
|
+
panel.marked.clear()
|
|
101
|
+
panel.refresh_listing()
|
|
102
|
+
|
|
103
|
+
def action_toggle_mark(self) -> None:
|
|
104
|
+
self._active_panel().toggle_mark_selected()
|
|
105
|
+
|
|
106
|
+
def action_refresh_both(self) -> None:
|
|
107
|
+
self._panel("left").refresh_listing()
|
|
108
|
+
self._panel("right").refresh_listing()
|
|
109
|
+
|
|
110
|
+
def action_toggle_hidden(self) -> None:
|
|
111
|
+
show = not self._panel("left").show_hidden
|
|
112
|
+
for side in ("left", "right"):
|
|
113
|
+
panel = self._panel(side)
|
|
114
|
+
panel.show_hidden = show
|
|
115
|
+
panel.refresh_listing()
|
|
116
|
+
|
|
117
|
+
def action_show_size(self) -> None:
|
|
118
|
+
items = self._active_panel().marked_or_selected()
|
|
119
|
+
if items:
|
|
120
|
+
self.app.push_screen(SizeScreen(items))
|
|
121
|
+
|
|
122
|
+
# ------------------------------------------------------------- mkdir
|
|
123
|
+
def action_make_dir(self) -> None:
|
|
124
|
+
panel = self._active_panel()
|
|
125
|
+
|
|
126
|
+
def on_named(name: str | None) -> None:
|
|
127
|
+
if not name:
|
|
128
|
+
return
|
|
129
|
+
target = os.path.join(panel.path, name)
|
|
130
|
+
try:
|
|
131
|
+
os.makedirs(target)
|
|
132
|
+
except FileExistsError:
|
|
133
|
+
self.app.push_screen(MessageScreen(f"'{name}' already exists."))
|
|
134
|
+
return
|
|
135
|
+
except OSError as exc:
|
|
136
|
+
self.app.push_screen(MessageScreen(f"Could not create '{name}': {exc}"))
|
|
137
|
+
return
|
|
138
|
+
panel.refresh_listing()
|
|
139
|
+
|
|
140
|
+
self.app.push_screen(InputScreen("New folder name:"), on_named)
|
|
141
|
+
|
|
142
|
+
# --------------------------------------------------------- op queues
|
|
143
|
+
def action_copy(self) -> None:
|
|
144
|
+
self._run_marked_op(OpKind.COPY, "Copy")
|
|
145
|
+
|
|
146
|
+
def action_move(self) -> None:
|
|
147
|
+
self._run_marked_op(OpKind.MOVE, "Move")
|
|
148
|
+
|
|
149
|
+
def action_delete(self) -> None:
|
|
150
|
+
src_panel = self._active_panel()
|
|
151
|
+
items = src_panel.marked_or_selected()
|
|
152
|
+
if not items:
|
|
153
|
+
return
|
|
154
|
+
|
|
155
|
+
side = OpSide.LEFT if src_panel.side == "left" else OpSide.RIGHT
|
|
156
|
+
queue = OperationQueue()
|
|
157
|
+
for item in items:
|
|
158
|
+
rel_path = os.path.relpath(item, src_panel.path)
|
|
159
|
+
queue.add(QueuedOp(OpKind.DELETE, side, rel_path, os.path.isdir(item), size=path_size(item)))
|
|
160
|
+
|
|
161
|
+
def after(report) -> None:
|
|
162
|
+
src_panel.marked.clear()
|
|
163
|
+
src_panel.refresh_listing()
|
|
164
|
+
|
|
165
|
+
run_queue_with_progress(
|
|
166
|
+
self, "Delete (to trash)", queue, self._panel("left").path, self._panel("right").path, after
|
|
167
|
+
)
|
|
168
|
+
|
|
169
|
+
def _run_marked_op(self, kind: OpKind, label: str) -> None:
|
|
170
|
+
src_panel = self._active_panel()
|
|
171
|
+
dst_panel = self._other_panel()
|
|
172
|
+
items = src_panel.marked_or_selected()
|
|
173
|
+
if not items:
|
|
174
|
+
return
|
|
175
|
+
|
|
176
|
+
side = OpSide.LEFT_TO_RIGHT if src_panel.side == "left" else OpSide.RIGHT_TO_LEFT
|
|
177
|
+
queue = OperationQueue()
|
|
178
|
+
for item in items:
|
|
179
|
+
rel_path = os.path.relpath(item, src_panel.path)
|
|
180
|
+
queue.add(QueuedOp(kind, side, rel_path, os.path.isdir(item), size=path_size(item)))
|
|
181
|
+
|
|
182
|
+
def after(report) -> None:
|
|
183
|
+
src_panel.marked.clear()
|
|
184
|
+
src_panel.refresh_listing()
|
|
185
|
+
dst_panel.refresh_listing()
|
|
186
|
+
|
|
187
|
+
run_queue_with_progress(
|
|
188
|
+
self,
|
|
189
|
+
f"{label} to {dst_panel.path}",
|
|
190
|
+
queue,
|
|
191
|
+
self._panel("left").path,
|
|
192
|
+
self._panel("right").path,
|
|
193
|
+
after,
|
|
194
|
+
)
|
|
195
|
+
|
|
196
|
+
# ------------------------------------------------------------- misc
|
|
197
|
+
def action_show_compare(self) -> None:
|
|
198
|
+
left = self._panel("left").path
|
|
199
|
+
right = self._panel("right").path
|
|
200
|
+
self.app.push_screen(CompareScreen(left, right))
|
|
201
|
+
|
|
202
|
+
def action_show_places(self) -> None:
|
|
203
|
+
def on_picked(path: str | None) -> None:
|
|
204
|
+
if not path:
|
|
205
|
+
return
|
|
206
|
+
panel = self._active_panel()
|
|
207
|
+
panel.path = path
|
|
208
|
+
panel.marked.clear()
|
|
209
|
+
panel.refresh_listing()
|
|
210
|
+
|
|
211
|
+
self.app.push_screen(PlacesScreen(), on_picked)
|
commandertui/modals.py
ADDED
|
@@ -0,0 +1,250 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
import time
|
|
5
|
+
|
|
6
|
+
from textual.app import ComposeResult
|
|
7
|
+
from textual.binding import Binding
|
|
8
|
+
from textual.containers import Vertical
|
|
9
|
+
from textual.screen import ModalScreen
|
|
10
|
+
from textual.widgets import DataTable, Footer, Input, Static
|
|
11
|
+
|
|
12
|
+
from .bookmarks import Place, all_places
|
|
13
|
+
from .models import OperationQueue
|
|
14
|
+
from .scanner import tree_stats
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def _human_bytes(n: int) -> str:
|
|
18
|
+
size = float(n)
|
|
19
|
+
for unit in ("B", "KB", "MB", "GB", "TB"):
|
|
20
|
+
if size < 1024 or unit == "TB":
|
|
21
|
+
return f"{size:.1f} {unit}"
|
|
22
|
+
size /= 1024
|
|
23
|
+
return f"{size:.1f} TB"
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class ConfirmScreen(ModalScreen[bool]):
|
|
27
|
+
"""A destructive action never runs without this: a plain-language summary
|
|
28
|
+
of exactly what is about to happen, requiring an explicit yes."""
|
|
29
|
+
|
|
30
|
+
DEFAULT_CSS = """
|
|
31
|
+
ConfirmScreen {
|
|
32
|
+
align: center middle;
|
|
33
|
+
}
|
|
34
|
+
#confirm-box {
|
|
35
|
+
width: 60;
|
|
36
|
+
height: auto;
|
|
37
|
+
border: solid $foreground;
|
|
38
|
+
padding: 1 2;
|
|
39
|
+
}
|
|
40
|
+
"""
|
|
41
|
+
|
|
42
|
+
BINDINGS = [
|
|
43
|
+
Binding("y", "confirm", "Confirm", priority=True),
|
|
44
|
+
Binding("n", "cancel", "Cancel", priority=True),
|
|
45
|
+
Binding("escape", "cancel", "Cancel", priority=True),
|
|
46
|
+
]
|
|
47
|
+
|
|
48
|
+
def __init__(self, title: str, queue: OperationQueue) -> None:
|
|
49
|
+
super().__init__()
|
|
50
|
+
self.title_text = title
|
|
51
|
+
self.queue = queue
|
|
52
|
+
|
|
53
|
+
def compose(self) -> ComposeResult:
|
|
54
|
+
summary = self.queue.summary()
|
|
55
|
+
by_kind = ", ".join(f"{n} {k}" for k, n in summary["by_kind"].items())
|
|
56
|
+
text = (
|
|
57
|
+
f"{self.title_text}\n\n"
|
|
58
|
+
f" {summary['count']} operations ({by_kind or 'nothing to do'})\n"
|
|
59
|
+
f" {_human_bytes(summary['total_bytes'])} total"
|
|
60
|
+
)
|
|
61
|
+
with Vertical(id="confirm-box"):
|
|
62
|
+
yield Static(text)
|
|
63
|
+
# A real Footer, not a hand-written text line: it renders the actual
|
|
64
|
+
# keybindings the same way the rest of the app does, so "how do I
|
|
65
|
+
# confirm this" is never a guess.
|
|
66
|
+
yield Footer()
|
|
67
|
+
|
|
68
|
+
def action_confirm(self) -> None:
|
|
69
|
+
self.dismiss(True)
|
|
70
|
+
|
|
71
|
+
def action_cancel(self) -> None:
|
|
72
|
+
self.dismiss(False)
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
class PlacesScreen(ModalScreen[str | None]):
|
|
76
|
+
"""Quick-jump to a mounted drive (USB/SMB/local) or a saved bookmark,
|
|
77
|
+
instead of typing the path."""
|
|
78
|
+
|
|
79
|
+
DEFAULT_CSS = """
|
|
80
|
+
PlacesScreen {
|
|
81
|
+
align: center middle;
|
|
82
|
+
}
|
|
83
|
+
#places-box {
|
|
84
|
+
width: 70;
|
|
85
|
+
height: auto;
|
|
86
|
+
max-height: 20;
|
|
87
|
+
border: solid $foreground;
|
|
88
|
+
}
|
|
89
|
+
"""
|
|
90
|
+
|
|
91
|
+
BINDINGS = [Binding("escape", "cancel", "Cancel", priority=True)]
|
|
92
|
+
|
|
93
|
+
def compose(self) -> ComposeResult:
|
|
94
|
+
table = DataTable(id="places-table", cursor_type="row")
|
|
95
|
+
table.add_columns("Kind", "Label", "Path")
|
|
96
|
+
with Vertical(id="places-box"):
|
|
97
|
+
yield table
|
|
98
|
+
yield Footer()
|
|
99
|
+
|
|
100
|
+
def on_mount(self) -> None:
|
|
101
|
+
table = self.query_one("#places-table", DataTable)
|
|
102
|
+
self.places: list[Place] = all_places()
|
|
103
|
+
for place in self.places:
|
|
104
|
+
table.add_row(place.kind, place.label, place.path, key=place.path)
|
|
105
|
+
|
|
106
|
+
def on_data_table_row_selected(self, event: DataTable.RowSelected) -> None:
|
|
107
|
+
self.dismiss(str(event.row_key.value))
|
|
108
|
+
|
|
109
|
+
def action_cancel(self) -> None:
|
|
110
|
+
self.dismiss(None)
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
class InputScreen(ModalScreen[str | None]):
|
|
114
|
+
"""A single text prompt (e.g. new folder name). Enter confirms with the
|
|
115
|
+
current text, Escape cancels with None -- same shape as ConfirmScreen so
|
|
116
|
+
callers can use the same push_screen(..., callback) pattern."""
|
|
117
|
+
|
|
118
|
+
DEFAULT_CSS = """
|
|
119
|
+
InputScreen {
|
|
120
|
+
align: center middle;
|
|
121
|
+
}
|
|
122
|
+
#input-box {
|
|
123
|
+
width: 60;
|
|
124
|
+
height: auto;
|
|
125
|
+
border: solid $foreground;
|
|
126
|
+
padding: 1 2;
|
|
127
|
+
}
|
|
128
|
+
#input-box Static {
|
|
129
|
+
margin-bottom: 1;
|
|
130
|
+
}
|
|
131
|
+
"""
|
|
132
|
+
|
|
133
|
+
BINDINGS = [Binding("escape", "cancel", "Cancel", priority=True)]
|
|
134
|
+
|
|
135
|
+
def __init__(self, prompt: str) -> None:
|
|
136
|
+
super().__init__()
|
|
137
|
+
self.prompt = prompt
|
|
138
|
+
|
|
139
|
+
def compose(self) -> ComposeResult:
|
|
140
|
+
with Vertical(id="input-box"):
|
|
141
|
+
yield Static(self.prompt)
|
|
142
|
+
yield Input(id="input-field")
|
|
143
|
+
yield Footer()
|
|
144
|
+
|
|
145
|
+
def on_mount(self) -> None:
|
|
146
|
+
self.query_one(Input).focus()
|
|
147
|
+
|
|
148
|
+
def on_input_submitted(self, event: Input.Submitted) -> None:
|
|
149
|
+
self.dismiss(event.value.strip() or None)
|
|
150
|
+
|
|
151
|
+
def action_cancel(self) -> None:
|
|
152
|
+
self.dismiss(None)
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
class SizeScreen(ModalScreen[None]):
|
|
156
|
+
"""Counts files/folders/bytes of the given paths in a worker thread, so a
|
|
157
|
+
big tree (or a slow network share) never freezes the UI. Closing it
|
|
158
|
+
early (Escape) stops the count."""
|
|
159
|
+
|
|
160
|
+
DEFAULT_CSS = """
|
|
161
|
+
SizeScreen {
|
|
162
|
+
align: center middle;
|
|
163
|
+
}
|
|
164
|
+
#size-box {
|
|
165
|
+
width: 60;
|
|
166
|
+
height: auto;
|
|
167
|
+
border: solid $foreground;
|
|
168
|
+
padding: 1 2;
|
|
169
|
+
}
|
|
170
|
+
"""
|
|
171
|
+
|
|
172
|
+
BINDINGS = [
|
|
173
|
+
Binding("escape", "close", "Close", priority=True),
|
|
174
|
+
Binding("enter", "close", "Close", priority=True),
|
|
175
|
+
]
|
|
176
|
+
|
|
177
|
+
def __init__(self, paths: list[str]) -> None:
|
|
178
|
+
super().__init__()
|
|
179
|
+
self.paths = paths
|
|
180
|
+
self.cancelled = False
|
|
181
|
+
self.label = os.path.basename(paths[0]) if len(paths) == 1 else f"{len(paths)} items"
|
|
182
|
+
|
|
183
|
+
def compose(self) -> ComposeResult:
|
|
184
|
+
with Vertical(id="size-box"):
|
|
185
|
+
yield Static(f"{self.label}\n\n Calculating...", id="size-text")
|
|
186
|
+
yield Footer()
|
|
187
|
+
|
|
188
|
+
def on_mount(self) -> None:
|
|
189
|
+
self.run_worker(self._work, thread=True)
|
|
190
|
+
|
|
191
|
+
def _work(self) -> None:
|
|
192
|
+
last = 0.0
|
|
193
|
+
|
|
194
|
+
def on_progress(files: int, dirs: int, total: int) -> None:
|
|
195
|
+
nonlocal last
|
|
196
|
+
now = time.monotonic()
|
|
197
|
+
if now - last >= 0.2:
|
|
198
|
+
last = now
|
|
199
|
+
self.app.call_from_thread(self._show, files, dirs, total, False)
|
|
200
|
+
|
|
201
|
+
files, dirs, total = tree_stats(self.paths, lambda: self.cancelled, on_progress)
|
|
202
|
+
if not self.cancelled:
|
|
203
|
+
self.app.call_from_thread(self._show, files, dirs, total, True)
|
|
204
|
+
|
|
205
|
+
def _show(self, files: int, dirs: int, total: int, done: bool) -> None:
|
|
206
|
+
if self.cancelled or not self.is_mounted:
|
|
207
|
+
return
|
|
208
|
+
status = "" if done else " Calculating...\n"
|
|
209
|
+
self.query_one("#size-text", Static).update(
|
|
210
|
+
f"{self.label}\n\n{status}"
|
|
211
|
+
f" {files:,} files, {dirs:,} folders\n"
|
|
212
|
+
f" {_human_bytes(total)} ({total:,} bytes)"
|
|
213
|
+
)
|
|
214
|
+
|
|
215
|
+
def action_close(self) -> None:
|
|
216
|
+
self.cancelled = True
|
|
217
|
+
self.dismiss(None)
|
|
218
|
+
|
|
219
|
+
|
|
220
|
+
class MessageScreen(ModalScreen[None]):
|
|
221
|
+
"""A short result notice (e.g. failures after an operation ran)."""
|
|
222
|
+
|
|
223
|
+
DEFAULT_CSS = """
|
|
224
|
+
MessageScreen {
|
|
225
|
+
align: center middle;
|
|
226
|
+
}
|
|
227
|
+
#msg-box {
|
|
228
|
+
width: 60;
|
|
229
|
+
height: auto;
|
|
230
|
+
border: solid $error;
|
|
231
|
+
padding: 1 2;
|
|
232
|
+
}
|
|
233
|
+
"""
|
|
234
|
+
|
|
235
|
+
BINDINGS = [
|
|
236
|
+
Binding("escape", "close", "Close", priority=True),
|
|
237
|
+
Binding("enter", "close", "Close", priority=True),
|
|
238
|
+
]
|
|
239
|
+
|
|
240
|
+
def __init__(self, text: str) -> None:
|
|
241
|
+
super().__init__()
|
|
242
|
+
self.text = text
|
|
243
|
+
|
|
244
|
+
def compose(self) -> ComposeResult:
|
|
245
|
+
with Vertical(id="msg-box"):
|
|
246
|
+
yield Static(self.text)
|
|
247
|
+
yield Footer()
|
|
248
|
+
|
|
249
|
+
def action_close(self) -> None:
|
|
250
|
+
self.dismiss(None)
|
commandertui/models.py
ADDED
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass, field
|
|
4
|
+
from enum import Enum
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
class DiffStatus(str, Enum):
|
|
8
|
+
LEFT_ONLY = "left_only"
|
|
9
|
+
RIGHT_ONLY = "right_only"
|
|
10
|
+
MODIFIED = "modified"
|
|
11
|
+
IDENTICAL = "identical"
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class EntryKind(str, Enum):
|
|
15
|
+
FILE = "file"
|
|
16
|
+
DIR = "dir"
|
|
17
|
+
SYMLINK = "symlink"
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
@dataclass(frozen=True)
|
|
21
|
+
class FileEntry:
|
|
22
|
+
rel_path: str
|
|
23
|
+
name: str
|
|
24
|
+
kind: EntryKind
|
|
25
|
+
size: int
|
|
26
|
+
mtime: float
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
@dataclass(frozen=True)
|
|
30
|
+
class DiffEntry:
|
|
31
|
+
rel_path: str
|
|
32
|
+
name: str
|
|
33
|
+
status: DiffStatus
|
|
34
|
+
left: FileEntry | None
|
|
35
|
+
right: FileEntry | None
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
class OpKind(str, Enum):
|
|
39
|
+
COPY = "copy"
|
|
40
|
+
MOVE = "move"
|
|
41
|
+
DELETE = "delete"
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
class OpSide(str, Enum):
|
|
45
|
+
LEFT_TO_RIGHT = "left_to_right"
|
|
46
|
+
RIGHT_TO_LEFT = "right_to_left"
|
|
47
|
+
LEFT = "left"
|
|
48
|
+
RIGHT = "right"
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
@dataclass
|
|
52
|
+
class QueuedOp:
|
|
53
|
+
kind: OpKind
|
|
54
|
+
side: OpSide
|
|
55
|
+
rel_path: str
|
|
56
|
+
is_dir: bool
|
|
57
|
+
size: int = 0
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
@dataclass
|
|
61
|
+
class OperationQueue:
|
|
62
|
+
ops: list[QueuedOp] = field(default_factory=list)
|
|
63
|
+
|
|
64
|
+
def add(self, op: QueuedOp) -> None:
|
|
65
|
+
self.ops.append(op)
|
|
66
|
+
|
|
67
|
+
def clear(self) -> None:
|
|
68
|
+
self.ops.clear()
|
|
69
|
+
|
|
70
|
+
def summary(self) -> dict:
|
|
71
|
+
by_kind: dict[str, int] = {}
|
|
72
|
+
total_bytes = 0
|
|
73
|
+
for op in self.ops:
|
|
74
|
+
by_kind[op.kind.value] = by_kind.get(op.kind.value, 0) + 1
|
|
75
|
+
total_bytes += op.size
|
|
76
|
+
return {"count": len(self.ops), "by_kind": by_kind, "total_bytes": total_bytes}
|
|
77
|
+
|
|
78
|
+
def __len__(self) -> int:
|
|
79
|
+
return len(self.ops)
|
commandertui/plan.py
ADDED
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from .models import DiffEntry, DiffStatus, EntryKind, OpKind, OpSide, OperationQueue, QueuedOp
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
def plan_mirror(diffs: list[DiffEntry]) -> OperationQueue:
|
|
7
|
+
"""Make Right identical to Left: copy everything Left has, delete everything
|
|
8
|
+
Right has that Left doesn't."""
|
|
9
|
+
queue = OperationQueue()
|
|
10
|
+
for d in diffs:
|
|
11
|
+
if d.status in (DiffStatus.LEFT_ONLY, DiffStatus.MODIFIED):
|
|
12
|
+
queue.add(_copy_op(d, OpSide.LEFT_TO_RIGHT))
|
|
13
|
+
elif d.status == DiffStatus.RIGHT_ONLY:
|
|
14
|
+
queue.add(QueuedOp(OpKind.DELETE, OpSide.RIGHT, d.rel_path, _is_dir(d.right)))
|
|
15
|
+
return queue
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def plan_update(diffs: list[DiffEntry]) -> OperationQueue:
|
|
19
|
+
"""Copy only new/modified files from Left to Right. Never deletes."""
|
|
20
|
+
queue = OperationQueue()
|
|
21
|
+
for d in diffs:
|
|
22
|
+
if d.status in (DiffStatus.LEFT_ONLY, DiffStatus.MODIFIED):
|
|
23
|
+
queue.add(_copy_op(d, OpSide.LEFT_TO_RIGHT))
|
|
24
|
+
return queue
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def plan_two_way(diffs: list[DiffEntry]) -> tuple[OperationQueue, list[DiffEntry]]:
|
|
28
|
+
"""Propagate each side's exclusive files to the other. MODIFIED entries are
|
|
29
|
+
real conflicts (both sides changed) and are returned separately for the
|
|
30
|
+
user to resolve by hand rather than guessed at.
|
|
31
|
+
"""
|
|
32
|
+
queue = OperationQueue()
|
|
33
|
+
conflicts: list[DiffEntry] = []
|
|
34
|
+
for d in diffs:
|
|
35
|
+
if d.status == DiffStatus.LEFT_ONLY:
|
|
36
|
+
queue.add(_copy_op(d, OpSide.LEFT_TO_RIGHT))
|
|
37
|
+
elif d.status == DiffStatus.RIGHT_ONLY:
|
|
38
|
+
queue.add(_copy_op(d, OpSide.RIGHT_TO_LEFT))
|
|
39
|
+
elif d.status == DiffStatus.MODIFIED:
|
|
40
|
+
conflicts.append(d)
|
|
41
|
+
return queue, conflicts
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def _copy_op(d: DiffEntry, side: OpSide) -> QueuedOp:
|
|
45
|
+
entry = d.left if side == OpSide.LEFT_TO_RIGHT else d.right
|
|
46
|
+
return QueuedOp(OpKind.COPY, side, d.rel_path, _is_dir(entry), size=entry.size if entry else 0)
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def _is_dir(entry) -> bool:
|
|
50
|
+
return bool(entry and entry.kind == EntryKind.DIR)
|