commandertui 1.1.0__tar.gz → 1.3.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 (52) hide show
  1. {commandertui-1.1.0 → commandertui-1.3.0}/PKG-INFO +58 -9
  2. {commandertui-1.1.0 → commandertui-1.3.0}/README.md +57 -8
  3. commandertui-1.3.0/commandertui/__init__.py +1 -0
  4. commandertui-1.3.0/commandertui/app.py +81 -0
  5. {commandertui-1.1.0 → commandertui-1.3.0}/commandertui/bookmarks.py +4 -1
  6. {commandertui-1.1.0 → commandertui-1.3.0}/commandertui/cli.py +6 -1
  7. {commandertui-1.1.0 → commandertui-1.3.0}/commandertui/compare_screen.py +97 -9
  8. commandertui-1.3.0/commandertui/editor.py +26 -0
  9. commandertui-1.3.0/commandertui/main_screen.py +493 -0
  10. commandertui-1.3.0/commandertui/modals.py +644 -0
  11. commandertui-1.3.0/commandertui/netlocations.py +118 -0
  12. {commandertui-1.1.0 → commandertui-1.3.0}/commandertui/progress_screen.py +38 -3
  13. commandertui-1.3.0/commandertui/settings.py +35 -0
  14. {commandertui-1.1.0 → commandertui-1.3.0}/commandertui/sync.py +13 -2
  15. commandertui-1.3.0/commandertui/theme.py +284 -0
  16. commandertui-1.3.0/commandertui/updater.py +163 -0
  17. commandertui-1.3.0/commandertui/widgets.py +359 -0
  18. {commandertui-1.1.0 → commandertui-1.3.0}/commandertui.egg-info/PKG-INFO +58 -9
  19. {commandertui-1.1.0 → commandertui-1.3.0}/commandertui.egg-info/SOURCES.txt +6 -0
  20. commandertui-1.3.0/tests/test_app_smoke.py +952 -0
  21. commandertui-1.3.0/tests/test_netlocations.py +51 -0
  22. {commandertui-1.1.0 → commandertui-1.3.0}/tests/test_sync.py +19 -0
  23. commandertui-1.3.0/tests/test_updater.py +109 -0
  24. {commandertui-1.1.0 → commandertui-1.3.0}/tests/test_widgets.py +13 -0
  25. commandertui-1.1.0/commandertui/__init__.py +0 -1
  26. commandertui-1.1.0/commandertui/app.py +0 -40
  27. commandertui-1.1.0/commandertui/main_screen.py +0 -211
  28. commandertui-1.1.0/commandertui/modals.py +0 -250
  29. commandertui-1.1.0/commandertui/theme.py +0 -94
  30. commandertui-1.1.0/commandertui/widgets.py +0 -134
  31. commandertui-1.1.0/tests/test_app_smoke.py +0 -129
  32. {commandertui-1.1.0 → commandertui-1.3.0}/LICENSE +0 -0
  33. {commandertui-1.1.0 → commandertui-1.3.0}/commandertui/ascii_font.py +0 -0
  34. {commandertui-1.1.0 → commandertui-1.3.0}/commandertui/boot.py +0 -0
  35. {commandertui-1.1.0 → commandertui-1.3.0}/commandertui/diff.py +0 -0
  36. {commandertui-1.1.0 → commandertui-1.3.0}/commandertui/executor.py +0 -0
  37. {commandertui-1.1.0 → commandertui-1.3.0}/commandertui/models.py +0 -0
  38. {commandertui-1.1.0 → commandertui-1.3.0}/commandertui/plan.py +0 -0
  39. {commandertui-1.1.0 → commandertui-1.3.0}/commandertui/run_queue.py +0 -0
  40. {commandertui-1.1.0 → commandertui-1.3.0}/commandertui/scanner.py +0 -0
  41. {commandertui-1.1.0 → commandertui-1.3.0}/commandertui/trash.py +0 -0
  42. {commandertui-1.1.0 → commandertui-1.3.0}/commandertui.egg-info/dependency_links.txt +0 -0
  43. {commandertui-1.1.0 → commandertui-1.3.0}/commandertui.egg-info/entry_points.txt +0 -0
  44. {commandertui-1.1.0 → commandertui-1.3.0}/commandertui.egg-info/requires.txt +0 -0
  45. {commandertui-1.1.0 → commandertui-1.3.0}/commandertui.egg-info/top_level.txt +0 -0
  46. {commandertui-1.1.0 → commandertui-1.3.0}/pyproject.toml +0 -0
  47. {commandertui-1.1.0 → commandertui-1.3.0}/setup.cfg +0 -0
  48. {commandertui-1.1.0 → commandertui-1.3.0}/tests/test_bookmarks.py +0 -0
  49. {commandertui-1.1.0 → commandertui-1.3.0}/tests/test_diff.py +0 -0
  50. {commandertui-1.1.0 → commandertui-1.3.0}/tests/test_executor.py +0 -0
  51. {commandertui-1.1.0 → commandertui-1.3.0}/tests/test_plan.py +0 -0
  52. {commandertui-1.1.0 → commandertui-1.3.0}/tests/test_scanner.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: commandertui
