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.
- {commandertui-1.4.0 → commandertui-1.4.2}/PKG-INFO +5 -2
- {commandertui-1.4.0 → commandertui-1.4.2}/README.md +4 -1
- commandertui-1.4.2/commandertui/__init__.py +1 -0
- {commandertui-1.4.0 → commandertui-1.4.2}/commandertui/main_screen.py +124 -19
- {commandertui-1.4.0 → commandertui-1.4.2}/commandertui/modals.py +15 -0
- {commandertui-1.4.0 → commandertui-1.4.2}/commandertui/netlocations.py +102 -7
- {commandertui-1.4.0 → commandertui-1.4.2}/commandertui.egg-info/PKG-INFO +5 -2
- {commandertui-1.4.0 → commandertui-1.4.2}/tests/test_app_smoke.py +181 -0
- {commandertui-1.4.0 → commandertui-1.4.2}/tests/test_netlocations.py +104 -0
- commandertui-1.4.0/commandertui/__init__.py +0 -1
- {commandertui-1.4.0 → commandertui-1.4.2}/LICENSE +0 -0
- {commandertui-1.4.0 → commandertui-1.4.2}/commandertui/app.py +0 -0
- {commandertui-1.4.0 → commandertui-1.4.2}/commandertui/ascii_font.py +0 -0
- {commandertui-1.4.0 → commandertui-1.4.2}/commandertui/bookmarks.py +0 -0
- {commandertui-1.4.0 → commandertui-1.4.2}/commandertui/boot.py +0 -0
- {commandertui-1.4.0 → commandertui-1.4.2}/commandertui/cli.py +0 -0
- {commandertui-1.4.0 → commandertui-1.4.2}/commandertui/compare_screen.py +0 -0
- {commandertui-1.4.0 → commandertui-1.4.2}/commandertui/diff.py +0 -0
- {commandertui-1.4.0 → commandertui-1.4.2}/commandertui/editor.py +0 -0
- {commandertui-1.4.0 → commandertui-1.4.2}/commandertui/executor.py +0 -0
- {commandertui-1.4.0 → commandertui-1.4.2}/commandertui/models.py +0 -0
- {commandertui-1.4.0 → commandertui-1.4.2}/commandertui/plan.py +0 -0
- {commandertui-1.4.0 → commandertui-1.4.2}/commandertui/preview.py +0 -0
- {commandertui-1.4.0 → commandertui-1.4.2}/commandertui/progress_screen.py +0 -0
- {commandertui-1.4.0 → commandertui-1.4.2}/commandertui/run_queue.py +0 -0
- {commandertui-1.4.0 → commandertui-1.4.2}/commandertui/scanner.py +0 -0
- {commandertui-1.4.0 → commandertui-1.4.2}/commandertui/settings.py +0 -0
- {commandertui-1.4.0 → commandertui-1.4.2}/commandertui/sync.py +0 -0
- {commandertui-1.4.0 → commandertui-1.4.2}/commandertui/theme.py +0 -0
- {commandertui-1.4.0 → commandertui-1.4.2}/commandertui/trash.py +0 -0
- {commandertui-1.4.0 → commandertui-1.4.2}/commandertui/updater.py +0 -0
- {commandertui-1.4.0 → commandertui-1.4.2}/commandertui/widgets.py +0 -0
- {commandertui-1.4.0 → commandertui-1.4.2}/commandertui.egg-info/SOURCES.txt +0 -0
- {commandertui-1.4.0 → commandertui-1.4.2}/commandertui.egg-info/dependency_links.txt +0 -0
- {commandertui-1.4.0 → commandertui-1.4.2}/commandertui.egg-info/entry_points.txt +0 -0
- {commandertui-1.4.0 → commandertui-1.4.2}/commandertui.egg-info/requires.txt +0 -0
- {commandertui-1.4.0 → commandertui-1.4.2}/commandertui.egg-info/top_level.txt +0 -0
- {commandertui-1.4.0 → commandertui-1.4.2}/pyproject.toml +0 -0
- {commandertui-1.4.0 → commandertui-1.4.2}/setup.cfg +0 -0
- {commandertui-1.4.0 → commandertui-1.4.2}/tests/test_bookmarks.py +0 -0
- {commandertui-1.4.0 → commandertui-1.4.2}/tests/test_diff.py +0 -0
- {commandertui-1.4.0 → commandertui-1.4.2}/tests/test_executor.py +0 -0
- {commandertui-1.4.0 → commandertui-1.4.2}/tests/test_plan.py +0 -0
- {commandertui-1.4.0 → commandertui-1.4.2}/tests/test_preview.py +0 -0
- {commandertui-1.4.0 → commandertui-1.4.2}/tests/test_scanner.py +0 -0
- {commandertui-1.4.0 → commandertui-1.4.2}/tests/test_sync.py +0 -0
- {commandertui-1.4.0 → commandertui-1.4.2}/tests/test_updater.py +0 -0
- {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.
|
|
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",
|
|
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
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
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
|
|
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
|
-
|
|
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(
|
|
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(
|
|
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
|
-
|
|
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(
|
|
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.
|
|
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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|