commandertui 1.0.0__tar.gz → 1.2.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 → commandertui-1.2.0}/PKG-INFO +68 -6
- {commandertui-1.0.0 → commandertui-1.2.0}/README.md +67 -5
- commandertui-1.2.0/commandertui/__init__.py +1 -0
- commandertui-1.2.0/commandertui/app.py +70 -0
- {commandertui-1.0.0 → commandertui-1.2.0}/commandertui/bookmarks.py +66 -7
- {commandertui-1.0.0 → commandertui-1.2.0}/commandertui/cli.py +6 -1
- {commandertui-1.0.0 → commandertui-1.2.0}/commandertui/compare_screen.py +4 -4
- commandertui-1.2.0/commandertui/main_screen.py +443 -0
- commandertui-1.2.0/commandertui/modals.py +626 -0
- commandertui-1.2.0/commandertui/netlocations.py +118 -0
- {commandertui-1.0.0 → commandertui-1.2.0}/commandertui/progress_screen.py +38 -3
- {commandertui-1.0.0 → commandertui-1.2.0}/commandertui/sync.py +13 -2
- commandertui-1.2.0/commandertui/theme.py +255 -0
- commandertui-1.2.0/commandertui/updater.py +163 -0
- commandertui-1.2.0/commandertui/widgets.py +357 -0
- {commandertui-1.0.0 → commandertui-1.2.0}/commandertui.egg-info/PKG-INFO +68 -6
- {commandertui-1.0.0 → commandertui-1.2.0}/commandertui.egg-info/SOURCES.txt +4 -0
- commandertui-1.2.0/tests/test_app_smoke.py +761 -0
- {commandertui-1.0.0 → commandertui-1.2.0}/tests/test_bookmarks.py +58 -0
- {commandertui-1.0.0 → commandertui-1.2.0}/tests/test_executor.py +7 -1
- commandertui-1.2.0/tests/test_netlocations.py +51 -0
- {commandertui-1.0.0 → commandertui-1.2.0}/tests/test_sync.py +19 -0
- commandertui-1.2.0/tests/test_updater.py +109 -0
- {commandertui-1.0.0 → commandertui-1.2.0}/tests/test_widgets.py +13 -0
- commandertui-1.0.0/commandertui/__init__.py +0 -1
- commandertui-1.0.0/commandertui/app.py +0 -40
- commandertui-1.0.0/commandertui/main_screen.py +0 -211
- commandertui-1.0.0/commandertui/modals.py +0 -250
- commandertui-1.0.0/commandertui/theme.py +0 -94
- commandertui-1.0.0/commandertui/widgets.py +0 -134
- commandertui-1.0.0/tests/test_app_smoke.py +0 -129
- {commandertui-1.0.0 → commandertui-1.2.0}/LICENSE +0 -0
- {commandertui-1.0.0 → commandertui-1.2.0}/commandertui/ascii_font.py +0 -0
- {commandertui-1.0.0 → commandertui-1.2.0}/commandertui/boot.py +0 -0
- {commandertui-1.0.0 → commandertui-1.2.0}/commandertui/diff.py +0 -0
- {commandertui-1.0.0 → commandertui-1.2.0}/commandertui/executor.py +0 -0
- {commandertui-1.0.0 → commandertui-1.2.0}/commandertui/models.py +0 -0
- {commandertui-1.0.0 → commandertui-1.2.0}/commandertui/plan.py +0 -0
- {commandertui-1.0.0 → commandertui-1.2.0}/commandertui/run_queue.py +0 -0
- {commandertui-1.0.0 → commandertui-1.2.0}/commandertui/scanner.py +0 -0
- {commandertui-1.0.0 → commandertui-1.2.0}/commandertui/trash.py +0 -0
- {commandertui-1.0.0 → commandertui-1.2.0}/commandertui.egg-info/dependency_links.txt +0 -0
- {commandertui-1.0.0 → commandertui-1.2.0}/commandertui.egg-info/entry_points.txt +0 -0
- {commandertui-1.0.0 → commandertui-1.2.0}/commandertui.egg-info/requires.txt +0 -0
- {commandertui-1.0.0 → commandertui-1.2.0}/commandertui.egg-info/top_level.txt +0 -0
- {commandertui-1.0.0 → commandertui-1.2.0}/pyproject.toml +0 -0
- {commandertui-1.0.0 → commandertui-1.2.0}/setup.cfg +0 -0
- {commandertui-1.0.0 → commandertui-1.2.0}/tests/test_diff.py +0 -0
- {commandertui-1.0.0 → commandertui-1.2.0}/tests/test_plan.py +0 -0
- {commandertui-1.0.0 → commandertui-1.2.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.2.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.2.0** — 108/108 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,12 +58,34 @@ 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
|
+
Or inside an activated virtual environment:
|
|
68
|
+
|
|
69
|
+
```bash
|
|
70
|
+
pip install commandertui
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
**macOS.** Same Python package (`pipx install commandertui`), plus
|
|
74
|
+
[kitty](https://sw.kovidgoyal.net/kitty/) for the standalone window. Places
|
|
75
|
+
lists the volumes under `/Volumes` (system volumes such as Recovery and
|
|
76
|
+
Preboot are hidden) and FUSE mounts inside your home folder. To get a
|
|
77
|
+
double-clickable app with the CommanderTUI icon:
|
|
78
|
+
|
|
79
|
+
```bash
|
|
80
|
+
packaging/macos/build_app.sh # builds packaging/macos/CommanderTUI.app
|
|
81
|
+
packaging/macos/build_app.sh --install # also copies it to /Applications
|
|
82
|
+
```
|
|
83
|
+
|
|
84
|
+
The app opens kitty at 1174x637 px and runs `commandertui`. It looks for the
|
|
85
|
+
command in `~/.local/bin` (pipx), Homebrew, `/usr/local/bin`, your `PATH`,
|
|
86
|
+
and a source checkout's `.venv`. Tested by the author on macOS with a Time
|
|
87
|
+
Machine volume, Google Drive and OneDrive mounts.
|
|
88
|
+
|
|
67
89
|
**From a source checkout** (development):
|
|
68
90
|
|
|
69
91
|
```bash
|
|
@@ -104,13 +126,17 @@ o.window("^commandertui$", { float = true, center = true, size = { 1174, 637 } }
|
|
|
104
126
|
| `↑`/`↓`, `Enter` | Navigate, open directory |
|
|
105
127
|
| `Backspace` | Go to parent directory |
|
|
106
128
|
| `Space` | Mark/unmark item under cursor |
|
|
129
|
+
| `F2` | Rename the item under the cursor (never overwrites an existing name) |
|
|
107
130
|
| `F5` | Copy marked (or item under cursor) to the other pane |
|
|
108
131
|
| `F6` | Move marked to the other pane |
|
|
109
132
|
| `F7` | Create a folder in the active pane |
|
|
110
133
|
| `F8` / `Delete` | Delete marked (to trash) |
|
|
134
|
+
| `/` | Filter the active pane by name (contains, ignores case) as you type; `Enter` keeps it, `Esc` clears it |
|
|
135
|
+
| `+` / `-` / `*` | Mark / unmark by pattern (`*.iso`, or plain text = contains) / invert marks, over the rows shown |
|
|
136
|
+
| `u` | Update: shows the pending new version (see *Updates*) |
|
|
111
137
|
| `i` | Size: file/folder count and total bytes of marked items (background, `Esc` stops it) |
|
|
112
138
|
| `h` | Show/hide hidden (dot) files in both panes |
|
|
113
|
-
| `p` | Places: jump to a mounted drive (USB/SMB/rclone) or saved bookmark |
|
|
139
|
+
| `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 |
|
|
114
140
|
| `c` | Compare: full recursive diff between the two panes' current folders |
|
|
115
141
|
| `r` | Refresh both panes |
|
|
116
142
|
| `q` | Quit |
|
|
@@ -125,6 +151,41 @@ half-written), and a move never removes a source it didn't fully copy.
|
|
|
125
151
|
on-demand mount point doesn't show), GVFS/Nautilus network shares, FUSE
|
|
126
152
|
mounts inside `$HOME` (e.g. `rclone mount`), and saved bookmarks.
|
|
127
153
|
|
|
154
|
+
**Network locations (Linux, needs `gio`/GVFS):** in Places press `a`, pick
|
|
155
|
+
SMB, SFTP or FTP and type the server's IP or name. For SMB the app lists the
|
|
156
|
+
shares on the server and you pick one; SFTP/FTP connect straight in. The
|
|
157
|
+
location is saved in `~/.config/commandertui/network.json` (protocol, host
|
|
158
|
+
and share only -- never a password) and reappears in Places, mounting itself
|
|
159
|
+
when picked. If the server asks for a login, `gio` asks for it in the
|
|
160
|
+
terminal and can remember it in the system keyring, exactly like Nautilus.
|
|
161
|
+
|
|
162
|
+
Every key also has a clickable box on the bottom bar: one row on wide windows
|
|
163
|
+
(168+ columns), two rows when narrower (110 columns is the practical minimum).
|
|
164
|
+
The active pane has the thick border and the inverted path bar; each path bar
|
|
165
|
+
shows the free space of that disk, and the marked count and total size.
|
|
166
|
+
|
|
167
|
+
## Updates
|
|
168
|
+
|
|
169
|
+
Once a day, in the background and only after the start-up animation, the app
|
|
170
|
+
asks PyPI for the latest version (a plain GET, nothing about you is sent). If
|
|
171
|
+
a newer one exists you get a dialog -- **Update now / Later / Skip this
|
|
172
|
+
version** -- and the header keeps saying `update X available (press u)`.
|
|
173
|
+
It never installs on its own. **Update now** hands the terminal over and runs
|
|
174
|
+
the command that matches how it was installed:
|
|
175
|
+
|
|
176
|
+
| Installed as | Update runs |
|
|
177
|
+
|---|---|
|
|
178
|
+
| 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) |
|
|
179
|
+
| pipx | `pipx upgrade commandertui` |
|
|
180
|
+
| pip | `pip install --upgrade commandertui` |
|
|
181
|
+
| git checkout | no button: the dialog tells you to `git pull` |
|
|
182
|
+
|
|
183
|
+
Turn the check off with `commandertui --no-update-check` or by setting
|
|
184
|
+
`COMMANDERTUI_NO_UPDATE_CHECK=1`. `COMMANDERTUI_FAKE_LATEST=9.9.9` shows the
|
|
185
|
+
dialog without a real release, to see how it looks. The logic lives in one
|
|
186
|
+
self-contained file, `commandertui/updater.py`, written to be copied into
|
|
187
|
+
other apps.
|
|
188
|
+
|
|
128
189
|
Inside **Compare**:
|
|
129
190
|
|
|
130
191
|
| Key | Action |
|
|
@@ -151,6 +212,7 @@ commandertui/
|
|
|
151
212
|
├── trash.py # 3-layer safe delete (send2trash / gio trash / manual)
|
|
152
213
|
├── executor.py # runs an OperationQueue against the filesystem
|
|
153
214
|
├── bookmarks.py # detected mounts (USB/SMB/rclone) + saved places
|
|
215
|
+
├── netlocations.py # SMB/SFTP/FTP locations via gio (list shares, mount, save)
|
|
154
216
|
├── theme.py # green/amber phosphor palettes
|
|
155
217
|
├── boot.py # startup "modem" reveal animation
|
|
156
218
|
├── widgets.py # FilePanel: one pane's directory listing
|
|
@@ -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.2.0** — 108/108 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,12 +40,34 @@ 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
|
+
Or inside an activated virtual environment:
|
|
50
|
+
|
|
51
|
+
```bash
|
|
52
|
+
pip install commandertui
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
**macOS.** Same Python package (`pipx install commandertui`), plus
|
|
56
|
+
[kitty](https://sw.kovidgoyal.net/kitty/) for the standalone window. Places
|
|
57
|
+
lists the volumes under `/Volumes` (system volumes such as Recovery and
|
|
58
|
+
Preboot are hidden) and FUSE mounts inside your home folder. To get a
|
|
59
|
+
double-clickable app with the CommanderTUI icon:
|
|
60
|
+
|
|
61
|
+
```bash
|
|
62
|
+
packaging/macos/build_app.sh # builds packaging/macos/CommanderTUI.app
|
|
63
|
+
packaging/macos/build_app.sh --install # also copies it to /Applications
|
|
64
|
+
```
|
|
65
|
+
|
|
66
|
+
The app opens kitty at 1174x637 px and runs `commandertui`. It looks for the
|
|
67
|
+
command in `~/.local/bin` (pipx), Homebrew, `/usr/local/bin`, your `PATH`,
|
|
68
|
+
and a source checkout's `.venv`. Tested by the author on macOS with a Time
|
|
69
|
+
Machine volume, Google Drive and OneDrive mounts.
|
|
70
|
+
|
|
49
71
|
**From a source checkout** (development):
|
|
50
72
|
|
|
51
73
|
```bash
|
|
@@ -86,13 +108,17 @@ o.window("^commandertui$", { float = true, center = true, size = { 1174, 637 } }
|
|
|
86
108
|
| `↑`/`↓`, `Enter` | Navigate, open directory |
|
|
87
109
|
| `Backspace` | Go to parent directory |
|
|
88
110
|
| `Space` | Mark/unmark item under cursor |
|
|
111
|
+
| `F2` | Rename the item under the cursor (never overwrites an existing name) |
|
|
89
112
|
| `F5` | Copy marked (or item under cursor) to the other pane |
|
|
90
113
|
| `F6` | Move marked to the other pane |
|
|
91
114
|
| `F7` | Create a folder in the active pane |
|
|
92
115
|
| `F8` / `Delete` | Delete marked (to trash) |
|
|
116
|
+
| `/` | Filter the active pane by name (contains, ignores case) as you type; `Enter` keeps it, `Esc` clears it |
|
|
117
|
+
| `+` / `-` / `*` | Mark / unmark by pattern (`*.iso`, or plain text = contains) / invert marks, over the rows shown |
|
|
118
|
+
| `u` | Update: shows the pending new version (see *Updates*) |
|
|
93
119
|
| `i` | Size: file/folder count and total bytes of marked items (background, `Esc` stops it) |
|
|
94
120
|
| `h` | Show/hide hidden (dot) files in both panes |
|
|
95
|
-
| `p` | Places: jump to a mounted drive (USB/SMB/rclone) or saved bookmark |
|
|
121
|
+
| `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 |
|
|
96
122
|
| `c` | Compare: full recursive diff between the two panes' current folders |
|
|
97
123
|
| `r` | Refresh both panes |
|
|
98
124
|
| `q` | Quit |
|
|
@@ -107,6 +133,41 @@ half-written), and a move never removes a source it didn't fully copy.
|
|
|
107
133
|
on-demand mount point doesn't show), GVFS/Nautilus network shares, FUSE
|
|
108
134
|
mounts inside `$HOME` (e.g. `rclone mount`), and saved bookmarks.
|
|
109
135
|
|
|
136
|
+
**Network locations (Linux, needs `gio`/GVFS):** in Places press `a`, pick
|
|
137
|
+
SMB, SFTP or FTP and type the server's IP or name. For SMB the app lists the
|
|
138
|
+
shares on the server and you pick one; SFTP/FTP connect straight in. The
|
|
139
|
+
location is saved in `~/.config/commandertui/network.json` (protocol, host
|
|
140
|
+
and share only -- never a password) and reappears in Places, mounting itself
|
|
141
|
+
when picked. If the server asks for a login, `gio` asks for it in the
|
|
142
|
+
terminal and can remember it in the system keyring, exactly like Nautilus.
|
|
143
|
+
|
|
144
|
+
Every key also has a clickable box on the bottom bar: one row on wide windows
|
|
145
|
+
(168+ columns), two rows when narrower (110 columns is the practical minimum).
|
|
146
|
+
The active pane has the thick border and the inverted path bar; each path bar
|
|
147
|
+
shows the free space of that disk, and the marked count and total size.
|
|
148
|
+
|
|
149
|
+
## Updates
|
|
150
|
+
|
|
151
|
+
Once a day, in the background and only after the start-up animation, the app
|
|
152
|
+
asks PyPI for the latest version (a plain GET, nothing about you is sent). If
|
|
153
|
+
a newer one exists you get a dialog -- **Update now / Later / Skip this
|
|
154
|
+
version** -- and the header keeps saying `update X available (press u)`.
|
|
155
|
+
It never installs on its own. **Update now** hands the terminal over and runs
|
|
156
|
+
the command that matches how it was installed:
|
|
157
|
+
|
|
158
|
+
| Installed as | Update runs |
|
|
159
|
+
|---|---|
|
|
160
|
+
| 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) |
|
|
161
|
+
| pipx | `pipx upgrade commandertui` |
|
|
162
|
+
| pip | `pip install --upgrade commandertui` |
|
|
163
|
+
| git checkout | no button: the dialog tells you to `git pull` |
|
|
164
|
+
|
|
165
|
+
Turn the check off with `commandertui --no-update-check` or by setting
|
|
166
|
+
`COMMANDERTUI_NO_UPDATE_CHECK=1`. `COMMANDERTUI_FAKE_LATEST=9.9.9` shows the
|
|
167
|
+
dialog without a real release, to see how it looks. The logic lives in one
|
|
168
|
+
self-contained file, `commandertui/updater.py`, written to be copied into
|
|
169
|
+
other apps.
|
|
170
|
+
|
|
110
171
|
Inside **Compare**:
|
|
111
172
|
|
|
112
173
|
| Key | Action |
|
|
@@ -133,6 +194,7 @@ commandertui/
|
|
|
133
194
|
├── trash.py # 3-layer safe delete (send2trash / gio trash / manual)
|
|
134
195
|
├── executor.py # runs an OperationQueue against the filesystem
|
|
135
196
|
├── bookmarks.py # detected mounts (USB/SMB/rclone) + saved places
|
|
197
|
+
├── netlocations.py # SMB/SFTP/FTP locations via gio (list shares, mount, save)
|
|
136
198
|
├── theme.py # green/amber phosphor palettes
|
|
137
199
|
├── boot.py # startup "modem" reveal animation
|
|
138
200
|
├── widgets.py # FilePanel: one pane's directory listing
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
__version__ = "1.2.0"
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
|
|
5
|
+
from textual.app import App
|
|
6
|
+
|
|
7
|
+
from . import updater
|
|
8
|
+
from .boot import BootScreen
|
|
9
|
+
from .main_screen import MainScreen
|
|
10
|
+
from .theme import css_for
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class CommanderApp(App):
|
|
14
|
+
"""Owns only the screen stack. All pane behavior lives in MainScreen --
|
|
15
|
+
see its docstring for why that split matters for key bindings.
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
CSS = css_for("green")
|
|
19
|
+
|
|
20
|
+
def __init__(
|
|
21
|
+
self, left_path: str | None = None, right_path: str | None = None, check_updates: bool = True
|
|
22
|
+
) -> None:
|
|
23
|
+
super().__init__()
|
|
24
|
+
self.check_updates = check_updates and not updater.updates_disabled()
|
|
25
|
+
self.update_info: updater.UpdateInfo | None = None
|
|
26
|
+
self.update_prompted = False
|
|
27
|
+
self.left_path = os.path.abspath(left_path or os.path.expanduser("~"))
|
|
28
|
+
self.right_path = os.path.abspath(right_path or os.path.expanduser("~"))
|
|
29
|
+
|
|
30
|
+
def on_mount(self) -> None:
|
|
31
|
+
self.push_screen(MainScreen(self.left_path, self.right_path))
|
|
32
|
+
self.push_screen(BootScreen(), self._after_boot)
|
|
33
|
+
|
|
34
|
+
def _after_boot(self, _result=None) -> None:
|
|
35
|
+
# Looked up only once the boot animation is over, so a quick answer
|
|
36
|
+
# can never pop a dialog over it.
|
|
37
|
+
if self.check_updates:
|
|
38
|
+
self.run_worker(self._look_for_update, thread=True)
|
|
39
|
+
|
|
40
|
+
def _look_for_update(self) -> None:
|
|
41
|
+
info = updater.check()
|
|
42
|
+
if info:
|
|
43
|
+
self.call_from_thread(self.update_found, info)
|
|
44
|
+
|
|
45
|
+
def update_found(self, info: updater.UpdateInfo) -> None:
|
|
46
|
+
"""Remember the newer version and say so in the header; the dialog
|
|
47
|
+
itself waits until the main screen is actually showing (not the boot
|
|
48
|
+
animation)."""
|
|
49
|
+
self.update_info = info
|
|
50
|
+
self.sub_title = f"update {info.latest} available (press u)"
|
|
51
|
+
if isinstance(self.screen, MainScreen):
|
|
52
|
+
self.screen.prompt_update()
|
|
53
|
+
|
|
54
|
+
def clear_update(self) -> None:
|
|
55
|
+
self.update_info = None
|
|
56
|
+
self.sub_title = ""
|
|
57
|
+
|
|
58
|
+
# Convenience passthroughs so existing tests/tools that poke at the app
|
|
59
|
+
# directly (e.g. app._panel("left")) keep working without knowing about
|
|
60
|
+
# the MainScreen split.
|
|
61
|
+
def _panel(self, side: str):
|
|
62
|
+
return self.main_screen._panel(side)
|
|
63
|
+
|
|
64
|
+
@property
|
|
65
|
+
def main_screen(self) -> MainScreen:
|
|
66
|
+
return next(s for s in self.screen_stack if isinstance(s, MainScreen))
|
|
67
|
+
|
|
68
|
+
@property
|
|
69
|
+
def active_side(self) -> str:
|
|
70
|
+
return self.main_screen.active_side
|
|
@@ -2,13 +2,30 @@ from __future__ import annotations
|
|
|
2
2
|
|
|
3
3
|
import json
|
|
4
4
|
import os
|
|
5
|
+
import subprocess
|
|
6
|
+
import sys
|
|
5
7
|
from dataclasses import dataclass
|
|
6
8
|
|
|
7
9
|
CONFIG_DIR = os.path.expanduser("~/.config/commandertui")
|
|
8
10
|
BOOKMARKS_FILE = os.path.join(CONFIG_DIR, "bookmarks.json")
|
|
9
11
|
|
|
10
|
-
# Where
|
|
11
|
-
|
|
12
|
+
# Where OS mounts removable/network drives.
|
|
13
|
+
# Linux uses /run/media, /media, /mnt; macOS uses /Volumes.
|
|
14
|
+
if sys.platform == "darwin":
|
|
15
|
+
MOUNT_ROOTS = ("/Volumes",)
|
|
16
|
+
else:
|
|
17
|
+
MOUNT_ROOTS = ("/run/media", "/media", "/mnt")
|
|
18
|
+
|
|
19
|
+
# macOS system volumes to exclude from Places (macOS only: on Linux a drive
|
|
20
|
+
# labelled "VM" or "Update" is a perfectly normal drive and must show up).
|
|
21
|
+
MACOS_EXCLUDED_VOLUMES = {"Recovery", "VM", "Preboot", "Update"}
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def _hidden_volume(name: str) -> bool:
|
|
25
|
+
if sys.platform != "darwin":
|
|
26
|
+
return False
|
|
27
|
+
return name.startswith((".", "com.apple.")) or name in MACOS_EXCLUDED_VOLUMES
|
|
28
|
+
|
|
12
29
|
|
|
13
30
|
# Nautilus (and GNOME apps generally) mount network shares -- SMB, FTP,
|
|
14
31
|
# SFTP -- through GVFS rather than udisks2, so they never show up under
|
|
@@ -30,11 +47,15 @@ def detect_mounted() -> list[Place]:
|
|
|
30
47
|
continue
|
|
31
48
|
try:
|
|
32
49
|
for user_dir in os.scandir(root):
|
|
50
|
+
if _hidden_volume(user_dir.name):
|
|
51
|
+
continue
|
|
33
52
|
if not user_dir.is_dir():
|
|
34
53
|
continue
|
|
35
|
-
# /run/media/<user>/<Label> vs /mnt/<Label> (no user subdir)
|
|
54
|
+
# /run/media/<user>/<Label> vs /mnt/<Label> or /Volumes/<Label> (no user subdir)
|
|
36
55
|
candidates = list(os.scandir(user_dir.path)) if root == "/run/media" else [user_dir]
|
|
37
56
|
for c in candidates:
|
|
57
|
+
if _hidden_volume(c.name):
|
|
58
|
+
continue
|
|
38
59
|
# An on-demand mount point (e.g. /mnt/i7server) exists as
|
|
39
60
|
# an empty directory even when nothing is mounted there --
|
|
40
61
|
# os.path.ismount() is what actually tells them apart.
|
|
@@ -81,9 +102,7 @@ def _unescape_mount_path(path: str) -> str:
|
|
|
81
102
|
return path.replace("\\040", " ").replace("\\011", "\t").replace("\\012", "\n").replace("\\134", "\\")
|
|
82
103
|
|
|
83
104
|
|
|
84
|
-
def
|
|
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."""
|
|
105
|
+
def _detect_home_mounts_proc() -> list[Place]:
|
|
87
106
|
home = os.path.expanduser("~")
|
|
88
107
|
try:
|
|
89
108
|
with open(PROC_MOUNTS) as f:
|
|
@@ -101,6 +120,43 @@ def detect_home_mounts() -> list[Place]:
|
|
|
101
120
|
return places
|
|
102
121
|
|
|
103
122
|
|
|
123
|
+
def _detect_home_mounts_darwin() -> list[Place]:
|
|
124
|
+
home = os.path.expanduser("~")
|
|
125
|
+
places: list[Place] = []
|
|
126
|
+
try:
|
|
127
|
+
res = subprocess.run(["mount"], capture_output=True, text=True, timeout=5)
|
|
128
|
+
if res.returncode != 0:
|
|
129
|
+
return []
|
|
130
|
+
for line in res.stdout.splitlines():
|
|
131
|
+
# e.g.: remote: on /Users/user/365 (macfuse, nodev, ...)
|
|
132
|
+
parts = line.split(" on ")
|
|
133
|
+
if len(parts) < 2:
|
|
134
|
+
continue
|
|
135
|
+
rest = parts[1]
|
|
136
|
+
path, _, fstype_part = rest.rpartition(" (")
|
|
137
|
+
if not fstype_part:
|
|
138
|
+
continue
|
|
139
|
+
fstype = fstype_part.rstrip(")").split(",")[0].strip()
|
|
140
|
+
path = path.strip()
|
|
141
|
+
if path.startswith(home + os.sep) and any(
|
|
142
|
+
f in fstype.lower() for f in ("fuse", "osxfuse", "macfuse", "rclone")
|
|
143
|
+
):
|
|
144
|
+
places.append(Place(label=os.path.basename(path), path=path, kind=fstype))
|
|
145
|
+
except Exception:
|
|
146
|
+
pass
|
|
147
|
+
return places
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
def detect_home_mounts() -> list[Place]:
|
|
151
|
+
"""FUSE mounts inside $HOME, e.g. rclone's ~/365 and ~/GoogleDrive.
|
|
152
|
+
Read from /proc/mounts on Linux (or if PROC_MOUNTS exists) or mount on macOS."""
|
|
153
|
+
if os.path.isfile(PROC_MOUNTS):
|
|
154
|
+
return _detect_home_mounts_proc()
|
|
155
|
+
if sys.platform == "darwin":
|
|
156
|
+
return _detect_home_mounts_darwin()
|
|
157
|
+
return []
|
|
158
|
+
|
|
159
|
+
|
|
104
160
|
def load_saved() -> list[Place]:
|
|
105
161
|
if not os.path.isfile(BOOKMARKS_FILE):
|
|
106
162
|
return []
|
|
@@ -130,4 +186,7 @@ def remove_bookmark(path: str) -> None:
|
|
|
130
186
|
|
|
131
187
|
def all_places() -> list[Place]:
|
|
132
188
|
home = Place(label="Home", path=os.path.expanduser("~"), kind="home")
|
|
133
|
-
|
|
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
|
|
|
@@ -4,13 +4,14 @@ from textual.app import ComposeResult
|
|
|
4
4
|
from textual.binding import Binding
|
|
5
5
|
from textual.containers import Vertical
|
|
6
6
|
from textual.screen import Screen
|
|
7
|
-
from textual.widgets import DataTable,
|
|
7
|
+
from textual.widgets import DataTable, Static
|
|
8
8
|
|
|
9
9
|
from .diff import compare
|
|
10
10
|
from .modals import MessageScreen
|
|
11
11
|
from .models import DiffStatus, OpKind, OpSide, OperationQueue, QueuedOp
|
|
12
12
|
from .plan import plan_mirror, plan_two_way, plan_update
|
|
13
13
|
from .run_queue import run_queue_with_progress
|
|
14
|
+
from .widgets import CompareBar
|
|
14
15
|
|
|
15
16
|
STATUS_LABEL = {
|
|
16
17
|
DiffStatus.LEFT_ONLY: "< only left",
|
|
@@ -45,7 +46,7 @@ class CompareScreen(Screen):
|
|
|
45
46
|
def compose(self) -> ComposeResult:
|
|
46
47
|
yield Static("", id="compare-summary")
|
|
47
48
|
yield DataTable(id="compare-table", cursor_type="row")
|
|
48
|
-
yield
|
|
49
|
+
yield CompareBar()
|
|
49
50
|
|
|
50
51
|
def on_mount(self) -> None:
|
|
51
52
|
self.reload()
|
|
@@ -65,8 +66,7 @@ class CompareScreen(Screen):
|
|
|
65
66
|
summary = self.query_one("#compare-summary", Static)
|
|
66
67
|
summary.update(
|
|
67
68
|
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"
|
|
69
|
+
f"{non_identical} differences"
|
|
70
70
|
)
|
|
71
71
|
|
|
72
72
|
def action_back(self) -> None:
|