commandertui 1.4.1__tar.gz → 1.4.2__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 (48) hide show
  1. {commandertui-1.4.1 → commandertui-1.4.2}/PKG-INFO +5 -3
  2. {commandertui-1.4.1 → commandertui-1.4.2}/README.md +4 -2
  3. commandertui-1.4.2/commandertui/__init__.py +1 -0
  4. {commandertui-1.4.1 → commandertui-1.4.2}/commandertui/main_screen.py +81 -15
  5. {commandertui-1.4.1 → commandertui-1.4.2}/commandertui/modals.py +15 -0
  6. {commandertui-1.4.1 → commandertui-1.4.2}/commandertui/netlocations.py +67 -13
  7. {commandertui-1.4.1 → commandertui-1.4.2}/commandertui.egg-info/PKG-INFO +5 -3
  8. {commandertui-1.4.1 → commandertui-1.4.2}/tests/test_app_smoke.py +118 -0
  9. {commandertui-1.4.1 → commandertui-1.4.2}/tests/test_netlocations.py +65 -3
  10. commandertui-1.4.1/commandertui/__init__.py +0 -1
  11. {commandertui-1.4.1 → commandertui-1.4.2}/LICENSE +0 -0
  12. {commandertui-1.4.1 → commandertui-1.4.2}/commandertui/app.py +0 -0
  13. {commandertui-1.4.1 → commandertui-1.4.2}/commandertui/ascii_font.py +0 -0
  14. {commandertui-1.4.1 → commandertui-1.4.2}/commandertui/bookmarks.py +0 -0
  15. {commandertui-1.4.1 → commandertui-1.4.2}/commandertui/boot.py +0 -0
  16. {commandertui-1.4.1 → commandertui-1.4.2}/commandertui/cli.py +0 -0
  17. {commandertui-1.4.1 → commandertui-1.4.2}/commandertui/compare_screen.py +0 -0
  18. {commandertui-1.4.1 → commandertui-1.4.2}/commandertui/diff.py +0 -0
  19. {commandertui-1.4.1 → commandertui-1.4.2}/commandertui/editor.py +0 -0
  20. {commandertui-1.4.1 → commandertui-1.4.2}/commandertui/executor.py +0 -0
  21. {commandertui-1.4.1 → commandertui-1.4.2}/commandertui/models.py +0 -0
  22. {commandertui-1.4.1 → commandertui-1.4.2}/commandertui/plan.py +0 -0
  23. {commandertui-1.4.1 → commandertui-1.4.2}/commandertui/preview.py +0 -0
  24. {commandertui-1.4.1 → commandertui-1.4.2}/commandertui/progress_screen.py +0 -0
  25. {commandertui-1.4.1 → commandertui-1.4.2}/commandertui/run_queue.py +0 -0
  26. {commandertui-1.4.1 → commandertui-1.4.2}/commandertui/scanner.py +0 -0
  27. {commandertui-1.4.1 → commandertui-1.4.2}/commandertui/settings.py +0 -0
  28. {commandertui-1.4.1 → commandertui-1.4.2}/commandertui/sync.py +0 -0
  29. {commandertui-1.4.1 → commandertui-1.4.2}/commandertui/theme.py +0 -0
  30. {commandertui-1.4.1 → commandertui-1.4.2}/commandertui/trash.py +0 -0
  31. {commandertui-1.4.1 → commandertui-1.4.2}/commandertui/updater.py +0 -0
  32. {commandertui-1.4.1 → commandertui-1.4.2}/commandertui/widgets.py +0 -0
  33. {commandertui-1.4.1 → commandertui-1.4.2}/commandertui.egg-info/SOURCES.txt +0 -0
  34. {commandertui-1.4.1 → commandertui-1.4.2}/commandertui.egg-info/dependency_links.txt +0 -0
  35. {commandertui-1.4.1 → commandertui-1.4.2}/commandertui.egg-info/entry_points.txt +0 -0
  36. {commandertui-1.4.1 → commandertui-1.4.2}/commandertui.egg-info/requires.txt +0 -0
  37. {commandertui-1.4.1 → commandertui-1.4.2}/commandertui.egg-info/top_level.txt +0 -0
  38. {commandertui-1.4.1 → commandertui-1.4.2}/pyproject.toml +0 -0
  39. {commandertui-1.4.1 → commandertui-1.4.2}/setup.cfg +0 -0
  40. {commandertui-1.4.1 → commandertui-1.4.2}/tests/test_bookmarks.py +0 -0
  41. {commandertui-1.4.1 → commandertui-1.4.2}/tests/test_diff.py +0 -0
  42. {commandertui-1.4.1 → commandertui-1.4.2}/tests/test_executor.py +0 -0
  43. {commandertui-1.4.1 → commandertui-1.4.2}/tests/test_plan.py +0 -0
  44. {commandertui-1.4.1 → commandertui-1.4.2}/tests/test_preview.py +0 -0
  45. {commandertui-1.4.1 → commandertui-1.4.2}/tests/test_scanner.py +0 -0
  46. {commandertui-1.4.1 → commandertui-1.4.2}/tests/test_sync.py +0 -0
  47. {commandertui-1.4.1 → commandertui-1.4.2}/tests/test_updater.py +0 -0
  48. {commandertui-1.4.1 → commandertui-1.4.2}/tests/test_widgets.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: commandertui
