commandertui 1.1.0__tar.gz → 1.3.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.1.0 → commandertui-1.3.0}/PKG-INFO +58 -9
- {commandertui-1.1.0 → commandertui-1.3.0}/README.md +57 -8
- commandertui-1.3.0/commandertui/__init__.py +1 -0
- commandertui-1.3.0/commandertui/app.py +81 -0
- {commandertui-1.1.0 → commandertui-1.3.0}/commandertui/bookmarks.py +4 -1
- {commandertui-1.1.0 → commandertui-1.3.0}/commandertui/cli.py +6 -1
- {commandertui-1.1.0 → commandertui-1.3.0}/commandertui/compare_screen.py +97 -9
- commandertui-1.3.0/commandertui/editor.py +26 -0
- commandertui-1.3.0/commandertui/main_screen.py +493 -0
- commandertui-1.3.0/commandertui/modals.py +644 -0
- commandertui-1.3.0/commandertui/netlocations.py +118 -0
- {commandertui-1.1.0 → commandertui-1.3.0}/commandertui/progress_screen.py +38 -3
- commandertui-1.3.0/commandertui/settings.py +35 -0
- {commandertui-1.1.0 → commandertui-1.3.0}/commandertui/sync.py +13 -2
- commandertui-1.3.0/commandertui/theme.py +284 -0
- commandertui-1.3.0/commandertui/updater.py +163 -0
- commandertui-1.3.0/commandertui/widgets.py +359 -0
- {commandertui-1.1.0 → commandertui-1.3.0}/commandertui.egg-info/PKG-INFO +58 -9
- {commandertui-1.1.0 → commandertui-1.3.0}/commandertui.egg-info/SOURCES.txt +6 -0
- commandertui-1.3.0/tests/test_app_smoke.py +952 -0
- commandertui-1.3.0/tests/test_netlocations.py +51 -0
- {commandertui-1.1.0 → commandertui-1.3.0}/tests/test_sync.py +19 -0
- commandertui-1.3.0/tests/test_updater.py +109 -0
- {commandertui-1.1.0 → commandertui-1.3.0}/tests/test_widgets.py +13 -0
- commandertui-1.1.0/commandertui/__init__.py +0 -1
- commandertui-1.1.0/commandertui/app.py +0 -40
- commandertui-1.1.0/commandertui/main_screen.py +0 -211
- commandertui-1.1.0/commandertui/modals.py +0 -250
- commandertui-1.1.0/commandertui/theme.py +0 -94
- commandertui-1.1.0/commandertui/widgets.py +0 -134
- commandertui-1.1.0/tests/test_app_smoke.py +0 -129
- {commandertui-1.1.0 → commandertui-1.3.0}/LICENSE +0 -0
- {commandertui-1.1.0 → commandertui-1.3.0}/commandertui/ascii_font.py +0 -0
- {commandertui-1.1.0 → commandertui-1.3.0}/commandertui/boot.py +0 -0
- {commandertui-1.1.0 → commandertui-1.3.0}/commandertui/diff.py +0 -0
- {commandertui-1.1.0 → commandertui-1.3.0}/commandertui/executor.py +0 -0
- {commandertui-1.1.0 → commandertui-1.3.0}/commandertui/models.py +0 -0
- {commandertui-1.1.0 → commandertui-1.3.0}/commandertui/plan.py +0 -0
- {commandertui-1.1.0 → commandertui-1.3.0}/commandertui/run_queue.py +0 -0
- {commandertui-1.1.0 → commandertui-1.3.0}/commandertui/scanner.py +0 -0
- {commandertui-1.1.0 → commandertui-1.3.0}/commandertui/trash.py +0 -0
- {commandertui-1.1.0 → commandertui-1.3.0}/commandertui.egg-info/dependency_links.txt +0 -0
- {commandertui-1.1.0 → commandertui-1.3.0}/commandertui.egg-info/entry_points.txt +0 -0
- {commandertui-1.1.0 → commandertui-1.3.0}/commandertui.egg-info/requires.txt +0 -0
- {commandertui-1.1.0 → commandertui-1.3.0}/commandertui.egg-info/top_level.txt +0 -0
- {commandertui-1.1.0 → commandertui-1.3.0}/pyproject.toml +0 -0
- {commandertui-1.1.0 → commandertui-1.3.0}/setup.cfg +0 -0
- {commandertui-1.1.0 → commandertui-1.3.0}/tests/test_bookmarks.py +0 -0
- {commandertui-1.1.0 → commandertui-1.3.0}/tests/test_diff.py +0 -0
- {commandertui-1.1.0 → commandertui-1.3.0}/tests/test_executor.py +0 -0
- {commandertui-1.1.0 → commandertui-1.3.0}/tests/test_plan.py +0 -0
- {commandertui-1.1.0 → commandertui-1.3.0}/tests/test_scanner.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: commandertui
|
|
3
|
-
Version: 1.
|
|
3
|
+
Version: 1.3.0
|
|
4
4
|
Summary: Dual-pane retro terminal file manager: compare, sync, copy and move folders
|
|
5
5
|
Author: Humberto Barchini
|
|
6
6
|
License-Expression: MIT
|
|
@@ -22,7 +22,7 @@ Dual-pane terminal file manager: compare two folders, sync them, or move
|
|
|
22
22
|
files/folders between them (and to USB/SMB/any mounted path) — with a
|
|
23
23
|
phosphor-green 80s-terminal look and a short modem-style boot animation.
|
|
24
24
|
|
|
25
|
-
Status: **v1.
|
|
25
|
+
Status: **v1.3.0** — 119/119 tests passing. MIT licensed.
|
|
26
26
|
|
|
27
27
|
---
|
|
28
28
|
|
|
@@ -32,7 +32,7 @@ A safe, simple alternative to heavier dual-pane file managers. It was
|
|
|
32
32
|
written after an earlier attempt of the same idea had to be scrapped for
|
|
33
33
|
running an unauthenticated local HTTP server. This project deliberately:
|
|
34
34
|
|
|
35
|
-
- **Never opens a network port.** Pure terminal UI (Textual), no HTTP server,
|
|
35
|
+
- **Never opens a network port.** Pure terminal UI (Textual), no HTTP server (the only outgoing request is the daily update check, which you can turn off),
|
|
36
36
|
no webview.
|
|
37
37
|
- **Never does a raw `rm -rf`.** Deletes always go through the desktop trash
|
|
38
38
|
(`send2trash` → `gio trash` → manual move into `~/.local/share/Trash`, in
|
|
@@ -58,13 +58,19 @@ makepkg -si
|
|
|
58
58
|
|
|
59
59
|
Remove it with `sudo pacman -R commandertui`.
|
|
60
60
|
|
|
61
|
-
**
|
|
61
|
+
**PyPI (Linux / macOS / WSL).** It is a standard package on [PyPI](https://pypi.org/project/commandertui/) (Python 3.11+). Recommended via `pipx`:
|
|
62
62
|
|
|
63
63
|
```bash
|
|
64
|
-
pipx install
|
|
64
|
+
pipx install commandertui
|
|
65
65
|
```
|
|
66
66
|
|
|
67
|
-
|
|
67
|
+
Or inside an activated virtual environment:
|
|
68
|
+
|
|
69
|
+
```bash
|
|
70
|
+
pip install commandertui
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
**macOS.** Same Python package (`pipx install commandertui`), plus
|
|
68
74
|
[kitty](https://sw.kovidgoyal.net/kitty/) for the standalone window. Places
|
|
69
75
|
lists the volumes under `/Volumes` (system volumes such as Recovery and
|
|
70
76
|
Preboot are hidden) and FUSE mounts inside your home folder. To get a
|
|
@@ -120,14 +126,20 @@ o.window("^commandertui$", { float = true, center = true, size = { 1174, 637 } }
|
|
|
120
126
|
| `↑`/`↓`, `Enter` | Navigate, open directory |
|
|
121
127
|
| `Backspace` | Go to parent directory |
|
|
122
128
|
| `Space` | Mark/unmark item under cursor |
|
|
129
|
+
| `F2` | Rename the item under the cursor (never overwrites an existing name) |
|
|
130
|
+
| `F4` | Edit the file under the cursor with `$VISUAL` / `$EDITOR` (falls back to nano, vim or vi); CommanderTUI pauses and comes back when you close the editor |
|
|
123
131
|
| `F5` | Copy marked (or item under cursor) to the other pane |
|
|
124
132
|
| `F6` | Move marked to the other pane |
|
|
125
133
|
| `F7` | Create a folder in the active pane |
|
|
126
134
|
| `F8` / `Delete` | Delete marked (to trash) |
|
|
135
|
+
| `/` | Filter the active pane by name (contains, ignores case) as you type; `Enter` keeps it, `Esc` clears it |
|
|
136
|
+
| `+` / `-` / `*` | Mark / unmark by pattern (`*.iso`, or plain text = contains) / invert marks, over the rows shown |
|
|
137
|
+
| `u` | Update: shows the pending new version (see *Updates*) |
|
|
127
138
|
| `i` | Size: file/folder count and total bytes of marked items (background, `Esc` stops it) |
|
|
128
139
|
| `h` | Show/hide hidden (dot) files in both panes |
|
|
129
|
-
| `
|
|
130
|
-
| `
|
|
140
|
+
| `t` | Color theme: Phosphor green (default), Phosphor amber, Nord, Gruvbox, Dracula, Tokyo Night, Catppuccin Mocha, Solarized Dark, Monokai, Textual Dark. Previews as you move, Enter keeps it (saved in `~/.config/commandertui/settings.json`), Esc goes back |
|
|
141
|
+
| `p` | Places: jump to a mounted drive (USB/SMB/rclone), saved bookmark or saved network location. Inside: `a` add a network location, `s` save the active pane's folder as a bookmark, `d` delete a saved one |
|
|
142
|
+
| `c` | Compare: full recursive diff between the two panes' current folders, with size and date of both sides (and which is newer when a file changed) |
|
|
131
143
|
| `r` | Refresh both panes |
|
|
132
144
|
| `q` | Quit |
|
|
133
145
|
|
|
@@ -141,6 +153,41 @@ half-written), and a move never removes a source it didn't fully copy.
|
|
|
141
153
|
on-demand mount point doesn't show), GVFS/Nautilus network shares, FUSE
|
|
142
154
|
mounts inside `$HOME` (e.g. `rclone mount`), and saved bookmarks.
|
|
143
155
|
|
|
156
|
+
**Network locations (Linux, needs `gio`/GVFS):** in Places press `a`, pick
|
|
157
|
+
SMB, SFTP or FTP and type the server's IP or name. For SMB the app lists the
|
|
158
|
+
shares on the server and you pick one; SFTP/FTP connect straight in. The
|
|
159
|
+
location is saved in `~/.config/commandertui/network.json` (protocol, host
|
|
160
|
+
and share only -- never a password) and reappears in Places, mounting itself
|
|
161
|
+
when picked. If the server asks for a login, `gio` asks for it in the
|
|
162
|
+
terminal and can remember it in the system keyring, exactly like Nautilus.
|
|
163
|
+
|
|
164
|
+
Every key also has a clickable box on the bottom bar: one row on wide windows
|
|
165
|
+
(168+ columns), two rows when narrower (120 columns is the practical minimum).
|
|
166
|
+
The active pane has the thick border and the inverted path bar; each path bar
|
|
167
|
+
shows the free space of that disk, and the marked count and total size.
|
|
168
|
+
|
|
169
|
+
## Updates
|
|
170
|
+
|
|
171
|
+
Once a day, in the background and only after the start-up animation, the app
|
|
172
|
+
asks PyPI for the latest version (a plain GET, nothing about you is sent). If
|
|
173
|
+
a newer one exists you get a dialog -- **Update now / Later / Skip this
|
|
174
|
+
version** -- and the header keeps saying `update X available (press u)`.
|
|
175
|
+
It never installs on its own. **Update now** hands the terminal over and runs
|
|
176
|
+
the command that matches how it was installed:
|
|
177
|
+
|
|
178
|
+
| Installed as | Update runs |
|
|
179
|
+
|---|---|
|
|
180
|
+
| Arch package (`makepkg`) | rebuilds from the release's PKGBUILD with `makepkg -si` (your sudo password is typed in the terminal, never read by the app) |
|
|
181
|
+
| pipx | `pipx upgrade commandertui` |
|
|
182
|
+
| pip | `pip install --upgrade commandertui` |
|
|
183
|
+
| git checkout | no button: the dialog tells you to `git pull` |
|
|
184
|
+
|
|
185
|
+
Turn the check off with `commandertui --no-update-check` or by setting
|
|
186
|
+
`COMMANDERTUI_NO_UPDATE_CHECK=1`. `COMMANDERTUI_FAKE_LATEST=9.9.9` shows the
|
|
187
|
+
dialog without a real release, to see how it looks. The logic lives in one
|
|
188
|
+
self-contained file, `commandertui/updater.py`, written to be copied into
|
|
189
|
+
other apps.
|
|
190
|
+
|
|
144
191
|
Inside **Compare**:
|
|
145
192
|
|
|
146
193
|
| Key | Action |
|
|
@@ -167,7 +214,9 @@ commandertui/
|
|
|
167
214
|
├── trash.py # 3-layer safe delete (send2trash / gio trash / manual)
|
|
168
215
|
├── executor.py # runs an OperationQueue against the filesystem
|
|
169
216
|
├── bookmarks.py # detected mounts (USB/SMB/rclone) + saved places
|
|
170
|
-
├──
|
|
217
|
+
├── netlocations.py # SMB/SFTP/FTP locations via gio (list shares, mount, save)
|
|
218
|
+
├── theme.py # phosphor green/amber themes + the stylesheet (Textual theme variables)
|
|
219
|
+
├── settings.py # saved settings (color theme)
|
|
171
220
|
├── boot.py # startup "modem" reveal animation
|
|
172
221
|
├── widgets.py # FilePanel: one pane's directory listing
|
|
173
222
|
├── modals.py # Confirm / Places / Input / Size / Message screens
|
|
@@ -4,7 +4,7 @@ Dual-pane terminal file manager: compare two folders, sync them, or move
|
|
|
4
4
|
files/folders between them (and to USB/SMB/any mounted path) — with a
|
|
5
5
|
phosphor-green 80s-terminal look and a short modem-style boot animation.
|
|
6
6
|
|
|
7
|
-
Status: **v1.
|
|
7
|
+
Status: **v1.3.0** — 119/119 tests passing. MIT licensed.
|
|
8
8
|
|
|
9
9
|
---
|
|
10
10
|
|
|
@@ -14,7 +14,7 @@ A safe, simple alternative to heavier dual-pane file managers. It was
|
|
|
14
14
|
written after an earlier attempt of the same idea had to be scrapped for
|
|
15
15
|
running an unauthenticated local HTTP server. This project deliberately:
|
|
16
16
|
|
|
17
|
-
- **Never opens a network port.** Pure terminal UI (Textual), no HTTP server,
|
|
17
|
+
- **Never opens a network port.** Pure terminal UI (Textual), no HTTP server (the only outgoing request is the daily update check, which you can turn off),
|
|
18
18
|
no webview.
|
|
19
19
|
- **Never does a raw `rm -rf`.** Deletes always go through the desktop trash
|
|
20
20
|
(`send2trash` → `gio trash` → manual move into `~/.local/share/Trash`, in
|
|
@@ -40,13 +40,19 @@ makepkg -si
|
|
|
40
40
|
|
|
41
41
|
Remove it with `sudo pacman -R commandertui`.
|
|
42
42
|
|
|
43
|
-
**
|
|
43
|
+
**PyPI (Linux / macOS / WSL).** It is a standard package on [PyPI](https://pypi.org/project/commandertui/) (Python 3.11+). Recommended via `pipx`:
|
|
44
44
|
|
|
45
45
|
```bash
|
|
46
|
-
pipx install
|
|
46
|
+
pipx install commandertui
|
|
47
47
|
```
|
|
48
48
|
|
|
49
|
-
|
|
49
|
+
Or inside an activated virtual environment:
|
|
50
|
+
|
|
51
|
+
```bash
|
|
52
|
+
pip install commandertui
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
**macOS.** Same Python package (`pipx install commandertui`), plus
|
|
50
56
|
[kitty](https://sw.kovidgoyal.net/kitty/) for the standalone window. Places
|
|
51
57
|
lists the volumes under `/Volumes` (system volumes such as Recovery and
|
|
52
58
|
Preboot are hidden) and FUSE mounts inside your home folder. To get a
|
|
@@ -102,14 +108,20 @@ o.window("^commandertui$", { float = true, center = true, size = { 1174, 637 } }
|
|
|
102
108
|
| `↑`/`↓`, `Enter` | Navigate, open directory |
|
|
103
109
|
| `Backspace` | Go to parent directory |
|
|
104
110
|
| `Space` | Mark/unmark item under cursor |
|
|
111
|
+
| `F2` | Rename the item under the cursor (never overwrites an existing name) |
|
|
112
|
+
| `F4` | Edit the file under the cursor with `$VISUAL` / `$EDITOR` (falls back to nano, vim or vi); CommanderTUI pauses and comes back when you close the editor |
|
|
105
113
|
| `F5` | Copy marked (or item under cursor) to the other pane |
|
|
106
114
|
| `F6` | Move marked to the other pane |
|
|
107
115
|
| `F7` | Create a folder in the active pane |
|
|
108
116
|
| `F8` / `Delete` | Delete marked (to trash) |
|
|
117
|
+
| `/` | Filter the active pane by name (contains, ignores case) as you type; `Enter` keeps it, `Esc` clears it |
|
|
118
|
+
| `+` / `-` / `*` | Mark / unmark by pattern (`*.iso`, or plain text = contains) / invert marks, over the rows shown |
|
|
119
|
+
| `u` | Update: shows the pending new version (see *Updates*) |
|
|
109
120
|
| `i` | Size: file/folder count and total bytes of marked items (background, `Esc` stops it) |
|
|
110
121
|
| `h` | Show/hide hidden (dot) files in both panes |
|
|
111
|
-
| `
|
|
112
|
-
| `
|
|
122
|
+
| `t` | Color theme: Phosphor green (default), Phosphor amber, Nord, Gruvbox, Dracula, Tokyo Night, Catppuccin Mocha, Solarized Dark, Monokai, Textual Dark. Previews as you move, Enter keeps it (saved in `~/.config/commandertui/settings.json`), Esc goes back |
|
|
123
|
+
| `p` | Places: jump to a mounted drive (USB/SMB/rclone), saved bookmark or saved network location. Inside: `a` add a network location, `s` save the active pane's folder as a bookmark, `d` delete a saved one |
|
|
124
|
+
| `c` | Compare: full recursive diff between the two panes' current folders, with size and date of both sides (and which is newer when a file changed) |
|
|
113
125
|
| `r` | Refresh both panes |
|
|
114
126
|
| `q` | Quit |
|
|
115
127
|
|
|
@@ -123,6 +135,41 @@ half-written), and a move never removes a source it didn't fully copy.
|
|
|
123
135
|
on-demand mount point doesn't show), GVFS/Nautilus network shares, FUSE
|
|
124
136
|
mounts inside `$HOME` (e.g. `rclone mount`), and saved bookmarks.
|
|
125
137
|
|
|
138
|
+
**Network locations (Linux, needs `gio`/GVFS):** in Places press `a`, pick
|
|
139
|
+
SMB, SFTP or FTP and type the server's IP or name. For SMB the app lists the
|
|
140
|
+
shares on the server and you pick one; SFTP/FTP connect straight in. The
|
|
141
|
+
location is saved in `~/.config/commandertui/network.json` (protocol, host
|
|
142
|
+
and share only -- never a password) and reappears in Places, mounting itself
|
|
143
|
+
when picked. If the server asks for a login, `gio` asks for it in the
|
|
144
|
+
terminal and can remember it in the system keyring, exactly like Nautilus.
|
|
145
|
+
|
|
146
|
+
Every key also has a clickable box on the bottom bar: one row on wide windows
|
|
147
|
+
(168+ columns), two rows when narrower (120 columns is the practical minimum).
|
|
148
|
+
The active pane has the thick border and the inverted path bar; each path bar
|
|
149
|
+
shows the free space of that disk, and the marked count and total size.
|
|
150
|
+
|
|
151
|
+
## Updates
|
|
152
|
+
|
|
153
|
+
Once a day, in the background and only after the start-up animation, the app
|
|
154
|
+
asks PyPI for the latest version (a plain GET, nothing about you is sent). If
|
|
155
|
+
a newer one exists you get a dialog -- **Update now / Later / Skip this
|
|
156
|
+
version** -- and the header keeps saying `update X available (press u)`.
|
|
157
|
+
It never installs on its own. **Update now** hands the terminal over and runs
|
|
158
|
+
the command that matches how it was installed:
|
|
159
|
+
|
|
160
|
+
| Installed as | Update runs |
|
|
161
|
+
|---|---|
|
|
162
|
+
| Arch package (`makepkg`) | rebuilds from the release's PKGBUILD with `makepkg -si` (your sudo password is typed in the terminal, never read by the app) |
|
|
163
|
+
| pipx | `pipx upgrade commandertui` |
|
|
164
|
+
| pip | `pip install --upgrade commandertui` |
|
|
165
|
+
| git checkout | no button: the dialog tells you to `git pull` |
|
|
166
|
+
|
|
167
|
+
Turn the check off with `commandertui --no-update-check` or by setting
|
|
168
|
+
`COMMANDERTUI_NO_UPDATE_CHECK=1`. `COMMANDERTUI_FAKE_LATEST=9.9.9` shows the
|
|
169
|
+
dialog without a real release, to see how it looks. The logic lives in one
|
|
170
|
+
self-contained file, `commandertui/updater.py`, written to be copied into
|
|
171
|
+
other apps.
|
|
172
|
+
|
|
126
173
|
Inside **Compare**:
|
|
127
174
|
|
|
128
175
|
| Key | Action |
|
|
@@ -149,7 +196,9 @@ commandertui/
|
|
|
149
196
|
├── trash.py # 3-layer safe delete (send2trash / gio trash / manual)
|
|
150
197
|
├── executor.py # runs an OperationQueue against the filesystem
|
|
151
198
|
├── bookmarks.py # detected mounts (USB/SMB/rclone) + saved places
|
|
152
|
-
├──
|
|
199
|
+
├── netlocations.py # SMB/SFTP/FTP locations via gio (list shares, mount, save)
|
|
200
|
+
├── theme.py # phosphor green/amber themes + the stylesheet (Textual theme variables)
|
|
201
|
+
├── settings.py # saved settings (color theme)
|
|
153
202
|
├── boot.py # startup "modem" reveal animation
|
|
154
203
|
├── widgets.py # FilePanel: one pane's directory listing
|
|
155
204
|
├── modals.py # Confirm / Places / Input / Size / Message screens
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
__version__ = "1.3.0"
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
|
|
5
|
+
from textual.app import App
|
|
6
|
+
|
|
7
|
+
from . import settings, theme, updater
|
|
8
|
+
from .boot import BootScreen
|
|
9
|
+
from .main_screen import MainScreen
|
|
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 = theme.CSS
|
|
18
|
+
|
|
19
|
+
def __init__(
|
|
20
|
+
self, left_path: str | None = None, right_path: str | None = None, check_updates: bool = True
|
|
21
|
+
) -> None:
|
|
22
|
+
super().__init__()
|
|
23
|
+
for phosphor in theme.PHOSPHOR_THEMES:
|
|
24
|
+
self.register_theme(phosphor)
|
|
25
|
+
saved = settings.load_theme()
|
|
26
|
+
self.theme = saved if saved in self.available_themes else theme.DEFAULT_THEME
|
|
27
|
+
self.check_updates = check_updates and not updater.updates_disabled()
|
|
28
|
+
self.update_info: updater.UpdateInfo | None = None
|
|
29
|
+
self.update_prompted = False
|
|
30
|
+
self.left_path = os.path.abspath(left_path or os.path.expanduser("~"))
|
|
31
|
+
self.right_path = os.path.abspath(right_path or os.path.expanduser("~"))
|
|
32
|
+
|
|
33
|
+
def on_unmount(self) -> None:
|
|
34
|
+
# Whatever theme is showing when the app closes is the one to reopen
|
|
35
|
+
# with -- covers the picker (t) and Textual's own palette (ctrl+p).
|
|
36
|
+
settings.save_theme(self.theme)
|
|
37
|
+
|
|
38
|
+
def preview_theme(self, name: str) -> None:
|
|
39
|
+
self.theme = name
|
|
40
|
+
|
|
41
|
+
def on_mount(self) -> None:
|
|
42
|
+
self.push_screen(MainScreen(self.left_path, self.right_path))
|
|
43
|
+
self.push_screen(BootScreen(), self._after_boot)
|
|
44
|
+
|
|
45
|
+
def _after_boot(self, _result=None) -> None:
|
|
46
|
+
# Looked up only once the boot animation is over, so a quick answer
|
|
47
|
+
# can never pop a dialog over it.
|
|
48
|
+
if self.check_updates:
|
|
49
|
+
self.run_worker(self._look_for_update, thread=True)
|
|
50
|
+
|
|
51
|
+
def _look_for_update(self) -> None:
|
|
52
|
+
info = updater.check()
|
|
53
|
+
if info:
|
|
54
|
+
self.call_from_thread(self.update_found, info)
|
|
55
|
+
|
|
56
|
+
def update_found(self, info: updater.UpdateInfo) -> None:
|
|
57
|
+
"""Remember the newer version and say so in the header; the dialog
|
|
58
|
+
itself waits until the main screen is actually showing (not the boot
|
|
59
|
+
animation)."""
|
|
60
|
+
self.update_info = info
|
|
61
|
+
self.sub_title = f"update {info.latest} available (press u)"
|
|
62
|
+
if isinstance(self.screen, MainScreen):
|
|
63
|
+
self.screen.prompt_update()
|
|
64
|
+
|
|
65
|
+
def clear_update(self) -> None:
|
|
66
|
+
self.update_info = None
|
|
67
|
+
self.sub_title = ""
|
|
68
|
+
|
|
69
|
+
# Convenience passthroughs so existing tests/tools that poke at the app
|
|
70
|
+
# directly (e.g. app._panel("left")) keep working without knowing about
|
|
71
|
+
# the MainScreen split.
|
|
72
|
+
def _panel(self, side: str):
|
|
73
|
+
return self.main_screen._panel(side)
|
|
74
|
+
|
|
75
|
+
@property
|
|
76
|
+
def main_screen(self) -> MainScreen:
|
|
77
|
+
return next(s for s in self.screen_stack if isinstance(s, MainScreen))
|
|
78
|
+
|
|
79
|
+
@property
|
|
80
|
+
def active_side(self) -> str:
|
|
81
|
+
return self.main_screen.active_side
|
|
@@ -186,4 +186,7 @@ def remove_bookmark(path: str) -> None:
|
|
|
186
186
|
|
|
187
187
|
def all_places() -> list[Place]:
|
|
188
188
|
home = Place(label="Home", path=os.path.expanduser("~"), kind="home")
|
|
189
|
-
|
|
189
|
+
from .netlocations import load_locations # lazy: netlocations imports CONFIG_DIR from here
|
|
190
|
+
|
|
191
|
+
saved_net = [Place(label=loc.label, path=loc.uri, kind="net-saved") for loc in load_locations()]
|
|
192
|
+
return [home] + detect_mounted() + detect_network_mounts() + detect_home_mounts() + load_saved() + saved_net
|
|
@@ -11,10 +11,15 @@ def main() -> None:
|
|
|
11
11
|
parser = argparse.ArgumentParser(prog="commandertui", description="Dual-pane TUI file manager")
|
|
12
12
|
parser.add_argument("left", nargs="?", default=os.path.expanduser("~"))
|
|
13
13
|
parser.add_argument("right", nargs="?", default=os.path.expanduser("~"))
|
|
14
|
+
parser.add_argument(
|
|
15
|
+
"--no-update-check",
|
|
16
|
+
action="store_true",
|
|
17
|
+
help="never contact PyPI to look for a newer version",
|
|
18
|
+
)
|
|
14
19
|
parser.add_argument("--version", action="version", version=f"commandertui {__version__}")
|
|
15
20
|
args = parser.parse_args()
|
|
16
21
|
|
|
17
|
-
app = CommanderApp(left_path=args.left, right_path=args.right)
|
|
22
|
+
app = CommanderApp(left_path=args.left, right_path=args.right, check_updates=not args.no_update_check)
|
|
18
23
|
app.run()
|
|
19
24
|
|
|
20
25
|
|
|
@@ -1,16 +1,20 @@
|
|
|
1
1
|
from __future__ import annotations
|
|
2
2
|
|
|
3
|
+
import threading
|
|
4
|
+
import time
|
|
5
|
+
|
|
3
6
|
from textual.app import ComposeResult
|
|
4
7
|
from textual.binding import Binding
|
|
5
8
|
from textual.containers import Vertical
|
|
6
9
|
from textual.screen import Screen
|
|
7
|
-
from textual.widgets import DataTable,
|
|
10
|
+
from textual.widgets import DataTable, Static
|
|
8
11
|
|
|
9
12
|
from .diff import compare
|
|
10
13
|
from .modals import MessageScreen
|
|
11
|
-
from .models import DiffStatus, OpKind, OpSide, OperationQueue, QueuedOp
|
|
14
|
+
from .models import DiffEntry, DiffStatus, EntryKind, FileEntry, OpKind, OpSide, OperationQueue, QueuedOp
|
|
12
15
|
from .plan import plan_mirror, plan_two_way, plan_update
|
|
13
16
|
from .run_queue import run_queue_with_progress
|
|
17
|
+
from .widgets import SPINNER_FRAMES, CompareBar, _format_bytes
|
|
14
18
|
|
|
15
19
|
STATUS_LABEL = {
|
|
16
20
|
DiffStatus.LEFT_ONLY: "< only left",
|
|
@@ -20,6 +24,23 @@ STATUS_LABEL = {
|
|
|
20
24
|
}
|
|
21
25
|
|
|
22
26
|
|
|
27
|
+
def side_info(entry: FileEntry | None) -> str:
|
|
28
|
+
""""1.2 MB 2026-10-20 14:03" for one side of a difference ("-" if absent)."""
|
|
29
|
+
if entry is None:
|
|
30
|
+
return "-"
|
|
31
|
+
when = time.strftime("%Y-%m-%d %H:%M", time.localtime(entry.mtime))
|
|
32
|
+
size = "<dir>" if entry.kind == EntryKind.DIR else _format_bytes(entry.size)
|
|
33
|
+
return f"{size} {when}"
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def status_text(d: DiffEntry) -> str:
|
|
37
|
+
"""Status label; for a changed file also says which side has the newer copy."""
|
|
38
|
+
label = STATUS_LABEL[d.status]
|
|
39
|
+
if d.status == DiffStatus.MODIFIED and d.left and d.right and d.left.mtime != d.right.mtime:
|
|
40
|
+
label += " (left newer)" if d.left.mtime > d.right.mtime else " (right newer)"
|
|
41
|
+
return label
|
|
42
|
+
|
|
43
|
+
|
|
23
44
|
class CompareScreen(Screen):
|
|
24
45
|
"""Full recursive diff between the two panes' current directories, with
|
|
25
46
|
bulk actions (mirror/update/two-way) or per-item manual handling.
|
|
@@ -41,51 +62,114 @@ class CompareScreen(Screen):
|
|
|
41
62
|
self.left_root = left_root
|
|
42
63
|
self.right_root = right_root
|
|
43
64
|
self.manual_marks: set[str] = set()
|
|
65
|
+
self.diffs: list[DiffEntry] = []
|
|
66
|
+
self.scanning = False
|
|
67
|
+
self._cancel = threading.Event()
|
|
68
|
+
self._spinner = None
|
|
44
69
|
|
|
45
70
|
def compose(self) -> ComposeResult:
|
|
46
71
|
yield Static("", id="compare-summary")
|
|
47
72
|
yield DataTable(id="compare-table", cursor_type="row")
|
|
48
|
-
yield
|
|
73
|
+
yield CompareBar()
|
|
49
74
|
|
|
50
75
|
def on_mount(self) -> None:
|
|
51
76
|
self.reload()
|
|
52
77
|
|
|
78
|
+
def _stop_spinner(self) -> None:
|
|
79
|
+
if self._spinner is not None:
|
|
80
|
+
self._spinner.stop()
|
|
81
|
+
self._spinner = None
|
|
82
|
+
|
|
53
83
|
def reload(self) -> None:
|
|
54
|
-
|
|
84
|
+
"""Compare in a worker thread (a big tree can take minutes), with a
|
|
85
|
+
spinner so the screen never looks frozen; Esc cancels."""
|
|
86
|
+
if self.scanning:
|
|
87
|
+
return
|
|
88
|
+
self.scanning = True
|
|
89
|
+
cancel = self._cancel = threading.Event()
|
|
90
|
+
summary = self.query_one("#compare-summary", Static)
|
|
91
|
+
started = time.monotonic()
|
|
92
|
+
frame = 0
|
|
93
|
+
|
|
94
|
+
def spin() -> None:
|
|
95
|
+
nonlocal frame
|
|
96
|
+
frame += 1
|
|
97
|
+
summary.update(
|
|
98
|
+
f"{self.left_root} <-> {self.right_root}\n"
|
|
99
|
+
f"Comparing {SPINNER_FRAMES[frame % 4]} {int(time.monotonic() - started)}s"
|
|
100
|
+
" (Esc to cancel)"
|
|
101
|
+
)
|
|
102
|
+
|
|
103
|
+
spin()
|
|
104
|
+
self._spinner = self.set_interval(0.12, spin)
|
|
105
|
+
|
|
106
|
+
def work() -> None:
|
|
107
|
+
try:
|
|
108
|
+
diffs, error = compare(self.left_root, self.right_root, cancel_check=cancel.is_set), ""
|
|
109
|
+
except OSError as e:
|
|
110
|
+
diffs, error = [], str(e)
|
|
111
|
+
if not cancel.is_set():
|
|
112
|
+
self.app.call_from_thread(self._scan_done, diffs, error)
|
|
113
|
+
|
|
114
|
+
self.run_worker(work, thread=True)
|
|
115
|
+
|
|
116
|
+
def _scan_done(self, diffs: list[DiffEntry], error: str) -> None:
|
|
117
|
+
self._stop_spinner()
|
|
118
|
+
self.scanning = False
|
|
119
|
+
if error:
|
|
120
|
+
self.app.push_screen(MessageScreen(f"Could not compare:\n{error}"))
|
|
121
|
+
self.diffs = diffs
|
|
122
|
+
self._fill_table()
|
|
123
|
+
|
|
124
|
+
def _fill_table(self) -> None:
|
|
55
125
|
table = self.query_one("#compare-table", DataTable)
|
|
56
126
|
table.clear(columns=True)
|
|
57
|
-
table.add_columns(" ", "Path", "Status")
|
|
127
|
+
table.add_columns(" ", "Path", "Status", "Left (size, date)", "Right (size, date)")
|
|
58
128
|
for d in self.diffs:
|
|
59
129
|
if d.status == DiffStatus.IDENTICAL:
|
|
60
130
|
continue
|
|
61
131
|
mark = "*" if d.rel_path in self.manual_marks else ""
|
|
62
|
-
table.add_row(
|
|
132
|
+
table.add_row(
|
|
133
|
+
mark, d.rel_path, status_text(d), side_info(d.left), side_info(d.right), key=d.rel_path
|
|
134
|
+
)
|
|
63
135
|
|
|
64
136
|
non_identical = sum(1 for d in self.diffs if d.status != DiffStatus.IDENTICAL)
|
|
65
137
|
summary = self.query_one("#compare-summary", Static)
|
|
66
138
|
summary.update(
|
|
67
139
|
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"
|
|
140
|
+
f"{non_identical} differences"
|
|
70
141
|
)
|
|
71
142
|
|
|
72
143
|
def action_back(self) -> None:
|
|
144
|
+
self._cancel.set() # stops a running comparison; its late result is dropped
|
|
145
|
+
self._stop_spinner()
|
|
73
146
|
self.dismiss()
|
|
74
147
|
|
|
148
|
+
def _busy(self) -> bool:
|
|
149
|
+
if self.scanning:
|
|
150
|
+
self.notify("Still comparing. Wait for it to finish, or press Esc to cancel.", severity="warning")
|
|
151
|
+
return self.scanning
|
|
152
|
+
|
|
75
153
|
def _run_queue(self, title: str, queue: OperationQueue) -> None:
|
|
76
154
|
run_queue_with_progress(
|
|
77
155
|
self, title, queue, self.left_root, self.right_root, after=lambda report: self.reload()
|
|
78
156
|
)
|
|
79
157
|
|
|
80
158
|
def action_mirror(self) -> None:
|
|
159
|
+
if self._busy():
|
|
160
|
+
return
|
|
81
161
|
active = [d for d in self.diffs if d.status != DiffStatus.IDENTICAL]
|
|
82
162
|
self._run_queue("Mirror: make RIGHT identical to LEFT", plan_mirror(active))
|
|
83
163
|
|
|
84
164
|
def action_update(self) -> None:
|
|
165
|
+
if self._busy():
|
|
166
|
+
return
|
|
85
167
|
active = [d for d in self.diffs if d.status != DiffStatus.IDENTICAL]
|
|
86
168
|
self._run_queue("Update: copy new/changed LEFT -> RIGHT (no deletes)", plan_update(active))
|
|
87
169
|
|
|
88
170
|
def action_two_way(self) -> None:
|
|
171
|
+
if self._busy():
|
|
172
|
+
return
|
|
89
173
|
active = [d for d in self.diffs if d.status != DiffStatus.IDENTICAL]
|
|
90
174
|
queue, conflicts = plan_two_way(active)
|
|
91
175
|
if conflicts:
|
|
@@ -99,6 +183,8 @@ class CompareScreen(Screen):
|
|
|
99
183
|
self._run_queue("Two-way: propagate each side's exclusive changes", queue)
|
|
100
184
|
|
|
101
185
|
def action_toggle_manual(self) -> None:
|
|
186
|
+
if self._busy():
|
|
187
|
+
return
|
|
102
188
|
table = self.query_one("#compare-table", DataTable)
|
|
103
189
|
if table.row_count == 0:
|
|
104
190
|
return
|
|
@@ -110,9 +196,11 @@ class CompareScreen(Screen):
|
|
|
110
196
|
self.manual_marks.discard(rel_path)
|
|
111
197
|
else:
|
|
112
198
|
self.manual_marks.add(rel_path)
|
|
113
|
-
self.
|
|
199
|
+
self._fill_table()
|
|
114
200
|
|
|
115
201
|
def action_copy_manual(self) -> None:
|
|
202
|
+
if self._busy():
|
|
203
|
+
return
|
|
116
204
|
if not self.manual_marks:
|
|
117
205
|
self.app.push_screen(MessageScreen("No items marked. Use [space] first."))
|
|
118
206
|
return
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
"""Which text editor F4 opens: the user's own choice first."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import os
|
|
6
|
+
import shlex
|
|
7
|
+
import shutil
|
|
8
|
+
|
|
9
|
+
FALLBACKS = ("nano", "vim", "vi")
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def editor_command() -> list[str] | None:
|
|
13
|
+
"""$VISUAL, then $EDITOR, then the first of nano/vim/vi that is installed."""
|
|
14
|
+
for var in ("VISUAL", "EDITOR"):
|
|
15
|
+
value = os.environ.get(var, "").strip()
|
|
16
|
+
if value:
|
|
17
|
+
try:
|
|
18
|
+
parts = shlex.split(value)
|
|
19
|
+
except ValueError:
|
|
20
|
+
continue
|
|
21
|
+
if parts and shutil.which(parts[0]):
|
|
22
|
+
return parts
|
|
23
|
+
for name in FALLBACKS:
|
|
24
|
+
if shutil.which(name):
|
|
25
|
+
return [name]
|
|
26
|
+
return None
|