commandertui 1.3.0__tar.gz → 1.4.0__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 (51) hide show
  1. {commandertui-1.3.0 → commandertui-1.4.0}/PKG-INFO +15 -12
  2. {commandertui-1.3.0 → commandertui-1.4.0}/README.md +13 -11
  3. commandertui-1.4.0/commandertui/__init__.py +1 -0
  4. {commandertui-1.3.0 → commandertui-1.4.0}/commandertui/app.py +15 -8
  5. {commandertui-1.3.0 → commandertui-1.4.0}/commandertui/ascii_font.py +6 -3
  6. {commandertui-1.3.0 → commandertui-1.4.0}/commandertui/boot.py +60 -12
  7. {commandertui-1.3.0 → commandertui-1.4.0}/commandertui/main_screen.py +150 -17
  8. {commandertui-1.3.0 → commandertui-1.4.0}/commandertui/modals.py +46 -5
  9. commandertui-1.4.0/commandertui/netlocations.py +388 -0
  10. commandertui-1.4.0/commandertui/preview.py +122 -0
  11. {commandertui-1.3.0 → commandertui-1.4.0}/commandertui/settings.py +22 -0
  12. {commandertui-1.3.0 → commandertui-1.4.0}/commandertui/trash.py +10 -3
  13. {commandertui-1.3.0 → commandertui-1.4.0}/commandertui/updater.py +40 -21
  14. {commandertui-1.3.0 → commandertui-1.4.0}/commandertui/widgets.py +116 -5
  15. {commandertui-1.3.0 → commandertui-1.4.0}/commandertui.egg-info/PKG-INFO +15 -12
  16. {commandertui-1.3.0 → commandertui-1.4.0}/commandertui.egg-info/SOURCES.txt +2 -0
  17. {commandertui-1.3.0 → commandertui-1.4.0}/pyproject.toml +1 -0
  18. {commandertui-1.3.0 → commandertui-1.4.0}/tests/test_app_smoke.py +220 -11
  19. commandertui-1.4.0/tests/test_netlocations.py +246 -0
  20. commandertui-1.4.0/tests/test_preview.py +305 -0
  21. {commandertui-1.3.0 → commandertui-1.4.0}/tests/test_updater.py +17 -8
  22. commandertui-1.4.0/tests/test_widgets.py +200 -0
  23. commandertui-1.3.0/commandertui/__init__.py +0 -1
  24. commandertui-1.3.0/commandertui/netlocations.py +0 -118
  25. commandertui-1.3.0/tests/test_netlocations.py +0 -51
  26. commandertui-1.3.0/tests/test_widgets.py +0 -49
  27. {commandertui-1.3.0 → commandertui-1.4.0}/LICENSE +0 -0
  28. {commandertui-1.3.0 → commandertui-1.4.0}/commandertui/bookmarks.py +0 -0
  29. {commandertui-1.3.0 → commandertui-1.4.0}/commandertui/cli.py +0 -0
  30. {commandertui-1.3.0 → commandertui-1.4.0}/commandertui/compare_screen.py +0 -0
  31. {commandertui-1.3.0 → commandertui-1.4.0}/commandertui/diff.py +0 -0
  32. {commandertui-1.3.0 → commandertui-1.4.0}/commandertui/editor.py +0 -0
  33. {commandertui-1.3.0 → commandertui-1.4.0}/commandertui/executor.py +0 -0
  34. {commandertui-1.3.0 → commandertui-1.4.0}/commandertui/models.py +0 -0
  35. {commandertui-1.3.0 → commandertui-1.4.0}/commandertui/plan.py +0 -0
  36. {commandertui-1.3.0 → commandertui-1.4.0}/commandertui/progress_screen.py +0 -0
  37. {commandertui-1.3.0 → commandertui-1.4.0}/commandertui/run_queue.py +0 -0
  38. {commandertui-1.3.0 → commandertui-1.4.0}/commandertui/scanner.py +0 -0
  39. {commandertui-1.3.0 → commandertui-1.4.0}/commandertui/sync.py +0 -0
  40. {commandertui-1.3.0 → commandertui-1.4.0}/commandertui/theme.py +0 -0
  41. {commandertui-1.3.0 → commandertui-1.4.0}/commandertui.egg-info/dependency_links.txt +0 -0
  42. {commandertui-1.3.0 → commandertui-1.4.0}/commandertui.egg-info/entry_points.txt +0 -0
  43. {commandertui-1.3.0 → commandertui-1.4.0}/commandertui.egg-info/requires.txt +0 -0
  44. {commandertui-1.3.0 → commandertui-1.4.0}/commandertui.egg-info/top_level.txt +0 -0
  45. {commandertui-1.3.0 → commandertui-1.4.0}/setup.cfg +0 -0
  46. {commandertui-1.3.0 → commandertui-1.4.0}/tests/test_bookmarks.py +0 -0
  47. {commandertui-1.3.0 → commandertui-1.4.0}/tests/test_diff.py +0 -0
  48. {commandertui-1.3.0 → commandertui-1.4.0}/tests/test_executor.py +0 -0
  49. {commandertui-1.3.0 → commandertui-1.4.0}/tests/test_plan.py +0 -0
  50. {commandertui-1.3.0 → commandertui-1.4.0}/tests/test_scanner.py +0 -0
  51. {commandertui-1.3.0 → commandertui-1.4.0}/tests/test_sync.py +0 -0
