commandertui 1.4.0__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.0 → commandertui-1.4.2}/PKG-INFO +5 -2
  2. {commandertui-1.4.0 → commandertui-1.4.2}/README.md +4 -1
  3. commandertui-1.4.2/commandertui/__init__.py +1 -0
  4. {commandertui-1.4.0 → commandertui-1.4.2}/commandertui/main_screen.py +124 -19
  5. {commandertui-1.4.0 → commandertui-1.4.2}/commandertui/modals.py +15 -0
  6. {commandertui-1.4.0 → commandertui-1.4.2}/commandertui/netlocations.py +102 -7
  7. {commandertui-1.4.0 → commandertui-1.4.2}/commandertui.egg-info/PKG-INFO +5 -2
  8. {commandertui-1.4.0 → commandertui-1.4.2}/tests/test_app_smoke.py +181 -0
  9. {commandertui-1.4.0 → commandertui-1.4.2}/tests/test_netlocations.py +104 -0
  10. commandertui-1.4.0/commandertui/__init__.py +0 -1
  11. {commandertui-1.4.0 → commandertui-1.4.2}/LICENSE +0 -0
  12. {commandertui-1.4.0 → commandertui-1.4.2}/commandertui/app.py +0 -0
  13. {commandertui-1.4.0 → commandertui-1.4.2}/commandertui/ascii_font.py +0 -0
  14. {commandertui-1.4.0 → commandertui-1.4.2}/commandertui/bookmarks.py +0 -0
  15. {commandertui-1.4.0 → commandertui-1.4.2}/commandertui/boot.py +0 -0
  16. {commandertui-1.4.0 → commandertui-1.4.2}/commandertui/cli.py +0 -0
  17. {commandertui-1.4.0 → commandertui-1.4.2}/commandertui/compare_screen.py +0 -0
  18. {commandertui-1.4.0 → commandertui-1.4.2}/commandertui/diff.py +0 -0
  19. {commandertui-1.4.0 → commandertui-1.4.2}/commandertui/editor.py +0 -0
  20. {commandertui-1.4.0 → commandertui-1.4.2}/commandertui/executor.py +0 -0
  21. {commandertui-1.4.0 → commandertui-1.4.2}/commandertui/models.py +0 -0
  22. {commandertui-1.4.0 → commandertui-1.4.2}/commandertui/plan.py +0 -0
  23. {commandertui-1.4.0 → commandertui-1.4.2}/commandertui/preview.py +0 -0
  24. {commandertui-1.4.0 → commandertui-1.4.2}/commandertui/progress_screen.py +0 -0
  25. {commandertui-1.4.0 → commandertui-1.4.2}/commandertui/run_queue.py +0 -0
  26. {commandertui-1.4.0 → commandertui-1.4.2}/commandertui/scanner.py +0 -0
  27. {commandertui-1.4.0 → commandertui-1.4.2}/commandertui/settings.py +0 -0
  28. {commandertui-1.4.0 → commandertui-1.4.2}/commandertui/sync.py +0 -0
  29. {commandertui-1.4.0 → commandertui-1.4.2}/commandertui/theme.py +0 -0
  30. {commandertui-1.4.0 → commandertui-1.4.2}/commandertui/trash.py +0 -0
  31. {commandertui-1.4.0 → commandertui-1.4.2}/commandertui/updater.py +0 -0
  32. {commandertui-1.4.0 → commandertui-1.4.2}/commandertui/widgets.py +0 -0
  33. {commandertui-1.4.0 → commandertui-1.4.2}/commandertui.egg-info/SOURCES.txt +0 -0
  34. {commandertui-1.4.0 → commandertui-1.4.2}/commandertui.egg-info/dependency_links.txt +0 -0
  35. {commandertui-1.4.0 → commandertui-1.4.2}/commandertui.egg-info/entry_points.txt +0 -0
  36. {commandertui-1.4.0 → commandertui-1.4.2}/commandertui.egg-info/requires.txt +0 -0
  37. {commandertui-1.4.0 → commandertui-1.4.2}/commandertui.egg-info/top_level.txt +0 -0
  38. {commandertui-1.4.0 → commandertui-1.4.2}/pyproject.toml +0 -0
  39. {commandertui-1.4.0 → commandertui-1.4.2}/setup.cfg +0 -0
  40. {commandertui-1.4.0 → commandertui-1.4.2}/tests/test_bookmarks.py +0 -0
  41. {commandertui-1.4.0 → commandertui-1.4.2}/tests/test_diff.py +0 -0
  42. {commandertui-1.4.0 → commandertui-1.4.2}/tests/test_executor.py +0 -0
  43. {commandertui-1.4.0 → commandertui-1.4.2}/tests/test_plan.py +0 -0
  44. {commandertui-1.4.0 → commandertui-1.4.2}/tests/test_preview.py +0 -0
  45. {commandertui-1.4.0 → commandertui-1.4.2}/tests/test_scanner.py +0 -0
  46. {commandertui-1.4.0 → commandertui-1.4.2}/tests/test_sync.py +0 -0
  47. {commandertui-1.4.0 → commandertui-1.4.2}/tests/test_updater.py +0 -0
  48. {commandertui-1.4.0 → 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.0
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,6 +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
+ 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*.
165
168
 
166
169
  Every key also has a clickable box on the bottom bar: one row on wide windows
167
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,6 +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
+ 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*.
146
149
 
147
150
  Every key also has a clickable box on the bottom bar: one row on wide windows
148
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.
@@ -365,7 +369,10 @@ class MainScreen(Screen):
365
369
 
366
370
  self.app.push_screen(
367
371
  ChoiceScreen(
368
- "Color theme", labels, initial=initial, on_highlight=lambda i: self.app.preview_theme(names[i])
372
+ "Color theme",
373
+ labels,
374
+ initial=initial,
375
+ on_highlight=lambda i: self.app.preview_theme(names[i]),
369
376
  ),
370
377
  on_choice,
371
378
  )
@@ -421,9 +428,7 @@ class MainScreen(Screen):
421
428
  return
422
429
  command = editor.editor_command()
423
430
  if command is None:
424
- self.app.push_screen(
425
- MessageScreen("No text editor found.\nSet $EDITOR or install nano.")
426
- )
431
+ self.app.push_screen(MessageScreen("No text editor found.\nSet $EDITOR or install nano."))
427
432
  return
428
433
  with self.app.suspend():
429
434
  try:
@@ -518,6 +523,8 @@ class MainScreen(Screen):
518
523
  return
519
524
  if path == ADD_NETWORK:
520
525
  self._add_network()
526
+ elif path.startswith(CHANGE_LOGIN):
527
+ self._change_login(path[len(CHANGE_LOGIN) :])
521
528
  elif netlocations.is_uri(path):
522
529
  self._connect(path)
523
530
  else:
@@ -546,14 +553,27 @@ class MainScreen(Screen):
546
553
  if not host or not host.strip():
547
554
  return
548
555
  host_clean = host.strip()
549
- if scheme == "smb":
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)
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
+ )
554
563
  else:
