diff --git a/tests/test_nav.py b/tests/test_nav.py new file mode 100644 index 0000000..c7a8cf6 --- /dev/null +++ b/tests/test_nav.py @@ -0,0 +1,46 @@ +from __future__ import annotations + +from pathlib import Path + +from trushell.commands.nav import run_jump + + +def test_run_jump_no_matches(tmp_path, monkeypatch): + monkeypatch.chdir(tmp_path) + result = run_jump("missing") + assert "No directories found matching" in result + assert "missing" in result + + +def test_run_jump_single_match(tmp_path, monkeypatch): + target = tmp_path / "src" + target.mkdir() + (tmp_path / "docs").mkdir() + monkeypatch.chdir(tmp_path) + + result = run_jump("src") + assert result == f"__TRUSHELL_CD__: {target}" + + +def test_run_jump_multiple_matches(tmp_path, monkeypatch): + first = tmp_path / "src" + first.mkdir() + second = tmp_path / "my_src" + second.mkdir() + (tmp_path / "docs").mkdir() + monkeypatch.chdir(tmp_path) + + result = run_jump("src") + assert "Multiple matches found. Please be more specific." in result + assert str(first) in result + assert str(second) in result + + +def test_run_jump_ignores_hidden_directories(tmp_path, monkeypatch): + (tmp_path / ".hidden_dir").mkdir() + visible = tmp_path / "src" + visible.mkdir() + monkeypatch.chdir(tmp_path) + + result = run_jump("src") + assert result == f"__TRUSHELL_CD__: {visible}" diff --git a/trushell/commands/nav.py b/trushell/commands/nav.py new file mode 100644 index 0000000..b16324b --- /dev/null +++ b/trushell/commands/nav.py @@ -0,0 +1,77 @@ +from __future__ import annotations + +import os +from io import StringIO +from pathlib import Path + +from rich.console import Console +from rich.table import Table + + +def _render_markup_message(message: str) -> str: + console = Console(record=True, file=StringIO()) + console.print(message) + return console.export_text(styles=True) + + +def _is_hidden_path(path: Path, base: Path) -> bool: + try: + relative = path.relative_to(base) + except ValueError: + return False + return any(part.startswith(".") for part in relative.parts) + + +def _relevance_key(path: Path, query: str, base: Path) -> tuple[int, int, int, int, str]: + lowered_name = path.name.lower() + exact_name = 0 if lowered_name == query else 1 + ends_with_query = 0 if lowered_name.endswith(query) else 1 + normalized = str(path.relative_to(base).as_posix()).lower() + index = normalized.find(query) + index_score = index if index >= 0 else 9999 + depth = len(path.relative_to(base).parts) + return (exact_name, ends_with_query, index_score, depth, normalized) + + +def run_jump(args: str) -> str: + query = args.strip() + if not query: + return _render_markup_message(f"[red]No directories found matching '{query}'.[/red]") + + cwd = Path.cwd() + query_lower = query.lower() + matches: list[Path] = [] + + for path in cwd.rglob("*"): + if not path.is_dir(): + continue + + if _is_hidden_path(path, cwd): + continue + + relative = path.relative_to(cwd) + if len(relative.parts) > 3: + continue + + if query_lower in str(relative.as_posix()).lower(): + matches.append(path.resolve()) + + if not matches: + return _render_markup_message(f"[red]No directories found matching '{query}'.[/red]") + + matches.sort(key=lambda path: _relevance_key(path, query_lower, cwd)) + + if len(matches) == 1: + return f"__TRUSHELL_CD__: {matches[0]}" + + top_matches = matches[:5] + console = Console(record=True, file=StringIO()) + table = Table(show_header=True, header_style="bold cyan", row_styles=["white", "grey93"], expand=False) + table.add_column("Path", style="cyan") + + for path in top_matches: + table.add_row(str(path)) + + console.print(table) + console.print("[yellow]Multiple matches found. Please be more specific.[/yellow]") + return console.export_text(styles=True) diff --git a/trushell/config/builtin_commands.md b/trushell/config/builtin_commands.md index f37b6f8..9e325d7 100644 --- a/trushell/config/builtin_commands.md +++ b/trushell/config/builtin_commands.md @@ -15,4 +15,5 @@ {cmd: alarm}; "run_alarm()"; [trushell/commands/chronoterm.py]; {cmd: sw}; "run_sw()"; [trushell/commands/chronoterm.py]; {cmd: csv-view}; "run_csv_view()"; [trushell/commands/data.py]; +{cmd: j}; "run_jump()"; [trushell/commands/nav.py];