@@ -1,11 +1,12 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: commandertui
3
- Version: 1.3.0
3
+ Version: 1.4.0
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
7
7
  Project-URL: Homepage, https://github.com/juniorbarchini-oss/commandertui
8
8
  Classifier: Environment :: Console
9
+ Classifier: Operating System :: MacOS
9
10
  Classifier: Operating System :: POSIX :: Linux
10
11
  Classifier: Programming Language :: Python :: 3
11
12
  Classifier: Topic :: System :: Filesystems
@@ -22,7 +23,7 @@ Dual-pane terminal file manager: compare two folders, sync them, or move
22
23
  files/folders between them (and to USB/SMB/any mounted path) — with a
23
24
  phosphor-green 80s-terminal look and a short modem-style boot animation.
24
25
 
25
- Status: **v1.3.0** — 119/119 tests passing. MIT licensed.
26
+ Status: **v1.4.0** — 153/153 tests passing. MIT licensed.
26
27
 
27
28
  ---
28
29
 
@@ -32,7 +33,7 @@ A safe, simple alternative to heavier dual-pane file managers. It was
32
33
  written after an earlier attempt of the same idea had to be scrapped for
33
34
  running an unauthenticated local HTTP server. This project deliberately:
34
35
 
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),
36
+ - **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
37
  no webview.
37
38
  - **Never does a raw `rm -rf`.** Deletes always go through the desktop trash