3
- Version: 1.4.1
3
+ Version: 1.4.2
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
@@ -140,7 +140,7 @@ o.window("^commandertui$", { float = true, center = true, size = { 1174, 637 } }
140
140
  | `i` | Size: file/folder count and total bytes of marked items (background, `Esc` stops it) |
141
141
  | `h` | Show/hide hidden (dot) files in both panes |
142
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 |
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 |
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, `l` change the login of a saved network location (forgets the session, then anonymous or another user) |
144
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) |
145
145
  | `r` | Refresh both panes |
146
146
  | `q` | Quit |
@@ -162,7 +162,9 @@ location is saved in `~/.config/commandertui/network.json` (protocol, host,
162
162
  share and user only -- never a password) and reappears in Places, mounting itself
163
163
  when picked. If the server asks for a login, `gio` asks for it in the
164
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*.
165
+ For SMB and FTP the app first asks **Anonymous (no login)** or **User and password**; anonymous connects with `gio mount --anonymous`, with no user, workgroup or password prompt, and is remembered per location. Choosing *User and password* forgets any earlier session for that server first, so the login prompt always appears.
166
+
167
+ If the GVFS backend (or `gvfs-fuse`, which gives a mounted share a folder to browse) 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 gvfs-fuse`, `pacman -S gvfs gvfs-smb` or `dnf install gvfs-smb gvfs-fuse`); it asks for your sudo password in the terminal and only runs if you answer *Install now*.
166
168
 
167
169
  Every key also has a clickable box on the bottom bar: one row on wide windows
168
170
  (168+ columns), two rows when narrower (120 columns is the practical minimum).
@@ -121,7 +121,7 @@ o.window("^commandertui$", { float = true, center = true, size = { 1174, 637 } }
121
121
  | `i` | Size: file/folder count and total bytes of marked items (background, `Esc` stops it) |
122
122
  | `h` | Show/hide hidden (dot) files in both panes |
123
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 |
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
+ | `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, `l` change the login of a saved network location (forgets the session, then anonymous or another user) |
125
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) |
126
126
  | `r` | Refresh both panes |
127
127
  | `q` | Quit |
@@ -143,7 +143,9 @@ location is saved in `~/.config/commandertui/network.json` (protocol, host,
143
143
  share and user only -- never a password) and reappears in Places, mounting itself
144
144
  when picked. If the server asks for a login, `gio` asks for it in the
