riched 0.1.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.
@@ -0,0 +1,75 @@
1
+ ### JavaScript ###
2
+ .DS_Store
3
+ .env
4
+ .env.*
5
+ *.tsbuildinfo
6
+ /build
7
+ /coverage
8
+ /dist
9
+ /node_modules
10
+ /out
11
+ /tmp
12
+
13
+ ### Python ###
14
+ # Byte-compiled / optimized / DLL files
15
+ __pycache__/
16
+ *.py[cod]
17
+ *$py.class
18
+ .ruff_cache/
19
+
20
+ # C extensions
21
+ *.so
22
+
23
+ # Distribution / packaging
24
+ .Python
25
+ build/
26
+ develop-eggs/
27
+ downloads/
28
+ eggs/
29
+ .eggs/
30
+ lib/
31
+ lib64/
32
+ parts/
33
+ sdist/
34
+ var/
35
+ wheels/
36
+ share/python-wheels/
37
+ *.egg-info/
38
+ .installed.cfg
39
+ *.egg
40
+ MANIFEST
41
+
42
+ # Unit test / coverage reports
43
+ htmlcov/
44
+ .tox/
45
+ .nox/
46
+ .coverage
47
+ .coverage.*
48
+ .cache
49
+ nosetests.xml
50
+ coverage.xml
51
+ *.cover
52
+ *.py,cover
53
+ .hypothesis/
54
+ .pytest_cache/
55
+ cover/
56
+
57
+ # Jupyter Notebook
58
+ .ipynb_checkpoints
59
+
60
+ # Environments
61
+ .venv
62
+ env/
63
+ venv/
64
+ ENV/
65
+ env.bak/
66
+ venv.bak/
67
+ .env.local
68
+
69
+ # mkdocs output
70
+ site/
71
+
72
+ # custom
73
+ try*
74
+ secrets.toml
75
+ keys.log
@@ -0,0 +1 @@
1
+ 3.14
riched-0.1.0/AGENTS.md ADDED
@@ -0,0 +1,31 @@
1
+ # Repository Guidelines
2
+
3
+ ## Project Structure & Module Organization
4
+
5
+ This repository is a uv-managed Python project for `riched`, a Textual TUI editor. Application code lives in `src/riched/`: `cli.py` owns argument parsing, `app.py` owns the Textual app and file tree, `editor.py` customizes text editing, `screens.py` contains modal screens, `keybindings.py` loads shipped shortcut config and popup display rows, `bindings.yaml` defines shipped bindings, `settings.py` persists user settings such as theme, and `syntax.py` handles language detection/highlighting. End-to-end tests live in `tests/`. Root-level `foo.*` and `hello.txt` files are test fixtures/sample content. Avoid committing generated files such as `.venv/`, `__pycache__/`, `keys.log`, or build artifacts.
6
+
7
+ ## Build, Test, and Development Commands
8
+
9
+ - `uv sync`: install project dependencies from `pyproject.toml` and `uv.lock`.
10
+ - `uv run riched path/to/file.txt`: open or create a file in the editor.
11
+ - `uv run python -m tests.runner`: run the Textual Pilot end-to-end suite.
12
+ - `uv add <package>`: add runtime dependencies; do not use `pip`.
13
+
14
+ ## Coding Style & Naming Conventions
15
+
16
+ Use Python 3.14-compatible code with type hints, `from __future__ import annotations`, `Path`, and small focused helpers. Use 4-space indentation. Keep constants in `UPPER_SNAKE_CASE`, classes in `PascalCase`, functions and methods in `snake_case`, and private helpers prefixed with `_`. Prefer Textual widgets and events over manual terminal control. Keep comments brief and only for non-obvious behavior.
17
+
18
+ Keep key bindings in `src/riched/bindings.yaml`, not hardcoded in Python. The F1 hotkey popup is generated from this YAML and displays one user-facing key per binding, preferring `cmd` aliases over `super`.
19
+
20
+ ## Testing Guidelines
21
+
22
+ Only add end-to-end tests in `tests/`; do not create unit tests. Tests use Textual's Pilot harness and should be named `test_<behavior>`. Keep each test focused on user-visible behavior such as file open/save, dirty prompts, file tree switching, or syntax highlighting. Use temporary files as existing tests do.
23
+
24
+ ## Commit & Pull Request Guidelines
25
+
26
+ Use concise imperative commit messages, for example `Add file tree` or `Refactor editor package`. Pull requests should include a short summary, manual/e2e verification steps, related issue links if any, and terminal screenshots or recordings for visible TUI changes. When merging pull requests, use rebase and merge.
27
+
28
+ ## Agent-Specific Instructions
29
+
30
+ Prefer `uv` for Python tasks. Keep changes scoped to requested behavior. Update `AGENTS.md` when commands, structure, or testing policy changes.
31
+ Never change Ghostty settings or configuration, even when investigating Command-key behavior; only document suggested user-side settings when explicitly asked.
riched-0.1.0/PKG-INFO ADDED
@@ -0,0 +1,72 @@
1
+ Metadata-Version: 2.4
2
+ Name: riched
3
+ Version: 0.1.0
4
+ Summary: A Textual TUI editor
5
+ Requires-Python: >=3.14
6
+ Requires-Dist: pyyaml>=6.0.3
7
+ Requires-Dist: textual[syntax]>=8.2.4
8
+ Requires-Dist: tree-sitter-typescript>=0.23.2
9
+ Description-Content-Type: text/markdown
10
+
11
+ # Riched
12
+
13
+ `riched` is a TUI editor powered by the [Rich](https://github.com/Textualize/rich) package and the [Textual](https://github.com/Textualize/textual) framework, the name is a nod to Rich.
14
+
15
+ > [!NOTE]
16
+ > `riched` targets Ghostty on macOS. Some key bindings, especially shortcuts
17
+ > that use the Command key, may not work in other terminals or on other
18
+ > operating systems.
19
+
20
+ ## Key Features
21
+
22
+ - Syntax highlighting
23
+ - Resizable project file tree
24
+ - VS Code-style text editing key bindings
25
+
26
+ ## Usage
27
+
28
+ ```sh
29
+ uv sync
30
+ uv run riched .
31
+ uv run python -m tests.runner
32
+ ```
33
+
34
+ ## Key Bindings
35
+
36
+ Key bindings are generated from the YAML in `src/riched/bindings.yaml`.
37
+
38
+ App shortcuts:
39
+
40
+ | Shortcut | Action |
41
+ | --- | --- |
42
+ | `Ctrl+S` | Save |
43
+ | `Ctrl+Q` | Quit |
44
+ | `Ctrl+W` | Close buffer |
45
+ | `F1` | Key bindings |
46
+ | `Cmd+B` / `Super+B` | Toggle file tree |
47
+ | `Cmd+P` / `Super+P` | Quick open |
48
+
49
+ Editor shortcuts:
50
+
51
+ | Shortcut | Action |
52
+ | --- | --- |
53
+ | `Alt+Up` | Move line up |
54
+ | `Alt+Down` | Move line down |
55
+ | `Alt+Shift+Up` / `Shift+Alt+Up` | Copy line up |
56
+ | `Alt+Shift+Down` / `Shift+Alt+Down` | Copy line down |
57
+ | `Alt+Backspace` | Delete word left |
58
+ | `Alt+Shift+Left` / `Shift+Alt+Left` | Select word left |
59
+ | `Alt+Shift+Right` / `Shift+Alt+Right` | Select word right |
60
+ | `Super+L` / `Cmd+L` | Select line |
61
+ | `Shift+Super+Left` / `Super+Shift+Left` / `Shift+Cmd+Left` / `Cmd+Shift+Left` | Select to line start |
62
+ | `Shift+Super+Right` / `Super+Shift+Right` / `Shift+Cmd+Right` / `Cmd+Shift+Right` | Select to line end |
63
+
64
+ ## Settings
65
+
66
+ `riched` persists the selected Textual theme in `settings.yaml` under the user
67
+ config directory: `~/Library/Application Support/riched/settings.yaml`
68
+
69
+ ## Won't Implemented
70
+
71
+ `riched` will not implement key bindings that require users to modify the Ghostty terminal configuration, since terminal-specific configuration makes shortcuts harder to document, test, and support consistently across environments.
72
+
riched-0.1.0/README.md ADDED
@@ -0,0 +1,62 @@
1
+ # Riched
2
+
3
+ `riched` is a TUI editor powered by the [Rich](https://github.com/Textualize/rich) package and the [Textual](https://github.com/Textualize/textual) framework, the name is a nod to Rich.
4
+
5
+ > [!NOTE]
6
+ > `riched` targets Ghostty on macOS. Some key bindings, especially shortcuts
7
+ > that use the Command key, may not work in other terminals or on other
8
+ > operating systems.
9
+
10
+ ## Key Features
11
+
12
+ - Syntax highlighting
13
+ - Resizable project file tree
14
+ - VS Code-style text editing key bindings
15
+
16
+ ## Usage
17
+
18
+ ```sh
19
+ uv sync
20
+ uv run riched .
21
+ uv run python -m tests.runner
22
+ ```
23
+
24
+ ## Key Bindings
25
+
26
+ Key bindings are generated from the YAML in `src/riched/bindings.yaml`.
27
+
28
+ App shortcuts:
29
+
30
+ | Shortcut | Action |
31
+ | --- | --- |
32
+ | `Ctrl+S` | Save |
33
+ | `Ctrl+Q` | Quit |
34
+ | `Ctrl+W` | Close buffer |
35
+ | `F1` | Key bindings |
36
+ | `Cmd+B` / `Super+B` | Toggle file tree |
37
+ | `Cmd+P` / `Super+P` | Quick open |
38
+
39
+ Editor shortcuts:
40
+
41
+ | Shortcut | Action |
42
+ | --- | --- |
43
+ | `Alt+Up` | Move line up |
44
+ | `Alt+Down` | Move line down |
45
+ | `Alt+Shift+Up` / `Shift+Alt+Up` | Copy line up |
46
+ | `Alt+Shift+Down` / `Shift+Alt+Down` | Copy line down |
47
+ | `Alt+Backspace` | Delete word left |
48
+ | `Alt+Shift+Left` / `Shift+Alt+Left` | Select word left |
49
+ | `Alt+Shift+Right` / `Shift+Alt+Right` | Select word right |
50
+ | `Super+L` / `Cmd+L` | Select line |
51
+ | `Shift+Super+Left` / `Super+Shift+Left` / `Shift+Cmd+Left` / `Cmd+Shift+Left` | Select to line start |
52
+ | `Shift+Super+Right` / `Super+Shift+Right` / `Shift+Cmd+Right` / `Cmd+Shift+Right` | Select to line end |
53
+
54
+ ## Settings
55
+
56
+ `riched` persists the selected Textual theme in `settings.yaml` under the user
57
+ config directory: `~/Library/Application Support/riched/settings.yaml`
58
+
59
+ ## Won't Implemented
60
+
61
+ `riched` will not implement key bindings that require users to modify the Ghostty terminal configuration, since terminal-specific configuration makes shortcuts harder to document, test, and support consistently across environments.
62
+
@@ -0,0 +1,21 @@
1
+ [project]
2
+ name = "riched"
3
+ version = "0.1.0"
4
+ description = "A Textual TUI editor"
5
+ readme = "README.md"
6
+ requires-python = ">=3.14"
7
+ dependencies = [
8
+ "pyyaml>=6.0.3",
9
+ "textual[syntax]>=8.2.4",
10
+ "tree-sitter-typescript>=0.23.2",
11
+ ]
12
+
13
+ [project.scripts]
14
+ riched = "riched.cli:main"
15
+
16
+ [build-system]
17
+ requires = ["hatchling"]
18
+ build-backend = "hatchling.build"
19
+
20
+ [tool.hatch.build.targets.wheel]
21
+ packages = ["src/riched"]
@@ -0,0 +1,2 @@
1
+ """Riched package."""
2
+
@@ -0,0 +1,433 @@
1
+ from __future__ import annotations
2
+
3
+ from collections.abc import Iterable
4
+ from pathlib import Path
5
+ from typing import Any
6
+
7
+ from textual import events
8
+ from textual.app import App, ComposeResult, SystemCommand
9
+ from textual.binding import Binding
10
+ from textual.command import CommandPalette
11
+ from textual.containers import Container, Horizontal
12
+ from textual.keys import format_key
13
+ from textual.screen import Screen
14
+ from textual.widgets import DirectoryTree, Footer, Header, Static, TextArea
15
+
16
+ from .editor import RichedTextArea
17
+ from .keybindings import display_key
18
+ from .screens import KeysHelpScreen, QuickOpenScreen, UnsavedChangesScreen
19
+ from .settings import load_theme, save_theme
20
+ from .syntax import apply_language
21
+
22
+ FILE_TREE_DEFAULT_WIDTH = 30
23
+ FILE_TREE_MIN_WIDTH = 18
24
+ FILE_TREE_MAX_WIDTH = 44
25
+ QUICK_OPEN_SKIP_DIRS = {
26
+ ".git",
27
+ ".venv",
28
+ "__pycache__",
29
+ "build",
30
+ "dist",
31
+ "node_modules",
32
+ }
33
+ KEY_MODIFIER_SYMBOLS = {
34
+ "cmd": "⌘",
35
+ "super": "⌘",
36
+ "ctrl": "⌃",
37
+ "control": "⌃",
38
+ "alt": "⌥",
39
+ "option": "⌥",
40
+ "shift": "⇧",
41
+ }
42
+
43
+
44
+ class RichedDirectoryTree(DirectoryTree):
45
+ """Directory tree with editor-style keyboard affordances."""
46
+
47
+ BINDINGS = [
48
+ *(binding for binding in DirectoryTree.BINDINGS if binding.key != "space"),
49
+ Binding("left", "collapse_cursor", "Collapse folder", show=False),
50
+ Binding("right", "expand_cursor", "Expand folder", show=False),
51
+ Binding("space", "activate_cursor", "Open file or toggle folder", show=False),
52
+ ]
53
+
54
+ def _cursor_dir_node(self) -> Any | None:
55
+ node = self.cursor_node
56
+ if node is None or node.data is None or not node.allow_expand:
57
+ return None
58
+ return node
59
+
60
+ def action_collapse_cursor(self) -> None:
61
+ node = self._cursor_dir_node()
62
+ if node is not None:
63
+ node.collapse()
64
+
65
+ def action_expand_cursor(self) -> None:
66
+ node = self._cursor_dir_node()
67
+ if node is not None:
68
+ node.expand()
69
+
70
+ def action_activate_cursor(self) -> None:
71
+ node = self.cursor_node
72
+ if node is None:
73
+ return
74
+ if node.allow_expand:
75
+ node.toggle()
76
+ return
77
+ self.action_select_cursor()
78
+
79
+
80
+ class FileTreeResizeHandle(Static):
81
+ """Mouse handle for resizing the file tree."""
82
+
83
+ can_focus = False
84
+
85
+ def __init__(self) -> None:
86
+ super().__init__("", id="file-tree-resize-handle")
87
+ self._drag_start_x: int | None = None
88
+ self._drag_start_width: int | None = None
89
+
90
+ def on_mouse_down(self, event: events.MouseDown) -> None:
91
+ if event.button != 1 or event.screen_x is None:
92
+ return
93
+ tree = self.app.query_one("#file-tree", DirectoryTree)
94
+ self._drag_start_x = int(event.screen_x)
95
+ self._drag_start_width = tree.region.width
96
+ self.capture_mouse()
97
+ self.add_class("dragging")
98
+ event.stop()
99
+ event.prevent_default()
100
+
101
+ def on_mouse_move(self, event: events.MouseMove) -> None:
102
+ if self._drag_start_x is None or self._drag_start_width is None:
103
+ return
104
+ if event.screen_x is None:
105
+ return
106
+ delta = int(event.screen_x) - self._drag_start_x
107
+ app = self.app
108
+ if isinstance(app, RichedApp):
109
+ app.set_file_tree_width(self._drag_start_width + delta)
110
+ event.stop()
111
+ event.prevent_default()
112
+
113
+ def on_mouse_up(self, event: events.MouseUp) -> None:
114
+ if self._drag_start_x is None:
115
+ return
116
+ self._drag_start_x = None
117
+ self._drag_start_width = None
118
+ self.release_mouse()
119
+ self.remove_class("dragging")
120
+ event.stop()
121
+ event.prevent_default()
122
+
123
+
124
+ class RichedApp(App):
125
+ """TUI text editor with a project file tree."""
126
+
127
+ CSS = """
128
+ #workspace {
129
+ height: 1fr;
130
+ }
131
+ #file-tree {
132
+ width: 30;
133
+ min-width: 18;
134
+ max-width: 44;
135
+ }
136
+ #file-tree-resize-handle {
137
+ width: 1;
138
+ min-width: 1;
139
+ max-width: 1;
140
+ height: 1fr;
141
+ background: $panel;
142
+ }
143
+ #file-tree-resize-handle:hover,
144
+ #file-tree-resize-handle.dragging {
145
+ background: $accent;
146
+ }
147
+ #editor {
148
+ height: 1fr;
149
+ width: 1fr;
150
+ }
151
+ #editor-slot {
152
+ height: 1fr;
153
+ width: 1fr;
154
+ }
155
+ HeaderIcon {
156
+ display: none;
157
+ }
158
+ """
159
+
160
+ BINDINGS: list[Binding] = []
161
+
162
+ def __init__(
163
+ self,
164
+ path: Path,
165
+ root: Path | None = None,
166
+ ) -> None:
167
+ super().__init__()
168
+ saved_theme = load_theme()
169
+ if saved_theme in self.available_themes:
170
+ self.theme = saved_theme
171
+ self.watch(self, "theme", self._save_theme, init=False)
172
+ initial_path = path.expanduser()
173
+ self._initial_path = None if initial_path.is_dir() else initial_path
174
+ self.path: Path | None = self._initial_path
175
+ self.root = root or Path.cwd()
176
+ self._saved_text = ""
177
+
178
+ def _save_theme(self, theme: str) -> None:
179
+ try:
180
+ save_theme(theme)
181
+ except Exception as exc:
182
+ self.notify(f"Could not save theme: {exc}", severity="warning")
183
+
184
+ def get_system_commands(self, screen: Screen) -> Iterable[SystemCommand]:
185
+ for command in super().get_system_commands(screen):
186
+ if command.title == "Maximize":
187
+ continue
188
+ if command.title == "Keys":
189
+ yield SystemCommand(
190
+ "Keys",
191
+ "Show key bindings",
192
+ self.action_show_keys_popup,
193
+ )
194
+ continue
195
+ yield command
196
+
197
+ def get_key_display(self, binding: Binding) -> str:
198
+ if binding.key_display:
199
+ return binding.key_display
200
+
201
+ key = display_key(binding.key)
202
+ parts = key.split("+")
203
+ base_key = format_key(parts[-1])
204
+ if len(base_key) == 1:
205
+ base_key = base_key.upper()
206
+ else:
207
+ base_key = base_key.title()
208
+ modifiers = "".join(
209
+ KEY_MODIFIER_SYMBOLS.get(part, part) for part in parts[:-1]
210
+ )
211
+ return f"{modifiers}{base_key}"
212
+
213
+ def compose(self) -> ComposeResult:
214
+ yield Header()
215
+ with Horizontal(id="workspace"):
216
+ yield RichedDirectoryTree(str(self.root), id="file-tree")
217
+ yield FileTreeResizeHandle()
218
+ yield Container(id="editor-slot")
219
+ yield Footer()
220
+
221
+ def on_mount(self) -> None:
222
+ self.title = "riched"
223
+ self.set_file_tree_width(FILE_TREE_DEFAULT_WIDTH)
224
+ if self._initial_path is None:
225
+ self.sub_title = ""
226
+ self.query_one("#file-tree", DirectoryTree).focus()
227
+ return
228
+ self._open_path(self._initial_path)
229
+
230
+ def _editor_or_none(self) -> TextArea | None:
231
+ editors = list(self.query("#editor"))
232
+ return editors[0] if editors else None
233
+
234
+ def _file_tree(self) -> DirectoryTree:
235
+ return self.query_one("#file-tree", DirectoryTree)
236
+
237
+ def _file_tree_resize_handle(self) -> Static:
238
+ return self.query_one("#file-tree-resize-handle", Static)
239
+
240
+ def _is_file_tree_visible(self) -> bool:
241
+ return self._file_tree().styles.display != "none"
242
+
243
+ def _show_file_tree(self) -> None:
244
+ self._file_tree().styles.display = "block"
245
+ self._file_tree_resize_handle().styles.display = "block"
246
+
247
+ def _hide_file_tree(self) -> None:
248
+ self._file_tree().styles.display = "none"
249
+ self._file_tree_resize_handle().styles.display = "none"
250
+
251
+ def set_file_tree_width(self, width: int) -> None:
252
+ clamped_width = max(FILE_TREE_MIN_WIDTH, min(FILE_TREE_MAX_WIDTH, width))
253
+ tree = self._file_tree()
254
+ tree.styles.width = clamped_width
255
+ tree.refresh(layout=True)
256
+
257
+ def _get_or_create_editor(self) -> TextArea:
258
+ editor = self._editor_or_none()
259
+ if editor is not None:
260
+ return editor
261
+ editor = RichedTextArea.code_editor(id="editor", theme="css")
262
+ self.query_one("#editor-slot", Container).mount(editor)
263
+ return editor
264
+
265
+ def _close_buffer(self) -> None:
266
+ self.query_one("#editor-slot", Container).remove_children()
267
+ self.path = None
268
+ self.sub_title = ""
269
+ self._saved_text = ""
270
+ self._show_file_tree()
271
+ self._file_tree().focus()
272
+
273
+ def _open_path(self, path: Path) -> None:
274
+ self.path = path.expanduser()
275
+ self.sub_title = str(self.path)
276
+ editor = self._get_or_create_editor()
277
+ if self.path.exists():
278
+ try:
279
+ content = self.path.read_text()
280
+ except Exception as exc:
281
+ self.notify(f"Could not read: {exc}", severity="error")
282
+ content = ""
283
+ else:
284
+ content = ""
285
+ editor.load_text(content)
286
+ self._saved_text = content
287
+ try:
288
+ apply_language(editor, self.path.suffix)
289
+ except Exception as exc:
290
+ self.notify(f"Syntax highlight off: {exc}", severity="warning")
291
+ editor.focus()
292
+
293
+ def _is_dirty(self) -> bool:
294
+ editor = self._editor_or_none()
295
+ return self.path is not None and editor is not None and editor.text != self._saved_text
296
+
297
+ def on_directory_tree_file_selected(
298
+ self, event: DirectoryTree.FileSelected
299
+ ) -> None:
300
+ event.stop()
301
+ selected = Path(event.path)
302
+ if selected == self.path:
303
+ editor = self._editor_or_none()
304
+ if editor is not None:
305
+ editor.focus()
306
+ return
307
+ self._switch_path(selected)
308
+
309
+ def _switch_path(self, selected: Path) -> None:
310
+ if not self._is_dirty():
311
+ self._open_path(selected)
312
+ return
313
+
314
+ def handle(choice: str) -> None:
315
+ if choice == "save":
316
+ self.action_save()
317
+ if not self._is_dirty():
318
+ self._open_path(selected)
319
+ elif choice == "discard":
320
+ self._open_path(selected)
321
+
322
+ self.push_screen(UnsavedChangesScreen(), handle)
323
+
324
+ def _quick_open_files(self) -> list[Path]:
325
+ root = self.root.expanduser()
326
+ files: list[Path] = []
327
+ stack = [root]
328
+ while stack:
329
+ directory = stack.pop()
330
+ try:
331
+ entries = sorted(directory.iterdir(), key=lambda path: path.name.lower())
332
+ except OSError:
333
+ continue
334
+ for entry in entries:
335
+ try:
336
+ if entry.is_dir():
337
+ if entry.name not in QUICK_OPEN_SKIP_DIRS and not entry.is_symlink():
338
+ stack.append(entry)
339
+ elif entry.is_file():
340
+ files.append(entry)
341
+ except OSError:
342
+ continue
343
+ return sorted(files, key=lambda path: path.relative_to(root).as_posix().lower())
344
+
345
+ def action_quick_open(self) -> None:
346
+ def handle(selected: Path | None) -> None:
347
+ if selected is not None:
348
+ self._switch_path(selected)
349
+
350
+ self.push_screen(
351
+ QuickOpenScreen(self.root.expanduser(), self._quick_open_files()),
352
+ handle,
353
+ )
354
+
355
+ def action_toggle_command_palette(self) -> None:
356
+ if CommandPalette.is_open(self):
357
+ self.pop_screen()
358
+ return
359
+ self.action_command_palette()
360
+
361
+ def action_show_keys_popup(self) -> None:
362
+ self.push_screen(KeysHelpScreen())
363
+
364
+ def action_save(self) -> None:
365
+ if self.path is None:
366
+ self.notify("No file open", severity="warning")
367
+ return
368
+ editor = self._editor_or_none()
369
+ if editor is None:
370
+ self.notify("No file open", severity="warning")
371
+ return
372
+ try:
373
+ self.path.write_text(editor.text)
374
+ except Exception as exc:
375
+ self.notify(f"Save failed: {exc}", severity="error")
376
+ return
377
+ self._saved_text = editor.text
378
+ self.notify(f"Saved {self.path}")
379
+
380
+ def action_quit_check(self) -> None:
381
+ if not self._is_dirty():
382
+ self.exit()
383
+ return
384
+
385
+ def handle(choice: str) -> None:
386
+ if choice == "save":
387
+ self.action_save()
388
+ if not self._is_dirty():
389
+ self.exit()
390
+ elif choice == "discard":
391
+ self.exit()
392
+
393
+ self.push_screen(UnsavedChangesScreen(), handle)
394
+
395
+ def action_close_buffer(self) -> None:
396
+ if self.path is None:
397
+ return
398
+ if not self._is_dirty():
399
+ self._close_buffer()
400
+ return
401
+
402
+ def handle(choice: str) -> None:
403
+ if choice == "save":
404
+ self.action_save()
405
+ if not self._is_dirty():
406
+ self._close_buffer()
407
+ elif choice == "discard":
408
+ self._close_buffer()
409
+
410
+ self.push_screen(UnsavedChangesScreen(), handle)
411
+
412
+ def action_toggle_file_tree(self) -> None:
413
+ editor = self._editor_or_none()
414
+ if self._is_file_tree_visible():
415
+ if editor is None:
416
+ return
417
+ self._hide_file_tree()
418
+ editor.focus()
419
+ return
420
+ self._show_file_tree()
421
+
422
+ def action_toggle_file_tree_focus(self) -> None:
423
+ tree = self._file_tree()
424
+ if not self._is_file_tree_visible():
425
+ self._show_file_tree()
426
+ tree.focus()
427
+ return
428
+ editor = self._editor_or_none()
429
+ if tree.has_focus:
430
+ if editor is not None:
431
+ editor.focus()
432
+ return
433
+ tree.focus()