38
39
  (`send2trash` → `gio trash` → manual move into `~/.local/share/Trash`, in
@@ -123,7 +124,7 @@ o.window("^commandertui$", { float = true, center = true, size = { 1174, 637 } }
123
124
  | Key | Action |
124
125
  |---|---|
125
126
  | `Tab` | Switch active pane |
126
- | `↑`/`↓`, `Enter` | Navigate, open directory |
127
+ | `↑`/`↓`, `Enter` | Navigate, open directory (or sort column when header is focused with `↑` beyond row 0) |
127
128
  | `Backspace` | Go to parent directory |
128
129
  | `Space` | Mark/unmark item under cursor |
129
130
  | `F2` | Rename the item under the cursor (never overwrites an existing name) |
@@ -134,11 +135,12 @@ o.window("^commandertui$", { float = true, center = true, size = { 1174, 637 } }
134
135
  | `F8` / `Delete` | Delete marked (to trash) |
135
136
  | `/` | Filter the active pane by name (contains, ignores case) as you type; `Enter` keeps it, `Esc` clears it |
136
137
  | `+` / `-` / `*` | 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*) |
138
+ | `u` | Check for updates now (also the **Updates** button): asks PyPI right away and offers the new version if there is one |
139
+ | `v` | Quick Look file preview (macOS only): opens `qlmanage -p` for the file under cursor; press `v` again or close the window to dismiss |
138
140
  | `i` | Size: file/folder count and total bytes of marked items (background, `Esc` stops it) |
139
141
  | `h` | Show/hide hidden (dot) files in both panes |
140
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 |
141
- | `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 |
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 |
142
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) |
143
145
  | `r` | Refresh both panes |
144
146
  | `q` | Quit |
@@ -153,13 +155,13 @@ half-written), and a move never removes a source it didn't fully copy.
153
155
  on-demand mount point doesn't show), GVFS/Nautilus network shares, FUSE
154
156
  mounts inside `$HOME` (e.g. `rclone mount`), and saved bookmarks.
155
157
 
156
- **Network locations (Linux, needs `gio`/GVFS):** in Places press `a`, pick
158
+ **Network locations (Linux, needs `gio`/GVFS; macOS supports SMB via `smbutil`/`osascript` and `/Volumes`):** in Places press `a`, pick
157
159
  SMB, SFTP or FTP and type the server's IP or name. For SMB the app lists the
158
- shares on the server and you pick one; SFTP/FTP connect straight in. The
159
- location is saved in `~/.config/commandertui/network.json` (protocol, host
160
- and share only -- never a password) and reappears in Places, mounting itself
160
+ shares on the server and you pick one; SFTP/FTP connect straight in (Linux only). The
161
+ location is saved in `~/.config/commandertui/network.json` (protocol, host,
162
+ share and user only -- never a password) and reappears in Places, mounting itself
161
163
  when picked. If the server asks for a login, `gio` asks for it in the
162
- terminal and can remember it in the system keyring, exactly like Nautilus.
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).
163
165
 
164
166
  Every key also has a clickable box on the bottom bar: one row on wide windows
165
167
  (168+ columns), two rows when narrower (120 columns is the practical minimum).
@@ -168,7 +170,8 @@ shows the free space of that disk, and the marked count and total size.
168
170
 
169
171
  ## Updates
170
172
 
171
- Once a day, in the background and only after the start-up animation, the app
173
+ On every launch, in the background while the start-up animation plays (its
174
+ CHECKING UPDATES line shows the result), the app
172
175
  asks PyPI for the latest version (a plain GET, nothing about you is sent). If
173
176
  a newer one exists you get a dialog -- **Update now / Later / Skip this
174
177
  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.4.0** — 153/153 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
@@ -105,7 +105,7 @@ o.window("^commandertui$", { float = true, center = true, size = { 1174, 637 } }
105
105
  | Key | Action |
106
106
  |---|---|
107
107
  | `Tab` | Switch active pane |
108
- | `↑`/`↓`, `Enter` | Navigate, open directory |
108
+ | `↑`/`↓`, `Enter` | Navigate, open directory (or sort column when header is focused with `↑` beyond row 0) |
109
109
  | `Backspace` | Go to parent directory |
110
110
  | `Space` | Mark/unmark item under cursor |
111
111
  | `F2` | Rename the item under the cursor (never overwrites an existing name) |
@@ -116,11 +116,12 @@ 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
+ | `v` | Quick Look file preview (macOS only): opens `qlmanage -p` for the file under cursor; press `v` again or close the window to dismiss |
120
121
  | `i` | Size: file/folder count and total bytes of marked items (background, `Esc` stops it) |
121
122
  | `h` | Show/hide hidden (dot) files in both panes |
122
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 |
123
- | `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 |
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
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) |
125
126
  | `r` | Refresh both panes |
126
127
  | `q` | Quit |
@@ -135,13 +136,13 @@ half-written), and a move never removes a source it didn't fully copy.
135
136
  on-demand mount point doesn't show), GVFS/Nautilus network shares, FUSE