145
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*.
146
+ For SMB and FTP the app first asks **Anonymous (no login)** or **User and password**; anonymous connects with `gio mount --anonymous`, with no user, workgroup or password prompt, and is remembered per location. Choosing *User and password* forgets any earlier session for that server first, so the login prompt always appears.
147
+
148
+ If the GVFS backend (or `gvfs-fuse`, which gives a mounted share a folder to browse) 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 gvfs-fuse`, `pacman -S gvfs gvfs-smb` or `dnf install gvfs-smb gvfs-fuse`); it asks for your sudo password in the terminal and only runs if you answer *Install now*.
147
149
 
148
150
  Every key also has a clickable box on the bottom bar: one row on wide windows
149
151
  (168+ columns), two rows when narrower (120 columns is the practical minimum).
@@ -0,0 +1 @@
1
+ __version__ = "1.4.2"
@@ -13,6 +13,7 @@ from .compare_screen import CompareScreen
13
13
  from . import __version__, editor, netlocations, preview, theme, updater
14
14
  from .modals import (
15
15
  ADD_NETWORK,
16
+ CHANGE_LOGIN,
16
17
  ChoiceScreen,
17
18
  FilterScreen,
18
19
  InputScreen,
@@ -26,6 +27,9 @@ from .run_queue import run_queue_with_progress
26
27
  from .scanner import path_size
27
28
  from .widgets import PARENT_MARK, FilePanel, FunctionBar, MainTopBar
28
29
 
30
+ LOGIN_ANON = "Anonymous (no login)"
31
+ LOGIN_USER = "User and password"
32
+
29
33
 
30
34
  class MainScreen(Screen):
31
35
  """The dual-pane view and its keybindings.
@@ -519,6 +523,8 @@ class MainScreen(Screen):
519
523
  return
520
524
  if path == ADD_NETWORK:
521
525
  self._add_network()
526
+ elif path.startswith(CHANGE_LOGIN):
527
+ self._change_login(path[len(CHANGE_LOGIN) :])
522
528
  elif netlocations.is_uri(path):
523
529
  self._connect(path)
524
530
  else:
@@ -547,14 +553,27 @@ class MainScreen(Screen):
547
553
  if not host or not host.strip():
548
554
  return
549
555
  host_clean = host.strip()
550
- if scheme == "smb":
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)
556
+ if scheme == "smb" and netlocations.is_macos():
557
+ self.app.push_screen(InputScreen("SMB user:"), lambda u: on_user(host_clean, u))
558
+ elif scheme in ("smb", "ftp"):
559
+ self.app.push_screen(
560
+ ChoiceScreen(f"Login for {host_clean}", [LOGIN_ANON, LOGIN_USER]),
561
+ lambda choice: on_login(scheme, host_clean, choice),
562
+ )
555
563
  else:
556
564
  self._connect(netlocations.build_uri(scheme, host_clean), save=True)
557
565
 
566
+ def on_login(scheme: str, host: str, choice: str | None) -> None:
567
+ if not choice:
568
+ return
569
+ anonymous = choice == LOGIN_ANON
570
+ if scheme == "smb":
571
+ self._pick_share(host, anonymous=anonymous, reset=not anonymous)
572
+ else:
573
+ self._connect(
574
+ netlocations.build_uri(scheme, host), save=True, anonymous=anonymous, reset=not anonymous
575
+ )
576
+
558
577
  def on_scheme(choice: str | None) -> None:
559
578
  if not choice:
560
579
  return
@@ -571,6 +590,32 @@ class MainScreen(Screen):
571
590
 
572
591
  self.app.push_screen(ChoiceScreen("Protocol", ["SMB", "SFTP", "FTP"]), on_scheme)
573
592
 
593
+ def _change_login(self, uri: str) -> None:
594
+ """Forget the session of a saved location and connect again, anonymous or with another user."""
595
+ if netlocations.is_macos():
596
+ self.app.push_screen(
597
+ MessageScreen("Change login is not available on macOS: remove the location and add it again.")
598
+ )
599
+ return
600
+ local = netlocations.local_path(uri)
601
+ if local:
602
+ for side in ("left", "right"):
603
+ panel = self._panel(side)
604
+ if panel.path == local or panel.path.startswith(local.rstrip(os.sep) + os.sep):
605
+ panel.path = os.path.expanduser("~")
606
+ panel.marked.clear()
607
+ panel.refresh_listing()
608
+
609
+ def on_choice(choice: str | None) -> None:
610
+ if not choice:
611
+ return
612
+ anonymous = choice == LOGIN_ANON
613
+ self._connect(uri, save=True, anonymous=anonymous, reset=True)
614
+
615
+ scheme = uri.partition("://")[0].lower()
616
+ options = [LOGIN_ANON, LOGIN_USER] if scheme in ("smb", "ftp") else [LOGIN_USER]
617
+ self.app.push_screen(ChoiceScreen(f"Login for {netlocations.label_for(uri)}", options), on_choice)
618
+
574
619
  def _offer_backend_install(self, scheme: str, label: str) -> None:
575
620
  command = netlocations.backend_install_command()
576
621
  if command is None:
@@ -606,16 +651,25 @@ class MainScreen(Screen):
606
651
  on_choice,
607
652
  )
608
653
 
609
- def _pick_share(self, host: str, retried: bool = False, user: str | None = None) -> None:
654
+ def _pick_share(
655
+ self,
656
+ host: str,
657
+ retried: bool = False,
658
+ user: str | None = None,
659
+ anonymous: bool = False,
660
+ reset: bool = False,
661
+ ) -> None:
610
662
  self.notify(f"Listing shares on {host}...")
611
663
 
612
664
  def work() -> None:
613
- shares, err = netlocations.list_shares(host, user=user)
665
+ if reset: # forget a previous session so the server asks for a login again
666
+ netlocations.reset_login(netlocations.build_uri("smb", host))
667
+ shares, err = netlocations.list_shares(host, user=user, anonymous=anonymous)
614
668
  self.app.call_from_thread(done, shares, err)
615
669
 
616
670
  def done(shares: list[str] | None, err: str) -> None:
617
671
  if shares is None:
618
- if retried:
672
+ if retried or anonymous:
619
673
  self.app.push_screen(MessageScreen(f"Could not list shares on {host}.\n{err}"))
620
674
  return
621
675
  if netlocations.is_macos():
@@ -633,33 +687,45 @@ class MainScreen(Screen):
633
687
  self.app.push_screen(
634
688
  ChoiceScreen(f"Shares on {host}", shares),
635
689
  lambda share: share
636
- and self._connect(netlocations.build_uri("smb", host, share, user=user), save=True),
690
+ and self._connect(
691
+ netlocations.build_uri("smb", host, share, user=user), save=True, anonymous=anonymous
692
+ ),
637
693
  )
638
694
 
639
695
  self.run_worker(work, thread=True)
640
696
 
641
- def _connect(self, uri: str, save: bool = False, retried: bool = False) -> None:
697
+ def _connect(
698
+ self,
699
+ uri: str,
700
+ save: bool = False,
701
+ retried: bool = False,
702
+ anonymous: bool | None = None,
703
+ reset: bool = False,
704
+ ) -> None:
642
705
  if netlocations.is_macos() and not uri.lower().startswith("smb://"):
643
706
  self.app.push_screen(MessageScreen(netlocations.MACOS_UNSUPPORTED_MSG))
644
707
  return
645
708
  self.notify(f"Connecting to {uri} ...")
709
+ anon = netlocations.is_anonymous(uri) if anonymous is None else anonymous
646
710
 
647
711
  def work() -> None:
648
- ok, err = netlocations.mount(uri)
712
+ if reset: # forget a previous session so the server asks for a login again
713
+ netlocations.reset_login(uri)
714
+ ok, err = netlocations.mount(uri, anonymous=anon)
649
715
  path = netlocations.local_path(uri) if ok else None
650
716
  self.app.call_from_thread(done, path, err)
651
717
 
652
718
  def done(path: str | None, err: str) -> None:
653
719
  if path:
654
720
  if save:
655
- netlocations.save_location(uri)
721
+ netlocations.save_location(uri, anonymous=anon)
656
722
  self._go_to(path)
657
723
  return
658
- if retried or netlocations.is_macos():
724
+ if retried or anon or netlocations.is_macos():
659
725
  self.app.push_screen(MessageScreen(f"Could not connect to {uri}.\n{err}"))
660
726
  return
661
727
  with self.app.suspend():
662
- netlocations.mount(uri, interactive=True)
663
- self._connect(uri, save=save, retried=True)
728
+ netlocations.mount(uri, interactive=True, anonymous=False)
729
+ self._connect(uri, save=save, retried=True, anonymous=False)
664
730
 
665
731
  self.run_worker(work, thread=True)
@@ -117,6 +117,7 @@ class ConfirmScreen(ModalScreen[bool]):
117
117
  self.dismiss(False)
118
118
 
119
119
 
120
+ CHANGE_LOGIN = "\0change-login:" # prefix PlacesScreen dismisses with, followed by the saved location's URI
120
121
  ADD_NETWORK = "\0add-network" # sentinel PlacesScreen dismisses with to start the add-network flow
121
122
 
122
123
 
@@ -160,6 +161,7 @@ class PlacesScreen(ModalScreen[str | None]):
160
161
  Binding("s", "save_here", "Save here", priority=True),
161
162
  Binding("d", "delete_saved", "Delete saved", priority=True),
162
163
  Binding("e", "unmount_share", "Unmount share", priority=True),
164
+ Binding("l", "change_login", "Change login", priority=True),
163
165
  Binding("escape", "cancel", "Cancel", priority=True),
164
166
  ]
165
167
 
@@ -178,6 +180,7 @@ class PlacesScreen(ModalScreen[str | None]):
178
180
  yield Button("Save here (s)", id="save")
179
181
  yield Button("Delete saved (d)", id="delete")
180
182
  yield Button("Unmount share (e)", id="unmount")
183
+ yield Button("Change login (l)", id="login")
181
184
  yield Button("Close (Esc)", id="close")
182
185
 
183
186
  def on_mount(self) -> None:
@@ -238,6 +241,17 @@ class PlacesScreen(ModalScreen[str | None]):
238
241
  panel.marked.clear()
239
242
  panel.refresh_listing()
240
243
 
244
+ def action_change_login(self) -> None:
245
+ """Forget the session of a saved network location to log in again (anonymous or another user)."""
246
+ table = self.query_one("#places-table", DataTable)
247
+ if not self.places or table.cursor_row >= len(self.places):
248
+ return
249
+ place = self.places[table.cursor_row]
250
+ if place.kind != "net-saved":
251
+ self.notify("Pick a saved network location to change its login.", severity="warning")
252
+ return
253
+ self.dismiss(CHANGE_LOGIN + place.path)
254
+
241
255
  def action_unmount_share(self) -> None:
242
256
  table = self.query_one("#places-table", DataTable)
243
257
  if not self.places or table.cursor_row >= len(self.places):
@@ -268,6 +282,7 @@ class PlacesScreen(ModalScreen[str | None]):
268
282
  "save": self.action_save_here,
269
283
  "delete": self.action_delete_saved,
270
284
  "unmount": self.action_unmount_share,
285
+ "login": self.action_change_login,
271
286
  }.get(event.button.id, self.action_cancel)()
272
287
 
273
288
  def on_data_table_row_selected(self, event: DataTable.RowSelected) -> None:
@@ -32,6 +32,7 @@ class NetLocation:
32
32
  label: str
33
33
  uri: str
34
34
  user: str | None = None
35
+ anonymous: bool = False
35
36
 
36
37
 
37
38
  def is_macos() -> bool:
@@ -57,19 +58,36 @@ def _gvfs_mount_dirs() -> list[str]:
57
58
  ]
58
59
 
59
60
 
61
+ _FUSE_DAEMONS = (
62
+ "/usr/libexec/gvfsd-fuse",
63
+ "/usr/lib/gvfsd-fuse",
64
+ "/usr/lib/gvfs/gvfsd-fuse",
65
+ "/usr/lib64/gvfs/gvfsd-fuse",
66
+ "/usr/lib/x86_64-linux-gnu/gvfs/gvfsd-fuse",
67
+ "/usr/lib/aarch64-linux-gnu/gvfs/gvfsd-fuse",
68
+ )
69
+
70
+
71
+ def _fuse_present() -> bool:
72
+ """GVFS shows a mounted share as a local folder (~/.../gvfs/...) only through gvfsd-fuse."""
73
+ return any(os.path.isfile(p) for p in _FUSE_DAEMONS) or shutil.which("gvfsd-fuse") is not None
74
+
75
+
60
76
  def backend_missing(scheme: str) -> bool:
61
- """True on Linux when GVFS has no backend for `scheme` (gio then answers
62
- "Operation not supported"). Always False on macOS and when `gio` is absent."""
77
+ """True on Linux when GVFS cannot serve `scheme`: no backend for it (gio then answers
78
+ "Operation not supported") or no gvfsd-fuse (the share mounts but has no local folder to open).
79
+ Always False on macOS and when `gio` is absent."""
63
80
  if is_macos() or not gio_available():
64
81
  return False
65
- return not any(os.path.isfile(os.path.join(d, f"{scheme}.mount")) for d in _gvfs_mount_dirs())
82
+ has_backend = any(os.path.isfile(os.path.join(d, f"{scheme}.mount")) for d in _gvfs_mount_dirs())
83
+ return not (has_backend and _fuse_present())
66
84
 
67
85
 
68
86
  # Package that ships the GVFS backends, per package manager.
69
87
  _BACKEND_INSTALL = {
70
- "apt-get": ["sudo", "apt-get", "install", "gvfs-backends"],
88
+ "apt-get": ["sudo", "apt-get", "install", "gvfs-backends", "gvfs-fuse"],
71
89
  "pacman": ["sudo", "pacman", "-S", "gvfs", "gvfs-smb"],
72
- "dnf": ["sudo", "dnf", "install", "gvfs-smb"],
90
+ "dnf": ["sudo", "dnf", "install", "gvfs-smb", "gvfs-fuse"],
73
91
  }
74
92
 
75
93
 
@@ -86,7 +104,11 @@ def install_backend() -> bool:
86
104
  command = backend_install_command()
87
105
  if command is None:
88
106
  return False
89
- return subprocess.run(command).returncode == 0
107
+ if subprocess.run(command).returncode != 0:
108
+ return False
109
+ # A gvfsd that was already running does not know the new components: let it restart on demand.
110
+ subprocess.run(["pkill", "-x", "gvfsd"], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
111
+ return True
90
112
 
91
113
 
92
114
  def build_uri(scheme: str, host: str, share: str | None = None, user: str | None = None) -> str:
@@ -187,11 +209,15 @@ def _list_shares_darwin(host: str, user: str | None = None) -> tuple[list[str] |
187
209
  return _parse_smbutil_view(res.stdout), ""
188
210
 
189
211
 
190
- def list_shares(host: str, user: str | None = None) -> tuple[list[str] | None, str]:
212
+ def list_shares(host: str, user: str | None = None, anonymous: bool = False) -> tuple[list[str] | None, str]:
191
213
  """Shares on an SMB server. Returns (names, "") or (None, error) -- the
192
214
  caller retries after an interactive mount if login was the problem."""
193
215
  if is_macos():
194
216
  return _list_shares_darwin(host, user=user)
217
+ if anonymous:
218
+ ok, err = mount(build_uri("smb", host), anonymous=True)
219
+ if not ok:
220
+ return None, err
195
221
  try:
196
222
  res = _gio(["list", build_uri("smb", host)])
197
223
  except subprocess.TimeoutExpired:
@@ -291,11 +317,17 @@ def _mount_darwin(uri: str, interactive: bool = False) -> tuple[bool, str]:
291
317
  return False, f"timed out waiting for /Volumes/{share}"
292
318
 
293
319
 
294
- def mount(uri: str, interactive: bool = False) -> tuple[bool, str]:
320
+ def mount(uri: str, interactive: bool = False, anonymous: bool | None = None) -> tuple[bool, str]:
321
+ """`anonymous=None` means: anonymous if the saved location says so. An anonymous
322
+ mount never prompts (`gio mount --anonymous`), so it is never interactive."""
295
323
  if is_macos():
296
324
  return _mount_darwin(uri, interactive=interactive)
325
+ if anonymous is None:
326
+ anonymous = is_anonymous(uri)
327
+ if anonymous:
328
+ interactive = False
297
329
  try:
298
- res = _gio(["mount", uri], interactive)
330
+ res = _gio(["mount", "--anonymous", uri] if anonymous else ["mount", uri], interactive)
299
331
  except subprocess.TimeoutExpired:
300
332
  return False, "timed out"
301
333
  if res.returncode == 0:
@@ -397,7 +429,9 @@ def load_locations() -> list[NetLocation]:
397
429
  for d in json.load(f):
398
430
  uri = d["uri"]
399
431
  user = d.get("user") or _split_uri(uri)[1] or None
400
- items.append(NetLocation(label=d["label"], uri=uri, user=user))
432
+ items.append(
433
+ NetLocation(label=d["label"], uri=uri, user=user, anonymous=bool(d.get("anonymous")))
434
+ )
401
435
  return items
402
436
  except (OSError, ValueError, KeyError):
403
437
  return []
@@ -412,18 +446,38 @@ def _write(locations: list[NetLocation]) -> None:
412
446
  user = loc.user or uri_user
413
447
  if user:
414
448
  row["user"] = user
449
+ if loc.anonymous:
450
+ row["anonymous"] = True
415
451
  rows.append(row)
416
452
  with open(NETWORK_FILE, "w") as f:
417
453
  json.dump(rows, f, indent=2)
418
454
 
419
455
 
420
- def save_location(uri: str, user: str | None = None) -> None:
456
+ def save_location(uri: str, user: str | None = None, anonymous: bool = False) -> None:
421
457
  _, uri_user, _, _ = _split_uri(uri)
422
- effective_user = user or uri_user or None
458
+ effective_user = None if anonymous else (user or uri_user or None)
423
459
  locations = [loc for loc in load_locations() if loc.uri != uri]
424
- locations.append(NetLocation(label=label_for(uri), uri=uri, user=effective_user))
460
+ locations.append(NetLocation(label=label_for(uri), uri=uri, user=effective_user, anonymous=anonymous))
425
461
  _write(locations)
426
462
 
427
463
 
464
+ def is_anonymous(uri: str) -> bool:
465
+ """True when `uri` is a saved location that connects without a login."""
466
+ return any(loc.anonymous for loc in load_locations() if loc.uri == uri)
467
+
468
+
428
469
  def remove_location(uri: str) -> None:
429
470
  _write([loc for loc in load_locations() if loc.uri != uri])
471
+
472
+
473
+ def reset_login(uri: str) -> None:
474
+ """Make gio forget the session for this location so the next connect asks for a login again:
475
+ unmounts the share and the server's browse mount. Not being mounted is fine; macOS has no such session."""
476
+ if is_macos():
477
+ return
478
+ scheme, _, host, _ = _split_uri(uri)
479
+ for target in dict.fromkeys([uri, build_uri(scheme, host)]):
480
+ try:
481
+ _gio(["mount", "-u", target])
482
+ except subprocess.TimeoutExpired:
483
+ pass
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: commandertui
3
- Version: 1.4.1
3
+ Version: 1.4.2
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
@@ -140,7 +140,7 @@ o.window("^commandertui$", { float = true, center = true, size = { 1174, 637 } }
140
140
  | `i` | Size: file/folder count and total bytes of marked items (background, `Esc` stops it) |
141
141
  | `h` | Show/hide hidden (dot) files in both panes |
142
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 |
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 |
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, `l` change the login of a saved network location (forgets the session, then anonymous or another user) |
144
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) |
145
145
  | `r` | Refresh both panes |
146
146
  | `q` | Quit |
@@ -162,7 +162,9 @@ location is saved in `~/.config/commandertui/network.json` (protocol, host,
162
162
  share and user only -- never a password) and reappears in Places, mounting itself
163
163
  when picked. If the server asks for a login, `gio` asks for it in the
164
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*.
165
+ For SMB and FTP the app first asks **Anonymous (no login)** or **User and password**; anonymous connects with `gio mount --anonymous`, with no user, workgroup or password prompt, and is remembered per location. Choosing *User and password* forgets any earlier session for that server first, so the login prompt always appears.
166
+
167
+ If the GVFS backend (or `gvfs-fuse`, which gives a mounted share a folder to browse) 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 gvfs-fuse`, `pacman -S gvfs gvfs-smb` or `dnf install gvfs-smb gvfs-fuse`); it asks for your sudo password in the terminal and only runs if you answer *Install now*.
166
168
 
