commandertui 1.3.0__tar.gz → 1.4.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.3.0 → commandertui-1.4.0}/PKG-INFO +15 -12
- {commandertui-1.3.0 → commandertui-1.4.0}/README.md +13 -11
- commandertui-1.4.0/commandertui/__init__.py +1 -0
- {commandertui-1.3.0 → commandertui-1.4.0}/commandertui/app.py +15 -8
- {commandertui-1.3.0 → commandertui-1.4.0}/commandertui/ascii_font.py +6 -3
- {commandertui-1.3.0 → commandertui-1.4.0}/commandertui/boot.py +60 -12
- {commandertui-1.3.0 → commandertui-1.4.0}/commandertui/main_screen.py +150 -17
- {commandertui-1.3.0 → commandertui-1.4.0}/commandertui/modals.py +46 -5
- commandertui-1.4.0/commandertui/netlocations.py +388 -0
- commandertui-1.4.0/commandertui/preview.py +122 -0
- {commandertui-1.3.0 → commandertui-1.4.0}/commandertui/settings.py +22 -0
- {commandertui-1.3.0 → commandertui-1.4.0}/commandertui/trash.py +10 -3
- {commandertui-1.3.0 → commandertui-1.4.0}/commandertui/updater.py +40 -21
- {commandertui-1.3.0 → commandertui-1.4.0}/commandertui/widgets.py +116 -5
- {commandertui-1.3.0 → commandertui-1.4.0}/commandertui.egg-info/PKG-INFO +15 -12
- {commandertui-1.3.0 → commandertui-1.4.0}/commandertui.egg-info/SOURCES.txt +2 -0
- {commandertui-1.3.0 → commandertui-1.4.0}/pyproject.toml +1 -0
- {commandertui-1.3.0 → commandertui-1.4.0}/tests/test_app_smoke.py +220 -11
- commandertui-1.4.0/tests/test_netlocations.py +246 -0
- commandertui-1.4.0/tests/test_preview.py +305 -0
- {commandertui-1.3.0 → commandertui-1.4.0}/tests/test_updater.py +17 -8
- commandertui-1.4.0/tests/test_widgets.py +200 -0
- commandertui-1.3.0/commandertui/__init__.py +0 -1
- commandertui-1.3.0/commandertui/netlocations.py +0 -118
- commandertui-1.3.0/tests/test_netlocations.py +0 -51
- commandertui-1.3.0/tests/test_widgets.py +0 -49
- {commandertui-1.3.0 → commandertui-1.4.0}/LICENSE +0 -0
- {commandertui-1.3.0 → commandertui-1.4.0}/commandertui/bookmarks.py +0 -0
- {commandertui-1.3.0 → commandertui-1.4.0}/commandertui/cli.py +0 -0
- {commandertui-1.3.0 → commandertui-1.4.0}/commandertui/compare_screen.py +0 -0
- {commandertui-1.3.0 → commandertui-1.4.0}/commandertui/diff.py +0 -0
- {commandertui-1.3.0 → commandertui-1.4.0}/commandertui/editor.py +0 -0
- {commandertui-1.3.0 → commandertui-1.4.0}/commandertui/executor.py +0 -0
- {commandertui-1.3.0 → commandertui-1.4.0}/commandertui/models.py +0 -0
- {commandertui-1.3.0 → commandertui-1.4.0}/commandertui/plan.py +0 -0
- {commandertui-1.3.0 → commandertui-1.4.0}/commandertui/progress_screen.py +0 -0
- {commandertui-1.3.0 → commandertui-1.4.0}/commandertui/run_queue.py +0 -0
- {commandertui-1.3.0 → commandertui-1.4.0}/commandertui/scanner.py +0 -0
- {commandertui-1.3.0 → commandertui-1.4.0}/commandertui/sync.py +0 -0
- {commandertui-1.3.0 → commandertui-1.4.0}/commandertui/theme.py +0 -0
- {commandertui-1.3.0 → commandertui-1.4.0}/commandertui.egg-info/dependency_links.txt +0 -0
- {commandertui-1.3.0 → commandertui-1.4.0}/commandertui.egg-info/entry_points.txt +0 -0
- {commandertui-1.3.0 → commandertui-1.4.0}/commandertui.egg-info/requires.txt +0 -0
- {commandertui-1.3.0 → commandertui-1.4.0}/commandertui.egg-info/top_level.txt +0 -0
- {commandertui-1.3.0 → commandertui-1.4.0}/setup.cfg +0 -0
- {commandertui-1.3.0 → commandertui-1.4.0}/tests/test_bookmarks.py +0 -0
- {commandertui-1.3.0 → commandertui-1.4.0}/tests/test_diff.py +0 -0
- {commandertui-1.3.0 → commandertui-1.4.0}/tests/test_executor.py +0 -0
- {commandertui-1.3.0 → commandertui-1.4.0}/tests/test_plan.py +0 -0
- {commandertui-1.3.0 → commandertui-1.4.0}/tests/test_scanner.py +0 -0
- {commandertui-1.3.0 → commandertui-1.4.0}/tests/test_sync.py +0 -0
|
@@ -1,11 +1,12 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: commandertui
|
|
3
|
-
Version: 1.
|
|
3
|
+
Version: 1.4.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
|
|
7
7
|
Project-URL: Homepage, https://github.com/juniorbarchini-oss/commandertui
|
|
8
8
|
Classifier: Environment :: Console
|
|
9
|
+
Classifier: Operating System :: MacOS
|
|
9
10
|
Classifier: Operating System :: POSIX :: Linux
|
|
10
11
|
Classifier: Programming Language :: Python :: 3
|
|
11
12
|
Classifier: Topic :: System :: Filesystems
|
|
@@ -22,7 +23,7 @@ Dual-pane terminal file manager: compare two folders, sync them, or move
|
|
|
22
23
|
files/folders between them (and to USB/SMB/any mounted path) — with a
|
|
23
24
|
phosphor-green 80s-terminal look and a short modem-style boot animation.
|
|
24
25
|
|
|
25
|
-
Status: **v1.
|
|
26
|
+
Status: **v1.4.0** — 153/153 tests passing. MIT licensed.
|
|
26
27
|
|
|
27
28
|
---
|
|
28
29
|
|
|
@@ -32,7 +33,7 @@ A safe, simple alternative to heavier dual-pane file managers. It was
|
|
|
32
33
|
written after an earlier attempt of the same idea had to be scrapped for
|
|
33
34
|
running an unauthenticated local HTTP server. This project deliberately:
|
|
34
35
|
|
|
35
|
-
- **Never opens a network port.** Pure terminal UI (Textual), no HTTP server (the only outgoing request is the
|
|
36
|
+
- **Never opens a network port.** Pure terminal UI (Textual), no HTTP server (the only outgoing request is the update check on startup, which you can turn off),
|
|
36
37
|
no webview.
|
|
37
38
|
- **Never does a raw `rm -rf`.** Deletes always go through the desktop trash
|
|
38
39
|
(`send2trash` → `gio trash` → manual move into `~/.local/share/Trash`, in
|
|
@@ -123,7 +124,7 @@ o.window("^commandertui$", { float = true, center = true, size = { 1174, 637 } }
|
|
|
123
124
|
| Key | Action |
|
|
124
125
|
|---|---|
|
|
125
126
|
| `Tab` | Switch active pane |
|
|
126
|
-
| `↑`/`↓`, `Enter` | Navigate, open directory |
|
|
127
|
+
| `↑`/`↓`, `Enter` | Navigate, open directory (or sort column when header is focused with `↑` beyond row 0) |
|
|
127
128
|
| `Backspace` | Go to parent directory |
|
|
128
129
|
| `Space` | Mark/unmark item under cursor |
|
|
129
130
|
| `F2` | Rename the item under the cursor (never overwrites an existing name) |
|
|
@@ -134,11 +135,12 @@ o.window("^commandertui$", { float = true, center = true, size = { 1174, 637 } }
|
|
|
134
135
|
| `F8` / `Delete` | Delete marked (to trash) |
|
|
135
136
|
| `/` | Filter the active pane by name (contains, ignores case) as you type; `Enter` keeps it, `Esc` clears it |
|
|
136
137
|
| `+` / `-` / `*` | Mark / unmark by pattern (`*.iso`, or plain text = contains) / invert marks, over the rows shown |
|
|
137
|
-
| `u` |
|
|
138
|
+
| `u` | Check for updates now (also the **Updates** button): asks PyPI right away and offers the new version if there is one |
|
|
139
|
+
| `v` | Quick Look file preview (macOS only): opens `qlmanage -p` for the file under cursor; press `v` again or close the window to dismiss |
|
|
138
140
|
| `i` | Size: file/folder count and total bytes of marked items (background, `Esc` stops it) |
|
|
139
141
|
| `h` | Show/hide hidden (dot) files in both panes |
|
|
140
142
|
| `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 |
|
|
143
|
+
| `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, `e` unmount a mounted SMB share |
|
|
142
144
|
| `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) |
|
|
143
145
|
| `r` | Refresh both panes |
|
|
144
146
|
| `q` | Quit |
|
|
@@ -153,13 +155,13 @@ half-written), and a move never removes a source it didn't fully copy.
|
|
|
153
155
|
on-demand mount point doesn't show), GVFS/Nautilus network shares, FUSE
|
|
154
156
|
mounts inside `$HOME` (e.g. `rclone mount`), and saved bookmarks.
|
|
155
157
|
|
|
156
|
-
**Network locations (Linux, needs `gio`/GVFS):** in Places press `a`, pick
|
|
158
|
+
**Network locations (Linux, needs `gio`/GVFS; macOS supports SMB via `smbutil`/`osascript` and `/Volumes`):** in Places press `a`, pick
|
|
157
159
|
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
|
|
160
|
+
shares on the server and you pick one; SFTP/FTP connect straight in (Linux only). The
|
|
161
|
+
location is saved in `~/.config/commandertui/network.json` (protocol, host,
|
|
162
|
+
share and user only -- never a password) and reappears in Places, mounting itself
|
|
161
163
|
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.
|
|
164
|
+
terminal and can remember it in the system keyring, exactly like Nautilus (on macOS, the system login dialog asks and saves it in Keychain).
|
|
163
165
|
|
|
164
166
|
Every key also has a clickable box on the bottom bar: one row on wide windows
|
|
165
167
|
(168+ columns), two rows when narrower (120 columns is the practical minimum).
|
|
@@ -168,7 +170,8 @@ shows the free space of that disk, and the marked count and total size.
|
|
|
168
170
|
|
|
169
171
|
## Updates
|
|
170
172
|
|
|
171
|
-
|
|
173
|
+
On every launch, in the background while the start-up animation plays (its
|
|
174
|
+
CHECKING UPDATES line shows the result), the app
|
|
172
175
|
asks PyPI for the latest version (a plain GET, nothing about you is sent). If
|
|
173
176
|
a newer one exists you get a dialog -- **Update now / Later / Skip this
|
|
174
177
|
version** -- and the header keeps saying `update X available (press u)`.
|
|
@@ -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.4.0** — 153/153 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 (the only outgoing request is the
|
|
17
|
+
- **Never opens a network port.** Pure terminal UI (Textual), no HTTP server (the only outgoing request is the update check on startup, 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
|
|
@@ -105,7 +105,7 @@ o.window("^commandertui$", { float = true, center = true, size = { 1174, 637 } }
|
|
|
105
105
|
| Key | Action |
|
|
106
106
|
|---|---|
|
|
107
107
|
| `Tab` | Switch active pane |
|
|
108
|
-
| `↑`/`↓`, `Enter` | Navigate, open directory |
|
|
108
|
+
| `↑`/`↓`, `Enter` | Navigate, open directory (or sort column when header is focused with `↑` beyond row 0) |
|
|
109
109
|
| `Backspace` | Go to parent directory |
|
|
110
110
|
| `Space` | Mark/unmark item under cursor |
|
|
111
111
|
| `F2` | Rename the item under the cursor (never overwrites an existing name) |
|
|
@@ -116,11 +116,12 @@ o.window("^commandertui$", { float = true, center = true, size = { 1174, 637 } }
|
|
|
116
116
|
| `F8` / `Delete` | Delete marked (to trash) |
|
|
117
117
|
| `/` | Filter the active pane by name (contains, ignores case) as you type; `Enter` keeps it, `Esc` clears it |
|
|
118
118
|
| `+` / `-` / `*` | Mark / unmark by pattern (`*.iso`, or plain text = contains) / invert marks, over the rows shown |
|
|
119
|
-
| `u` |
|
|
119
|
+
| `u` | Check for updates now (also the **Updates** button): asks PyPI right away and offers the new version if there is one |
|
|
120
|
+
| `v` | Quick Look file preview (macOS only): opens `qlmanage -p` for the file under cursor; press `v` again or close the window to dismiss |
|
|
120
121
|
| `i` | Size: file/folder count and total bytes of marked items (background, `Esc` stops it) |
|
|
121
122
|
| `h` | Show/hide hidden (dot) files in both panes |
|
|
122
123
|
| `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
|
+
| `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, `e` unmount a mounted SMB share |
|
|
124
125
|
| `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) |
|
|
125
126
|
| `r` | Refresh both panes |
|
|
126
127
|
| `q` | Quit |
|
|
@@ -135,13 +136,13 @@ half-written), and a move never removes a source it didn't fully copy.
|
|
|
135
136
|
on-demand mount point doesn't show), GVFS/Nautilus network shares, FUSE
|
|
136
137
|
mounts inside `$HOME` (e.g. `rclone mount`), and saved bookmarks.
|
|
137
138
|
|
|
138
|
-
**Network locations (Linux, needs `gio`/GVFS):** in Places press `a`, pick
|
|
139
|
+
**Network locations (Linux, needs `gio`/GVFS; macOS supports SMB via `smbutil`/`osascript` and `/Volumes`):** in Places press `a`, pick
|
|
139
140
|
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
|
|
141
|
+
shares on the server and you pick one; SFTP/FTP connect straight in (Linux only). The
|
|
142
|
+
location is saved in `~/.config/commandertui/network.json` (protocol, host,
|
|
143
|
+
share and user only -- never a password) and reappears in Places, mounting itself
|
|
143
144
|
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
|
+
terminal and can remember it in the system keyring, exactly like Nautilus (on macOS, the system login dialog asks and saves it in Keychain).
|
|
145
146
|
|
|
146
147
|
Every key also has a clickable box on the bottom bar: one row on wide windows
|
|
147
148
|
(168+ columns), two rows when narrower (120 columns is the practical minimum).
|
|
@@ -150,7 +151,8 @@ shows the free space of that disk, and the marked count and total size.
|
|
|
150
151
|
|
|
151
152
|
## Updates
|
|
152
153
|
|
|
153
|
-
|
|
154
|
+
On every launch, in the background while the start-up animation plays (its
|
|
155
|
+
CHECKING UPDATES line shows the result), the app
|
|
154
156
|
asks PyPI for the latest version (a plain GET, nothing about you is sent). If
|
|
155
157
|
a newer one exists you get a dialog -- **Update now / Later / Skip this
|
|
156
158
|
version** -- and the header keeps saying `update X available (press u)`.
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
__version__ = "1.4.0"
|
|
@@ -4,7 +4,7 @@ import os
|
|
|
4
4
|
|
|
5
5
|
from textual.app import App
|
|
6
6
|
|
|
7
|
-
from . import settings, theme, updater
|
|
7
|
+
from . import preview, settings, theme, updater
|
|
8
8
|
from .boot import BootScreen
|
|
9
9
|
from .main_screen import MainScreen
|
|
10
10
|
|
|
@@ -26,11 +26,15 @@ class CommanderApp(App):
|
|
|
26
26
|
self.theme = saved if saved in self.available_themes else theme.DEFAULT_THEME
|
|
27
27
|
self.check_updates = check_updates and not updater.updates_disabled()
|
|
28
28
|
self.update_info: updater.UpdateInfo | None = None
|
|
29
|
+
# What the boot screen's CHECKING UPDATES line shows.
|
|
30
|
+
self.update_state = "pending" if self.check_updates else "off"
|
|
31
|
+
self.update_latest = ""
|
|
29
32
|
self.update_prompted = False
|
|
30
33
|
self.left_path = os.path.abspath(left_path or os.path.expanduser("~"))
|
|
31
34
|
self.right_path = os.path.abspath(right_path or os.path.expanduser("~"))
|
|
32
35
|
|
|
33
36
|
def on_unmount(self) -> None:
|
|
37
|
+
preview.close_preview()
|
|
34
38
|
# Whatever theme is showing when the app closes is the one to reopen
|
|
35
39
|
# with -- covers the picker (t) and Textual's own palette (ctrl+p).
|
|
36
40
|
settings.save_theme(self.theme)
|
|
@@ -40,18 +44,21 @@ class CommanderApp(App):
|
|
|
40
44
|
|
|
41
45
|
def on_mount(self) -> None:
|
|
42
46
|
self.push_screen(MainScreen(self.left_path, self.right_path))
|
|
43
|
-
self.push_screen(BootScreen()
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
# Looked up only once the boot animation is over, so a quick answer
|
|
47
|
-
# can never pop a dialog over it.
|
|
47
|
+
self.push_screen(BootScreen())
|
|
48
|
+
# Looked up while the boot animation plays (it shows the result); the
|
|
49
|
+
# dialog itself waits for the main screen, so it never covers the boot.
|
|
48
50
|
if self.check_updates:
|
|
49
51
|
self.run_worker(self._look_for_update, thread=True)
|
|
50
52
|
|
|
51
53
|
def _look_for_update(self) -> None:
|
|
52
|
-
info = updater.
|
|
54
|
+
info, status = updater.check_status()
|
|
55
|
+
self.call_from_thread(self._update_checked, info, status)
|
|
56
|
+
|
|
57
|
+
def _update_checked(self, info: updater.UpdateInfo | None, status: str) -> None:
|
|
58
|
+
self.update_state = status
|
|
53
59
|
if info:
|
|
54
|
-
self.
|
|
60
|
+
self.update_latest = info.latest
|
|
61
|
+
self.update_found(info)
|
|
55
62
|
|
|
56
63
|
def update_found(self, info: updater.UpdateInfo) -> None:
|
|
57
64
|
"""Remember the newer version and say so in the header; the dialog
|
|
@@ -17,11 +17,14 @@ _FONT: dict[str, list[str]] = {
|
|
|
17
17
|
BLOCK = "█" # solid block, swapped in for the '#' placeholders above
|
|
18
18
|
|
|
19
19
|
|
|
20
|
-
def banner(word: str, gap: int = 1) -> str:
|
|
21
|
-
"""Render `word` as 5
|
|
20
|
+
def banner(word: str, gap: int = 1, scale: int = 1) -> str:
|
|
21
|
+
"""Render `word` as block letters, 5 rows tall at scale 1; a larger scale
|
|
22
|
+
makes every block `scale` cells wide and tall."""
|
|
22
23
|
letters = [_FONT[ch] for ch in word.upper()]
|
|
23
24
|
sep = " " * gap
|
|
24
25
|
rows = []
|
|
25
26
|
for row_index in range(5):
|
|
26
|
-
|
|
27
|
+
row = sep.join(letter[row_index] for letter in letters).replace("#", BLOCK)
|
|
28
|
+
row = "".join(ch * scale for ch in row)
|
|
29
|
+
rows.extend([row] * scale)
|
|
27
30
|
return "\n".join(rows)
|
|
@@ -8,16 +8,25 @@ from textual.widgets import Static
|
|
|
8
8
|
from .ascii_font import banner
|
|
9
9
|
|
|
10
10
|
CURSOR = "█" # solid block, like a CRT text cursor
|
|
11
|
+
SPINNER = "|/-\\"
|
|
12
|
+
LINE_WIDTH = 44 # fixed, so a status changing later never moves the box
|
|
13
|
+
WIDE_ENOUGH = 114 # terminal columns for the double-size title (110 wide with its box)
|
|
11
14
|
|
|
12
15
|
|
|
13
|
-
def
|
|
14
|
-
|
|
16
|
+
def _line(label: str, status: str) -> str:
|
|
17
|
+
dots = "." * (LINE_WIDTH - len(label) - len(status) - 2)
|
|
18
|
+
return f"{label} {dots} {status}"
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def _build_art(scale: int = 1, update_status: str = "OK") -> str:
|
|
22
|
+
title_lines = banner("COMMANDER", scale=scale).split("\n")
|
|
15
23
|
body_lines = [
|
|
16
24
|
"",
|
|
17
25
|
"T U I -- dual-pane file manager",
|
|
18
26
|
"",
|
|
19
|
-
"CONNECT 2400
|
|
20
|
-
"LOADING PANELS
|
|
27
|
+
_line("CONNECT 2400", "OK"),
|
|
28
|
+
_line("LOADING PANELS", "OK"),
|
|
29
|
+
_line("CHECKING UPDATES", update_status),
|
|
21
30
|
"READY.",
|
|
22
31
|
]
|
|
23
32
|
content = title_lines + body_lines
|
|
@@ -31,6 +40,11 @@ def _build_art() -> str:
|
|
|
31
40
|
return "\n".join(boxed)
|
|
32
41
|
|
|
33
42
|
|
|
43
|
+
def update_status_text(state: str, latest: str = "") -> str:
|
|
44
|
+
"""What the CHECKING UPDATES line says for an app update state."""
|
|
45
|
+
return {"ok": "OK", "off": "OFF", "offline": "OFFLINE", "skipped": "SKIPPED"}.get(state, f"NEW {latest}")
|
|
46
|
+
|
|
47
|
+
|
|
34
48
|
ART = _build_art()
|
|
35
49
|
|
|
36
50
|
|
|
@@ -63,13 +77,16 @@ class BootScreen(Screen):
|
|
|
63
77
|
|
|
64
78
|
def __init__(
|
|
65
79
|
self,
|
|
66
|
-
art: str =
|
|
80
|
+
art: str | None = None,
|
|
67
81
|
chars_per_tick: int = 3,
|
|
68
82
|
tick_seconds: float = 0.035,
|
|
69
83
|
bell: bool = True,
|
|
70
84
|
) -> None:
|
|
71
85
|
super().__init__()
|
|
72
|
-
self.
|
|
86
|
+
self.fixed_art = art # tests/tools can pass a fixed picture; normally built live
|
|
87
|
+
self.art = art or ART
|
|
88
|
+
self._scale = 1
|
|
89
|
+
self._frame = 0
|
|
73
90
|
self.chars_per_tick = chars_per_tick
|
|
74
91
|
self.tick_seconds = tick_seconds
|
|
75
92
|
self.bell = bell
|
|
@@ -87,10 +104,35 @@ class BootScreen(Screen):
|
|
|
87
104
|
yield Static("", id="boot-hint")
|
|
88
105
|
|
|
89
106
|
def on_mount(self) -> None:
|
|
107
|
+
# Double-size title when the window is wide enough for it.
|
|
108
|
+
self._scale = 2 if self.app.size.width >= WIDE_ENOUGH else 1
|
|
90
109
|
self._timer = self.set_interval(self.tick_seconds, self._tick)
|
|
91
110
|
|
|
111
|
+
def _pending(self) -> bool:
|
|
112
|
+
return self.fixed_art is None and getattr(self.app, "update_state", "off") == "pending"
|
|
113
|
+
|
|
114
|
+
def _current_art(self) -> str:
|
|
115
|
+
if self.fixed_art is not None:
|
|
116
|
+
return self.fixed_art
|
|
117
|
+
state = getattr(self.app, "update_state", "off")
|
|
118
|
+
if state == "pending":
|
|
119
|
+
status = SPINNER[self._frame % 4]
|
|
120
|
+
else:
|
|
121
|
+
status = update_status_text(state, getattr(self.app, "update_latest", ""))
|
|
122
|
+
return _build_art(self._scale, status)
|
|
123
|
+
|
|
124
|
+
def _limit(self) -> int:
|
|
125
|
+
"""How far the reveal may go: while the update check is still running
|
|
126
|
+
it waits just before the READY. row."""
|
|
127
|
+
if not self._pending():
|
|
128
|
+
return len(self.art)
|
|
129
|
+
ready = self.art.index("READY.")
|
|
130
|
+
return self.art.rfind("\n", 0, ready) + 1
|
|
131
|
+
|
|
92
132
|
def _tick(self) -> None:
|
|
93
|
-
self.
|
|
133
|
+
self._frame += 1
|
|
134
|
+
self.art = self._current_art()
|
|
135
|
+
self._pos = min(self._limit(), self._pos + self.chars_per_tick)
|
|
94
136
|
widget = self.query_one("#boot-text", Static)
|
|
95
137
|
visible = self.art[: self._pos]
|
|
96
138
|
cursor = CURSOR if self._pos < len(self.art) else ""
|
|
@@ -102,13 +144,19 @@ class BootScreen(Screen):
|
|
|
102
144
|
self._timer.stop()
|
|
103
145
|
if self.bell:
|
|
104
146
|
self.app.bell()
|
|
105
|
-
self.query_one("#boot-hint", Static).update("[ENTER] to continue")
|
|
147
|
+
self.query_one("#boot-hint", Static).update("\\[ENTER] to continue")
|
|
106
148
|
|
|
107
149
|
def action_continue_(self) -> None:
|
|
108
|
-
if
|
|
109
|
-
# Not drawn yet: Enter fast-forwards
|
|
110
|
-
#
|
|
111
|
-
self._pos =
|
|
150
|
+
if self._pos < self._limit():
|
|
151
|
+
# Not drawn yet: Enter fast-forwards as far as it can go instead
|
|
152
|
+
# of doing nothing, in case someone doesn't want to wait.
|
|
153
|
+
self._pos = self._limit()
|
|
112
154
|
self._tick()
|
|
113
155
|
return
|
|
156
|
+
if self._pending() and self._pos < len(self.art):
|
|
157
|
+
# Reveal is parked waiting on the update check: don't make the
|
|
158
|
+
# user wait for the network; a newer version still gets offered
|
|
159
|
+
# when it arrives.
|
|
160
|
+
self.dismiss()
|
|
161
|
+
return
|
|
114
162
|
self.dismiss()
|
|
@@ -10,7 +10,7 @@ from textual.screen import Screen
|
|
|
10
10
|
from textual.widgets import DataTable, Header
|
|
11
11
|
|
|
12
12
|
from .compare_screen import CompareScreen
|
|
13
|
-
from . import editor, netlocations, theme, updater
|
|
13
|
+
from . import __version__, editor, netlocations, preview, theme, updater
|
|
14
14
|
from .modals import (
|
|
15
15
|
ADD_NETWORK,
|
|
16
16
|
ChoiceScreen,
|
|
@@ -44,6 +44,10 @@ class MainScreen(Screen):
|
|
|
44
44
|
BINDINGS = [
|
|
45
45
|
Binding("tab", "switch_pane", "Switch pane", priority=True),
|
|
46
46
|
Binding("enter", "enter_dir", "Open", priority=True),
|
|
47
|
+
Binding("up", "nav_up", "Up", priority=True),
|
|
48
|
+
Binding("down", "nav_down", "Down", priority=True),
|
|
49
|
+
Binding("left", "nav_left", "Left", priority=True),
|
|
50
|
+
Binding("right", "nav_right", "Right", priority=True),
|
|
47
51
|
Binding("backspace", "up_dir", "Up", priority=True),
|
|
48
52
|
Binding("space", "toggle_mark", "Mark", priority=True),
|
|
49
53
|
Binding("t", "pick_theme", "Theme", priority=True),
|
|
@@ -64,6 +68,7 @@ class MainScreen(Screen):
|
|
|
64
68
|
Binding("r", "refresh_both", "Refresh", priority=True),
|
|
65
69
|
Binding("h", "toggle_hidden", "Hidden", priority=True),
|
|
66
70
|
Binding("i", "show_size", "Size", priority=True),
|
|
71
|
+
Binding("v", "preview_file", "Preview", priority=True),
|
|
67
72
|
# "app.quit", not "quit": this binding lives on the Screen, and a bare
|
|
68
73
|
# "quit" resolves against the Screen's own (nonexistent) action_quit
|
|
69
74
|
# rather than bubbling to the App, so it silently did nothing.
|
|
@@ -131,13 +136,91 @@ class MainScreen(Screen):
|
|
|
131
136
|
|
|
132
137
|
# -------------------------------------------------------------- nav
|
|
133
138
|
def action_switch_pane(self) -> None:
|
|
139
|
+
panel = self._active_panel()
|
|
140
|
+
if panel.header_active:
|
|
141
|
+
panel.header_active = False
|
|
142
|
+
panel.update_header_labels()
|
|
134
143
|
self._set_active("right" if self.active_side == "left" else "left")
|
|
135
144
|
|
|
136
145
|
def action_enter_dir(self) -> None:
|
|
137
|
-
self._active_panel()
|
|
146
|
+
panel = self._active_panel()
|
|
147
|
+
if panel.header_active:
|
|
148
|
+
col_name = ["Name", "Size", "Modified"][panel.header_selected_index]
|
|
149
|
+
panel.toggle_sort(col_name)
|
|
150
|
+
else:
|
|
151
|
+
panel.enter_selected()
|
|
152
|
+
|
|
153
|
+
def action_nav_up(self) -> None:
|
|
154
|
+
panel = self._active_panel()
|
|
155
|
+
table = panel.query_one(f"#{panel.side}-table", DataTable)
|
|
156
|
+
if panel.header_active:
|
|
157
|
+
return
|
|
158
|
+
if table.cursor_row == 0:
|
|
159
|
+
panel.header_active = True
|
|
160
|
+
panel.update_header_labels()
|
|
161
|
+
else:
|
|
162
|
+
table.action_cursor_up()
|
|
163
|
+
|
|
164
|
+
def action_nav_down(self) -> None:
|
|
165
|
+
panel = self._active_panel()
|
|
166
|
+
table = panel.query_one(f"#{panel.side}-table", DataTable)
|
|
167
|
+
if panel.header_active:
|
|
168
|
+
panel.header_active = False
|
|
169
|
+
panel.update_header_labels()
|
|
170
|
+
else:
|
|
171
|
+
table.action_cursor_down()
|
|
172
|
+
|
|
173
|
+
def action_nav_left(self) -> None:
|
|
174
|
+
panel = self._active_panel()
|
|
175
|
+
if panel.header_active:
|
|
176
|
+
panel.header_selected_index = max(0, panel.header_selected_index - 1)
|
|
177
|
+
panel.update_header_labels()
|
|
178
|
+
else:
|
|
179
|
+
table = panel.query_one(f"#{panel.side}-table", DataTable)
|
|
180
|
+
table.action_cursor_left()
|
|
181
|
+
|
|
182
|
+
def action_nav_right(self) -> None:
|
|
183
|
+
panel = self._active_panel()
|
|
184
|
+
if panel.header_active:
|
|
185
|
+
panel.header_selected_index = min(2, panel.header_selected_index + 1)
|
|
186
|
+
panel.update_header_labels()
|
|
187
|
+
else:
|
|
188
|
+
table = panel.query_one(f"#{panel.side}-table", DataTable)
|
|
189
|
+
table.action_cursor_right()
|
|
190
|
+
|
|
191
|
+
def on_data_table_header_selected(self, event: DataTable.HeaderSelected) -> None:
|
|
192
|
+
# Col 0: " ", Col 1: Name, Col 2: Size, Col 3: Modified (Date)
|
|
193
|
+
if event.column_index < 1:
|
|
194
|
+
return
|
|
195
|
+
col_names = {1: "Name", 2: "Size", 3: "Modified"}
|
|
196
|
+
col_name = col_names.get(event.column_index)
|
|
197
|
+
if not col_name:
|
|
198
|
+
return
|
|
199
|
+
panel = None
|
|
200
|
+
if isinstance(event.data_table.parent, FilePanel):
|
|
201
|
+
panel = event.data_table.parent
|
|
202
|
+
elif event.data_table.id and "left" in event.data_table.id:
|
|
203
|
+
panel = self._panel("left")
|
|
204
|
+
elif event.data_table.id and "right" in event.data_table.id:
|
|
205
|
+
panel = self._panel("right")
|
|
206
|
+
else:
|
|
207
|
+
panel = self._active_panel()
|
|
208
|
+
|
|
209
|
+
if self.active_side != panel.side:
|
|
210
|
+
active = self._active_panel()
|
|
211
|
+
if active.header_active:
|
|
212
|
+
active.header_active = False
|
|
213
|
+
active.update_header_labels()
|
|
214
|
+
self._set_active(panel.side)
|
|
215
|
+
|
|
216
|
+
panel.header_selected_index = event.column_index - 1
|
|
217
|
+
panel.toggle_sort(col_name)
|
|
138
218
|
|
|
139
219
|
def action_up_dir(self) -> None:
|
|
140
220
|
panel = self._active_panel()
|
|
221
|
+
if panel.header_active:
|
|
222
|
+
panel.header_active = False
|
|
223
|
+
panel.update_header_labels()
|
|
141
224
|
panel.path = os.path.dirname(panel.path)
|
|
142
225
|
panel.marked.clear()
|
|
143
226
|
panel.refresh_listing()
|
|
@@ -220,16 +303,33 @@ class MainScreen(Screen):
|
|
|
220
303
|
|
|
221
304
|
# ------------------------------------------------------------ updates
|
|
222
305
|
def action_update_app(self) -> None:
|
|
223
|
-
if self.app.update_info is None:
|
|
224
|
-
self.
|
|
306
|
+
if self.app.update_info is not None:
|
|
307
|
+
self.app.update_prompted = False
|
|
308
|
+
self.prompt_update()
|
|
225
309
|
return
|
|
226
|
-
|
|
227
|
-
self.
|
|
310
|
+
# Nothing known yet: ask PyPI right now instead of trusting the daily check.
|
|
311
|
+
self.notify("Checking for updates...")
|
|
312
|
+
self.run_worker(self._check_now, thread=True)
|
|
313
|
+
|
|
314
|
+
def _check_now(self) -> None:
|
|
315
|
+
info, reachable = updater.check_now()
|
|
316
|
+
self.app.call_from_thread(self._check_done, info, reachable)
|
|
317
|
+
|
|
318
|
+
def _check_done(self, info, reachable: bool) -> None:
|
|
319
|
+
if info is not None:
|
|
320
|
+
self.app.update_prompted = False
|
|
321
|
+
self.app.update_found(info)
|
|
322
|
+
elif not reachable:
|
|
323
|
+
self.notify("Could not reach PyPI. Check the connection and try again.", severity="warning")
|
|
324
|
+
else:
|
|
325
|
+
self.notify(f"CommanderTUI {__version__} is up to date.")
|
|
228
326
|
|
|
229
327
|
def prompt_update(self) -> None:
|
|
230
328
|
info = self.app.update_info
|
|
231
329
|
if info is None or self.app.update_prompted:
|
|
232
330
|
return
|
|
331
|
+
if self.app.screen is not self:
|
|
332
|
+
return # the boot animation (or a dialog) is on top: resume will call us again
|
|
233
333
|
self.app.update_prompted = True
|
|
234
334
|
|
|
235
335
|
def on_choice(choice: str | None) -> None:
|
|
@@ -337,6 +437,20 @@ class MainScreen(Screen):
|
|
|
337
437
|
panel.refresh_listing()
|
|
338
438
|
panel.move_cursor_to(name)
|
|
339
439
|
|
|
440
|
+
def action_preview_file(self) -> None:
|
|
441
|
+
if not preview.is_macos():
|
|
442
|
+
return
|
|
443
|
+
panel = self._active_panel()
|
|
444
|
+
name = panel.selected_name()
|
|
445
|
+
if not name or name == PARENT_MARK:
|
|
446
|
+
self.app.push_screen(MessageScreen("Preview works on files only"))
|
|
447
|
+
return
|
|
448
|
+
path = os.path.join(panel.path, name)
|
|
449
|
+
if os.path.isdir(path):
|
|
450
|
+
self.app.push_screen(MessageScreen("Preview works on files only"))
|
|
451
|
+
return
|
|
452
|
+
preview.toggle_preview(path)
|
|
453
|
+
|
|
340
454
|
def action_copy(self) -> None:
|
|
341
455
|
self._run_marked_op(OpKind.COPY, "Copy")
|
|
342
456
|
|
|
@@ -423,29 +537,41 @@ class MainScreen(Screen):
|
|
|
423
537
|
self.app.push_screen(MessageScreen("Network locations need `gio` (GVFS) installed."))
|
|
424
538
|
return
|
|
425
539
|
|
|
540
|
+
def on_user(host: str, user: str | None) -> None:
|
|
541
|
+
if not user or not user.strip():
|
|
542
|
+
return
|
|
543
|
+
self._pick_share(host, user=user.strip())
|
|
544
|
+
|
|
426
545
|
def on_host(scheme: str, host: str | None) -> None:
|
|
427
546
|
if not host or not host.strip():
|
|
428
547
|
return
|
|
548
|
+
host_clean = host.strip()
|
|
429
549
|
if scheme == "smb":
|
|
430
|
-
|
|
550
|
+
if netlocations.is_macos():
|
|
551
|
+
self.app.push_screen(InputScreen("SMB user:"), lambda u: on_user(host_clean, u))
|
|
552
|
+
else:
|
|
553
|
+
self._pick_share(host_clean)
|
|
431
554
|
else:
|
|
432
|
-
self._connect(netlocations.build_uri(scheme,
|
|
555
|
+
self._connect(netlocations.build_uri(scheme, host_clean), save=True)
|
|
433
556
|
|
|
434
557
|
def on_scheme(choice: str | None) -> None:
|
|
435
558
|
if not choice:
|
|
436
559
|
return
|
|
437
560
|
scheme = choice.lower()
|
|
561
|
+
if netlocations.is_macos() and scheme != "smb":
|
|
562
|
+
self.app.push_screen(MessageScreen(netlocations.MACOS_UNSUPPORTED_MSG))
|
|
563
|
+
return
|
|
438
564
|
self.app.push_screen(
|
|
439
565
|
InputScreen(f"{choice} server (IP or name):"), lambda host: on_host(scheme, host)
|
|
440
566
|
)
|
|
441
567
|
|
|
442
568
|
self.app.push_screen(ChoiceScreen("Protocol", ["SMB", "SFTP", "FTP"]), on_scheme)
|
|
443
569
|
|
|
444
|
-
def _pick_share(self, host: str, retried: bool = False) -> None:
|
|
570
|
+
def _pick_share(self, host: str, retried: bool = False, user: str | None = None) -> None:
|
|
445
571
|
self.notify(f"Listing shares on {host}...")
|
|
446
572
|
|
|
447
573
|
def work() -> None:
|
|
448
|
-
shares, err = netlocations.list_shares(host)
|
|
574
|
+
shares, err = netlocations.list_shares(host, user=user)
|
|
449
575
|
self.app.call_from_thread(done, shares, err)
|
|
450
576
|
|
|
451
577
|
def done(shares: list[str] | None, err: str) -> None:
|
|
@@ -453,23 +579,30 @@ class MainScreen(Screen):
|
|
|
453
579
|
if retried:
|
|
454
580
|
self.app.push_screen(MessageScreen(f"Could not list shares on {host}.\n{err}"))
|
|
455
581
|
return
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
582
|
+
if netlocations.is_macos():
|
|
583
|
+
netlocations.mount(netlocations.build_uri("smb", host, user=user), interactive=True)
|
|
584
|
+
else:
|
|
585
|
+
# Most likely a login is needed: let gio ask for it on the real
|
|
586
|
+
# terminal (and offer to remember it in the keyring).
|
|
587
|
+
with self.app.suspend():
|
|
588
|
+
netlocations.mount(netlocations.build_uri("smb", host), interactive=True)
|
|
589
|
+
self._pick_share(host, retried=True, user=user)
|
|
461
590
|
return
|
|
462
591
|
if not shares:
|
|
463
592
|
self.app.push_screen(MessageScreen(f"No shares found on {host}."))
|
|
464
593
|
return
|
|
465
594
|
self.app.push_screen(
|
|
466
595
|
ChoiceScreen(f"Shares on {host}", shares),
|
|
467
|
-
lambda share: share
|
|
596
|
+
lambda share: share
|
|
597
|
+
and self._connect(netlocations.build_uri("smb", host, share, user=user), save=True),
|
|
468
598
|
)
|
|
469
599
|
|
|
470
600
|
self.run_worker(work, thread=True)
|
|
471
601
|
|
|
472
602
|
def _connect(self, uri: str, save: bool = False, retried: bool = False) -> None:
|
|
603
|
+
if netlocations.is_macos() and not uri.lower().startswith("smb://"):
|
|
604
|
+
self.app.push_screen(MessageScreen(netlocations.MACOS_UNSUPPORTED_MSG))
|
|
605
|
+
return
|
|
473
606
|
self.notify(f"Connecting to {uri} ...")
|
|
474
607
|
|
|
475
608
|
def work() -> None:
|
|
@@ -483,7 +616,7 @@ class MainScreen(Screen):
|
|
|
483
616
|
netlocations.save_location(uri)
|
|
484
617
|
self._go_to(path)
|
|
485
618
|
return
|
|
486
|
-
if retried:
|
|
619
|
+
if retried or netlocations.is_macos():
|
|
487
620
|
self.app.push_screen(MessageScreen(f"Could not connect to {uri}.\n{err}"))
|
|
488
621
|
return
|
|
489
622
|
with self.app.suspend():
|