3
- Version: 1.1.0
3
+ Version: 1.3.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
@@ -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.1.0** — tests passing. MIT licensed.
25
+ Status: **v1.3.0** — 119/119 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,
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
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
@@ -58,13 +58,19 @@ makepkg -si
58
58
 
59
59
  Remove it with `sudo pacman -R commandertui`.
60
60
 
61
- **Other distros.** It is a regular Python package (Python 3.11+):
61
+ **PyPI (Linux / macOS / WSL).** It is a standard package on [PyPI](https://pypi.org/project/commandertui/) (Python 3.11+). Recommended via `pipx`:
62
62
 
63
63
  ```bash
64
- pipx install git+https://github.com/juniorbarchini-oss/commandertui.git
64
+ pipx install commandertui
65
65
  ```
66
66
 
67
- **macOS.** Same Python package (`pipx install ...` as above), plus
67
+ Or inside an activated virtual environment:
68
+
69
+ ```bash
70
+ pip install commandertui
71
+ ```
72
+
73
+ **macOS.** Same Python package (`pipx install commandertui`), plus
68
74
  [kitty](https://sw.kovidgoyal.net/kitty/) for the standalone window. Places
69
75
  lists the volumes under `/Volumes` (system volumes such as Recovery and
70
76
  Preboot are hidden) and FUSE mounts inside your home folder. To get a
@@ -120,14 +126,20 @@ o.window("^commandertui$", { float = true, center = true, size = { 1174, 637 } }
120
126
  | `↑`/`↓`, `Enter` | Navigate, open directory |
121
127
  | `Backspace` | Go to parent directory |
122
128
  | `Space` | Mark/unmark item under cursor |
129
+ | `F2` | Rename the item under the cursor (never overwrites an existing name) |
130
+ | `F4` | Edit the file under the cursor with `$VISUAL` / `$EDITOR` (falls back to nano, vim or vi); CommanderTUI pauses and comes back when you close the editor |
123
131
  | `F5` | Copy marked (or item under cursor) to the other pane |
124
132
  | `F6` | Move marked to the other pane |
125
133
  | `F7` | Create a folder in the active pane |
126
134
  | `F8` / `Delete` | Delete marked (to trash) |
135
+ | `/` | Filter the active pane by name (contains, ignores case) as you type; `Enter` keeps it, `Esc` clears it |
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*) |
127
138
  | `i` | Size: file/folder count and total bytes of marked items (background, `Esc` stops it) |
128
139
  | `h` | Show/hide hidden (dot) files in both panes |
129
- | `p` | Places: jump to a mounted drive (USB/SMB/rclone) or saved bookmark |
130
- | `c` | Compare: full recursive diff between the two panes' current folders |
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 |
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 |
142
+ | `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) |
131
143
  | `r` | Refresh both panes |
132
144
  | `q` | Quit |
133
145
 
@@ -141,6 +153,41 @@ half-written), and a move never removes a source it didn't fully copy.
141
153
  on-demand mount point doesn't show), GVFS/Nautilus network shares, FUSE
142
154
  mounts inside `$HOME` (e.g. `rclone mount`), and saved bookmarks.
143
155
 
156
+ **Network locations (Linux, needs `gio`/GVFS):** in Places press `a`, pick
157
+ 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
161
+ 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.
163
+
164
+ Every key also has a clickable box on the bottom bar: one row on wide windows
165
+ (168+ columns), two rows when narrower (120 columns is the practical minimum).
166
+ The active pane has the thick border and the inverted path bar; each path bar
167
+ shows the free space of that disk, and the marked count and total size.
168
+
169
+ ## Updates
170
+
171
+ Once a day, in the background and only after the start-up animation, the app
172
+ asks PyPI for the latest version (a plain GET, nothing about you is sent). If
173
+ a newer one exists you get a dialog -- **Update now / Later / Skip this
174
+ version** -- and the header keeps saying `update X available (press u)`.
175
+ It never installs on its own. **Update now** hands the terminal over and runs
176
+ the command that matches how it was installed:
177
+
178
+ | Installed as | Update runs |
179
+ |---|---|
180
+ | Arch package (`makepkg`) | rebuilds from the release's PKGBUILD with `makepkg -si` (your sudo password is typed in the terminal, never read by the app) |
181
+ | pipx | `pipx upgrade commandertui` |
182
+ | pip | `pip install --upgrade commandertui` |
183
+ | git checkout | no button: the dialog tells you to `git pull` |
184
+
185
+ Turn the check off with `commandertui --no-update-check` or by setting
186
+ `COMMANDERTUI_NO_UPDATE_CHECK=1`. `COMMANDERTUI_FAKE_LATEST=9.9.9` shows the
187
+ dialog without a real release, to see how it looks. The logic lives in one
188
+ self-contained file, `commandertui/updater.py`, written to be copied into
189
+ other apps.
190
+
144
191
  Inside **Compare**:
145
192
 
146
193
  | Key | Action |
@@ -167,7 +214,9 @@ commandertui/
167
214
  ├── trash.py # 3-layer safe delete (send2trash / gio trash / manual)
168
215
  ├── executor.py # runs an OperationQueue against the filesystem
169
216
  ├── bookmarks.py # detected mounts (USB/SMB/rclone) + saved places
170
- ├── theme.py # green/amber phosphor palettes
217
+ ├── netlocations.py # SMB/SFTP/FTP locations via gio (list shares, mount, save)
218
+ ├── theme.py # phosphor green/amber themes + the stylesheet (Textual theme variables)
219
+ ├── settings.py # saved settings (color theme)
171
220
  ├── boot.py # startup "modem" reveal animation
172
221
  ├── widgets.py # FilePanel: one pane's directory listing
173
222
  ├── modals.py # Confirm / Places / Input / Size / Message screens
@@ -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.1.0** — tests passing. MIT licensed.
7
+ Status: **v1.3.0** — 119/119 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,
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),
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
@@ -40,13 +40,19 @@ makepkg -si
40
40
 
41
41
  Remove it with `sudo pacman -R commandertui`.
42
42
 
43
- **Other distros.** It is a regular Python package (Python 3.11+):
43
+ **PyPI (Linux / macOS / WSL).** It is a standard package on [PyPI](https://pypi.org/project/commandertui/) (Python 3.11+). Recommended via `pipx`:
44
44
 
45
45
  ```bash
46
- pipx install git+https://github.com/juniorbarchini-oss/commandertui.git
46
+ pipx install commandertui
47
47
  ```
48
48
 
49
- **macOS.** Same Python package (`pipx install ...` as above), plus
49
+ Or inside an activated virtual environment:
50
+
51
+ ```bash
52
+ pip install commandertui
53
+ ```
54
+
55
+ **macOS.** Same Python package (`pipx install commandertui`), plus
50
56
  [kitty](https://sw.kovidgoyal.net/kitty/) for the standalone window. Places
51
57
  lists the volumes under `/Volumes` (system volumes such as Recovery and
52
58
  Preboot are hidden) and FUSE mounts inside your home folder. To get a
@@ -102,14 +108,20 @@ o.window("^commandertui$", { float = true, center = true, size = { 1174, 637 } }
102
108
  | `↑`/`↓`, `Enter` | Navigate, open directory |
103
109
  | `Backspace` | Go to parent directory |
104
110
  | `Space` | Mark/unmark item under cursor |
111
+ | `F2` | Rename the item under the cursor (never overwrites an existing name) |
112
+ | `F4` | Edit the file under the cursor with `$VISUAL` / `$EDITOR` (falls back to nano, vim or vi); CommanderTUI pauses and comes back when you close the editor |
105
113
  | `F5` | Copy marked (or item under cursor) to the other pane |
106
114
  | `F6` | Move marked to the other pane |
107
115
  | `F7` | Create a folder in the active pane |
108
116
  | `F8` / `Delete` | Delete marked (to trash) |
117
+ | `/` | Filter the active pane by name (contains, ignores case) as you type; `Enter` keeps it, `Esc` clears it |
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*) |
109
120
  | `i` | Size: file/folder count and total bytes of marked items (background, `Esc` stops it) |
110
121
  | `h` | Show/hide hidden (dot) files in both panes |
111
- | `p` | Places: jump to a mounted drive (USB/SMB/rclone) or saved bookmark |
112
- | `c` | Compare: full recursive diff between the two panes' current folders |
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 |
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
+ | `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) |
113
125
  | `r` | Refresh both panes |
114
126
  | `q` | Quit |
115
127
 
@@ -123,6 +135,41 @@ half-written), and a move never removes a source it didn't fully copy.
123
135
  on-demand mount point doesn't show), GVFS/Nautilus network shares, FUSE
