commandertui 1.0.0__tar.gz
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-1.0.0/LICENSE +21 -0
- commandertui-1.0.0/PKG-INFO +191 -0
- commandertui-1.0.0/README.md +173 -0
- commandertui-1.0.0/commandertui/__init__.py +1 -0
- commandertui-1.0.0/commandertui/app.py +40 -0
- commandertui-1.0.0/commandertui/ascii_font.py +27 -0
- commandertui-1.0.0/commandertui/bookmarks.py +133 -0
- commandertui-1.0.0/commandertui/boot.py +114 -0
- commandertui-1.0.0/commandertui/cli.py +22 -0
- commandertui-1.0.0/commandertui/compare_screen.py +146 -0
- commandertui-1.0.0/commandertui/diff.py +61 -0
- commandertui-1.0.0/commandertui/executor.py +134 -0
- commandertui-1.0.0/commandertui/main_screen.py +211 -0
- commandertui-1.0.0/commandertui/modals.py +250 -0
- commandertui-1.0.0/commandertui/models.py +79 -0
- commandertui-1.0.0/commandertui/plan.py +50 -0
- commandertui-1.0.0/commandertui/progress_screen.py +137 -0
- commandertui-1.0.0/commandertui/run_queue.py +83 -0
- commandertui-1.0.0/commandertui/scanner.py +115 -0
- commandertui-1.0.0/commandertui/sync.py +149 -0
- commandertui-1.0.0/commandertui/theme.py +94 -0
- commandertui-1.0.0/commandertui/trash.py +49 -0
- commandertui-1.0.0/commandertui/widgets.py +134 -0
- commandertui-1.0.0/commandertui.egg-info/PKG-INFO +191 -0
- commandertui-1.0.0/commandertui.egg-info/SOURCES.txt +37 -0
- commandertui-1.0.0/commandertui.egg-info/dependency_links.txt +1 -0
- commandertui-1.0.0/commandertui.egg-info/entry_points.txt +2 -0
- commandertui-1.0.0/commandertui.egg-info/requires.txt +2 -0
- commandertui-1.0.0/commandertui.egg-info/top_level.txt +1 -0
- commandertui-1.0.0/pyproject.toml +39 -0
- commandertui-1.0.0/setup.cfg +4 -0
- commandertui-1.0.0/tests/test_app_smoke.py +129 -0
- commandertui-1.0.0/tests/test_bookmarks.py +73 -0
- commandertui-1.0.0/tests/test_diff.py +60 -0
- commandertui-1.0.0/tests/test_executor.py +79 -0
- commandertui-1.0.0/tests/test_plan.py +50 -0
- commandertui-1.0.0/tests/test_scanner.py +78 -0
- commandertui-1.0.0/tests/test_sync.py +130 -0
- commandertui-1.0.0/tests/test_widgets.py +36 -0
|
@@ -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,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,173 @@
|
|
|
1
|
+
# CommanderTUI
|
|
2
|
+
|
|
3
|
+
Dual-pane terminal file manager: compare two folders, sync them, or move
|
|
4
|
+
files/folders between them (and to USB/SMB/any mounted path) — with a
|
|
5
|
+
phosphor-green 80s-terminal look and a short modem-style boot animation.
|
|
6
|
+
|
|
7
|
+
Status: **v1.0.0** — 40/40 tests passing. MIT licensed.
|
|
8
|
+
|
|
9
|
+
---
|
|
10
|
+
|
|
11
|
+
## Why this exists
|
|
12
|
+
|
|
13
|
+
A safe, simple alternative to heavier dual-pane file managers. It was
|
|
14
|
+
written after an earlier attempt of the same idea had to be scrapped for
|
|
15
|
+
running an unauthenticated local HTTP server. This project deliberately:
|
|
16
|
+
|
|
17
|
+
- **Never opens a network port.** Pure terminal UI (Textual), no HTTP server,
|
|
18
|
+
no webview.
|
|
19
|
+
- **Never does a raw `rm -rf`.** Deletes always go through the desktop trash
|
|
20
|
+
(`send2trash` → `gio trash` → manual move into `~/.local/share/Trash`, in
|
|
21
|
+
that order).
|
|
22
|
+
- **Never overwrites in place.** Copies stream into a hidden `.tmp` file next
|
|
23
|
+
to the destination and are committed with `os.replace` — a crash or
|
|
24
|
+
Ctrl+C never leaves a half-written file.
|
|
25
|
+
- **Never guesses on a conflict.** Two-way sync copies each side's exclusive
|
|
26
|
+
files automatically, but a file changed on *both* sides is always shown to
|
|
27
|
+
you, never auto-resolved.
|
|
28
|
+
|
|
29
|
+
## Install
|
|
30
|
+
|
|
31
|
+
**Arch Linux / Omarchy.** An AUR package is on the way (`yay -S commandertui`).
|
|
32
|
+
Until then, build it from the `PKGBUILD` in this repo. `makepkg` pulls in
|
|
33
|
+
everything it needs:
|
|
34
|
+
|
|
35
|
+
```bash
|
|
36
|
+
git clone https://github.com/juniorbarchini-oss/commandertui.git
|
|
37
|
+
cd commandertui/packaging/arch
|
|
38
|
+
makepkg -si
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
Remove it with `sudo pacman -R commandertui`.
|
|
42
|
+
|
|
43
|
+
**Other distros.** It is a regular Python package (Python 3.11+):
|
|
44
|
+
|
|
45
|
+
```bash
|
|
46
|
+
pipx install git+https://github.com/juniorbarchini-oss/commandertui.git
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
**From a source checkout** (development):
|
|
50
|
+
|
|
51
|
+
```bash
|
|
52
|
+
python3 -m venv .venv && .venv/bin/pip install -e . -r requirements-dev.txt
|
|
53
|
+
bin/commandertui [left-dir] [right-dir]
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
## Run
|
|
57
|
+
|
|
58
|
+
| Command | What it does |
|
|
59
|
+
|---|---|
|
|
60
|
+
| `cmdrtui` | Opens it in its own terminal window. This is also what the **CommanderTUI** app-launcher entry runs |
|
|
61
|
+
| `commandertui` | Runs it inside the terminal you are in |
|
|
62
|
+
| `commandertui DIR1 DIR2` | Opens with those two folders side by side |
|
|
63
|
+
|
|
64
|
+
`cmdrtui` uses your desktop's default terminal (`xdg-terminal-exec`). If that
|
|
65
|
+
isn't available it tries foot, alacritty, kitty and ghostty. The window
|
|
66
|
+
always gets the app-id/class `commandertui`, so a window-manager rule can
|
|
67
|
+
float it. It needs about 1174x637 px to fit every key hint. On Hyprland:
|
|
68
|
+
|
|
69
|
+
```ini
|
|
70
|
+
# hyprland.conf
|
|
71
|
+
windowrulev2 = float, class:^(commandertui)$
|
|
72
|
+
windowrulev2 = size 1174 637, class:^(commandertui)$
|
|
73
|
+
windowrulev2 = center, class:^(commandertui)$
|
|
74
|
+
```
|
|
75
|
+
|
|
76
|
+
```lua
|
|
77
|
+
-- Omarchy Lua config (~/.config/hypr/hyprland.lua)
|
|
78
|
+
o.window("^commandertui$", { float = true, center = true, size = { 1174, 637 } })
|
|
79
|
+
```
|
|
80
|
+
|
|
81
|
+
## Keys
|
|
82
|
+
|
|
83
|
+
| Key | Action |
|
|
84
|
+
|---|---|
|
|
85
|
+
| `Tab` | Switch active pane |
|
|
86
|
+
| `↑`/`↓`, `Enter` | Navigate, open directory |
|
|
87
|
+
| `Backspace` | Go to parent directory |
|
|
88
|
+
| `Space` | Mark/unmark item under cursor |
|
|
89
|
+
| `F5` | Copy marked (or item under cursor) to the other pane |
|
|
90
|
+
| `F6` | Move marked to the other pane |
|
|
91
|
+
| `F7` | Create a folder in the active pane |
|
|
92
|
+
| `F8` / `Delete` | Delete marked (to trash) |
|
|
93
|
+
| `i` | Size: file/folder count and total bytes of marked items (background, `Esc` stops it) |
|
|
94
|
+
| `h` | Show/hide hidden (dot) files in both panes |
|
|
95
|
+
| `p` | Places: jump to a mounted drive (USB/SMB/rclone) or saved bookmark |
|
|
96
|
+
| `c` | Compare: full recursive diff between the two panes' current folders |
|
|
97
|
+
| `r` | Refresh both panes |
|
|
98
|
+
| `q` | Quit |
|
|
99
|
+
|
|
100
|
+
While a copy/move runs, the progress window shows two bars (current file
|
|
101
|
+
and whole job, by bytes) plus speed in Mb/s and time left. `Esc` asks to
|
|
102
|
+
cancel: finished files stay, the file in progress is discarded (never left
|
|
103
|
+
half-written), and a move never removes a source it didn't fully copy.
|
|
104
|
+
|
|
105
|
+
**Places** lists only what is mounted right now: USB drives
|
|
106
|
+
(`/run/media`, `/media`, `/mnt` — checked with `ismount`, so an empty
|
|
107
|
+
on-demand mount point doesn't show), GVFS/Nautilus network shares, FUSE
|
|
108
|
+
mounts inside `$HOME` (e.g. `rclone mount`), and saved bookmarks.
|
|
109
|
+
|
|
110
|
+
Inside **Compare**:
|
|
111
|
+
|
|
112
|
+
| Key | Action |
|
|
113
|
+
|---|---|
|
|
114
|
+
| `m` | Mirror: make right identical to left |
|
|
115
|
+
| `u` | Update: copy new/changed left → right, never deletes |
|
|
116
|
+
| `b` | Two-way: propagate each side's exclusive changes; conflicts (changed on both sides) are listed, never guessed |
|
|
117
|
+
| `Space` | Mark a row for manual handling |
|
|
118
|
+
| `c` | Copy marked rows in whichever direction they diverge |
|
|
119
|
+
| `Esc` | Back to the panes |
|
|
120
|
+
|
|
121
|
+
Every bulk or destructive action shows a plain-language summary (count of
|
|
122
|
+
operations, total bytes) and requires `y` to confirm.
|
|
123
|
+
|
|
124
|
+
## Architecture
|
|
125
|
+
|
|
126
|
+
```
|
|
127
|
+
commandertui/
|
|
128
|
+
├── models.py # FileEntry, DiffEntry, OperationQueue, QueuedOp
|
|
129
|
+
├── scanner.py # os.scandir recursive tree walk
|
|
130
|
+
├── diff.py # pure comparison: left-only / right-only / modified / identical
|
|
131
|
+
├── plan.py # diff -> OperationQueue (mirror / update / two-way)
|
|
132
|
+
├── sync.py # atomic copy/move (streamed, tmp+replace), checksum verify
|
|
133
|
+
├── trash.py # 3-layer safe delete (send2trash / gio trash / manual)
|
|
134
|
+
├── executor.py # runs an OperationQueue against the filesystem
|
|
135
|
+
├── bookmarks.py # detected mounts (USB/SMB/rclone) + saved places
|
|
136
|
+
├── theme.py # green/amber phosphor palettes
|
|
137
|
+
├── boot.py # startup "modem" reveal animation
|
|
138
|
+
├── widgets.py # FilePanel: one pane's directory listing
|
|
139
|
+
├── modals.py # Confirm / Places / Input / Size / Message screens
|
|
140
|
+
├── progress_screen.py # two-bar transfer progress + cancel
|
|
141
|
+
├── run_queue.py # confirm -> run queue in a worker thread
|
|
142
|
+
├── compare_screen.py # full diff view + bulk/manual sync actions
|
|
143
|
+
├── app.py # CommanderApp: wires panes + keybindings
|
|
144
|
+
└── cli.py # argument parsing, entrypoint
|
|
145
|
+
```
|
|
146
|
+
|
|
147
|
+
## Tests
|
|
148
|
+
|
|
149
|
+
```bash
|
|
150
|
+
pip install -r requirements-dev.txt
|
|
151
|
+
pytest
|
|
152
|
+
black commandertui tests # formatter, line length set in pyproject.toml
|
|
153
|
+
```
|
|
154
|
+
|
|
155
|
+
Engine (scanner/diff/plan/sync/executor) is fully unit tested against a real
|
|
156
|
+
filesystem (`tmp_path`). The UI has smoke tests via Textual's `Pilot`
|
|
157
|
+
(boot sequence dismisses, pane switching, mark→copy→confirm end to end).
|
|
158
|
+
|
|
159
|
+
## Not in scope
|
|
160
|
+
|
|
161
|
+
No file preview, no deduplication engine, no desktop GUI wrapper — those are
|
|
162
|
+
different problems.
|
|
163
|
+
|
|
164
|
+
## Credits
|
|
165
|
+
|
|
166
|
+
Designed and tested by Humberto Barchini. The code was written together
|
|
167
|
+
with [Claude](https://www.anthropic.com/claude) (Anthropic) through
|
|
168
|
+
[Claude Code](https://claude.com/claude-code): **Claude Sonnet 5** built the
|
|
169
|
+
first version, **Claude Opus 5.5** did most of v0.2 and the v1.0 packaging.
|
|
170
|
+
|
|
171
|
+
## License
|
|
172
|
+
|
|
173
|
+
MIT — see [LICENSE](LICENSE).
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
__version__ = "1.0.0"
|
|
@@ -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()
|