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.
Files changed (51) hide show
  1. {commandertui-1.3.1 → commandertui-1.4.0}/PKG-INFO +11 -9
  2. {commandertui-1.3.1 → commandertui-1.4.0}/README.md +9 -8
  3. commandertui-1.4.0/commandertui/__init__.py +1 -0
  4. {commandertui-1.3.1 → commandertui-1.4.0}/commandertui/app.py +2 -1
  5. {commandertui-1.3.1 → commandertui-1.4.0}/commandertui/main_screen.py +129 -13
  6. {commandertui-1.3.1 → commandertui-1.4.0}/commandertui/modals.py +46 -5
  7. commandertui-1.4.0/commandertui/netlocations.py +388 -0
  8. commandertui-1.4.0/commandertui/preview.py +122 -0
  9. {commandertui-1.3.1 → commandertui-1.4.0}/commandertui/settings.py +22 -0
  10. {commandertui-1.3.1 → commandertui-1.4.0}/commandertui/trash.py +10 -3
  11. {commandertui-1.3.1 → commandertui-1.4.0}/commandertui/widgets.py +115 -5
  12. {commandertui-1.3.1 → commandertui-1.4.0}/commandertui.egg-info/PKG-INFO +11 -9
  13. {commandertui-1.3.1 → commandertui-1.4.0}/commandertui.egg-info/SOURCES.txt +2 -0
  14. {commandertui-1.3.1 → commandertui-1.4.0}/pyproject.toml +1 -0
  15. {commandertui-1.3.1 → commandertui-1.4.0}/tests/test_app_smoke.py +124 -2
  16. commandertui-1.4.0/tests/test_netlocations.py +246 -0
  17. commandertui-1.4.0/tests/test_preview.py +305 -0
  18. commandertui-1.4.0/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.0}/LICENSE +0 -0
  24. {commandertui-1.3.1 → commandertui-1.4.0}/commandertui/ascii_font.py +0 -0
  25. {commandertui-1.3.1 → commandertui-1.4.0}/commandertui/bookmarks.py +0 -0
  26. {commandertui-1.3.1 → commandertui-1.4.0}/commandertui/boot.py +0 -0
  27. {commandertui-1.3.1 → commandertui-1.4.0}/commandertui/cli.py +0 -0
  28. {commandertui-1.3.1 → commandertui-1.4.0}/commandertui/compare_screen.py +0 -0
  29. {commandertui-1.3.1 → commandertui-1.4.0}/commandertui/diff.py +0 -0
  30. {commandertui-1.3.1 → commandertui-1.4.0}/commandertui/editor.py +0 -0
  31. {commandertui-1.3.1 → commandertui-1.4.0}/commandertui/executor.py +0 -0
  32. {commandertui-1.3.1 → commandertui-1.4.0}/commandertui/models.py +0 -0
  33. {commandertui-1.3.1 → commandertui-1.4.0}/commandertui/plan.py +0 -0
  34. {commandertui-1.3.1 → commandertui-1.4.0}/commandertui/progress_screen.py +0 -0
  35. {commandertui-1.3.1 → commandertui-1.4.0}/commandertui/run_queue.py +0 -0
  36. {commandertui-1.3.1 → commandertui-1.4.0}/commandertui/scanner.py +0 -0
  37. {commandertui-1.3.1 → commandertui-1.4.0}/commandertui/sync.py +0 -0
  38. {commandertui-1.3.1 → commandertui-1.4.0}/commandertui/theme.py +0 -0
  39. {commandertui-1.3.1 → commandertui-1.4.0}/commandertui/updater.py +0 -0
  40. {commandertui-1.3.1 → commandertui-1.4.0}/commandertui.egg-info/dependency_links.txt +0 -0
  41. {commandertui-1.3.1 → commandertui-1.4.0}/commandertui.egg-info/entry_points.txt +0 -0
  42. {commandertui-1.3.1 → commandertui-1.4.0}/commandertui.egg-info/requires.txt +0 -0
  43. {commandertui-1.3.1 → commandertui-1.4.0}/commandertui.egg-info/top_level.txt +0 -0
  44. {commandertui-1.3.1 → commandertui-1.4.0}/setup.cfg +0 -0
  45. {commandertui-1.3.1 → commandertui-1.4.0}/tests/test_bookmarks.py +0 -0
  46. {commandertui-1.3.1 → commandertui-1.4.0}/tests/test_diff.py +0 -0
  47. {commandertui-1.3.1 → commandertui-1.4.0}/tests/test_executor.py +0 -0
  48. {commandertui-1.3.1 → commandertui-1.4.0}/tests/test_plan.py +0 -0
  49. {commandertui-1.3.1 → commandertui-1.4.0}/tests/test_scanner.py +0 -0
  50. {commandertui-1.3.1 → commandertui-1.4.0}/tests/test_sync.py +0 -0
  51. {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.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.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,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 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).
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.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,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 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).
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().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()
@@ -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
- self._pick_share(host.strip())
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, host), save=True)
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
- # 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)
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 and self._connect(netlocations.build_uri("smb", host, share), save=True),
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
- }.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))