124
136
  mounts inside `$HOME` (e.g. `rclone mount`), and saved bookmarks.
125
137
 
138
+ **Network locations (Linux, needs `gio`/GVFS):** in Places press `a`, pick
139
+ 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
143
+ 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
+
146
+ Every key also has a clickable box on the bottom bar: one row on wide windows
147
+ (168+ columns), two rows when narrower (120 columns is the practical minimum).
148
+ The active pane has the thick border and the inverted path bar; each path bar
149
+ shows the free space of that disk, and the marked count and total size.
150
+
151
+ ## Updates
152
+
153
+ Once a day, in the background and only after the start-up animation, the app
154
+ asks PyPI for the latest version (a plain GET, nothing about you is sent). If
155
+ a newer one exists you get a dialog -- **Update now / Later / Skip this
156
+ version** -- and the header keeps saying `update X available (press u)`.
157
+ It never installs on its own. **Update now** hands the terminal over and runs
158
+ the command that matches how it was installed:
159
+
160
+ | Installed as | Update runs |
161
+ |---|---|
162
+ | Arch package (`makepkg`) | rebuilds from the release's PKGBUILD with `makepkg -si` (your sudo password is typed in the terminal, never read by the app) |
163
+ | pipx | `pipx upgrade commandertui` |
164
+ | pip | `pip install --upgrade commandertui` |
165
+ | git checkout | no button: the dialog tells you to `git pull` |
166
+
167
+ Turn the check off with `commandertui --no-update-check` or by setting
168
+ `COMMANDERTUI_NO_UPDATE_CHECK=1`. `COMMANDERTUI_FAKE_LATEST=9.9.9` shows the
169
+ dialog without a real release, to see how it looks. The logic lives in one
170
+ self-contained file, `commandertui/updater.py`, written to be copied into
171
+ other apps.
172
+
126
173
  Inside **Compare**:
