commandertui 1.3.1__tar.gz → 1.4.1__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.1 → commandertui-1.4.1}/PKG-INFO +12 -9
- {commandertui-1.3.1 → commandertui-1.4.1}/README.md +10 -8
- commandertui-1.4.1/commandertui/__init__.py +1 -0
- {commandertui-1.3.1 → commandertui-1.4.1}/commandertui/app.py +2 -1
- {commandertui-1.3.1 → commandertui-1.4.1}/commandertui/main_screen.py +172 -17
- {commandertui-1.3.1 → commandertui-1.4.1}/commandertui/modals.py +46 -5
- commandertui-1.4.1/commandertui/netlocations.py +429 -0
- commandertui-1.4.1/commandertui/preview.py +122 -0
- {commandertui-1.3.1 → commandertui-1.4.1}/commandertui/settings.py +22 -0
- {commandertui-1.3.1 → commandertui-1.4.1}/commandertui/trash.py +10 -3
- {commandertui-1.3.1 → commandertui-1.4.1}/commandertui/widgets.py +115 -5
- {commandertui-1.3.1 → commandertui-1.4.1}/commandertui.egg-info/PKG-INFO +12 -9
- {commandertui-1.3.1 → commandertui-1.4.1}/commandertui.egg-info/SOURCES.txt +2 -0
- {commandertui-1.3.1 → commandertui-1.4.1}/pyproject.toml +1 -0
- {commandertui-1.3.1 → commandertui-1.4.1}/tests/test_app_smoke.py +187 -2
- commandertui-1.4.1/tests/test_netlocations.py +288 -0
- commandertui-1.4.1/tests/test_preview.py +305 -0
- commandertui-1.4.1/tests/test_widgets.py +200 -0
- commandertui-1.3.1/commandertui/__init__.py +0 -1
- commandertui-1.3.1/commandertui/netlocations.py +0 -118
- commandertui-1.3.1/tests/test_netlocations.py +0 -51
- commandertui-1.3.1/tests/test_widgets.py +0 -49
- {commandertui-1.3.1 → commandertui-1.4.1}/LICENSE +0 -0
- {commandertui-1.3.1 → commandertui-1.4.1}/commandertui/ascii_font.py +0 -0
- {commandertui-1.3.1 → commandertui-1.4.1}/commandertui/bookmarks.py +0 -0
- {commandertui-1.3.1 → commandertui-1.4.1}/commandertui/boot.py +0 -0
- {commandertui-1.3.1 → commandertui-1.4.1}/commandertui/cli.py +0 -0
- {commandertui-1.3.1 → commandertui-1.4.1}/commandertui/compare_screen.py +0 -0
- {commandertui-1.3.1 → commandertui-1.4.1}/commandertui/diff.py +0 -0
- {commandertui-1.3.1 → commandertui-1.4.1}/commandertui/editor.py +0 -0
- {commandertui-1.3.1 → commandertui-1.4.1}/commandertui/executor.py +0 -0
- {commandertui-1.3.1 → commandertui-1.4.1}/commandertui/models.py +0 -0
- {commandertui-1.3.1 → commandertui-1.4.1}/commandertui/plan.py +0 -0
- {commandertui-1.3.1 → commandertui-1.4.1}/commandertui/progress_screen.py +0 -0
- {commandertui-1.3.1 → commandertui-1.4.1}/commandertui/run_queue.py +0 -0
- {commandertui-1.3.1 → commandertui-1.4.1}/commandertui/scanner.py +0 -0
- {commandertui-1.3.1 → commandertui-1.4.1}/commandertui/sync.py +0 -0
- {commandertui-1.3.1 → commandertui-1.4.1}/commandertui/theme.py +0 -0
- {commandertui-1.3.1 → commandertui-1.4.1}/commandertui/updater.py +0 -0
- {commandertui-1.3.1 → commandertui-1.4.1}/commandertui.egg-info/dependency_links.txt +0 -0
- {commandertui-1.3.1 → commandertui-1.4.1}/commandertui.egg-info/entry_points.txt +0 -0
- {commandertui-1.3.1 → commandertui-1.4.1}/commandertui.egg-info/requires.txt +0 -0
- {commandertui-1.3.1 → commandertui-1.4.1}/commandertui.egg-info/top_level.txt +0 -0
- {commandertui-1.3.1 → commandertui-1.4.1}/setup.cfg +0 -0
- {commandertui-1.3.1 → commandertui-1.4.1}/tests/test_bookmarks.py +0 -0
- {commandertui-1.3.1 → commandertui-1.4.1}/tests/test_diff.py +0 -0
- {commandertui-1.3.1 → commandertui-1.4.1}/tests/test_executor.py +0 -0
- {commandertui-1.3.1 → commandertui-1.4.1}/tests/test_plan.py +0 -0
- {commandertui-1.3.1 → commandertui-1.4.1}/tests/test_scanner.py +0 -0
- {commandertui-1.3.1 → commandertui-1.4.1}/tests/test_sync.py +0 -0
- {commandertui-1.3.1 → commandertui-1.4.1}/tests/test_updater.py +0 -0
|
@@ -1,11 +1,12 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: commandertui
|
|
3
|
-
Version: 1.
|
|
3
|
+
Version: 1.4.1
|
|
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
|
|
|
@@ -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) |
|
|
@@ -135,10 +136,11 @@ o.window("^commandertui$", { float = true, center = true, size = { 1174, 637 } }
|
|
|
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
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,14 @@ 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).
|
|
165
|
+
If the GVFS backend for the chosen protocol is not installed (a bare Ubuntu server, for example, has none), the app says so and offers to install it for you (`apt-get install gvfs-backends`, `pacman -S gvfs gvfs-smb` or `dnf install gvfs-smb`); it asks for your sudo password in the terminal and only runs if you answer *Install now*.
|
|
163
166
|
|
|
164
167
|
Every key also has a clickable box on the bottom bar: one row on wide windows
|
|
165
168
|
(168+ columns), two rows when narrower (120 columns is the practical minimum).
|
|
@@ -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
|
|
|
@@ -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) |
|
|
@@ -117,10 +117,11 @@ o.window("^commandertui$", { float = true, center = true, size = { 1174, 637 } }
|
|
|
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
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,14 @@ 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).
|
|
146
|
+
If the GVFS backend for the chosen protocol is not installed (a bare Ubuntu server, for example, has none), the app says so and offers to install it for you (`apt-get install gvfs-backends`, `pacman -S gvfs gvfs-smb` or `dnf install gvfs-smb`); it asks for your sudo password in the terminal and only runs if you answer *Install now*.
|
|
145
147
|
|
|
146
148
|
Every key also has a clickable box on the bottom bar: one row on wide windows
|
|
147
149
|
(168+ columns), two rows when narrower (120 columns is the practical minimum).
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
__version__ = "1.4.1"
|
|
@@ -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
|
|
|
@@ -34,6 +34,7 @@ class CommanderApp(App):
|
|
|
34
34
|
self.right_path = os.path.abspath(right_path or os.path.expanduser("~"))
|
|
35
35
|
|
|
36
36
|
def on_unmount(self) -> None:
|
|
37
|
+
preview.close_preview()
|
|
37
38
|
# Whatever theme is showing when the app closes is the one to reopen
|
|
38
39
|
# with -- covers the picker (t) and Textual's own palette (ctrl+p).
|
|
39
40
|
settings.save_theme(self.theme)
|
|
@@ -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 __version__, 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()
|
|
@@ -282,7 +365,10 @@ class MainScreen(Screen):
|
|
|
282
365
|
|
|
283
366
|
self.app.push_screen(
|
|
284
367
|
ChoiceScreen(
|
|
285
|
-
"Color theme",
|
|
368
|
+
"Color theme",
|
|
369
|
+
labels,
|
|
370
|
+
initial=initial,
|
|
371
|
+
on_highlight=lambda i: self.app.preview_theme(names[i]),
|
|
286
372
|
),
|
|
287
373
|
on_choice,
|
|
288
374
|
)
|
|
@@ -338,9 +424,7 @@ class MainScreen(Screen):
|
|
|
338
424
|
return
|
|
339
425
|
command = editor.editor_command()
|
|
340
426
|
if command is None:
|
|
341
|
-
self.app.push_screen(
|
|
342
|
-
MessageScreen("No text editor found.\nSet $EDITOR or install nano.")
|
|
343
|
-
)
|
|
427
|
+
self.app.push_screen(MessageScreen("No text editor found.\nSet $EDITOR or install nano."))
|
|
344
428
|
return
|
|
345
429
|
with self.app.suspend():
|
|
346
430
|
try:
|
|
@@ -354,6 +438,20 @@ class MainScreen(Screen):
|
|
|
354
438
|
panel.refresh_listing()
|
|
355
439
|
panel.move_cursor_to(name)
|
|
356
440
|
|
|
441
|
+
def action_preview_file(self) -> None:
|
|
442
|
+
if not preview.is_macos():
|
|
443
|
+
return
|
|
444
|
+
panel = self._active_panel()
|
|
445
|
+
name = panel.selected_name()
|
|
446
|
+
if not name or name == PARENT_MARK:
|
|
447
|
+
self.app.push_screen(MessageScreen("Preview works on files only"))
|
|
448
|
+
return
|
|
449
|
+
path = os.path.join(panel.path, name)
|
|
450
|
+
if os.path.isdir(path):
|
|
451
|
+
self.app.push_screen(MessageScreen("Preview works on files only"))
|
|
452
|
+
return
|
|
453
|
+
preview.toggle_preview(path)
|
|
454
|
+
|
|
357
455
|
def action_copy(self) -> None:
|
|
358
456
|
self._run_marked_op(OpKind.COPY, "Copy")
|
|
359
457
|
|
|
@@ -440,29 +538,79 @@ class MainScreen(Screen):
|
|
|
440
538
|
self.app.push_screen(MessageScreen("Network locations need `gio` (GVFS) installed."))
|
|
441
539
|
return
|
|
442
540
|
|
|
541
|
+
def on_user(host: str, user: str | None) -> None:
|
|
542
|
+
if not user or not user.strip():
|
|
543
|
+
return
|
|
544
|
+
self._pick_share(host, user=user.strip())
|
|
545
|
+
|
|
443
546
|
def on_host(scheme: str, host: str | None) -> None:
|
|
444
547
|
if not host or not host.strip():
|
|
445
548
|
return
|
|
549
|
+
host_clean = host.strip()
|
|
446
550
|
if scheme == "smb":
|
|
447
|
-
|
|
551
|
+
if netlocations.is_macos():
|
|
552
|
+
self.app.push_screen(InputScreen("SMB user:"), lambda u: on_user(host_clean, u))
|
|
553
|
+
else:
|
|
554
|
+
self._pick_share(host_clean)
|
|
448
555
|
else:
|
|
449
|
-
self._connect(netlocations.build_uri(scheme,
|
|
556
|
+
self._connect(netlocations.build_uri(scheme, host_clean), save=True)
|
|
450
557
|
|
|
451
558
|
def on_scheme(choice: str | None) -> None:
|
|
452
559
|
if not choice:
|
|
453
560
|
return
|
|
454
561
|
scheme = choice.lower()
|
|
562
|
+
if netlocations.is_macos() and scheme != "smb":
|
|
563
|
+
self.app.push_screen(MessageScreen(netlocations.MACOS_UNSUPPORTED_MSG))
|
|
564
|
+
return
|
|
565
|
+
if netlocations.backend_missing(scheme):
|
|
566
|
+
self._offer_backend_install(scheme, choice)
|
|
567
|
+
return
|
|
455
568
|
self.app.push_screen(
|
|
456
569
|
InputScreen(f"{choice} server (IP or name):"), lambda host: on_host(scheme, host)
|
|
457
570
|
)
|
|
458
571
|
|
|
459
572
|
self.app.push_screen(ChoiceScreen("Protocol", ["SMB", "SFTP", "FTP"]), on_scheme)
|
|
460
573
|
|
|
461
|
-
def
|
|
574
|
+
def _offer_backend_install(self, scheme: str, label: str) -> None:
|
|
575
|
+
command = netlocations.backend_install_command()
|
|
576
|
+
if command is None:
|
|
577
|
+
self.app.push_screen(
|
|
578
|
+
MessageScreen(
|
|
579
|
+
f"{label} needs the GVFS {scheme} backend, which is not installed.\nInstall your system's GVFS backends package and try again."
|
|
580
|
+
)
|
|
581
|
+
)
|
|
582
|
+
return
|
|
583
|
+
text = " ".join(command)
|
|
584
|
+
|
|
585
|
+
def on_choice(choice: str | None) -> None:
|
|
586
|
+
if choice != "Install now":
|
|
587
|
+
return
|
|
588
|
+
with self.app.suspend():
|
|
589
|
+
ok = netlocations.install_backend()
|
|
590
|
+
if ok:
|
|
591
|
+
self.app.push_screen(
|
|
592
|
+
MessageScreen(
|
|
593
|
+
"GVFS backends installed.\nQuit and reopen CommanderTUI, then add the location again."
|
|
594
|
+
)
|
|
595
|
+
)
|
|
596
|
+
else:
|
|
597
|
+
self.app.push_screen(
|
|
598
|
+
MessageScreen("The install did not finish. Check the terminal output, then try again.")
|
|
599
|
+
)
|
|
600
|
+
|
|
601
|
+
self.app.push_screen(
|
|
602
|
+
ChoiceScreen(
|
|
603
|
+
f"{label} needs the GVFS backend, which is missing. Install it now? ({text}; asks for your sudo password)",
|
|
604
|
+
["Install now", "Cancel"],
|
|
605
|
+
),
|
|
606
|
+
on_choice,
|
|
607
|
+
)
|
|
608
|
+
|
|
609
|
+
def _pick_share(self, host: str, retried: bool = False, user: str | None = None) -> None:
|
|
462
610
|
self.notify(f"Listing shares on {host}...")
|
|
463
611
|
|
|
464
612
|
def work() -> None:
|
|
465
|
-
shares, err = netlocations.list_shares(host)
|
|
613
|
+
shares, err = netlocations.list_shares(host, user=user)
|
|
466
614
|
self.app.call_from_thread(done, shares, err)
|
|
467
615
|
|
|
468
616
|
def done(shares: list[str] | None, err: str) -> None:
|
|
@@ -470,23 +618,30 @@ class MainScreen(Screen):
|
|
|
470
618
|
if retried:
|
|
471
619
|
self.app.push_screen(MessageScreen(f"Could not list shares on {host}.\n{err}"))
|
|
472
620
|
return
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
621
|
+
if netlocations.is_macos():
|
|
622
|
+
netlocations.mount(netlocations.build_uri("smb", host, user=user), interactive=True)
|
|
623
|
+
else:
|
|
624
|
+
# Most likely a login is needed: let gio ask for it on the real
|
|
625
|
+
# terminal (and offer to remember it in the keyring).
|
|
626
|
+
with self.app.suspend():
|
|
627
|
+
netlocations.mount(netlocations.build_uri("smb", host), interactive=True)
|
|
628
|
+
self._pick_share(host, retried=True, user=user)
|
|
478
629
|
return
|
|
479
630
|
if not shares:
|
|
480
631
|
self.app.push_screen(MessageScreen(f"No shares found on {host}."))
|
|
481
632
|
return
|
|
482
633
|
self.app.push_screen(
|
|
483
634
|
ChoiceScreen(f"Shares on {host}", shares),
|
|
484
|
-
lambda share: share
|
|
635
|
+
lambda share: share
|
|
636
|
+
and self._connect(netlocations.build_uri("smb", host, share, user=user), save=True),
|
|
485
637
|
)
|
|
486
638
|
|
|
487
639
|
self.run_worker(work, thread=True)
|
|
488
640
|
|
|
489
641
|
def _connect(self, uri: str, save: bool = False, retried: bool = False) -> None:
|
|
642
|
+
if netlocations.is_macos() and not uri.lower().startswith("smb://"):
|
|
643
|
+
self.app.push_screen(MessageScreen(netlocations.MACOS_UNSUPPORTED_MSG))
|
|
644
|
+
return
|
|
490
645
|
self.notify(f"Connecting to {uri} ...")
|
|
491
646
|
|
|
492
647
|
def work() -> None:
|
|
@@ -500,7 +655,7 @@ class MainScreen(Screen):
|
|
|
500
655
|
netlocations.save_location(uri)
|
|
501
656
|
self._go_to(path)
|
|
502
657
|
return
|
|
503
|
-
if retried:
|
|
658
|
+
if retried or netlocations.is_macos():
|
|
504
659
|
self.app.push_screen(MessageScreen(f"Could not connect to {uri}.\n{err}"))
|
|
505
660
|
return
|
|
506
661
|
with self.app.suspend():
|
|
@@ -13,7 +13,7 @@ from textual.widgets import Button, DataTable, Input, OptionList, Static
|
|
|
13
13
|
|
|
14
14
|
from .bookmarks import Place, all_places, remove_bookmark, save_bookmark
|
|
15
15
|
from .models import OpKind, OperationQueue
|
|
16
|
-
from .netlocations import remove_location
|
|
16
|
+
from .netlocations import is_smb_mount_point, local_path, remove_location, unmount
|
|
17
17
|
from .scanner import tree_stats
|
|
18
18
|
|
|
19
19
|
|
|
@@ -97,7 +97,7 @@ class ConfirmScreen(ModalScreen[bool]):
|
|
|
97
97
|
yield Static(
|
|
98
98
|
id="confirm-summary",
|
|
99
99
|
content=f"{summary['count']} operations ({by_kind or 'nothing to do'}), "
|
|
100
|
-
f"{_human_bytes(summary['total_bytes'])} total"
|
|
100
|
+
f"{_human_bytes(summary['total_bytes'])} total",
|
|
101
101
|
)
|
|
102
102
|
yield Static("\n".join(f" {name}" for name in listed), id="confirm-items", markup=False)
|
|
103
103
|
with Horizontal(id="confirm-buttons"):
|
|
@@ -159,6 +159,7 @@ class PlacesScreen(ModalScreen[str | None]):
|
|
|
159
159
|
Binding("a", "add_network", "Add network", priority=True),
|
|
160
160
|
Binding("s", "save_here", "Save here", priority=True),
|
|
161
161
|
Binding("d", "delete_saved", "Delete saved", priority=True),
|
|
162
|
+
Binding("e", "unmount_share", "Unmount share", priority=True),
|
|
162
163
|
Binding("escape", "cancel", "Cancel", priority=True),
|
|
163
164
|
]
|
|
164
165
|
|
|
@@ -176,6 +177,7 @@ class PlacesScreen(ModalScreen[str | None]):
|
|
|
176
177
|
yield Button("Add network (a)", id="add")
|
|
177
178
|
yield Button("Save here (s)", id="save")
|
|
178
179
|
yield Button("Delete saved (d)", id="delete")
|
|
180
|
+
yield Button("Unmount share (e)", id="unmount")
|
|
179
181
|
yield Button("Close (Esc)", id="close")
|
|
180
182
|
|
|
181
183
|
def on_mount(self) -> None:
|
|
@@ -183,10 +185,13 @@ class PlacesScreen(ModalScreen[str | None]):
|
|
|
183
185
|
|
|
184
186
|
def _fill(self) -> None:
|
|
185
187
|
table = self.query_one("#places-table", DataTable)
|
|
188
|
+
current_row = table.cursor_row
|
|
186
189
|
table.clear()
|
|
187
190
|
self.places: list[Place] = all_places()
|
|
188
191
|
for place in self.places:
|
|
189
192
|
table.add_row(place.kind, place.label, place.path, key=place.path)
|
|
193
|
+
if self.places:
|
|
194
|
+
table.move_cursor(row=min(current_row, len(self.places) - 1))
|
|
190
195
|
|
|
191
196
|
def action_add_network(self) -> None:
|
|
192
197
|
self.dismiss(ADD_NETWORK)
|
|
@@ -220,14 +225,50 @@ class PlacesScreen(ModalScreen[str | None]):
|
|
|
220
225
|
return
|
|
221
226
|
self._fill()
|
|
222
227
|
|
|
228
|
+
def _evict_panels_from(self, mounted_path: str) -> None:
|
|
229
|
+
home = os.path.expanduser("~")
|
|
230
|
+
prefix = mounted_path.rstrip(os.sep) + os.sep
|
|
231
|
+
for side in ("left", "right"):
|
|
232
|
+
try:
|
|
233
|
+
panel = self.app._panel(side)
|
|
234
|
+
except Exception:
|
|
235
|
+
continue
|
|
236
|
+
if panel.path == mounted_path or panel.path.startswith(prefix):
|
|
237
|
+
panel.path = home
|
|
238
|
+
panel.marked.clear()
|
|
239
|
+
panel.refresh_listing()
|
|
240
|
+
|
|
241
|
+
def action_unmount_share(self) -> None:
|
|
242
|
+
table = self.query_one("#places-table", DataTable)
|
|
243
|
+
if not self.places or table.cursor_row >= len(self.places):
|
|
244
|
+
return
|
|
245
|
+
place = self.places[table.cursor_row]
|
|
246
|
+
mounted: str | None = None
|
|
247
|
+
if place.kind == "mounted" and is_smb_mount_point(place.path):
|
|
248
|
+
mounted = place.path
|
|
249
|
+
elif (place.kind == "net-saved" and place.path.lower().startswith("smb://")) or (
|
|
250
|
+
place.kind == "network" and "smb" in os.path.basename(place.path).lower()
|
|
251
|
+
):
|
|
252
|
+
mounted = local_path(place.path)
|
|
253
|
+
|
|
254
|
+
if not mounted:
|
|
255
|
+
self.app.push_screen(MessageScreen("Not a mounted network location"))
|
|
256
|
+
return
|
|
257
|
+
self._evict_panels_from(mounted)
|
|
258
|
+
ok, err = unmount(place.path)
|
|
259
|
+
if not ok:
|
|
260
|
+
self.app.push_screen(MessageScreen(f"Could not unmount {place.label}.\n{err}"))
|
|
261
|
+
return
|
|
262
|
+
self.notify(f"Unmounted {place.label}.")
|
|
263
|
+
self._fill()
|
|
264
|
+
|
|
223
265
|
def on_button_pressed(self, event: Button.Pressed) -> None:
|
|
224
266
|
{
|
|
225
267
|
"add": self.action_add_network,
|
|
226
268
|
"save": self.action_save_here,
|
|
227
269
|
"delete": self.action_delete_saved,
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
)()
|
|
270
|
+
"unmount": self.action_unmount_share,
|
|
271
|
+
}.get(event.button.id, self.action_cancel)()
|
|
231
272
|
|
|
232
273
|
def on_data_table_row_selected(self, event: DataTable.RowSelected) -> None:
|
|
233
274
|
self.dismiss(str(event.row_key.value))
|