136
137
  mounts inside `$HOME` (e.g. `rclone mount`), and saved bookmarks.
137
138
 
138
- **Network locations (Linux, needs `gio`/GVFS):** in Places press `a`, pick
139
+ **Network locations (Linux, needs `gio`/GVFS; macOS supports SMB via `smbutil`/`osascript` and `/Volumes`):** in Places press `a`, pick
139
140
  SMB, SFTP or FTP and type the server's IP or name. For SMB the app lists the
140
- shares on the server and you pick one; SFTP/FTP connect straight in. The
141
- location is saved in `~/.config/commandertui/network.json` (protocol, host
142
- and share only -- never a password) and reappears in Places, mounting itself
141
+ shares on the server and you pick one; SFTP/FTP connect straight in (Linux only). The
142
+ location is saved in `~/.config/commandertui/network.json` (protocol, host,
143
+ share and user only -- never a password) and reappears in Places, mounting itself
143
144
  when picked. If the server asks for a login, `gio` asks for it in the
144
- terminal and can remember it in the system keyring, exactly like Nautilus.
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).
145
146
 
146
147
  Every key also has a clickable box on the bottom bar: one row on wide windows
147
148
  (168+ columns), two rows when narrower (120 columns is the practical minimum).
@@ -150,7 +151,8 @@ shows the free space of that disk, and the marked count and total size.
150
151
 
151
152
  ## Updates
152
153
 
153
- Once a day, in the background and only after the start-up animation, the app
154
+ On every launch, in the background while the start-up animation plays (its
155
+ CHECKING UPDATES line shows the result), the app
154
156
  asks PyPI for the latest version (a plain GET, nothing about you is sent). If
155
157
  a newer one exists you get a dialog -- **Update now / Later / Skip this
156
158
  version** -- and the header keeps saying `update X available (press u)`.
@@ -0,0 +1 @@
1
+ __version__ = "1.4.0"
@@ -4,7 +4,7 @@ import os
4
4
 
5
5
  from textual.app import App
6
6
 
7
- from . import settings, theme, updater
7
+ from . import preview, settings, theme, updater
8
8
  from .boot import BootScreen
9
9
  from .main_screen import MainScreen
10
10
 
@@ -26,11 +26,15 @@ 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("~"))
32
35
 
33
36
  def on_unmount(self) -> None:
37
+ preview.close_preview()
34
38
  # Whatever theme is showing when the app closes is the one to reopen
35
39
  # with -- covers the picker (t) and Textual's own palette (ctrl+p).
36
40
  settings.save_theme(self.theme)
@@ -40,18 +44,21 @@ class CommanderApp(App):
40
44
 
41
45
  def on_mount(self) -> None:
42
46
  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.
47
+ self.push_screen(BootScreen())
48
+ # Looked up while the boot animation plays (it shows the result); the
49
+ # dialog itself waits for the main screen, so it never covers the boot.
48
50
  if self.check_updates:
49
51
  self.run_worker(self._look_for_update, thread=True)
50
52
 
51
53
  def _look_for_update(self) -> None:
52
- info = updater.check()
54
+ info, status = updater.check_status()
55
+ self.call_from_thread(self._update_checked, info, status)
56
+
57
+ def _update_checked(self, info: updater.UpdateInfo | None, status: str) -> None:
58
+ self.update_state = status
53
59
  if info:
54
- self.call_from_thread(self.update_found, info)
60
+ self.update_latest = info.latest
61
+ self.update_found(info)
55
62
 
56
63
  def update_found(self, info: updater.UpdateInfo) -> None:
