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,134 @@
1
+ from __future__ import annotations
2
+
3
+ import os
4
+ from datetime import datetime
5
+
6
+ from textual.app import ComposeResult
7
+ from textual.containers import Vertical
8
+ from textual.reactive import reactive
9
+ from textual.widgets import DataTable, Static
10
+
11
+ PARENT_MARK = ".."
12
+
13
+
14
+ def _human_size(n: int) -> str:
15
+ size = float(n)
16
+ for unit in ("B", "K", "M", "G", "T"):
17
+ if size < 1024 or unit == "T":
18
+ return f"{size:.0f}{unit}" if unit == "B" else f"{size:.1f}{unit}"
19
+ size /= 1024
20
+ return f"{size:.1f}T"
21
+
22
+
23
+ class FilePanel(Vertical):
24
+ """One side of the dual-pane view: a single-directory listing you can
25
+ navigate, mark entries in, and read the current path from.
26
+ """
27
+
28
+ path: reactive[str] = reactive(os.path.expanduser("~"))
29
+ active: reactive[bool] = reactive(False)
30
+ show_hidden: reactive[bool] = reactive(False)
31
+
32
+ def __init__(self, start_path: str, side: str, **kwargs) -> None:
33
+ super().__init__(**kwargs)
34
+ self.side = side # "left" | "right"
35
+ self.marked: set[str] = set()
36
+ self.path = os.path.abspath(start_path)
37
+ self._rows: list[tuple[str, bool]] = [] # (name, is_dir), in display order
38
+
39
+ def compose(self) -> ComposeResult:
40
+ yield Static(self.path, id=f"{self.side}-pathbar", classes="pathbar")
41
+ table = DataTable(id=f"{self.side}-table", cursor_type="row")
42
+ table.add_columns(" ", "Name", "Size", "Modified")
43
+ yield table
44
+
45
+ def on_mount(self) -> None:
46
+ self.refresh_listing()
47
+
48
+ def watch_active(self, active: bool) -> None:
49
+ bar = self.query_one(f"#{self.side}-pathbar", Static)
50
+ bar.set_class(active, "active")
51
+
52
+ def refresh_listing(self) -> None:
53
+ table = self.query_one(f"#{self.side}-table", DataTable)
54
+ table.clear()
55
+ self._rows = []
56
+
57
+ entries: list[os.DirEntry] = []
58
+ try:
59
+ with os.scandir(self.path) as it:
60
+ entries = sorted(
61
+ (e for e in it if self.show_hidden or not e.name.startswith(".")),
62
+ key=lambda e: (not e.is_dir(), e.name.lower()),
63
+ )
64
+ except (PermissionError, FileNotFoundError):
65
+ pass
66
+
67
+ if os.path.dirname(self.path) != self.path:
68
+ table.add_row("", PARENT_MARK, "", "", key=PARENT_MARK)
69
+ self._rows.append((PARENT_MARK, True))
70
+
71
+ for entry in entries:
72
+ try:
73
+ stat = entry.stat(follow_symlinks=False)
74
+ except (PermissionError, FileNotFoundError):
75
+ continue
76
+ is_dir = entry.is_dir(follow_symlinks=False)
77
+ mark = "*" if entry.path in self.marked else ""
78
+ size = "<DIR>" if is_dir else _human_size(stat.st_size)
79
+ mtime = datetime.fromtimestamp(stat.st_mtime).strftime("%Y-%m-%d %H:%M")
80
+ display_name = f"{entry.name}/" if is_dir else entry.name
81
+ table.add_row(mark, display_name, size, mtime, key=entry.name)
82
+ self._rows.append((entry.name, is_dir))
83
+
84
+ bar = self.query_one(f"#{self.side}-pathbar", Static)
85
+ bar.update(self.path)
86
+
87
+ def selected_name(self) -> str | None:
88
+ table = self.query_one(f"#{self.side}-table", DataTable)
89
+ if table.row_count == 0:
90
+ return None
91
+ row_key = table.coordinate_to_cell_key(table.cursor_coordinate).row_key
92
+ return row_key.value if row_key is not None else None
93
+
94
+ def enter_selected(self) -> None:
95
+ name = self.selected_name()
96
+ if name is None:
97
+ return
98
+ if name == PARENT_MARK:
99
+ self.path = os.path.dirname(self.path)
100
+ else:
101
+ candidate = os.path.join(self.path, name)
102
+ if os.path.isdir(candidate):
103
+ self.path = candidate
104
+ self.marked.clear()
105
+ self.refresh_listing()
106
+
107
+ def toggle_mark_selected(self) -> None:
108
+ name = self.selected_name()
109
+ if name is None or name == PARENT_MARK:
110
+ return
111
+ full = os.path.join(self.path, name)
112
+ if full in self.marked:
113
+ self.marked.discard(full)
114
+ mark = ""
115
+ else:
116
+ self.marked.add(full)
117
+ mark = "*"
118
+
119
+ # Update just the mark cell in place rather than calling
120
+ # refresh_listing(): rebuilding the whole table resets the cursor to
121
+ # row 0, so marking an item used to bounce you straight back to the
122
+ # top -- annoying when marking several items in a row.
123
+ table = self.query_one(f"#{self.side}-table", DataTable)
124
+ table.update_cell_at(table.cursor_coordinate._replace(column=0), mark)
125
+
126
+ def marked_or_selected(self) -> list[str]:
127
+ """What an action should operate on: the marked set if non-empty,
128
+ otherwise just whatever is under the cursor."""
129
+ if self.marked:
130
+ return sorted(self.marked)
131
+ name = self.selected_name()
132
+ if name and name != PARENT_MARK:
133
+ return [os.path.join(self.path, name)]
134
+ return []
@@ -0,0 +1,191 @@
1
+ Metadata-Version: 2.4
2
+ Name: commandertui
3
+ Version: 1.0.0
4
+ Summary: Dual-pane retro terminal file manager: compare, sync, copy and move folders
5
+ Author: Humberto Barchini
6
+ License-Expression: MIT
7
+ Project-URL: Homepage, https://github.com/juniorbarchini-oss/commandertui
8
+ Classifier: Environment :: Console
9
+ Classifier: Operating System :: POSIX :: Linux
10
+ Classifier: Programming Language :: Python :: 3
11
+ Classifier: Topic :: System :: Filesystems
12
+ Requires-Python: >=3.11
13
+ Description-Content-Type: text/markdown
14
+ License-File: LICENSE
15
+ Requires-Dist: textual>=8.0
16
+ Requires-Dist: send2trash>=1.8
17
+ Dynamic: license-file
18
+
19
+ # CommanderTUI
20
+
21
+ Dual-pane terminal file manager: compare two folders, sync them, or move
22
+ files/folders between them (and to USB/SMB/any mounted path) — with a
23
+ phosphor-green 80s-terminal look and a short modem-style boot animation.
24
+
25
+ Status: **v1.0.0** — 40/40 tests passing. MIT licensed.
26
+
27
+ ---
28
+
29
+ ## Why this exists
30
+
31
+ A safe, simple alternative to heavier dual-pane file managers. It was
32
+ written after an earlier attempt of the same idea had to be scrapped for
33
+ running an unauthenticated local HTTP server. This project deliberately:
34
+
35
+ - **Never opens a network port.** Pure terminal UI (Textual), no HTTP server,
36
+ no webview.
37
+ - **Never does a raw `rm -rf`.** Deletes always go through the desktop trash
38
+ (`send2trash` → `gio trash` → manual move into `~/.local/share/Trash`, in
39
+ that order).
40
+ - **Never overwrites in place.** Copies stream into a hidden `.tmp` file next
41
+ to the destination and are committed with `os.replace` — a crash or
42
+ Ctrl+C never leaves a half-written file.
43
+ - **Never guesses on a conflict.** Two-way sync copies each side's exclusive
44
+ files automatically, but a file changed on *both* sides is always shown to
45
+ you, never auto-resolved.
46
+
47
+ ## Install
48
+
49
+ **Arch Linux / Omarchy.** An AUR package is on the way (`yay -S commandertui`).
50
+ Until then, build it from the `PKGBUILD` in this repo. `makepkg` pulls in
51
+ everything it needs:
52
+
53
+ ```bash
54
+ git clone https://github.com/juniorbarchini-oss/commandertui.git
55
+ cd commandertui/packaging/arch
56
+ makepkg -si
57
+ ```
58
+
59
+ Remove it with `sudo pacman -R commandertui`.
60
+
61
+ **Other distros.** It is a regular Python package (Python 3.11+):
62
+
63
+ ```bash
64
+ pipx install git+https://github.com/juniorbarchini-oss/commandertui.git
65
+ ```
66
+
67
+ **From a source checkout** (development):
68
+
69
+ ```bash
70
+ python3 -m venv .venv && .venv/bin/pip install -e . -r requirements-dev.txt
71
+ bin/commandertui [left-dir] [right-dir]
72
+ ```
73
+
74
+ ## Run
75
+
76
+ | Command | What it does |
77
+ |---|---|
78
+ | `cmdrtui` | Opens it in its own terminal window. This is also what the **CommanderTUI** app-launcher entry runs |
79
+ | `commandertui` | Runs it inside the terminal you are in |
80
+ | `commandertui DIR1 DIR2` | Opens with those two folders side by side |
81
+
82
+ `cmdrtui` uses your desktop's default terminal (`xdg-terminal-exec`). If that
83
+ isn't available it tries foot, alacritty, kitty and ghostty. The window
84
+ always gets the app-id/class `commandertui`, so a window-manager rule can
85
+ float it. It needs about 1174x637 px to fit every key hint. On Hyprland:
86
+
87
+ ```ini
88
+ # hyprland.conf
89
+ windowrulev2 = float, class:^(commandertui)$
90
+ windowrulev2 = size 1174 637, class:^(commandertui)$
91
+ windowrulev2 = center, class:^(commandertui)$
92
+ ```
93
+
94
+ ```lua
95
+ -- Omarchy Lua config (~/.config/hypr/hyprland.lua)
96
+ o.window("^commandertui$", { float = true, center = true, size = { 1174, 637 } })
97
+ ```
98
+
99
+ ## Keys
100
+
101
+ | Key | Action |
102
+ |---|---|
103
+ | `Tab` | Switch active pane |
104
+ | `↑`/`↓`, `Enter` | Navigate, open directory |
105
+ | `Backspace` | Go to parent directory |
106
+ | `Space` | Mark/unmark item under cursor |
107
+ | `F5` | Copy marked (or item under cursor) to the other pane |
108
+ | `F6` | Move marked to the other pane |
109
+ | `F7` | Create a folder in the active pane |
110
+ | `F8` / `Delete` | Delete marked (to trash) |
111
+ | `i` | Size: file/folder count and total bytes of marked items (background, `Esc` stops it) |
112
+ | `h` | Show/hide hidden (dot) files in both panes |
113
+ | `p` | Places: jump to a mounted drive (USB/SMB/rclone) or saved bookmark |
114
+ | `c` | Compare: full recursive diff between the two panes' current folders |
115
+ | `r` | Refresh both panes |
116
+ | `q` | Quit |
117
+
118
+ While a copy/move runs, the progress window shows two bars (current file
119
+ and whole job, by bytes) plus speed in Mb/s and time left. `Esc` asks to
120
+ cancel: finished files stay, the file in progress is discarded (never left
121
+ half-written), and a move never removes a source it didn't fully copy.
122
+
123
+ **Places** lists only what is mounted right now: USB drives
124
+ (`/run/media`, `/media`, `/mnt` — checked with `ismount`, so an empty
125
+ on-demand mount point doesn't show), GVFS/Nautilus network shares, FUSE
126
+ mounts inside `$HOME` (e.g. `rclone mount`), and saved bookmarks.
127
+
128
+ Inside **Compare**:
129
+
130
+ | Key | Action |
131
+ |---|---|
132
+ | `m` | Mirror: make right identical to left |
133
+ | `u` | Update: copy new/changed left → right, never deletes |
134
+ | `b` | Two-way: propagate each side's exclusive changes; conflicts (changed on both sides) are listed, never guessed |
135
+ | `Space` | Mark a row for manual handling |
136
+ | `c` | Copy marked rows in whichever direction they diverge |
137
+ | `Esc` | Back to the panes |
138
+
139
+ Every bulk or destructive action shows a plain-language summary (count of
140
+ operations, total bytes) and requires `y` to confirm.
141
+
142
+ ## Architecture
143
+
144
+ ```
145
+ commandertui/
146
+ ├── models.py # FileEntry, DiffEntry, OperationQueue, QueuedOp
147
+ ├── scanner.py # os.scandir recursive tree walk
148
+ ├── diff.py # pure comparison: left-only / right-only / modified / identical
149
+ ├── plan.py # diff -> OperationQueue (mirror / update / two-way)
150
+ ├── sync.py # atomic copy/move (streamed, tmp+replace), checksum verify
151
+ ├── trash.py # 3-layer safe delete (send2trash / gio trash / manual)
152
+ ├── executor.py # runs an OperationQueue against the filesystem
153
+ ├── bookmarks.py # detected mounts (USB/SMB/rclone) + saved places
154
+ ├── theme.py # green/amber phosphor palettes
155
+ ├── boot.py # startup "modem" reveal animation
156
+ ├── widgets.py # FilePanel: one pane's directory listing
157
+ ├── modals.py # Confirm / Places / Input / Size / Message screens
158
+ ├── progress_screen.py # two-bar transfer progress + cancel
159
+ ├── run_queue.py # confirm -> run queue in a worker thread
160
+ ├── compare_screen.py # full diff view + bulk/manual sync actions
161
+ ├── app.py # CommanderApp: wires panes + keybindings
162
+ └── cli.py # argument parsing, entrypoint
163
+ ```
164
+
165
+ ## Tests
166
+
167
+ ```bash
168
+ pip install -r requirements-dev.txt
169
+ pytest
170
+ black commandertui tests # formatter, line length set in pyproject.toml
171
+ ```
172
+
173
+ Engine (scanner/diff/plan/sync/executor) is fully unit tested against a real
174
+ filesystem (`tmp_path`). The UI has smoke tests via Textual's `Pilot`
175
+ (boot sequence dismisses, pane switching, mark→copy→confirm end to end).
176
+
177
+ ## Not in scope
178
+
179
+ No file preview, no deduplication engine, no desktop GUI wrapper — those are
180
+ different problems.
181
+
182
+ ## Credits
183
+
184
+ Designed and tested by Humberto Barchini. The code was written together
185
+ with [Claude](https://www.anthropic.com/claude) (Anthropic) through
186
+ [Claude Code](https://claude.com/claude-code): **Claude Sonnet 5** built the
187
+ first version, **Claude Opus 5.5** did most of v0.2 and the v1.0 packaging.
188
+
189
+ ## License
190
+
191
+ MIT — see [LICENSE](LICENSE).
@@ -0,0 +1,26 @@
1
+ commandertui/__init__.py,sha256=J-j-u0itpEFT6irdmWmixQqYMadNl1X91TxUmoiLHMI,22
2
+ commandertui/app.py,sha256=Y992S7qSt1q5weamKyAAh1nKeg3RDhIRMfdUt5TsDts,1284
3
+ commandertui/ascii_font.py,sha256=4wRQ0xQ-AXFPvyayciGQvFQot-KX5K0vCuVPmY4Q63w,1098
4
+ commandertui/bookmarks.py,sha256=dQ8xqbam3b27fu-BA0ZNaZWKY0tC5DWcltS5KAZTbWc,4719
5
+ commandertui/boot.py,sha256=8N8GQhVk5ul2luDtAPj-IYDIzA5mk7_unGVurvmA5pY,3567
6
+ commandertui/cli.py,sha256=gtD8Igjyp24UfLG14rjDyVUtTMvbYjjOQDjj-PNkm-E,641
7
+ commandertui/compare_screen.py,sha256=4wyPcrQS2xhLu0miEcr4nlU26T2xWXFBGqnQZGK4ylc,5808
8
+ commandertui/diff.py,sha256=79hnNAs1QDW7qjsWzwBtOm5faRuMbnIa4QHZsJc6FyQ,1952
9
+ commandertui/executor.py,sha256=KF3LVa0duk5cLqG17AMtBv0gS3k3Cb8MuIr35FIqVlY,4494
10
+ commandertui/main_screen.py,sha256=OLVBPSBPIcecOWVKqGJvZ6ov_JNWeNwk8-zboecatBg,8207
11
+ commandertui/modals.py,sha256=Xs-vOh4-CUKa-9ScMrz90wCOqGzrygAJ6zKUG-_-IHY,7266
12
+ commandertui/models.py,sha256=Tu5Xdf9t1NMNgoNxKSsk1onYlty2z96J48BKaXUoNH0,1536
13
+ commandertui/plan.py,sha256=sTXxArQblKQ7paVtqjfD0PDAHha-tMtO53fWKiMnmRs,1970
14
+ commandertui/progress_screen.py,sha256=vzo6b6Y9TFVzsporCZMEIzo-IMkOudylXGtTdHqEshU,5005
15
+ commandertui/run_queue.py,sha256=YybH5it1FwiPPMRxUtdQu00c6vEyg19uDC3SkBY3b20,3052
16
+ commandertui/scanner.py,sha256=ZLhN5KKbm64UNyqGGQ_Ab5W0BucKCFaJonxyz0mXwO4,3405
17
+ commandertui/sync.py,sha256=2BcFc4-Qta1Vr9yrxke3wSwWX8gCZmBoG0hdIq_-3Ys,5216
18
+ commandertui/theme.py,sha256=0UZYrmdhb31dHFw666Drg9dkEY2n4jwzMpLl4N3RifY,1983
19
+ commandertui/trash.py,sha256=-tg01AqS3RBA1xBRkqg8MX6bvJlunB3sHe282ROv3zk,1458
20
+ commandertui/widgets.py,sha256=KJdOSTBN8rdRLJh3mar44FZ4JsGWNFYTS0Uv8DpfXxM,4989
21
+ commandertui-1.0.0.dist-info/licenses/LICENSE,sha256=_j5_t7RHiqrm0E1_eF1bAdwTIX2Q09ojfR-c2_lBfVc,1074
22
+ commandertui-1.0.0.dist-info/METADATA,sha256=ybrb244qCLtTsvdJnKmUk2hUZfkV7gv6Pw5rD5k3eWs,7489
23
+ commandertui-1.0.0.dist-info/WHEEL,sha256=YVMoNqKzERt-wjUZwJ33xBGAwnFl-4cqbYkTtWa4itE,91
24
+ commandertui-1.0.0.dist-info/entry_points.txt,sha256=tILtL7qtJSstkglZFqwO6MKE8oszeuZDXU2RnEp5geU,55
25
+ commandertui-1.0.0.dist-info/top_level.txt,sha256=2Vurq5RqhhH2b8mf9XvAdL1fsoh9ft4l6ZKn6laDB8g,13
26
+ commandertui-1.0.0.dist-info/RECORD,,
@@ -0,0 +1,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (84.0.0)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ commandertui = commandertui.cli:main
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Humberto Barchini
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1 @@
1
+ commandertui