riched 0.6.0__tar.gz → 0.6.2__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.
- {riched-0.6.0 → riched-0.6.2}/AGENTS.md +5 -5
- {riched-0.6.0 → riched-0.6.2}/PKG-INFO +10 -8
- {riched-0.6.0 → riched-0.6.2}/README.md +9 -7
- {riched-0.6.0 → riched-0.6.2}/pyproject.toml +1 -1
- {riched-0.6.0 → riched-0.6.2}/src/riched/app.py +15 -10
- {riched-0.6.0 → riched-0.6.2}/src/riched/bindings.yaml +4 -1
- {riched-0.6.0 → riched-0.6.2}/src/riched/formatting.py +106 -1
- {riched-0.6.0 → riched-0.6.2}/src/riched/keybindings.py +79 -47
- {riched-0.6.0 → riched-0.6.2}/src/riched/screens.py +3 -3
- {riched-0.6.0 → riched-0.6.2}/tests/helpers.py +7 -7
- {riched-0.6.0 → riched-0.6.2}/tests/test_command_palette.py +37 -2
- {riched-0.6.0 → riched-0.6.2}/tests/test_footer.py +7 -7
- {riched-0.6.0 → riched-0.6.2}/tests/test_formatting.py +114 -0
- {riched-0.6.0 → riched-0.6.2}/tests/test_markdown_preview.py +2 -2
- {riched-0.6.0 → riched-0.6.2}/uv.lock +1 -1
- {riched-0.6.0 → riched-0.6.2}/.gitignore +0 -0
- {riched-0.6.0 → riched-0.6.2}/.python-version +0 -0
- {riched-0.6.0 → riched-0.6.2}/src/riched/__init__.py +0 -0
- {riched-0.6.0 → riched-0.6.2}/src/riched/cli.py +0 -0
- {riched-0.6.0 → riched-0.6.2}/src/riched/editor.py +0 -0
- {riched-0.6.0 → riched-0.6.2}/src/riched/quick_open.py +0 -0
- {riched-0.6.0 → riched-0.6.2}/src/riched/settings.py +0 -0
- {riched-0.6.0 → riched-0.6.2}/src/riched/syntax.py +0 -0
- {riched-0.6.0 → riched-0.6.2}/tests/__init__.py +0 -0
- {riched-0.6.0 → riched-0.6.2}/tests/runner.py +0 -0
- {riched-0.6.0 → riched-0.6.2}/tests/test_cli.py +0 -0
- {riched-0.6.0 → riched-0.6.2}/tests/test_dirty_buffers.py +0 -0
- {riched-0.6.0 → riched-0.6.2}/tests/test_editor_shortcuts.py +0 -0
- {riched-0.6.0 → riched-0.6.2}/tests/test_file_io.py +0 -0
- {riched-0.6.0 → riched-0.6.2}/tests/test_file_tree.py +0 -0
- {riched-0.6.0 → riched-0.6.2}/tests/test_quick_open.py +0 -0
- {riched-0.6.0 → riched-0.6.2}/tests/test_syntax.py +0 -0
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
# Repository Guidelines
|
|
2
2
|
|
|
3
|
-
- check if a hotkey is bounded with Ghostty built-in hotkey before adding any new key binding
|
|
4
|
-
- riched only supports Ghostty on macOS. For every new hotkey or
|
|
3
|
+
- check if a hotkey is bounded with Ghostty built-in hotkey before adding any new key binding. Add startup detection for Ghostty-conflicted hotkeys and document any alternative hotkeys as alternatives.
|
|
4
|
+
- riched only supports Ghostty on macOS. For every new hotkey or alternative, verify the real Ghostty/Textual terminal path, not only Textual Pilot's synthetic key name. Use `ghostty +list-keybinds --default` to check Ghostty interception, then inspect Textual's ANSI mapping with `uv run python` when the key uses Ctrl punctuation or other ambiguous terminal sequences. Add e2e coverage for both the requested human spelling and the real Textual key name when they differ, for example `ctrl+right_square_bracket` for real terminal Ctrl+]. Do not use alternatives that collapse to `escape` or another existing semantic key; Ctrl+[ is Escape in terminal input and must not be used as a distinct alternative.
|
|
5
5
|
|
|
6
6
|
## Project Structure & Module Organization
|
|
7
7
|
|
|
@@ -20,15 +20,15 @@ This repository is a uv-managed Python project for `riched`, a Textual TUI edito
|
|
|
20
20
|
|
|
21
21
|
Use Python 3.12-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.
|
|
22
22
|
|
|
23
|
-
Keep key bindings in `src/riched/bindings.yaml`, not hardcoded in Python. The F1 hotkey popup is generated from this YAML and displays
|
|
23
|
+
Keep key bindings in `src/riched/bindings.yaml`, not hardcoded in Python. The F1 hotkey popup is generated from this YAML and displays user-facing keys from the binding metadata, preferring `cmd` aliases over `super`.
|
|
24
24
|
|
|
25
|
-
When changing app-level key bindings, test both file-open and no-buffer/directory-start states. For Command-key regressions, verify both Textual names (`cmd+...` and terminal-normalized `super+...`) because Pilot can pass for synthetic keys while a real terminal path or missing app state still breaks. For
|
|
25
|
+
When changing app-level key bindings, test both file-open and no-buffer/directory-start states. For Command-key regressions, verify both Textual names (`cmd+...` and terminal-normalized `super+...`) because Pilot can pass for synthetic keys while a real terminal path or missing app state still breaks. For alternative-key regressions, verify the real Ghostty/Textual key name in addition to the displayed alternative label; Pilot can synthesize impossible keys such as `ctrl+[` even though real Ghostty/macOS terminal input arrives as `escape`. Do not patch Command-key behavior by adding Python-side hardcoded alternatives; add aliases to `bindings.yaml` and e2e coverage instead.
|
|
26
26
|
|
|
27
27
|
## Testing Guidelines
|
|
28
28
|
|
|
29
29
|
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, quick-open search/indexing, or syntax highlighting. Use temporary files as existing tests do.
|
|
30
30
|
|
|
31
|
-
For hotkey e2e tests, cover the YAML-declared preferred key, terminal-normalized aliases, and
|
|
31
|
+
For hotkey e2e tests, cover the YAML-declared preferred key, terminal-normalized aliases, and any alternative keys. If the real terminal key name differs from the user-facing label, cover the real Textual name too. Before accepting an alternative, confirm it can be produced distinctly by Ghostty on macOS; Ctrl punctuation is especially risky because some combinations are control bytes with legacy meanings.
|
|
32
32
|
|
|
33
33
|
## Commit & Pull Request Guidelines
|
|
34
34
|
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: riched
|
|
3
|
-
Version: 0.6.
|
|
3
|
+
Version: 0.6.2
|
|
4
4
|
Summary: A Rich library powered editor(hence the name riched) that implements VS Code keybindings in TUI
|
|
5
5
|
Project-URL: Repository, https://github.com/hoishing/riched
|
|
6
6
|
Requires-Python: >=3.12
|
|
@@ -90,16 +90,18 @@ Editor shortcuts:
|
|
|
90
90
|
The `⌥⇧F` shortcut formats the current buffer with external formatter CLIs:
|
|
91
91
|
|
|
92
92
|
- Python files use `ruff`.
|
|
93
|
+
- TOML files use `taplo`.
|
|
94
|
+
- XML files use `prettier` with `@prettier/plugin-xml`.
|
|
93
95
|
- Other supported source/config formats use `prettier`.
|
|
94
96
|
|
|
95
|
-
Install
|
|
97
|
+
Install the required tools before using the shortcut:
|
|
96
98
|
|
|
97
99
|
```sh
|
|
98
|
-
bun add --global prettier
|
|
99
|
-
|
|
100
|
+
bun add --global prettier @prettier/plugin-xml
|
|
101
|
+
brew install ruff taplo
|
|
100
102
|
```
|
|
101
103
|
|
|
102
|
-
Files unsupported by
|
|
104
|
+
Files unsupported by these tools are left unchanged and `riched` shows a warning.
|
|
103
105
|
|
|
104
106
|
## Limitations
|
|
105
107
|
|
|
@@ -111,9 +113,9 @@ Files unsupported by both tools are left unchanged and `riched` shows a warning.
|
|
|
111
113
|
|
|
112
114
|
- some hotkeys are bounded by Ghostty by default. eg. `⌘Enter`, `⌘⇧Enter`, `⌘⇧V`, `⌘[`, `⌘]`, `⌘⇧P`, `⌘W`, `⌘Q`...etc
|
|
113
115
|
- `riched` detects conflicted shortcuts at startup.
|
|
114
|
-
- Conflicted shortcuts are hidden from the footer until they are unbound in Ghostty config.
|
|
116
|
+
- Conflicted shortcuts are hidden from the footer until they are unbound in Ghostty config, unless the command has another available alternative.
|
|
115
117
|
- The key bindings popup marks conflicted shortcuts with `⚠️` and shows a Ghostty config reminder.
|
|
116
|
-
- `⌘⇧P`
|
|
118
|
+
- `⌘⇧P` and `F1` are alternative hotkeys for the command palette.
|
|
117
119
|
|
|
118
120
|
### Unbind Ghostty default keybindings
|
|
119
121
|
|
|
@@ -134,7 +136,7 @@ keybind = super+enter=unbind
|
|
|
134
136
|
keybind = super+shift+enter=unbind
|
|
135
137
|
```
|
|
136
138
|
|
|
137
|
-
|
|
139
|
+
The command palette can still be opened with `F1`. To use `⌘⇧P` directly, also add:
|
|
138
140
|
|
|
139
141
|
```conf
|
|
140
142
|
keybind = super+shift+p=unbind
|
|
@@ -79,16 +79,18 @@ Editor shortcuts:
|
|
|
79
79
|
The `⌥⇧F` shortcut formats the current buffer with external formatter CLIs:
|
|
80
80
|
|
|
81
81
|
- Python files use `ruff`.
|
|
82
|
+
- TOML files use `taplo`.
|
|
83
|
+
- XML files use `prettier` with `@prettier/plugin-xml`.
|
|
82
84
|
- Other supported source/config formats use `prettier`.
|
|
83
85
|
|
|
84
|
-
Install
|
|
86
|
+
Install the required tools before using the shortcut:
|
|
85
87
|
|
|
86
88
|
```sh
|
|
87
|
-
bun add --global prettier
|
|
88
|
-
|
|
89
|
+
bun add --global prettier @prettier/plugin-xml
|
|
90
|
+
brew install ruff taplo
|
|
89
91
|
```
|
|
90
92
|
|
|
91
|
-
Files unsupported by
|
|
93
|
+
Files unsupported by these tools are left unchanged and `riched` shows a warning.
|
|
92
94
|
|
|
93
95
|
## Limitations
|
|
94
96
|
|
|
@@ -100,9 +102,9 @@ Files unsupported by both tools are left unchanged and `riched` shows a warning.
|
|
|
100
102
|
|
|
101
103
|
- some hotkeys are bounded by Ghostty by default. eg. `⌘Enter`, `⌘⇧Enter`, `⌘⇧V`, `⌘[`, `⌘]`, `⌘⇧P`, `⌘W`, `⌘Q`...etc
|
|
102
104
|
- `riched` detects conflicted shortcuts at startup.
|
|
103
|
-
- Conflicted shortcuts are hidden from the footer until they are unbound in Ghostty config.
|
|
105
|
+
- Conflicted shortcuts are hidden from the footer until they are unbound in Ghostty config, unless the command has another available alternative.
|
|
104
106
|
- The key bindings popup marks conflicted shortcuts with `⚠️` and shows a Ghostty config reminder.
|
|
105
|
-
- `⌘⇧P`
|
|
107
|
+
- `⌘⇧P` and `F1` are alternative hotkeys for the command palette.
|
|
106
108
|
|
|
107
109
|
### Unbind Ghostty default keybindings
|
|
108
110
|
|
|
@@ -123,7 +125,7 @@ keybind = super+enter=unbind
|
|
|
123
125
|
keybind = super+shift+enter=unbind
|
|
124
126
|
```
|
|
125
127
|
|
|
126
|
-
|
|
128
|
+
The command palette can still be opened with `F1`. To use `⌘⇧P` directly, also add:
|
|
127
129
|
|
|
128
130
|
```conf
|
|
129
131
|
keybind = super+shift+p=unbind
|
|
@@ -30,7 +30,7 @@ from .keybindings import (
|
|
|
30
30
|
DEFAULT_BINDINGS,
|
|
31
31
|
app_binding_display_key,
|
|
32
32
|
display_key_with_symbols,
|
|
33
|
-
|
|
33
|
+
ghostty_conflicted_hotkey_triggers,
|
|
34
34
|
)
|
|
35
35
|
from .quick_open import (
|
|
36
36
|
MAX_QUICK_OPEN_INDEX_FILES,
|
|
@@ -66,10 +66,7 @@ class RichedFooter(Footer):
|
|
|
66
66
|
"""Footer that shows app bindings and omits blocked bindings."""
|
|
67
67
|
|
|
68
68
|
def _hide_binding(self, binding: Binding) -> bool:
|
|
69
|
-
return (
|
|
70
|
-
not self.app.get_key_display(binding)
|
|
71
|
-
and binding.action in self.app._ghostty_app_hotkey_conflicts
|
|
72
|
-
)
|
|
69
|
+
return not self.app.get_key_display(binding)
|
|
73
70
|
|
|
74
71
|
def compose(self) -> ComposeResult:
|
|
75
72
|
if not self._bindings_ready:
|
|
@@ -304,7 +301,9 @@ class RichedApp(App):
|
|
|
304
301
|
self._quick_open_limited = False
|
|
305
302
|
self._quick_open_generation = 0
|
|
306
303
|
self._quick_open_screen: QuickOpenScreen | None = None
|
|
307
|
-
self.
|
|
304
|
+
self._ghostty_conflicted_hotkey_triggers = (
|
|
305
|
+
ghostty_conflicted_hotkey_triggers()
|
|
306
|
+
)
|
|
308
307
|
|
|
309
308
|
def _save_theme(self, theme: str) -> None:
|
|
310
309
|
try:
|
|
@@ -343,7 +342,7 @@ class RichedApp(App):
|
|
|
343
342
|
key = app_binding_display_key(
|
|
344
343
|
binding.action,
|
|
345
344
|
binding.key,
|
|
346
|
-
|
|
345
|
+
conflicted_triggers=self._ghostty_conflicted_hotkey_triggers,
|
|
347
346
|
)
|
|
348
347
|
return display_key_with_symbols(key)
|
|
349
348
|
|
|
@@ -622,7 +621,9 @@ class RichedApp(App):
|
|
|
622
621
|
|
|
623
622
|
def action_show_keys_popup(self) -> None:
|
|
624
623
|
self.push_screen(
|
|
625
|
-
KeysHelpScreen(
|
|
624
|
+
KeysHelpScreen(
|
|
625
|
+
conflicted_triggers=self._ghostty_conflicted_hotkey_triggers
|
|
626
|
+
)
|
|
626
627
|
)
|
|
627
628
|
|
|
628
629
|
async def action_toggle_markdown_preview(self) -> None:
|
|
@@ -681,9 +682,13 @@ class RichedApp(App):
|
|
|
681
682
|
|
|
682
683
|
result = format_text(self.path, editor.text)
|
|
683
684
|
if result.missing_tool is not None:
|
|
685
|
+
detail = (
|
|
686
|
+
"Install it and ensure Prettier can load it."
|
|
687
|
+
if result.missing_tool.startswith("@")
|
|
688
|
+
else "Install it and ensure it is on PATH."
|
|
689
|
+
)
|
|
684
690
|
self.notify(
|
|
685
|
-
f"`{result.missing_tool}` is required for formatting. "
|
|
686
|
-
"Install it and ensure it is on PATH.",
|
|
691
|
+
f"`{result.missing_tool}` is required for formatting. {detail}",
|
|
687
692
|
severity="warning",
|
|
688
693
|
)
|
|
689
694
|
return
|
|
@@ -7,7 +7,9 @@ app:
|
|
|
7
7
|
description: Command palette
|
|
8
8
|
key: cmd+shift+p,shift+cmd+p,super+shift+p,shift+super+p,f1
|
|
9
9
|
preferred_key: cmd+shift+p,shift+cmd+p,super+shift+p,shift+super+p
|
|
10
|
-
|
|
10
|
+
display_keys:
|
|
11
|
+
- cmd+shift+p
|
|
12
|
+
- f1
|
|
11
13
|
- name: toggle_file_tree
|
|
12
14
|
description: Toggle file tree
|
|
13
15
|
key: cmd+b,super+b
|
|
@@ -40,6 +42,7 @@ editor:
|
|
|
40
42
|
preferred_key: alt+shift+f,shift+alt+f
|
|
41
43
|
action: format_document
|
|
42
44
|
description: Format document
|
|
45
|
+
category: App
|
|
43
46
|
- key: cmd+enter,super+enter
|
|
44
47
|
preferred_key: cmd+enter,super+enter
|
|
45
48
|
action: insert_line_below
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
from __future__ import annotations
|
|
2
2
|
|
|
3
3
|
import json
|
|
4
|
+
import os
|
|
4
5
|
import subprocess
|
|
5
6
|
from dataclasses import dataclass
|
|
6
7
|
from pathlib import Path
|
|
@@ -9,6 +10,12 @@ from typing import Callable
|
|
|
9
10
|
|
|
10
11
|
FORMAT_TIMEOUT_SECONDS = 10.0
|
|
11
12
|
PYTHON_SUFFIXES = {".py", ".pyi"}
|
|
13
|
+
TOML_SUFFIXES = {".toml"}
|
|
14
|
+
XML_SUFFIXES = {".xml"}
|
|
15
|
+
PRETTIER_XML_PLUGIN = "@prettier/plugin-xml"
|
|
16
|
+
PRETTIER_XML_PLUGIN_ENTRYPOINT = Path(
|
|
17
|
+
"@prettier/plugin-xml/src/plugin.js"
|
|
18
|
+
)
|
|
12
19
|
|
|
13
20
|
|
|
14
21
|
@dataclass(frozen=True)
|
|
@@ -37,6 +44,10 @@ def format_text(
|
|
|
37
44
|
suffix = path.suffix.lower()
|
|
38
45
|
if suffix in PYTHON_SUFFIXES:
|
|
39
46
|
return _format_with_ruff(path, text, run_command, find_binary)
|
|
47
|
+
if suffix in TOML_SUFFIXES:
|
|
48
|
+
return _format_with_taplo(path, text, run_command, find_binary)
|
|
49
|
+
if suffix in XML_SUFFIXES:
|
|
50
|
+
return _format_with_prettier_xml(path, text, run_command, find_binary)
|
|
40
51
|
return _format_with_prettier(path, text, run_command, find_binary)
|
|
41
52
|
|
|
42
53
|
|
|
@@ -79,14 +90,60 @@ def _format_with_prettier(
|
|
|
79
90
|
)
|
|
80
91
|
|
|
81
92
|
|
|
93
|
+
def _format_with_taplo(
|
|
94
|
+
path: Path,
|
|
95
|
+
text: str,
|
|
96
|
+
run_command: RunCommand,
|
|
97
|
+
find_binary: FindBinary,
|
|
98
|
+
) -> FormatResult:
|
|
99
|
+
taplo = find_binary("taplo")
|
|
100
|
+
if taplo is None:
|
|
101
|
+
return FormatResult(missing_tool="taplo")
|
|
102
|
+
return _run_formatter(
|
|
103
|
+
[taplo, "format", "--stdin-filepath", str(path), "-"],
|
|
104
|
+
text,
|
|
105
|
+
run_command,
|
|
106
|
+
)
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def _format_with_prettier_xml(
|
|
110
|
+
path: Path,
|
|
111
|
+
text: str,
|
|
112
|
+
run_command: RunCommand,
|
|
113
|
+
find_binary: FindBinary,
|
|
114
|
+
) -> FormatResult:
|
|
115
|
+
prettier = find_binary("prettier")
|
|
116
|
+
if prettier is None:
|
|
117
|
+
return FormatResult(missing_tool="prettier")
|
|
118
|
+
|
|
119
|
+
plugin_arg = f"--plugin={_prettier_xml_plugin_path(prettier)}"
|
|
120
|
+
file_info = _prettier_file_info(prettier, path, run_command, plugin_arg)
|
|
121
|
+
if file_info is False:
|
|
122
|
+
return FormatResult(unsupported=True)
|
|
123
|
+
if isinstance(file_info, FormatResult):
|
|
124
|
+
return file_info
|
|
125
|
+
|
|
126
|
+
return _run_formatter(
|
|
127
|
+
[prettier, plugin_arg, "--stdin-filepath", str(path)],
|
|
128
|
+
text,
|
|
129
|
+
run_command,
|
|
130
|
+
missing_tool=PRETTIER_XML_PLUGIN,
|
|
131
|
+
)
|
|
132
|
+
|
|
133
|
+
|
|
82
134
|
def _prettier_file_info(
|
|
83
135
|
prettier: str,
|
|
84
136
|
path: Path,
|
|
85
137
|
run_command: RunCommand,
|
|
138
|
+
plugin_arg: str | None = None,
|
|
86
139
|
) -> bool | FormatResult:
|
|
140
|
+
command = [prettier]
|
|
141
|
+
if plugin_arg is not None:
|
|
142
|
+
command.append(plugin_arg)
|
|
143
|
+
command.extend(["--file-info", str(path)])
|
|
87
144
|
try:
|
|
88
145
|
result = run_command(
|
|
89
|
-
|
|
146
|
+
command,
|
|
90
147
|
stdout=subprocess.PIPE,
|
|
91
148
|
stderr=subprocess.PIPE,
|
|
92
149
|
text=True,
|
|
@@ -99,6 +156,11 @@ def _prettier_file_info(
|
|
|
99
156
|
return FormatResult(error=f"Prettier failed: {exc}")
|
|
100
157
|
|
|
101
158
|
if result.returncode != 0:
|
|
159
|
+
if plugin_arg is not None and _missing_node_package(
|
|
160
|
+
result,
|
|
161
|
+
PRETTIER_XML_PLUGIN,
|
|
162
|
+
):
|
|
163
|
+
return FormatResult(missing_tool=PRETTIER_XML_PLUGIN)
|
|
102
164
|
message = _command_error_message("Prettier", result)
|
|
103
165
|
return FormatResult(error=message)
|
|
104
166
|
|
|
@@ -110,10 +172,39 @@ def _prettier_file_info(
|
|
|
110
172
|
return bool(info.get("inferredParser")) and not bool(info.get("ignored"))
|
|
111
173
|
|
|
112
174
|
|
|
175
|
+
def _prettier_xml_plugin_path(prettier: str) -> str:
|
|
176
|
+
for directory in _node_module_roots(prettier):
|
|
177
|
+
plugin = directory / PRETTIER_XML_PLUGIN_ENTRYPOINT
|
|
178
|
+
if plugin.exists():
|
|
179
|
+
return os.fspath(plugin)
|
|
180
|
+
return PRETTIER_XML_PLUGIN
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
def _node_module_roots(prettier: str) -> list[Path]:
|
|
184
|
+
roots: list[Path] = []
|
|
185
|
+
cwd_root = Path.cwd() / "node_modules"
|
|
186
|
+
roots.append(cwd_root)
|
|
187
|
+
|
|
188
|
+
try:
|
|
189
|
+
resolved_prettier = Path(prettier).resolve()
|
|
190
|
+
except OSError:
|
|
191
|
+
resolved_prettier = Path(prettier)
|
|
192
|
+
for parent in resolved_prettier.parents:
|
|
193
|
+
if parent.name == "node_modules":
|
|
194
|
+
roots.append(parent)
|
|
195
|
+
break
|
|
196
|
+
|
|
197
|
+
bun_global = Path.home() / ".bun/install/global/node_modules"
|
|
198
|
+
roots.append(bun_global)
|
|
199
|
+
return list(dict.fromkeys(roots))
|
|
200
|
+
|
|
201
|
+
|
|
113
202
|
def _run_formatter(
|
|
114
203
|
command: list[str],
|
|
115
204
|
text: str,
|
|
116
205
|
run_command: RunCommand,
|
|
206
|
+
*,
|
|
207
|
+
missing_tool: str | None = None,
|
|
117
208
|
) -> FormatResult:
|
|
118
209
|
tool = Path(command[0]).name.capitalize()
|
|
119
210
|
try:
|
|
@@ -132,6 +223,8 @@ def _run_formatter(
|
|
|
132
223
|
return FormatResult(error=f"{tool} failed: {exc}")
|
|
133
224
|
|
|
134
225
|
if result.returncode != 0:
|
|
226
|
+
if missing_tool is not None and _missing_node_package(result, missing_tool):
|
|
227
|
+
return FormatResult(missing_tool=missing_tool)
|
|
135
228
|
return FormatResult(error=_command_error_message(tool, result))
|
|
136
229
|
return FormatResult(text=result.stdout)
|
|
137
230
|
|
|
@@ -141,3 +234,15 @@ def _command_error_message(tool: str, result: subprocess.CompletedProcess[str])
|
|
|
141
234
|
if not detail:
|
|
142
235
|
detail = f"exit code {result.returncode}"
|
|
143
236
|
return f"{tool} failed: {detail}"
|
|
237
|
+
|
|
238
|
+
|
|
239
|
+
def _missing_node_package(
|
|
240
|
+
result: subprocess.CompletedProcess[str],
|
|
241
|
+
package: str,
|
|
242
|
+
) -> bool:
|
|
243
|
+
detail = f"{result.stderr}\n{result.stdout}"
|
|
244
|
+
return package in detail and (
|
|
245
|
+
"Cannot find package" in detail
|
|
246
|
+
or "Cannot find module" in detail
|
|
247
|
+
or "MODULE_NOT_FOUND" in detail
|
|
248
|
+
)
|
|
@@ -22,6 +22,15 @@ KEY_MODIFIER_SYMBOLS = {
|
|
|
22
22
|
"option": "⌥",
|
|
23
23
|
"shift": "⇧",
|
|
24
24
|
}
|
|
25
|
+
KEY_MODIFIER_ORDER = {
|
|
26
|
+
"ctrl": 0,
|
|
27
|
+
"control": 0,
|
|
28
|
+
"super": 1,
|
|
29
|
+
"cmd": 1,
|
|
30
|
+
"alt": 2,
|
|
31
|
+
"option": 2,
|
|
32
|
+
"shift": 3,
|
|
33
|
+
}
|
|
25
34
|
|
|
26
35
|
|
|
27
36
|
@dataclass(frozen=True)
|
|
@@ -78,18 +87,23 @@ def _build_item_bindings(items: list[dict[str, Any]]) -> list[Binding]:
|
|
|
78
87
|
|
|
79
88
|
|
|
80
89
|
def binding_help_groups(
|
|
81
|
-
|
|
90
|
+
conflicted_triggers: set[str] | None = None,
|
|
82
91
|
) -> list[KeyBindingHelpGroup]:
|
|
83
|
-
|
|
92
|
+
conflicted_triggers = conflicted_triggers or set()
|
|
84
93
|
groups: list[KeyBindingHelpGroup] = []
|
|
85
94
|
|
|
95
|
+
categorized_app_items = [
|
|
96
|
+
item
|
|
97
|
+
for item in BINDING_SPEC["editor"]
|
|
98
|
+
if item.get("help", True) and item.get("category") == "App"
|
|
99
|
+
]
|
|
86
100
|
app_rows = tuple(
|
|
87
101
|
(
|
|
88
102
|
_help_display_key(item),
|
|
89
103
|
item.get("description") or item["name"],
|
|
90
|
-
_help_conflicted(item,
|
|
104
|
+
_help_conflicted(item, conflicted_triggers),
|
|
91
105
|
)
|
|
92
|
-
for item in BINDING_SPEC["app"]["commands"]
|
|
106
|
+
for item in [*BINDING_SPEC["app"]["commands"], *categorized_app_items]
|
|
93
107
|
)
|
|
94
108
|
groups.append(KeyBindingHelpGroup("App", app_rows))
|
|
95
109
|
|
|
@@ -97,10 +111,10 @@ def binding_help_groups(
|
|
|
97
111
|
(
|
|
98
112
|
_help_display_key(item),
|
|
99
113
|
item.get("description") or item["action"],
|
|
100
|
-
_help_conflicted(item,
|
|
114
|
+
_help_conflicted(item, conflicted_triggers),
|
|
101
115
|
)
|
|
102
116
|
for item in BINDING_SPEC["editor"]
|
|
103
|
-
if item.get("help", True)
|
|
117
|
+
if item.get("help", True) and item.get("category") != "App"
|
|
104
118
|
)
|
|
105
119
|
groups.append(KeyBindingHelpGroup("Editor", editor_rows))
|
|
106
120
|
|
|
@@ -122,40 +136,45 @@ def app_binding_display_key(
|
|
|
122
136
|
action: str,
|
|
123
137
|
key: str,
|
|
124
138
|
*,
|
|
125
|
-
|
|
139
|
+
conflicted_triggers: set[str] | None = None,
|
|
126
140
|
) -> str:
|
|
141
|
+
conflicted_triggers = conflicted_triggers or set()
|
|
127
142
|
item = _app_command_or_none(action)
|
|
128
143
|
if item is None:
|
|
129
144
|
return display_key(key)
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
if
|
|
133
|
-
return
|
|
134
|
-
|
|
135
|
-
return display_key(item.get("preferred_key") or key)
|
|
145
|
+
for display_key_candidate in _item_display_keys(item):
|
|
146
|
+
display = display_key(display_key_candidate)
|
|
147
|
+
if not _display_key_conflicted(display, conflicted_triggers):
|
|
148
|
+
return display
|
|
149
|
+
return ""
|
|
136
150
|
|
|
137
151
|
|
|
138
152
|
def _help_conflicted(
|
|
139
153
|
item: dict[str, Any],
|
|
140
|
-
|
|
154
|
+
conflicted_triggers: set[str],
|
|
141
155
|
) -> bool:
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
and item.get("preferred_key")
|
|
146
|
-
and not item.get("fallback_key")
|
|
156
|
+
return any(
|
|
157
|
+
_display_key_conflicted(display_key(key), conflicted_triggers)
|
|
158
|
+
for key in _item_display_keys(item)
|
|
147
159
|
)
|
|
148
160
|
|
|
149
161
|
|
|
150
162
|
def _help_display_key(item: dict[str, Any]) -> str:
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
163
|
+
return " / ".join(
|
|
164
|
+
display_key_with_symbols(display_key(key))
|
|
165
|
+
for key in _item_display_keys(item)
|
|
166
|
+
)
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
def _item_display_keys(item: dict[str, Any]) -> list[str]:
|
|
170
|
+
display_keys = item.get("display_keys")
|
|
171
|
+
if isinstance(display_keys, list):
|
|
172
|
+
return [str(key) for key in display_keys]
|
|
173
|
+
return [str(item.get("preferred_key") or item["key"])]
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
def _display_key_conflicted(key: str, conflicted_triggers: set[str]) -> bool:
|
|
177
|
+
return bool(_hotkey_conflict_triggers(key) & conflicted_triggers)
|
|
159
178
|
|
|
160
179
|
|
|
161
180
|
def _app_command_or_none(action: str) -> dict[str, Any] | None:
|
|
@@ -209,7 +228,7 @@ def _display_base_key(key: str) -> str:
|
|
|
209
228
|
return base_key.title()
|
|
210
229
|
|
|
211
230
|
|
|
212
|
-
def
|
|
231
|
+
def ghostty_conflicted_hotkey_triggers(
|
|
213
232
|
env: Mapping[str, str] | None = None,
|
|
214
233
|
run_command: Callable[..., CompletedProcess[str]] = run,
|
|
215
234
|
find_binary: Callable[[str], str | None] = which,
|
|
@@ -217,11 +236,11 @@ def ghostty_app_hotkey_conflicts(
|
|
|
217
236
|
triggers = _ghostty_conflict_triggers()
|
|
218
237
|
env = environ if env is None else env
|
|
219
238
|
if env.get("TERM_PROGRAM") != "ghostty" and env.get("TERM") != "xterm-ghostty":
|
|
220
|
-
return set(triggers
|
|
239
|
+
return set(triggers)
|
|
221
240
|
|
|
222
241
|
ghostty = _ghostty_binary(find_binary)
|
|
223
242
|
if ghostty is None:
|
|
224
|
-
return set(triggers
|
|
243
|
+
return set(triggers)
|
|
225
244
|
|
|
226
245
|
try:
|
|
227
246
|
result = run_command(
|
|
@@ -233,14 +252,14 @@ def ghostty_app_hotkey_conflicts(
|
|
|
233
252
|
check=False,
|
|
234
253
|
)
|
|
235
254
|
except Exception:
|
|
236
|
-
return set(triggers
|
|
255
|
+
return set(triggers)
|
|
237
256
|
if result.returncode != 0:
|
|
238
|
-
return set(triggers
|
|
257
|
+
return set(triggers)
|
|
239
258
|
conflicted_triggers = _ghostty_config_conflicted_triggers(
|
|
240
259
|
result.stdout,
|
|
241
|
-
|
|
260
|
+
triggers,
|
|
242
261
|
)
|
|
243
|
-
return
|
|
262
|
+
return conflicted_triggers
|
|
244
263
|
|
|
245
264
|
|
|
246
265
|
def ghostty_markdown_preview_hotkey_conflicted(
|
|
@@ -248,7 +267,7 @@ def ghostty_markdown_preview_hotkey_conflicted(
|
|
|
248
267
|
run_command: Callable[..., CompletedProcess[str]] = run,
|
|
249
268
|
find_binary: Callable[[str], str | None] = which,
|
|
250
269
|
) -> bool:
|
|
251
|
-
return "
|
|
270
|
+
return "super+shift+v" in ghostty_conflicted_hotkey_triggers(
|
|
252
271
|
env,
|
|
253
272
|
run_command,
|
|
254
273
|
find_binary,
|
|
@@ -262,25 +281,38 @@ def _ghostty_binary(find_binary: Callable[[str], str | None]) -> str | None:
|
|
|
262
281
|
return find_binary("ghostty")
|
|
263
282
|
|
|
264
283
|
|
|
265
|
-
def _ghostty_conflict_triggers() ->
|
|
266
|
-
triggers:
|
|
284
|
+
def _ghostty_conflict_triggers() -> set[str]:
|
|
285
|
+
triggers: set[str] = set()
|
|
267
286
|
for item in [*APP_COMMANDS, *BINDING_SPEC["editor"]]:
|
|
268
287
|
preferred_key = item.get("preferred_key")
|
|
269
288
|
if not preferred_key:
|
|
270
289
|
continue
|
|
271
|
-
action = item.get("name") or item.get("action")
|
|
272
|
-
if not action:
|
|
273
|
-
continue
|
|
274
290
|
for candidate in preferred_key.split(","):
|
|
275
|
-
|
|
276
|
-
if "cmd" in trigger:
|
|
277
|
-
continue
|
|
278
|
-
if "super" in trigger or "alt" in trigger:
|
|
279
|
-
triggers[trigger] = action
|
|
280
|
-
break
|
|
291
|
+
triggers.update(_hotkey_conflict_triggers(candidate.strip()))
|
|
281
292
|
return triggers
|
|
282
293
|
|
|
283
294
|
|
|
295
|
+
def _hotkey_conflict_triggers(key: str) -> set[str]:
|
|
296
|
+
parts = key.split("+")
|
|
297
|
+
if "cmd" in parts:
|
|
298
|
+
return {
|
|
299
|
+
_canonical_hotkey_trigger(
|
|
300
|
+
"+".join("super" if part == "cmd" else part for part in parts)
|
|
301
|
+
)
|
|
302
|
+
}
|
|
303
|
+
if "super" in parts or "alt" in parts:
|
|
304
|
+
return {_canonical_hotkey_trigger(key)}
|
|
305
|
+
return set()
|
|
306
|
+
|
|
307
|
+
|
|
308
|
+
def _canonical_hotkey_trigger(key: str) -> str:
|
|
309
|
+
parts = key.split("+")
|
|
310
|
+
modifiers = [part for part in parts[:-1] if part in KEY_MODIFIER_ORDER]
|
|
311
|
+
base_parts = [part for part in parts if part not in KEY_MODIFIER_ORDER]
|
|
312
|
+
ordered_modifiers = sorted(modifiers, key=lambda part: KEY_MODIFIER_ORDER[part])
|
|
313
|
+
return "+".join([*ordered_modifiers, *base_parts])
|
|
314
|
+
|
|
315
|
+
|
|
284
316
|
def _ghostty_config_conflicted_triggers(
|
|
285
317
|
config: str,
|
|
286
318
|
triggers: set[str],
|
|
@@ -296,7 +328,7 @@ def _ghostty_config_conflicted_triggers(
|
|
|
296
328
|
if key.strip() != "keybind":
|
|
297
329
|
continue
|
|
298
330
|
trigger, _, action = value.partition("=")
|
|
299
|
-
trigger = trigger.strip()
|
|
331
|
+
trigger = _canonical_hotkey_trigger(trigger.strip())
|
|
300
332
|
if trigger not in triggers:
|
|
301
333
|
continue
|
|
302
334
|
if action.strip() != "unbind":
|
|
@@ -354,16 +354,16 @@ class KeysHelpScreen(_DismissOnCloseScreen, ModalScreen[None]):
|
|
|
354
354
|
}
|
|
355
355
|
"""
|
|
356
356
|
|
|
357
|
-
def __init__(self,
|
|
357
|
+
def __init__(self, conflicted_triggers: set[str] | None = None) -> None:
|
|
358
358
|
super().__init__()
|
|
359
|
-
self.
|
|
359
|
+
self._conflicted_triggers = conflicted_triggers or set()
|
|
360
360
|
|
|
361
361
|
def compose(self) -> ComposeResult:
|
|
362
362
|
has_warnings = False
|
|
363
363
|
with Vertical(id="dialog"):
|
|
364
364
|
yield Label("Key Bindings")
|
|
365
365
|
with Vertical(id="bindings-list"):
|
|
366
|
-
for group in binding_help_groups(self.
|
|
366
|
+
for group in binding_help_groups(self._conflicted_triggers):
|
|
367
367
|
with Vertical(classes="binding-group"):
|
|
368
368
|
yield Static(group.title, classes="binding-title")
|
|
369
369
|
for key, description, conflicted in group.rows:
|
|
@@ -46,7 +46,7 @@ def _make_app(
|
|
|
46
46
|
path: Path,
|
|
47
47
|
root: Path | None = None,
|
|
48
48
|
markdown_preview_hotkey_conflicted: bool | None = None,
|
|
49
|
-
|
|
49
|
+
ghostty_conflicted_hotkey_triggers: set[str] | None = None,
|
|
50
50
|
):
|
|
51
51
|
cls = type(
|
|
52
52
|
"ConfiguredRichedApp",
|
|
@@ -54,15 +54,15 @@ def _make_app(
|
|
|
54
54
|
{"BINDINGS": mod.build_bindings()},
|
|
55
55
|
)
|
|
56
56
|
app = cls(path, root or path.parent)
|
|
57
|
-
if
|
|
58
|
-
app.
|
|
57
|
+
if ghostty_conflicted_hotkey_triggers is not None:
|
|
58
|
+
app._ghostty_conflicted_hotkey_triggers = ghostty_conflicted_hotkey_triggers
|
|
59
59
|
if markdown_preview_hotkey_conflicted is not None:
|
|
60
|
-
conflicts = set(app.
|
|
60
|
+
conflicts = set(app._ghostty_conflicted_hotkey_triggers)
|
|
61
61
|
if markdown_preview_hotkey_conflicted:
|
|
62
|
-
conflicts.add("
|
|
62
|
+
conflicts.add("super+shift+v")
|
|
63
63
|
else:
|
|
64
|
-
conflicts.discard("
|
|
65
|
-
app.
|
|
64
|
+
conflicts.discard("super+shift+v")
|
|
65
|
+
app._ghostty_conflicted_hotkey_triggers = conflicts
|
|
66
66
|
return app
|
|
67
67
|
|
|
68
68
|
|
|
@@ -17,6 +17,16 @@ def _system_commands(app) -> dict[str, object]:
|
|
|
17
17
|
return {command.title: command for command in app.get_system_commands(app.screen)}
|
|
18
18
|
|
|
19
19
|
|
|
20
|
+
def _key_help_groups(app) -> dict[str, list[tuple[str, str]]]:
|
|
21
|
+
return {
|
|
22
|
+
group.children[0].content: [
|
|
23
|
+
(row.children[0].content, row.children[1].content)
|
|
24
|
+
for row in group.query(".binding-row")
|
|
25
|
+
]
|
|
26
|
+
for group in app.screen.query(".binding-group")
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
|
|
20
30
|
async def test_command_palette_button_is_hidden() -> None:
|
|
21
31
|
app = _palette_app()
|
|
22
32
|
async with app.run_test() as pilot:
|
|
@@ -72,7 +82,7 @@ async def test_command_palette_items_are_sorted_alphabetically() -> None:
|
|
|
72
82
|
|
|
73
83
|
|
|
74
84
|
async def test_keys_help_includes_command_palette_binding() -> None:
|
|
75
|
-
app = _palette_app(
|
|
85
|
+
app = _palette_app(ghostty_conflicted_hotkey_triggers=set())
|
|
76
86
|
async with app.run_test() as pilot:
|
|
77
87
|
await pilot.pause()
|
|
78
88
|
|
|
@@ -93,8 +103,33 @@ async def test_keys_help_includes_command_palette_binding() -> None:
|
|
|
93
103
|
)
|
|
94
104
|
|
|
95
105
|
|
|
106
|
+
async def test_keys_help_shows_format_document_under_app() -> None:
|
|
107
|
+
app = _palette_app(ghostty_conflicted_hotkey_triggers=set())
|
|
108
|
+
async with app.run_test() as pilot:
|
|
109
|
+
await pilot.pause()
|
|
110
|
+
|
|
111
|
+
app.action_show_keys_popup()
|
|
112
|
+
await pilot.pause()
|
|
113
|
+
|
|
114
|
+
groups = _key_help_groups(app)
|
|
115
|
+
assert ("⌥⇧F", "Format document") in groups["App"]
|
|
116
|
+
assert ("⌥⇧F", "Format document") not in groups["Editor"]
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
async def test_keys_help_warns_for_conflicted_command_palette_alternative() -> None:
|
|
120
|
+
app = _palette_app(ghostty_conflicted_hotkey_triggers={"super+shift+p"})
|
|
121
|
+
async with app.run_test() as pilot:
|
|
122
|
+
await pilot.pause()
|
|
123
|
+
|
|
124
|
+
app.action_show_keys_popup()
|
|
125
|
+
await pilot.pause()
|
|
126
|
+
|
|
127
|
+
rows = _key_help_rows(app)
|
|
128
|
+
assert ("⚠️ ⌘⇧P / F1", "Command palette") in rows
|
|
129
|
+
|
|
130
|
+
|
|
96
131
|
async def test_keys_help_warns_for_ghostty_conflicted_format_document() -> None:
|
|
97
|
-
app = _palette_app(
|
|
132
|
+
app = _palette_app(ghostty_conflicted_hotkey_triggers={"alt+shift+f"})
|
|
98
133
|
async with app.run_test() as pilot:
|
|
99
134
|
await pilot.pause()
|
|
100
135
|
|
|
@@ -5,7 +5,7 @@ from subprocess import CompletedProcess
|
|
|
5
5
|
from textual.widgets import Footer
|
|
6
6
|
|
|
7
7
|
from .helpers import _file_app, _footer_labels, _temporary_env
|
|
8
|
-
from riched.keybindings import
|
|
8
|
+
from riched.keybindings import ghostty_conflicted_hotkey_triggers
|
|
9
9
|
|
|
10
10
|
|
|
11
11
|
def _footer_app(**kwargs):
|
|
@@ -14,7 +14,7 @@ def _footer_app(**kwargs):
|
|
|
14
14
|
|
|
15
15
|
def _conflicts_from_config(*lines: str) -> set[str]:
|
|
16
16
|
config = "\n".join(lines)
|
|
17
|
-
return
|
|
17
|
+
return ghostty_conflicted_hotkey_triggers(
|
|
18
18
|
env={"TERM_PROGRAM": "ghostty"},
|
|
19
19
|
run_command=lambda *args, **kwargs: CompletedProcess(
|
|
20
20
|
args[0],
|
|
@@ -27,7 +27,7 @@ def _conflicts_from_config(*lines: str) -> set[str]:
|
|
|
27
27
|
|
|
28
28
|
|
|
29
29
|
async def test_footer_uses_macos_modifier_symbols_with_preferred_markdown_preview() -> None:
|
|
30
|
-
app = _footer_app(
|
|
30
|
+
app = _footer_app(ghostty_conflicted_hotkey_triggers=set())
|
|
31
31
|
async with app.run_test() as pilot:
|
|
32
32
|
await pilot.pause()
|
|
33
33
|
footer = app.query_one(Footer)
|
|
@@ -47,8 +47,8 @@ async def test_footer_uses_macos_modifier_symbols_with_preferred_markdown_previe
|
|
|
47
47
|
assert not footer.query(".-command-palette")
|
|
48
48
|
|
|
49
49
|
|
|
50
|
-
async def
|
|
51
|
-
app = _footer_app(
|
|
50
|
+
async def test_footer_uses_command_palette_alternative_for_ghostty_conflict() -> None:
|
|
51
|
+
app = _footer_app(ghostty_conflicted_hotkey_triggers={"super+shift+p"})
|
|
52
52
|
async with app.run_test() as pilot:
|
|
53
53
|
await pilot.pause()
|
|
54
54
|
footer = app.query_one(Footer)
|
|
@@ -58,7 +58,7 @@ async def test_footer_uses_command_palette_fallback_for_ghostty_conflict() -> No
|
|
|
58
58
|
|
|
59
59
|
async def test_footer_uses_command_palette_preferred_when_ghostty_unbound() -> None:
|
|
60
60
|
app = _footer_app(
|
|
61
|
-
|
|
61
|
+
ghostty_conflicted_hotkey_triggers=_conflicts_from_config(
|
|
62
62
|
"keybind = super+shift+,=reload_config",
|
|
63
63
|
"keybind = super+shift+v=toggle_quick_terminal",
|
|
64
64
|
)
|
|
@@ -95,7 +95,7 @@ async def test_footer_hides_markdown_preview_outside_ghostty() -> None:
|
|
|
95
95
|
|
|
96
96
|
async def test_footer_uses_markdown_preview_preferred_when_ghostty_unbound() -> None:
|
|
97
97
|
app = _footer_app(
|
|
98
|
-
|
|
98
|
+
ghostty_conflicted_hotkey_triggers=_conflicts_from_config(
|
|
99
99
|
"keybind = super+shift+,=reload_config",
|
|
100
100
|
"keybind = super+shift+v=unbind",
|
|
101
101
|
)
|
|
@@ -28,8 +28,22 @@ exit 2
|
|
|
28
28
|
_write_executable(
|
|
29
29
|
bin_dir / "prettier",
|
|
30
30
|
"""#!/bin/sh
|
|
31
|
+
plugin=
|
|
32
|
+
case "$1" in
|
|
33
|
+
--plugin=*)
|
|
34
|
+
plugin=xml
|
|
35
|
+
shift
|
|
36
|
+
;;
|
|
37
|
+
esac
|
|
31
38
|
if [ "$1" = "--file-info" ]; then
|
|
32
39
|
case "$2" in
|
|
40
|
+
*.xml)
|
|
41
|
+
if [ "$plugin" = "xml" ]; then
|
|
42
|
+
printf '{ "ignored": false, "inferredParser": "xml" }\\n'
|
|
43
|
+
else
|
|
44
|
+
printf '{ "ignored": false, "inferredParser": null }\\n'
|
|
45
|
+
fi
|
|
46
|
+
;;
|
|
33
47
|
*.json|*.js) printf '{ "ignored": false, "inferredParser": "json" }\\n' ;;
|
|
34
48
|
*) printf '{ "ignored": false, "inferredParser": null }\\n' ;;
|
|
35
49
|
esac
|
|
@@ -41,10 +55,42 @@ if [ "$1" = "--stdin-filepath" ]; then
|
|
|
41
55
|
exit 2
|
|
42
56
|
fi
|
|
43
57
|
cat >/dev/null
|
|
58
|
+
if [ "$plugin" = "xml" ]; then
|
|
59
|
+
printf '<root>\\n <child />\\n</root>\\n'
|
|
60
|
+
exit 0
|
|
61
|
+
fi
|
|
44
62
|
printf '{ "a": 1 }\\n'
|
|
45
63
|
exit 0
|
|
46
64
|
fi
|
|
47
65
|
exit 2
|
|
66
|
+
""",
|
|
67
|
+
)
|
|
68
|
+
_write_executable(
|
|
69
|
+
bin_dir / "taplo",
|
|
70
|
+
"""#!/bin/sh
|
|
71
|
+
if [ "$1" = "format" ]; then
|
|
72
|
+
cat >/dev/null
|
|
73
|
+
printf 'name = "riched"\\n'
|
|
74
|
+
exit 0
|
|
75
|
+
fi
|
|
76
|
+
exit 2
|
|
77
|
+
""",
|
|
78
|
+
)
|
|
79
|
+
return bin_dir
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def _missing_xml_plugin_bin(tmp: Path) -> Path:
|
|
83
|
+
bin_dir = _formatter_bin(tmp)
|
|
84
|
+
_write_executable(
|
|
85
|
+
bin_dir / "prettier",
|
|
86
|
+
"""#!/bin/sh
|
|
87
|
+
case "$1" in
|
|
88
|
+
--plugin=*)
|
|
89
|
+
printf "Cannot find package '@prettier/plugin-xml'\\n" >&2
|
|
90
|
+
exit 1
|
|
91
|
+
;;
|
|
92
|
+
esac
|
|
93
|
+
exit 2
|
|
48
94
|
""",
|
|
49
95
|
)
|
|
50
96
|
return bin_dir
|
|
@@ -75,6 +121,30 @@ async def test_format_document_uses_prettier_for_supported_files() -> None:
|
|
|
75
121
|
assert path.read_text() == '{"a":1}'
|
|
76
122
|
|
|
77
123
|
|
|
124
|
+
async def test_format_document_uses_taplo_for_toml() -> None:
|
|
125
|
+
tmp, path, app = _file_app("config.toml", 'name="riched"')
|
|
126
|
+
bin_dir = _formatter_bin(tmp)
|
|
127
|
+
with _temporary_env(PATH=f"{bin_dir}{os.pathsep}{os.environ.get('PATH', '')}"):
|
|
128
|
+
async with app.run_test() as pilot:
|
|
129
|
+
await pilot.pause()
|
|
130
|
+
editor = _editor(app)
|
|
131
|
+
await _press(pilot, "alt+shift+f")
|
|
132
|
+
assert editor.text == 'name = "riched"\n'
|
|
133
|
+
assert path.read_text() == 'name="riched"'
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
async def test_format_document_uses_prettier_xml_plugin_for_xml() -> None:
|
|
137
|
+
tmp, path, app = _file_app("document.xml", "<root><child/></root>")
|
|
138
|
+
bin_dir = _formatter_bin(tmp)
|
|
139
|
+
with _temporary_env(PATH=f"{bin_dir}{os.pathsep}{os.environ.get('PATH', '')}"):
|
|
140
|
+
async with app.run_test() as pilot:
|
|
141
|
+
await pilot.pause()
|
|
142
|
+
editor = _editor(app)
|
|
143
|
+
await _press(pilot, "alt+shift+f")
|
|
144
|
+
assert editor.text == "<root>\n <child />\n</root>\n"
|
|
145
|
+
assert path.read_text() == "<root><child/></root>"
|
|
146
|
+
|
|
147
|
+
|
|
78
148
|
async def test_format_document_unsupported_file_notifies_without_change() -> None:
|
|
79
149
|
tmp, _, app = _file_app("notes.txt", "plain text")
|
|
80
150
|
bin_dir = _formatter_bin(tmp)
|
|
@@ -115,6 +185,50 @@ async def test_format_document_missing_formatter_notifies_without_change() -> No
|
|
|
115
185
|
]
|
|
116
186
|
|
|
117
187
|
|
|
188
|
+
async def test_format_document_missing_taplo_notifies_without_change() -> None:
|
|
189
|
+
tmp, _, app = _file_app("config.toml", 'name="riched"')
|
|
190
|
+
empty_bin = tmp / "empty-bin"
|
|
191
|
+
empty_bin.mkdir()
|
|
192
|
+
notifications: list[tuple[str, str | None]] = []
|
|
193
|
+
app.notify = lambda message, **kwargs: notifications.append(
|
|
194
|
+
(str(message), kwargs.get("severity"))
|
|
195
|
+
)
|
|
196
|
+
with _temporary_env(PATH=str(empty_bin)):
|
|
197
|
+
async with app.run_test() as pilot:
|
|
198
|
+
await pilot.pause()
|
|
199
|
+
editor = _editor(app)
|
|
200
|
+
await _press(pilot, "alt+shift+f")
|
|
201
|
+
assert editor.text == 'name="riched"'
|
|
202
|
+
assert notifications == [
|
|
203
|
+
(
|
|
204
|
+
"`taplo` is required for formatting. Install it and ensure it is on PATH.",
|
|
205
|
+
"warning",
|
|
206
|
+
)
|
|
207
|
+
]
|
|
208
|
+
|
|
209
|
+
|
|
210
|
+
async def test_format_document_missing_xml_plugin_notifies_without_change() -> None:
|
|
211
|
+
tmp, _, app = _file_app("document.xml", "<root><child/></root>")
|
|
212
|
+
bin_dir = _missing_xml_plugin_bin(tmp)
|
|
213
|
+
notifications: list[tuple[str, str | None]] = []
|
|
214
|
+
app.notify = lambda message, **kwargs: notifications.append(
|
|
215
|
+
(str(message), kwargs.get("severity"))
|
|
216
|
+
)
|
|
217
|
+
with _temporary_env(PATH=f"{bin_dir}{os.pathsep}{os.environ.get('PATH', '')}"):
|
|
218
|
+
async with app.run_test() as pilot:
|
|
219
|
+
await pilot.pause()
|
|
220
|
+
editor = _editor(app)
|
|
221
|
+
await _press(pilot, "alt+shift+f")
|
|
222
|
+
assert editor.text == "<root><child/></root>"
|
|
223
|
+
assert notifications == [
|
|
224
|
+
(
|
|
225
|
+
"`@prettier/plugin-xml` is required for formatting. "
|
|
226
|
+
"Install it and ensure Prettier can load it.",
|
|
227
|
+
"warning",
|
|
228
|
+
)
|
|
229
|
+
]
|
|
230
|
+
|
|
231
|
+
|
|
118
232
|
async def test_format_document_formatter_failure_notifies_without_change() -> None:
|
|
119
233
|
tmp, _, app = _file_app("format-fail.js", "const value={a:1}")
|
|
120
234
|
bin_dir = _formatter_bin(tmp)
|
|
@@ -118,7 +118,7 @@ async def test_keys_help_includes_markdown_preview_binding() -> None:
|
|
|
118
118
|
"README.md",
|
|
119
119
|
"# Title",
|
|
120
120
|
root_is_tmp=True,
|
|
121
|
-
|
|
121
|
+
ghostty_conflicted_hotkey_triggers=set(),
|
|
122
122
|
)
|
|
123
123
|
async with app.run_test() as pilot:
|
|
124
124
|
await pilot.pause()
|
|
@@ -135,7 +135,7 @@ async def test_keys_help_warns_for_ghostty_conflicted_markdown_preview() -> None
|
|
|
135
135
|
"README.md",
|
|
136
136
|
"# Title",
|
|
137
137
|
root_is_tmp=True,
|
|
138
|
-
|
|
138
|
+
ghostty_conflicted_hotkey_triggers={"super+shift+v"},
|
|
139
139
|
)
|
|
140
140
|
async with app.run_test() as pilot:
|
|
141
141
|
await pilot.pause()
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|