57
64
  """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, preview, theme, updater
14
14
  from .modals import (
15
15
  ADD_NETWORK,
16
16
  ChoiceScreen,
@@ -44,6 +44,10 @@ class MainScreen(Screen):
44
44
  BINDINGS = [
45
45
  Binding("tab", "switch_pane", "Switch pane", priority=True),
46
46
  Binding("enter", "enter_dir", "Open", priority=True),
47
+ Binding("up", "nav_up", "Up", priority=True),
48
+ Binding("down", "nav_down", "Down", priority=True),
49
+ Binding("left", "nav_left", "Left", priority=True),
50
+ Binding("right", "nav_right", "Right", priority=True),
47
51
  Binding("backspace", "up_dir", "Up", priority=True),
48
52
  Binding("space", "toggle_mark", "Mark", priority=True),
49
53
  Binding("t", "pick_theme", "Theme", priority=True),
@@ -64,6 +68,7 @@ class MainScreen(Screen):
64
68
  Binding("r", "refresh_both", "Refresh", priority=True),
65
69
  Binding("h", "toggle_hidden", "Hidden", priority=True),
66
70
  Binding("i", "show_size", "Size", priority=True),
71
+ Binding("v", "preview_file", "Preview", priority=True),
67
72
  # "app.quit", not "quit": this binding lives on the Screen, and a bare
68
73
  # "quit" resolves against the Screen's own (nonexistent) action_quit
69
74
  # rather than bubbling to the App, so it silently did nothing.
@@ -131,13 +136,91 @@ class MainScreen(Screen):
131
136
 
132
137
  # -------------------------------------------------------------- nav
133
138
  def action_switch_pane(self) -> None:
139
+ panel = self._active_panel()
140
+ if panel.header_active:
141
+ panel.header_active = False
142
+ panel.update_header_labels()
134
143
  self._set_active("right" if self.active_side == "left" else "left")
135
144
 
136
145
  def action_enter_dir(self) -> None:
137
- self._active_panel().enter_selected()
146
+ panel = self._active_panel()
147
+ if panel.header_active:
148
+ col_name = ["Name", "Size", "Modified"][panel.header_selected_index]
149
+ panel.toggle_sort(col_name)
150
+ else:
151
+ panel.enter_selected()
152
+
153
+ def action_nav_up(self) -> None:
154
+ panel = self._active_panel()
155
+ table = panel.query_one(f"#{panel.side}-table", DataTable)
156
+ if panel.header_active:
157
+ return
158
+ if table.cursor_row == 0:
159
+ panel.header_active = True
160
+ panel.update_header_labels()
161
+ else:
162
+ table.action_cursor_up()
163
+
164
+ def action_nav_down(self) -> None:
165
+ panel = self._active_panel()
166
+ table = panel.query_one(f"#{panel.side}-table", DataTable)
167
+ if panel.header_active:
168
+ panel.header_active = False
169
+ panel.update_header_labels()
170
+ else:
171
+ table.action_cursor_down()
172
+
173
+ def action_nav_left(self) -> None:
174
+ panel = self._active_panel()
175
+ if panel.header_active:
176
+ panel.header_selected_index = max(0, panel.header_selected_index - 1)
177
+ panel.update_header_labels()
178
+ else:
179
+ table = panel.query_one(f"#{panel.side}-table", DataTable)
180
+ table.action_cursor_left()
181
+
182
+ def action_nav_right(self) -> None:
183
+ panel = self._active_panel()
184
+ if panel.header_active:
185
+ panel.header_selected_index = min(2, panel.header_selected_index + 1)
186
+ panel.update_header_labels()
187
+ else:
188
+ table = panel.query_one(f"#{panel.side}-table", DataTable)
189
+ table.action_cursor_right()
190
+
191
+ def on_data_table_header_selected(self, event: DataTable.HeaderSelected) -> None:
192
+ # Col 0: " ", Col 1: Name, Col 2: Size, Col 3: Modified (Date)
193
+ if event.column_index < 1:
194
+ return
195
+ col_names = {1: "Name", 2: "Size", 3: "Modified"}
196
+ col_name = col_names.get(event.column_index)
197
+ if not col_name:
198
+ return
199
+ panel = None
200
+ if isinstance(event.data_table.parent, FilePanel):
201
+ panel = event.data_table.parent
202
+ elif event.data_table.id and "left" in event.data_table.id:
203
+ panel = self._panel("left")
204
+ elif event.data_table.id and "right" in event.data_table.id:
205
+ panel = self._panel("right")
206
+ else:
207
+ panel = self._active_panel()
208
+
209
+ if self.active_side != panel.side:
210
+ active = self._active_panel()
211
+ if active.header_active:
212
+ active.header_active = False
213
+ active.update_header_labels()
214
+ self._set_active(panel.side)
215
+
216
+ panel.header_selected_index = event.column_index - 1
217
+ panel.toggle_sort(col_name)
138
218
 
139
219
  def action_up_dir(self) -> None:
140
220
  panel = self._active_panel()
221
+ if panel.header_active:
222
+ panel.header_active = False
223
+ panel.update_header_labels()
141
224
  panel.path = os.path.dirname(panel.path)
142
225
  panel.marked.clear()
143
226
  panel.refresh_listing()
@@ -220,16 +303,33 @@ class MainScreen(Screen):
220
303
 
221
304
  # ------------------------------------------------------------ updates
222
305
  def action_update_app(self) -> None:
223
- if self.app.update_info is None:
224
- self.notify("CommanderTUI is up to date.")
306
+ if self.app.update_info is not None:
307
+ self.app.update_prompted = False
308
+ self.prompt_update()
225
309
  return
226
- self.app.update_prompted = False
227
- self.prompt_update()
310
+ # Nothing known yet: ask PyPI right now instead of trusting the daily check.
311
+ self.notify("Checking for updates...")
312
+ self.run_worker(self._check_now, thread=True)
313
+
314
+ def _check_now(self) -> None:
315
+ info, reachable = updater.check_now()
316
+ self.app.call_from_thread(self._check_done, info, reachable)
317
+
318
+ def _check_done(self, info, reachable: bool) -> None:
319
+ if info is not None:
320
+ self.app.update_prompted = False
321
+ self.app.update_found(info)
322
+ elif not reachable:
323
+ self.notify("Could not reach PyPI. Check the connection and try again.", severity="warning")
324
+ else:
325
+ self.notify(f"CommanderTUI {__version__} is up to date.")
228
326
 
229
327
  def prompt_update(self) -> None:
230
328
  info = self.app.update_info
231
329
  if info is None or self.app.update_prompted:
232
330
  return
331
+ if self.app.screen is not self:
332
+ return # the boot animation (or a dialog) is on top: resume will call us again
233
333
  self.app.update_prompted = True
234
334
 
235
335
  def on_choice(choice: str | None) -> None:
@@ -337,6 +437,20 @@ class MainScreen(Screen):
337
437
  panel.refresh_listing()
338
438
  panel.move_cursor_to(name)
339
439
 
440
+ def action_preview_file(self) -> None:
441
+ if not preview.is_macos():
442
+ return
443
+ panel = self._active_panel()
444
+ name = panel.selected_name()
445
+ if not name or name == PARENT_MARK:
446
+ self.app.push_screen(MessageScreen("Preview works on files only"))
447
+ return
448
+ path = os.path.join(panel.path, name)
449
+ if os.path.isdir(path):
450
+ self.app.push_screen(MessageScreen("Preview works on files only"))
451
+ return
452
+ preview.toggle_preview(path)
453
+
340
454
  def action_copy(self) -> None:
341
455
  self._run_marked_op(OpKind.COPY, "Copy")
342
456
 
@@ -423,29 +537,41 @@ class MainScreen(Screen):
423
537
  self.app.push_screen(MessageScreen("Network locations need `gio` (GVFS) installed."))
424
538
  return
425
539
 
540
+ def on_user(host: str, user: str | None) -> None:
541
+ if not user or not user.strip():
542
+ return
543
+ self._pick_share(host, user=user.strip())
544
+
426
545
  def on_host(scheme: str, host: str | None) -> None:
427
546
  if not host or not host.strip():
428
547
  return
548
+ host_clean = host.strip()
429
549
  if scheme == "smb":
430
- self._pick_share(host.strip())
550
+ if netlocations.is_macos():
551
+ self.app.push_screen(InputScreen("SMB user:"), lambda u: on_user(host_clean, u))
552
+ else:
553
+ self._pick_share(host_clean)
431
554
  else:
432
- self._connect(netlocations.build_uri(scheme, host), save=True)
555
+ self._connect(netlocations.build_uri(scheme, host_clean), save=True)
433
556
 
434
557
  def on_scheme(choice: str | None) -> None:
435
558
  if not choice:
436
559
  return
437
560
  scheme = choice.lower()
561
+ if netlocations.is_macos() and scheme != "smb":
562
+ self.app.push_screen(MessageScreen(netlocations.MACOS_UNSUPPORTED_MSG))
563
+ return
438
564
  self.app.push_screen(
439
565
  InputScreen(f"{choice} server (IP or name):"), lambda host: on_host(scheme, host)
440
566
  )
441
567
 
442
568
  self.app.push_screen(ChoiceScreen("Protocol", ["SMB", "SFTP", "FTP"]), on_scheme)
443
569
 
444
- def _pick_share(self, host: str, retried: bool = False) -> None:
570
+ def _pick_share(self, host: str, retried: bool = False, user: str | None = None) -> None:
445
571
  self.notify(f"Listing shares on {host}...")
446
572
 
447
573
  def work() -> None:
448
- shares, err = netlocations.list_shares(host)
574
+ shares, err = netlocations.list_shares(host, user=user)
449
575
  self.app.call_from_thread(done, shares, err)
450
576
 
451
577
  def done(shares: list[str] | None, err: str) -> None:
@@ -453,23 +579,30 @@ class MainScreen(Screen):
453
579
  if retried:
454
580
  self.app.push_screen(MessageScreen(f"Could not list shares on {host}.\n{err}"))
455
581
  return
456
- # Most likely a login is needed: let gio ask for it on the real
457
- # terminal (and offer to remember it in the keyring).
458
- with self.app.suspend():
459
- netlocations.mount(netlocations.build_uri("smb", host), interactive=True)
460
- self._pick_share(host, retried=True)
582
+ if netlocations.is_macos():
583
+ netlocations.mount(netlocations.build_uri("smb", host, user=user), interactive=True)
584
+ else:
585
+ # Most likely a login is needed: let gio ask for it on the real
586
+ # terminal (and offer to remember it in the keyring).
587
+ with self.app.suspend():
588
+ netlocations.mount(netlocations.build_uri("smb", host), interactive=True)
589
+ self._pick_share(host, retried=True, user=user)
461
590
  return
462
591
  if not shares:
463
592
  self.app.push_screen(MessageScreen(f"No shares found on {host}."))
464
593
  return
465
594
  self.app.push_screen(
466
595
  ChoiceScreen(f"Shares on {host}", shares),
467
- lambda share: share and self._connect(netlocations.build_uri("smb", host, share), save=True),
596
+ lambda share: share
597
+ and self._connect(netlocations.build_uri("smb", host, share, user=user), save=True),
468
598
  )
469
599
 
470
600
  self.run_worker(work, thread=True)
471
601
 
472
602
  def _connect(self, uri: str, save: bool = False, retried: bool = False) -> None:
603
+ if netlocations.is_macos() and not uri.lower().startswith("smb://"):
604
+ self.app.push_screen(MessageScreen(netlocations.MACOS_UNSUPPORTED_MSG))
605
+ return
473
606
  self.notify(f"Connecting to {uri} ...")
474
607
 
475
608
  def work() -> None:
@@ -483,7 +616,7 @@ class MainScreen(Screen):
483
616
  netlocations.save_location(uri)
484
617
  self._go_to(path)
485
618
  return
486
- if retried:
619
+ if retried or netlocations.is_macos():
487
620
  self.app.push_screen(MessageScreen(f"Could not connect to {uri}.\n{err}"))
488
621
  return
489
622
  with self.app.suspend():