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