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.
Files changed (46) hide show
  1. {commandertui-1.3.0 → commandertui-1.3.1}/PKG-INFO +6 -5
  2. {commandertui-1.3.0 → commandertui-1.3.1}/README.md +5 -4
  3. commandertui-1.3.1/commandertui/__init__.py +1 -0
  4. {commandertui-1.3.0 → commandertui-1.3.1}/commandertui/app.py +13 -7
  5. {commandertui-1.3.0 → commandertui-1.3.1}/commandertui/ascii_font.py +6 -3
  6. {commandertui-1.3.0 → commandertui-1.3.1}/commandertui/boot.py +60 -12
  7. {commandertui-1.3.0 → commandertui-1.3.1}/commandertui/main_screen.py +22 -5
  8. {commandertui-1.3.0 → commandertui-1.3.1}/commandertui/updater.py +40 -21
  9. {commandertui-1.3.0 → commandertui-1.3.1}/commandertui/widgets.py +1 -0
  10. {commandertui-1.3.0 → commandertui-1.3.1}/commandertui.egg-info/PKG-INFO +6 -5
  11. {commandertui-1.3.0 → commandertui-1.3.1}/tests/test_app_smoke.py +98 -11
  12. {commandertui-1.3.0 → commandertui-1.3.1}/tests/test_updater.py +17 -8
  13. commandertui-1.3.0/commandertui/__init__.py +0 -1
  14. {commandertui-1.3.0 → commandertui-1.3.1}/LICENSE +0 -0
  15. {commandertui-1.3.0 → commandertui-1.3.1}/commandertui/bookmarks.py +0 -0
  16. {commandertui-1.3.0 → commandertui-1.3.1}/commandertui/cli.py +0 -0
  17. {commandertui-1.3.0 → commandertui-1.3.1}/commandertui/compare_screen.py +0 -0
  18. {commandertui-1.3.0 → commandertui-1.3.1}/commandertui/diff.py +0 -0
  19. {commandertui-1.3.0 → commandertui-1.3.1}/commandertui/editor.py +0 -0
  20. {commandertui-1.3.0 → commandertui-1.3.1}/commandertui/executor.py +0 -0
  21. {commandertui-1.3.0 → commandertui-1.3.1}/commandertui/modals.py +0 -0
  22. {commandertui-1.3.0 → commandertui-1.3.1}/commandertui/models.py +0 -0
  23. {commandertui-1.3.0 → commandertui-1.3.1}/commandertui/netlocations.py +0 -0
  24. {commandertui-1.3.0 → commandertui-1.3.1}/commandertui/plan.py +0 -0
  25. {commandertui-1.3.0 → commandertui-1.3.1}/commandertui/progress_screen.py +0 -0
  26. {commandertui-1.3.0 → commandertui-1.3.1}/commandertui/run_queue.py +0 -0
  27. {commandertui-1.3.0 → commandertui-1.3.1}/commandertui/scanner.py +0 -0
  28. {commandertui-1.3.0 → commandertui-1.3.1}/commandertui/settings.py +0 -0
  29. {commandertui-1.3.0 → commandertui-1.3.1}/commandertui/sync.py +0 -0
  30. {commandertui-1.3.0 → commandertui-1.3.1}/commandertui/theme.py +0 -0
  31. {commandertui-1.3.0 → commandertui-1.3.1}/commandertui/trash.py +0 -0
  32. {commandertui-1.3.0 → commandertui-1.3.1}/commandertui.egg-info/SOURCES.txt +0 -0
  33. {commandertui-1.3.0 → commandertui-1.3.1}/commandertui.egg-info/dependency_links.txt +0 -0
  34. {commandertui-1.3.0 → commandertui-1.3.1}/commandertui.egg-info/entry_points.txt +0 -0
  35. {commandertui-1.3.0 → commandertui-1.3.1}/commandertui.egg-info/requires.txt +0 -0
  36. {commandertui-1.3.0 → commandertui-1.3.1}/commandertui.egg-info/top_level.txt +0 -0
  37. {commandertui-1.3.0 → commandertui-1.3.1}/pyproject.toml +0 -0
  38. {commandertui-1.3.0 → commandertui-1.3.1}/setup.cfg +0 -0
  39. {commandertui-1.3.0 → commandertui-1.3.1}/tests/test_bookmarks.py +0 -0
  40. {commandertui-1.3.0 → commandertui-1.3.1}/tests/test_diff.py +0 -0
  41. {commandertui-1.3.0 → commandertui-1.3.1}/tests/test_executor.py +0 -0
  42. {commandertui-1.3.0 → commandertui-1.3.1}/tests/test_netlocations.py +0 -0
  43. {commandertui-1.3.0 → commandertui-1.3.1}/tests/test_plan.py +0 -0
  44. {commandertui-1.3.0 → commandertui-1.3.1}/tests/test_scanner.py +0 -0
  45. {commandertui-1.3.0 → commandertui-1.3.1}/tests/test_sync.py +0 -0
  46. {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.0
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.0** — 119/119 tests passing. MIT licensed.
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 daily update check, which you can turn off),
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` | Update: shows the pending new version (see *Updates*) |
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
- Once a day, in the background and only after the start-up animation, the app
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.0** — 119/119 tests passing. MIT licensed.
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 daily update check, which you can turn off),
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` | Update: shows the pending new version (see *Updates*) |
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
- Once a day, in the background and only after the start-up animation, the app
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(), self._after_boot)
44
-
45
- def _after_boot(self, _result=None) -> None:
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.check()
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.call_from_thread(self.update_found, info)
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 lines of block letters."""
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
- rows.append(sep.join(letter[row_index] for letter in letters).replace("#", BLOCK))
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 _build_art() -> str:
14
- title_lines = banner("COMMANDER").split("\n")
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 ...................... OK",
20
- "LOADING PANELS ..................... OK",
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 = ART,
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.art = art
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._pos = min(len(self.art), self._pos + self.chars_per_tick)
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 not self._done:
109
- # Not drawn yet: Enter fast-forwards straight to the final frame
110
- # instead of doing nothing, in case someone doesn't want to wait.
111
- self._pos = len(self.art)
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.notify("CommanderTUI is up to date.")
223
+ if self.app.update_info is not None:
224
+ self.app.update_prompted = False
225
+ self.prompt_update()
225
226
  return
226
- self.app.update_prompted = False
227
- self.prompt_update()
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
- * once a day, in the background, asks PyPI for the latest version (a plain
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 check(current: str = __version__, now: float | None = None) -> UpdateInfo | None:
130
- """Blocking (network): call from a worker thread. Returns an UpdateInfo when
131
- a newer, not-skipped version exists."""
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
- fake = os.environ.get(ENV_FAKE_LATEST)
136
- if fake:
137
- latest = fake
138
- elif cache.get("latest") and now - cache.get("checked", 0) < CHECK_EVERY:
139
- latest = cache["latest"]
140
- else:
141
- latest = fetch_latest()
142
- if latest is None:
143
- return None
144
- cache.update(checked=now, latest=latest)
145
- _save_cache(cache)
146
-
147
- if not is_newer(latest, current) or cache.get("skipped") == latest:
148
- return None
149
- return UpdateInfo(current=current, latest=latest, install=detect_install())
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:
@@ -343,6 +343,7 @@ class FunctionBar(ActionBar):
343
343
  ("F6", "Move", "move"),
344
344
  ("F7", "MkDir", "make_dir"),
345
345
  ("F8", "Delete", "delete"),
346
+ ("u", "Updates", "update_app"),
346
347
  ]
347
348
 
348
349
 
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: commandertui
3
- Version: 1.3.0
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.0** — 119/119 tests passing. MIT licensed.
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 daily update check, which you can turn off),
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` | Update: shows the pending new version (see *Updates*) |
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
- Once a day, in the background and only after the start-up animation, the app
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 second Enter dismisses it -- mirrors what a user actually does."""
10
- await pilot.pause()
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) == 16
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=(200, 30)) as pilot:
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 # nothing is even looked up during the boot animation
682
- assert not isinstance(app.screen, UpdateScreen)
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 test_u_says_up_to_date_when_nothing_is_pending(tmp_path):
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 test_check_fetches_then_reuses_the_cache_for_a_day(monkeypatch):
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 + 3600) is not None
26
- assert calls == [1] # second call came from the cache
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", now=time.time() + updater.CHECK_EVERY + 5).latest == "2.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