commandertui 1.2.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.
- {commandertui-1.2.0 → commandertui-1.3.0}/PKG-INFO +8 -5
- {commandertui-1.2.0 → commandertui-1.3.0}/README.md +7 -4
- commandertui-1.3.0/commandertui/__init__.py +1 -0
- {commandertui-1.2.0 → commandertui-1.3.0}/commandertui/app.py +14 -3
- {commandertui-1.2.0 → commandertui-1.3.0}/commandertui/compare_screen.py +94 -6
- commandertui-1.3.0/commandertui/editor.py +26 -0
- {commandertui-1.2.0 → commandertui-1.3.0}/commandertui/main_screen.py +51 -1
- {commandertui-1.2.0 → commandertui-1.3.0}/commandertui/modals.py +19 -1
- commandertui-1.3.0/commandertui/settings.py +35 -0
- commandertui-1.3.0/commandertui/theme.py +284 -0
- {commandertui-1.2.0 → commandertui-1.3.0}/commandertui/widgets.py +2 -0
- {commandertui-1.2.0 → commandertui-1.3.0}/commandertui.egg-info/PKG-INFO +8 -5
- {commandertui-1.2.0 → commandertui-1.3.0}/commandertui.egg-info/SOURCES.txt +2 -0
- {commandertui-1.2.0 → commandertui-1.3.0}/tests/test_app_smoke.py +199 -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.0}/LICENSE +0 -0
- {commandertui-1.2.0 → commandertui-1.3.0}/commandertui/ascii_font.py +0 -0
- {commandertui-1.2.0 → commandertui-1.3.0}/commandertui/bookmarks.py +0 -0
- {commandertui-1.2.0 → commandertui-1.3.0}/commandertui/boot.py +0 -0
- {commandertui-1.2.0 → commandertui-1.3.0}/commandertui/cli.py +0 -0
- {commandertui-1.2.0 → commandertui-1.3.0}/commandertui/diff.py +0 -0
- {commandertui-1.2.0 → commandertui-1.3.0}/commandertui/executor.py +0 -0
- {commandertui-1.2.0 → commandertui-1.3.0}/commandertui/models.py +0 -0
- {commandertui-1.2.0 → commandertui-1.3.0}/commandertui/netlocations.py +0 -0
- {commandertui-1.2.0 → commandertui-1.3.0}/commandertui/plan.py +0 -0
- {commandertui-1.2.0 → commandertui-1.3.0}/commandertui/progress_screen.py +0 -0
- {commandertui-1.2.0 → commandertui-1.3.0}/commandertui/run_queue.py +0 -0
- {commandertui-1.2.0 → commandertui-1.3.0}/commandertui/scanner.py +0 -0
- {commandertui-1.2.0 → commandertui-1.3.0}/commandertui/sync.py +0 -0
- {commandertui-1.2.0 → commandertui-1.3.0}/commandertui/trash.py +0 -0
- {commandertui-1.2.0 → commandertui-1.3.0}/commandertui/updater.py +0 -0
- {commandertui-1.2.0 → commandertui-1.3.0}/commandertui.egg-info/dependency_links.txt +0 -0
- {commandertui-1.2.0 → commandertui-1.3.0}/commandertui.egg-info/entry_points.txt +0 -0
- {commandertui-1.2.0 → commandertui-1.3.0}/commandertui.egg-info/requires.txt +0 -0
- {commandertui-1.2.0 → commandertui-1.3.0}/commandertui.egg-info/top_level.txt +0 -0
- {commandertui-1.2.0 → commandertui-1.3.0}/pyproject.toml +0 -0
- {commandertui-1.2.0 → commandertui-1.3.0}/setup.cfg +0 -0
- {commandertui-1.2.0 → commandertui-1.3.0}/tests/test_bookmarks.py +0 -0
- {commandertui-1.2.0 → commandertui-1.3.0}/tests/test_diff.py +0 -0
- {commandertui-1.2.0 → commandertui-1.3.0}/tests/test_executor.py +0 -0
- {commandertui-1.2.0 → commandertui-1.3.0}/tests/test_netlocations.py +0 -0
- {commandertui-1.2.0 → commandertui-1.3.0}/tests/test_plan.py +0 -0
- {commandertui-1.2.0 → commandertui-1.3.0}/tests/test_scanner.py +0 -0
- {commandertui-1.2.0 → commandertui-1.3.0}/tests/test_sync.py +0 -0
- {commandertui-1.2.0 → commandertui-1.3.0}/tests/test_updater.py +0 -0
- {commandertui-1.2.0 → commandertui-1.3.0}/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.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.
|
|
25
|
+
Status: **v1.3.0** — 119/119 tests passing. MIT licensed.
|
|
26
26
|
|
|
27
27
|
---
|
|
28
28
|
|
|
@@ -127,6 +127,7 @@ 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 |
|
|
@@ -136,8 +137,9 @@ o.window("^commandertui$", { float = true, center = true, size = { 1174, 637 } }
|
|
|
136
137
|
| `u` | Update: shows the pending new version (see *Updates*) |
|
|
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,7 +162,7 @@ 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
|
|
|
@@ -213,7 +215,8 @@ commandertui/
|
|
|
213
215
|
├── executor.py # runs an OperationQueue against the filesystem
|
|
214
216
|
├── bookmarks.py # detected mounts (USB/SMB/rclone) + saved places
|
|
215
217
|
├── netlocations.py # SMB/SFTP/FTP locations via gio (list shares, mount, save)
|
|
216
|
-
├── theme.py # green/amber
|
|
218
|
+
├── theme.py # phosphor green/amber themes + the stylesheet (Textual theme variables)
|
|
219
|
+
├── settings.py # saved settings (color theme)
|
|
217
220
|
├── boot.py # startup "modem" reveal animation
|
|
218
221
|
├── widgets.py # FilePanel: one pane's directory listing
|
|
219
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.
|
|
7
|
+
Status: **v1.3.0** — 119/119 tests passing. MIT licensed.
|
|
8
8
|
|
|
9
9
|
---
|
|
10
10
|
|
|
@@ -109,6 +109,7 @@ 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 |
|
|
@@ -118,8 +119,9 @@ o.window("^commandertui$", { float = true, center = true, size = { 1174, 637 } }
|
|
|
118
119
|
| `u` | Update: shows the pending new version (see *Updates*) |
|
|
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,7 +144,7 @@ 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
|
|
|
@@ -195,7 +197,8 @@ commandertui/
|
|
|
195
197
|
├── executor.py # runs an OperationQueue against the filesystem
|
|
196
198
|
├── bookmarks.py # detected mounts (USB/SMB/rclone) + saved places
|
|
197
199
|
├── netlocations.py # SMB/SFTP/FTP locations via gio (list shares, mount, save)
|
|
198
|
-
├── theme.py # green/amber
|
|
200
|
+
├── theme.py # phosphor green/amber themes + the stylesheet (Textual theme variables)
|
|
201
|
+
├── settings.py # saved settings (color theme)
|
|
199
202
|
├── boot.py # startup "modem" reveal animation
|
|
200
203
|
├── widgets.py # FilePanel: one pane's directory listing
|
|
201
204
|
├── modals.py # Confirm / Places / Input / Size / Message screens
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
__version__ = "1.3.0"
|
|
@@ -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,18 +14,30 @@ 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
|
|
26
29
|
self.update_prompted = False
|
|
27
30
|
self.left_path = os.path.abspath(left_path or os.path.expanduser("~"))
|
|
28
31
|
self.right_path = os.path.abspath(right_path or os.path.expanduser("~"))
|
|
29
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
|
+
|
|
30
41
|
def on_mount(self) -> None:
|
|
31
42
|
self.push_screen(MainScreen(self.left_path, self.right_path))
|
|
32
43
|
self.push_screen(BootScreen(), self._after_boot)
|
|
@@ -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
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
from __future__ import annotations
|
|
2
2
|
|
|
3
3
|
import os
|
|
4
|
+
import subprocess
|
|
4
5
|
|
|
5
6
|
from textual.app import ComposeResult
|
|
6
7
|
from textual.binding import Binding
|
|
@@ -9,7 +10,7 @@ from textual.screen import Screen
|
|
|
9
10
|
from textual.widgets import DataTable, Header
|
|
10
11
|
|
|
11
12
|
from .compare_screen import CompareScreen
|
|
12
|
-
from . import netlocations, updater
|
|
13
|
+
from . import editor, netlocations, theme, updater
|
|
13
14
|
from .modals import (
|
|
14
15
|
ADD_NETWORK,
|
|
15
16
|
ChoiceScreen,
|
|
@@ -45,12 +46,14 @@ class MainScreen(Screen):
|
|
|
45
46
|
Binding("enter", "enter_dir", "Open", priority=True),
|
|
46
47
|
Binding("backspace", "up_dir", "Up", priority=True),
|
|
47
48
|
Binding("space", "toggle_mark", "Mark", priority=True),
|
|
49
|
+
Binding("t", "pick_theme", "Theme", priority=True),
|
|
48
50
|
Binding("u", "update_app", "Update", priority=True),
|
|
49
51
|
Binding("slash", "filter", "Filter", priority=True),
|
|
50
52
|
Binding("plus", "mark_pattern", "Mark pattern", priority=True),
|
|
51
53
|
Binding("minus", "unmark_pattern", "Unmark pattern", priority=True),
|
|
52
54
|
Binding("asterisk", "invert_marks", "Invert marks", priority=True),
|
|
53
55
|
Binding("f2", "rename", "Rename", priority=True),
|
|
56
|
+
Binding("f4", "edit", "Edit", priority=True),
|
|
54
57
|
Binding("f5", "copy", "Copy", priority=True),
|
|
55
58
|
Binding("f6", "move", "Move", priority=True),
|
|
56
59
|
Binding("f7", "make_dir", "MkDir", priority=True),
|
|
@@ -248,6 +251,25 @@ class MainScreen(Screen):
|
|
|
248
251
|
|
|
249
252
|
self.app.push_screen(UpdateScreen(info), on_choice)
|
|
250
253
|
|
|
254
|
+
def action_pick_theme(self) -> None:
|
|
255
|
+
labels = [label for label, _ in theme.THEME_CHOICES]
|
|
256
|
+
names = [name for _, name in theme.THEME_CHOICES]
|
|
257
|
+
original = self.app.theme
|
|
258
|
+
initial = names.index(original) if original in names else 0
|
|
259
|
+
|
|
260
|
+
def on_choice(choice: str | None) -> None:
|
|
261
|
+
if choice is None:
|
|
262
|
+
self.app.preview_theme(original) # Esc: back to what it was
|
|
263
|
+
else:
|
|
264
|
+
self.app.theme = names[labels.index(choice)] # saved by the app
|
|
265
|
+
|
|
266
|
+
self.app.push_screen(
|
|
267
|
+
ChoiceScreen(
|
|
268
|
+
"Color theme", labels, initial=initial, on_highlight=lambda i: self.app.preview_theme(names[i])
|
|
269
|
+
),
|
|
270
|
+
on_choice,
|
|
271
|
+
)
|
|
272
|
+
|
|
251
273
|
def action_filter(self) -> None:
|
|
252
274
|
panel = self._active_panel()
|
|
253
275
|
self.app.push_screen(
|
|
@@ -287,6 +309,34 @@ class MainScreen(Screen):
|
|
|
287
309
|
|
|
288
310
|
self.app.push_screen(InputScreen("Rename to:", initial=name), on_named)
|
|
289
311
|
|
|
312
|
+
def action_edit(self) -> None:
|
|
313
|
+
panel = self._active_panel()
|
|
314
|
+
name = panel.selected_name()
|
|
315
|
+
if not name or name == PARENT_MARK:
|
|
316
|
+
self._nothing_selected()
|
|
317
|
+
return
|
|
318
|
+
path = os.path.join(panel.path, name)
|
|
319
|
+
if os.path.isdir(path):
|
|
320
|
+
self.app.push_screen(MessageScreen(f'"{name}" is a folder. Pick a file to edit.'))
|
|
321
|
+
return
|
|
322
|
+
command = editor.editor_command()
|
|
323
|
+
if command is None:
|
|
324
|
+
self.app.push_screen(
|
|
325
|
+
MessageScreen("No text editor found.\nSet $EDITOR or install nano.")
|
|
326
|
+
)
|
|
327
|
+
return
|
|
328
|
+
with self.app.suspend():
|
|
329
|
+
try:
|
|
330
|
+
subprocess.run([*command, path])
|
|
331
|
+
except OSError as e:
|
|
332
|
+
error = str(e)
|
|
333
|
+
else:
|
|
334
|
+
error = ""
|
|
335
|
+
if error:
|
|
336
|
+
self.app.push_screen(MessageScreen(f"Could not start the editor:\n{error}"))
|
|
337
|
+
panel.refresh_listing()
|
|
338
|
+
panel.move_cursor_to(name)
|
|
339
|
+
|
|
290
340
|
def action_copy(self) -> None:
|
|
291
341
|
self._run_marked_op(OpKind.COPY, "Copy")
|
|
292
342
|
|
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
from __future__ import annotations
|
|
2
2
|
|
|
3
|
+
from collections.abc import Callable
|
|
4
|
+
|
|
3
5
|
import os
|
|
4
6
|
import time
|
|
5
7
|
|
|
@@ -466,10 +468,26 @@ class ChoiceScreen(ModalScreen[str | None]):
|
|
|
466
468
|
|
|
467
469
|
BINDINGS = [Binding("escape", "cancel", "Cancel", priority=True)]
|
|
468
470
|
|
|
469
|
-
def __init__(
|
|
471
|
+
def __init__(
|
|
472
|
+
self,
|
|
473
|
+
prompt: str,
|
|
474
|
+
options: list[str],
|
|
475
|
+
initial: int | None = None,
|
|
476
|
+
on_highlight: Callable[[int], None] | None = None,
|
|
477
|
+
) -> None:
|
|
470
478
|
super().__init__()
|
|
471
479
|
self.prompt = prompt
|
|
472
480
|
self.options = options
|
|
481
|
+
self.initial = initial
|
|
482
|
+
self.on_highlight = on_highlight # lets a picker preview each item as you move
|
|
483
|
+
|
|
484
|
+
def on_mount(self) -> None:
|
|
485
|
+
if self.initial is not None:
|
|
486
|
+
self.query_one("#choice-list", OptionList).highlighted = self.initial
|
|
487
|
+
|
|
488
|
+
def on_option_list_option_highlighted(self, event: OptionList.OptionHighlighted) -> None:
|
|
489
|
+
if self.on_highlight is not None:
|
|
490
|
+
self.on_highlight(event.option_index)
|
|
473
491
|
|
|
474
492
|
def compose(self) -> ComposeResult:
|
|
475
493
|
with Vertical(id="choice-box"):
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
"""User settings that survive restarts (today: the color theme)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import os
|
|
7
|
+
|
|
8
|
+
from .bookmarks import CONFIG_DIR
|
|
9
|
+
|
|
10
|
+
SETTINGS_FILE = os.path.join(CONFIG_DIR, "settings.json")
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def _load() -> dict:
|
|
14
|
+
try:
|
|
15
|
+
with open(SETTINGS_FILE) as f:
|
|
16
|
+
data = json.load(f)
|
|
17
|
+
return data if isinstance(data, dict) else {}
|
|
18
|
+
except (OSError, ValueError):
|
|
19
|
+
return {}
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def load_theme() -> str | None:
|
|
23
|
+
value = _load().get("theme")
|
|
24
|
+
return value if isinstance(value, str) else None
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def save_theme(name: str) -> None:
|
|
28
|
+
data = _load()
|
|
29
|
+
data["theme"] = name
|
|
30
|
+
try:
|
|
31
|
+
os.makedirs(CONFIG_DIR, exist_ok=True)
|
|
32
|
+
with open(SETTINGS_FILE, "w") as f:
|
|
33
|
+
json.dump(data, f)
|
|
34
|
+
except OSError:
|
|
35
|
+
pass # a read-only home must never break the app
|