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.
- {commandertui-1.2.0 → commandertui-1.3.1}/PKG-INFO +12 -8
- {commandertui-1.2.0 → commandertui-1.3.1}/README.md +11 -7
- commandertui-1.3.1/commandertui/__init__.py +1 -0
- {commandertui-1.2.0 → commandertui-1.3.1}/commandertui/app.py +27 -10
- {commandertui-1.2.0 → commandertui-1.3.1}/commandertui/ascii_font.py +6 -3
- {commandertui-1.2.0 → commandertui-1.3.1}/commandertui/boot.py +60 -12
- {commandertui-1.2.0 → commandertui-1.3.1}/commandertui/compare_screen.py +94 -6
- commandertui-1.3.1/commandertui/editor.py +26 -0
- {commandertui-1.2.0 → commandertui-1.3.1}/commandertui/main_screen.py +72 -5
- {commandertui-1.2.0 → commandertui-1.3.1}/commandertui/modals.py +19 -1
- commandertui-1.3.1/commandertui/settings.py +35 -0
- commandertui-1.3.1/commandertui/theme.py +284 -0
- {commandertui-1.2.0 → commandertui-1.3.1}/commandertui/updater.py +40 -21
- {commandertui-1.2.0 → commandertui-1.3.1}/commandertui/widgets.py +3 -0
- {commandertui-1.2.0 → commandertui-1.3.1}/commandertui.egg-info/PKG-INFO +12 -8
- {commandertui-1.2.0 → commandertui-1.3.1}/commandertui.egg-info/SOURCES.txt +2 -0
- {commandertui-1.2.0 → commandertui-1.3.1}/tests/test_app_smoke.py +296 -18
- {commandertui-1.2.0 → commandertui-1.3.1}/tests/test_updater.py +17 -8
- commandertui-1.2.0/commandertui/__init__.py +0 -1
- commandertui-1.2.0/commandertui/theme.py +0 -255
- {commandertui-1.2.0 → commandertui-1.3.1}/LICENSE +0 -0
- {commandertui-1.2.0 → commandertui-1.3.1}/commandertui/bookmarks.py +0 -0
- {commandertui-1.2.0 → commandertui-1.3.1}/commandertui/cli.py +0 -0
- {commandertui-1.2.0 → commandertui-1.3.1}/commandertui/diff.py +0 -0
- {commandertui-1.2.0 → commandertui-1.3.1}/commandertui/executor.py +0 -0
- {commandertui-1.2.0 → commandertui-1.3.1}/commandertui/models.py +0 -0
- {commandertui-1.2.0 → commandertui-1.3.1}/commandertui/netlocations.py +0 -0
- {commandertui-1.2.0 → commandertui-1.3.1}/commandertui/plan.py +0 -0
- {commandertui-1.2.0 → commandertui-1.3.1}/commandertui/progress_screen.py +0 -0
- {commandertui-1.2.0 → commandertui-1.3.1}/commandertui/run_queue.py +0 -0
- {commandertui-1.2.0 → commandertui-1.3.1}/commandertui/scanner.py +0 -0
- {commandertui-1.2.0 → commandertui-1.3.1}/commandertui/sync.py +0 -0
- {commandertui-1.2.0 → commandertui-1.3.1}/commandertui/trash.py +0 -0
- {commandertui-1.2.0 → commandertui-1.3.1}/commandertui.egg-info/dependency_links.txt +0 -0
- {commandertui-1.2.0 → commandertui-1.3.1}/commandertui.egg-info/entry_points.txt +0 -0
- {commandertui-1.2.0 → commandertui-1.3.1}/commandertui.egg-info/requires.txt +0 -0
- {commandertui-1.2.0 → commandertui-1.3.1}/commandertui.egg-info/top_level.txt +0 -0
- {commandertui-1.2.0 → commandertui-1.3.1}/pyproject.toml +0 -0
- {commandertui-1.2.0 → commandertui-1.3.1}/setup.cfg +0 -0
- {commandertui-1.2.0 → commandertui-1.3.1}/tests/test_bookmarks.py +0 -0
- {commandertui-1.2.0 → commandertui-1.3.1}/tests/test_diff.py +0 -0
- {commandertui-1.2.0 → commandertui-1.3.1}/tests/test_executor.py +0 -0
- {commandertui-1.2.0 → commandertui-1.3.1}/tests/test_netlocations.py +0 -0
- {commandertui-1.2.0 → commandertui-1.3.1}/tests/test_plan.py +0 -0
- {commandertui-1.2.0 → commandertui-1.3.1}/tests/test_scanner.py +0 -0
- {commandertui-1.2.0 → commandertui-1.3.1}/tests/test_sync.py +0 -0
- {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.
|
|
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.
|
|
25
|
+
Status: **v1.3.1** — 127/127 tests passing. MIT licensed.
|
|
26
26
|
|
|
27
27
|
---
|
|
28
28
|
|
|
@@ -32,7 +32,7 @@ A safe, simple alternative to heavier dual-pane file managers. It was
|
|
|
32
32
|
written after an earlier attempt of the same idea had to be scrapped for
|
|
33
33
|
running an unauthenticated local HTTP server. This project deliberately:
|
|
34
34
|
|
|
35
|
-
- **Never opens a network port.** Pure terminal UI (Textual), no HTTP server (the only outgoing request is the
|
|
35
|
+
- **Never opens a network port.** Pure terminal UI (Textual), no HTTP server (the only outgoing request is the update check on startup, which you can turn off),
|
|
36
36
|
no webview.
|
|
37
37
|
- **Never does a raw `rm -rf`.** Deletes always go through the desktop trash
|
|
38
38
|
(`send2trash` → `gio trash` → manual move into `~/.local/share/Trash`, in
|
|
@@ -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` |
|
|
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 (
|
|
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
|
-
|
|
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
|
|
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.
|
|
7
|
+
Status: **v1.3.1** — 127/127 tests passing. MIT licensed.
|
|
8
8
|
|
|
9
9
|
---
|
|
10
10
|
|
|
@@ -14,7 +14,7 @@ A safe, simple alternative to heavier dual-pane file managers. It was
|
|
|
14
14
|
written after an earlier attempt of the same idea had to be scrapped for
|
|
15
15
|
running an unauthenticated local HTTP server. This project deliberately:
|
|
16
16
|
|
|
17
|
-
- **Never opens a network port.** Pure terminal UI (Textual), no HTTP server (the only outgoing request is the
|
|
17
|
+
- **Never opens a network port.** Pure terminal UI (Textual), no HTTP server (the only outgoing request is the update check on startup, which you can turn off),
|
|
18
18
|
no webview.
|
|
19
19
|
- **Never does a raw `rm -rf`.** Deletes always go through the desktop trash
|
|
20
20
|
(`send2trash` → `gio trash` → manual move into `~/.local/share/Trash`, in
|
|
@@ -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` |
|
|
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 (
|
|
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
|
-
|
|
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
|
|
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 =
|
|
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()
|
|
33
|
-
|
|
34
|
-
|
|
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.
|
|
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.
|
|
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
|
|
20
|
+
def banner(word: str, gap: int = 1, scale: int = 1) -> str:
|
|
21
|
+
"""Render `word` as block letters, 5 rows tall at scale 1; a larger scale
|
|
22
|
+
makes every block `scale` cells wide and tall."""
|
|
22
23
|
letters = [_FONT[ch] for ch in word.upper()]
|
|
23
24
|
sep = " " * gap
|
|
24
25
|
rows = []
|
|
25
26
|
for row_index in range(5):
|
|
26
|
-
|
|
27
|
+
row = sep.join(letter[row_index] for letter in letters).replace("#", BLOCK)
|
|
28
|
+
row = "".join(ch * scale for ch in row)
|
|
29
|
+
rows.extend([row] * scale)
|
|
27
30
|
return "\n".join(rows)
|
|
@@ -8,16 +8,25 @@ from textual.widgets import Static
|
|
|
8
8
|
from .ascii_font import banner
|
|
9
9
|
|
|
10
10
|
CURSOR = "█" # solid block, like a CRT text cursor
|
|
11
|
+
SPINNER = "|/-\\"
|
|
12
|
+
LINE_WIDTH = 44 # fixed, so a status changing later never moves the box
|
|
13
|
+
WIDE_ENOUGH = 114 # terminal columns for the double-size title (110 wide with its box)
|
|
11
14
|
|
|
12
15
|
|
|
13
|
-
def
|
|
14
|
-
|
|
16
|
+
def _line(label: str, status: str) -> str:
|
|
17
|
+
dots = "." * (LINE_WIDTH - len(label) - len(status) - 2)
|
|
18
|
+
return f"{label} {dots} {status}"
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def _build_art(scale: int = 1, update_status: str = "OK") -> str:
|
|
22
|
+
title_lines = banner("COMMANDER", scale=scale).split("\n")
|
|
15
23
|
body_lines = [
|
|
16
24
|
"",
|
|
17
25
|
"T U I -- dual-pane file manager",
|
|
18
26
|
"",
|
|
19
|
-
"CONNECT 2400
|
|
20
|
-
"LOADING PANELS
|
|
27
|
+
_line("CONNECT 2400", "OK"),
|
|
28
|
+
_line("LOADING PANELS", "OK"),
|
|
29
|
+
_line("CHECKING UPDATES", update_status),
|
|
21
30
|
"READY.",
|
|
22
31
|
]
|
|
23
32
|
content = title_lines + body_lines
|
|
@@ -31,6 +40,11 @@ def _build_art() -> str:
|
|
|
31
40
|
return "\n".join(boxed)
|
|
32
41
|
|
|
33
42
|
|
|
43
|
+
def update_status_text(state: str, latest: str = "") -> str:
|
|
44
|
+
"""What the CHECKING UPDATES line says for an app update state."""
|
|
45
|
+
return {"ok": "OK", "off": "OFF", "offline": "OFFLINE", "skipped": "SKIPPED"}.get(state, f"NEW {latest}")
|
|
46
|
+
|
|
47
|
+
|
|
34
48
|
ART = _build_art()
|
|
35
49
|
|
|
36
50
|
|
|
@@ -63,13 +77,16 @@ class BootScreen(Screen):
|
|
|
63
77
|
|
|
64
78
|
def __init__(
|
|
65
79
|
self,
|
|
66
|
-
art: str =
|
|
80
|
+
art: str | None = None,
|
|
67
81
|
chars_per_tick: int = 3,
|
|
68
82
|
tick_seconds: float = 0.035,
|
|
69
83
|
bell: bool = True,
|
|
70
84
|
) -> None:
|
|
71
85
|
super().__init__()
|
|
72
|
-
self.
|
|
86
|
+
self.fixed_art = art # tests/tools can pass a fixed picture; normally built live
|
|
87
|
+
self.art = art or ART
|
|
88
|
+
self._scale = 1
|
|
89
|
+
self._frame = 0
|
|
73
90
|
self.chars_per_tick = chars_per_tick
|
|
74
91
|
self.tick_seconds = tick_seconds
|
|
75
92
|
self.bell = bell
|
|
@@ -87,10 +104,35 @@ class BootScreen(Screen):
|
|
|
87
104
|
yield Static("", id="boot-hint")
|
|
88
105
|
|
|
89
106
|
def on_mount(self) -> None:
|
|
107
|
+
# Double-size title when the window is wide enough for it.
|
|
108
|
+
self._scale = 2 if self.app.size.width >= WIDE_ENOUGH else 1
|
|
90
109
|
self._timer = self.set_interval(self.tick_seconds, self._tick)
|
|
91
110
|
|
|
111
|
+
def _pending(self) -> bool:
|
|
112
|
+
return self.fixed_art is None and getattr(self.app, "update_state", "off") == "pending"
|
|
113
|
+
|
|
114
|
+
def _current_art(self) -> str:
|
|
115
|
+
if self.fixed_art is not None:
|
|
116
|
+
return self.fixed_art
|
|
117
|
+
state = getattr(self.app, "update_state", "off")
|
|
118
|
+
if state == "pending":
|
|
119
|
+
status = SPINNER[self._frame % 4]
|
|
120
|
+
else:
|
|
121
|
+
status = update_status_text(state, getattr(self.app, "update_latest", ""))
|
|
122
|
+
return _build_art(self._scale, status)
|
|
123
|
+
|
|
124
|
+
def _limit(self) -> int:
|
|
125
|
+
"""How far the reveal may go: while the update check is still running
|
|
126
|
+
it waits just before the READY. row."""
|
|
127
|
+
if not self._pending():
|
|
128
|
+
return len(self.art)
|
|
129
|
+
ready = self.art.index("READY.")
|
|
130
|
+
return self.art.rfind("\n", 0, ready) + 1
|
|
131
|
+
|
|
92
132
|
def _tick(self) -> None:
|
|
93
|
-
self.
|
|
133
|
+
self._frame += 1
|
|
134
|
+
self.art = self._current_art()
|
|
135
|
+
self._pos = min(self._limit(), self._pos + self.chars_per_tick)
|
|
94
136
|
widget = self.query_one("#boot-text", Static)
|
|
95
137
|
visible = self.art[: self._pos]
|
|
96
138
|
cursor = CURSOR if self._pos < len(self.art) else ""
|
|
@@ -102,13 +144,19 @@ class BootScreen(Screen):
|
|
|
102
144
|
self._timer.stop()
|
|
103
145
|
if self.bell:
|
|
104
146
|
self.app.bell()
|
|
105
|
-
self.query_one("#boot-hint", Static).update("[ENTER] to continue")
|
|
147
|
+
self.query_one("#boot-hint", Static).update("\\[ENTER] to continue")
|
|
106
148
|
|
|
107
149
|
def action_continue_(self) -> None:
|
|
108
|
-
if
|
|
109
|
-
# Not drawn yet: Enter fast-forwards
|
|
110
|
-
#
|
|
111
|
-
self._pos =
|
|
150
|
+
if self._pos < self._limit():
|
|
151
|
+
# Not drawn yet: Enter fast-forwards as far as it can go instead
|
|
152
|
+
# of doing nothing, in case someone doesn't want to wait.
|
|
153
|
+
self._pos = self._limit()
|
|
112
154
|
self._tick()
|
|
113
155
|
return
|
|
156
|
+
if self._pending() and self._pos < len(self.art):
|
|
157
|
+
# Reveal is parked waiting on the update check: don't make the
|
|
158
|
+
# user wait for the network; a newer version still gets offered
|
|
159
|
+
# when it arrives.
|
|
160
|
+
self.dismiss()
|
|
161
|
+
return
|
|
114
162
|
self.dismiss()
|
|
@@ -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
|
-
|
|
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(
|
|
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.
|
|
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
|