127
174
 
128
175
  | Key | Action |
@@ -149,7 +196,9 @@ commandertui/
149
196
  ├── trash.py # 3-layer safe delete (send2trash / gio trash / manual)
150
197
  ├── executor.py # runs an OperationQueue against the filesystem
151
198
  ├── bookmarks.py # detected mounts (USB/SMB/rclone) + saved places
152
- ├── theme.py # green/amber phosphor palettes
199
+ ├── netlocations.py # SMB/SFTP/FTP locations via gio (list shares, mount, save)
200
+ ├── theme.py # phosphor green/amber themes + the stylesheet (Textual theme variables)
201
+ ├── settings.py # saved settings (color theme)
153
202
  ├── boot.py # startup "modem" reveal animation
154
203
  ├── widgets.py # FilePanel: one pane's directory listing
155
204
  ├── modals.py # Confirm / Places / Input / Size / Message screens
@@ -0,0 +1 @@
1
+ __version__ = "1.3.0"
@@ -0,0 +1,81 @@
1
+ from __future__ import annotations
2
+
3
+ import os
4
+
5
+ from textual.app import App
6
+
7
+ from . import settings, theme, updater
8
+ from .boot import BootScreen
9
+ from .main_screen import MainScreen
10
+
11
+
12
+ class CommanderApp(App):
13
+ """Owns only the screen stack. All pane behavior lives in MainScreen --
14
+ see its docstring for why that split matters for key bindings.
15
+ """
16
+
17
+ CSS = theme.CSS
18
+
19
+ def __init__(
20
+ self, left_path: str | None = None, right_path: str | None = None, check_updates: bool = True
21
+ ) -> None:
22
+ super().__init__()
23
+ for phosphor in theme.PHOSPHOR_THEMES:
24
+ self.register_theme(phosphor)
25
+ saved = settings.load_theme()
26
+ self.theme = saved if saved in self.available_themes else theme.DEFAULT_THEME
27
+ self.check_updates = check_updates and not updater.updates_disabled()
28
+ self.update_info: updater.UpdateInfo | None = None
29
+ self.update_prompted = False
30
+ self.left_path = os.path.abspath(left_path or os.path.expanduser("~"))
31
+ self.right_path = os.path.abspath(right_path or os.path.expanduser("~"))
32
+
33
+ def on_unmount(self) -> None:
34
+ # Whatever theme is showing when the app closes is the one to reopen
35
+ # with -- covers the picker (t) and Textual's own palette (ctrl+p).
36
+ settings.save_theme(self.theme)
37
+
38
+ def preview_theme(self, name: str) -> None:
39
+ self.theme = name
40
+
41
+ def on_mount(self) -> None:
42
+ 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.
48
+ if self.check_updates:
49
+ self.run_worker(self._look_for_update, thread=True)
50
+
51
+ def _look_for_update(self) -> None:
52
+ info = updater.check()
53
+ if info:
54
+ self.call_from_thread(self.update_found, info)
55
+
56
+ def update_found(self, info: updater.UpdateInfo) -> None:
57
+ """Remember the newer version and say so in the header; the dialog
58
+ itself waits until the main screen is actually showing (not the boot
59
+ animation)."""
60
+ self.update_info = info
61
+ self.sub_title = f"update {info.latest} available (press u)"
62
+ if isinstance(self.screen, MainScreen):
63
+ self.screen.prompt_update()
64
+
65
+ def clear_update(self) -> None:
66
+ self.update_info = None
67
+ self.sub_title = ""
68
+
69
+ # Convenience passthroughs so existing tests/tools that poke at the app
70
+ # directly (e.g. app._panel("left")) keep working without knowing about
71
+ # the MainScreen split.
72
+ def _panel(self, side: str):
73
+ return self.main_screen._panel(side)
74
+
75
+ @property
76
+ def main_screen(self) -> MainScreen:
77
+ return next(s for s in self.screen_stack if isinstance(s, MainScreen))
78
+
79
+ @property
80
+ def active_side(self) -> str:
81
+ return self.main_screen.active_side
@@ -186,4 +186,7 @@ def remove_bookmark(path: str) -> None:
186
186
 
