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,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)