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.
Files changed (28) hide show
  1. {riched-0.1.0 → riched-0.2.0}/AGENTS.md +4 -2
  2. {riched-0.1.0 → riched-0.2.0}/PKG-INFO +40 -19
  3. {riched-0.1.0 → riched-0.2.0}/README.md +39 -18
  4. {riched-0.1.0 → riched-0.2.0}/pyproject.toml +1 -1
  5. {riched-0.1.0 → riched-0.2.0}/src/riched/app.py +142 -33
  6. riched-0.2.0/src/riched/quick_open.py +158 -0
  7. {riched-0.1.0 → riched-0.2.0}/src/riched/screens.py +124 -22
  8. {riched-0.1.0 → riched-0.2.0}/tests/runner.py +36 -0
  9. {riched-0.1.0 → riched-0.2.0}/tests/test_file_tree.py +0 -1
  10. riched-0.2.0/tests/test_quick_open.py +208 -0
  11. {riched-0.1.0 → riched-0.2.0}/uv.lock +1 -1
  12. {riched-0.1.0 → riched-0.2.0}/.gitignore +0 -0
  13. {riched-0.1.0 → riched-0.2.0}/.python-version +0 -0
  14. {riched-0.1.0 → riched-0.2.0}/src/riched/__init__.py +0 -0
  15. {riched-0.1.0 → riched-0.2.0}/src/riched/bindings.yaml +0 -0
  16. {riched-0.1.0 → riched-0.2.0}/src/riched/cli.py +0 -0
  17. {riched-0.1.0 → riched-0.2.0}/src/riched/editor.py +0 -0
  18. {riched-0.1.0 → riched-0.2.0}/src/riched/keybindings.py +0 -0
  19. {riched-0.1.0 → riched-0.2.0}/src/riched/settings.py +0 -0
  20. {riched-0.1.0 → riched-0.2.0}/src/riched/syntax.py +0 -0
  21. {riched-0.1.0 → riched-0.2.0}/tests/__init__.py +0 -0
  22. {riched-0.1.0 → riched-0.2.0}/tests/helpers.py +0 -0
  23. {riched-0.1.0 → riched-0.2.0}/tests/test_command_palette.py +0 -0
  24. {riched-0.1.0 → riched-0.2.0}/tests/test_dirty_buffers.py +0 -0
  25. {riched-0.1.0 → riched-0.2.0}/tests/test_editor_shortcuts.py +0 -0
  26. {riched-0.1.0 → riched-0.2.0}/tests/test_file_io.py +0 -0
  27. {riched-0.1.0 → riched-0.2.0}/tests/test_footer.py +0 -0
  28. {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/`. 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.
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.1.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
- - VS Code-style text editing key bindings
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
- | `Ctrl+S` | Save |
43
- | `Ctrl+Q` | Quit |
44
- | `Ctrl+W` | Close buffer |
56
+ | `⌃S` | Save |
57
+ | `⌃Q` | Quit |
58
+ | `⌃W` | Close buffer |
45
59
  | `F1` | Key bindings |
46
- | `Cmd+B` / `Super+B` | Toggle file tree |
47
- | `Cmd+P` / `Super+P` | Quick open |
60
+ | `⌘B` | Toggle file tree |
61
+ | `⌘P` | Quick open |
48
62
 
49
63
  Editor shortcuts:
50
64
 
51
65
  | Shortcut | Action |
52
66
  | --- | --- |
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 |
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
- - VS Code-style text editing key bindings
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
- | `Ctrl+S` | Save |
33
- | `Ctrl+Q` | Quit |
34
- | `Ctrl+W` | Close buffer |
46
+ | `⌃S` | Save |
47
+ | `⌃Q` | Quit |
48
+ | `⌃W` | Close buffer |
35
49
  | `F1` | Key bindings |
36
- | `Cmd+B` / `Super+B` | Toggle file tree |
37
- | `Cmd+P` / `Super+P` | Quick open |
50
+ | `⌘B` | Toggle file tree |
51
+ | `⌘P` | Quick open |
38
52
 
39
53
  Editor shortcuts:
40
54
 
41
55
  | Shortcut | Action |
42
56
  | --- | --- |
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 |
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
-
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "riched"
3
- version = "0.1.0"
3
+ version = "0.2.0"
4
4
  description = "A Textual TUI editor"
5
5
  readme = "README.md"
6
6
  requires-python = ">=3.14"
@@ -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 _quick_open_files(self) -> list[Path]:
330
+ def action_quick_open(self) -> None:
325
331
  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())
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
- QuickOpenScreen(self.root.expanduser(), self._quick_open_files()),
352
- handle,
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__(self, root: Path, files: list[Path]) -> None:
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.files = files
100
- self._relative_paths = {
101
- path: path.relative_to(root).as_posix() for path in files
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, path in enumerate(matches[:MAX_QUICK_OPEN_RESULTS]):
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(self._relative_paths[path], id=option_id))
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 _ranked_matches(self, query: str) -> list[Path]:
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 sorted(self.files, key=lambda path: self._relative_paths[path].lower())
230
+ return self.entries[:MAX_QUICK_OPEN_RESULTS]
169
231
 
170
- scored: list[tuple[tuple[int, int, int, int, str], Path]] = []
171
- for path in self.files:
172
- relative_path = self._relative_paths[path]
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, path))
176
- scored.sort(key=lambda item: item[0])
177
- return [path for _score, path in scored]
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
- basename = Path(candidate_lower).name
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),
@@ -56,4 +56,3 @@ async def test_file_tree_dirty_switch_prompts_then_discard_opens_file() -> None:
56
56
  assert editor.text == "second", repr(editor.text)
57
57
  assert app.path == second, app.path
58
58
  assert first.read_text() == "first"
59
-
@@ -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"
@@ -111,7 +111,7 @@ wheels = [
111
111
 
112
112
  [[package]]
113
113
  name = "riched"
114
- version = "0.1.0"
114
+ version = "0.2.0"
115
115
  source = { editable = "." }
116
116
  dependencies = [
117
117
  { name = "pyyaml" },
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