commandertui 1.2.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 (47) hide show
  1. {commandertui-1.2.0 → commandertui-1.3.1}/PKG-INFO +12 -8
  2. {commandertui-1.2.0 → commandertui-1.3.1}/README.md +11 -7
  3. commandertui-1.3.1/commandertui/__init__.py +1 -0
  4. {commandertui-1.2.0 → commandertui-1.3.1}/commandertui/app.py +27 -10
  5. {commandertui-1.2.0 → commandertui-1.3.1}/commandertui/ascii_font.py +6 -3
  6. {commandertui-1.2.0 → commandertui-1.3.1}/commandertui/boot.py +60 -12
  7. {commandertui-1.2.0 → commandertui-1.3.1}/commandertui/compare_screen.py +94 -6
  8. commandertui-1.3.1/commandertui/editor.py +26 -0
  9. {commandertui-1.2.0 → commandertui-1.3.1}/commandertui/main_screen.py +72 -5
  10. {commandertui-1.2.0 → commandertui-1.3.1}/commandertui/modals.py +19 -1
  11. commandertui-1.3.1/commandertui/settings.py +35 -0
  12. commandertui-1.3.1/commandertui/theme.py +284 -0
  13. {commandertui-1.2.0 → commandertui-1.3.1}/commandertui/updater.py +40 -21
  14. {commandertui-1.2.0 → commandertui-1.3.1}/commandertui/widgets.py +3 -0
  15. {commandertui-1.2.0 → commandertui-1.3.1}/commandertui.egg-info/PKG-INFO +12 -8
  16. {commandertui-1.2.0 → commandertui-1.3.1}/commandertui.egg-info/SOURCES.txt +2 -0
  17. {commandertui-1.2.0 → commandertui-1.3.1}/tests/test_app_smoke.py +296 -18
  18. {commandertui-1.2.0 → commandertui-1.3.1}/tests/test_updater.py +17 -8
  19. commandertui-1.2.0/commandertui/__init__.py +0 -1
  20. commandertui-1.2.0/commandertui/theme.py +0 -255
  21. {commandertui-1.2.0 → commandertui-1.3.1}/LICENSE +0 -0
  22. {commandertui-1.2.0 → commandertui-1.3.1}/commandertui/bookmarks.py +0 -0
  23. {commandertui-1.2.0 → commandertui-1.3.1}/commandertui/cli.py +0 -0
  24. {commandertui-1.2.0 → commandertui-1.3.1}/commandertui/diff.py +0 -0
  25. {commandertui-1.2.0 → commandertui-1.3.1}/commandertui/executor.py +0 -0
  26. {commandertui-1.2.0 → commandertui-1.3.1}/commandertui/models.py +0 -0
  27. {commandertui-1.2.0 → commandertui-1.3.1}/commandertui/netlocations.py +0 -0
  28. {commandertui-1.2.0 → commandertui-1.3.1}/commandertui/plan.py +0 -0
  29. {commandertui-1.2.0 → commandertui-1.3.1}/commandertui/progress_screen.py +0 -0
  30. {commandertui-1.2.0 → commandertui-1.3.1}/commandertui/run_queue.py +0 -0
  31. {commandertui-1.2.0 → commandertui-1.3.1}/commandertui/scanner.py +0 -0
  32. {commandertui-1.2.0 → commandertui-1.3.1}/commandertui/sync.py +0 -0
  33. {commandertui-1.2.0 → commandertui-1.3.1}/commandertui/trash.py +0 -0
  34. {commandertui-1.2.0 → commandertui-1.3.1}/commandertui.egg-info/dependency_links.txt +0 -0
  35. {commandertui-1.2.0 → commandertui-1.3.1}/commandertui.egg-info/entry_points.txt +0 -0
  36. {commandertui-1.2.0 → commandertui-1.3.1}/commandertui.egg-info/requires.txt +0 -0
  37. {commandertui-1.2.0 → commandertui-1.3.1}/commandertui.egg-info/top_level.txt +0 -0
  38. {commandertui-1.2.0 → commandertui-1.3.1}/pyproject.toml +0 -0
  39. {commandertui-1.2.0 → commandertui-1.3.1}/setup.cfg +0 -0
  40. {commandertui-1.2.0 → commandertui-1.3.1}/tests/test_bookmarks.py +0 -0
  41. {commandertui-1.2.0 → commandertui-1.3.1}/tests/test_diff.py +0 -0
  42. {commandertui-1.2.0 → commandertui-1.3.1}/tests/test_executor.py +0 -0
  43. {commandertui-1.2.0 → commandertui-1.3.1}/tests/test_netlocations.py +0 -0
  44. {commandertui-1.2.0 → commandertui-1.3.1}/tests/test_plan.py +0 -0
  45. {commandertui-1.2.0 → commandertui-1.3.1}/tests/test_scanner.py +0 -0
  46. {commandertui-1.2.0 → commandertui-1.3.1}/tests/test_sync.py +0 -0
  47. {commandertui-1.2.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.2.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.2.0** — 108/108 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
@@ -127,17 +127,19 @@ o.window("^commandertui$", { float = true, center = true, size = { 1174, 637 } }
127
127
  | `Backspace` | Go to parent directory |
128
128
  | `Space` | Mark/unmark item under cursor |
129
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 |
130
131
  | `F5` | Copy marked (or item under cursor) to the other pane |
131
132
  | `F6` | Move marked to the other pane |
132
133
  | `F7` | Create a folder in the active pane |
133
134
  | `F8` / `Delete` | Delete marked (to trash) |
134
135
  | `/` | Filter the active pane by name (contains, ignores case) as you type; `Enter` keeps it, `Esc` clears it |
135
136
  | `+` / `-` / `*` | Mark / unmark by pattern (`*.iso`, or plain text = contains) / invert marks, over the rows shown |
136
- | `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 |
137
138
  | `i` | Size: file/folder count and total bytes of marked items (background, `Esc` stops it) |
138
139
  | `h` | Show/hide hidden (dot) files in both panes |
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 |
139
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 |
140
- | `c` | Compare: full recursive diff between the two panes' current folders |
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) |
141
143
  | `r` | Refresh both panes |
142
144
  | `q` | Quit |
143
145
 
@@ -160,13 +162,14 @@ when picked. If the server asks for a login, `gio` asks for it in the
160
162
  terminal and can remember it in the system keyring, exactly like Nautilus.
161
163
 
162
164
  Every key also has a clickable box on the bottom bar: one row on wide windows
163
- (168+ columns), two rows when narrower (110 columns is the practical minimum).
165
+ (168+ columns), two rows when narrower (120 columns is the practical minimum).
164
166
  The active pane has the thick border and the inverted path bar; each path bar
165
167
  shows the free space of that disk, and the marked count and total size.
166
168
 
167
169
  ## Updates
168
170
 
169
- 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
170
173
  asks PyPI for the latest version (a plain GET, nothing about you is sent). If
171
174
  a newer one exists you get a dialog -- **Update now / Later / Skip this
172
175
  version** -- and the header keeps saying `update X available (press u)`.
@@ -213,7 +216,8 @@ commandertui/
213
216
  ├── executor.py # runs an OperationQueue against the filesystem
214
217
  ├── bookmarks.py # detected mounts (USB/SMB/rclone) + saved places
215
218
  ├── netlocations.py # SMB/SFTP/FTP locations via gio (list shares, mount, save)
216
- ├── theme.py # green/amber phosphor palettes
219
+ ├── theme.py # phosphor green/amber themes + the stylesheet (Textual theme variables)
220
+ ├── settings.py # saved settings (color theme)
217
221
  ├── boot.py # startup "modem" reveal animation
218
222
  ├── widgets.py # FilePanel: one pane's directory listing
219
223
  ├── 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.2.0** — 108/108 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
@@ -109,17 +109,19 @@ o.window("^commandertui$", { float = true, center = true, size = { 1174, 637 } }
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) |
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 |
112
113
  | `F5` | Copy marked (or item under cursor) to the other pane |
113
114
  | `F6` | Move marked to the other pane |
114
115
  | `F7` | Create a folder in the active pane |
115
116
  | `F8` / `Delete` | Delete marked (to trash) |
116
117
  | `/` | Filter the active pane by name (contains, ignores case) as you type; `Enter` keeps it, `Esc` clears it |
117
118
  | `+` / `-` / `*` | Mark / unmark by pattern (`*.iso`, or plain text = contains) / invert marks, over the rows shown |
118
- | `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 |
119
120
  | `i` | Size: file/folder count and total bytes of marked items (background, `Esc` stops it) |
120
121
  | `h` | Show/hide hidden (dot) files in both panes |
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 |
121
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 |
122
- | `c` | Compare: full recursive diff between the two panes' current folders |
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) |
123
125
  | `r` | Refresh both panes |
124
126
  | `q` | Quit |
125
127
 
@@ -142,13 +144,14 @@ when picked. If the server asks for a login, `gio` asks for it in the
142
144
  terminal and can remember it in the system keyring, exactly like Nautilus.
143
145
 
144
146
  Every key also has a clickable box on the bottom bar: one row on wide windows
145
- (168+ columns), two rows when narrower (110 columns is the practical minimum).
147
+ (168+ columns), two rows when narrower (120 columns is the practical minimum).
146
148
  The active pane has the thick border and the inverted path bar; each path bar
147
149
  shows the free space of that disk, and the marked count and total size.
148
150
 
149
151
  ## Updates
150
152
 
151
- 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
152
155
  asks PyPI for the latest version (a plain GET, nothing about you is sent). If
153
156
  a newer one exists you get a dialog -- **Update now / Later / Skip this
154
157
  version** -- and the header keeps saying `update X available (press u)`.
@@ -195,7 +198,8 @@ commandertui/
195
198
  ├── executor.py # runs an OperationQueue against the filesystem
196
199
  ├── bookmarks.py # detected mounts (USB/SMB/rclone) + saved places
197
200
  ├── netlocations.py # SMB/SFTP/FTP locations via gio (list shares, mount, save)
198
- ├── theme.py # green/amber phosphor palettes
201
+ ├── theme.py # phosphor green/amber themes + the stylesheet (Textual theme variables)
202
+ ├── settings.py # saved settings (color theme)
199
203
  ├── boot.py # startup "modem" reveal animation
200
204
  ├── widgets.py # FilePanel: one pane's directory listing
201
205
  ├── modals.py # Confirm / Places / Input / Size / Message screens
@@ -0,0 +1 @@
1
+ __version__ = "1.3.1"
@@ -4,10 +4,9 @@ import os
4
4
 
5
5
  from textual.app import App
6
6
 
7
- from . import updater
7
+ from . import settings, theme, updater
8
8
  from .boot import BootScreen
9
9
  from .main_screen import MainScreen
10
- from .theme import css_for
11
10
 
12
11
 
13
12
  class CommanderApp(App):
@@ -15,32 +14,50 @@ class CommanderApp(App):
15
14
  see its docstring for why that split matters for key bindings.
16
15
  """
17
16
 
18
- CSS = css_for("green")
17
+ CSS = theme.CSS
19
18
 
20
19
  def __init__(
21
20
  self, left_path: str | None = None, right_path: str | None = None, check_updates: bool = True
22
21
  ) -> None:
23
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
24
27
  self.check_updates = check_updates and not updater.updates_disabled()
25
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 = ""
26
32
  self.update_prompted = False
27
33
  self.left_path = os.path.abspath(left_path or os.path.expanduser("~"))
28
34
  self.right_path = os.path.abspath(right_path or os.path.expanduser("~"))
29
35
 
36
+ def on_unmount(self) -> None:
37
+ # Whatever theme is showing when the app closes is the one to reopen
38
+ # with -- covers the picker (t) and Textual's own palette (ctrl+p).
39
+ settings.save_theme(self.theme)
40
+
41
+ def preview_theme(self, name: str) -> None:
42
+ self.theme = name
43
+
30
44
  def on_mount(self) -> None:
31
45
  self.push_screen(MainScreen(self.left_path, self.right_path))
32
- self.push_screen(BootScreen(), self._after_boot)
33
-
34
- def _after_boot(self, _result=None) -> None:
35
- # Looked up only once the boot animation is over, so a quick answer
36
- # 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.
37
49
  if self.check_updates:
38
50
  self.run_worker(self._look_for_update, thread=True)
39
51
 
40
52
  def _look_for_update(self) -> None:
41
- 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
42
58
  if info:
43
- self.call_from_thread(self.update_found, info)
59
+ self.update_latest = info.latest
60
+ self.update_found(info)
44
61
 
45
62
  def update_found(self, info: updater.UpdateInfo) -> None:
46
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()
@@ -1,5 +1,8 @@
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
@@ -8,10 +11,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
14
- from .widgets import CompareBar
17
+ from .widgets import SPINNER_FRAMES, CompareBar, _format_bytes
15
18
 
16
19
  STATUS_LABEL = {
17
20
  DiffStatus.LEFT_ONLY: "< only left",
@@ -21,6 +24,23 @@ STATUS_LABEL = {
21
24
  }
22
25
 
23
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
+
24
44
  class CompareScreen(Screen):
25
45
  """Full recursive diff between the two panes' current directories, with
26
46
  bulk actions (mirror/update/two-way) or per-item manual handling.
@@ -42,6 +62,10 @@ class CompareScreen(Screen):
42
62
  self.left_root = left_root
43
63
  self.right_root = right_root
44
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
45
69
 
46
70
  def compose(self) -> ComposeResult:
47
71
  yield Static("", id="compare-summary")
@@ -51,16 +75,63 @@ class CompareScreen(Screen):
51
75
  def on_mount(self) -> None:
52
76
  self.reload()
53
77
 
78
+ def _stop_spinner(self) -> None:
79
+ if self._spinner is not None:
80
+ self._spinner.stop()
81
+ self._spinner = None
82
+
54
83
  def reload(self) -> None:
55
- 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:
56
125
  table = self.query_one("#compare-table", DataTable)
57
126
  table.clear(columns=True)
58
- table.add_columns(" ", "Path", "Status")
127
+ table.add_columns(" ", "Path", "Status", "Left (size, date)", "Right (size, date)")
59
128
  for d in self.diffs:
60
129
  if d.status == DiffStatus.IDENTICAL:
61
130
  continue
62
131
  mark = "*" if d.rel_path in self.manual_marks else ""
63
- 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
+ )
64
135
 
65
136
  non_identical = sum(1 for d in self.diffs if d.status != DiffStatus.IDENTICAL)
66
137
  summary = self.query_one("#compare-summary", Static)
@@ -70,22 +141,35 @@ class CompareScreen(Screen):
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