commandertui 1.3.1__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.1 → commandertui-1.4.0}/PKG-INFO +11 -9
- {commandertui-1.3.1 → commandertui-1.4.0}/README.md +9 -8
- commandertui-1.4.0/commandertui/__init__.py +1 -0
- {commandertui-1.3.1 → commandertui-1.4.0}/commandertui/app.py +2 -1
- {commandertui-1.3.1 → commandertui-1.4.0}/commandertui/main_screen.py +129 -13
- {commandertui-1.3.1 → 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.1 → commandertui-1.4.0}/commandertui/settings.py +22 -0
- {commandertui-1.3.1 → commandertui-1.4.0}/commandertui/trash.py +10 -3
- {commandertui-1.3.1 → commandertui-1.4.0}/commandertui/widgets.py +115 -5
- {commandertui-1.3.1 → commandertui-1.4.0}/commandertui.egg-info/PKG-INFO +11 -9
- {commandertui-1.3.1 → commandertui-1.4.0}/commandertui.egg-info/SOURCES.txt +2 -0
- {commandertui-1.3.1 → commandertui-1.4.0}/pyproject.toml +1 -0
- {commandertui-1.3.1 → commandertui-1.4.0}/tests/test_app_smoke.py +124 -2
- commandertui-1.4.0/tests/test_netlocations.py +246 -0
- commandertui-1.4.0/tests/test_preview.py +305 -0
- commandertui-1.4.0/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.0}/LICENSE +0 -0
- {commandertui-1.3.1 → commandertui-1.4.0}/commandertui/ascii_font.py +0 -0
- {commandertui-1.3.1 → commandertui-1.4.0}/commandertui/bookmarks.py +0 -0
- {commandertui-1.3.1 → commandertui-1.4.0}/commandertui/boot.py +0 -0
- {commandertui-1.3.1 → commandertui-1.4.0}/commandertui/cli.py +0 -0
- {commandertui-1.3.1 → commandertui-1.4.0}/commandertui/compare_screen.py +0 -0
- {commandertui-1.3.1 → commandertui-1.4.0}/commandertui/diff.py +0 -0
- {commandertui-1.3.1 → commandertui-1.4.0}/commandertui/editor.py +0 -0
- {commandertui-1.3.1 → commandertui-1.4.0}/commandertui/executor.py +0 -0
- {commandertui-1.3.1 → commandertui-1.4.0}/commandertui/models.py +0 -0
- {commandertui-1.3.1 → commandertui-1.4.0}/commandertui/plan.py +0 -0
- {commandertui-1.3.1 → commandertui-1.4.0}/commandertui/progress_screen.py +0 -0
- {commandertui-1.3.1 → commandertui-1.4.0}/commandertui/run_queue.py +0 -0
- {commandertui-1.3.1 → commandertui-1.4.0}/commandertui/scanner.py +0 -0
- {commandertui-1.3.1 → commandertui-1.4.0}/commandertui/sync.py +0 -0
- {commandertui-1.3.1 → commandertui-1.4.0}/commandertui/theme.py +0 -0
- {commandertui-1.3.1 → commandertui-1.4.0}/commandertui/updater.py +0 -0
- {commandertui-1.3.1 → commandertui-1.4.0}/commandertui.egg-info/dependency_links.txt +0 -0
- {commandertui-1.3.1 → commandertui-1.4.0}/commandertui.egg-info/entry_points.txt +0 -0
- {commandertui-1.3.1 → commandertui-1.4.0}/commandertui.egg-info/requires.txt +0 -0
- {commandertui-1.3.1 → commandertui-1.4.0}/commandertui.egg-info/top_level.txt +0 -0
- {commandertui-1.3.1 → commandertui-1.4.0}/setup.cfg +0 -0
- {commandertui-1.3.1 → commandertui-1.4.0}/tests/test_bookmarks.py +0 -0
- {commandertui-1.3.1 → commandertui-1.4.0}/tests/test_diff.py +0 -0
- {commandertui-1.3.1 → commandertui-1.4.0}/tests/test_executor.py +0 -0
- {commandertui-1.3.1 → commandertui-1.4.0}/tests/test_plan.py +0 -0
- {commandertui-1.3.1 → commandertui-1.4.0}/tests/test_scanner.py +0 -0
- {commandertui-1.3.1 → commandertui-1.4.0}/tests/test_sync.py +0 -0
- {commandertui-1.3.1 → commandertui-1.4.0}/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.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
|
|
|
@@ -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,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).
|
|
@@ -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,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).
|
|
@@ -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
|
|
|
@@ -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()
|
|
@@ -354,6 +437,20 @@ class MainScreen(Screen):
|
|
|
354
437
|
panel.refresh_listing()
|
|
355
438
|
panel.move_cursor_to(name)
|
|
356
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
|
+
|
|
357
454
|
def action_copy(self) -> None:
|
|
358
455
|
self._run_marked_op(OpKind.COPY, "Copy")
|
|
359
456
|
|
|
@@ -440,29 +537,41 @@ class MainScreen(Screen):
|
|
|
440
537
|
self.app.push_screen(MessageScreen("Network locations need `gio` (GVFS) installed."))
|
|
441
538
|
return
|
|
442
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
|
+
|
|
443
545
|
def on_host(scheme: str, host: str | None) -> None:
|
|
444
546
|
if not host or not host.strip():
|
|
445
547
|
return
|
|
548
|
+
host_clean = host.strip()
|
|
446
549
|
if scheme == "smb":
|
|
447
|
-
|
|
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)
|
|
448
554
|
else:
|
|
449
|
-
self._connect(netlocations.build_uri(scheme,
|
|
555
|
+
self._connect(netlocations.build_uri(scheme, host_clean), save=True)
|
|
450
556
|
|
|
451
557
|
def on_scheme(choice: str | None) -> None:
|
|
452
558
|
if not choice:
|
|
453
559
|
return
|
|
454
560
|
scheme = choice.lower()
|
|
561
|
+
if netlocations.is_macos() and scheme != "smb":
|
|
562
|
+
self.app.push_screen(MessageScreen(netlocations.MACOS_UNSUPPORTED_MSG))
|
|
563
|
+
return
|
|
455
564
|
self.app.push_screen(
|
|
456
565
|
InputScreen(f"{choice} server (IP or name):"), lambda host: on_host(scheme, host)
|
|
457
566
|
)
|
|
458
567
|
|
|
459
568
|
self.app.push_screen(ChoiceScreen("Protocol", ["SMB", "SFTP", "FTP"]), on_scheme)
|
|
460
569
|
|
|
461
|
-
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:
|
|
462
571
|
self.notify(f"Listing shares on {host}...")
|
|
463
572
|
|
|
464
573
|
def work() -> None:
|
|
465
|
-
shares, err = netlocations.list_shares(host)
|
|
574
|
+
shares, err = netlocations.list_shares(host, user=user)
|
|
466
575
|
self.app.call_from_thread(done, shares, err)
|
|
467
576
|
|
|
468
577
|
def done(shares: list[str] | None, err: str) -> None:
|
|
@@ -470,23 +579,30 @@ class MainScreen(Screen):
|
|
|
470
579
|
if retried:
|
|
471
580
|
self.app.push_screen(MessageScreen(f"Could not list shares on {host}.\n{err}"))
|
|
472
581
|
return
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
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)
|
|
478
590
|
return
|
|
479
591
|
if not shares:
|
|
480
592
|
self.app.push_screen(MessageScreen(f"No shares found on {host}."))
|
|
481
593
|
return
|
|
482
594
|
self.app.push_screen(
|
|
483
595
|
ChoiceScreen(f"Shares on {host}", shares),
|
|
484
|
-
lambda share: share
|
|
596
|
+
lambda share: share
|
|
597
|
+
and self._connect(netlocations.build_uri("smb", host, share, user=user), save=True),
|
|
485
598
|
)
|
|
486
599
|
|
|
487
600
|
self.run_worker(work, thread=True)
|
|
488
601
|
|
|
489
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
|
|
490
606
|
self.notify(f"Connecting to {uri} ...")
|
|
491
607
|
|
|
492
608
|
def work() -> None:
|
|
@@ -500,7 +616,7 @@ class MainScreen(Screen):
|
|
|
500
616
|
netlocations.save_location(uri)
|
|
501
617
|
self._go_to(path)
|
|
502
618
|
return
|
|
503
|
-
if retried:
|
|
619
|
+
if retried or netlocations.is_macos():
|
|
504
620
|
self.app.push_screen(MessageScreen(f"Could not connect to {uri}.\n{err}"))
|
|
505
621
|
return
|
|
506
622
|
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))
|