riched 0.1.0__tar.gz → 0.2.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.
- {riched-0.1.0 → riched-0.2.0}/AGENTS.md +4 -2
- {riched-0.1.0 → riched-0.2.0}/PKG-INFO +40 -19
- {riched-0.1.0 → riched-0.2.0}/README.md +39 -18
- {riched-0.1.0 → riched-0.2.0}/pyproject.toml +1 -1
- {riched-0.1.0 → riched-0.2.0}/src/riched/app.py +142 -33
- riched-0.2.0/src/riched/quick_open.py +158 -0
- {riched-0.1.0 → riched-0.2.0}/src/riched/screens.py +124 -22
- {riched-0.1.0 → riched-0.2.0}/tests/runner.py +36 -0
- {riched-0.1.0 → riched-0.2.0}/tests/test_file_tree.py +0 -1
- riched-0.2.0/tests/test_quick_open.py +208 -0
- {riched-0.1.0 → riched-0.2.0}/uv.lock +1 -1
- {riched-0.1.0 → riched-0.2.0}/.gitignore +0 -0
- {riched-0.1.0 → riched-0.2.0}/.python-version +0 -0
- {riched-0.1.0 → riched-0.2.0}/src/riched/__init__.py +0 -0
- {riched-0.1.0 → riched-0.2.0}/src/riched/bindings.yaml +0 -0
- {riched-0.1.0 → riched-0.2.0}/src/riched/cli.py +0 -0
- {riched-0.1.0 → riched-0.2.0}/src/riched/editor.py +0 -0
- {riched-0.1.0 → riched-0.2.0}/src/riched/keybindings.py +0 -0
- {riched-0.1.0 → riched-0.2.0}/src/riched/settings.py +0 -0
- {riched-0.1.0 → riched-0.2.0}/src/riched/syntax.py +0 -0
- {riched-0.1.0 → riched-0.2.0}/tests/__init__.py +0 -0
- {riched-0.1.0 → riched-0.2.0}/tests/helpers.py +0 -0
- {riched-0.1.0 → riched-0.2.0}/tests/test_command_palette.py +0 -0
- {riched-0.1.0 → riched-0.2.0}/tests/test_dirty_buffers.py +0 -0
- {riched-0.1.0 → riched-0.2.0}/tests/test_editor_shortcuts.py +0 -0
- {riched-0.1.0 → riched-0.2.0}/tests/test_file_io.py +0 -0
- {riched-0.1.0 → riched-0.2.0}/tests/test_footer.py +0 -0
- {riched-0.1.0 → riched-0.2.0}/tests/test_syntax.py +0 -0
|
@@ -2,13 +2,15 @@
|
|
|
2
2
|
|
|
3
3
|
## Project Structure & Module Organization
|
|
4
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/`.
|
|
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, `quick_open.py` indexes quick-open candidates, `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/`. Avoid committing generated files such as `.venv/`, `__pycache__/`, `keys.log`, `dist/`, or build artifacts.
|
|
6
6
|
|
|
7
7
|
## Build, Test, and Development Commands
|
|
8
8
|
|
|
9
9
|
- `uv sync`: install project dependencies from `pyproject.toml` and `uv.lock`.
|
|
10
10
|
- `uv run riched path/to/file.txt`: open or create a file in the editor.
|
|
11
11
|
- `uv run python -m tests.runner`: run the Textual Pilot end-to-end suite.
|
|
12
|
+
- `uv build`: build the source distribution and wheel in `dist/`.
|
|
13
|
+
- `uv publish`: publish built distributions for a release.
|
|
12
14
|
- `uv add <package>`: add runtime dependencies; do not use `pip`.
|
|
13
15
|
|
|
14
16
|
## Coding Style & Naming Conventions
|
|
@@ -19,7 +21,7 @@ Keep key bindings in `src/riched/bindings.yaml`, not hardcoded in Python. The F1
|
|
|
19
21
|
|
|
20
22
|
## Testing Guidelines
|
|
21
23
|
|
|
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.
|
|
24
|
+
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.
|
|
23
25
|
|
|
24
26
|
## Commit & Pull Request Guidelines
|
|
25
27
|
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: riched
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.2.0
|
|
4
4
|
Summary: A Textual TUI editor
|
|
5
5
|
Requires-Python: >=3.14
|
|
6
6
|
Requires-Dist: pyyaml>=6.0.3
|
|
@@ -10,6 +10,8 @@ Description-Content-Type: text/markdown
|
|
|
10
10
|
|
|
11
11
|
# Riched
|
|
12
12
|
|
|
13
|
+
> TUI editor tailored for VSCode keybindigs in Ghostty terminal
|
|
14
|
+
|
|
13
15
|
`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
16
|
|
|
15
17
|
> [!NOTE]
|
|
@@ -19,54 +21,73 @@ Description-Content-Type: text/markdown
|
|
|
19
21
|
|
|
20
22
|
## Key Features
|
|
21
23
|
|
|
24
|
+
- VS Code-style text editing key bindings
|
|
22
25
|
- Syntax highlighting
|
|
23
26
|
- Resizable project file tree
|
|
24
|
-
-
|
|
27
|
+
- Quick open with git-aware indexing, bounded large-folder scanning
|
|
28
|
+
- fuzzy matching, symlink traversal
|
|
25
29
|
|
|
26
30
|
## Usage
|
|
27
31
|
|
|
32
|
+
```sh
|
|
33
|
+
uv tool install riched
|
|
34
|
+
riched .
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
## Dev
|
|
38
|
+
|
|
28
39
|
```sh
|
|
29
40
|
uv sync
|
|
30
|
-
uv run riched .
|
|
41
|
+
uv run riched .
|
|
31
42
|
uv run python -m tests.runner
|
|
43
|
+
uv build
|
|
44
|
+
uv publish
|
|
32
45
|
```
|
|
33
46
|
|
|
34
47
|
## Key Bindings
|
|
35
48
|
|
|
36
49
|
Key bindings are generated from the YAML in `src/riched/bindings.yaml`.
|
|
50
|
+
Symbols: `⌘` = Command, `⌃` = Control, `⌥` = Option, `⇧` = Shift.
|
|
37
51
|
|
|
38
52
|
App shortcuts:
|
|
39
53
|
|
|
40
54
|
| Shortcut | Action |
|
|
41
55
|
| --- | --- |
|
|
42
|
-
|
|
|
43
|
-
|
|
|
44
|
-
|
|
|
56
|
+
| `⌃S` | Save |
|
|
57
|
+
| `⌃Q` | Quit |
|
|
58
|
+
| `⌃W` | Close buffer |
|
|
45
59
|
| `F1` | Key bindings |
|
|
46
|
-
|
|
|
47
|
-
|
|
|
60
|
+
| `⌘B` | Toggle file tree |
|
|
61
|
+
| `⌘P` | Quick open |
|
|
48
62
|
|
|
49
63
|
Editor shortcuts:
|
|
50
64
|
|
|
51
65
|
| Shortcut | Action |
|
|
52
66
|
| --- | --- |
|
|
53
|
-
|
|
|
54
|
-
|
|
|
55
|
-
|
|
|
56
|
-
|
|
|
57
|
-
|
|
|
58
|
-
|
|
|
59
|
-
|
|
|
60
|
-
|
|
|
61
|
-
|
|
|
62
|
-
|
|
|
67
|
+
| `⌥↑` | Move line up |
|
|
68
|
+
| `⌥↓` | Move line down |
|
|
69
|
+
| `⌥⇧↑` | Copy line up |
|
|
70
|
+
| `⌥⇧↓` | Copy line down |
|
|
71
|
+
| `⌥⌫` | Delete word left |
|
|
72
|
+
| `⌥⇧←` | Select word left |
|
|
73
|
+
| `⌥⇧→` | Select word right |
|
|
74
|
+
| `⌘L` | Select line |
|
|
75
|
+
| `⌘⇧←` | Select to line start |
|
|
76
|
+
| `⌘⇧→` | Select to line end |
|
|
63
77
|
|
|
64
78
|
## Settings
|
|
65
79
|
|
|
66
80
|
`riched` persists the selected Textual theme in `settings.yaml` under the user
|
|
67
81
|
config directory: `~/Library/Application Support/riched/settings.yaml`
|
|
68
82
|
|
|
83
|
+
## Release Notes
|
|
84
|
+
|
|
85
|
+
### 0.2.0
|
|
86
|
+
|
|
87
|
+
- Quick open now opens immediately while indexing in the background.
|
|
88
|
+
- File search prefers git-indexed files when available, follows symlinked files and directories, skips cache/build dependency folders, and caps indexing at 10,000 files.
|
|
89
|
+
- Fuzzy ranking now prioritizes exact path and filename matches such as `.zshrc`.
|
|
90
|
+
|
|
69
91
|
## Won't Implemented
|
|
70
92
|
|
|
71
93
|
`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
|
-
|
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
# Riched
|
|
2
2
|
|
|
3
|
+
> TUI editor tailored for VSCode keybindigs in Ghostty terminal
|
|
4
|
+
|
|
3
5
|
`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
6
|
|
|
5
7
|
> [!NOTE]
|
|
@@ -9,54 +11,73 @@
|
|
|
9
11
|
|
|
10
12
|
## Key Features
|
|
11
13
|
|
|
14
|
+
- VS Code-style text editing key bindings
|
|
12
15
|
- Syntax highlighting
|
|
13
16
|
- Resizable project file tree
|
|
14
|
-
-
|
|
17
|
+
- Quick open with git-aware indexing, bounded large-folder scanning
|
|
18
|
+
- fuzzy matching, symlink traversal
|
|
15
19
|
|
|
16
20
|
## Usage
|
|
17
21
|
|
|
22
|
+
```sh
|
|
23
|
+
uv tool install riched
|
|
24
|
+
riched .
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
## Dev
|
|
28
|
+
|
|
18
29
|
```sh
|
|
19
30
|
uv sync
|
|
20
|
-
uv run riched .
|
|
31
|
+
uv run riched .
|
|
21
32
|
uv run python -m tests.runner
|
|
33
|
+
uv build
|
|
34
|
+
uv publish
|
|
22
35
|
```
|
|
23
36
|
|
|
24
37
|
## Key Bindings
|
|
25
38
|
|
|
26
39
|
Key bindings are generated from the YAML in `src/riched/bindings.yaml`.
|
|
40
|
+
Symbols: `⌘` = Command, `⌃` = Control, `⌥` = Option, `⇧` = Shift.
|
|
27
41
|
|
|
28
42
|
App shortcuts:
|
|
29
43
|
|
|
30
44
|
| Shortcut | Action |
|
|
31
45
|
| --- | --- |
|
|
32
|
-
|
|
|
33
|
-
|
|
|
34
|
-
|
|
|
46
|
+
| `⌃S` | Save |
|
|
47
|
+
| `⌃Q` | Quit |
|
|
48
|
+
| `⌃W` | Close buffer |
|
|
35
49
|
| `F1` | Key bindings |
|
|
36
|
-
|
|
|
37
|
-
|
|
|
50
|
+
| `⌘B` | Toggle file tree |
|
|
51
|
+
| `⌘P` | Quick open |
|
|
38
52
|
|
|
39
53
|
Editor shortcuts:
|
|
40
54
|
|
|
41
55
|
| Shortcut | Action |
|
|
42
56
|
| --- | --- |
|
|
43
|
-
|
|
|
44
|
-
|
|
|
45
|
-
|
|
|
46
|
-
|
|
|
47
|
-
|
|
|
48
|
-
|
|
|
49
|
-
|
|
|
50
|
-
|
|
|
51
|
-
|
|
|
52
|
-
|
|
|
57
|
+
| `⌥↑` | Move line up |
|
|
58
|
+
| `⌥↓` | Move line down |
|
|
59
|
+
| `⌥⇧↑` | Copy line up |
|
|
60
|
+
| `⌥⇧↓` | Copy line down |
|
|
61
|
+
| `⌥⌫` | Delete word left |
|
|
62
|
+
| `⌥⇧←` | Select word left |
|
|
63
|
+
| `⌥⇧→` | Select word right |
|
|
64
|
+
| `⌘L` | Select line |
|
|
65
|
+
| `⌘⇧←` | Select to line start |
|
|
66
|
+
| `⌘⇧→` | Select to line end |
|
|
53
67
|
|
|
54
68
|
## Settings
|
|
55
69
|
|
|
56
70
|
`riched` persists the selected Textual theme in `settings.yaml` under the user
|
|
57
71
|
config directory: `~/Library/Application Support/riched/settings.yaml`
|
|
58
72
|
|
|
73
|
+
## Release Notes
|
|
74
|
+
|
|
75
|
+
### 0.2.0
|
|
76
|
+
|
|
77
|
+
- Quick open now opens immediately while indexing in the background.
|
|
78
|
+
- File search prefers git-indexed files when available, follows symlinked files and directories, skips cache/build dependency folders, and caps indexing at 10,000 files.
|
|
79
|
+
- Fuzzy ranking now prioritizes exact path and filename matches such as `.zshrc`.
|
|
80
|
+
|
|
59
81
|
## Won't Implemented
|
|
60
82
|
|
|
61
83
|
`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
|
-
|
|
@@ -4,17 +4,25 @@ from collections.abc import Iterable
|
|
|
4
4
|
from pathlib import Path
|
|
5
5
|
from typing import Any
|
|
6
6
|
|
|
7
|
-
from textual import events
|
|
7
|
+
from textual import events, work
|
|
8
8
|
from textual.app import App, ComposeResult, SystemCommand
|
|
9
9
|
from textual.binding import Binding
|
|
10
10
|
from textual.command import CommandPalette
|
|
11
11
|
from textual.containers import Container, Horizontal
|
|
12
12
|
from textual.keys import format_key
|
|
13
13
|
from textual.screen import Screen
|
|
14
|
+
from textual.worker import get_current_worker
|
|
14
15
|
from textual.widgets import DirectoryTree, Footer, Header, Static, TextArea
|
|
15
16
|
|
|
16
17
|
from .editor import RichedTextArea
|
|
17
18
|
from .keybindings import display_key
|
|
19
|
+
from .quick_open import (
|
|
20
|
+
MAX_QUICK_OPEN_INDEX_FILES,
|
|
21
|
+
QUICK_OPEN_BATCH_SIZE,
|
|
22
|
+
QuickOpenEntry,
|
|
23
|
+
git_quick_open_entries,
|
|
24
|
+
scan_quick_open_entries,
|
|
25
|
+
)
|
|
18
26
|
from .screens import KeysHelpScreen, QuickOpenScreen, UnsavedChangesScreen
|
|
19
27
|
from .settings import load_theme, save_theme
|
|
20
28
|
from .syntax import apply_language
|
|
@@ -22,14 +30,6 @@ from .syntax import apply_language
|
|
|
22
30
|
FILE_TREE_DEFAULT_WIDTH = 30
|
|
23
31
|
FILE_TREE_MIN_WIDTH = 18
|
|
24
32
|
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
33
|
KEY_MODIFIER_SYMBOLS = {
|
|
34
34
|
"cmd": "⌘",
|
|
35
35
|
"super": "⌘",
|
|
@@ -174,6 +174,12 @@ class RichedApp(App):
|
|
|
174
174
|
self.path: Path | None = self._initial_path
|
|
175
175
|
self.root = root or Path.cwd()
|
|
176
176
|
self._saved_text = ""
|
|
177
|
+
self._quick_open_entries: list[QuickOpenEntry] = []
|
|
178
|
+
self._quick_open_complete = False
|
|
179
|
+
self._quick_open_indexing = False
|
|
180
|
+
self._quick_open_limited = False
|
|
181
|
+
self._quick_open_generation = 0
|
|
182
|
+
self._quick_open_screen: QuickOpenScreen | None = None
|
|
177
183
|
|
|
178
184
|
def _save_theme(self, theme: str) -> None:
|
|
179
185
|
try:
|
|
@@ -321,36 +327,138 @@ class RichedApp(App):
|
|
|
321
327
|
|
|
322
328
|
self.push_screen(UnsavedChangesScreen(), handle)
|
|
323
329
|
|
|
324
|
-
def
|
|
330
|
+
def action_quick_open(self) -> None:
|
|
325
331
|
root = self.root.expanduser()
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
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())
|
|
332
|
+
screen = QuickOpenScreen(
|
|
333
|
+
root,
|
|
334
|
+
self._quick_open_entries,
|
|
335
|
+
indexing=not self._quick_open_complete,
|
|
336
|
+
limited=self._quick_open_limited,
|
|
337
|
+
)
|
|
338
|
+
self._quick_open_screen = screen
|
|
344
339
|
|
|
345
|
-
def action_quick_open(self) -> None:
|
|
346
340
|
def handle(selected: Path | None) -> None:
|
|
341
|
+
if self._quick_open_screen is screen:
|
|
342
|
+
self._quick_open_screen = None
|
|
347
343
|
if selected is not None:
|
|
348
344
|
self._switch_path(selected)
|
|
349
345
|
|
|
350
|
-
self.push_screen(
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
346
|
+
self.push_screen(screen, handle)
|
|
347
|
+
if not self._quick_open_complete and not self._quick_open_indexing:
|
|
348
|
+
self._start_quick_open_index()
|
|
349
|
+
|
|
350
|
+
def _start_quick_open_index(self) -> None:
|
|
351
|
+
self._quick_open_indexing = True
|
|
352
|
+
self._build_quick_open_index(self._quick_open_generation)
|
|
353
|
+
|
|
354
|
+
@work(thread=True, exclusive=True, group="quick_open_index", exit_on_error=False)
|
|
355
|
+
def _build_quick_open_index(self, generation: int) -> None:
|
|
356
|
+
try:
|
|
357
|
+
root = self.root.expanduser()
|
|
358
|
+
git_result = git_quick_open_entries(root, MAX_QUICK_OPEN_INDEX_FILES)
|
|
359
|
+
if git_result is not None:
|
|
360
|
+
git_entries, limited = git_result
|
|
361
|
+
self.call_from_thread(
|
|
362
|
+
self._replace_quick_open_entries,
|
|
363
|
+
generation,
|
|
364
|
+
git_entries,
|
|
365
|
+
True,
|
|
366
|
+
limited,
|
|
367
|
+
)
|
|
368
|
+
return
|
|
369
|
+
|
|
370
|
+
worker = get_current_worker()
|
|
371
|
+
batch: list[QuickOpenEntry] = []
|
|
372
|
+
count = 0
|
|
373
|
+
limited = False
|
|
374
|
+
for entry in scan_quick_open_entries(root):
|
|
375
|
+
if worker.is_cancelled:
|
|
376
|
+
return
|
|
377
|
+
if count >= MAX_QUICK_OPEN_INDEX_FILES:
|
|
378
|
+
limited = True
|
|
379
|
+
break
|
|
380
|
+
batch.append(entry)
|
|
381
|
+
count += 1
|
|
382
|
+
if len(batch) >= QUICK_OPEN_BATCH_SIZE:
|
|
383
|
+
self.call_from_thread(
|
|
384
|
+
self._append_quick_open_entries,
|
|
385
|
+
generation,
|
|
386
|
+
batch,
|
|
387
|
+
False,
|
|
388
|
+
False,
|
|
389
|
+
)
|
|
390
|
+
batch = []
|
|
391
|
+
|
|
392
|
+
if batch:
|
|
393
|
+
self.call_from_thread(
|
|
394
|
+
self._append_quick_open_entries, generation, batch, False, False
|
|
395
|
+
)
|
|
396
|
+
self.call_from_thread(self._finish_quick_open_index, generation, limited)
|
|
397
|
+
except Exception as exc:
|
|
398
|
+
self.call_from_thread(
|
|
399
|
+
self._fail_quick_open_index, generation, str(exc) or type(exc).__name__
|
|
400
|
+
)
|
|
401
|
+
|
|
402
|
+
def _append_quick_open_entries(
|
|
403
|
+
self,
|
|
404
|
+
generation: int,
|
|
405
|
+
entries: list[QuickOpenEntry],
|
|
406
|
+
complete: bool,
|
|
407
|
+
limited: bool,
|
|
408
|
+
) -> None:
|
|
409
|
+
if generation != self._quick_open_generation:
|
|
410
|
+
return
|
|
411
|
+
self._quick_open_entries.extend(entries)
|
|
412
|
+
self._quick_open_limited = self._quick_open_limited or limited
|
|
413
|
+
screen = self._quick_open_screen
|
|
414
|
+
if screen is not None and screen.is_mounted:
|
|
415
|
+
screen.append_entries(entries, complete=complete, limited=limited)
|
|
416
|
+
|
|
417
|
+
def _replace_quick_open_entries(
|
|
418
|
+
self,
|
|
419
|
+
generation: int,
|
|
420
|
+
entries: list[QuickOpenEntry],
|
|
421
|
+
complete: bool,
|
|
422
|
+
limited: bool,
|
|
423
|
+
) -> None:
|
|
424
|
+
if generation != self._quick_open_generation:
|
|
425
|
+
return
|
|
426
|
+
self._quick_open_entries = list(entries)
|
|
427
|
+
self._quick_open_complete = complete
|
|
428
|
+
self._quick_open_limited = limited
|
|
429
|
+
self._quick_open_indexing = False
|
|
430
|
+
screen = self._quick_open_screen
|
|
431
|
+
if screen is not None and screen.is_mounted:
|
|
432
|
+
screen.replace_entries(entries, complete=complete, limited=limited)
|
|
433
|
+
|
|
434
|
+
def _finish_quick_open_index(self, generation: int, limited: bool) -> None:
|
|
435
|
+
if generation != self._quick_open_generation:
|
|
436
|
+
return
|
|
437
|
+
self._quick_open_complete = True
|
|
438
|
+
self._quick_open_limited = limited
|
|
439
|
+
self._quick_open_indexing = False
|
|
440
|
+
screen = self._quick_open_screen
|
|
441
|
+
if screen is not None and screen.is_mounted:
|
|
442
|
+
screen.finish_indexing(limited=limited)
|
|
443
|
+
|
|
444
|
+
def _fail_quick_open_index(self, generation: int, message: str) -> None:
|
|
445
|
+
if generation != self._quick_open_generation:
|
|
446
|
+
return
|
|
447
|
+
self._quick_open_complete = True
|
|
448
|
+
self._quick_open_indexing = False
|
|
449
|
+
screen = self._quick_open_screen
|
|
450
|
+
if screen is not None and screen.is_mounted:
|
|
451
|
+
screen.fail_indexing(message)
|
|
452
|
+
|
|
453
|
+
def _invalidate_quick_open_index(self) -> None:
|
|
454
|
+
self._quick_open_generation += 1
|
|
455
|
+
self._quick_open_entries = []
|
|
456
|
+
self._quick_open_complete = False
|
|
457
|
+
self._quick_open_indexing = False
|
|
458
|
+
self._quick_open_limited = False
|
|
459
|
+
screen = self._quick_open_screen
|
|
460
|
+
if screen is not None and screen.is_mounted:
|
|
461
|
+
screen.replace_entries([], complete=False, limited=False)
|
|
354
462
|
|
|
355
463
|
def action_toggle_command_palette(self) -> None:
|
|
356
464
|
if CommandPalette.is_open(self):
|
|
@@ -375,6 +483,7 @@ class RichedApp(App):
|
|
|
375
483
|
self.notify(f"Save failed: {exc}", severity="error")
|
|
376
484
|
return
|
|
377
485
|
self._saved_text = editor.text
|
|
486
|
+
self._invalidate_quick_open_index()
|
|
378
487
|
self.notify(f"Saved {self.path}")
|
|
379
488
|
|
|
380
489
|
def action_quit_check(self) -> None:
|
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
import subprocess
|
|
5
|
+
from collections import deque
|
|
6
|
+
from collections.abc import Iterator
|
|
7
|
+
from dataclasses import dataclass
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
|
|
10
|
+
MAX_QUICK_OPEN_INDEX_FILES = 10_000
|
|
11
|
+
QUICK_OPEN_BATCH_SIZE = 512
|
|
12
|
+
QUICK_OPEN_SKIP_DIRS = {
|
|
13
|
+
".cache",
|
|
14
|
+
".git",
|
|
15
|
+
".mypy_cache",
|
|
16
|
+
".pytest_cache",
|
|
17
|
+
".ruff_cache",
|
|
18
|
+
".venv",
|
|
19
|
+
"__pycache__",
|
|
20
|
+
"build",
|
|
21
|
+
"cache",
|
|
22
|
+
"caches",
|
|
23
|
+
"dist",
|
|
24
|
+
"node_modules",
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
@dataclass(frozen=True, slots=True)
|
|
29
|
+
class QuickOpenEntry:
|
|
30
|
+
"""A quick-open candidate with precomputed display text."""
|
|
31
|
+
|
|
32
|
+
path: Path
|
|
33
|
+
relative_path: str
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def git_quick_open_entries(
|
|
37
|
+
root: Path, limit: int = MAX_QUICK_OPEN_INDEX_FILES
|
|
38
|
+
) -> tuple[list[QuickOpenEntry], bool] | None:
|
|
39
|
+
"""Return git-aware file candidates, or None when root is not in a repo."""
|
|
40
|
+
|
|
41
|
+
process: subprocess.Popen[str] | None = None
|
|
42
|
+
try:
|
|
43
|
+
process = subprocess.Popen(
|
|
44
|
+
[
|
|
45
|
+
"git",
|
|
46
|
+
"-C",
|
|
47
|
+
os.fspath(root),
|
|
48
|
+
"ls-files",
|
|
49
|
+
"--cached",
|
|
50
|
+
"--others",
|
|
51
|
+
"--exclude-standard",
|
|
52
|
+
],
|
|
53
|
+
stdout=subprocess.PIPE,
|
|
54
|
+
stderr=subprocess.DEVNULL,
|
|
55
|
+
text=True,
|
|
56
|
+
)
|
|
57
|
+
except OSError:
|
|
58
|
+
return None
|
|
59
|
+
|
|
60
|
+
entries: list[QuickOpenEntry] = []
|
|
61
|
+
limited = False
|
|
62
|
+
try:
|
|
63
|
+
assert process.stdout is not None
|
|
64
|
+
for line in process.stdout:
|
|
65
|
+
relative_path = line.strip()
|
|
66
|
+
if not relative_path or _has_skipped_part(relative_path):
|
|
67
|
+
continue
|
|
68
|
+
path = root / relative_path
|
|
69
|
+
try:
|
|
70
|
+
if path.is_dir():
|
|
71
|
+
if _should_skip_dir(path.name):
|
|
72
|
+
continue
|
|
73
|
+
for entry in scan_quick_open_entries(path):
|
|
74
|
+
if len(entries) >= limit:
|
|
75
|
+
limited = True
|
|
76
|
+
break
|
|
77
|
+
entries.append(
|
|
78
|
+
QuickOpenEntry(
|
|
79
|
+
entry.path,
|
|
80
|
+
_join_relative(relative_path, entry.relative_path),
|
|
81
|
+
)
|
|
82
|
+
)
|
|
83
|
+
if limited:
|
|
84
|
+
break
|
|
85
|
+
elif path.is_file():
|
|
86
|
+
if len(entries) >= limit:
|
|
87
|
+
limited = True
|
|
88
|
+
break
|
|
89
|
+
entries.append(QuickOpenEntry(path, relative_path))
|
|
90
|
+
except OSError:
|
|
91
|
+
continue
|
|
92
|
+
|
|
93
|
+
if limited:
|
|
94
|
+
process.terminate()
|
|
95
|
+
return_code = process.wait(timeout=10)
|
|
96
|
+
except subprocess.TimeoutExpired:
|
|
97
|
+
process.kill()
|
|
98
|
+
return None
|
|
99
|
+
finally:
|
|
100
|
+
if process.stdout is not None:
|
|
101
|
+
process.stdout.close()
|
|
102
|
+
|
|
103
|
+
if return_code != 0 and not limited:
|
|
104
|
+
return None
|
|
105
|
+
return sorted(entries, key=lambda entry: entry.relative_path.lower()), limited
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def scan_quick_open_entries(root: Path) -> Iterator[QuickOpenEntry]:
|
|
109
|
+
"""Yield fallback candidates breadth-first, following symlinks safely."""
|
|
110
|
+
|
|
111
|
+
queue: deque[tuple[str, str]] = deque([(os.fspath(root), "")])
|
|
112
|
+
visited_directories: set[str] = set()
|
|
113
|
+
while queue:
|
|
114
|
+
directory, relative_directory = queue.popleft()
|
|
115
|
+
try:
|
|
116
|
+
real_directory = os.path.realpath(directory)
|
|
117
|
+
except OSError:
|
|
118
|
+
continue
|
|
119
|
+
if real_directory in visited_directories:
|
|
120
|
+
continue
|
|
121
|
+
visited_directories.add(real_directory)
|
|
122
|
+
|
|
123
|
+
try:
|
|
124
|
+
with os.scandir(directory) as entries:
|
|
125
|
+
directories: list[tuple[str, str]] = []
|
|
126
|
+
files: list[QuickOpenEntry] = []
|
|
127
|
+
for entry in entries:
|
|
128
|
+
relative_path = _join_relative(relative_directory, entry.name)
|
|
129
|
+
try:
|
|
130
|
+
if entry.is_dir(follow_symlinks=True):
|
|
131
|
+
if not _should_skip_dir(entry.name):
|
|
132
|
+
directories.append((entry.path, relative_path))
|
|
133
|
+
elif entry.is_file(follow_symlinks=True):
|
|
134
|
+
files.append(
|
|
135
|
+
QuickOpenEntry(Path(entry.path), relative_path)
|
|
136
|
+
)
|
|
137
|
+
except OSError:
|
|
138
|
+
continue
|
|
139
|
+
except OSError:
|
|
140
|
+
continue
|
|
141
|
+
|
|
142
|
+
directories.sort(key=lambda item: item[1].lower())
|
|
143
|
+
queue.extend(directories)
|
|
144
|
+
yield from sorted(files, key=lambda entry: entry.relative_path.lower())
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
def _join_relative(directory: str, name: str) -> str:
|
|
148
|
+
if not directory:
|
|
149
|
+
return name
|
|
150
|
+
return f"{directory}/{name}"
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
def _has_skipped_part(relative_path: str) -> bool:
|
|
154
|
+
return any(_should_skip_dir(part) for part in relative_path.split("/"))
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
def _should_skip_dir(name: str) -> bool:
|
|
158
|
+
return name in QUICK_OPEN_SKIP_DIRS or name.lower() in QUICK_OPEN_SKIP_DIRS
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
from __future__ import annotations
|
|
2
2
|
|
|
3
|
+
from heapq import nsmallest
|
|
3
4
|
from pathlib import Path
|
|
4
5
|
|
|
5
6
|
from textual import events
|
|
@@ -10,6 +11,7 @@ from textual.widgets import Button, Input, Label, OptionList, Static
|
|
|
10
11
|
from textual.widgets.option_list import Option
|
|
11
12
|
|
|
12
13
|
from .keybindings import binding_help_groups, build_screen_bindings
|
|
14
|
+
from .quick_open import QuickOpenEntry
|
|
13
15
|
|
|
14
16
|
MAX_QUICK_OPEN_RESULTS = 100
|
|
15
17
|
|
|
@@ -93,18 +95,27 @@ class QuickOpenScreen(ModalScreen[Path | None]):
|
|
|
93
95
|
}
|
|
94
96
|
"""
|
|
95
97
|
|
|
96
|
-
def __init__(
|
|
98
|
+
def __init__(
|
|
99
|
+
self,
|
|
100
|
+
root: Path,
|
|
101
|
+
entries: list[QuickOpenEntry],
|
|
102
|
+
*,
|
|
103
|
+
indexing: bool,
|
|
104
|
+
limited: bool,
|
|
105
|
+
) -> None:
|
|
97
106
|
super().__init__()
|
|
98
107
|
self.root = root
|
|
99
|
-
self.
|
|
100
|
-
self.
|
|
101
|
-
|
|
102
|
-
|
|
108
|
+
self.entries = list(entries)
|
|
109
|
+
self._indexing = indexing
|
|
110
|
+
self._limited = limited
|
|
111
|
+
self._error: str | None = None
|
|
103
112
|
self._option_paths: dict[str, Path] = {}
|
|
113
|
+
self._query = ""
|
|
104
114
|
|
|
105
115
|
def compose(self) -> ComposeResult:
|
|
106
116
|
with Vertical(id="dialog"):
|
|
107
117
|
yield Input(placeholder="Quick open", id="query")
|
|
118
|
+
yield Static("", id="status")
|
|
108
119
|
yield OptionList(id="results")
|
|
109
120
|
|
|
110
121
|
def on_mount(self) -> None:
|
|
@@ -144,7 +155,45 @@ class QuickOpenScreen(ModalScreen[Path | None]):
|
|
|
144
155
|
def action_close(self) -> None:
|
|
145
156
|
self.dismiss(None)
|
|
146
157
|
|
|
158
|
+
def append_entries(
|
|
159
|
+
self,
|
|
160
|
+
entries: list[QuickOpenEntry],
|
|
161
|
+
*,
|
|
162
|
+
complete: bool = False,
|
|
163
|
+
limited: bool = False,
|
|
164
|
+
) -> None:
|
|
165
|
+
self.entries.extend(entries)
|
|
166
|
+
if complete:
|
|
167
|
+
self._indexing = False
|
|
168
|
+
if limited:
|
|
169
|
+
self._limited = True
|
|
170
|
+
self._refresh_results(self._query)
|
|
171
|
+
|
|
172
|
+
def replace_entries(
|
|
173
|
+
self,
|
|
174
|
+
entries: list[QuickOpenEntry],
|
|
175
|
+
*,
|
|
176
|
+
complete: bool,
|
|
177
|
+
limited: bool,
|
|
178
|
+
) -> None:
|
|
179
|
+
self.entries = list(entries)
|
|
180
|
+
self._indexing = not complete
|
|
181
|
+
self._limited = limited
|
|
182
|
+
self._refresh_results(self._query)
|
|
183
|
+
|
|
184
|
+
def finish_indexing(self, *, limited: bool) -> None:
|
|
185
|
+
self._indexing = False
|
|
186
|
+
self._limited = limited
|
|
187
|
+
self._refresh_results(self._query)
|
|
188
|
+
|
|
189
|
+
def fail_indexing(self, message: str) -> None:
|
|
190
|
+
self._indexing = False
|
|
191
|
+
self._error = message
|
|
192
|
+
self._refresh_results(self._query)
|
|
193
|
+
|
|
147
194
|
def _refresh_results(self, query: str) -> None:
|
|
195
|
+
self._query = query
|
|
196
|
+
self._refresh_status()
|
|
148
197
|
results = self.query_one("#results", OptionList)
|
|
149
198
|
results.clear_options()
|
|
150
199
|
self._option_paths.clear()
|
|
@@ -155,26 +204,42 @@ class QuickOpenScreen(ModalScreen[Path | None]):
|
|
|
155
204
|
return
|
|
156
205
|
|
|
157
206
|
options: list[Option] = []
|
|
158
|
-
for index,
|
|
207
|
+
for index, entry in enumerate(matches[:MAX_QUICK_OPEN_RESULTS]):
|
|
159
208
|
option_id = str(index)
|
|
160
|
-
self._option_paths[option_id] = path
|
|
161
|
-
options.append(Option(
|
|
209
|
+
self._option_paths[option_id] = entry.path
|
|
210
|
+
options.append(Option(entry.relative_path, id=option_id))
|
|
162
211
|
results.add_options(options)
|
|
163
212
|
results.highlighted = 0
|
|
164
213
|
|
|
165
|
-
def
|
|
214
|
+
def _refresh_status(self) -> None:
|
|
215
|
+
status = self.query_one("#status", Static)
|
|
216
|
+
if self._error is not None:
|
|
217
|
+
status.update(f"Indexing failed: {self._error}")
|
|
218
|
+
elif self._limited:
|
|
219
|
+
status.update(
|
|
220
|
+
f"Showing first {len(self.entries)} files; index limit reached."
|
|
221
|
+
)
|
|
222
|
+
elif self._indexing:
|
|
223
|
+
status.update(f"Indexing {len(self.entries)} files...")
|
|
224
|
+
else:
|
|
225
|
+
status.update("")
|
|
226
|
+
|
|
227
|
+
def _ranked_matches(self, query: str) -> list[QuickOpenEntry]:
|
|
166
228
|
stripped_query = query.strip()
|
|
167
229
|
if not stripped_query:
|
|
168
|
-
return
|
|
230
|
+
return self.entries[:MAX_QUICK_OPEN_RESULTS]
|
|
169
231
|
|
|
170
|
-
scored: list[tuple[tuple[int, int, int, int, str],
|
|
171
|
-
for
|
|
172
|
-
|
|
173
|
-
score = _fuzzy_score(stripped_query, relative_path)
|
|
232
|
+
scored: list[tuple[tuple[int, int, int, int, str], QuickOpenEntry]] = []
|
|
233
|
+
for entry in self.entries:
|
|
234
|
+
score = _fuzzy_score(stripped_query, entry.relative_path)
|
|
174
235
|
if score is not None:
|
|
175
|
-
scored.append((score,
|
|
176
|
-
|
|
177
|
-
|
|
236
|
+
scored.append((score, entry))
|
|
237
|
+
return [
|
|
238
|
+
entry
|
|
239
|
+
for _score, entry in nsmallest(
|
|
240
|
+
MAX_QUICK_OPEN_RESULTS, scored, key=lambda item: item[0]
|
|
241
|
+
)
|
|
242
|
+
]
|
|
178
243
|
|
|
179
244
|
def _open_highlighted(self) -> None:
|
|
180
245
|
results = self.query_one("#results", OptionList)
|
|
@@ -264,6 +329,47 @@ class KeysHelpScreen(ModalScreen[None]):
|
|
|
264
329
|
def _fuzzy_score(query: str, candidate: str) -> tuple[int, int, int, int, str] | None:
|
|
265
330
|
query_lower = query.lower()
|
|
266
331
|
candidate_lower = candidate.lower()
|
|
332
|
+
basename = Path(candidate_lower).name
|
|
333
|
+
depth = candidate_lower.count("/")
|
|
334
|
+
|
|
335
|
+
if candidate_lower == query_lower:
|
|
336
|
+
return (0, depth, 0, len(candidate_lower), candidate_lower)
|
|
337
|
+
if basename == query_lower:
|
|
338
|
+
return (1, depth, 0, len(candidate_lower), candidate_lower)
|
|
339
|
+
if basename.startswith(query_lower):
|
|
340
|
+
return (2, depth, 0, len(candidate_lower), candidate_lower)
|
|
341
|
+
if candidate_lower.startswith(query_lower):
|
|
342
|
+
return (3, depth, 0, len(candidate_lower), candidate_lower)
|
|
343
|
+
if query_lower in basename:
|
|
344
|
+
return (
|
|
345
|
+
4,
|
|
346
|
+
depth,
|
|
347
|
+
basename.index(query_lower),
|
|
348
|
+
len(candidate_lower),
|
|
349
|
+
candidate_lower,
|
|
350
|
+
)
|
|
351
|
+
if query_lower in candidate_lower:
|
|
352
|
+
return (
|
|
353
|
+
5,
|
|
354
|
+
depth,
|
|
355
|
+
candidate_lower.index(query_lower),
|
|
356
|
+
len(candidate_lower),
|
|
357
|
+
candidate_lower,
|
|
358
|
+
)
|
|
359
|
+
|
|
360
|
+
basename_score = _fuzzy_positions(query_lower, basename)
|
|
361
|
+
if basename_score is not None:
|
|
362
|
+
first, gaps = basename_score
|
|
363
|
+
return (6, depth, gaps, first, candidate_lower)
|
|
364
|
+
|
|
365
|
+
path_score = _fuzzy_positions(query_lower, candidate_lower)
|
|
366
|
+
if path_score is None:
|
|
367
|
+
return None
|
|
368
|
+
first, gaps = path_score
|
|
369
|
+
return (7, depth, gaps, first, candidate_lower)
|
|
370
|
+
|
|
371
|
+
|
|
372
|
+
def _fuzzy_positions(query_lower: str, candidate_lower: str) -> tuple[int, int] | None:
|
|
267
373
|
positions: list[int] = []
|
|
268
374
|
search_from = 0
|
|
269
375
|
for char in query_lower:
|
|
@@ -277,8 +383,4 @@ def _fuzzy_score(query: str, candidate: str) -> tuple[int, int, int, int, str] |
|
|
|
277
383
|
last = positions[-1]
|
|
278
384
|
spread = last - first + 1
|
|
279
385
|
gaps = spread - len(query_lower)
|
|
280
|
-
|
|
281
|
-
basename_index = candidate_lower.rfind(basename)
|
|
282
|
-
basename_bonus = 0 if first >= basename_index else 1
|
|
283
|
-
contiguous_bonus = 0 if query_lower in candidate_lower else 1
|
|
284
|
-
return (basename_bonus, contiguous_bonus, gaps, len(candidate_lower), candidate_lower)
|
|
386
|
+
return first, gaps
|
|
@@ -47,6 +47,16 @@ from .test_file_tree import (
|
|
|
47
47
|
test_file_tree_switch_opens_selected_file,
|
|
48
48
|
)
|
|
49
49
|
from .test_footer import test_footer_uses_macos_modifier_symbols
|
|
50
|
+
from .test_quick_open import (
|
|
51
|
+
test_quick_open_exact_hidden_filename_match_wins,
|
|
52
|
+
test_quick_open_fallback_follows_symlinks,
|
|
53
|
+
test_quick_open_fallback_limit_is_visible,
|
|
54
|
+
test_quick_open_fallback_indexes_by_directory_level,
|
|
55
|
+
test_quick_open_fallback_skips_heavy_directories,
|
|
56
|
+
test_quick_open_git_index_limit_is_visible,
|
|
57
|
+
test_quick_open_git_index_respects_excludes,
|
|
58
|
+
test_quick_open_screen_opens_before_indexing_completes,
|
|
59
|
+
)
|
|
50
60
|
from .test_syntax import (
|
|
51
61
|
test_python_highlight,
|
|
52
62
|
test_tsx_highlight,
|
|
@@ -83,6 +93,32 @@ TESTS: list[tuple[str, Callable[[], Awaitable[None]]]] = [
|
|
|
83
93
|
"file tree: dirty switch prompts then discard",
|
|
84
94
|
test_file_tree_dirty_switch_prompts_then_discard_opens_file,
|
|
85
95
|
),
|
|
96
|
+
(
|
|
97
|
+
"quick open: screen opens before indexing completes",
|
|
98
|
+
test_quick_open_screen_opens_before_indexing_completes,
|
|
99
|
+
),
|
|
100
|
+
(
|
|
101
|
+
"quick open: fallback skips heavy directories",
|
|
102
|
+
test_quick_open_fallback_skips_heavy_directories,
|
|
103
|
+
),
|
|
104
|
+
(
|
|
105
|
+
"quick open: fallback indexes by directory level",
|
|
106
|
+
test_quick_open_fallback_indexes_by_directory_level,
|
|
107
|
+
),
|
|
108
|
+
(
|
|
109
|
+
"quick open: fallback follows symlinks",
|
|
110
|
+
test_quick_open_fallback_follows_symlinks,
|
|
111
|
+
),
|
|
112
|
+
(
|
|
113
|
+
"quick open: git index respects excludes",
|
|
114
|
+
test_quick_open_git_index_respects_excludes,
|
|
115
|
+
),
|
|
116
|
+
("quick open: git index limit is visible", test_quick_open_git_index_limit_is_visible),
|
|
117
|
+
("quick open: fallback limit is visible", test_quick_open_fallback_limit_is_visible),
|
|
118
|
+
(
|
|
119
|
+
"quick open: exact hidden filename match wins",
|
|
120
|
+
test_quick_open_exact_hidden_filename_match_wins,
|
|
121
|
+
),
|
|
86
122
|
("footer: macOS modifier symbols", test_footer_uses_macos_modifier_symbols),
|
|
87
123
|
("syntax: python", test_python_highlight),
|
|
88
124
|
("syntax: typescript", test_typescript_highlight),
|
|
@@ -0,0 +1,208 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import subprocess
|
|
4
|
+
from time import monotonic
|
|
5
|
+
|
|
6
|
+
from textual.widgets import Static
|
|
7
|
+
|
|
8
|
+
import riched.app as app_mod
|
|
9
|
+
from riched.screens import QuickOpenScreen
|
|
10
|
+
|
|
11
|
+
from .helpers import _fresh_env, _make_app
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
async def _wait_for_quick_open_index(app: app_mod.RichedApp, pilot) -> None:
|
|
15
|
+
deadline = monotonic() + 5
|
|
16
|
+
while not app._quick_open_complete:
|
|
17
|
+
assert monotonic() < deadline
|
|
18
|
+
await pilot.pause()
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
async def test_quick_open_screen_opens_before_indexing_completes() -> None:
|
|
22
|
+
tmp, _ = _fresh_env()
|
|
23
|
+
(tmp / "file.txt").write_text("content")
|
|
24
|
+
app = _make_app(tmp, root=tmp)
|
|
25
|
+
started = False
|
|
26
|
+
|
|
27
|
+
def fake_start() -> None:
|
|
28
|
+
nonlocal started
|
|
29
|
+
started = True
|
|
30
|
+
app._quick_open_indexing = True
|
|
31
|
+
|
|
32
|
+
app._start_quick_open_index = fake_start
|
|
33
|
+
async with app.run_test() as pilot:
|
|
34
|
+
await pilot.pause()
|
|
35
|
+
app.action_quick_open()
|
|
36
|
+
await pilot.pause()
|
|
37
|
+
assert started
|
|
38
|
+
assert isinstance(app.screen, QuickOpenScreen)
|
|
39
|
+
status = app.screen.query_one("#status", Static)
|
|
40
|
+
assert str(status.content) == "Indexing 0 files..."
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
async def test_quick_open_fallback_skips_heavy_directories() -> None:
|
|
44
|
+
tmp, _ = _fresh_env()
|
|
45
|
+
(tmp / "keep.py").write_text("keep")
|
|
46
|
+
for directory in [
|
|
47
|
+
".cache",
|
|
48
|
+
".venv",
|
|
49
|
+
"__pycache__",
|
|
50
|
+
"cache",
|
|
51
|
+
"caches",
|
|
52
|
+
"node_modules",
|
|
53
|
+
]:
|
|
54
|
+
skipped = tmp / directory
|
|
55
|
+
skipped.mkdir()
|
|
56
|
+
(skipped / "hidden.py").write_text("hidden")
|
|
57
|
+
case_check = tmp / "case-check"
|
|
58
|
+
case_check.mkdir()
|
|
59
|
+
mixed_case_cache = case_check / "Caches"
|
|
60
|
+
mixed_case_cache.mkdir()
|
|
61
|
+
(mixed_case_cache / "hidden.py").write_text("hidden")
|
|
62
|
+
|
|
63
|
+
app = _make_app(tmp, root=tmp)
|
|
64
|
+
async with app.run_test() as pilot:
|
|
65
|
+
await pilot.pause()
|
|
66
|
+
app.action_quick_open()
|
|
67
|
+
await _wait_for_quick_open_index(app, pilot)
|
|
68
|
+
|
|
69
|
+
relative_paths = {entry.relative_path for entry in app._quick_open_entries}
|
|
70
|
+
assert "keep.py" in relative_paths
|
|
71
|
+
assert all("hidden.py" not in relative_path for relative_path in relative_paths)
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
async def test_quick_open_fallback_indexes_by_directory_level() -> None:
|
|
75
|
+
tmp, _ = _fresh_env()
|
|
76
|
+
(tmp / "root.txt").write_text("root")
|
|
77
|
+
first = tmp / "first"
|
|
78
|
+
first.mkdir()
|
|
79
|
+
(first / "first.txt").write_text("first")
|
|
80
|
+
second = tmp / "second"
|
|
81
|
+
second.mkdir()
|
|
82
|
+
(second / "second.txt").write_text("second")
|
|
83
|
+
deep = first / "deep"
|
|
84
|
+
deep.mkdir()
|
|
85
|
+
(deep / "deep.txt").write_text("deep")
|
|
86
|
+
|
|
87
|
+
app = _make_app(tmp, root=tmp)
|
|
88
|
+
async with app.run_test() as pilot:
|
|
89
|
+
await pilot.pause()
|
|
90
|
+
app.action_quick_open()
|
|
91
|
+
await _wait_for_quick_open_index(app, pilot)
|
|
92
|
+
|
|
93
|
+
relative_paths = [entry.relative_path for entry in app._quick_open_entries]
|
|
94
|
+
assert relative_paths.index("root.txt") < relative_paths.index("first/first.txt")
|
|
95
|
+
assert relative_paths.index("root.txt") < relative_paths.index("second/second.txt")
|
|
96
|
+
assert relative_paths.index("second/second.txt") < relative_paths.index(
|
|
97
|
+
"first/deep/deep.txt"
|
|
98
|
+
)
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
async def test_quick_open_fallback_follows_symlinks() -> None:
|
|
102
|
+
tmp, _ = _fresh_env()
|
|
103
|
+
outside = tmp / "outside"
|
|
104
|
+
outside.mkdir()
|
|
105
|
+
(outside / "target.txt").write_text("target")
|
|
106
|
+
(outside / "linked-file.txt").write_text("linked file")
|
|
107
|
+
(tmp / "linked-dir").symlink_to(outside, target_is_directory=True)
|
|
108
|
+
(tmp / "file-link.txt").symlink_to(outside / "linked-file.txt")
|
|
109
|
+
(outside / "loop").symlink_to(tmp, target_is_directory=True)
|
|
110
|
+
|
|
111
|
+
app = _make_app(tmp, root=tmp)
|
|
112
|
+
async with app.run_test() as pilot:
|
|
113
|
+
await pilot.pause()
|
|
114
|
+
app.action_quick_open()
|
|
115
|
+
await _wait_for_quick_open_index(app, pilot)
|
|
116
|
+
|
|
117
|
+
relative_paths = [entry.relative_path for entry in app._quick_open_entries]
|
|
118
|
+
assert "file-link.txt" in relative_paths
|
|
119
|
+
assert "linked-dir/target.txt" in relative_paths
|
|
120
|
+
assert len(relative_paths) == len(set(relative_paths))
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
async def test_quick_open_git_index_respects_excludes() -> None:
|
|
124
|
+
tmp, _ = _fresh_env()
|
|
125
|
+
subprocess.run(["git", "init"], cwd=tmp, check=True, capture_output=True)
|
|
126
|
+
(tmp / ".gitignore").write_text("*.log\n")
|
|
127
|
+
(tmp / "tracked.py").write_text("tracked")
|
|
128
|
+
(tmp / "untracked.py").write_text("untracked")
|
|
129
|
+
(tmp / "ignored.log").write_text("ignored")
|
|
130
|
+
subprocess.run(
|
|
131
|
+
["git", "add", ".gitignore", "tracked.py"],
|
|
132
|
+
cwd=tmp,
|
|
133
|
+
check=True,
|
|
134
|
+
capture_output=True,
|
|
135
|
+
)
|
|
136
|
+
|
|
137
|
+
app = _make_app(tmp, root=tmp)
|
|
138
|
+
async with app.run_test() as pilot:
|
|
139
|
+
await pilot.pause()
|
|
140
|
+
app.action_quick_open()
|
|
141
|
+
await _wait_for_quick_open_index(app, pilot)
|
|
142
|
+
|
|
143
|
+
relative_paths = {entry.relative_path for entry in app._quick_open_entries}
|
|
144
|
+
assert "tracked.py" in relative_paths
|
|
145
|
+
assert "untracked.py" in relative_paths
|
|
146
|
+
assert "ignored.log" not in relative_paths
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
async def test_quick_open_git_index_limit_is_visible() -> None:
|
|
150
|
+
tmp, _ = _fresh_env()
|
|
151
|
+
subprocess.run(["git", "init"], cwd=tmp, check=True, capture_output=True)
|
|
152
|
+
for index in range(5):
|
|
153
|
+
(tmp / f"file-{index}.txt").write_text(str(index))
|
|
154
|
+
|
|
155
|
+
old_limit = app_mod.MAX_QUICK_OPEN_INDEX_FILES
|
|
156
|
+
app_mod.MAX_QUICK_OPEN_INDEX_FILES = 3
|
|
157
|
+
try:
|
|
158
|
+
app = _make_app(tmp, root=tmp)
|
|
159
|
+
async with app.run_test() as pilot:
|
|
160
|
+
await pilot.pause()
|
|
161
|
+
app.action_quick_open()
|
|
162
|
+
await _wait_for_quick_open_index(app, pilot)
|
|
163
|
+
assert isinstance(app.screen, QuickOpenScreen)
|
|
164
|
+
status = app.screen.query_one("#status", Static)
|
|
165
|
+
assert len(app._quick_open_entries) == 3
|
|
166
|
+
assert str(status.content) == "Showing first 3 files; index limit reached."
|
|
167
|
+
finally:
|
|
168
|
+
app_mod.MAX_QUICK_OPEN_INDEX_FILES = old_limit
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
async def test_quick_open_fallback_limit_is_visible() -> None:
|
|
172
|
+
tmp, _ = _fresh_env()
|
|
173
|
+
for index in range(5):
|
|
174
|
+
(tmp / f"file-{index}.txt").write_text(str(index))
|
|
175
|
+
|
|
176
|
+
old_limit = app_mod.MAX_QUICK_OPEN_INDEX_FILES
|
|
177
|
+
app_mod.MAX_QUICK_OPEN_INDEX_FILES = 3
|
|
178
|
+
try:
|
|
179
|
+
app = _make_app(tmp, root=tmp)
|
|
180
|
+
async with app.run_test() as pilot:
|
|
181
|
+
await pilot.pause()
|
|
182
|
+
app.action_quick_open()
|
|
183
|
+
await _wait_for_quick_open_index(app, pilot)
|
|
184
|
+
assert isinstance(app.screen, QuickOpenScreen)
|
|
185
|
+
status = app.screen.query_one("#status", Static)
|
|
186
|
+
assert str(status.content) == "Showing first 3 files; index limit reached."
|
|
187
|
+
finally:
|
|
188
|
+
app_mod.MAX_QUICK_OPEN_INDEX_FILES = old_limit
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
async def test_quick_open_exact_hidden_filename_match_wins() -> None:
|
|
192
|
+
tmp, _ = _fresh_env()
|
|
193
|
+
(tmp / ".zshrc").write_text("target")
|
|
194
|
+
nested = tmp / "nested"
|
|
195
|
+
nested.mkdir()
|
|
196
|
+
(nested / ".zshrc").write_text("nested")
|
|
197
|
+
(tmp / ".zshrc.backup").write_text("backup")
|
|
198
|
+
(tmp / "notes-zshrc.txt").write_text("notes")
|
|
199
|
+
|
|
200
|
+
app = _make_app(tmp, root=tmp)
|
|
201
|
+
async with app.run_test() as pilot:
|
|
202
|
+
await pilot.pause()
|
|
203
|
+
app.action_quick_open()
|
|
204
|
+
await _wait_for_quick_open_index(app, pilot)
|
|
205
|
+
assert isinstance(app.screen, QuickOpenScreen)
|
|
206
|
+
matches = app.screen._ranked_matches(".zshrc")
|
|
207
|
+
|
|
208
|
+
assert matches[0].relative_path == ".zshrc"
|
|
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
|