187
187
  def all_places() -> list[Place]:
188
188
  home = Place(label="Home", path=os.path.expanduser("~"), kind="home")
189
- return [home] + detect_mounted() + detect_network_mounts() + detect_home_mounts() + load_saved()
189
+ from .netlocations import load_locations # lazy: netlocations imports CONFIG_DIR from here
190
+
191
+ saved_net = [Place(label=loc.label, path=loc.uri, kind="net-saved") for loc in load_locations()]
192
+ return [home] + detect_mounted() + detect_network_mounts() + detect_home_mounts() + load_saved() + saved_net
@@ -11,10 +11,15 @@ def main() -> None:
11
11
  parser = argparse.ArgumentParser(prog="commandertui", description="Dual-pane TUI file manager")
12
12
  parser.add_argument("left", nargs="?", default=os.path.expanduser("~"))
13
13
  parser.add_argument("right", nargs="?", default=os.path.expanduser("~"))
14
+ parser.add_argument(
15
+ "--no-update-check",
16
+ action="store_true",
17
+ help="never contact PyPI to look for a newer version",
18
+ )
14
19
  parser.add_argument("--version", action="version", version=f"commandertui {__version__}")
15
20
  args = parser.parse_args()
16
21
 
17
- app = CommanderApp(left_path=args.left, right_path=args.right)
22
+ app = CommanderApp(left_path=args.left, right_path=args.right, check_updates=not args.no_update_check)
18
23
  app.run()
19
24
 
20
25
 
@@ -1,16 +1,20 @@
1
1
  from __future__ import annotations
2
2
 
3
+ import threading
4
+ import time
5
+
3
6
  from textual.app import ComposeResult
4
7
  from textual.binding import Binding
5
8
  from textual.containers import Vertical
6
9
  from textual.screen import Screen
7
- from textual.widgets import DataTable, Footer, Static
10
+ from textual.widgets import DataTable, Static
8
11
 
9
12
  from .diff import compare
10
13
  from .modals import MessageScreen