555
564
  self._connect(netlocations.build_uri(scheme, host_clean), save=True)
556
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
+
557
577
  def on_scheme(choice: str | None) -> None:
558
578
  if not choice:
559
579
  return
@@ -561,22 +581,95 @@ class MainScreen(Screen):
561
581
  if netlocations.is_macos() and scheme != "smb":
562
582
  self.app.push_screen(MessageScreen(netlocations.MACOS_UNSUPPORTED_MSG))
563
583
  return
584
+ if netlocations.backend_missing(scheme):
585
+ self._offer_backend_install(scheme, choice)
586
+ return
564
587
  self.app.push_screen(
565
588
  InputScreen(f"{choice} server (IP or name):"), lambda host: on_host(scheme, host)
566
589
  )
567
590
 
568
591
  self.app.push_screen(ChoiceScreen("Protocol", ["SMB", "SFTP", "FTP"]), on_scheme)
569
592
 
570
- def _pick_share(self, host: str, retried: bool = False, user: str | None = None) -> None:
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
+
619
+ def _offer_backend_install(self, scheme: str, label: str) -> None:
620
+ command = netlocations.backend_install_command()
621
+ if command is None:
622
+ self.app.push_screen(
623
+ MessageScreen(
624
+ f"{label} needs the GVFS {scheme} backend, which is not installed.\nInstall your system's GVFS backends package and try again."
625
+ )
626
+ )
627
+ return
628
+ text = " ".join(command)
629
+
630
+ def on_choice(choice: str | None) -> None:
631
+ if choice != "Install now":
632
+ return
633
+ with self.app.suspend():
634
+ ok = netlocations.install_backend()
635
+ if ok:
636
+ self.app.push_screen(
637
+ MessageScreen(
638
+ "GVFS backends installed.\nQuit and reopen CommanderTUI, then add the location again."
639
+ )
640
+ )
641
+ else:
642
+ self.app.push_screen(
643
+ MessageScreen("The install did not finish. Check the terminal output, then try again.")
644
+ )
645
+
646
+ self.app.push_screen(
647
+ ChoiceScreen(
648
+ f"{label} needs the GVFS backend, which is missing. Install it now? ({text}; asks for your sudo password)",
649
+ ["Install now", "Cancel"],
650
+ ),
651
+ on_choice,
652
+ )
653
+
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:
571
662
  self.notify(f"Listing shares on {host}...")
