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.
Files changed (51) hide show
  1. {commandertui-1.3.1 → commandertui-1.4.1}/PKG-INFO +12 -9
  2. {commandertui-1.3.1 → commandertui-1.4.1}/README.md +10 -8
  3. commandertui-1.4.1/commandertui/__init__.py +1 -0
  4. {commandertui-1.3.1 → commandertui-1.4.1}/commandertui/app.py +2 -1
  5. {commandertui-1.3.1 → commandertui-1.4.1}/commandertui/main_screen.py +172 -17
  6. {commandertui-1.3.1 → commandertui-1.4.1}/commandertui/modals.py +46 -5
  7. commandertui-1.4.1/commandertui/netlocations.py +429 -0
  8. commandertui-1.4.1/commandertui/preview.py +122 -0
  9. {commandertui-1.3.1 → commandertui-1.4.1}/commandertui/settings.py +22 -0
  10. {commandertui-1.3.1 → commandertui-1.4.1}/commandertui/trash.py +10 -3
  11. {commandertui-1.3.1 → commandertui-1.4.1}/commandertui/widgets.py +115 -5
  12. {commandertui-1.3.1 → commandertui-1.4.1}/commandertui.egg-info/PKG-INFO +12 -9
  13. {commandertui-1.3.1 → commandertui-1.4.1}/commandertui.egg-info/SOURCES.txt +2 -0
  14. {commandertui-1.3.1 → commandertui-1.4.1}/pyproject.toml +1 -0
  15. {commandertui-1.3.1 → commandertui-1.4.1}/tests/test_app_smoke.py +187 -2
  16. commandertui-1.4.1/tests/test_netlocations.py +288 -0
  17. commandertui-1.4.1/tests/test_preview.py +305 -0
  18. commandertui-1.4.1/tests/test_widgets.py +200 -0
  19. commandertui-1.3.1/commandertui/__init__.py +0 -1
  20. commandertui-1.3.1/commandertui/netlocations.py +0 -118
  21. commandertui-1.3.1/tests/test_netlocations.py +0 -51
  22. commandertui-1.3.1/tests/test_widgets.py +0 -49
  23. {commandertui-1.3.1 → commandertui-1.4.1}/LICENSE +0 -0
  24. {commandertui-1.3.1 → commandertui-1.4.1}/commandertui/ascii_font.py +0 -0
  25. {commandertui-1.3.1 → commandertui-1.4.1}/commandertui/bookmarks.py +0 -0
  26. {commandertui-1.3.1 → commandertui-1.4.1}/commandertui/boot.py +0 -0
  27. {commandertui-1.3.1 → commandertui-1.4.1}/commandertui/cli.py +0 -0
  28. {commandertui-1.3.1 → commandertui-1.4.1}/commandertui/compare_screen.py +0 -0
  29. {commandertui-1.3.1 → commandertui-1.4.1}/commandertui/diff.py +0 -0
  30. {commandertui-1.3.1 → commandertui-1.4.1}/commandertui/editor.py +0 -0
  31. {commandertui-1.3.1 → commandertui-1.4.1}/commandertui/executor.py +0 -0
  32. {commandertui-1.3.1 → commandertui-1.4.1}/commandertui/models.py +0 -0
  33. {commandertui-1.3.1 → commandertui-1.4.1}/commandertui/plan.py +0 -0
  34. {commandertui-1.3.1 → commandertui-1.4.1}/commandertui/progress_screen.py +0 -0
  35. {commandertui-1.3.1 → commandertui-1.4.1}/commandertui/run_queue.py +0 -0
  36. {commandertui-1.3.1 → commandertui-1.4.1}/commandertui/scanner.py +0 -0
  37. {commandertui-1.3.1 → commandertui-1.4.1}/commandertui/sync.py +0 -0
  38. {commandertui-1.3.1 → commandertui-1.4.1}/commandertui/theme.py +0 -0
  39. {commandertui-1.3.1 → commandertui-1.4.1}/commandertui/updater.py +0 -0
  40. {commandertui-1.3.1 → commandertui-1.4.1}/commandertui.egg-info/dependency_links.txt +0 -0
  41. {commandertui-1.3.1 → commandertui-1.4.1}/commandertui.egg-info/entry_points.txt +0 -0
  42. {commandertui-1.3.1 → commandertui-1.4.1}/commandertui.egg-info/requires.txt +0 -0
  43. {commandertui-1.3.1 → commandertui-1.4.1}/commandertui.egg-info/top_level.txt +0 -0
  44. {commandertui-1.3.1 → commandertui-1.4.1}/setup.cfg +0 -0
  45. {commandertui-1.3.1 → commandertui-1.4.1}/tests/test_bookmarks.py +0 -0
  46. {commandertui-1.3.1 → commandertui-1.4.1}/tests/test_diff.py +0 -0
  47. {commandertui-1.3.1 → commandertui-1.4.1}/tests/test_executor.py +0 -0
  48. {commandertui-1.3.1 → commandertui-1.4.1}/tests/test_plan.py +0 -0
  49. {commandertui-1.3.1 → commandertui-1.4.1}/tests/test_scanner.py +0 -0
  50. {commandertui-1.3.1 → commandertui-1.4.1}/tests/test_sync.py +0 -0
  51. {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.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.3.1** — 127/127 tests passing. MIT licensed.
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 share only -- never a password) and reappears in Places, mounting itself
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.3.1** — 127/127 tests passing. MIT licensed.
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 share only -- never a password) and reappears in Places, mounting itself
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().enter_selected()
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", labels, initial=initial, on_highlight=lambda i: self.app.preview_theme(names[i])
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
- self._pick_share(host.strip())
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, host), save=True)
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 _pick_share(self, host: str, retried: bool = False) -> None:
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
- # Most likely a login is needed: let gio ask for it on the real
474
- # terminal (and offer to remember it in the keyring).
475
- with self.app.suspend():
476
- netlocations.mount(netlocations.build_uri("smb", host), interactive=True)
477
- self._pick_share(host, retried=True)
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 and self._connect(netlocations.build_uri("smb", host, share), save=True),
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
- }.get(
229
- event.button.id, self.action_cancel
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))