11
- from .models import DiffStatus, OpKind, OpSide, OperationQueue, QueuedOp
14
+ from .models import DiffEntry, DiffStatus, EntryKind, FileEntry, OpKind, OpSide, OperationQueue, QueuedOp
12
15
  from .plan import plan_mirror, plan_two_way, plan_update
13
16
  from .run_queue import run_queue_with_progress
17
+ from .widgets import SPINNER_FRAMES, CompareBar, _format_bytes
14
18
 
15
19
  STATUS_LABEL = {
16
20
  DiffStatus.LEFT_ONLY: "< only left",
@@ -20,6 +24,23 @@ STATUS_LABEL = {
20
24
  }
21
25
 
22
26
 
27
+ def side_info(entry: FileEntry | None) -> str:
28
+ """"1.2 MB 2026-10-20 14:03" for one side of a difference ("-" if absent)."""
29
+ if entry is None:
30
+ return "-"
31
+ when = time.strftime("%Y-%m-%d %H:%M", time.localtime(entry.mtime))
32
+ size = "<dir>" if entry.kind == EntryKind.DIR else _format_bytes(entry.size)
33
+ return f"{size} {when}"
34
+
35
+
36
+ def status_text(d: DiffEntry) -> str:
37
+ """Status label; for a changed file also says which side has the newer copy."""
38
+ label = STATUS_LABEL[d.status]
39
+ if d.status == DiffStatus.MODIFIED and d.left and d.right and d.left.mtime != d.right.mtime:
40
+ label += " (left newer)" if d.left.mtime > d.right.mtime else " (right newer)"
41
+ return label
42
+
43
+
23
44
  class CompareScreen(Screen):
24
45
  """Full recursive diff between the two panes' current directories, with
25
46
  bulk actions (mirror/update/two-way) or per-item manual handling.
@@ -41,51 +62,114 @@ class CompareScreen(Screen):
41
62
  self.left_root = left_root
42
63
  self.right_root = right_root
43
64
  self.manual_marks: set[str] = set()
65
+ self.diffs: list[DiffEntry] = []
66
+ self.scanning = False
67
+ self._cancel = threading.Event()
68
+ self._spinner = None
44
69
 
45
70
  def compose(self) -> ComposeResult:
46
71
  yield Static("", id="compare-summary")
47
72
  yield DataTable(id="compare-table", cursor_type="row")
48
- yield Footer()
73
+ yield CompareBar()
49
74
 
50
75
  def on_mount(self) -> None:
51
76
  self.reload()
52
77
 
78
+ def _stop_spinner(self) -> None:
79
+ if self._spinner is not None:
80
+ self._spinner.stop()
81
+ self._spinner = None
82
+
53
83
  def reload(self) -> None:
54
- self.diffs = compare(self.left_root, self.right_root)
84
+ """Compare in a worker thread (a big tree can take minutes), with a
85
+ spinner so the screen never looks frozen; Esc cancels."""
86
+ if self.scanning:
87
+ return
88
+ self.scanning = True
89
+ cancel = self._cancel = threading.Event()
90
+ summary = self.query_one("#compare-summary", Static)
91
+ started = time.monotonic()
92
+ frame = 0
93
+
94
+ def spin() -> None:
95
+ nonlocal frame
96
+ frame += 1
97
+ summary.update(
98
+ f"{self.left_root} <-> {self.right_root}\n"
99
+ f"Comparing {SPINNER_FRAMES[frame % 4]} {int(time.monotonic() - started)}s"
100
+ " (Esc to cancel)"
101
+ )
102
+
103
+ spin()
104
+ self._spinner = self.set_interval(0.12, spin)
105
+
106
+ def work() -> None:
107
+ try:
108
+ diffs, error = compare(self.left_root, self.right_root, cancel_check=cancel.is_set), ""
109
+ except OSError as e:
110
+ diffs, error = [], str(e)
111
+ if not cancel.is_set():
112
+ self.app.call_from_thread(self._scan_done, diffs, error)
113
+
114
+ self.run_worker(work, thread=True)
115
+
116
+ def _scan_done(self, diffs: list[DiffEntry], error: str) -> None:
117
+ self._stop_spinner()
118
+ self.scanning = False
119
+ if error:
120
+ self.app.push_screen(MessageScreen(f"Could not compare:\n{error}"))
121
+ self.diffs = diffs
122
+ self._fill_table()
123
+
124
+ def _fill_table(self) -> None:
55
125
  table = self.query_one("#compare-table", DataTable)
56
126
  table.clear(columns=True)
57
- table.add_columns(" ", "Path", "Status")
127
+ table.add_columns(" ", "Path", "Status", "Left (size, date)", "Right (size, date)")
58
128
  for d in self.diffs:
59
129
  if d.status == DiffStatus.IDENTICAL:
60
130
  continue
61
131
  mark = "*" if d.rel_path in self.manual_marks else ""
62
- table.add_row(mark, d.rel_path, STATUS_LABEL[d.status], key=d.rel_path)
132
+ table.add_row(
133
+ mark, d.rel_path, status_text(d), side_info(d.left), side_info(d.right), key=d.rel_path
134
+ )
63
135
 
64
136
  non_identical = sum(1 for d in self.diffs if d.status != DiffStatus.IDENTICAL)
65
137
  summary = self.query_one("#compare-summary", Static)
66
138
  summary.update(
67
139
  f"{self.left_root} <-> {self.right_root}\n"
68
- f"{non_identical} differences "
69
- "[m]irror [u]pdate [b]idirectional [space] mark [c]opy marked [esc] back"
140
+ f"{non_identical} differences"
70
141
  )
71
142
 
72
143
  def action_back(self) -> None:
144
+ self._cancel.set() # stops a running comparison; its late result is dropped
145
+ self._stop_spinner()
73
146
  self.dismiss()
74
147
 
148
+ def _busy(self) -> bool:
149
+ if self.scanning:
150
+ self.notify("Still comparing. Wait for it to finish, or press Esc to cancel.", severity="warning")
151
+ return self.scanning
152
+
75
153
  def _run_queue(self, title: str, queue: OperationQueue) -> None:
76
154
  run_queue_with_progress(
77
155
  self, title, queue, self.left_root, self.right_root, after=lambda report: self.reload()
78
156
  )
79
157
 
80
158
  def action_mirror(self) -> None:
159
+ if self._busy():
160
+ return
81
161
  active = [d for d in self.diffs if d.status != DiffStatus.IDENTICAL]
82
162
  self._run_queue("Mirror: make RIGHT identical to LEFT", plan_mirror(active))
83
163
 
84
164
  def action_update(self) -> None:
165
+ if self._busy():
166
+ return
85
167
  active = [d for d in self.diffs if d.status != DiffStatus.IDENTICAL]
86
168
  self._run_queue("Update: copy new/changed LEFT -> RIGHT (no deletes)", plan_update(active))
87
169
 
88
170
  def action_two_way(self) -> None:
171
+ if self._busy():
172
+ return
89
173
  active = [d for d in self.diffs if d.status != DiffStatus.IDENTICAL]
90
174
  queue, conflicts = plan_two_way(active)
91
175
  if conflicts:
@@ -99,6 +183,8 @@ class CompareScreen(Screen):
99
183
  self._run_queue("Two-way: propagate each side's exclusive changes", queue)
100
184
 
101
185
  def action_toggle_manual(self) -> None:
186
+ if self._busy():
187
+ return
102
188
  table = self.query_one("#compare-table", DataTable)
103
189
  if table.row_count == 0:
104
190
  return
@@ -110,9 +196,11 @@ class CompareScreen(Screen):
110
196
  self.manual_marks.discard(rel_path)
111
197
  else:
112
198
  self.manual_marks.add(rel_path)
113
- self.reload()
199
+ self._fill_table()
114
200
 
115
201
  def action_copy_manual(self) -> None:
202
+ if self._busy():
203
+ return
116
204
  if not self.manual_marks:
117
205
  self.app.push_screen(MessageScreen("No items marked. Use [space] first."))
118
206
  return
@@ -0,0 +1,26 @@
1
+ """Which text editor F4 opens: the user's own choice first."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import os
6
+ import shlex
7
+ import shutil
8
+
9
+ FALLBACKS = ("nano", "vim", "vi")
10
+
11
+
12
+ def editor_command() -> list[str] | None:
13
+ """$VISUAL, then $EDITOR, then the first of nano/vim/vi that is installed."""
14
+ for var in ("VISUAL", "EDITOR"):
15
+ value = os.environ.get(var, "").strip()
16
+ if value:
17
+ try:
18
+ parts = shlex.split(value)
19
+ except ValueError:
20
+ continue
21
+ if parts and shutil.which(parts[0]):
22
+ return parts
23
+ for name in FALLBACKS:
24
+ if shutil.which(name):
25
+ return [name]
26
+ return None