572
663
 
573
664
  def work() -> None:
574
- 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)
575
668
  self.app.call_from_thread(done, shares, err)
576
669
 
577
670
  def done(shares: list[str] | None, err: str) -> None:
578
671
  if shares is None:
579
- if retried:
672
+ if retried or anonymous:
580
673
  self.app.push_screen(MessageScreen(f"Could not list shares on {host}.\n{err}"))
581
674
  return
582
675
  if netlocations.is_macos():
@@ -594,33 +687,45 @@ class MainScreen(Screen):
594
687
  self.app.push_screen(
595
688
  ChoiceScreen(f"Shares on {host}", shares),
596
689
  lambda share: share
597
- 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
+ ),
598
693
  )
599
694
 
600
695
  self.run_worker(work, thread=True)
601
696
 
602
- 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:
603
705
  if netlocations.is_macos() and not uri.lower().startswith("smb://"):
604
706
  self.app.push_screen(MessageScreen(netlocations.MACOS_UNSUPPORTED_MSG))
605
707
  return
606
708
  self.notify(f"Connecting to {uri} ...")
709
+ anon = netlocations.is_anonymous(uri) if anonymous is None else anonymous
607
710
 
608
711
  def work() -> None:
609
- 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)
610
715
  path = netlocations.local_path(uri) if ok else None
611
716
  self.app.call_from_thread(done, path, err)
612
717
 
613
718
  def done(path: str | None, err: str) -> None:
614
719
  if path:
615
720
  if save:
616
- netlocations.save_location(uri)
721
+ netlocations.save_location(uri, anonymous=anon)
617
722
  self._go_to(path)
618
723
  return
619
- if retried or netlocations.is_macos():
724
+ if retried or anon or netlocations.is_macos():
620
725
  self.app.push_screen(MessageScreen(f"Could not connect to {uri}.\n{err}"))
621
726
  return
622
727
  with self.app.suspend():
623
- netlocations.mount(uri, interactive=True)
624
- 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)
625
730
 
626
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:
@@ -48,6 +49,68 @@ def network_available() -> bool:
48
49
  return gio_available()
49
50
 
50
51
 
52
+ def _gvfs_mount_dirs() -> list[str]:
53
+ data_dirs = os.environ.get("XDG_DATA_DIRS") or "/usr/local/share:/usr/share"
54
+ return [
55
+ os.path.join(d, "gvfs", "mounts")
56
+ for d in ["/usr/share", "/usr/local/share", *data_dirs.split(":")]
57
+ if d
58
+ ]
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
+
76
+ def backend_missing(scheme: str) -> bool:
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."""
80
+ if is_macos() or not gio_available():
81
+ return False
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())
84
+
85
+
86
+ # Package that ships the GVFS backends, per package manager.
87
+ _BACKEND_INSTALL = {
88
+ "apt-get": ["sudo", "apt-get", "install", "gvfs-backends", "gvfs-fuse"],
89
+ "pacman": ["sudo", "pacman", "-S", "gvfs", "gvfs-smb"],
90
+ "dnf": ["sudo", "dnf", "install", "gvfs-smb", "gvfs-fuse"],
91
+ }
92
+
93
+
94
+ def backend_install_command() -> list[str] | None:
95
+ """The command that installs the GVFS backends here, or None if the package manager is unknown."""
96
+ for manager, command in _BACKEND_INSTALL.items():
97
+ if shutil.which(manager):
98
+ return list(command)
99
+ return None
100
+
101
+
102
+ def install_backend() -> bool:
103
+ """Run the install attached to the real terminal (sudo asks for the password). True if it exited 0."""
104
+ command = backend_install_command()
105
+ if command is None:
106
+ return False
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
112
+
113
+
51
114
  def build_uri(scheme: str, host: str, share: str | None = None, user: str | None = None) -> str:
52
115
  host = host.strip().strip("/")
53
116
  user_clean = user.strip() if user else ""
@@ -146,11 +209,15 @@ def _list_shares_darwin(host: str, user: str | None = None) -> tuple[list[str] |
146
209
  return _parse_smbutil_view(res.stdout), ""
147
210
 
148
211
 
149
- 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]:
150
213
  """Shares on an SMB server. Returns (names, "") or (None, error) -- the
