commandertui 1.3.0__tar.gz → 1.3.1__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.3.0 → commandertui-1.3.1}/PKG-INFO +6 -5
- {commandertui-1.3.0 → commandertui-1.3.1}/README.md +5 -4
- commandertui-1.3.1/commandertui/__init__.py +1 -0
- {commandertui-1.3.0 → commandertui-1.3.1}/commandertui/app.py +13 -7
- {commandertui-1.3.0 → commandertui-1.3.1}/commandertui/ascii_font.py +6 -3
- {commandertui-1.3.0 → commandertui-1.3.1}/commandertui/boot.py +60 -12
- {commandertui-1.3.0 → commandertui-1.3.1}/commandertui/main_screen.py +22 -5
- {commandertui-1.3.0 → commandertui-1.3.1}/commandertui/updater.py +40 -21
- {commandertui-1.3.0 → commandertui-1.3.1}/commandertui/widgets.py +1 -0
- {commandertui-1.3.0 → commandertui-1.3.1}/commandertui.egg-info/PKG-INFO +6 -5
- {commandertui-1.3.0 → commandertui-1.3.1}/tests/test_app_smoke.py +98 -11
- {commandertui-1.3.0 → commandertui-1.3.1}/tests/test_updater.py +17 -8
- commandertui-1.3.0/commandertui/__init__.py +0 -1
- {commandertui-1.3.0 → commandertui-1.3.1}/LICENSE +0 -0
- {commandertui-1.3.0 → commandertui-1.3.1}/commandertui/bookmarks.py +0 -0
- {commandertui-1.3.0 → commandertui-1.3.1}/commandertui/cli.py +0 -0
- {commandertui-1.3.0 → commandertui-1.3.1}/commandertui/compare_screen.py +0 -0
- {commandertui-1.3.0 → commandertui-1.3.1}/commandertui/diff.py +0 -0
- {commandertui-1.3.0 → commandertui-1.3.1}/commandertui/editor.py +0 -0
- {commandertui-1.3.0 → commandertui-1.3.1}/commandertui/executor.py +0 -0
- {commandertui-1.3.0 → commandertui-1.3.1}/commandertui/modals.py +0 -0
- {commandertui-1.3.0 → commandertui-1.3.1}/commandertui/models.py +0 -0
- {commandertui-1.3.0 → commandertui-1.3.1}/commandertui/netlocations.py +0 -0
- {commandertui-1.3.0 → commandertui-1.3.1}/commandertui/plan.py +0 -0
- {commandertui-1.3.0 → commandertui-1.3.1}/commandertui/progress_screen.py +0 -0
- {commandertui-1.3.0 → commandertui-1.3.1}/commandertui/run_queue.py +0 -0
- {commandertui-1.3.0 → commandertui-1.3.1}/commandertui/scanner.py +0 -0
- {commandertui-1.3.0 → commandertui-1.3.1}/commandertui/settings.py +0 -0
- {commandertui-1.3.0 → commandertui-1.3.1}/commandertui/sync.py +0 -0
- {commandertui-1.3.0 → commandertui-1.3.1}/commandertui/theme.py +0 -0
- {commandertui-1.3.0 → commandertui-1.3.1}/commandertui/trash.py +0 -0
- {commandertui-1.3.0 → commandertui-1.3.1}/commandertui.egg-info/SOURCES.txt +0 -0
- {commandertui-1.3.0 → commandertui-1.3.1}/commandertui.egg-info/dependency_links.txt +0 -0
- {commandertui-1.3.0 → commandertui-1.3.1}/commandertui.egg-info/entry_points.txt +0 -0
- {commandertui-1.3.0 → commandertui-1.3.1}/commandertui.egg-info/requires.txt +0 -0
- {commandertui-1.3.0 → commandertui-1.3.1}/commandertui.egg-info/top_level.txt +0 -0
- {commandertui-1.3.0 → commandertui-1.3.1}/pyproject.toml +0 -0
- {commandertui-1.3.0 → commandertui-1.3.1}/setup.cfg +0 -0
- {commandertui-1.3.0 → commandertui-1.3.1}/tests/test_bookmarks.py +0 -0
- {commandertui-1.3.0 → commandertui-1.3.1}/tests/test_diff.py +0 -0
- {commandertui-1.3.0 → commandertui-1.3.1}/tests/test_executor.py +0 -0
- {commandertui-1.3.0 → commandertui-1.3.1}/tests/test_netlocations.py +0 -0
- {commandertui-1.3.0 → commandertui-1.3.1}/tests/test_plan.py +0 -0
- {commandertui-1.3.0 → commandertui-1.3.1}/tests/test_scanner.py +0 -0
- {commandertui-1.3.0 → commandertui-1.3.1}/tests/test_sync.py +0 -0
- {commandertui-1.3.0 → commandertui-1.3.1}/tests/test_widgets.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: commandertui
|
|
3
|
-
Version: 1.3.
|
|
3
|
+
Version: 1.3.1
|
|
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
|
|
@@ -22,7 +22,7 @@ Dual-pane terminal file manager: compare two folders, sync them, or move
|
|
|
22
22
|
files/folders between them (and to USB/SMB/any mounted path) — with a
|
|
23
23
|
phosphor-green 80s-terminal look and a short modem-style boot animation.
|
|
24
24
|
|
|
25
|
-
Status: **v1.3.
|
|
25
|
+
Status: **v1.3.1** — 127/127 tests passing. MIT licensed.
|
|
26
26
|
|
|
27
27
|
---
|
|
28
28
|
|
|
@@ -32,7 +32,7 @@ A safe, simple alternative to heavier dual-pane file managers. It was
|
|
|
32
32
|
written after an earlier attempt of the same idea had to be scrapped for
|
|
33
33
|
running an unauthenticated local HTTP server. This project deliberately:
|
|
34
34
|
|
|
35
|
-
- **Never opens a network port.** Pure terminal UI (Textual), no HTTP server (the only outgoing request is the
|
|
35
|
+
- **Never opens a network port.** Pure terminal UI (Textual), no HTTP server (the only outgoing request is the update check on startup, which you can turn off),
|
|
36
36
|
no webview.
|
|
37
37
|
- **Never does a raw `rm -rf`.** Deletes always go through the desktop trash
|
|
38
38
|
(`send2trash` → `gio trash` → manual move into `~/.local/share/Trash`, in
|
|
@@ -134,7 +134,7 @@ o.window("^commandertui$", { float = true, center = true, size = { 1174, 637 } }
|
|
|
134
134
|
| `F8` / `Delete` | Delete marked (to trash) |
|
|
135
135
|
| `/` | Filter the active pane by name (contains, ignores case) as you type; `Enter` keeps it, `Esc` clears it |
|
|
136
136
|
| `+` / `-` / `*` | Mark / unmark by pattern (`*.iso`, or plain text = contains) / invert marks, over the rows shown |
|
|
137
|
-
| `u` |
|
|
137
|
+
| `u` | Check for updates now (also the **Updates** button): asks PyPI right away and offers the new version if there is one |
|
|
138
138
|
| `i` | Size: file/folder count and total bytes of marked items (background, `Esc` stops it) |
|
|
139
139
|
| `h` | Show/hide hidden (dot) files in both panes |
|
|
140
140
|
| `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 |
|
|
@@ -168,7 +168,8 @@ shows the free space of that disk, and the marked count and total size.
|
|
|
168
168
|
|
|
169
169
|
## Updates
|
|
170
170
|
|
|
171
|
-
|
|
171
|
+
On every launch, in the background while the start-up animation plays (its
|
|
172
|
+
CHECKING UPDATES line shows the result), the app
|
|
172
173
|
asks PyPI for the latest version (a plain GET, nothing about you is sent). If
|
|
173
174
|
a newer one exists you get a dialog -- **Update now / Later / Skip this
|
|
174
175
|
version** -- and the header keeps saying `update X available (press u)`.
|
|
@@ -4,7 +4,7 @@ Dual-pane terminal file manager: compare two folders, sync them, or move
|
|
|
4
4
|
files/folders between them (and to USB/SMB/any mounted path) — with a
|
|
5
5
|
phosphor-green 80s-terminal look and a short modem-style boot animation.
|
|
6
6
|
|
|
7
|
-
Status: **v1.3.
|
|
7
|
+
Status: **v1.3.1** — 127/127 tests passing. MIT licensed.
|
|
8
8
|
|
|
9
9
|
---
|
|
10
10
|
|
|
@@ -14,7 +14,7 @@ A safe, simple alternative to heavier dual-pane file managers. It was
|
|
|
14
14
|
written after an earlier attempt of the same idea had to be scrapped for
|
|
15
15
|
running an unauthenticated local HTTP server. This project deliberately:
|
|
16
16
|
|
|
17
|
-
- **Never opens a network port.** Pure terminal UI (Textual), no HTTP server (the only outgoing request is the
|
|
17
|
+
- **Never opens a network port.** Pure terminal UI (Textual), no HTTP server (the only outgoing request is the update check on startup, which you can turn off),
|
|
18
18
|
no webview.
|
|
19
19
|
- **Never does a raw `rm -rf`.** Deletes always go through the desktop trash
|
|
20
20
|
(`send2trash` → `gio trash` → manual move into `~/.local/share/Trash`, in
|
|
@@ -116,7 +116,7 @@ o.window("^commandertui$", { float = true, center = true, size = { 1174, 637 } }
|
|
|
116
116
|
| `F8` / `Delete` | Delete marked (to trash) |
|
|
117
117
|
| `/` | Filter the active pane by name (contains, ignores case) as you type; `Enter` keeps it, `Esc` clears it |
|
|
118
118
|
| `+` / `-` / `*` | Mark / unmark by pattern (`*.iso`, or plain text = contains) / invert marks, over the rows shown |
|
|
119
|
-
| `u` |
|
|
119
|
+
| `u` | Check for updates now (also the **Updates** button): asks PyPI right away and offers the new version if there is one |
|
|
120
120
|
| `i` | Size: file/folder count and total bytes of marked items (background, `Esc` stops it) |
|
|
121
121
|
| `h` | Show/hide hidden (dot) files in both panes |
|
|
122
122
|
| `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 |
|
|
@@ -150,7 +150,8 @@ shows the free space of that disk, and the marked count and total size.
|
|
|
150
150
|
|
|
151
151
|
## Updates
|
|
152
152
|
|
|
153
|
-
|
|
153
|
+
On every launch, in the background while the start-up animation plays (its
|
|
154
|
+
CHECKING UPDATES line shows the result), the app
|
|
154
155
|
asks PyPI for the latest version (a plain GET, nothing about you is sent). If
|
|
155
156
|
a newer one exists you get a dialog -- **Update now / Later / Skip this
|
|
156
157
|
version** -- and the header keeps saying `update X available (press u)`.
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
__version__ = "1.3.1"
|
|
@@ -26,6 +26,9 @@ class CommanderApp(App):
|
|
|
26
26
|
self.theme = saved if saved in self.available_themes else theme.DEFAULT_THEME
|
|
27
27
|
self.check_updates = check_updates and not updater.updates_disabled()
|
|
28
28
|
self.update_info: updater.UpdateInfo | None = None
|
|
29
|
+
# What the boot screen's CHECKING UPDATES line shows.
|
|
30
|
+
self.update_state = "pending" if self.check_updates else "off"
|
|
31
|
+
self.update_latest = ""
|
|
29
32
|
self.update_prompted = False
|
|
30
33
|
self.left_path = os.path.abspath(left_path or os.path.expanduser("~"))
|
|
31
34
|
self.right_path = os.path.abspath(right_path or os.path.expanduser("~"))
|
|
@@ -40,18 +43,21 @@ class CommanderApp(App):
|
|
|
40
43
|
|
|
41
44
|
def on_mount(self) -> None:
|
|
42
45
|
self.push_screen(MainScreen(self.left_path, self.right_path))
|
|
43
|
-
self.push_screen(BootScreen()
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
# Looked up only once the boot animation is over, so a quick answer
|
|
47
|
-
# can never pop a dialog over it.
|
|
46
|
+
self.push_screen(BootScreen())
|
|
47
|
+
# Looked up while the boot animation plays (it shows the result); the
|
|
48
|
+
# dialog itself waits for the main screen, so it never covers the boot.
|
|
48
49
|
if self.check_updates:
|
|
49
50
|
self.run_worker(self._look_for_update, thread=True)
|
|
50
51
|
|
|
51
52
|
def _look_for_update(self) -> None:
|
|
52
|
-
info = updater.
|
|
53
|
+
info, status = updater.check_status()
|
|
54
|
+
self.call_from_thread(self._update_checked, info, status)
|
|
55
|
+
|
|
56
|
+
def _update_checked(self, info: updater.UpdateInfo | None, status: str) -> None:
|
|
57
|
+
self.update_state = status
|
|
53
58
|
if info:
|
|
54
|
-
self.
|
|
59
|
+
self.update_latest = info.latest
|
|
60
|
+
self.update_found(info)
|
|
55
61
|
|
|
56
62
|
def update_found(self, info: updater.UpdateInfo) -> None:
|
|
57
63
|
"""Remember the newer version and say so in the header; the dialog
|
|
@@ -17,11 +17,14 @@ _FONT: dict[str, list[str]] = {
|
|
|
17
17
|
BLOCK = "█" # solid block, swapped in for the '#' placeholders above
|
|
18
18
|
|
|
19
19
|
|
|
20
|
-
def banner(word: str, gap: int = 1) -> str:
|
|
21
|
-
"""Render `word` as 5
|
|
20
|
+
def banner(word: str, gap: int = 1, scale: int = 1) -> str:
|
|
21
|
+
"""Render `word` as block letters, 5 rows tall at scale 1; a larger scale
|
|
22
|
+
makes every block `scale` cells wide and tall."""
|
|
22
23
|
letters = [_FONT[ch] for ch in word.upper()]
|
|
23
24
|
sep = " " * gap
|
|
24
25
|
rows = []
|
|
25
26
|
for row_index in range(5):
|
|
26
|
-
|
|
27
|
+
row = sep.join(letter[row_index] for letter in letters).replace("#", BLOCK)
|
|
28
|
+
row = "".join(ch * scale for ch in row)
|
|
29
|
+
rows.extend([row] * scale)
|
|
27
30
|
return "\n".join(rows)
|
|
@@ -8,16 +8,25 @@ from textual.widgets import Static
|
|
|
8
8
|
from .ascii_font import banner
|
|
9
9
|
|
|
10
10
|
CURSOR = "█" # solid block, like a CRT text cursor
|
|
11
|
+
SPINNER = "|/-\\"
|
|
12
|
+
LINE_WIDTH = 44 # fixed, so a status changing later never moves the box
|
|
13
|
+
WIDE_ENOUGH = 114 # terminal columns for the double-size title (110 wide with its box)
|
|
11
14
|
|
|
12
15
|
|
|
13
|
-
def
|
|
14
|
-
|
|
16
|
+
def _line(label: str, status: str) -> str:
|
|
17
|
+
dots = "." * (LINE_WIDTH - len(label) - len(status) - 2)
|
|
18
|
+
return f"{label} {dots} {status}"
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def _build_art(scale: int = 1, update_status: str = "OK") -> str:
|
|
22
|
+
title_lines = banner("COMMANDER", scale=scale).split("\n")
|
|
15
23
|
body_lines = [
|
|
16
24
|
"",
|
|
17
25
|
"T U I -- dual-pane file manager",
|
|
18
26
|
"",
|
|
19
|
-
"CONNECT 2400
|
|
20
|
-
"LOADING PANELS
|
|
27
|
+
_line("CONNECT 2400", "OK"),
|
|
28
|
+
_line("LOADING PANELS", "OK"),
|
|
29
|
+
_line("CHECKING UPDATES", update_status),
|
|
21
30
|
"READY.",
|
|
22
31
|
]
|
|
23
32
|
content = title_lines + body_lines
|
|
@@ -31,6 +40,11 @@ def _build_art() -> str:
|
|
|
31
40
|
return "\n".join(boxed)
|
|
32
41
|
|
|
33
42
|
|
|
43
|
+
def update_status_text(state: str, latest: str = "") -> str:
|
|
44
|
+
"""What the CHECKING UPDATES line says for an app update state."""
|
|
45
|
+
return {"ok": "OK", "off": "OFF", "offline": "OFFLINE", "skipped": "SKIPPED"}.get(state, f"NEW {latest}")
|
|
46
|
+
|
|
47
|
+
|
|
34
48
|
ART = _build_art()
|
|
35
49
|
|
|
36
50
|
|
|
@@ -63,13 +77,16 @@ class BootScreen(Screen):
|
|
|
63
77
|
|
|
64
78
|
def __init__(
|
|
65
79
|
self,
|
|
66
|
-
art: str =
|
|
80
|
+
art: str | None = None,
|
|
67
81
|
chars_per_tick: int = 3,
|
|
68
82
|
tick_seconds: float = 0.035,
|
|
69
83
|
bell: bool = True,
|
|
70
84
|
) -> None:
|
|
71
85
|
super().__init__()
|
|
72
|
-
self.
|
|
86
|
+
self.fixed_art = art # tests/tools can pass a fixed picture; normally built live
|
|
87
|
+
self.art = art or ART
|
|
88
|
+
self._scale = 1
|
|
89
|
+
self._frame = 0
|
|
73
90
|
self.chars_per_tick = chars_per_tick
|
|
74
91
|
self.tick_seconds = tick_seconds
|
|
75
92
|
self.bell = bell
|
|
@@ -87,10 +104,35 @@ class BootScreen(Screen):
|
|
|
87
104
|
yield Static("", id="boot-hint")
|
|
88
105
|
|
|
89
106
|
def on_mount(self) -> None:
|
|
107
|
+
# Double-size title when the window is wide enough for it.
|
|
108
|
+
self._scale = 2 if self.app.size.width >= WIDE_ENOUGH else 1
|
|
90
109
|
self._timer = self.set_interval(self.tick_seconds, self._tick)
|
|
91
110
|
|
|
111
|
+
def _pending(self) -> bool:
|
|
112
|
+
return self.fixed_art is None and getattr(self.app, "update_state", "off") == "pending"
|
|
113
|
+
|
|
114
|
+
def _current_art(self) -> str:
|
|
115
|
+
if self.fixed_art is not None:
|
|
116
|
+
return self.fixed_art
|
|
117
|
+
state = getattr(self.app, "update_state", "off")
|
|
118
|
+
if state == "pending":
|
|
119
|
+
status = SPINNER[self._frame % 4]
|
|
120
|
+
else:
|
|
121
|
+
status = update_status_text(state, getattr(self.app, "update_latest", ""))
|
|
122
|
+
return _build_art(self._scale, status)
|
|
123
|
+
|
|
124
|
+
def _limit(self) -> int:
|
|
125
|
+
"""How far the reveal may go: while the update check is still running
|
|
126
|
+
it waits just before the READY. row."""
|
|
127
|
+
if not self._pending():
|
|
128
|
+
return len(self.art)
|
|
129
|
+
ready = self.art.index("READY.")
|
|
130
|
+
return self.art.rfind("\n", 0, ready) + 1
|
|
131
|
+
|
|
92
132
|
def _tick(self) -> None:
|
|
93
|
-
self.
|
|
133
|
+
self._frame += 1
|
|
134
|
+
self.art = self._current_art()
|
|
135
|
+
self._pos = min(self._limit(), self._pos + self.chars_per_tick)
|
|
94
136
|
widget = self.query_one("#boot-text", Static)
|
|
95
137
|
visible = self.art[: self._pos]
|
|
96
138
|
cursor = CURSOR if self._pos < len(self.art) else ""
|
|
@@ -102,13 +144,19 @@ class BootScreen(Screen):
|
|
|
102
144
|
self._timer.stop()
|
|
103
145
|
if self.bell:
|
|
104
146
|
self.app.bell()
|
|
105
|
-
self.query_one("#boot-hint", Static).update("[ENTER] to continue")
|
|
147
|
+
self.query_one("#boot-hint", Static).update("\\[ENTER] to continue")
|
|
106
148
|
|
|
107
149
|
def action_continue_(self) -> None:
|
|
108
|
-
if
|
|
109
|
-
# Not drawn yet: Enter fast-forwards
|
|
110
|
-
#
|
|
111
|
-
self._pos =
|
|
150
|
+
if self._pos < self._limit():
|
|
151
|
+
# Not drawn yet: Enter fast-forwards as far as it can go instead
|
|
152
|
+
# of doing nothing, in case someone doesn't want to wait.
|
|
153
|
+
self._pos = self._limit()
|
|
112
154
|
self._tick()
|
|
113
155
|
return
|
|
156
|
+
if self._pending() and self._pos < len(self.art):
|
|
157
|
+
# Reveal is parked waiting on the update check: don't make the
|
|
158
|
+
# user wait for the network; a newer version still gets offered
|
|
159
|
+
# when it arrives.
|
|
160
|
+
self.dismiss()
|
|
161
|
+
return
|
|
114
162
|
self.dismiss()
|
|
@@ -10,7 +10,7 @@ from textual.screen import Screen
|
|
|
10
10
|
from textual.widgets import DataTable, Header
|
|
11
11
|
|
|
12
12
|
from .compare_screen import CompareScreen
|
|
13
|
-
from . import editor, netlocations, theme, updater
|
|
13
|
+
from . import __version__, editor, netlocations, theme, updater
|
|
14
14
|
from .modals import (
|
|
15
15
|
ADD_NETWORK,
|
|
16
16
|
ChoiceScreen,
|
|
@@ -220,16 +220,33 @@ class MainScreen(Screen):
|
|
|
220
220
|
|
|
221
221
|
# ------------------------------------------------------------ updates
|
|
222
222
|
def action_update_app(self) -> None:
|
|
223
|
-
if self.app.update_info is None:
|
|
224
|
-
self.
|
|
223
|
+
if self.app.update_info is not None:
|
|
224
|
+
self.app.update_prompted = False
|
|
225
|
+
self.prompt_update()
|
|
225
226
|
return
|
|
226
|
-
|
|
227
|
-
self.
|
|
227
|
+
# Nothing known yet: ask PyPI right now instead of trusting the daily check.
|
|
228
|
+
self.notify("Checking for updates...")
|
|
229
|
+
self.run_worker(self._check_now, thread=True)
|
|
230
|
+
|
|
231
|
+
def _check_now(self) -> None:
|
|
232
|
+
info, reachable = updater.check_now()
|
|
233
|
+
self.app.call_from_thread(self._check_done, info, reachable)
|
|
234
|
+
|
|
235
|
+
def _check_done(self, info, reachable: bool) -> None:
|
|
236
|
+
if info is not None:
|
|
237
|
+
self.app.update_prompted = False
|
|
238
|
+
self.app.update_found(info)
|
|
239
|
+
elif not reachable:
|
|
240
|
+
self.notify("Could not reach PyPI. Check the connection and try again.", severity="warning")
|
|
241
|
+
else:
|
|
242
|
+
self.notify(f"CommanderTUI {__version__} is up to date.")
|
|
228
243
|
|
|
229
244
|
def prompt_update(self) -> None:
|
|
230
245
|
info = self.app.update_info
|
|
231
246
|
if info is None or self.app.update_prompted:
|
|
232
247
|
return
|
|
248
|
+
if self.app.screen is not self:
|
|
249
|
+
return # the boot animation (or a dialog) is on top: resume will call us again
|
|
233
250
|
self.app.update_prompted = True
|
|
234
251
|
|
|
235
252
|
def on_choice(choice: str | None) -> None:
|
|
@@ -2,8 +2,8 @@
|
|
|
2
2
|
another app and change PACKAGE / REPO to give it the same behavior.
|
|
3
3
|
|
|
4
4
|
What it does, and nothing more:
|
|
5
|
-
*
|
|
6
|
-
GET; nothing about the user is sent);
|
|
5
|
+
* on every launch, in the background, asks PyPI for the latest version (a
|
|
6
|
+
plain GET; nothing about the user is sent);
|
|
7
7
|
* tells the app if a newer one exists and it has not been skipped;
|
|
8
8
|
* builds the right upgrade command for however this copy was installed.
|
|
9
9
|
It never installs anything by itself -- the app runs the command only after
|
|
@@ -29,7 +29,6 @@ PACKAGE = "commandertui"
|
|
|
29
29
|
REPO = "juniorbarchini-oss/commandertui"
|
|
30
30
|
PYPI_URL = f"https://pypi.org/pypi/{PACKAGE}/json"
|
|
31
31
|
CACHE_FILE = os.path.join(CONFIG_DIR, "update.json")
|
|
32
|
-
CHECK_EVERY = 24 * 3600
|
|
33
32
|
FETCH_TIMEOUT = 4
|
|
34
33
|
ENV_DISABLE = "COMMANDERTUI_NO_UPDATE_CHECK"
|
|
35
34
|
ENV_FAKE_LATEST = "COMMANDERTUI_FAKE_LATEST" # shows the update dialog without a real release
|
|
@@ -126,27 +125,47 @@ def updates_disabled(cli_flag: bool = False) -> bool:
|
|
|
126
125
|
return cli_flag or bool(os.environ.get(ENV_DISABLE))
|
|
127
126
|
|
|
128
127
|
|
|
129
|
-
def
|
|
130
|
-
"""Blocking (network): call from a worker thread. Returns
|
|
131
|
-
|
|
128
|
+
def check_status(current: str = __version__, now: float | None = None) -> tuple[UpdateInfo | None, str]:
|
|
129
|
+
"""Blocking (network): call from a worker thread. Returns (info, status)
|
|
130
|
+
with status "new" (info set), "ok" (up to date), "skipped" (a newer one
|
|
131
|
+
exists but the user skipped it) or "offline"."""
|
|
132
132
|
now = time.time() if now is None else now
|
|
133
133
|
cache = _load_cache()
|
|
134
134
|
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
135
|
+
# Asked every launch (one small GET in the background): a release is seen
|
|
136
|
+
# the moment it exists, not up to a day later.
|
|
137
|
+
latest = os.environ.get(ENV_FAKE_LATEST) or fetch_latest()
|
|
138
|
+
if latest is None:
|
|
139
|
+
return None, "offline"
|
|
140
|
+
cache.update(checked=now, latest=latest)
|
|
141
|
+
_save_cache(cache)
|
|
142
|
+
|
|
143
|
+
if not is_newer(latest, current):
|
|
144
|
+
return None, "ok"
|
|
145
|
+
if cache.get("skipped") == latest:
|
|
146
|
+
return None, "skipped"
|
|
147
|
+
return UpdateInfo(current=current, latest=latest, install=detect_install()), "new"
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
def check(current: str = __version__, now: float | None = None) -> UpdateInfo | None:
|
|
151
|
+
"""Just the UpdateInfo of check_status(): None when there is nothing to offer."""
|
|
152
|
+
return check_status(current, now)[0]
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
def check_now(current: str = __version__) -> tuple[UpdateInfo | None, bool]:
|
|
156
|
+
"""The manual "Check for updates": always asks PyPI (ignores the daily
|
|
157
|
+
cache and a previous Skip, since the user is asking). Blocking (network).
|
|
158
|
+
Returns (UpdateInfo or None, reachable)."""
|
|
159
|
+
latest = os.environ.get(ENV_FAKE_LATEST) or fetch_latest()
|
|
160
|
+
if latest is None:
|
|
161
|
+
return None, False
|
|
162
|
+
cache = _load_cache()
|
|
163
|
+
cache.update(checked=time.time(), latest=latest)
|
|
164
|
+
cache.pop("skipped", None)
|
|
165
|
+
_save_cache(cache)
|
|
166
|
+
if not is_newer(latest, current):
|
|
167
|
+
return None, True
|
|
168
|
+
return UpdateInfo(current=current, latest=latest, install=detect_install()), True
|
|
150
169
|
|
|
151
170
|
|
|
152
171
|
def skip_version(version: str) -> None:
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: commandertui
|
|
3
|
-
Version: 1.3.
|
|
3
|
+
Version: 1.3.1
|
|
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
|
|
@@ -22,7 +22,7 @@ Dual-pane terminal file manager: compare two folders, sync them, or move
|
|
|
22
22
|
files/folders between them (and to USB/SMB/any mounted path) — with a
|
|
23
23
|
phosphor-green 80s-terminal look and a short modem-style boot animation.
|
|
24
24
|
|
|
25
|
-
Status: **v1.3.
|
|
25
|
+
Status: **v1.3.1** — 127/127 tests passing. MIT licensed.
|
|
26
26
|
|
|
27
27
|
---
|
|
28
28
|
|
|
@@ -32,7 +32,7 @@ A safe, simple alternative to heavier dual-pane file managers. It was
|
|
|
32
32
|
written after an earlier attempt of the same idea had to be scrapped for
|
|
33
33
|
running an unauthenticated local HTTP server. This project deliberately:
|
|
34
34
|
|
|
35
|
-
- **Never opens a network port.** Pure terminal UI (Textual), no HTTP server (the only outgoing request is the
|
|
35
|
+
- **Never opens a network port.** Pure terminal UI (Textual), no HTTP server (the only outgoing request is the update check on startup, which you can turn off),
|
|
36
36
|
no webview.
|
|
37
37
|
- **Never does a raw `rm -rf`.** Deletes always go through the desktop trash
|
|
38
38
|
(`send2trash` → `gio trash` → manual move into `~/.local/share/Trash`, in
|
|
@@ -134,7 +134,7 @@ o.window("^commandertui$", { float = true, center = true, size = { 1174, 637 } }
|
|
|
134
134
|
| `F8` / `Delete` | Delete marked (to trash) |
|
|
135
135
|
| `/` | Filter the active pane by name (contains, ignores case) as you type; `Enter` keeps it, `Esc` clears it |
|
|
136
136
|
| `+` / `-` / `*` | Mark / unmark by pattern (`*.iso`, or plain text = contains) / invert marks, over the rows shown |
|
|
137
|
-
| `u` |
|
|
137
|
+
| `u` | Check for updates now (also the **Updates** button): asks PyPI right away and offers the new version if there is one |
|
|
138
138
|
| `i` | Size: file/folder count and total bytes of marked items (background, `Esc` stops it) |
|
|
139
139
|
| `h` | Show/hide hidden (dot) files in both panes |
|
|
140
140
|
| `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 |
|
|
@@ -168,7 +168,8 @@ shows the free space of that disk, and the marked count and total size.
|
|
|
168
168
|
|
|
169
169
|
## Updates
|
|
170
170
|
|
|
171
|
-
|
|
171
|
+
On every launch, in the background while the start-up animation plays (its
|
|
172
|
+
CHECKING UPDATES line shows the result), the app
|
|
172
173
|
asks PyPI for the latest version (a plain GET, nothing about you is sent). If
|
|
173
174
|
a newer one exists you get a dialog -- **Update now / Later / Skip this
|
|
174
175
|
version** -- and the header keeps saying `update X available (press u)`.
|
|
@@ -6,12 +6,18 @@ from commandertui.modals import ConfirmScreen, MessageScreen
|
|
|
6
6
|
|
|
7
7
|
async def _skip_boot(pilot):
|
|
8
8
|
"""Boot screen no longer auto-dismisses: Enter fast-forwards the reveal,
|
|
9
|
-
a
|
|
10
|
-
|
|
11
|
-
await pilot.press("enter")
|
|
12
|
-
await pilot.pause()
|
|
13
|
-
await pilot.press("enter")
|
|
9
|
+
a later Enter dismisses it (one more if the update check finishes in
|
|
10
|
+
between) -- mirrors what a user actually does."""
|
|
14
11
|
await pilot.pause()
|
|
12
|
+
for _ in range(4):
|
|
13
|
+
await pilot.press("enter")
|
|
14
|
+
await pilot.pause()
|
|
15
|
+
if app_screen_is_main(pilot.app):
|
|
16
|
+
break
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def app_screen_is_main(app) -> bool:
|
|
20
|
+
return app.screen.__class__.__name__ != "BootScreen"
|
|
15
21
|
|
|
16
22
|
|
|
17
23
|
@pytest.mark.asyncio
|
|
@@ -353,14 +359,14 @@ async def test_main_bars_fit_in_120_columns(tmp_path):
|
|
|
353
359
|
async with app.run_test(size=(120, 30)) as pilot:
|
|
354
360
|
await _skip_boot(pilot)
|
|
355
361
|
buttons = list(app.screen.query("FnButton"))
|
|
356
|
-
assert len(buttons) ==
|
|
362
|
+
assert len(buttons) == 17
|
|
357
363
|
assert all(b.region.right <= 120 for b in buttons)
|
|
358
364
|
|
|
359
365
|
|
|
360
366
|
@pytest.mark.asyncio
|
|
361
367
|
async def test_bars_share_one_row_when_wide_and_stack_when_narrow(tmp_path):
|
|
362
368
|
app, left, right = await _open_confirm(tmp_path, "")
|
|
363
|
-
async with app.run_test(size=(
|
|
369
|
+
async with app.run_test(size=(210, 30)) as pilot:
|
|
364
370
|
await _skip_boot(pilot)
|
|
365
371
|
await pilot.pause()
|
|
366
372
|
rows = {b.region.y for b in app.screen.query("FnButton")}
|
|
@@ -678,8 +684,9 @@ async def test_update_dialog_appears_after_the_boot_screen(tmp_path, monkeypatch
|
|
|
678
684
|
app, left, right = await _open_confirm(tmp_path, "")
|
|
679
685
|
async with app.run_test(size=(140, 30)) as pilot:
|
|
680
686
|
await pilot.pause(0.5)
|
|
681
|
-
assert app.update_info is None #
|
|
682
|
-
assert
|
|
687
|
+
assert app.update_info is not None # looked up while the boot animation plays...
|
|
688
|
+
assert app.screen.__class__.__name__ == "BootScreen"
|
|
689
|
+
assert not isinstance(app.screen, UpdateScreen) # ...but the dialog waits for the main screen
|
|
683
690
|
await _skip_boot(pilot)
|
|
684
691
|
await pilot.pause(0.5)
|
|
685
692
|
assert isinstance(app.screen, UpdateScreen)
|
|
@@ -752,15 +759,60 @@ async def test_source_checkout_offers_no_update_button(tmp_path, monkeypatch):
|
|
|
752
759
|
|
|
753
760
|
|
|
754
761
|
@pytest.mark.asyncio
|
|
755
|
-
async def
|
|
762
|
+
async def test_u_checks_now_and_says_up_to_date(tmp_path, monkeypatch):
|
|
763
|
+
from commandertui import updater
|
|
764
|
+
|
|
765
|
+
monkeypatch.setattr(updater, "check_now", lambda: (None, True))
|
|
756
766
|
app, left, right = await _open_confirm(tmp_path, "")
|
|
757
767
|
async with app.run_test(size=(140, 30)) as pilot:
|
|
758
768
|
await _skip_boot(pilot)
|
|
759
769
|
await pilot.press("u")
|
|
760
|
-
await pilot.pause()
|
|
770
|
+
await pilot.pause(0.3)
|
|
761
771
|
assert any("up to date" in n.message for n in app._notifications)
|
|
762
772
|
|
|
763
773
|
|
|
774
|
+
@pytest.mark.asyncio
|
|
775
|
+
async def test_u_checks_now_and_offers_a_new_version_ignoring_the_daily_cache(tmp_path, monkeypatch):
|
|
776
|
+
from commandertui import updater
|
|
777
|
+
from commandertui.modals import UpdateScreen
|
|
778
|
+
|
|
779
|
+
info = updater.UpdateInfo(current="1.2.0", latest="9.9.9", install="pip")
|
|
780
|
+
monkeypatch.setattr(updater, "check_now", lambda: (info, True))
|
|
781
|
+
app, left, right = await _open_confirm(tmp_path, "")
|
|
782
|
+
async with app.run_test(size=(140, 30)) as pilot:
|
|
783
|
+
await _skip_boot(pilot)
|
|
784
|
+
await pilot.click("#fb-6") # the Updates button
|
|
785
|
+
await pilot.pause(0.3)
|
|
786
|
+
assert isinstance(app.screen, UpdateScreen)
|
|
787
|
+
|
|
788
|
+
|
|
789
|
+
@pytest.mark.asyncio
|
|
790
|
+
async def test_u_says_when_pypi_cannot_be_reached(tmp_path, monkeypatch):
|
|
791
|
+
from commandertui import updater
|
|
792
|
+
|
|
793
|
+
monkeypatch.setattr(updater, "check_now", lambda: (None, False))
|
|
794
|
+
app, left, right = await _open_confirm(tmp_path, "")
|
|
795
|
+
async with app.run_test(size=(140, 30)) as pilot:
|
|
796
|
+
await _skip_boot(pilot)
|
|
797
|
+
await pilot.press("u")
|
|
798
|
+
await pilot.pause(0.3)
|
|
799
|
+
assert any("Could not reach" in n.message for n in app._notifications)
|
|
800
|
+
|
|
801
|
+
|
|
802
|
+
def test_check_now_ignores_cache_and_skip(monkeypatch):
|
|
803
|
+
from commandertui import updater
|
|
804
|
+
|
|
805
|
+
updater._save_cache({"checked": 9e12, "latest": "1.0.0", "skipped": "2.0.0"})
|
|
806
|
+
monkeypatch.setattr(updater, "fetch_latest", lambda: "2.0.0")
|
|
807
|
+
monkeypatch.setattr(updater, "detect_install", lambda: "pip")
|
|
808
|
+
info, reachable = updater.check_now(current="1.0.0")
|
|
809
|
+
assert reachable and info.latest == "2.0.0" # the skipped version is offered again
|
|
810
|
+
monkeypatch.setattr(updater, "fetch_latest", lambda: None)
|
|
811
|
+
assert updater.check_now(current="1.0.0") == (None, False)
|
|
812
|
+
monkeypatch.setattr(updater, "fetch_latest", lambda: "1.0.0")
|
|
813
|
+
assert updater.check_now(current="1.0.0") == (None, True)
|
|
814
|
+
|
|
815
|
+
|
|
764
816
|
@pytest.mark.asyncio
|
|
765
817
|
async def test_f4_opens_the_editor_on_the_selected_file(tmp_path, monkeypatch):
|
|
766
818
|
import contextlib
|
|
@@ -950,3 +1002,38 @@ async def test_saved_theme_is_used_at_startup_and_unknown_one_is_ignored(tmp_pat
|
|
|
950
1002
|
assert CommanderApp(str(tmp_path), str(tmp_path)).theme == "dracula"
|
|
951
1003
|
settings.save_theme("no-such-theme")
|
|
952
1004
|
assert CommanderApp(str(tmp_path), str(tmp_path)).theme == "phosphor-green"
|
|
1005
|
+
|
|
1006
|
+
|
|
1007
|
+
def test_title_doubles_when_scaled():
|
|
1008
|
+
from commandertui.ascii_font import banner
|
|
1009
|
+
|
|
1010
|
+
small, big = banner("COMMANDER").split("\n"), banner("COMMANDER", scale=2).split("\n")
|
|
1011
|
+
assert len(small) == 5 and len(big) == 10
|
|
1012
|
+
assert len(big[0]) == 2 * len(small[0])
|
|
1013
|
+
assert big[0] == big[1] # each row repeated
|
|
1014
|
+
|
|
1015
|
+
|
|
1016
|
+
@pytest.mark.asyncio
|
|
1017
|
+
async def test_boot_shows_checking_updates_line_with_the_result(tmp_path, monkeypatch):
|
|
1018
|
+
_enable_fake_update(monkeypatch)
|
|
1019
|
+
app, left, right = await _open_confirm(tmp_path, "")
|
|
1020
|
+
async with app.run_test(size=(140, 40)) as pilot:
|
|
1021
|
+
await pilot.pause(0.6)
|
|
1022
|
+
await pilot.press("enter") # fast-forward the reveal
|
|
1023
|
+
await pilot.pause(0.3)
|
|
1024
|
+
text = str(app.screen.query_one("#boot-text").render())
|
|
1025
|
+
assert "CHECKING UPDATES" in text and "NEW 9.9.9" in text
|
|
1026
|
+
assert "READY." in text
|
|
1027
|
+
|
|
1028
|
+
|
|
1029
|
+
@pytest.mark.asyncio
|
|
1030
|
+
@pytest.mark.parametrize("width, scale", [(140, 2), (100, 1)])
|
|
1031
|
+
async def test_boot_title_is_double_size_only_in_a_wide_window(tmp_path, width, scale):
|
|
1032
|
+
app, left, right = await _open_confirm(tmp_path, "")
|
|
1033
|
+
async with app.run_test(size=(width, 40)) as pilot:
|
|
1034
|
+
await pilot.pause()
|
|
1035
|
+
assert app.screen._scale == scale
|
|
1036
|
+
await pilot.press("enter")
|
|
1037
|
+
await pilot.pause(0.2)
|
|
1038
|
+
lines = str(app.screen.query_one("#boot-text").render()).splitlines()
|
|
1039
|
+
assert lines[0].startswith("+") and len(lines[0]) <= width # the box always fits
|
|
@@ -1,5 +1,4 @@
|
|
|
1
1
|
import json
|
|
2
|
-
import time
|
|
3
2
|
|
|
4
3
|
import pytest
|
|
5
4
|
|
|
@@ -15,18 +14,15 @@ def test_version_ordering_is_numeric_not_textual():
|
|
|
15
14
|
assert not updater.is_newer("1.2.0rc1", "1.2.0")
|
|
16
15
|
|
|
17
16
|
|
|
18
|
-
def
|
|
17
|
+
def test_check_asks_pypi_on_every_launch(monkeypatch):
|
|
19
18
|
calls = []
|
|
20
19
|
monkeypatch.setattr(updater, "fetch_latest", lambda: calls.append(1) or "2.0.0")
|
|
21
20
|
monkeypatch.setattr(updater, "detect_install", lambda: "pipx")
|
|
22
21
|
|
|
23
22
|
info = updater.check(current="1.1.0", now=1000.0)
|
|
24
23
|
assert info == updater.UpdateInfo("1.1.0", "2.0.0", "pipx")
|
|
25
|
-
assert updater.check(current="1.1.0", now=1000.0 +
|
|
26
|
-
assert calls == [1] #
|
|
27
|
-
|
|
28
|
-
updater.check(current="1.1.0", now=1000.0 + updater.CHECK_EVERY + 1)
|
|
29
|
-
assert calls == [1, 1] # a day later it asks again
|
|
24
|
+
assert updater.check(current="1.1.0", now=1000.0 + 60) is not None
|
|
25
|
+
assert calls == [1, 1] # no daily cache: each launch asks again
|
|
30
26
|
|
|
31
27
|
|
|
32
28
|
def test_check_is_silent_when_offline(monkeypatch):
|
|
@@ -47,7 +43,7 @@ def test_skipped_version_stays_quiet_but_a_newer_one_does_not(monkeypatch):
|
|
|
47
43
|
updater.skip_version("2.0.0")
|
|
48
44
|
assert updater.check(current="1.1.0") is None
|
|
49
45
|
monkeypatch.setattr(updater, "fetch_latest", lambda: "2.1.0")
|
|
50
|
-
assert updater.check(current="1.1.0"
|
|
46
|
+
assert updater.check(current="1.1.0").latest == "2.1.0"
|
|
51
47
|
|
|
52
48
|
|
|
53
49
|
def test_fetch_latest_swallows_network_errors(monkeypatch):
|
|
@@ -107,3 +103,16 @@ def test_cache_file_is_plain_json(monkeypatch):
|
|
|
107
103
|
updater.check(current="1.1.0", now=5.0)
|
|
108
104
|
with open(updater.CACHE_FILE) as f:
|
|
109
105
|
assert json.load(f) == {"checked": 5.0, "latest": "3.0.0"}
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def test_check_status_tells_ok_new_skipped_and_offline(monkeypatch):
|
|
109
|
+
monkeypatch.setattr(updater, "detect_install", lambda: "pip")
|
|
110
|
+
monkeypatch.setattr(updater, "fetch_latest", lambda: "1.1.0")
|
|
111
|
+
assert updater.check_status(current="1.1.0") == (None, "ok")
|
|
112
|
+
monkeypatch.setattr(updater, "fetch_latest", lambda: "2.0.0")
|
|
113
|
+
info, status = updater.check_status(current="1.1.0")
|
|
114
|
+
assert status == "new" and info.latest == "2.0.0"
|
|
115
|
+
updater.skip_version("2.0.0")
|
|
116
|
+
assert updater.check_status(current="1.1.0") == (None, "skipped")
|
|
117
|
+
monkeypatch.setattr(updater, "fetch_latest", lambda: None)
|
|
118
|
+
assert updater.check_status(current="1.1.0") == (None, "offline")
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
__version__ = "1.3.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
|