167
169
  Every key also has a clickable box on the bottom bar: one row on wide windows
168
170
  (168+ columns), two rows when narrower (120 columns is the practical minimum).
@@ -1222,3 +1222,121 @@ async def test_missing_gvfs_backend_cancel_installs_nothing(tmp_path, monkeypatc
1222
1222
  await pilot.press("down", "enter") # "Cancel"
1223
1223
  await pilot.pause()
1224
1224
  assert ran == []
1225
+
1226
+
1227
+ @pytest.mark.asyncio
1228
+ async def test_add_smb_asks_login_and_anonymous_skips_every_prompt(tmp_path, monkeypatch):
1229
+ from commandertui import netlocations
1230
+ from commandertui.modals import ChoiceScreen, InputScreen
1231
+
1232
+ seen = {}
1233
+ monkeypatch.setattr(netlocations, "gio_available", lambda: True)
1234
+ monkeypatch.setattr(netlocations, "backend_missing", lambda scheme: False)
1235
+ monkeypatch.setattr(netlocations.sys, "platform", "linux")
1236
+
1237
+ def fake_list(host, user=None, anonymous=False):
1238
+ seen["list"] = (host, user, anonymous)
1239
+ return ["Public", "HD1"], ""
1240
+
1241
+ monkeypatch.setattr(netlocations, "list_shares", fake_list)
1242
+ monkeypatch.setattr(netlocations, "reset_login", lambda uri: seen.setdefault("reset", uri))
1243
+ monkeypatch.setattr(
1244
+ netlocations,
1245
+ "mount",
1246
+ lambda uri, interactive=False, anonymous=None: seen.update(mount=(uri, interactive, anonymous))
1247
+ or (True, ""),
1248
+ )
1249
+ monkeypatch.setattr(netlocations, "local_path", lambda uri: str(tmp_path))
1250
+ saved = []
1251
+ monkeypatch.setattr(
1252
+ netlocations, "save_location", lambda uri, user=None, anonymous=False: saved.append((uri, anonymous))
1253
+ )
1254
+
1255
+ app, left, right = await _open_confirm(tmp_path, "")
1256
+ async with app.run_test(size=(140, 30)) as pilot:
1257
+ await _skip_boot(pilot)
1258
+ await pilot.press("p", "a", "enter") # Places -> Add -> SMB
1259
+ await pilot.pause()
1260
+ assert isinstance(app.screen, InputScreen)
1261
+ app.screen.query_one("#input-field").value = "192.168.100.14"
1262
+ await pilot.press("enter")
1263
+ await pilot.pause()
1264
+ assert isinstance(app.screen, ChoiceScreen) and app.screen.options == [
1265
+ "Anonymous (no login)",
1266
+ "User and password",
1267
+ ]
1268
+ await pilot.press("enter") # Anonymous
1269
+ await pilot.pause()
1270
+ await pilot.pause()
1271
+ assert seen["list"] == ("192.168.100.14", None, True)
1272
+ assert "reset" not in seen # anonymous does not need a fresh session
1273
+ assert isinstance(app.screen, ChoiceScreen) and app.screen.options == ["Public", "HD1"]
1274
+ await pilot.press("enter") # Public
1275
+ await pilot.pause()
1276
+ await pilot.pause()
1277
+ assert seen["mount"] == ("smb://192.168.100.14/Public/", False, True)
1278
+ assert saved == [("smb://192.168.100.14/Public/", True)]
1279
+
1280
+
1281
+ @pytest.mark.asyncio
1282
+ async def test_add_smb_with_user_resets_the_old_session_first(tmp_path, monkeypatch):
1283
+ from commandertui import netlocations
1284
+
1285
+ seen = {}
1286
+ monkeypatch.setattr(netlocations, "gio_available", lambda: True)
1287
+ monkeypatch.setattr(netlocations, "backend_missing", lambda scheme: False)
1288
+ monkeypatch.setattr(netlocations.sys, "platform", "linux")
1289
+ monkeypatch.setattr(netlocations, "reset_login", lambda uri: seen.update(reset=uri))
1290
+ monkeypatch.setattr(
1291
+ netlocations,
1292
+ "list_shares",
1293
+ lambda host, user=None, anonymous=False: seen.update(list=anonymous) or (["HD1"], ""),
1294
+ )
1295
+
1296
+ app, left, right = await _open_confirm(tmp_path, "")
1297
+ async with app.run_test(size=(140, 30)) as pilot:
1298
+ await _skip_boot(pilot)
1299
+ await pilot.press("p", "a", "enter")
1300
+ await pilot.pause()
1301
+ app.screen.query_one("#input-field").value = "nas"
1302
+ await pilot.press("enter")
1303
+ await pilot.pause()
1304
+ await pilot.press("down", "enter") # User and password
1305
+ await pilot.pause()
1306
+ await pilot.pause()
1307
+ assert seen == {"reset": "smb://nas/", "list": False}
1308
+
1309
+
1310
+ @pytest.mark.asyncio
1311
+ async def test_places_change_login_resets_and_reconnects(tmp_path, monkeypatch):
1312
+ from commandertui import netlocations
1313
+ from commandertui.modals import ChoiceScreen
1314
+
1315
+ monkeypatch.setattr(netlocations, "NETWORK_FILE", str(tmp_path / "network.json"))
1316
+ monkeypatch.setattr(netlocations.sys, "platform", "linux")
1317
+ netlocations.save_location("smb://nas/HD1/", user="old")
1318
+ seen = {}
1319
+ monkeypatch.setattr(netlocations, "reset_login", lambda uri: seen.update(reset=uri))
1320
+ monkeypatch.setattr(
1321
+ netlocations,
1322
+ "mount",
1323
+ lambda uri, interactive=False, anonymous=None: seen.update(mount=(uri, anonymous)) or (True, ""),
1324
+ )
1325
+ monkeypatch.setattr(netlocations, "local_path", lambda uri: str(tmp_path))
1326
+
1327
+ app, left, right = await _open_confirm(tmp_path, "")
1328
+ async with app.run_test(size=(140, 30)) as pilot:
1329
+ await _skip_boot(pilot)
1330
+ await pilot.press("p")
1331
+ await pilot.pause()
1332
+ table = app.screen.query_one("#places-table")
1333
+ row = [i for i, pl in enumerate(app.screen.places) if pl.path == "smb://nas/HD1/"][0]
1334
+ table.move_cursor(row=row)
1335
+ await pilot.press("l")
1336
+ await pilot.pause()
1337
+ assert isinstance(app.screen, ChoiceScreen) and app.screen.options[0] == "Anonymous (no login)"
1338
+ await pilot.press("enter") # Anonymous
1339
+ await pilot.pause()
1340
+ await pilot.pause()
1341
+ assert seen == {"reset": "smb://nas/HD1/", "mount": ("smb://nas/HD1/", True)}
1342
+ assert netlocations.is_anonymous("smb://nas/HD1/")
@@ -252,10 +252,15 @@ def test_backend_missing_checks_gvfs_mount_files(monkeypatch, tmp_path):
252
252
  mounts = tmp_path / "gvfs" / "mounts"
253
253
  mounts.mkdir(parents=True)
254
254
  monkeypatch.setattr(netlocations, "_gvfs_mount_dirs", lambda: [str(mounts)])
255
+ monkeypatch.setattr(netlocations, "_fuse_present", lambda: True)
255
256
  assert netlocations.backend_missing("smb") is True
256
257
  (mounts / "smb.mount").write_text("")
257
258
  assert netlocations.backend_missing("smb") is False
258
259
  assert netlocations.backend_missing("sftp") is True
260
+ monkeypatch.setattr(
261
+ netlocations, "_fuse_present", lambda: False
262
+ ) # backend ok but no folder to open the share in
263
+ assert netlocations.backend_missing("smb") is True
259
264
 
260
265
 
261
266
  def test_backend_missing_is_false_on_macos_or_without_gio(monkeypatch):
@@ -270,7 +275,13 @@ def test_backend_install_command_per_package_manager(monkeypatch):
270
275
  monkeypatch.setattr(
271
276
  netlocations.shutil, "which", lambda name: "/usr/bin/" + name if name == "apt-get" else None
272
277
  )
273
- assert netlocations.backend_install_command() == ["sudo", "apt-get", "install", "gvfs-backends"]
278
+ assert netlocations.backend_install_command() == [
279
+ "sudo",
280
+ "apt-get",
281
+ "install",
282
+ "gvfs-backends",
283
+ "gvfs-fuse",
284
+ ]
274
285
  monkeypatch.setattr(
275
286
  netlocations.shutil, "which", lambda name: "/usr/bin/pacman" if name == "pacman" else None
276
287
  )
@@ -282,7 +293,58 @@ def test_backend_install_command_per_package_manager(monkeypatch):
282
293
 
283
294
  def test_install_backend_reports_exit_code(monkeypatch):
284
295
  monkeypatch.setattr(netlocations, "backend_install_command", lambda: ["sudo", "true"])
285
- monkeypatch.setattr(netlocations.subprocess, "run", lambda cmd: subprocess.CompletedProcess(cmd, 0))
296
+ monkeypatch.setattr(netlocations.subprocess, "run", lambda cmd, **kw: subprocess.CompletedProcess(cmd, 0))
286
297
  assert netlocations.install_backend() is True
287
- monkeypatch.setattr(netlocations.subprocess, "run", lambda cmd: subprocess.CompletedProcess(cmd, 1))
298
+ monkeypatch.setattr(netlocations.subprocess, "run", lambda cmd, **kw: subprocess.CompletedProcess(cmd, 1))
288
299
  assert netlocations.install_backend() is False
300
+
301
+
302
+ def test_anonymous_location_is_saved_and_mounted_without_prompting(monkeypatch, tmp_path):
303
+ monkeypatch.setattr(netlocations.sys, "platform", "linux")
304
+ monkeypatch.setattr(netlocations, "NETWORK_FILE", str(tmp_path / "network.json"))
305
+ netlocations.save_location("smb://nas/Public/", anonymous=True)
306
+ loc = netlocations.load_locations()[0]
307
+ assert loc.anonymous is True and loc.user is None
308
+ assert netlocations.is_anonymous("smb://nas/Public/") and not netlocations.is_anonymous("smb://other/")
309
+ calls = []
310
+
311
+ def fake_gio(args, interactive=False):
312
+ calls.append((args, interactive))
313
+ return _cp()
314
+
315
+ monkeypatch.setattr(netlocations, "_gio", fake_gio)
316
+ assert netlocations.mount("smb://nas/Public/", interactive=True) == (True, "") # saved as anonymous
317
+ assert calls == [(["mount", "--anonymous", "smb://nas/Public/"], False)]
318
+ calls.clear()
319
+ netlocations.mount("smb://nas/Public/", anonymous=False) # explicit user login wins over the saved flag
320
+ assert calls == [(["mount", "smb://nas/Public/"], False)]
321
+
322
+
323
+ def test_anonymous_list_shares_mounts_the_server_first(monkeypatch):
324
+ monkeypatch.setattr(netlocations.sys, "platform", "linux")
325
+ calls = []
326
+
327
+ def fake_gio(args, interactive=False):
328
+ calls.append(args)
329
+ return _cp(stdout="Public\nHD1\n") if args[0] == "list" else _cp()
330
+
331
+ monkeypatch.setattr(netlocations, "_gio", fake_gio)
332
+ assert netlocations.list_shares("nas", anonymous=True) == (["Public", "HD1"], "")
333
+ assert calls == [["mount", "--anonymous", "smb://nas/"], ["list", "smb://nas/"]]
334
+ monkeypatch.setattr(
335
+ netlocations, "_gio", lambda args, interactive=False: _cp(returncode=1, stderr="denied")
336
+ )
337
+ assert netlocations.list_shares("nas", anonymous=True) == (None, "denied")
338
+
339
+
340
+ def test_reset_login_unmounts_the_share_and_the_server(monkeypatch):
341
+ monkeypatch.setattr(netlocations.sys, "platform", "linux")
342
+ calls = []
343
+ monkeypatch.setattr(
344
+ netlocations, "_gio", lambda args, interactive=False: calls.append(args) or _cp(returncode=1)
345
+ )
346
+ netlocations.reset_login("smb://nas/Public/") # "not mounted" errors are fine
347
+ assert calls == [["mount", "-u", "smb://nas/Public/"], ["mount", "-u", "smb://nas/"]]
348
+ calls.clear()
349
+ netlocations.reset_login("smb://nas/")
350
+ assert calls == [["mount", "-u", "smb://nas/"]]
@@ -1 +0,0 @@
1
- __version__ = "1.4.1"
File without changes
File without changes