151
214
  caller retries after an interactive mount if login was the problem."""
152
215
  if is_macos():
153
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
154
221
  try:
155
222
  res = _gio(["list", build_uri("smb", host)])
156
223
  except subprocess.TimeoutExpired:
@@ -250,11 +317,17 @@ def _mount_darwin(uri: str, interactive: bool = False) -> tuple[bool, str]:
250
317
  return False, f"timed out waiting for /Volumes/{share}"
251
318
 
252
319
 
253
- 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."""
254
323
  if is_macos():
255
324
  return _mount_darwin(uri, interactive=interactive)
325
+ if anonymous is None:
326
+ anonymous = is_anonymous(uri)
327
+ if anonymous:
328
+ interactive = False
256
329
  try:
257
- res = _gio(["mount", uri], interactive)
330
+ res = _gio(["mount", "--anonymous", uri] if anonymous else ["mount", uri], interactive)
258
331
  except subprocess.TimeoutExpired:
259
332
  return False, "timed out"
260
333
  if res.returncode == 0:
@@ -356,7 +429,9 @@ def load_locations() -> list[NetLocation]:
356
429
  for d in json.load(f):
357
430
  uri = d["uri"]
358
431
  user = d.get("user") or _split_uri(uri)[1] or None
359
- 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
+ )
360
435
  return items
361
436
  except (OSError, ValueError, KeyError):
362
437
  return []
@@ -371,18 +446,38 @@ def _write(locations: list[NetLocation]) -> None:
371
446
  user = loc.user or uri_user
372
447
  if user:
373
448
  row["user"] = user
449
+ if loc.anonymous:
450
+ row["anonymous"] = True
374
451
  rows.append(row)
375
452
  with open(NETWORK_FILE, "w") as f:
376
453
  json.dump(rows, f, indent=2)
377
454
 
378
455
 
379
- def save_location(uri: str, user: str | None = None) -> None:
456
+ def save_location(uri: str, user: str | None = None, anonymous: bool = False) -> None:
380
457
  _, uri_user, _, _ = _split_uri(uri)
381
- effective_user = user or uri_user or None
458
+ effective_user = None if anonymous else (user or uri_user or None)
382
459
  locations = [loc for loc in load_locations() if loc.uri != uri]
383
- 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))
384
461
  _write(locations)
385
462
 
386
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
+
387
469
  def remove_location(uri: str) -> None:
388
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.0
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,6 +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
+ 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*.
165
168
 
166
169
  Every key also has a clickable box on the bottom bar: one row on wide windows
167
170
  (168+ columns), two rows when narrower (120 columns is the practical minimum).
