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.
Files changed (47) hide show
  1. {commandertui-1.2.0 → commandertui-1.3.0}/PKG-INFO +8 -5
  2. {commandertui-1.2.0 → commandertui-1.3.0}/README.md +7 -4
  3. commandertui-1.3.0/commandertui/__init__.py +1 -0
  4. {commandertui-1.2.0 → commandertui-1.3.0}/commandertui/app.py +14 -3
  5. {commandertui-1.2.0 → commandertui-1.3.0}/commandertui/compare_screen.py +94 -6
  6. commandertui-1.3.0/commandertui/editor.py +26 -0
  7. {commandertui-1.2.0 → commandertui-1.3.0}/commandertui/main_screen.py +51 -1
  8. {commandertui-1.2.0 → commandertui-1.3.0}/commandertui/modals.py +19 -1
  9. commandertui-1.3.0/commandertui/settings.py +35 -0
  10. commandertui-1.3.0/commandertui/theme.py +284 -0
  11. {commandertui-1.2.0 → commandertui-1.3.0}/commandertui/widgets.py +2 -0
  12. {commandertui-1.2.0 → commandertui-1.3.0}/commandertui.egg-info/PKG-INFO +8 -5
  13. {commandertui-1.2.0 → commandertui-1.3.0}/commandertui.egg-info/SOURCES.txt +2 -0
  14. {commandertui-1.2.0 → commandertui-1.3.0}/tests/test_app_smoke.py +199 -8
  15. commandertui-1.2.0/commandertui/__init__.py +0 -1
  16. commandertui-1.2.0/commandertui/theme.py +0 -255
  17. {commandertui-1.2.0 → commandertui-1.3.0}/LICENSE +0 -0
  18. {commandertui-1.2.0 → commandertui-1.3.0}/commandertui/ascii_font.py +0 -0
  19. {commandertui-1.2.0 → commandertui-1.3.0}/commandertui/bookmarks.py +0 -0
  20. {commandertui-1.2.0 → commandertui-1.3.0}/commandertui/boot.py +0 -0
  21. {commandertui-1.2.0 → commandertui-1.3.0}/commandertui/cli.py +0 -0
  22. {commandertui-1.2.0 → commandertui-1.3.0}/commandertui/diff.py +0 -0
  23. {commandertui-1.2.0 → commandertui-1.3.0}/commandertui/executor.py +0 -0
  24. {commandertui-1.2.0 → commandertui-1.3.0}/commandertui/models.py +0 -0
  25. {commandertui-1.2.0 → commandertui-1.3.0}/commandertui/netlocations.py +0 -0
  26. {commandertui-1.2.0 → commandertui-1.3.0}/commandertui/plan.py +0 -0
  27. {commandertui-1.2.0 → commandertui-1.3.0}/commandertui/progress_screen.py +0 -0
  28. {commandertui-1.2.0 → commandertui-1.3.0}/commandertui/run_queue.py +0 -0
  29. {commandertui-1.2.0 → commandertui-1.3.0}/commandertui/scanner.py +0 -0
  30. {commandertui-1.2.0 → commandertui-1.3.0}/commandertui/sync.py +0 -0
  31. {commandertui-1.2.0 → commandertui-1.3.0}/commandertui/trash.py +0 -0
  32. {commandertui-1.2.0 → commandertui-1.3.0}/commandertui/updater.py +0 -0
  33. {commandertui-1.2.0 → commandertui-1.3.0}/commandertui.egg-info/dependency_links.txt +0 -0
  34. {commandertui-1.2.0 → commandertui-1.3.0}/commandertui.egg-info/entry_points.txt +0 -0
  35. {commandertui-1.2.0 → commandertui-1.3.0}/commandertui.egg-info/requires.txt +0 -0
  36. {commandertui-1.2.0 → commandertui-1.3.0}/commandertui.egg-info/top_level.txt +0 -0
  37. {commandertui-1.2.0 → commandertui-1.3.0}/pyproject.toml +0 -0
  38. {commandertui-1.2.0 → commandertui-1.3.0}/setup.cfg +0 -0
  39. {commandertui-1.2.0 → commandertui-1.3.0}/tests/test_bookmarks.py +0 -0
  40. {commandertui-1.2.0 → commandertui-1.3.0}/tests/test_diff.py +0 -0
  41. {commandertui-1.2.0 → commandertui-1.3.0}/tests/test_executor.py +0 -0
  42. {commandertui-1.2.0 → commandertui-1.3.0}/tests/test_netlocations.py +0 -0
  43. {commandertui-1.2.0 → commandertui-1.3.0}/tests/test_plan.py +0 -0
  44. {commandertui-1.2.0 → commandertui-1.3.0}/tests/test_scanner.py +0 -0
  45. {commandertui-1.2.0 → commandertui-1.3.0}/tests/test_sync.py +0 -0
  46. {commandertui-1.2.0 → commandertui-1.3.0}/tests/test_updater.py +0 -0
  47. {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.2.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.2.0** — 108/108 tests passing. MIT licensed.
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 (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
 
@@ -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 phosphor palettes
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.2.0** — 108/108 tests passing. MIT licensed.
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 (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
 
@@ -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 phosphor palettes
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 = 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
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
- 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
@@ -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__(self, prompt: str, options: list[str]) -> None:
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