@@ -1159,3 +1159,184 @@ async def test_places_unmount_share_checks_type_evicts_panel_and_reports_busy(tm
1159
1159
  await pilot.pause()
1160
1160
  assert isinstance(app.screen, MessageScreen)
1161
1161
  assert "failed to unmount" in app.screen.text
1162
+
1163
+
1164
+ @pytest.mark.asyncio
1165
+ async def test_missing_gvfs_backend_offers_install_and_runs_it(tmp_path, monkeypatch):
1166
+ import contextlib
1167
+
1168
+ from commandertui import netlocations
1169
+ from commandertui.modals import ChoiceScreen, InputScreen
1170
+
1171
+ left = tmp_path / "left"
1172
+ right = tmp_path / "right"
1173
+ left.mkdir()
1174
+ right.mkdir()
1175
+ ran = []
1176
+ monkeypatch.setattr(netlocations, "gio_available", lambda: True)
1177
+ monkeypatch.setattr(netlocations, "backend_missing", lambda scheme: True)
1178
+ monkeypatch.setattr(
1179
+ netlocations, "backend_install_command", lambda: ["sudo", "apt-get", "install", "gvfs-backends"]
1180
+ )
1181
+ monkeypatch.setattr(netlocations, "install_backend", lambda: ran.append(True) or True)
1182
+
1183
+ app = CommanderApp(left_path=str(left), right_path=str(right))
1184
+ async with app.run_test() as pilot:
1185
+ await _skip_boot(pilot)
1186
+ app.suspend = contextlib.nullcontext # headless: no real terminal to hand over
1187
+ app.screen._add_network()
1188
+ await pilot.pause()
1189
+ await pilot.press("enter") # protocol: SMB
1190
+ await pilot.pause()
1191
+ # no server prompt: the app asks to install first
1192
+ assert isinstance(app.screen, ChoiceScreen) and not isinstance(app.screen, InputScreen)
1193
+ assert "gvfs-backends" in app.screen.prompt
1194
+ await pilot.press("enter") # "Install now"
1195
+ await pilot.pause()
1196
+ assert ran == [True]
1197
+
1198
+
1199
+ @pytest.mark.asyncio
1200
+ async def test_missing_gvfs_backend_cancel_installs_nothing(tmp_path, monkeypatch):
1201
+ from commandertui import netlocations
1202
+
1203
+ left = tmp_path / "left"
1204
+ right = tmp_path / "right"
1205
+ left.mkdir()
1206
+ right.mkdir()
1207
+ ran = []
1208
+ monkeypatch.setattr(netlocations, "gio_available", lambda: True)
1209
+ monkeypatch.setattr(netlocations, "backend_missing", lambda scheme: True)
1210
+ monkeypatch.setattr(
1211
+ netlocations, "backend_install_command", lambda: ["sudo", "apt-get", "install", "gvfs-backends"]
1212
+ )
1213
+ monkeypatch.setattr(netlocations, "install_backend", lambda: ran.append(True) or True)
1214
+
1215
+ app = CommanderApp(left_path=str(left), right_path=str(right))
1216
+ async with app.run_test() as pilot:
1217
+ await _skip_boot(pilot)
1218
+ app.screen._add_network()
1219
+ await pilot.pause()
1220
+ await pilot.press("enter")
1221
+ await pilot.pause()
1222
+ await pilot.press("down", "enter") # "Cancel"
1223
+ await pilot.pause()
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/")
@@ -244,3 +244,107 @@ def test_trash_fallback_uses_macos_trash_on_darwin(tmp_path, monkeypatch):
244
244
  assert trash.send_to_trash(str(f)) is True
245
245
  assert not f.exists()
246
246
  assert (fake_home / ".Trash" / "doc.txt").read_text() == "trash me"
247
+
248
+
249
+ def test_backend_missing_checks_gvfs_mount_files(monkeypatch, tmp_path):
250
+ monkeypatch.setattr(netlocations.sys, "platform", "linux")
251
+ monkeypatch.setattr(netlocations, "gio_available", lambda: True)
252
+ mounts = tmp_path / "gvfs" / "mounts"
253
+ mounts.mkdir(parents=True)
254
+ monkeypatch.setattr(netlocations, "_gvfs_mount_dirs", lambda: [str(mounts)])
255
+ monkeypatch.setattr(netlocations, "_fuse_present", lambda: True)
256
+ assert netlocations.backend_missing("smb") is True
257
+ (mounts / "smb.mount").write_text("")
258
+ assert netlocations.backend_missing("smb") is False
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
264
+
265
+
266
+ def test_backend_missing_is_false_on_macos_or_without_gio(monkeypatch):
267
+ monkeypatch.setattr(netlocations.sys, "platform", "darwin")
268
+ assert netlocations.backend_missing("smb") is False
269
+ monkeypatch.setattr(netlocations.sys, "platform", "linux")
270
+ monkeypatch.setattr(netlocations, "gio_available", lambda: False)
271
+ assert netlocations.backend_missing("smb") is False
272
+
273
+
274
+ def test_backend_install_command_per_package_manager(monkeypatch):
275
+ monkeypatch.setattr(
276
+ netlocations.shutil, "which", lambda name: "/usr/bin/" + name if name == "apt-get" else None
277
+ )
278
+ assert netlocations.backend_install_command() == [
279
+ "sudo",
280
+ "apt-get",
281
+ "install",
282
+ "gvfs-backends",
283
+ "gvfs-fuse",
284
+ ]
285
+ monkeypatch.setattr(
286
+ netlocations.shutil, "which", lambda name: "/usr/bin/pacman" if name == "pacman" else None
287
+ )
288
+ assert netlocations.backend_install_command() == ["sudo", "pacman", "-S", "gvfs", "gvfs-smb"]
289
+ monkeypatch.setattr(netlocations.shutil, "which", lambda name: None)
290
+ assert netlocations.backend_install_command() is None
291
+ assert netlocations.install_backend() is False
292
+
293
+
294
+ def test_install_backend_reports_exit_code(monkeypatch):
295
+ monkeypatch.setattr(netlocations, "backend_install_command", lambda: ["sudo", "true"])
296
+ monkeypatch.setattr(netlocations.subprocess, "run", lambda cmd, **kw: subprocess.CompletedProcess(cmd, 0))
297
+ assert netlocations.install_backend() is True
298
+ monkeypatch.setattr(netlocations.subprocess, "run", lambda cmd, **kw: subprocess.CompletedProcess(cmd, 1))
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.0"
File without changes
File without changes